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 screen with Live TV, Movies, Series and Guide tiles, plus My List, Recently Watched, Refresh Playlist and App Settings +
+ Home: everything one press away, with the playlist's expiry and last update at the bottom. +

+ + + + + + + + + + + + + + +
+ Live TV: channel list with numbers and logos beside a live preview and now/next programme info +

Live TV
Channel list with a live preview and what's on now and next.

+
+ TV guide grid with channels down the side, a timeline across the top and a now marker +

TV Guide
A full EPG grid, with catch-up on supported channels.

+
+ Series details page with poster, rating, plot, cast, season tabs and episode cards +

Series
Details, seasons and episodes at a glance.

+
+ Player showing an episode with progress bar, Pause, Audio & Subtitles, Episodes, My List and Next Episode buttons +

Player
Audio and subtitle tracks, an episodes panel and Next Episode.

+
+ Search screen with an on-screen keyboard and results grouped into Live TV, Movies and Series +

Search
One search across channels, movies and series, typed with the remote.

+
+ Add a Playlist screen with Xtream Codes and M3U options and a sign-in form +

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 ` + + + + +${shapes(r, w, h, accent)} + + +${text} +${escapeXml(String(genre).split(",")[0].toUpperCase())} +${escapeXml(year)} +`; +} + +/** 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 ` + +${shapes(r, w, h, accent)} +${escapeXml(title.toUpperCase().slice(0, 18))} +`; +} + +/** 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 ` + + + +${escapeXml(initials)} +${label.map((line, i) => `${escapeXml(line)}`).join("")} +`; +} 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 }) =>

history of {profileId} on {source.name}

, })); +vi.mock("./screens/SearchScreen.js", () => ({ + SearchScreen: ({ onOpenSeries, onBack }: { onOpenSeries: (id: string) => void; onBack: () => void }) => ( +
+

search screen

+ + +
+ ), +})); +vi.mock("./screens/SeriesScreen.js", () => ({ + SeriesScreen: ({ initialSelectedId, onCloseDetail, onBack }: { initialSelectedId?: string; onCloseDetail?: () => void; onBack: () => void }) => ( +
+

series detail {initialSelectedId ?? "none"}

+ +
+ ), +})); 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> { + const stored = new Map(); + const add = (records: CatalogRecord[]) => { + for (const record of records) stored.set(record.id, { signature: signature(record), name: record.name }); + }; + if (categoryId !== undefined) { + add(await readCategoryRecords(catalogDb, kind, sourceId, categoryId)); + return stored; + } + for (let lastKey: string | null = null; ; ) { + const records = await readRecordsAfter(catalogDb, kind, sourceId, lastKey, READ_CHUNK); + if (records.length === 0) return stored; + add(records); + lastKey = records[records.length - 1].id; + } +} + +function searchRowId(kind: CatalogKind, recordId: string): string { + return `${kind}|${recordId}`; +} + +export interface WriteCatalogOptions extends Pick { + /** Only this category's stored rows are compared (and can be deleted). */ + categoryId?: string; + /** An empty list is a real answer (clears the table) rather than a suspect response. */ + allowEmpty?: boolean; +} + +/** + * Brings a source's stored catalog in line with `items` (see the module + * doc comment) and records the sync. Shared with live-sync-core.ts, which + * writes an M3U playlist's movies the same way. + */ +export async function writeCatalog( + catalogDb: CatalogDb, + kind: CatalogKind, + sourceId: string, + items: CatalogItem[], + options: WriteCatalogOptions = {}, +): Promise { + const { categoryId, allowEmpty = false, onProgress, batchSize = DEFAULT_CATALOG_BATCH_SIZE, yieldBetweenBatches } = options; + const key = catalogSyncMetaKey(sourceId, kind); + const previous = await getSyncMeta(catalogDb, key); + if (categoryId !== undefined && !previous) throw new Error("This catalog hasn't been downloaded yet."); + + const stored = await readStored(catalogDb, kind, sourceId, categoryId); + if (items.length === 0 && stored.size > 0 && !allowEmpty) throw new CatalogEmptyError(); + + const generation = previous?.generation ?? 1; + // Tagged under older Kids rules: rewrite every row so the tag index matches the bundled rules. + const retagAll = categoryId === undefined && (previous?.rulesVersion ?? 0) < KIDS_RULES_VERSION; + // Keep the search index current only once it's building this generation — before that, the indexer's own pass covers everything. + const searchMeta = await getSearchIndexMeta(catalogDb, searchIndexMetaKey(sourceId, kind)); + const maintainSearch = searchMeta !== undefined && searchMeta.generation === generation; + + const toRecord = (item: CatalogItem): CatalogRecord => + kind === "vod" ? channelToRecord(sourceId, generation, item as Channel) : seriesToRecord(sourceId, generation, item as ReturnType); + + let written = 0; + for (let offset = 0; offset < items.length; offset += batchSize) { + const put: CatalogRecord[] = []; + const searchPut: SearchTokenRow[] = []; + for (const item of items.slice(offset, offset + batchSize)) { + const record = toRecord(item); + const existing = stored.get(record.id); + stored.delete(record.id); // whatever is left at the end is gone from the provider + if (existing && !retagAll && existing.signature === signature(record)) continue; + put.push(record); + if (maintainSearch && existing?.name !== record.name) { + searchPut.push({ + id: searchRowId(kind, record.id), + sourceId, + kind, + recordId: record.id, + generation, + keys: indexTokens(record.name).map((word) => searchKey(sourceId, kind, word)), + }); + } + } + await writeCatalogChanges(catalogDb, kind, { put, delete: [], searchPut }); + written += put.length; + onProgress?.(Math.min(offset + batchSize, items.length)); + if (yieldBetweenBatches) await yieldBetweenBatches(); + } + + const gone = [...stored.keys()]; + for (let offset = 0; offset < gone.length; offset += batchSize) { + const ids = gone.slice(offset, offset + batchSize); + await writeCatalogChanges(catalogDb, kind, { put: [], delete: ids, searchDelete: maintainSearch ? ids.map((id) => searchRowId(kind, id)) : [] }); + if (yieldBetweenBatches) await yieldBetweenBatches(); + } + + const recordCount = categoryId === undefined ? items.length : await countRecords(catalogDb, kind, { sourceId }); + await putSyncMeta( + catalogDb, + categoryId === undefined || !previous + ? { key, lastSyncedAt: Date.now(), recordCount, generation, rulesVersion: KIDS_RULES_VERSION } + : { ...previous, recordCount }, + ); + return { recordCount, written, deleted: gone.length }; +} + +export async function runCatalogSync(request: CatalogSyncRequest, options: SyncJobOptions): Promise { + let catalogDb: CatalogDb; + try { + catalogDb = await openCatalogDb(); + } catch (err) { + throw new SyncStorageUnavailableError(err); + } + const items = await fetchCatalog(request, options.fetchImpl); + return writeCatalog(catalogDb, request.kind, request.source.id, items, { ...options, categoryId: request.categoryId }); +} diff --git a/src/catalog-sync.test.ts b/src/catalog-sync.test.ts index 18b6903..836fb97 100644 --- a/src/catalog-sync.test.ts +++ b/src/catalog-sync.test.ts @@ -1,20 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { __resetSyncWorkerClientForTests } from "./workers/sync-worker-client.js"; +import { updateSettings } from "./settings-store.js"; import { getCachedContent, setCachedContent } from "./content-cache.js"; import type { XtreamCredentials } from "@core"; import { __resetRequestDedupeCacheForTests } from "./core/xtream/xtream-client.js"; import { __clearCatalogDbForTests, __resetCatalogDbForTests, getSyncMeta, openCatalogDb, queryPage } from "./core/storage/catalog-db.js"; -const syncCatalogMock = vi.fn(); - -// Same rationale as content-loader.test.ts: the worker protocol itself has -// its own dedicated coverage in catalog-worker-client.test.ts, so here only -// catalog-sync.ts's own responsibility (auth-first, batching writes, -// generation swap, due-check gating) is under test. -vi.mock("./workers/catalog-worker-client.js", () => ({ - createCatalogWorkerClient: () => ({ fetchCatalog: vi.fn(), syncCatalog: syncCatalogMock, terminate: vi.fn() }), -})); - -const { isCatalogSyncDue, hasCompletedSync, syncCatalog, __resetCatalogSyncForTests } = await import("./catalog-sync.js"); +const { isCatalogSyncDue, hasCompletedSync, syncCatalog, refreshCatalogCategory, getCategoryRefreshedAt, __resetCatalogSyncForTests } = await import("./catalog-sync.js"); const xtreamSource: XtreamCredentials = { kind: "xtream", @@ -30,6 +22,7 @@ function jsonResponse(body: unknown): Response { } const AUTH_OK = jsonResponse({ user_info: { auth: 1, status: "Active", exp_date: null }, server_info: {} }); +const vod = (id: number, name: string, categoryId = "action") => ({ stream_id: id, name, category_id: categoryId, container_extension: "mp4" }); describe("catalog-sync", () => { let fetchMock: ReturnType; @@ -38,9 +31,11 @@ describe("catalog-sync", () => { __resetRequestDedupeCacheForTests(); __resetCatalogSyncForTests(); __resetCatalogDbForTests(); + // No Worker in the test environment: the job runs on the main-thread fallback, same code as in the worker. + __resetSyncWorkerClientForTests({ workerAvailable: false }); + localStorage.clear(); fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); - syncCatalogMock.mockReset(); // Clear any records/meta left over from a previous test in this file // against the shared fake-indexeddb instance. await __clearCatalogDbForTests(); @@ -57,10 +52,7 @@ describe("catalog-sync", () => { it("is not due right after a completed sync", async () => { fetchMock.mockResolvedValueOnce(AUTH_OK); - syncCatalogMock.mockImplementation(async (_req, onBatch) => { - onBatch([{ id: "1", name: "Movie", groupTitle: "action", streamUrl: "http://x/1", kind: "movie" }]); - return { total: 1 }; - }); + fetchMock.mockResolvedValueOnce(jsonResponse([vod(1, "Movie")])); await syncCatalog(xtreamSource, "vod"); @@ -68,6 +60,47 @@ describe("catalog-sync", () => { }); }); + describe("catalog refresh interval", () => { + it("follows the Movies & Series Sync Interval setting", async () => { + fetchMock.mockResolvedValueOnce(AUTH_OK).mockResolvedValueOnce(jsonResponse([vod(1, "Movie")])); + await syncCatalog(xtreamSource, "vod"); + const later = Date.now() + 30 * 60 * 60 * 1000; // 30h on + vi.spyOn(Date, "now").mockReturnValue(later); + try { + await expect(isCatalogSyncDue("source-1", "vod")).resolves.toBe(false); // default 48h + updateSettings({ catalogRefreshHours: 24 }); + await expect(isCatalogSyncDue("source-1", "vod")).resolves.toBe(true); + } finally { + vi.restoreAllMocks(); + } + }); + }); + + describe("refreshCatalogCategory", () => { + it("updates one category only, leaving the rest of the catalog and its sync time alone", async () => { + fetchMock.mockResolvedValueOnce(AUTH_OK).mockResolvedValueOnce(jsonResponse([vod(1, "Action One"), vod(2, "Action Two"), vod(3, "Comedy", "comedy")])); + await syncCatalog(xtreamSource, "vod"); + const catalogDb = await openCatalogDb(); + const before = await getSyncMeta(catalogDb, "vod:source-1"); + + __resetRequestDedupeCacheForTests(); + fetchMock.mockResolvedValueOnce(AUTH_OK).mockResolvedValueOnce(jsonResponse([vod(2, "Action Two"), vod(4, "Action Four")])); + await expect(refreshCatalogCategory(xtreamSource, "vod", "action")).resolves.toMatchObject({ recordCount: 3, written: 1, deleted: 1 }); + + expect(String(fetchMock.mock.calls[3][0])).toContain("category_id%3Daction"); + const page = await queryPage(catalogDb, "vod", { sourceId: "source-1", offset: 0, limit: 10 }); + expect(page.map((r) => r.name)).toEqual(["Action Four", "Comedy", "Action Two"]); + expect(await getSyncMeta(catalogDb, "vod:source-1")).toEqual({ ...before, recordCount: 3 }); + expect(getCategoryRefreshedAt("source-1", "vod", "action")).toBeTypeOf("number"); + }); + + it("refuses a catalog that has never been fully synced", async () => { + fetchMock.mockResolvedValueOnce(AUTH_OK).mockResolvedValueOnce(jsonResponse([vod(1, "Alpha")])); + await expect(refreshCatalogCategory(xtreamSource, "vod", "action")).rejects.toThrow(); + expect(await queryPage(await openCatalogDb(), "vod", { sourceId: "source-1", offset: 0, limit: 10 })).toEqual([]); + }); + }); + describe("hasCompletedSync", () => { it("is false before any sync", async () => { await expect(hasCompletedSync("source-1", "vod")).resolves.toBe(false); @@ -75,10 +108,7 @@ describe("catalog-sync", () => { it("is true after a sync completes", async () => { fetchMock.mockResolvedValueOnce(AUTH_OK); - syncCatalogMock.mockImplementation(async (_req, onBatch) => { - onBatch([{ id: "1", name: "Movie", groupTitle: "action", streamUrl: "http://x/1", kind: "movie" }]); - return { total: 1 }; - }); + fetchMock.mockResolvedValueOnce(jsonResponse([vod(1, "Movie")])); await syncCatalog(xtreamSource, "vod"); @@ -91,36 +121,25 @@ describe("catalog-sync", () => { fetchMock.mockResolvedValueOnce(jsonResponse({ user_info: { auth: 0 }, server_info: {} })); await expect(syncCatalog(xtreamSource, "vod")).rejects.toThrow(); - expect(syncCatalogMock).not.toHaveBeenCalled(); + expect(fetchMock).toHaveBeenCalledTimes(1); // the catalog was never requested }); - it("writes every batch into the local table under a new generation", async () => { - fetchMock.mockResolvedValueOnce(AUTH_OK); - syncCatalogMock.mockImplementation(async (_req, onBatch) => { - onBatch([ - { id: "1", name: "Alpha", groupTitle: "action", streamUrl: "http://x/1", kind: "movie" }, - { id: "2", name: "Beta", groupTitle: "comedy", streamUrl: "http://x/2", kind: "movie" }, - ]); - onBatch([{ id: "3", name: "Gamma", groupTitle: "action", streamUrl: "http://x/3", kind: "movie" }]); - return { total: 3 }; - }); + it("writes the downloaded catalog into the local table", async () => { + fetchMock.mockResolvedValueOnce(AUTH_OK).mockResolvedValueOnce(jsonResponse([vod(1, "Alpha"), vod(2, "Beta", "comedy"), vod(3, "Gamma")])); - await syncCatalog(xtreamSource, "vod"); + await expect(syncCatalog(xtreamSource, "vod")).resolves.toMatchObject({ recordCount: 3, written: 3, deleted: 0 }); const catalogDb = await openCatalogDb(); const page = await queryPage(catalogDb, "vod", { sourceId: "source-1", offset: 0, limit: 10 }); expect(page.map((r) => r.name).sort()).toEqual(["Alpha", "Beta", "Gamma"]); + expect(String(fetchMock.mock.calls[1][0])).toContain("get_vod_streams"); }); it("frees the fallback path's cached VOD blobs once the table holds the catalog", async () => { setCachedContent("vod:source-1", [{ id: "old" }], "catalog"); setCachedContent("vod:source-1:cat:7", [{ id: "old" }], "catalog"); setCachedContent("vod-categories:source-1", [{ id: "7" }], "category"); - fetchMock.mockResolvedValueOnce(AUTH_OK); - syncCatalogMock.mockImplementation(async (_req, onBatch) => { - onBatch([{ id: "1", name: "Alpha", streamUrl: "http://x/1", kind: "movie" }]); - return { total: 1 }; - }); + fetchMock.mockResolvedValueOnce(AUTH_OK).mockResolvedValueOnce(jsonResponse([vod(1, "Alpha")])); await syncCatalog(xtreamSource, "vod"); @@ -129,63 +148,46 @@ describe("catalog-sync", () => { expect(getCachedContent("vod-categories:source-1")).toEqual([{ id: "7" }]); // categories are still needed }); - it("records sync_meta with the total count and a bumped generation", async () => { - fetchMock.mockResolvedValueOnce(AUTH_OK); - syncCatalogMock.mockImplementation(async (_req, onBatch) => { - onBatch([{ id: "1", name: "Alpha", streamUrl: "http://x/1", kind: "movie" }]); - return { total: 1 }; - }); - + it("a second sync keeps the generation, writes only what changed and removes what's gone", async () => { + fetchMock.mockResolvedValueOnce(AUTH_OK).mockResolvedValueOnce(jsonResponse([vod(1, "Old Only"), vod(2, "Kept")])); await syncCatalog(xtreamSource, "vod"); + __resetRequestDedupeCacheForTests(); // a real second sync is hours later — sign in again + fetchMock.mockResolvedValueOnce(AUTH_OK).mockResolvedValueOnce(jsonResponse([vod(2, "Kept"), vod(3, "New Only")])); + await expect(syncCatalog(xtreamSource, "vod")).resolves.toMatchObject({ recordCount: 2, written: 1, deleted: 1 }); + const catalogDb = await openCatalogDb(); - const meta = await getSyncMeta(catalogDb, "vod:source-1"); - expect(meta).toMatchObject({ recordCount: 1, generation: 1 }); + const page = await queryPage(catalogDb, "vod", { sourceId: "source-1", offset: 0, limit: 10 }); + expect(page.map((r) => r.name)).toEqual(["New Only", "Kept"]); + expect(await getSyncMeta(catalogDb, "vod:source-1")).toMatchObject({ recordCount: 2, generation: 1 }); }); - it("a second sync bumps the generation and removes the previous generation's records that are gone from the new fetch", async () => { - fetchMock.mockResolvedValueOnce(AUTH_OK); - syncCatalogMock.mockImplementationOnce(async (_req, onBatch) => { - onBatch([{ id: "1", name: "Old Only", streamUrl: "http://x/1", kind: "movie" }]); - return { total: 1 }; - }); + it("keeps the stored catalog when a refresh comes back empty", async () => { + fetchMock.mockResolvedValueOnce(AUTH_OK).mockResolvedValueOnce(jsonResponse([vod(1, "Alpha")])); await syncCatalog(xtreamSource, "vod"); - fetchMock.mockResolvedValueOnce(AUTH_OK); - syncCatalogMock.mockImplementationOnce(async (_req, onBatch) => { - onBatch([{ id: "2", name: "New Only", streamUrl: "http://x/2", kind: "movie" }]); - return { total: 1 }; - }); - await syncCatalog(xtreamSource, "vod"); + __resetRequestDedupeCacheForTests(); + fetchMock.mockResolvedValueOnce(AUTH_OK).mockResolvedValueOnce(jsonResponse([])); + await expect(syncCatalog(xtreamSource, "vod")).rejects.toMatchObject({ name: "CatalogEmptyError" }); - const catalogDb = await openCatalogDb(); - const page = await queryPage(catalogDb, "vod", { sourceId: "source-1", offset: 0, limit: 10 }); - expect(page.map((r) => r.name)).toEqual(["New Only"]); - - const meta = await getSyncMeta(catalogDb, "vod:source-1"); - expect(meta?.generation).toBe(2); + const page = await queryPage(await openCatalogDb(), "vod", { sourceId: "source-1", offset: 0, limit: 10 }); + expect(page.map((r) => r.name)).toEqual(["Alpha"]); }); it("does nothing for a non-Xtream source", async () => { const m3uSource = { kind: "m3u-file" as const, id: "m3u-1", name: "M3U", content: "" }; await syncCatalog(m3uSource, "vod"); expect(fetchMock).not.toHaveBeenCalled(); - expect(syncCatalogMock).not.toHaveBeenCalled(); }); it("dedupes overlapping calls for the same source+kind into one in-flight sync", async () => { - fetchMock.mockResolvedValueOnce(AUTH_OK); - syncCatalogMock.mockImplementation(async (_req, onBatch) => { - onBatch([{ id: "1", name: "Alpha", streamUrl: "http://x/1", kind: "movie" }]); - return { total: 1 }; - }); + fetchMock.mockResolvedValueOnce(AUTH_OK).mockResolvedValueOnce(jsonResponse([vod(1, "Alpha")])); const first = syncCatalog(xtreamSource, "vod"); const second = syncCatalog(xtreamSource, "vod"); await Promise.all([first, second]); - expect(fetchMock).toHaveBeenCalledTimes(1); // only one authenticate() call - expect(syncCatalogMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledTimes(2); // one authenticate() call, one download }); }); }); diff --git a/src/catalog-sync.ts b/src/catalog-sync.ts index 7181baf..204a36b 100644 --- a/src/catalog-sync.ts +++ b/src/catalog-sync.ts @@ -1,47 +1,40 @@ -import { KIDS_RULES_VERSION, XtreamClient, type Channel, type PlaylistSource, type SeriesInfo } from "@core"; -import { - catalogSyncMetaKey, - deleteSourceCatalog, - deleteStaleGeneration, - getSyncMeta, - openCatalogDb, - putRecordsBatch, - putSyncMeta, - type CatalogKind, -} from "./core/storage/catalog-db.js"; +import { KIDS_RULES_VERSION, XtreamClient, type PlaylistSource } from "@core"; +import { deleteSourceCatalog, getSyncMeta, openCatalogDb, catalogSyncMetaKey, type CatalogKind } from "./core/storage/catalog-db.js"; import { bumpCacheVersion } from "./cache-invalidation-store.js"; -import { channelToRecord, seriesToRecord } from "./catalog-records.js"; +import type { CatalogSyncResult } from "./catalog-sync-core.js"; import { clearCachedContentMatching } from "./content-cache.js"; import { proxyFetch } from "./proxy-fetch.js"; -import { createCatalogWorkerClient } from "./workers/catalog-worker-client.js"; +import { loadSettings } from "./settings-store.js"; +import { runSyncJobOffMainThread } from "./workers/sync-worker-client.js"; export type { CatalogKind }; /** - * Background full-catalog sync: fetches the *entire* VOD/series catalog for - * a source via the catalog worker's streaming syncCatalog (see - * catalog-worker-client.ts/catalog-fetch-worker.ts), writing it into the - * local IndexedDB table (catalog-db.ts) in batches as it arrives, instead of - * content-cache.ts's old approach of holding one big array in memory/ - * sessionStorage/IndexedDB-as-a-blob. + * Background full-catalog sync for Xtream movies and series. The download, + * parse and writes run in the sync worker (catalog-sync-core.ts, which + * only writes what changed since the last sync); this is the main-thread + * side — checking the login first, deciding when a sync is due, deduping, + * and telling mounted screens (use-catalog-page.ts) when the table changed. * - * This is what lets VodScreen/SeriesScreen (see use-catalog-page.ts) read - * paginated, indexed slices of a source's catalog without ever loading the - * whole thing themselves — the full fetch still happens exactly once - * per-source per sync interval, just here, off the screen's critical path. - * - * Only Xtream sources are synced here — an M3U playlist's movies arrive in - * the same file as its live channels, so live-sync-core.ts writes them into - * this same table from that one download. When to sync is decided by - * sync/sync-manager.ts, never by screens. + * VodScreen/SeriesScreen read paginated, indexed slices of the table and + * never load the whole catalog themselves. An M3U playlist's movies arrive + * in the same file as its live channels, so live-sync-core.ts writes them. + * When to sync is decided by sync/sync-manager.ts, never by screens — except + * refreshCatalogCategory, a screen's "Refresh" on the category it shows. */ -const SYNC_STALE_AFTER_MS = 24 * 60 * 60 * 1000; // once/day, per the request's "periodically once every day" ask — coarser than content-cache.ts's hours-scale CacheKind thresholds, since this is the full-table resync, not the screen-level blob cache. +const HOUR_MS = 60 * 60 * 1000; + +/** A catalog is refetched once it's older than this — the "Movies & Series Sync Interval" setting. */ +export function catalogStaleAfterMs(): number { + return loadSettings().catalogRefreshHours * HOUR_MS; +} -const catalogWorker = createCatalogWorkerClient(); +/** One in-flight sync per source+kind (and per refreshed category), so overlapping requests never write the same rows twice at once. */ +const inFlightSyncs = new Map>(); -/** One in-flight sync per source+kind, so overlapping requests never write the same table twice at once. */ -const inFlightSyncs = new Map>(); +/** When each category was last refreshed on its own this session — for a screen's "Updated …" line. */ +const categoryRefreshedAt = new Map(); const syncMetaKey = catalogSyncMetaKey; @@ -50,10 +43,6 @@ export function catalogVersionKey(sourceId: string, kind: CatalogKind): string { return `local-catalog:${sourceId}:${kind}`; } -function actionForKind(kind: CatalogKind): "get_vod_streams" | "get_series" { - return kind === "vod" ? "get_vod_streams" : "get_series"; -} - export async function isCatalogSyncDue(sourceId: string, kind: CatalogKind): Promise { try { const catalogDb = await openCatalogDb(); @@ -61,7 +50,7 @@ export async function isCatalogSyncDue(sourceId: string, kind: CatalogKind): Pro if (!meta) return true; // Tagged under older Kids rules: re-sync so the tag index matches the bundled rules. if ((meta.rulesVersion ?? 0) < KIDS_RULES_VERSION) return true; - return Date.now() - meta.lastSyncedAt > SYNC_STALE_AFTER_MS; + return Date.now() - meta.lastSyncedAt > catalogStaleAfterMs(); } catch { // IndexedDB unavailable — nothing to sync into, so there's no sync to be "due". return false; @@ -79,63 +68,49 @@ export async function hasCompletedSync(sourceId: string, kind: CatalogKind): Pro } } +/** When the stored catalog was last fully synced, or undefined before its first sync. */ +export async function getCatalogSyncedAt(sourceId: string, kind: CatalogKind): Promise { + try { + return (await getSyncMeta(await openCatalogDb(), syncMetaKey(sourceId, kind)))?.lastSyncedAt; + } catch { + return undefined; + } +} + +/** When a category was last refreshed on its own this session (see refreshCatalogCategory). */ +export function getCategoryRefreshedAt(sourceId: string, kind: CatalogKind, categoryId: string): number | undefined { + return categoryRefreshedAt.get(`${sourceId}:${kind}:${categoryId}`); +} + +/** Lets mounted screens re-read the table (see use-catalog-page.ts) and frees the per-category lists fetched before it existed. */ +function afterCatalogChange(sourceId: string, kind: CatalogKind): void { + bumpCacheVersion(catalogVersionKey(sourceId, kind)); + const blobKey = kind === "vod" ? `vod:${sourceId}` : `series-list:${sourceId}`; + clearCachedContentMatching((key) => key === blobKey || key.startsWith(`${blobKey}:cat:`)); +} + /** - * Runs one full sync for a source+kind: authenticates on the main thread - * first (same reasoning as content-loader.ts's loadChannelsByKind — a bad - * login should surface as XtreamAuthError rather than a generic worker - * failure), then streams the catalog worker's batches straight into - * IndexedDB tagged with a new generation, and only once every batch has - * landed does it flip sync_meta to that generation and delete the old one — - * see catalog-db.ts's deleteStaleGeneration doc comment. A refresh is a - * full replace (dropped titles disappear); during it readers may briefly - * see old and new rows side by side, but never a title missing, and a sync - * that dies partway leaves the previous generation fully readable. + * Runs one full sync for a source+kind: checks the login on the main + * thread first (cheap, deduped by XtreamClient, and a bad password rejects + * with XtreamAuthError rather than a generic worker failure), then hands the + * download and writes to the sync worker. */ -async function runSync(source: PlaylistSource, kind: CatalogKind, onProgress?: (written: number) => void): Promise { - if (source.kind !== "xtream") return; - - const client = new XtreamClient(source, proxyFetch); - await client.authenticate(); - - const catalogDb = await openCatalogDb(); - const key = syncMetaKey(source.id, kind); - const previousMeta = await getSyncMeta(catalogDb, key); - const generation = (previousMeta?.generation ?? 0) + 1; - - let recordCount = 0; - const writeQueue: Promise[] = []; - - const { total } = await catalogWorker.syncCatalog({ credentials: source, action: actionForKind(kind) }, (batch) => { - const records = - kind === "vod" - ? (batch as Channel[]).map((item) => channelToRecord(source.id, generation, item)) - : (batch as Array>).map((item) => seriesToRecord(source.id, generation, item)); - recordCount += records.length; - onProgress?.(recordCount); - // Batches are written as they arrive rather than awaited serially here — - // queued and drained together below — so a slow IndexedDB write never - // backs up the worker's postMessage stream. - writeQueue.push(putRecordsBatch(catalogDb, kind, records)); - }); - - await Promise.all(writeQueue); - await putSyncMeta(catalogDb, { key, lastSyncedAt: Date.now(), recordCount: total || recordCount, generation, rulesVersion: KIDS_RULES_VERSION }); - if (previousMeta) await deleteStaleGeneration(catalogDb, kind, source.id, generation); - // Lets an already-mounted VodScreen/SeriesScreen (via use-catalog-page.ts) - // notice this completed sync and switch from its fallback direct-fetch - // path to the local table, without needing to remount — same pattern - // useCachedContent uses for its own cache keys. - bumpCacheVersion(catalogVersionKey(source.id, kind)); - // The per-category lists fetched while the table was being built (and any - // full-list blob from before the table existed) are superseded now — free them. - const blobKey = kind === "vod" ? `vod:${source.id}` : `series-list:${source.id}`; - clearCachedContentMatching((key) => key === blobKey || key.startsWith(`${blobKey}:cat:`)); +async function runSync(source: PlaylistSource, kind: CatalogKind, onProgress?: (processed: number) => void): Promise { + if (source.kind !== "xtream") return undefined; + await new XtreamClient(source, proxyFetch).authenticate(); + const result = await runSyncJobOffMainThread("catalog", { source, kind }, onProgress); + afterCatalogChange(source.id, kind); + return result; } /** Fetches and stores the full catalog for one source+kind, deduping overlapping calls for the same source+kind (see inFlightSyncs' doc comment). Safe to call even when a sync isn't due — callers that only want to sync when due should check isCatalogSyncDue first. */ -export function syncCatalog(source: PlaylistSource, kind: CatalogKind, options: { onProgress?: (written: number) => void } = {}): Promise { +export function syncCatalog( + source: PlaylistSource, + kind: CatalogKind, + options: { onProgress?: (processed: number) => void } = {}, +): Promise { const key = `${source.id}:${kind}`; - const existing = inFlightSyncs.get(key); + const existing = inFlightSyncs.get(key) as Promise | undefined; if (existing) return existing; const promise = runSync(source, kind, options.onProgress).finally(() => inFlightSyncs.delete(key)); @@ -143,6 +118,28 @@ export function syncCatalog(source: PlaylistSource, kind: CatalogKind, options: return promise; } +/** + * Re-downloads one category of an already-synced Xtream catalog (a + * screen's "Refresh") and writes only what changed in it. Waits for a + * full sync of the same catalog that's already running, then goes ahead. + */ +export function refreshCatalogCategory(source: PlaylistSource, kind: CatalogKind, categoryId: string): Promise { + const key = `${source.id}:${kind}:${categoryId}`; + const existing = inFlightSyncs.get(key) as Promise | undefined; + if (existing) return existing; + + const promise = (async () => { + await inFlightSyncs.get(`${source.id}:${kind}`)?.catch(() => undefined); + if (source.kind === "xtream") await new XtreamClient(source, proxyFetch).authenticate(); + const result = await runSyncJobOffMainThread("catalog", { source, kind, categoryId }); + categoryRefreshedAt.set(key, Date.now()); + afterCatalogChange(source.id, kind); + return result; + })().finally(() => inFlightSyncs.delete(key)); + inFlightSyncs.set(key, promise); + return promise; +} + /** Drops a source's movie and series tables and their sync records (reset / removal — see sync/purge.ts). */ export async function clearCatalogForSource(sourceId: string): Promise { try { @@ -158,4 +155,5 @@ export async function clearCatalogForSource(sourceId: string): Promise { /** Test-only escape hatch: clears in-flight sync tracking between tests, mirroring xtream-client.ts's __resetRequestDedupeCacheForTests. */ export function __resetCatalogSyncForTests(): void { inFlightSyncs.clear(); + categoryRefreshedAt.clear(); } diff --git a/src/category-usage-store.test.ts b/src/category-usage-store.test.ts new file mode 100644 index 0000000..09efb31 --- /dev/null +++ b/src/category-usage-store.test.ts @@ -0,0 +1,53 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + frequentCategoryIds, + recordCategoryUse, + removeProfileCategoryUsage, + removeSourceCategoryUsage, + splitByFrequency, +} from "./category-usage-store.js"; + +describe("category usage", () => { + beforeEach(() => { + localStorage.clear(); + vi.useRealTimers(); + }); + + it("ranks by how often a category is opened, the more recent first on a tie, capped at the limit", () => { + vi.useFakeTimers(); + for (const [id, times] of [["action", 3], ["drama", 1], ["comedy", 3], ["kids", 2]] as const) { + for (let i = 0; i < times; i++) { + vi.advanceTimersByTime(1000); + recordCategoryUse("p1", "s1", "vod", id); + } + } + expect(frequentCategoryIds("p1", "s1", "vod")).toEqual(["comedy", "action", "kids", "drama"]); + expect(frequentCategoryIds("p1", "s1", "vod", 2)).toEqual(["comedy", "action"]); + }); + + it("keeps profiles, playlists and Movies/Series apart", () => { + recordCategoryUse("p1", "s1", "vod", "action"); + expect(frequentCategoryIds("p2", "s1", "vod")).toEqual([]); + expect(frequentCategoryIds("p1", "s2", "vod")).toEqual([]); + expect(frequentCategoryIds("p1", "s1", "series")).toEqual([]); + }); + + it("forgets a removed playlist's or profile's usage only", () => { + recordCategoryUse("p1", "s1", "vod", "a"); + recordCategoryUse("p1", "s2", "vod", "b"); + recordCategoryUse("p2", "s2", "series", "c"); + + removeSourceCategoryUsage("s1"); + expect(frequentCategoryIds("p1", "s1", "vod")).toEqual([]); + expect(frequentCategoryIds("p1", "s2", "vod")).toEqual(["b"]); + + removeProfileCategoryUsage("p1"); + expect(frequentCategoryIds("p1", "s2", "vod")).toEqual([]); + expect(frequentCategoryIds("p2", "s2", "series")).toEqual(["c"]); + }); + + it("splits categories into the frequent ones (usage order) and the rest (original order), skipping ids that no longer exist", () => { + const categories = [{ id: "a" }, { id: "b" }, { id: "c" }, { id: "d" }]; + expect(splitByFrequency(categories, ["c", "gone", "a"])).toEqual({ frequent: [{ id: "c" }, { id: "a" }], rest: [{ id: "b" }, { id: "d" }] }); + }); +}); diff --git a/src/category-usage-store.ts b/src/category-usage-store.ts new file mode 100644 index 0000000..51f2dd5 --- /dev/null +++ b/src/category-usage-store.ts @@ -0,0 +1,90 @@ +/** + * How often each profile opens each Movies/Series category, per playlist, + * persisted in localStorage (`iptv.category-usage.v1`) — the category + * rails show the most-used ones at the top under "Frequently used". + * + * Keyed `${profileId}|${sourceId}|${kind}` so a Kids profile's habits never + * reorder a parent's rail, and removing a profile or playlist can drop its + * entries (see profile-store.ts). + */ + +const CATEGORY_USAGE_KEY = "iptv.category-usage.v1"; + +/** How many categories the "Frequently used" section shows at most. */ +export const FREQUENT_CATEGORY_LIMIT = 5; + +export type CategoryUsageKind = "vod" | "series"; + +interface CategoryUsage { + count: number; + /** Epoch ms — breaks ties, so of two equally used categories the more recent one ranks first. */ + lastUsedAt: number; +} + +type UsageByScope = Record>; + +const scopeKey = (profileId: string, sourceId: string, kind: CategoryUsageKind) => `${profileId}|${sourceId}|${kind}`; + +function readUsage(): UsageByScope { + try { + const raw = localStorage.getItem(CATEGORY_USAGE_KEY); + return raw ? (JSON.parse(raw) as UsageByScope) : {}; + } catch { + return {}; + } +} + +function writeUsage(usage: UsageByScope): void { + try { + localStorage.setItem(CATEGORY_USAGE_KEY, JSON.stringify(usage)); + } catch { + // Storage full or unavailable — the rail just keeps its provider order. + } +} + +export function recordCategoryUse(profileId: string, sourceId: string, kind: CategoryUsageKind, categoryId: string): void { + const usage = readUsage(); + const key = scopeKey(profileId, sourceId, kind); + const scope = (usage[key] ??= {}); + scope[categoryId] = { count: (scope[categoryId]?.count ?? 0) + 1, lastUsedAt: Date.now() }; + writeUsage(usage); +} + +/** Category ids, most used first, at most `limit`. May include categories that no longer exist — callers filter against the current list. */ +export function frequentCategoryIds(profileId: string, sourceId: string, kind: CategoryUsageKind, limit = FREQUENT_CATEGORY_LIMIT): string[] { + const scope = readUsage()[scopeKey(profileId, sourceId, kind)] ?? {}; + return Object.entries(scope) + .sort(([, a], [, b]) => b.count - a.count || b.lastUsedAt - a.lastUsedAt) + .slice(0, limit) + .map(([id]) => id); +} + +/** + * Splits `categories` into the frequently used ones (in usage order) and + * the rest (in their original order), so each appears exactly once. + */ +export function splitByFrequency(categories: T[], frequentIds: string[]): { frequent: T[]; rest: T[] } { + const byId = new Map(categories.map((c) => [c.id, c])); + const frequent = frequentIds.map((id) => byId.get(id)).filter((c): c is T => c !== undefined); + const promoted = new Set(frequent.map((c) => c.id)); + return { frequent, rest: categories.filter((c) => !promoted.has(c.id)) }; +} + +function removeScopesMatching(matches: (profileId: string, sourceId: string) => boolean): void { + const usage = readUsage(); + const kept = Object.fromEntries( + Object.entries(usage).filter(([key]) => { + const [profileId, sourceId] = key.split("|"); + return !matches(profileId, sourceId); + }), + ); + if (Object.keys(kept).length !== Object.keys(usage).length) writeUsage(kept); +} + +export function removeSourceCategoryUsage(sourceId: string): void { + removeScopesMatching((_profileId, id) => id === sourceId); +} + +export function removeProfileCategoryUsage(profileId: string): void { + removeScopesMatching((id) => id === profileId); +} diff --git a/src/content-loader.ts b/src/content-loader.ts index 8e168fb..08de818 100644 --- a/src/content-loader.ts +++ b/src/content-loader.ts @@ -9,6 +9,7 @@ import { type SeriesDetails, type SeriesEpisode, } from "@core"; +import { sortNewestFirst } from "./catalog-records.js"; import { getCatalogCategories } from "./catalog-store.js"; import { proxyDownloadFetch, proxyFetch } from "./proxy-fetch.js"; import { createCatalogWorkerClient } from "./workers/catalog-worker-client.js"; @@ -65,7 +66,7 @@ export async function loadChannelsByKind(source: PlaylistSource, kind: Channel[" await client.authenticate(); if (kind === "live") return client.getLiveChannels(categoryId); if (kind === "movie") { - return catalogWorker.fetchCatalog({ credentials: source, action: "get_vod_streams", categoryId }) as Promise; + return sortNewestFirst((await catalogWorker.fetchCatalog({ credentials: source, action: "get_vod_streams", categoryId })) as Channel[]); } return []; // series are fetched via loadSeriesList/loadSeriesDetails instead. } @@ -79,7 +80,7 @@ export async function loadSeriesList(source: PlaylistSource, categoryId?: string if (source.kind !== "xtream") return []; const client = new XtreamClient(source, proxyFetch); await client.authenticate(); - return catalogWorker.fetchCatalog({ credentials: source, action: "get_series", categoryId }) as ReturnType; + return sortNewestFirst((await catalogWorker.fetchCatalog({ credentials: source, action: "get_series", categoryId })) as Awaited>); } /** Series categories for the browse grid's category filter — M3U sources have no separate category API, so this is Xtream-only like loadSeriesList. */ diff --git a/src/content-policy.test.ts b/src/content-policy.test.ts index f8a07cd..99acc65 100644 --- a/src/content-policy.test.ts +++ b/src/content-policy.test.ts @@ -63,14 +63,14 @@ describe("catalog reads through a Kids filter", () => { const policy = createKidsPolicy({ profileId: "k", sourceId: SOURCE, parent: undefined, allowOtherCategories: false }); const filter = policy.catalogFilter("vod", vodCategories); const kids = await getCatalogPage(SOURCE, "vod", { categoryId: "1", offset: 0, limit: 10, filter }); - expect(kids.map((m) => m.name)).toEqual(["Frozen", "Moana"]); + expect(kids.map((m) => m.name)).toEqual(["Moana", "Frozen"]); // newest (highest id) first expect(await getCatalogCount(SOURCE, "vod", { categoryId: "1", filter })).toBe(2); expect(await getCatalogPage(SOURCE, "vod", { categoryId: "3", offset: 0, limit: 10, filter })).toEqual([]); // Search walks the whole playlist but still only yields allowed titles. const search = await getCatalogPage(SOURCE, "vod", { namePrefix: "c", offset: 0, limit: 10, filter }); expect(search.map((m) => m.name)).toEqual([]); // Offsets count allowed rows only. - expect((await getCatalogPage(SOURCE, "vod", { categoryId: "1", offset: 1, limit: 10, filter })).map((m) => m.name)).toEqual(["Moana"]); + expect((await getCatalogPage(SOURCE, "vod", { categoryId: "1", offset: 1, limit: 10, filter })).map((m) => m.name)).toEqual(["Frozen"]); }); it("serves Picked by Parent from force-included ids, and More for Kids from the tag index", async () => { diff --git a/src/core/index.ts b/src/core/index.ts index 1688a28..c62061b 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -8,4 +8,5 @@ export * from "./epg/epg-lookup.js"; export * from "./xtream/xtream-client.js"; export * from "./xtream/xtream-mappers.js"; export * from "./input/keymap.js"; +export * from "./text/clean-title.js"; export * from "./kids/index.js"; diff --git a/src/core/m3u/parse-m3u.ts b/src/core/m3u/parse-m3u.ts index 76fb9b3..406c28e 100644 --- a/src/core/m3u/parse-m3u.ts +++ b/src/core/m3u/parse-m3u.ts @@ -1,4 +1,5 @@ import type { Channel } from "../models/channel.js"; +import { cleanTitle } from "../text/clean-title.js"; const ATTR_RE = /([a-zA-Z0-9_-]+)="([^"]*)"/g; @@ -55,7 +56,7 @@ export function parseM3u(content: string): Channel[] { // Any non-comment, non-empty line following an #EXTINF is the stream URL. if (pendingExtinf) { const attrs = parseAttributes(pendingExtinf); - const name = attrs["tvg-name"] || parseDisplayName(pendingExtinf); + const name = cleanTitle(attrs["tvg-name"] || parseDisplayName(pendingExtinf)); const groupTitle = attrs["group-title"]; const parsedNumber = Number.parseInt(attrs["tvg-chno"] ?? "", 10); const channelNumber = Number.isInteger(parsedNumber) && parsedNumber > 0 ? parsedNumber : undefined; diff --git a/src/core/storage/catalog-db-upgrade.test.ts b/src/core/storage/catalog-db-upgrade.test.ts new file mode 100644 index 0000000..bf2d3fa --- /dev/null +++ b/src/core/storage/catalog-db-upgrade.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { catalogRecordId, getRecordsByIds, openCatalogDb, queryPage, type CatalogRecord } from "./catalog-db.js"; + +// Its own file so the hand-built v2 database below is the first thing this +// file's (fresh) fake-indexeddb sees — no connection from another test is +// left open to block the upgrade. + +function v2Record(streamId: string): CatalogRecord { + return { id: `source-1:${streamId}`, sourceId: "source-1", streamId, name: `Movie ${streamId}`, nameLower: `movie ${streamId}`, generation: 1 }; +} + +function createV2Database(records: CatalogRecord[]): Promise { + return new Promise((resolve, reject) => { + const open = indexedDB.open("iptv-catalog-v1", 2); + open.onupgradeneeded = () => { + for (const kind of ["vod", "series"]) { + const store = open.result.createObjectStore(kind, { keyPath: "id" }); + store.createIndex("by_source", "sourceId"); + store.createIndex("by_source_category", ["sourceId", "groupTitle"]); + store.createIndex("by_source_name", ["sourceId", "nameLower"]); + store.createIndex("by_tag_key", "tagKeys", { multiEntry: true }); + } + open.result.createObjectStore("sync_meta", { keyPath: "key" }); + const vod = open.transaction!.objectStore("vod"); + for (const record of records) vod.put(record); + }; + open.onsuccess = () => { + open.result.close(); + resolve(); + }; + open.onerror = () => reject(open.error); + }); +} + +describe("catalog-db upgrades", () => { + it("re-keys a v2 database's rows to padded ids in place, so they read newest first with no re-sync", async () => { + await createV2Database(["7", "12", "m3u-1"].map(v2Record)); + + const catalogDb = await openCatalogDb(); + + const all = await queryPage(catalogDb, "vod", { sourceId: "source-1", offset: 0, limit: 10 }); + expect(all.map((r) => r.id)).toEqual(["source-1:m3u-1", "source-1:000000000000012", "source-1:000000000000007"]); + const [favourite] = await getRecordsByIds(catalogDb, "vod", [catalogRecordId("source-1", "12")]); + expect(favourite?.name).toBe("Movie 12"); + + // v5: the search index's stores exist, empty — nothing was rewritten to create them. + expect([...catalogDb.db.objectStoreNames]).toEqual(expect.arrayContaining(["search_tokens", "search_index_meta"])); + expect(catalogDb.db.version).toBe(5); + }); +}); diff --git a/src/core/storage/catalog-db.test.ts b/src/core/storage/catalog-db.test.ts index 0930122..761534d 100644 --- a/src/core/storage/catalog-db.test.ts +++ b/src/core/storage/catalog-db.test.ts @@ -2,8 +2,8 @@ import { beforeEach, describe, expect, it } from "vitest"; import { __clearCatalogDbForTests, __resetCatalogDbForTests, + catalogRecordId, countRecords, - deleteStaleGeneration, getRecordsByIds, getSyncMeta, openCatalogDb, @@ -120,19 +120,6 @@ describe("catalog-db", () => { await expect(countRecords(catalogDb, "vod", { sourceId: "source-1" })).resolves.toBe(3); }); - it("deleteStaleGeneration removes only records older than the given generation", async () => { - const catalogDb = await openCatalogDb(); - await putRecordsBatch(catalogDb, "vod", [ - record({ streamId: "1", name: "Old", generation: 1 }), - record({ streamId: "2", name: "New", generation: 2 }), - ]); - - await deleteStaleGeneration(catalogDb, "vod", "source-1", 2); - - const page = await queryPage(catalogDb, "vod", { sourceId: "source-1", offset: 0, limit: 10 }); - expect(page.map((r) => r.name)).toEqual(["New"]); - }); - it("round-trips sync_meta", async () => { const catalogDb = await openCatalogDb(); await putSyncMeta(catalogDb, { key: "vod:source-1", lastSyncedAt: 123, recordCount: 2, generation: 1 }); @@ -150,3 +137,43 @@ describe("catalog-db", () => { await expect(getSyncMeta(catalogDb, "vod:never-synced")).resolves.toBeUndefined(); }); }); + +describe("newest-first order", () => { + beforeEach(async () => { + __resetCatalogDbForTests(); + await __clearCatalogDbForTests(); + }); + + function keyed(streamId: string, name: string, groupTitle?: string): CatalogRecord { + return { ...record({ streamId, name, groupTitle }), id: catalogRecordId("source-1", streamId) }; + } + + it("lists by descending numeric stream id, across ids of different lengths", async () => { + const catalogDb = await openCatalogDb(); + await putRecordsBatch(catalogDb, "vod", [ + keyed("9", "Nine", "action"), + keyed("100000", "Hundred Thousand", "action"), + keyed("99999", "Ninety-Nine Thousand", "comedy"), + keyed("10", "Ten", "action"), + ]); + + const all = await queryPage(catalogDb, "vod", { sourceId: "source-1", offset: 0, limit: 10 }); + expect(all.map((r) => r.streamId)).toEqual(["100000", "99999", "10", "9"]); + + const action = await queryPage(catalogDb, "vod", { sourceId: "source-1", categoryId: "action", offset: 1, limit: 10 }); + expect(action.map((r) => r.streamId)).toEqual(["10", "9"]); + }); + + it("keeps search alphabetical", async () => { + const catalogDb = await openCatalogDb(); + await putRecordsBatch(catalogDb, "vod", [keyed("1", "Matrix"), keyed("2", "Mad Max")]); + + const page = await queryPage(catalogDb, "vod", { sourceId: "source-1", namePrefixLower: "ma", offset: 0, limit: 10 }); + expect(page.map((r) => r.name)).toEqual(["Mad Max", "Matrix"]); + }); + + it("pads numeric ids only, leaving M3U-style ids as they are", () => { + expect(catalogRecordId("s", "42")).toBe("s:000000000000042"); + expect(catalogRecordId("s", "m3u-7")).toBe("s:m3u-7"); + }); +}); diff --git a/src/core/storage/catalog-db.ts b/src/core/storage/catalog-db.ts index 748a4d4..d6e7848 100644 --- a/src/core/storage/catalog-db.ts +++ b/src/core/storage/catalog-db.ts @@ -21,7 +21,7 @@ export type CatalogKind = "vod" | "series"; /** One VOD or series record as stored — a flattened, IndexedDB-friendly shape rather than the richer Channel/SeriesInfo types screens render, which catalog-store.ts maps to/from. */ export interface CatalogRecord { - /** `${sourceId}:${streamId}` — unique across every source sharing this store. */ + /** catalogRecordId(sourceId, streamId) — unique across every source sharing this store, and the order browse pages read in (descending). */ id: string; sourceId: string; streamId: string; @@ -41,7 +41,7 @@ export interface CatalogRecord { mature?: 0 | 1; /** `${sourceId}|${tag}` per tag — multiEntry-indexed, so "every animation title in this playlist" is one index range. */ tagKeys?: string[]; - /** Bumped once per completed sync (see catalog-sync.ts) — lets a sync that dies partway through be told apart from the previous complete generation, so its leftovers are swept by the next one. */ + /** The catalog generation the row was written under (see SyncMeta.generation). */ generation: number; } @@ -49,6 +49,11 @@ export interface SyncMeta { key: string; lastSyncedAt: number; recordCount: number; + /** + * Set when the table is first built and kept across refreshes, which only + * write what changed (see catalog-sync-core.ts) — so the search index, + * which starts over when this changes, keeps what it has built. + */ generation: number; /** The Kids rules version the records were tagged with (see KIDS_RULES_VERSION) — an older one makes the sync due. */ rulesVersion?: number; @@ -64,16 +69,50 @@ export interface CatalogDb { const DB_NAME = "iptv-catalog-v1"; // v2: the multiEntry by_tag_key index (Kids tags). Rows written before it // simply aren't in the index until the next sync re-tags them. -const DB_VERSION = 2; +// v3: two addedAt indexes, from a short-lived build — dropped again by v4. +// v4: zero-padded numeric ids (see catalogRecordId), existing rows re-keyed in place. +// v5: the search index's two stores (see search-index-db.ts), created empty — +// nothing existing is rewritten; the background indexer fills them later. +const DB_VERSION = 5; +/** Indexes an older build created that this one no longer uses. */ +const RETIRED_INDEXES = ["by_source_added", "by_source_category_added"]; const SYNC_META_STORE = "sync_meta"; +/** One row per indexed title: its search words (see src/search/). Kept apart from vod/series so indexing never blocks their reads. */ +export const SEARCH_TOKENS_STORE = "search_tokens"; +/** Where the background indexer got to, per source and kind. */ +export const SEARCH_INDEX_META_STORE = "search_index_meta"; +/** multiEntry over each row's `keys` (`${sourceId}\0${kind}\0${word}`). */ +export const SEARCH_BY_KEY_INDEX = "by_key"; +/** [sourceId, kind, generation] — for sweeping rows an older catalog generation left behind, and for purging a source. */ +export const SEARCH_BY_SOURCE_KIND_GENERATION_INDEX = "by_source_kind_generation"; const BY_SOURCE_INDEX = "by_source"; const BY_SOURCE_CATEGORY_INDEX = "by_source_category"; const BY_SOURCE_NAME_INDEX = "by_source_name"; const BY_TAG_KEY_INDEX = "by_tag_key"; +/** Wide enough for any Xtream stream id, so every numeric id pads to the same length. */ +const STREAM_ID_WIDTH = 15; + +/** + * A stream id as it sorts in the primary key: numeric ids zero-padded so + * text order is numeric order ("000…100000" after "000…99999", where the + * bare strings sort the other way round). Providers hand out ids as titles + * are added, so descending id order is roughly newest first — the order + * browse pages read in (see queryPage). Non-numeric ids (M3U tvg-ids) pass + * through unchanged. + */ +export function streamIdSortKey(streamId: string): string { + return /^\d+$/.test(streamId) ? streamId.padStart(STREAM_ID_WIDTH, "0") : streamId; +} + +/** The primary key for one source's stream — the only way records should be keyed or looked up. */ +export function catalogRecordId(sourceId: string, streamId: string): string { + return `${sourceId}:${streamIdSortKey(streamId)}`; +} + /** Upper bound for a same-prefix IDBKeyRange scan — the highest code point IndexedDB's default key comparator will ever sort a real string below. */ -const MAX_UTF16_SUFFIX = "￿"; +export const MAX_UTF16_SUFFIX = "￿"; function storeName(kind: CatalogKind): string { return kind; @@ -90,7 +129,7 @@ export function openCatalogDb(): Promise { return; } const request = indexedDB.open(DB_NAME, DB_VERSION); - request.onupgradeneeded = () => { + request.onupgradeneeded = (event) => { const db = request.result; for (const kind of ["vod", "series"] as CatalogKind[]) { const name = storeName(kind); @@ -104,10 +143,20 @@ export function openCatalogDb(): Promise { store.createIndex(BY_SOURCE_NAME_INDEX, ["sourceId", "nameLower"]); } if (!store.indexNames.contains(BY_TAG_KEY_INDEX)) store.createIndex(BY_TAG_KEY_INDEX, "tagKeys", { multiEntry: true }); + for (const retired of RETIRED_INDEXES) if (store.indexNames.contains(retired)) store.deleteIndex(retired); + if (event.oldVersion > 0 && event.oldVersion < 4) rekeyRecords(store); } if (!db.objectStoreNames.contains(SYNC_META_STORE)) { db.createObjectStore(SYNC_META_STORE, { keyPath: "key" }); } + if (!db.objectStoreNames.contains(SEARCH_TOKENS_STORE)) { + const search = db.createObjectStore(SEARCH_TOKENS_STORE, { keyPath: "id" }); + search.createIndex(SEARCH_BY_KEY_INDEX, "keys", { multiEntry: true }); + search.createIndex(SEARCH_BY_SOURCE_KIND_GENERATION_INDEX, ["sourceId", "kind", "generation"]); + } + if (!db.objectStoreNames.contains(SEARCH_INDEX_META_STORE)) { + db.createObjectStore(SEARCH_INDEX_META_STORE, { keyPath: "key" }); + } }; request.onsuccess = () => resolve({ db: request.result }); request.onerror = () => reject(request.error ?? new Error("Failed to open catalog IndexedDB")); @@ -116,6 +165,28 @@ export function openCatalogDb(): Promise { return dbPromise; } +/** + * Moves every pre-v4 row to its padded catalogRecordId, inside the upgrade + * transaction — so a synced catalog reads newest first (and Favorites' + * lookups by padded id still find it) straight away, without a re-sync. + * A re-keyed row sorts before the cursor's position ("0" < "1"–"9"), so + * the walk never visits it twice. + */ +function rekeyRecords(store: IDBObjectStore): void { + const cursorRequest = store.openCursor(); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor) return; + const record = cursor.value as CatalogRecord; + const id = catalogRecordId(record.sourceId, record.streamId); + if (id !== record.id) { + cursor.delete(); + store.put({ ...record, id }); + } + cursor.continue(); + }; +} + function runRequest(request: IDBRequest): Promise { return new Promise((resolve, reject) => { request.onsuccess = () => resolve(request.result); @@ -123,7 +194,7 @@ function runRequest(request: IDBRequest): Promise { }); } -/** Writes a batch of records in one readwrite transaction — the "batched DB transactions" piece: a sync's ~2000-record chunks each cost one transaction, not one per record. */ +/** Writes a batch of records in one readwrite transaction — one transaction per batch, not one per record. */ export function putRecordsBatch(catalogDb: CatalogDb, kind: CatalogKind, records: CatalogRecord[]): Promise { if (records.length === 0) return Promise.resolve(); const tx = catalogDb.db.transaction(storeName(kind), "readwrite"); @@ -135,39 +206,55 @@ export function putRecordsBatch(catalogDb: CatalogDb, kind: CatalogKind, records }); } +export interface CatalogChanges { + put: CatalogRecord[]; + /** Primary keys (catalogRecordId) to remove. */ + delete: string[]; + /** Search rows (search-index-db.ts's SearchTokenRow) for titles that are new or renamed — only while the search index is being kept up to date. */ + searchPut?: object[]; + /** Search row ids for titles that are gone or renamed. */ + searchDelete?: string[]; +} + /** - * Deletes every record for `sourceId` whose generation is older than - * `currentGeneration` — the cleanup half of the shadow-write-then-swap sync - * strategy (see catalog-sync.ts): the new generation's records are written - * first, sync_meta is flipped to point at it, and only then does the old - * generation get deleted. A refresh is therefore a full replace: titles the - * provider dropped disappear, the rest are upserted. While a sync runs a - * reader can briefly see a mix of old and new rows — never a title missing. + * Applies one batch of a sync's changes (see catalog-sync-core.ts) in a + * single readwrite transaction: the catalog rows and, when given, their + * search rows together — so search never points at a title that's gone, + * and a sync that dies partway leaves whole batches, never half of one. */ -export async function deleteStaleGeneration(catalogDb: CatalogDb, kind: CatalogKind, sourceId: string, currentGeneration: number): Promise { - const tx = catalogDb.db.transaction(storeName(kind), "readwrite"); - const index = tx.objectStore(storeName(kind)).index(BY_SOURCE_INDEX); - const range = IDBKeyRange.only(sourceId); - await new Promise((resolve, reject) => { - const cursorRequest = index.openCursor(range); - cursorRequest.onsuccess = () => { - const cursor = cursorRequest.result; - if (!cursor) { - resolve(); - return; - } - const record = cursor.value as CatalogRecord; - if (record.generation < currentGeneration) cursor.delete(); - cursor.continue(); - }; - cursorRequest.onerror = () => reject(cursorRequest.error ?? new Error("Stale-generation cleanup failed")); - }); +export function writeCatalogChanges(catalogDb: CatalogDb, kind: CatalogKind, changes: CatalogChanges): Promise { + const withSearch = Boolean(changes.searchPut?.length || changes.searchDelete?.length); + if (changes.put.length === 0 && changes.delete.length === 0 && !withSearch) return Promise.resolve(); + const tx = catalogDb.db.transaction(withSearch ? [storeName(kind), SEARCH_TOKENS_STORE] : storeName(kind), "readwrite"); + const store = tx.objectStore(storeName(kind)); + for (const record of changes.put) store.put(record); + for (const id of changes.delete) store.delete(id); + if (withSearch) { + const search = tx.objectStore(SEARCH_TOKENS_STORE); + for (const id of changes.searchDelete ?? []) search.delete(id); + for (const row of changes.searchPut ?? []) search.put(row); + } return new Promise((resolve, reject) => { tx.oncomplete = () => resolve(); - tx.onerror = () => reject(tx.error ?? new Error("Stale-generation cleanup transaction failed")); + tx.onerror = () => reject(tx.error ?? new Error("Catalog write failed")); }); } +/** Up to `limit` of a source's records after primary key `lastKey` (null: from the start), in key order — one short readonly read. */ +export async function readRecordsAfter(catalogDb: CatalogDb, kind: CatalogKind, sourceId: string, lastKey: string | null, limit: number): Promise { + const prefix = `${sourceId}:`; + const range = lastKey === null ? IDBKeyRange.bound(prefix, prefix + MAX_UTF16_SUFFIX) : IDBKeyRange.bound(lastKey, prefix + MAX_UTF16_SUFFIX, true, false); + const tx = catalogDb.db.transaction(storeName(kind), "readonly"); + const records = await runRequest(tx.objectStore(storeName(kind)).getAll(range, limit) as IDBRequest); + return records.filter((record) => record.sourceId === sourceId); +} + +/** Every record in one of a source's categories. */ +export function readCategoryRecords(catalogDb: CatalogDb, kind: CatalogKind, sourceId: string, categoryId: string): Promise { + const tx = catalogDb.db.transaction(storeName(kind), "readonly"); + return runRequest(tx.objectStore(storeName(kind)).index(BY_SOURCE_CATEGORY_INDEX).getAll(IDBKeyRange.only([sourceId, categoryId])) as IDBRequest); +} + /** Every record and the sync_meta entry for one source+kind — used when a source's data is reset or the source is removed. */ export async function deleteSourceCatalog(catalogDb: CatalogDb, kind: CatalogKind, sourceId: string): Promise { const tx = catalogDb.db.transaction([storeName(kind), SYNC_META_STORE], "readwrite"); @@ -208,11 +295,16 @@ export function queryPage(catalogDb: CatalogDb, kind: CatalogKind, options: Quer const tx = catalogDb.db.transaction(storeName(kind), "readonly"); const store = tx.objectStore(storeName(kind)); + // Search results read alphabetically. Category and full listings share one + // index key per row, so they come out in primary-key order — walked + // backwards, that's newest first (see streamIdSortKey). let source: IDBIndex; let range: IDBKeyRange; + let direction: IDBCursorDirection = "prev"; if (namePrefixLower !== undefined) { source = store.index(BY_SOURCE_NAME_INDEX); range = IDBKeyRange.bound([sourceId, namePrefixLower], [sourceId, namePrefixLower + MAX_UTF16_SUFFIX]); + direction = "next"; } else if (categoryId !== undefined) { source = store.index(BY_SOURCE_CATEGORY_INDEX); range = IDBKeyRange.only([sourceId, categoryId]); @@ -224,7 +316,7 @@ export function queryPage(catalogDb: CatalogDb, kind: CatalogKind, options: Quer const results: CatalogRecord[] = []; let skipped = 0; return new Promise((resolve, reject) => { - const cursorRequest = source.openCursor(range); + const cursorRequest = source.openCursor(range, direction); cursorRequest.onsuccess = () => { const cursor = cursorRequest.result; if (!cursor || results.length >= limit) { @@ -248,7 +340,7 @@ export function queryPage(catalogDb: CatalogDb, kind: CatalogKind, options: Quer } /** - * Looks up records by primary key (`${sourceId}:${streamId}`), preserving + * Looks up records by primary key (catalogRecordId), preserving * `ids`' order and silently dropping any id with no matching record (the * stream was removed from the provider since it was favourited/watched) — * callers (catalog-store.ts's getRecordsByIds) never see a hole in the @@ -400,7 +492,7 @@ export function __resetCatalogDbForTests(): void { /** Test-only escape hatch: wipes every store (vod/series/sync_meta) so one test file's writes never leak into the next — fake-indexeddb persists the "database" across openCatalogDb() calls within a test file, only __resetCatalogDbForTests's connection-cache reset doesn't clear its contents. */ export async function __clearCatalogDbForTests(): Promise { const catalogDb = await openCatalogDb(); - const storeNames = ["vod", "series", SYNC_META_STORE]; + const storeNames = ["vod", "series", SYNC_META_STORE, SEARCH_TOKENS_STORE, SEARCH_INDEX_META_STORE]; await Promise.all( storeNames.map( (name) => diff --git a/src/core/storage/live-db.ts b/src/core/storage/live-db.ts index c26d52e..800be1e 100644 --- a/src/core/storage/live-db.ts +++ b/src/core/storage/live-db.ts @@ -109,6 +109,16 @@ export function getSourceChannels(liveDb: LiveDb, sourceId: string): Promise); } +/** Up to `limit` of a source's channels after list position `afterPosition` (from the start when null) — one short readonly read, for the search indexer. */ +export function getSourceChannelsAfter(liveDb: LiveDb, sourceId: string, afterPosition: number | null, limit: number): Promise { + const tx = liveDb.db.transaction(CHANNELS_STORE, "readonly"); + const range = + afterPosition === null + ? IDBKeyRange.bound([sourceId, -Infinity], [sourceId, Infinity]) + : IDBKeyRange.bound([sourceId, afterPosition], [sourceId, Infinity], true, false); + return runRequest(tx.objectStore(CHANNELS_STORE).getAll(range, limit) as IDBRequest); +} + export function getLiveSyncMeta(liveDb: LiveDb, sourceId: string): Promise { const tx = liveDb.db.transaction(META_STORE, "readonly"); return runRequest(tx.objectStore(META_STORE).get(sourceId) as IDBRequest); diff --git a/src/core/storage/search-index-db.ts b/src/core/storage/search-index-db.ts new file mode 100644 index 0000000..9b2d00d --- /dev/null +++ b/src/core/storage/search-index-db.ts @@ -0,0 +1,158 @@ +import { + MAX_UTF16_SUFFIX, + readRecordsAfter, + SEARCH_BY_KEY_INDEX, + SEARCH_BY_SOURCE_KIND_GENERATION_INDEX, + SEARCH_INDEX_META_STORE, + SEARCH_TOKENS_STORE, + type CatalogDb, + type CatalogKind, + type CatalogRecord, +} from "./catalog-db.js"; + +/** + * Storage for the search index (docs/global-search-plan.md, phase 2): its + * own two stores in the catalog database, so the background indexer's + * writes never make a Movies/Series read or a sync's write wait — IndexedDB + * only queues transactions behind each other when their stores overlap. + * Every function here is one short transaction. + */ + +/** What's indexed: the two catalogs, and live channels (from live-db.ts). */ +export type SearchKind = CatalogKind | "live"; + +/** One indexed title or channel. */ +export interface SearchTokenRow { + /** `${kind}|${catalog record id}`, or `live|${sourceId}:${list position}` — overwritten in place when re-indexed. */ + id: string; + sourceId: string; + kind: SearchKind; + /** The catalog record's id (catalogRecordId), or a live channel's own id. */ + recordId: string; + /** The catalog generation this row was built from — older ones are swept after a full pass. */ + generation: number; + /** searchKey() per search word. */ + keys: string[]; +} + +export type SearchIndexPhase = "index" | "sweep" | "done"; + +/** Where the indexer got to for one source and kind. */ +export interface SearchIndexMeta { + key: string; + /** The catalog (or live list) generation being, or last, indexed. */ + generation: number; + /** The last catalog record id (or live list position) indexed, or null to start from the beginning. */ + lastKey: string | number | null; + phase: SearchIndexPhase; +} + +export function searchIndexMetaKey(sourceId: string, kind: SearchKind): string { + return `${sourceId}|${kind}`; +} + +/** The index key a word is stored under. The source and kind are part of it, since a multiEntry index can't be compound. */ +export function searchKey(sourceId: string, kind: SearchKind, word: string): string { + return `${sourceId}\u0000${kind}\u0000${word}`; +} + +function request(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error ?? new Error("IndexedDB request failed")); + }); +} + +function completion(tx: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error ?? new Error("IndexedDB transaction failed")); + tx.onabort = () => reject(tx.error ?? new Error("IndexedDB transaction aborted")); + }); +} + +export async function getSearchIndexMeta(catalogDb: CatalogDb, key: string): Promise { + const tx = catalogDb.db.transaction(SEARCH_INDEX_META_STORE, "readonly"); + return (await request(tx.objectStore(SEARCH_INDEX_META_STORE).get(key))) as SearchIndexMeta | undefined; +} + +export async function putSearchIndexMeta(catalogDb: CatalogDb, meta: SearchIndexMeta): Promise { + const tx = catalogDb.db.transaction(SEARCH_INDEX_META_STORE, "readwrite"); + tx.objectStore(SEARCH_INDEX_META_STORE).put(meta); + await completion(tx); +} + +/** Up to `limit` of a source's catalog records after `lastKey`, in key order — one short readonly read of the catalog store. */ +export function readCatalogRecordsAfter(catalogDb: CatalogDb, kind: CatalogKind, sourceId: string, lastKey: string | null, limit: number): Promise { + return readRecordsAfter(catalogDb, kind, sourceId, lastKey, limit); +} + +/** Writes a batch of rows and the indexer's new position together, so progress never runs ahead of what's stored. */ +export async function writeSearchRows(catalogDb: CatalogDb, rows: SearchTokenRow[], meta: SearchIndexMeta): Promise { + const tx = catalogDb.db.transaction([SEARCH_TOKENS_STORE, SEARCH_INDEX_META_STORE], "readwrite"); + const store = tx.objectStore(SEARCH_TOKENS_STORE); + for (const row of rows) store.put(row); + tx.objectStore(SEARCH_INDEX_META_STORE).put(meta); + await completion(tx); +} + +/** + * Deletes up to `limit` rows for a source and kind built from a generation + * older than `generation` (titles the provider dropped), and saves `meta` + * in the same transaction. Returns how many were deleted. + */ +export async function sweepStaleSearchRows(catalogDb: CatalogDb, sourceId: string, kind: SearchKind, generation: number, limit: number, meta: SearchIndexMeta): Promise { + const tx = catalogDb.db.transaction([SEARCH_TOKENS_STORE, SEARCH_INDEX_META_STORE], "readwrite"); + const index = tx.objectStore(SEARCH_TOKENS_STORE).index(SEARCH_BY_SOURCE_KIND_GENERATION_INDEX); + const range = IDBKeyRange.bound([sourceId, kind, -Infinity], [sourceId, kind, generation], false, true); + let deleted = 0; + await new Promise((resolve, reject) => { + const cursorRequest = index.openCursor(range); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor || deleted >= limit) { + resolve(); + return; + } + cursor.delete(); + deleted++; + cursor.continue(); + }; + cursorRequest.onerror = () => reject(cursorRequest.error ?? new Error("Search index sweep failed")); + }); + tx.objectStore(SEARCH_INDEX_META_STORE).put(meta); + await completion(tx); + return deleted; +} + +/** Record ids (catalog record ids, or live channel ids) indexed under a word starting with `word`, at most `limit` (not de-duplicated). */ +export async function findSearchCandidates(catalogDb: CatalogDb, sourceId: string, kind: SearchKind, word: string, limit: number): Promise { + const from = searchKey(sourceId, kind, word); + const tx = catalogDb.db.transaction(SEARCH_TOKENS_STORE, "readonly"); + const rows = (await request(tx.objectStore(SEARCH_TOKENS_STORE).index(SEARCH_BY_KEY_INDEX).getAll(IDBKeyRange.bound(from, from + MAX_UTF16_SUFFIX), limit))) as SearchTokenRow[]; + return rows.map((row) => row.recordId); +} + +/** Every search row and progress record for a source — when the source is removed. */ +export async function deleteSearchIndexForSource(catalogDb: CatalogDb, sourceId: string): Promise { + const tx = catalogDb.db.transaction([SEARCH_TOKENS_STORE, SEARCH_INDEX_META_STORE], "readwrite"); + const index = tx.objectStore(SEARCH_TOKENS_STORE).index(SEARCH_BY_SOURCE_KIND_GENERATION_INDEX); + // Compound keys sort element by element, and an array sorts after any string — so this covers every kind and generation. + const range = IDBKeyRange.bound([sourceId], [sourceId, []]); + await new Promise((resolve, reject) => { + const cursorRequest = index.openCursor(range); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor) { + resolve(); + return; + } + cursor.delete(); + cursor.continue(); + }; + cursorRequest.onerror = () => reject(cursorRequest.error ?? new Error("Search index purge failed")); + }); + const meta = tx.objectStore(SEARCH_INDEX_META_STORE); + for (const kind of ["vod", "series", "live"] as SearchKind[]) meta.delete(searchIndexMetaKey(sourceId, kind)); + await completion(tx); +} diff --git a/src/core/text/clean-title.test.ts b/src/core/text/clean-title.test.ts new file mode 100644 index 0000000..4bc9692 --- /dev/null +++ b/src/core/text/clean-title.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { cleanTitle } from "./clean-title.js"; + +describe("cleanTitle", () => { + it("drops the empty brackets a panel leaves for a missing year", () => { + expect(cleanTitle("Heat ()")).toBe("Heat"); + expect(cleanTitle("Heat ( )")).toBe("Heat"); + expect(cleanTitle("EN | Heat () [4K]")).toBe("EN | Heat [4K]"); + expect(cleanTitle("Heat []")).toBe("Heat"); + }); + + it("leaves real bracketed text alone", () => { + expect(cleanTitle("Heat (1995)")).toBe("Heat (1995)"); + expect(cleanTitle("Up (Director's Cut)")).toBe("Up (Director's Cut)"); + }); + + it("trims the ends and never returns an empty title", () => { + expect(cleanTitle(" Heat ")).toBe("Heat"); + expect(cleanTitle("()")).toBe("()"); + expect(cleanTitle(undefined)).toBe(""); + }); +}); diff --git a/src/core/text/clean-title.ts b/src/core/text/clean-title.ts new file mode 100644 index 0000000..ffb9cae --- /dev/null +++ b/src/core/text/clean-title.ts @@ -0,0 +1,13 @@ +/** Empty brackets, as a panel leaves them when it builds "Title (Year)" for a title with no year: "()", "( )", "[]", "{ }". */ +const EMPTY_BRACKETS = /\s*(?:\(\s*\)|\[\s*\]|\{\s*\})/g; + +/** + * A provider-supplied title as it should be shown: empty brackets removed + * ("Heat ()" → "Heat", "Heat () [4K]" → "Heat [4K]") and the ends trimmed. + * Falls back to the raw title if cleaning would leave nothing. + */ +export function cleanTitle(title: string | null | undefined): string { + const raw = typeof title === "string" ? title : title == null ? "" : String(title); + const cleaned = raw.replace(EMPTY_BRACKETS, "").trim(); + return cleaned || raw.trim(); +} diff --git a/src/core/xtream/xtream-client.ts b/src/core/xtream/xtream-client.ts index 8935ac2..46fc18d 100644 --- a/src/core/xtream/xtream-client.ts +++ b/src/core/xtream/xtream-client.ts @@ -1,6 +1,7 @@ import type { Category, Channel, MovieDetails, SeriesDetails, SeriesEpisode, SeriesInfo } from "../models/channel.js"; import type { EpgProgramme } from "../models/epg.js"; import type { XtreamCredentials } from "../models/playlist-source.js"; +import { cleanTitle } from "../text/clean-title.js"; import { buildXtreamStreamUrl, mapEpgListing, @@ -311,7 +312,7 @@ export class XtreamClient { seriesId, season: ep.season, episode: ep.episode_num, - title: ep.title, + title: cleanTitle(ep.title), posterUrl: ep.info?.movie_image, durationSeconds: ep.info?.duration_secs, plot: ep.info?.plot, diff --git a/src/core/xtream/xtream-mappers.ts b/src/core/xtream/xtream-mappers.ts index 61d52c0..1848d9a 100644 --- a/src/core/xtream/xtream-mappers.ts +++ b/src/core/xtream/xtream-mappers.ts @@ -1,6 +1,7 @@ import type { Channel, SeriesInfo } from "../models/channel.js"; import type { EpgProgramme } from "../models/epg.js"; import type { XtreamCredentials } from "../models/playlist-source.js"; +import { cleanTitle } from "../text/clean-title.js"; /** * Pure reshape functions for player_api.php's raw JSON shapes into this @@ -101,7 +102,7 @@ export function parseChannelNumber(raw: number | string | undefined): number | u export function mapLiveStream(credentials: Pick, s: XtreamLiveStreamRaw): Channel { return { id: String(s.stream_id), - name: s.name, + name: cleanTitle(s.name), logoUrl: s.stream_icon, groupTitle: s.category_id, epgChannelId: s.epg_channel_id, @@ -125,7 +126,7 @@ export function mapLiveStream(credentials: Pick, s: XtreamVodStreamRaw): Channel { return { id: String(s.stream_id), - name: s.name, + name: cleanTitle(s.name), logoUrl: s.stream_icon, groupTitle: s.category_id, streamUrl: buildXtreamStreamUrl(credentials, "movie", s.stream_id, s.container_extension || "mp4"), @@ -137,7 +138,7 @@ export function mapSeriesEntry(s: XtreamSeriesRaw): Pick(); const pendingReads = new Map>(); function toChannel({ sourceId: _sourceId, position: _position, generation: _generation, ...channel }: LiveChannelRecord): Channel { - return channel; + // Rows stored before titles were cleaned at sync time. + return { ...channel, name: cleanTitle(channel.name) }; } /** The source's channel list if it's already been read this session — synchronous, for a first render with no loading flash. */ diff --git a/src/live-stream-url.test.ts b/src/live-stream-url.test.ts index 2c8bee6..b92844e 100644 --- a/src/live-stream-url.test.ts +++ b/src/live-stream-url.test.ts @@ -1,24 +1,48 @@ import { beforeEach, describe, expect, it } from "vitest"; -import { alternateLiveStream, liveStreamUrl } from "./live-stream-url.js"; -import { updateSettings } from "./settings-store.js"; +import { alternateLiveStream, forgetWorkingLiveStreamFormat, liveStreamUrl, rememberWorkingLiveStreamFormat, workingLiveStreamFormat } from "./live-stream-url.js"; +import { loadSettings, updateSettings } from "./settings-store.js"; + +const channel = { streamUrl: "http://tv.example/live/me/pw/42.m3u8" }; describe("live stream URL", () => { beforeEach(() => localStorage.clear()); - it("plays Xtream live channels as HLS by default, and as MPEG-TS when chosen", () => { - const channel = { streamUrl: "http://tv.example/live/me/pw/42.m3u8" }; - expect(liveStreamUrl(channel)).toBe("http://tv.example/live/me/pw/42.m3u8"); + it("on Auto (the default), starts as HLS until a format has worked for the playlist, then in that one", () => { + expect(loadSettings().liveStreamFormat).toBe("auto"); + expect(liveStreamUrl(channel, "src-1")).toBe("http://tv.example/live/me/pw/42.m3u8"); + rememberWorkingLiveStreamFormat("src-1", "ts"); + expect(liveStreamUrl(channel, "src-1")).toBe("http://tv.example/live/me/pw/42.ts"); + expect(liveStreamUrl(channel, "src-2")).toBe("http://tv.example/live/me/pw/42.m3u8"); // per playlist + forgetWorkingLiveStreamFormat("src-1"); + expect(workingLiveStreamFormat("src-1")).toBeUndefined(); + }); + + it("a fixed format ignores what worked before", () => { + rememberWorkingLiveStreamFormat("src-1", "ts"); + updateSettings({ liveStreamFormat: "m3u8" }); + expect(liveStreamUrl(channel, "src-1")).toBe("http://tv.example/live/me/pw/42.m3u8"); updateSettings({ liveStreamFormat: "ts" }); - expect(liveStreamUrl(channel)).toBe("http://tv.example/live/me/pw/42.ts"); + expect(liveStreamUrl(channel, "src-2")).toBe("http://tv.example/live/me/pw/42.ts"); }); it("leaves M3U channels as they are", () => { updateSettings({ liveStreamFormat: "ts" }); - expect(liveStreamUrl({ streamUrl: "http://cdn.example/bbc/index.m3u8" })).toBe("http://cdn.example/bbc/index.m3u8"); + expect(liveStreamUrl({ streamUrl: "http://cdn.example/bbc/index.m3u8" }, "src-1")).toBe("http://cdn.example/bbc/index.m3u8"); }); it("names the alternative format", () => { expect(alternateLiveStream("http://tv.example/live/me/pw/42.m3u8")).toEqual({ url: "http://tv.example/live/me/pw/42.ts", format: "ts", label: "MPEG-TS" }); expect(alternateLiveStream("http://cdn.example/bbc/index.m3u8")).toBeUndefined(); }); + + it("moves settings saved before Auto existed onto Auto, keeping a deliberate MPEG-TS", () => { + localStorage.setItem("iptv.settings.v1", JSON.stringify({ playbackSpeed: 1.5, liveStreamFormat: "m3u8" })); + expect(loadSettings()).toMatchObject({ playbackSpeed: 1.5, liveStreamFormat: "auto" }); + localStorage.setItem("iptv.settings.v1", JSON.stringify({ liveStreamFormat: "ts" })); + expect(loadSettings().liveStreamFormat).toBe("ts"); + // Once saved in the new shape, HLS is a real choice. + updateSettings({ liveStreamFormat: "m3u8" }); + expect(loadSettings().liveStreamFormat).toBe("m3u8"); + expect(loadSettings()).not.toHaveProperty("liveStreamFormatVersion"); + }); }); diff --git a/src/live-stream-url.ts b/src/live-stream-url.ts index 78cd5db..b6d3f59 100644 --- a/src/live-stream-url.ts +++ b/src/live-stream-url.ts @@ -3,18 +3,63 @@ import { loadSettings } from "./settings-store.js"; export const LIVE_STREAM_FORMAT_LABELS: Record = { m3u8: "HLS", ts: "MPEG-TS" }; +const WORKING_FORMAT_KEY = "iptv.live-format-by-source.v1"; + +function readWorkingFormats(): Record { + try { + const raw = localStorage.getItem(WORKING_FORMAT_KEY); + return raw ? (JSON.parse(raw) as Record) : {}; + } catch { + return {}; + } +} + +function writeWorkingFormats(formats: Record): void { + try { + localStorage.setItem(WORKING_FORMAT_KEY, JSON.stringify(formats)); + } catch { + // Storage full or unavailable — Auto just starts from HLS again next time. + } +} + +/** + * The format a playlist's live channels last started in. A panel's HLS + * output tends to work for all of its channels or none, so Auto starts the + * next channel in this format and channel changes play on the first try. + */ +export function workingLiveStreamFormat(sourceId: string): LiveStreamFormat | undefined { + const format = readWorkingFormats()[sourceId]; + return format === "m3u8" || format === "ts" ? format : undefined; +} + +export function rememberWorkingLiveStreamFormat(sourceId: string, format: LiveStreamFormat): void { + const formats = readWorkingFormats(); + if (formats[sourceId] === format) return; + writeWorkingFormats({ ...formats, [sourceId]: format }); +} + +/** The playlist is being removed. */ +export function forgetWorkingLiveStreamFormat(sourceId: string): void { + const formats = readWorkingFormats(); + if (!(sourceId in formats)) return; + delete formats[sourceId]; + writeWorkingFormats(formats); +} + /** - * The URL to play a live channel with, in the Live Stream Format chosen in - * App Settings. Stored channel URLs are always HLS (see mapLiveStream), so - * the format is applied at play time — changing the setting needs no - * re-download. Anything that isn't an Xtream live URL (M3U entries) plays - * as it is. + * The URL to start a live channel with. Stored channel URLs are always HLS + * (see mapLiveStream), so the format is applied at play time: on Auto, the + * one that last worked for this playlist (HLS until something has played); + * otherwise the fixed format from App Settings. Anything that isn't an + * Xtream live URL (M3U entries) plays as it is. */ -export function liveStreamUrl(channel: Pick, format: LiveStreamFormat = loadSettings().liveStreamFormat): string { +export function liveStreamUrl(channel: Pick, sourceId: string): string { + const setting = loadSettings().liveStreamFormat; + const format = setting === "auto" ? (workingLiveStreamFormat(sourceId) ?? "m3u8") : setting; return withLiveStreamFormat(channel.streamUrl, format) ?? channel.streamUrl; } -/** The same Xtream live stream in the other format — what the player offers when one fails. undefined when there's no alternative. */ +/** The same Xtream live stream in the other format — what Auto switches to when one fails. undefined when there's no alternative. */ export function alternateLiveStream(url: string): { url: string; format: LiveStreamFormat; label: string } | undefined { const current = liveStreamFormatOf(url); if (!current) return undefined; diff --git a/src/live-sync-core.ts b/src/live-sync-core.ts index 9dc98e2..f960800 100644 --- a/src/live-sync-core.ts +++ b/src/live-sync-core.ts @@ -1,7 +1,8 @@ -import { KIDS_RULES_VERSION, mapLiveStream, parseM3u, type Channel, type PlaylistSource, type XtreamLiveStreamRaw } from "@core"; -import { catalogSyncMetaKey, deleteStaleGeneration, getSyncMeta, openCatalogDb, putRecordsBatch, putSyncMeta } from "./core/storage/catalog-db.js"; +import { mapLiveStream, parseM3u, type Channel, type PlaylistSource, type XtreamLiveStreamRaw } from "@core"; +import { openCatalogDb } from "./core/storage/catalog-db.js"; import { deleteStaleChannels, getLiveSyncMeta, openLiveDb, putChannels, putLiveSyncMeta, type LiveChannelRecord, type LiveDb } from "./core/storage/live-db.js"; -import { channelToRecord, UNGROUPED_CATEGORY } from "./catalog-records.js"; +import { UNGROUPED_CATEGORY } from "./catalog-records.js"; +import { writeCatalog } from "./catalog-sync-core.js"; import { SyncStorageUnavailableError, type SyncJobOptions } from "./sync-job.js"; /** @@ -81,18 +82,10 @@ async function fetchPlaylist(source: PlaylistSource, fetchImpl: typeof fetch): P return { live: (raw as XtreamLiveStreamRaw[]).map((s) => mapLiveStream(source, s)) }; } -/** Writes an M3U playlist's movies into the VOD catalog table, with catalog-sync.ts's own write-then-swap (integer generations there). */ +/** Writes an M3U playlist's movies into the VOD catalog table the way catalog-sync-core.ts writes an Xtream catalog — only what changed. */ async function writeMovies(sourceId: string, movies: Channel[], batchSize: number, yieldBetweenBatches?: () => Promise): Promise { - const catalogDb = await openCatalogDb(); - const key = catalogSyncMetaKey(sourceId, "vod"); - const previous = await getSyncMeta(catalogDb, key); - const generation = (previous?.generation ?? 0) + 1; - for (let offset = 0; offset < movies.length; offset += batchSize) { - await putRecordsBatch(catalogDb, "vod", movies.slice(offset, offset + batchSize).map((movie) => channelToRecord(sourceId, generation, movie))); - if (yieldBetweenBatches) await yieldBetweenBatches(); - } - await putSyncMeta(catalogDb, { key, lastSyncedAt: Date.now(), recordCount: movies.length, generation, rulesVersion: KIDS_RULES_VERSION }); - if (previous) await deleteStaleGeneration(catalogDb, "vod", sourceId, generation); + // The file itself came back fine (live channels were checked above), so no movies in it means none — not a broken response. + await writeCatalog(await openCatalogDb(), "vod", sourceId, movies, { batchSize, yieldBetweenBatches, allowEmpty: true }); } export async function runLiveSync(request: LiveSyncRequest, options: SyncJobOptions): Promise { diff --git a/src/main.tsx b/src/main.tsx index f4968f2..46c4e5e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,7 +1,13 @@ -import { StrictMode, useState } from "react"; +import { StrictMode } from "react"; +// Inter ships with the app: TVs don't have it installed, and their own +// system font is narrower with shorter lowercase letters, so the UI read +// smaller there than in a browser that happens to have Inter. Weight axis +// only; every subset is bundled, but a device only loads the ranges it uses. +import "@fontsource-variable/inter/wght.css"; import { createRoot } from "react-dom/client"; -import { ShimmerStyles, SplashScreen } from "@ui"; +import { ShimmerStyles } from "@ui"; import { App } from "./App.js"; +import { startBootSplashTimeout } from "./boot-splash.js"; import { purgeLegacyCacheEntries } from "./content-cache.js"; // Background housekeeping only — deletes pre-table cache blobs by key (see @@ -9,23 +15,15 @@ import { purgeLegacyCacheEntries } from "./content-cache.js"; // from IndexedDB lazily, per key, when a screen first asks for one. void purgeLegacyCacheEntries(); -/** - * Root component: renders the real App immediately (never blocked on the - * splash — see SplashScreen's doc comment for why it's not tied to any - * data fetch) with the animated splash drawn on top as a plain overlay, - * unmounting itself once its hold+fade finishes. webOS's own static - * splashBackground (see webos-meta/appinfo.json) covers the OS-level gap - * before this script even runs; this bridges the moment after that, once - * React has taken over. - */ -function Root(): JSX.Element { - const [isSplashing, setIsSplashing] = useState(true); +// The launch splash index.html has shown since the page's first paint stays +// until App has a real screen up (see boot-splash.ts) — or this runs out. +startBootSplashTimeout(); +function Root(): JSX.Element { return ( <> - {isSplashing && setIsSplashing(false)} />} ); } diff --git a/src/profile-store.ts b/src/profile-store.ts index 20655f8..e02c0cb 100644 --- a/src/profile-store.ts +++ b/src/profile-store.ts @@ -1,5 +1,7 @@ import { migrateAvatarUrl, type ContinueWatchingEntry, type FavoriteEntry, type FavoriteKind, type Profile, type WatchHistoryEntry } from "@core"; +import { removeProfileCategoryUsage, removeSourceCategoryUsage } from "./category-usage-store.js"; import { removeKidsProfileRules } from "./parental-store.js"; +import { removeProfileRecentSearches, removeSourceRecentSearches } from "./search/recent-searches.js"; const PROFILES_KEY = "iptv.profiles.v1"; const ACTIVE_PROFILE_KEY = "iptv.active-profile-id.v1"; @@ -54,6 +56,8 @@ export function deleteProfile(profileId: string): Profile[] { saveProfiles(profiles); writeWatchHistory(readWatchHistory().filter((e) => e.profileId !== profileId)); removeKidsProfileRules(profileId); + removeProfileCategoryUsage(profileId); + removeProfileRecentSearches(profileId); return profiles; } @@ -204,4 +208,6 @@ export function removeSourceUserData(sourceId: string): void { for (const listener of favoriteListeners) listener(); } writeWatchHistory(readWatchHistory().filter((e) => e.sourceId !== sourceId)); + removeSourceCategoryUsage(sourceId); + removeSourceRecentSearches(sourceId); } diff --git a/src/screens/GuideScreen.tsx b/src/screens/GuideScreen.tsx index b44762e..4fbfc38 100644 --- a/src/screens/GuideScreen.tsx +++ b/src/screens/GuideScreen.tsx @@ -150,7 +150,7 @@ export function GuideScreen({ source, platform, profile, onPlay, onBack, isPlayb return; } if (!programme || (programme.start.getTime() <= now && now < programme.stop.getTime())) { - onPlay(liveStreamUrl(channel)); + onPlay(liveStreamUrl(channel, source.id)); return; } if (programme.start.getTime() > now) { diff --git a/src/screens/HomeScreen.test.tsx b/src/screens/HomeScreen.test.tsx index 24c1752..cd9f957 100644 --- a/src/screens/HomeScreen.test.tsx +++ b/src/screens/HomeScreen.test.tsx @@ -206,7 +206,7 @@ describe("HomeScreen sync status", () => { useSyncStore.getState().beginRun(source.id, "interval"); useSyncStore.getState().setStage(source.id, "vod", { status: "running", done: 4000 }); }); - expect(screen.getByText(`Syncing Movies… ${(4000).toLocaleString()}`)).toBeTruthy(); + expect(screen.getByText("Sync in progress…")).toBeTruthy(); }); it("after Refresh, reports what's now stored", async () => { @@ -238,6 +238,25 @@ describe("HomeScreen sync status", () => { }); }); +describe("HomeScreen Search button", () => { + beforeEach(() => { + clearAllCachedContent(); + __resetHomeFocusMemoryForTests(); + useFocusStore.getState().clearGraph("home-grid"); + }); + afterEach(() => useFocusStore.getState().clearGraph("home-grid")); + + it("sits in the top bar right of the profile chip and opens Search", () => { + const onSelectTile = vi.fn(); + renderHome(onSelectTile); + act(() => useFocusStore.getState().focus(PROFILE_SWITCHER_FOCUS_ID)); + press("ArrowRight"); + expect(useFocusStore.getState().focusedId).toBe("home-search"); + press("Enter"); + expect(onSelectTile).toHaveBeenCalledWith("search"); + }); +}); + describe("HomeScreen playlist switching", () => { const second: PlaylistSource = { kind: "m3u-url", id: "src-2", name: "Sports Playlist", url: "http://example.com/list.m3u" }; @@ -259,10 +278,18 @@ describe("HomeScreen playlist switching", () => { return onSelectSource; } - it("shows no playlist chip with a single playlist", () => { + it("shows no playlist chip with a single playlist — Search is then the rightmost item in the top bar", () => { renderWithPlaylists([source]); expect(screen.queryByRole("button", { name: /Switch playlist/ })).toBeNull(); expect(useFocusStore.getState().nodes["home-playlist-chip"]).toBeUndefined(); + + act(() => useFocusStore.getState().focus("guide")); + press("ArrowUp"); + expect(useFocusStore.getState().focusedId).toBe("home-search"); + press("ArrowRight"); + expect(useFocusStore.getState().focusedId).toBe("home-search"); // nothing further right + press("ArrowDown"); + expect(useFocusStore.getState().focusedId).toBe("guide"); }); it("with more than one, the header chip names the active playlist and sits above the right-hand tiles", () => { @@ -272,7 +299,12 @@ describe("HomeScreen playlist switching", () => { press("ArrowUp"); expect(useFocusStore.getState().focusedId).toBe("home-playlist-chip"); press("ArrowLeft"); + expect(useFocusStore.getState().focusedId).toBe("home-search"); // Search sits just left of the chip + press("ArrowLeft"); expect(useFocusStore.getState().focusedId).toBe(PROFILE_SWITCHER_FOCUS_ID); + act(() => useFocusStore.getState().focus("series")); + press("ArrowUp"); + expect(useFocusStore.getState().focusedId).toBe("home-search"); // the rest of the right half goes to Search act(() => useFocusStore.getState().focus("live")); press("ArrowUp"); expect(useFocusStore.getState().focusedId).toBe(PROFILE_SWITCHER_FOCUS_ID); // the left half still goes to the profile chip diff --git a/src/screens/HomeScreen.tsx b/src/screens/HomeScreen.tsx index faff2f6..ba284a4 100644 --- a/src/screens/HomeScreen.tsx +++ b/src/screens/HomeScreen.tsx @@ -4,6 +4,7 @@ import type { PlatformId, PlaylistSource, Profile } from "@core"; import { Clock, Focusable, + SearchButton, LiftSurface, MeshBackground, ProfileSwitcher, @@ -88,15 +89,21 @@ export function __resetHomeFocusMemoryForTests(): void { lastSelectedTileId = PRIMARY_TILES[0].id; } +/** Focus id of the top bar's Search button. */ +const SEARCH_BUTTON_ID = "home-search"; + /** - * Header (profile chip, and the playlist chip when there's more than one - * playlist) and both tile rows as one focus graph. Left/Right move within - * a row, Up/Down move between rows within the same column; Up from the - * primary row reaches the header chip on that side (the playlist chip sits - * over the right half) and Down from either chip reaches the tiles. + * Header (profile chip on the left; Search and — when there's more than one + * playlist — the playlist chip on the right) and both tile rows as one + * focus graph. Left/Right move within a row, Up/Down move between rows + * within the same column. Up from the primary row reaches the header item + * above it: the profile chip over the left half, Search over the right + * half, and the playlist chip (Search when there isn't one) over the last + * tile. Down from any header item reaches the tiles. */ function buildHomeFocusGraph( onOpenProfiles: () => void, + onOpenSearch: () => void, onRefresh: () => void, onSelectTile: (id: string) => void, onOpenPlaylists: (() => void) | null, @@ -107,16 +114,21 @@ function buildHomeFocusGraph( const secondaryIds = SECONDARY_TILES.map((tile) => tile.id); const hasPlaylistChip = onOpenPlaylists !== null; - const profileNode: FocusNode = { - id: PROFILE_SWITCHER_FOCUS_ID, - neighbors: { down: primaryIds[0], right: hasPlaylistChip ? PLAYLIST_CHIP_ID : undefined }, - onSelect: onOpenProfiles, + const lastTileId = primaryIds[primaryIds.length - 1]; + const headerNodes: FocusNode[] = [ + { id: PROFILE_SWITCHER_FOCUS_ID, neighbors: { down: primaryIds[0], right: SEARCH_BUTTON_ID }, onSelect: onOpenProfiles }, + { + id: SEARCH_BUTTON_ID, + neighbors: { left: PROFILE_SWITCHER_FOCUS_ID, right: hasPlaylistChip ? PLAYLIST_CHIP_ID : undefined, down: lastTileId }, + onSelect: onOpenSearch, + }, + ...(hasPlaylistChip ? [{ id: PLAYLIST_CHIP_ID, neighbors: { left: SEARCH_BUTTON_ID, down: lastTileId }, onSelect: onOpenPlaylists }] : []), + ]; + // Up from a tile reaches the header item above its part of the screen. + const headerAbove = (index: number) => { + if (index === primaryIds.length - 1) return hasPlaylistChip ? PLAYLIST_CHIP_ID : SEARCH_BUTTON_ID; + return index >= primaryIds.length / 2 ? SEARCH_BUTTON_ID : PROFILE_SWITCHER_FOCUS_ID; }; - const headerNodes: FocusNode[] = hasPlaylistChip - ? [profileNode, { id: PLAYLIST_CHIP_ID, neighbors: { left: PROFILE_SWITCHER_FOCUS_ID, down: primaryIds[primaryIds.length - 1] }, onSelect: onOpenPlaylists }] - : [profileNode]; - // Up from a tile reaches the header chip above its half of the screen. - const headerAbove = (index: number) => (hasPlaylistChip && index >= primaryIds.length / 2 ? PLAYLIST_CHIP_ID : PROFILE_SWITCHER_FOCUS_ID); const primaryNodes: FocusNode[] = primaryIds.map((id, index) => ({ id, @@ -206,7 +218,7 @@ export function HomeScreen({ source, sources = [], onSelectSource, platform, pro // Footer status: what's syncing right now, why the last sync failed, or how fresh the data is. const syncState = useSourceSyncState(source.id); const syncSummary = useSyncSummary(source); - const runningLabel = describeRunningSync(syncState, source); + const runningLabel = describeRunningSync(syncState); const failure = describeSyncFailure(syncState, source); const syncStatus = runningLabel ? runningLabel @@ -234,6 +246,10 @@ export function HomeScreen({ source, sources = [], onSelectSource, platform, pro }, [onSelectTile], ); + const openSearch = useCallback(() => { + lastSelectedTileId = SEARCH_BUTTON_ID; // back on Search when returning Home + onSelectTile("search"); + }, [onSelectTile]); // Playlist switching: a header chip, shown only when there's something to switch to. const canSwitchPlaylist = sources.length > 1 && onSelectSource !== undefined; @@ -259,12 +275,12 @@ export function HomeScreen({ source, sources = [], onSelectSource, platform, pro setGraph(SCOPE, []); return; } - setGraph(SCOPE, buildHomeFocusGraph(onOpenProfiles, handleRefresh, handleSelectTile, canSwitchPlaylist ? openPicker : null), lastSelectedTileId); + setGraph(SCOPE, buildHomeFocusGraph(onOpenProfiles, openSearch, handleRefresh, handleSelectTile, canSwitchPlaylist ? openPicker : null), lastSelectedTileId); if (refocusChipRef.current) { refocusChipRef.current = false; useFocusStore.getState().focus(PLAYLIST_CHIP_ID); } - }, [setGraph, onOpenProfiles, handleRefresh, handleSelectTile, canSwitchPlaylist, openPicker, isPickerOpen]); + }, [setGraph, onOpenProfiles, openSearch, handleRefresh, handleSelectTile, canSwitchPlaylist, openPicker, isPickerOpen]); useEffect(() => () => clearGraph(SCOPE), [clearGraph]); // Tiles and the profile switcher carry their own node onSelect (see @@ -304,11 +320,14 @@ export function HomeScreen({ source, sources = [], onSelectSource, platform, pro
- {canSwitchPlaylist && ( - - - - )} +
+ + {canSwitchPlaylist && ( + + + + )} +
diff --git a/src/screens/KidsHomeScreen.test.tsx b/src/screens/KidsHomeScreen.test.tsx new file mode 100644 index 0000000..eaea7a7 --- /dev/null +++ b/src/screens/KidsHomeScreen.test.tsx @@ -0,0 +1,45 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PlaylistSource, Profile } from "@core"; +import { PASS_THROUGH_POLICY } from "../content-policy.js"; +import { useFocusStore } from "../ui/focus/focus-store.js"; +import { KidsHomeScreen } from "./KidsHomeScreen.js"; + +// The rails read the local tables; this covers the section tiles only. +vi.mock("../use-kids-home-rails.js", () => ({ useKidsHomeRails: () => ({ rails: [], isLoading: false }) })); + +const source: PlaylistSource = { kind: "m3u-url", id: "src-1", name: "My Source", url: "http://example.com/list.m3u" }; +const kid: Profile = { id: "kid-1", name: "Mia", avatarUrl: "avatar/toon_2.png", kind: "kids" }; + +beforeEach(() => { + Element.prototype.scrollIntoView = () => {}; +}); + +describe("KidsHomeScreen", () => { + it("has Search as its first tile, and it opens Search", () => { + const onSelectTile = vi.fn(); + render( + {}} + onPlayMovie={() => {}} + onPlayChannel={() => {}} + onContinueSeries={() => {}} + onOpenSeries={() => {}} + />, + ); + const tiles = screen.getAllByRole("button").map((b) => b.textContent); + expect(tiles.indexOf("Search")).toBeLessThan(tiles.indexOf("Live TV")); + + act(() => useFocusStore.getState().focus("kids-nav:search")); + act(() => { + fireEvent.keyDown(document, { key: "Enter" }); + fireEvent.keyUp(document, { key: "Enter" }); + }); + expect(onSelectTile).toHaveBeenCalledWith("search"); + }); +}); diff --git a/src/screens/KidsHomeScreen.tsx b/src/screens/KidsHomeScreen.tsx index acaf36b..7dd323f 100644 --- a/src/screens/KidsHomeScreen.tsx +++ b/src/screens/KidsHomeScreen.tsx @@ -35,8 +35,9 @@ interface NavTile { icon: LucideIcon; } -/** The Kids profile's sections — Home's destinations without App Settings or Refresh Playlist. */ +/** The Kids profile's sections — Home's destinations without App Settings or Refresh Playlist, with Search first. */ const NAV_TILES: NavTile[] = [ + { id: "search", label: "Search", icon: SECTION_ICONS.search }, { id: "live", label: "Live TV", icon: SECTION_ICONS.live }, { id: "movies", label: "Movies", icon: SECTION_ICONS.movies }, { id: "series", label: "Series", icon: SECTION_ICONS.series }, diff --git a/src/screens/ListTiles.tsx b/src/screens/ListTiles.tsx index 6a18511..bc36814 100644 --- a/src/screens/ListTiles.tsx +++ b/src/screens/ListTiles.tsx @@ -32,6 +32,7 @@ export function ChannelTile({ seed, isEditing, onSelect, + width = "24rem", }: { id: string; title: string; @@ -40,6 +41,8 @@ export function ChannelTile({ seed: string; isEditing: boolean; onSelect: () => void; + /** Defaults to the My List / Recently Watched tile size. */ + width?: string; }): JSX.Element { const isFocused = useIsFocused(id); return ( @@ -47,7 +50,7 @@ export function ChannelTile({ { localStorage.clear(); __resetLiveCategoryMemoryForTests(); __resetNowNextCacheForTests(); - for (const scope of ["chrome:category-rail", "content:channel-sidebar", "content:live-favorite"]) useFocusStore.getState().clearGraph(scope); + for (const scope of ["chrome:category-rail", "content:channel-sidebar", "content:live-favorite", "chrome:live-search"]) useFocusStore.getState().clearGraph(scope); }); afterEach(() => { - for (const scope of ["chrome:category-rail", "content:channel-sidebar", "content:live-favorite"]) useFocusStore.getState().clearGraph(scope); + for (const scope of ["chrome:category-rail", "content:channel-sidebar", "content:live-favorite", "chrome:live-search"]) useFocusStore.getState().clearGraph(scope); }); it("lists every channel with its number under All Channels, with the provider's categories in the rail", async () => { - render( {}} onPlay={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onBack={() => {}} onPlay={() => {}} />); await flush(); expect(screen.getByRole("button", { name: "News" })).toBeDefined(); // rail rows @@ -122,8 +122,23 @@ describe("LiveTvScreen", () => { expect(useFocusStore.getState().focusedId).toBe("ch-1"); }); + it("has a Search button above the channel list — Up from the first channel reaches it, and it opens Search", async () => { + const onOpenSearch = vi.fn(); + render( {}} onPlay={() => {}} onOpenSearch={onOpenSearch} />); + await flush(); + expect(useFocusStore.getState().focusedId).toBe("ch-1"); + + press("ArrowUp"); + expect(useFocusStore.getState().focusedId).toBe("live-search-button"); + press("ArrowDown"); + expect(useFocusStore.getState().focusedId).toBe("ch-1"); + press("ArrowUp"); + press("Enter"); + expect(onOpenSearch).toHaveBeenCalledTimes(1); + }); + it("selecting a rail category filters the channel list and moves focus into it", async () => { - render( {}} onPlay={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onBack={() => {}} onPlay={() => {}} />); await flush(); await act(async () => { @@ -137,7 +152,7 @@ describe("LiveTvScreen", () => { it("OK on a channel plays it full screen in one press", async () => { const onPlay = vi.fn(); - render( {}} onPlay={onPlay} />); + render( {}} source={source} platform="web" profile={profile} onBack={() => {}} onPlay={onPlay} />); await flush(); press("ArrowDown"); @@ -154,14 +169,14 @@ describe("LiveTvScreen", () => { }); it("marks the preview On Now (not LIVE — the programme may be recorded)", async () => { - render( {}} onPlay={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onBack={() => {}} onPlay={() => {}} />); await flush(); expect(screen.getByText("On Now")).toBeDefined(); expect(screen.queryByText(/LIVE/)).toBeNull(); }); it("shows what's on now and next for the previewed channel", async () => { - render( {}} onPlay={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onBack={() => {}} onPlay={() => {}} />); expect(await screen.findByText("Morning Headlines")).toBeDefined(); expect(screen.getByText("Business Hour")).toBeDefined(); expect(screen.getByText("NOW")).toBeDefined(); @@ -170,7 +185,7 @@ describe("LiveTvScreen", () => { it("Back goes channel list → category rail → leaves Live TV", async () => { const onBack = vi.fn(); - render( {}} />); + render( {}} source={source} platform="web" profile={profile} onBack={onBack} onPlay={() => {}} />); await flush(); press("Escape"); @@ -183,7 +198,7 @@ describe("LiveTvScreen", () => { it("lists favourite channels under the My List rail entry, with its heart icon", async () => { const { toggleFavorite } = await import("../profile-store.js"); toggleFavorite(profile.id, source.id, "live", "ch-3"); - render( {}} onPlay={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onBack={() => {}} onPlay={() => {}} />); await flush(); const myList = screen.getByRole("button", { name: "My List 1" }); @@ -199,7 +214,7 @@ describe("LiveTvScreen", () => { }); it("Right from a channel reaches the favourite button, which adds and removes the channel", async () => { - render( {}} onPlay={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onBack={() => {}} onPlay={() => {}} />); await flush(); press("ArrowRight"); @@ -219,7 +234,7 @@ describe("LiveTvScreen", () => { it("favourites the channel just left even if the preview hadn't caught up yet, and Back returns to it", async () => { const { loadFavorites } = await import("../profile-store.js"); - render( {}} onPlay={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onBack={() => {}} onPlay={() => {}} />); await flush(); press("ArrowDown"); // highlight ch-2; the preview is still on ch-1 (debounced) diff --git a/src/screens/LiveTvScreen.tsx b/src/screens/LiveTvScreen.tsx index f761902..a3c069c 100644 --- a/src/screens/LiveTvScreen.tsx +++ b/src/screens/LiveTvScreen.tsx @@ -13,6 +13,7 @@ import { useFocusStore, useIsFocused, useRemoteInput, + SearchButton, } from "@ui"; import { loadFavorites, toggleFavorite } from "../profile-store.js"; import { useContentPolicy } from "../content-policy.js"; @@ -29,6 +30,8 @@ export interface LiveTvScreenProps { onBack: () => void; /** Enters full-screen playback for the given channel — see App.tsx's playLive. The lineup lets the player change channel (CH+/CH−, number keys). */ onPlay: (channel: Channel, lineup: ChannelLineup) => void; + /** Opens the global Search screen (the Search button above the channel list). */ + onOpenSearch: () => void; /** True while PlayerScreen is open on top of this screen — disables this screen's own useRemoteInput so a single Back press doesn't both close the player and navigate this screen away (see use-remote-input.ts's `enabled` doc comment). */ isPlaybackOpen?: boolean; } @@ -38,6 +41,8 @@ const FAVOURITES_CATEGORY_ID = "__favourites__"; /** Focus id of the favourite button in the preview's channel line. */ const FAVORITE_BUTTON_ID = "live-preview-favorite"; const FAVORITE_BUTTON_SCOPE = "content:live-favorite"; +const SEARCH_BUTTON_ID = "live-search-button"; +const SEARCH_BUTTON_SCOPE = "chrome:live-search"; /** * How long the channel list must rest on a channel before the preview * switches to it. Each switch tears down and restarts the TV's video @@ -63,7 +68,7 @@ function groupByCategory(channels: Channel[]): Category[] { return Array.from(seen.values()); } -export function LiveTvScreen({ source, platform, profile, onBack, onPlay, isPlaybackOpen = false }: LiveTvScreenProps): JSX.Element { +export function LiveTvScreen({ source, platform, profile, onBack, onPlay, onOpenSearch, isPlaybackOpen = false }: LiveTvScreenProps): JSX.Element { // Read from the local live table; fetched and parsed in the sync worker (see use-live-channels.ts). // A Kids profile sees only allowed channels, minus any airing something mature right now (docs/kids-profile.md §3.6). const policy = useContentPolicy(profile, source.id); @@ -181,6 +186,18 @@ export function LiveTvScreen({ source, platform, profile, onBack, onPlay, isPlay }, [previewChannel, handleToggleFavorite, setGraph]); useEffect(() => () => clearGraph(FAVORITE_BUTTON_SCOPE), [clearGraph]); + // The Search button above the channel list: Left to the category rail, Down to the first channel. Passive, like the favourite button. + const firstVisibleChannelId = visibleChannels[0]?.id; + useEffect(() => { + setGraph( + SEARCH_BUTTON_SCOPE, + [{ id: SEARCH_BUTTON_ID, neighbors: { left: categoryRailItemId(activeCategoryId), down: firstVisibleChannelId }, onSelect: onOpenSearch }], + undefined, + { passive: true }, + ); + }, [activeCategoryId, firstVisibleChannelId, onOpenSearch, setGraph]); + useEffect(() => () => clearGraph(SEARCH_BUTTON_SCOPE), [clearGraph]); + // OK on a channel plays it full screen straight away — the preview is // already showing it, so a second "focus the preview, press OK" step // would just be friction. @@ -237,7 +254,7 @@ export function LiveTvScreen({ source, platform, profile, onBack, onPlay, isPlay // underneath, and without this its preview would keep streaming and // decoding the same channel in parallel with the fullscreen player — // two MSE pipelines competing for the TV's few hardware decoders. - const previewStreamUrl = isPlaybackOpen || !previewChannel ? null : liveStreamUrl(previewChannel); + const previewStreamUrl = isPlaybackOpen || !previewChannel ? null : liveStreamUrl(previewChannel, source.id); const isInitialLoading = isChannelsLoading || isCategoriesLoading; @@ -272,13 +289,16 @@ export function LiveTvScreen({ source, platform, profile, onBack, onPlay, isPlay />
-
-
- {activeCategoryLabel} -
-
- {visibleChannels.length} {visibleChannels.length === 1 ? "channel" : "channels"} +
+
+
+ {activeCategoryLabel} +
+
+ {visibleChannels.length} {visibleChannels.length === 1 ? "channel" : "channels"} +
+
{visibleChannels.length === 0 ? ( @@ -294,6 +314,7 @@ export function LiveTvScreen({ source, platform, profile, onBack, onPlay, isPlay onHighlight={handleHighlight} onSelect={handleSelectChannel} leftEntryId={categoryRailItemId(activeCategoryId)} + topEntryId={SEARCH_BUTTON_ID} rightEntryId={previewChannel ? FAVORITE_BUTTON_ID : undefined} /> )} diff --git a/src/screens/PlayerScreen.test.tsx b/src/screens/PlayerScreen.test.tsx index 10d3e04..8c09df5 100644 --- a/src/screens/PlayerScreen.test.tsx +++ b/src/screens/PlayerScreen.test.tsx @@ -5,6 +5,7 @@ import { useFocusStore } from "../ui/focus/focus-store.js"; import { isFavorite } from "../profile-store.js"; import { PlayerScreen } from "./PlayerScreen.js"; import { updateSettings } from "../settings-store.js"; +import { workingLiveStreamFormat } from "../live-stream-url.js"; const guide = vi.hoisted(() => ({ nowNext: null as unknown })); vi.mock("../use-now-next.js", () => ({ @@ -191,40 +192,115 @@ describe("PlayerScreen", () => { expect(second.engine.load).toHaveBeenCalledWith("s", expect.objectContaining({ startPositionSeconds: undefined })); }); - it("when an Xtream live channel fails, offers it in the other format first", async () => { + describe("live stream formats", () => { const hls = "http://tv.example/live/me/pw/42.m3u8"; - const { engine, fail } = makeEngine(NaN); - const onPlayAlternateStream = vi.fn(); - render( {}} title="News" isLive onPlayAlternateStream={onPlayAlternateStream} engineFactory={() => engine} />); - await act(async () => {}); + const ts = "http://tv.example/live/me/pw/42.ts"; + beforeEach(() => localStorage.clear()); + + /** Engines handed out in order; `hang` ones never finish loading (a channel that doesn't start). */ + function engineQueue(...hang: boolean[]) { + const engines = hang.map((hangOnLoad) => makeEngine(NaN, { hangOnLoad })); + let created = 0; + return { engines, factory: () => engines[created++].engine, created: () => created }; + } + const loadedUrl = (engine: PlayerEngine) => (engine.load as ReturnType).mock.calls[0]?.[0]; - fail({ kind: "media", fatal: true, message: "unsupported" }); - expect(screen.getByText(/was playing as HLS\. Some providers stream more reliably as MPEG-TS/)).toBeDefined(); - expect(focusedId()).toBe("player-error-alternate"); - press("Enter"); - expect(onPlayAlternateStream).toHaveBeenCalledWith("http://tv.example/live/me/pw/42.ts"); + it("Auto: a channel that fails before its first frame is quietly tried in the other format, which is remembered", async () => { + const { engines, factory } = engineQueue(true, false); + render( {}} title="News" isLive liveSourceId="src-1" engineFactory={factory} />); + await act(async () => {}); - press("ArrowRight"); - expect(focusedId()).toBe("player-error-retry"); - }); + engines[0].fail({ kind: "manifest", fatal: true, message: "bad playlist" }); + await act(async () => {}); - it("offers HLS when an MPEG-TS channel fails", async () => { - const { engine, fail } = makeEngine(NaN); - render( - {}} title="News" isLive onPlayAlternateStream={() => {}} engineFactory={() => engine} />, - ); - await act(async () => {}); - fail({ kind: "network", fatal: true, message: "gone" }); - expect(screen.getByRole("button", { name: "Try HLS" })).toBeDefined(); - }); + expect(screen.queryByRole("alert")).toBeNull(); + expect(loadedUrl(engines[1].engine)).toBe(ts); + expect(workingLiveStreamFormat("src-1")).toBe("ts"); + }); - it("offers no other format for anything that isn't an Xtream live channel", async () => { - const { engine, fail } = makeEngine(3600); - render( {}} title="Film" onPlayAlternateStream={() => {}} engineFactory={() => engine} />); - await act(async () => {}); - fail({ kind: "media", fatal: true, message: "codec" }); - expect(screen.queryByRole("button", { name: /Try (HLS|MPEG-TS)/ })).toBeNull(); - expect(focusedId()).toBe("player-error-retry"); + it("Auto: a channel that hasn't started after 12 seconds is tried in the other format", async () => { + const { engines, factory } = engineQueue(true, true); + render( {}} title="News" isLive liveSourceId="src-1" engineFactory={factory} />); + await act(async () => {}); + + act(() => vi.advanceTimersByTime(11_000)); + expect(engines[1].engine.load).not.toHaveBeenCalled(); + act(() => vi.advanceTimersByTime(1_000)); + await act(async () => {}); + expect(loadedUrl(engines[1].engine)).toBe(ts); + }); + + it("Auto: the error shows only once both formats have failed; Try Again starts over in the first", async () => { + const { engines, factory } = engineQueue(true, true, false); + render( {}} title="News" isLive liveSourceId="src-1" engineFactory={factory} />); + await act(async () => {}); + engines[0].fail({ kind: "network", fatal: true, message: "gone" }); + await act(async () => {}); + engines[1].fail({ kind: "network", fatal: true, message: "gone" }); + + expect(screen.getByRole("alert")).toBeDefined(); + expect(screen.queryByRole("button", { name: /Try (HLS|MPEG-TS)/ })).toBeNull(); + expect(screen.queryByText(/fixed in App Settings/)).toBeNull(); + expect(focusedId()).toBe("player-error-retry"); + expect(workingLiveStreamFormat("src-1")).toBeUndefined(); + + press("Enter"); + await act(async () => {}); + expect(loadedUrl(engines[2].engine)).toBe(hls); + }); + + it("a fixed format never switches by itself, and the error says where to change it", async () => { + updateSettings({ liveStreamFormat: "m3u8" }); + const { engines, factory, created } = engineQueue(true, false); + render( {}} title="News" isLive liveSourceId="src-1" engineFactory={factory} />); + await act(async () => {}); + engines[0].fail({ kind: "manifest", fatal: true, message: "bad playlist" }); + + expect(screen.getByRole("alert").textContent).toContain("Set it to Auto"); + expect(created()).toBe(1); + }); + + it("a channel that drops after playing is reconnected in the same format, twice, before the error", async () => { + const { engines, factory } = engineQueue(false, false, false); + render( {}} title="News" isLive liveSourceId="src-1" engineFactory={factory} />); + await act(async () => {}); + + for (const index of [0, 1]) { + engines[index].fail({ kind: "network", fatal: true, message: "dropped" }); + await act(async () => {}); + expect(screen.queryByRole("alert")).toBeNull(); + expect(loadedUrl(engines[index + 1].engine)).toBe(hls); + } + engines[2].fail({ kind: "network", fatal: true, message: "dropped" }); + expect(screen.getByRole("alert")).toBeDefined(); + }); + + it("the menu's Stream Format switches the channel by hand", async () => { + const { engines, factory } = engineQueue(false, false); + render( {}} title="News" isLive liveSourceId="src-1" engineFactory={factory} />); + await act(async () => {}); + engines[0].tick(10); + press("ArrowRight"); + press("Enter"); + expect(screen.getByRole("button", { name: "HLS", pressed: true })).toBeDefined(); + + act(() => useFocusStore.getState().focus("player-stream-format:ts")); + press("Enter"); + await act(async () => {}); + expect(loadedUrl(engines[1].engine)).toBe(ts); + expect(screen.queryByRole("dialog")).toBeNull(); + }); + + it("offers no format choice for anything that isn't an Xtream live channel", async () => { + const { engine, tick } = makeEngine(NaN); + render( {}} title="News" isLive engineFactory={() => engine} />); + await act(async () => {}); + tick(10); + press("ArrowRight"); + press("Enter"); + expect(screen.getByRole("dialog", { name: "Audio & Subtitles" })).toBeDefined(); + expect(screen.queryByText("Stream Format")).toBeNull(); + }); }); it("shows an error for a fatal failure (not a recovering one); Try Again reloads from where it got to", async () => { diff --git a/src/screens/PlayerScreen.tsx b/src/screens/PlayerScreen.tsx index 1b08243..caff66e 100644 --- a/src/screens/PlayerScreen.tsx +++ b/src/screens/PlayerScreen.tsx @@ -17,8 +17,8 @@ import { import { ArrowLeft, Play, RotateCcw, TriangleAlert } from "lucide-react"; import { isFavorite, toggleFavorite, upsertContinueWatching, type ResumePoint } from "../profile-store.js"; import { loadSettings } from "../settings-store.js"; -import { alternateLiveStream, LIVE_STREAM_FORMAT_LABELS } from "../live-stream-url.js"; -import { liveStreamFormatOf } from "@core"; +import { alternateLiveStream, LIVE_STREAM_FORMAT_LABELS, rememberWorkingLiveStreamFormat } from "../live-stream-url.js"; +import { liveStreamFormatOf, withLiveStreamFormat, type LiveStreamFormat } from "@core"; import { useFavoritesRevision } from "../use-favorites-revision.js"; import type { ChannelLineup } from "../channel-lineup.js"; import { useNowNext } from "../use-now-next.js"; @@ -71,11 +71,11 @@ export interface PlayerScreenProps { /** What's playing, for Recently Watched (omitted for catch-up). */ watchTarget?: WatchTarget; /** - * Plays the same Xtream live channel in the other format (HLS ↔ MPEG-TS) - * — offered on the error screen when this stream fails. Omitted: no - * alternative is offered. + * Live TV: the playlist the channel belongs to. On the Auto live stream + * format, the format a channel starts in is remembered for it (see + * live-stream-url.ts), so the next channel starts in it too. */ - onPlayAlternateStream?: (streamUrl: string) => void; + liveSourceId?: string; /** Test seam — see VideoSurface. */ engineFactory?: () => PlayerEngine; } @@ -92,6 +92,16 @@ const SEEK_COMMIT_MS = 800; /** Buffering this long without recovering (a dead link, or a stream that stopped) is shown as an error. */ const STALL_TIMEOUT_MS = 30_000; +/** Auto live format: a channel that hasn't started after this long is tried in the other format (HLS ↔ MPEG-TS). */ +const FORMAT_SWITCH_AFTER_MS = 12_000; + +/** A live channel that was playing and stalls this long is reconnected (in the same format)… */ +const LIVE_RECONNECT_STALL_MS = 15_000; +/** …up to this many times in a row before the error is shown. */ +const MAX_LIVE_RECONNECTS = 2; +/** Playing this long since the last reconnect makes the next drop a fresh one, with its reconnects available again. */ +const RECONNECT_RESET_MS = 60_000; + /** How long the channel banner stays up after changing channel. */ const CHANNEL_BANNER_MS = 5000; @@ -117,7 +127,6 @@ const START_OVER_ID = "player-resume-start-over"; const ERROR_SCOPE = "player-error"; const RETRY_ID = "player-error-retry"; const ERROR_BACK_ID = "player-error-back"; -const ALTERNATE_STREAM_ID = "player-error-alternate"; /** Scrub step grows the longer Left/Right is held or tapped in a row: 10s, then 30s, then 60s. */ function seekStepFor(pressCount: number): number { @@ -166,6 +175,14 @@ function describeFailure(failure: PlaybackFailure, isLive: boolean): string { * engine error, or buffering for STALL_TIMEOUT_MS) it shows an error with * Try Again, which reloads from where playback got to. * + * Xtream live channels come as HLS or MPEG-TS. On the Auto format setting, + * a channel that fails before its first frame is quietly tried in the + * other format behind the loading screen, and the error only shows if that + * fails too. A channel that drops after it was playing is reconnected in + * the same format (MAX_LIVE_RECONNECTS times) — a drop mid-stream is + * usually the network, not the format. The Audio & Subtitles menu also + * lets the viewer switch format by hand. + * * No on-screen volume control: TV playback uses the TV's own volume. */ export function PlayerScreen({ @@ -178,7 +195,7 @@ export function PlayerScreen({ onNextEpisode, upNextEpisode, isLive = false, - onPlayAlternateStream, + liveSourceId, liveChannel, guideSource, channelLineup, @@ -217,13 +234,24 @@ export function PlayerScreen({ const startPositionSeconds = startAt?.streamUrl === streamUrl ? startAt.seconds : autoResume && resumeFrom ? resumeFrom.positionSeconds : undefined; const [attempt, setAttempt] = useState(0); - // An Xtream live channel can also be played in the other format — offered if this one fails. - const alternateStream = useMemo(() => alternateLiveStream(streamUrl), [streamUrl]); - const currentFormat = liveStreamFormatOf(streamUrl); + // An Xtream live channel playing in the other format than it was opened + // in (Auto's fallback, or the viewer's pick) — tied to the URL it + // replaces, so a channel change drops it. + const [formatSwitch, setFormatSwitch] = useState<{ from: string; url: string } | null>(null); + const activeUrl = formatSwitch?.from === streamUrl ? formatSwitch.url : streamUrl; + const currentFormat = liveStreamFormatOf(activeUrl); // App Settings → Playback, read for each stream (and each retry) so a change applies to the next thing played. // eslint-disable-next-line react-hooks/exhaustive-deps const playbackPrefs = useMemo(() => loadSettings(), [streamUrl, attempt]); + // Auto may switch format once per channel open (a manual pick counts as the switch). + const autoAlternate = + isLive && playbackPrefs.liveStreamFormat === "auto" && formatSwitch?.from !== streamUrl ? alternateLiveStream(activeUrl) : undefined; const [failure, setFailure] = useState(null); + // The URL that has shown a frame since this channel opened: a failure + // before that is a startup failure (try the other format), after it a + // drop (reconnect in the same one). + const startedUrlRef = useRef(null); + const reconnectsRef = useRef({ count: 0, lastAt: 0 }); const [isPlaying, setIsPlaying] = useState(true); const [positionSeconds, setPositionSeconds] = useState(0); @@ -336,6 +364,8 @@ export function PlayerScreen({ panelRef.current = panel; const stateRef = useRef({ isPlaying, isPanelOpen, areControlsVisible, positionSeconds, durationSeconds, pendingSeekSeconds, isShowingVideo, isPausedInfoShown, hasEpisodes }); stateRef.current = { isPlaying, isPanelOpen, areControlsVisible, positionSeconds, durationSeconds, pendingSeekSeconds, isShowingVideo, isPausedInfoShown, hasEpisodes }; + const streamRef = useRef({ activeUrl, autoAlternate, isAutoFormat: playbackPrefs.liveStreamFormat === "auto", liveSourceId }); + streamRef.current = { activeUrl, autoAlternate, isAutoFormat: playbackPrefs.liveStreamFormat === "auto", liveSourceId }; const hideTimerRef = useRef | null>(null); const seekTimerRef = useRef | null>(null); @@ -404,29 +434,87 @@ export function PlayerScreen({ [onNextEpisode, showControls], ); - // Stable identity: VideoSurface reloads the stream when onError changes. - const handleError = useCallback( - (error: PlayerError) => { - if (!error.fatal) return; // the engine is recovering; buffering covers it + /** A fresh engine on the current (or a switched-to) URL, behind the loading screen or banner. */ + const reloadStream = useCallback(() => { + clearStallTimer(); + setFailure(null); + setHasStarted(false); + sawBufferingRef.current = false; + setAttempt((n) => n + 1); + }, [clearStallTimer]); + + /** + * A fatal error or a stall. Live channels get two chances before the + * error shows: before the first frame, Auto tries the other format; after + * it, the same stream is reconnected a couple of times. + */ + const handleFailure = useCallback( + (failed: PlaybackFailure) => { clearStallTimer(); - setFailure({ kind: error.kind }); + const { activeUrl: url, autoAlternate: alternate } = streamRef.current; + const hasPlayed = startedUrlRef.current === url; + if (!hasPlayed && alternate) { + setFormatSwitch({ from: streamUrl, url: alternate.url }); + reloadStream(); + return; + } + if (hasPlayed && isLive) { + const reconnects = reconnectsRef.current; + const now = Date.now(); + if (now - reconnects.lastAt > RECONNECT_RESET_MS) reconnects.count = 0; + if (reconnects.count < MAX_LIVE_RECONNECTS) { + reconnects.count += 1; + reconnects.lastAt = now; + reloadStream(); + return; + } + } + setFailure(failed); }, - [clearStallTimer], + [clearStallTimer, reloadStream, streamUrl, isLive], ); + const handleFailureRef = useRef(handleFailure); + handleFailureRef.current = handleFailure; + + // Stable identity: VideoSurface reloads the stream when onError changes. + const handleError = useCallback((error: PlayerError) => { + if (!error.fatal) return; // the engine is recovering; buffering covers it + handleFailureRef.current({ kind: error.kind }); + }, []); const handleBufferingChange = useCallback( (isBuffering: boolean) => { clearStallTimer(); if (isBuffering) { sawBufferingRef.current = true; - stallTimerRef.current = setTimeout(() => setFailure({ kind: "network", stalled: true }), STALL_TIMEOUT_MS); + // A channel not yet started is given less time when Auto has another format to try; a dropped one is reconnected sooner. + const { activeUrl: url, autoAlternate: alternate } = streamRef.current; + const hasPlayed = startedUrlRef.current === url; + const timeout = !hasPlayed && alternate ? FORMAT_SWITCH_AFTER_MS : hasPlayed && isLive ? LIVE_RECONNECT_STALL_MS : STALL_TIMEOUT_MS; + stallTimerRef.current = setTimeout(() => handleFailureRef.current({ kind: "network", stalled: true }), timeout); } else if (sawBufferingRef.current) { setHasStarted(true); // the first load finished — the stream is playing + // A live channel that starts on Auto: remember its format for the playlist, so the next channel starts in it. + const { activeUrl: url, isAutoFormat, liveSourceId: sourceId } = streamRef.current; + startedUrlRef.current = url; + const format = liveStreamFormatOf(url); + if (isLive && isAutoFormat && format && sourceId) rememberWorkingLiveStreamFormat(sourceId, format); } }, - [clearStallTimer], + [clearStallTimer, isLive], ); + /** Stream Format in the menu: the same channel in the chosen format, now. */ + function selectStreamFormat(format: LiveStreamFormat): void { + setPanel("none"); + if (format === currentFormat) return; + const url = withLiveStreamFormat(streamUrl, format); + if (!url) return; + setFormatSwitch(url === streamUrl ? null : { from: streamUrl, url }); + reloadStream(); + showControls(); + } + // A fresh stream (channel/episode change) starts with no known tracks // until the new engine reports in — reset so the Audio & Subtitles panel // doesn't briefly show the previous stream's tracks. @@ -440,6 +528,8 @@ export function PlayerScreen({ setPanel("none"); setHasStarted(false); sawBufferingRef.current = false; + startedUrlRef.current = null; + reconnectsRef.current = { count: 0, lastAt: 0 }; if (seekTimerRef.current) clearTimeout(seekTimerRef.current); }, [streamUrl]); @@ -455,10 +545,11 @@ export function PlayerScreen({ const { positionSeconds: position } = stateRef.current; const from = isLive ? undefined : position > 0 ? position : startPositionSeconds; setStartAt(from !== undefined ? { streamUrl, seconds: from } : null); - setFailure(null); - setHasStarted(false); - sawBufferingRef.current = false; - setAttempt((n) => n + 1); + // A fresh start: the channel's first format again, with Auto's switch and the reconnects available. + setFormatSwitch(null); + startedUrlRef.current = null; + reconnectsRef.current = { count: 0, lastAt: 0 }; + reloadStream(); showControls(); } @@ -711,7 +802,7 @@ export function PlayerScreen({ {failure === null && ( onNextEpisode?.()} + streamFormat={ + isLive && currentFormat + ? { + options: (["m3u8", "ts"] as const).map((format) => ({ id: format, label: LIVE_STREAM_FORMAT_LABELS[format] })), + activeId: currentFormat, + onSelect: (id) => selectStreamFormat(id as LiveStreamFormat), + } + : undefined + } /> {panel === "episodes" && episodes && ( onPlayAlternateStream(alternateStream.url) : undefined} onBack={onClose} /> )} @@ -868,51 +966,24 @@ function ResumeChoice({ ); } -/** - * A failed stream. For an Xtream live channel it also offers the same - * channel in the other format (HLS ↔ MPEG-TS) — first and focused, since - * retrying the format that just failed usually fails the same way, and - * which one a panel serves reliably varies. - */ +/** Shown under a live channel's error when the format is fixed in App Settings — Auto would have tried the other one. */ +const LIVE_FORMAT_HINT = "Your Live Stream Format is fixed in App Settings. Set it to Auto to try the other format automatically."; + +/** A failed stream: Try Again (focused) or Back. */ function PlaybackError({ title, message, - alternate, - currentFormatLabel, + hint, onRetry, - onAlternate, onBack, }: { title?: string; message: string; - alternate?: { label: string }; - currentFormatLabel?: string; + hint?: string; onRetry: () => void; - onAlternate?: () => void; onBack: () => void; }): JSX.Element { - const setGraph = useFocusStore((state) => state.setGraph); - const clearGraph = useFocusStore((state) => state.clearGraph); - const focus = useFocusStore((state) => state.focus); - const latestRef = useRef({ onRetry, onAlternate, onBack }); - latestRef.current = { onRetry, onAlternate, onBack }; - const hasAlternate = Boolean(alternate && onAlternate); - - useEffect(() => { - const ids = [...(hasAlternate ? [ALTERNATE_STREAM_ID] : []), RETRY_ID, ERROR_BACK_ID]; - const actions: Record void> = { - [ALTERNATE_STREAM_ID]: () => latestRef.current.onAlternate?.(), - [RETRY_ID]: () => latestRef.current.onRetry(), - [ERROR_BACK_ID]: () => latestRef.current.onBack(), - }; - setGraph( - ERROR_SCOPE, - ids.map((id, index) => ({ id, neighbors: { left: ids[index - 1], right: ids[index + 1] }, onSelect: actions[id] })), - ); - // Other scopes (the controls) may still hold focus while unmounting. - focus(ids[0]); - return () => clearGraph(ERROR_SCOPE); - }, [hasAlternate, setGraph, clearGraph, focus]); + useTwoButtonGraph(ERROR_SCOPE, RETRY_ID, ERROR_BACK_ID, onRetry, onBack); return ( @@ -920,14 +991,9 @@ function PlaybackError({

Can't play {title ? `“${title}”` : "this"}

{message}

- {hasAlternate && ( -

- This channel was playing as {currentFormatLabel}. Some providers stream more reliably as {alternate!.label} — you can set the default in App Settings. -

- )} + {hint &&

{hint}

}
- {hasAlternate && } - +
diff --git a/src/screens/PlaylistCards.test.tsx b/src/screens/PlaylistCards.test.tsx index 62f9f51..580f08a 100644 --- a/src/screens/PlaylistCards.test.tsx +++ b/src/screens/PlaylistCards.test.tsx @@ -120,7 +120,7 @@ describe("PlaylistCards", () => { useSyncStore.getState().beginRun("a", "manual"); useSyncStore.getState().setStage("a", "vod", { status: "running", done: 1200 }); }); - expect(screen.getByText(`Syncing Movies… ${(1200).toLocaleString()}`)).toBeDefined(); + expect(screen.getByText("Sync in progress…")).toBeDefined(); await act(async () => { useSyncStore.getState().setStage("a", "vod", { status: "synced", count: 1200 }); diff --git a/src/screens/PlaylistCards.tsx b/src/screens/PlaylistCards.tsx index 2c5825f..f39e3cd 100644 --- a/src/screens/PlaylistCards.tsx +++ b/src/screens/PlaylistCards.tsx @@ -344,7 +344,7 @@ function PlaylistCard({ // What's stored and how fresh — or, while this playlist syncs, its progress. const syncState = useSourceSyncState(source.id); const summary = useSyncSummary(source); - const running = describeRunningSync(syncState, source); + const running = describeRunningSync(syncState); const counts = formatCounts(summary); const dim = isFocused ? "rgba(235,236,242,0.8)" : "rgba(235,236,242,0.6)"; diff --git a/src/screens/PlaylistPicker.tsx b/src/screens/PlaylistPicker.tsx index d1ea5b7..9b79422 100644 --- a/src/screens/PlaylistPicker.tsx +++ b/src/screens/PlaylistPicker.tsx @@ -139,7 +139,7 @@ function PlaylistOption({ source, isActive, onSelect }: { source: PlaylistSource const loadInfo = () => loadPlaylistInfo(source); const { data: info } = useCachedContent(`playlist-info:${source.id}`, "playlist-info", loadInfo, EMPTY_PLAYLIST_INFO, { enabled: false }); - const running = describeRunningSync(syncState, source); + const running = describeRunningSync(syncState); const expired = info.expiresAt !== null && info.expiresAt.getTime() < Date.now(); const counts = formatCounts(summary); const status = expired diff --git a/src/screens/SearchScreen.test.tsx b/src/screens/SearchScreen.test.tsx new file mode 100644 index 0000000..f26669b --- /dev/null +++ b/src/screens/SearchScreen.test.tsx @@ -0,0 +1,300 @@ +import { act, fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { PlaylistSource, Profile } from "@core"; +import { clearAllCachedContent } from "../content-cache.js"; +import { __clearCatalogDbForTests, __resetCatalogDbForTests, openCatalogDb, putRecordsBatch, putSyncMeta, type CatalogRecord } from "../core/storage/catalog-db.js"; +import { __clearLiveDbForTests, __resetLiveDbForTests, openLiveDb, putChannels, putLiveSyncMeta } from "../core/storage/live-db.js"; +import { __resetLiveStoreForTests } from "../live-store.js"; +import { recordWatchHistory } from "../profile-store.js"; +import { loadRecentSearches } from "../search/recent-searches.js"; +import { useFocusStore } from "../ui/focus/focus-store.js"; +import { runIndexJobs } from "../search/search-indexer.js"; +import { __resetSearchMemoryForTests, SearchScreen } from "./SearchScreen.js"; + +vi.mock("../content-loader.js", () => ({ + loadVodCategories: vi.fn().mockResolvedValue([ + { id: "cat-1", name: "Action", kind: "movie" }, + { id: "cat-kids", name: "Kids Movies", kind: "movie" }, + ]), + loadSeriesCategories: vi.fn().mockResolvedValue([{ id: "cat-s", name: "Drama", kind: "series" }]), + loadLiveCategories: vi.fn().mockResolvedValue([]), +})); +// A playlist with no live list asks the sync manager for one; these tests cover what Search shows, not syncing. +vi.mock("../sync/sync-manager.js", () => ({ syncSource: vi.fn().mockResolvedValue({ stages: {}, errors: {} }) })); +const nowNext = vi.hoisted(() => ({ byChannel: new Map() })); +vi.mock("../use-now-next.js", () => ({ + useNowNext: (_source: unknown, channel: { id: string } | null) => { + const title = channel ? nowNext.byChannel.get(channel.id) : undefined; + return { nowNext: title ? { now: { title } } : null, isLoading: false }; + }, +})); + +const source: PlaylistSource = { kind: "xtream", id: "src-1", name: "My Source", baseUrl: "http://example.com", username: "u", password: "p" }; +const profile: Profile = { id: "profile-1", name: "Alex", avatarUrl: "avatar/toon_1.png" }; + +function record(kind: "vod" | "series", streamId: string, name: string, groupTitle: string): CatalogRecord { + return { id: `${source.id}:${streamId}`, sourceId: source.id, streamId, name, nameLower: name.toLowerCase(), groupTitle, streamUrl: kind === "vod" ? "x" : undefined, generation: 1 }; +} + +async function seed(movies: CatalogRecord[], series: CatalogRecord[]): Promise { + const catalogDb = await openCatalogDb(); + await putRecordsBatch(catalogDb, "vod", movies); + await putRecordsBatch(catalogDb, "series", series); + await putSyncMeta(catalogDb, { key: `vod:${source.id}`, lastSyncedAt: Date.now(), recordCount: movies.length, generation: 1 }); + await putSyncMeta(catalogDb, { key: `series:${source.id}`, lastSyncedAt: Date.now(), recordCount: series.length, generation: 1 }); +} + +async function seedChannels(names: string[]): Promise { + const liveDb = await openLiveDb(); + await putChannels( + liveDb, + names.map((name, position) => ({ id: `ch-${position}`, name, streamUrl: `http://x/${position}`, kind: "live" as const, sourceId: source.id, position, generation: 1 })), + ); + await putLiveSyncMeta(liveDb, { sourceId: source.id, lastSyncedAt: Date.now(), generation: 1, channelCount: names.length }); +} + +function press(key: string): void { + act(() => { + fireEvent.keyDown(document, { key }); + fireEvent.keyUp(document, { key }); + }); +} +/** Types on a physical keyboard, which goes straight into the query. */ +function type(text: string): void { + for (const key of text) press(key); +} +const focusedId = () => useFocusStore.getState().focusedId; + +function renderSearch(overrides: Partial[0]> = {}) { + const props = { source, platform: "web" as const, profile, onPlayMovie: vi.fn(), onPlayChannel: vi.fn(), onOpenSeries: vi.fn(), onContinueSeries: vi.fn(), onBack: vi.fn(), ...overrides }; + return { ...render(), props }; +} + +beforeEach(async () => { + Element.prototype.scrollIntoView = () => {}; + localStorage.clear(); + clearAllCachedContent(); + __resetSearchMemoryForTests(); + __resetCatalogDbForTests(); + await __clearCatalogDbForTests(); + __resetLiveStoreForTests(); + __resetLiveDbForTests(); + await __clearLiveDbForTests(); + nowNext.byChannel.clear(); + for (const scope of ["content", "search-keyboard"]) useFocusStore.getState().clearGraph(scope); + act(() => useFocusStore.setState({ focusedId: null })); + vi.clearAllMocks(); +}); + +describe("SearchScreen", () => { + it("opens on the keyboard, says which playlist it searches, and prompts before anything is typed", async () => { + await seed([], []); + renderSearch(); + await vi.waitFor(() => expect(focusedId()).toBe("kb:a")); + expect(screen.getByText("Searching “My Source”")).toBeTruthy(); + expect(screen.getByText("Search channels, movies and series by title.")).toBeTruthy(); + }); + + it("searches Movies and Series across every category — one row each — as the user types", async () => { + await seed( + [record("vod", "1", "Matrix Reloaded", "cat-1"), record("vod", "2", "Inception", "cat-1")], + [record("series", "3", "Matrix Chronicles", "cat-s"), record("series", "4", "Breaking Bad", "cat-s")], + ); + renderSearch(); + await vi.waitFor(() => expect(focusedId()).toBe("kb:a")); + + type("m"); + expect(await screen.findByText("Keep typing…")).toBeTruthy(); // one letter doesn't search yet + type("at"); + + expect(await screen.findByText("Matrix Reloaded")).toBeTruthy(); + expect(screen.getByText("Matrix Chronicles")).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Movies" })).toBeTruthy(); + expect(screen.getByRole("heading", { name: "Series" })).toBeTruthy(); + expect(screen.queryByText("Inception")).toBeNull(); + expect(screen.queryByText("Breaking Bad")).toBeNull(); + }); + + it("types with the on-screen keyboard, and Right from its edge reaches the results", async () => { + await seed([record("vod", "1", "Up", "cat-1")], []); + renderSearch(); + await vi.waitFor(() => expect(focusedId()).toBe("kb:a")); + + act(() => useFocusStore.getState().focus("kb:u")); + press("Enter"); + act(() => useFocusStore.getState().focus("kb:p")); + press("Enter"); + expect(screen.getByRole("searchbox").textContent).toBe("up"); + await screen.findByText("Up"); + + act(() => useFocusStore.getState().focus("kb:r")); // right-hand column + press("ArrowRight"); + expect(focusedId()).toBe("search:vod:1"); + press("ArrowLeft"); + expect(focusedId()).toBe("kb:f"); + + act(() => useFocusStore.getState().focus("kb:delete")); + press("Enter"); + expect(screen.getByRole("searchbox").textContent).toBe("u"); + }); + + it("once the background index has run, any word of a title finds it, best match first", async () => { + await seed([record("vod", "1", "EN | The Matrix", "cat-1"), record("vod", "2", "Matrix Reloaded", "cat-1"), record("vod", "3", "Inception", "cat-1")], []); + await runIndexJobs(await openCatalogDb(), [{ sourceId: source.id, kind: "vod" }], { paceMs: 0, shouldStop: () => false, sleep: () => Promise.resolve() }); + renderSearch(); + type("matrix"); + await screen.findByText("EN | The Matrix"); + const titles = Array.from(document.querySelectorAll('[data-focus-id^="search:vod:"]')).map((el) => el.textContent); + expect(titles).toEqual(["EN | The Matrix", "Matrix Reloaded"]); + }); + + it("says so when nothing matches", async () => { + await seed([record("vod", "1", "Inception", "cat-1")], []); + renderSearch(); + type("xyz"); + expect(await screen.findByText("No matches for “xyz” in My Source. Check the spelling or try fewer letters.")).toBeTruthy(); + }); + + it("a movie plays; a series opens — and coming back restores the query and the focused result", async () => { + await seed([record("vod", "1", "Matrix Reloaded", "cat-1")], [record("series", "3", "Matrix Chronicles", "cat-s")]); + const { props, unmount } = renderSearch(); + type("mat"); + await screen.findByText("Matrix Chronicles"); + + act(() => useFocusStore.getState().focus("search:vod:1")); + press("Enter"); + expect(props.onPlayMovie).toHaveBeenCalledWith(expect.objectContaining({ id: "1", name: "Matrix Reloaded" })); + + act(() => useFocusStore.getState().focus("search:series:3")); + press("Enter"); + expect(props.onOpenSeries).toHaveBeenCalledWith("3"); + + unmount(); + renderSearch(); + expect(screen.getByRole("searchbox").textContent).toBe("mat"); + await screen.findByText("Matrix Chronicles"); + await vi.waitFor(() => expect(focusedId()).toBe("search:series:3")); + }); + + it("a row with more than 20 matches ends in See all, which opens every match; Back returns to the rows", async () => { + await seed( + Array.from({ length: 25 }, (_, i) => record("vod", String(100 + i), `Star ${i}`, "cat-1")), + [], + ); + const { props } = renderSearch(); + type("star"); + await screen.findByText("Star 24"); // equally good matches: newest first + expect(screen.queryByText("Star 0")).toBeNull(); // the 21st onwards are behind See all + expect(document.querySelectorAll('[data-focus-id^="search:vod:"]')).toHaveLength(20); + + act(() => useFocusStore.getState().focus("search:see-all:vod")); + press("Enter"); + expect(await screen.findByText("25 matches for “star”")).toBeTruthy(); + await vi.waitFor(() => expect(document.querySelectorAll('[data-focus-id^="search-grid:vod:"]')).toHaveLength(25)); + + press("Escape"); + await vi.waitFor(() => expect(focusedId()).toBe("search:see-all:vod")); + expect(props.onBack).not.toHaveBeenCalled(); + press("Escape"); + expect(props.onBack).toHaveBeenCalledTimes(1); + }); + + it("Backspace deletes a character while there's a query, and goes Back once it's empty", async () => { + await seed([], []); + const { props } = renderSearch(); + type("ab"); + press("Backspace"); + expect(screen.getByRole("searchbox").textContent).toBe("a"); + press("Backspace"); + expect(props.onBack).not.toHaveBeenCalled(); + press("Backspace"); + expect(props.onBack).toHaveBeenCalledTimes(1); + }); + + it("a Kids profile only finds what it's allowed to browse", async () => { + await seed( + [record("vod", "1", "Frozen", "cat-kids"), record("vod", "2", "Fright Night", "cat-1")], + [], + ); + renderSearch({ profile: { id: "kid-1", name: "Mia", avatarUrl: "avatar/toon_2.png", kind: "kids" } }); + type("fr"); + expect(await screen.findByText("Frozen")).toBeTruthy(); + expect(screen.queryByText("Fright Night")).toBeNull(); + }); + + it("leaves out what hasn't downloaded yet, and says so when nothing has", async () => { + renderSearch(); + expect(await screen.findByText("Your playlist is still downloading. You can search it once it's ready.")).toBeTruthy(); + }); + + it("finds live channels too — with what's on now — and a channel plays with the matches as its lineup", async () => { + await seed([], []); + await seedChannels(["BBC One", "Sky News", "BBC Two"]); + nowNext.byChannel.set("ch-0", "The News at Six"); + const { props } = renderSearch(); + type("bbc"); + + expect(await screen.findByText("BBC One")).toBeTruthy(); + expect(screen.getByText("BBC Two")).toBeTruthy(); + expect(screen.queryByText("Sky News")).toBeNull(); + expect(screen.getByRole("heading", { name: "Live TV" })).toBeTruthy(); + expect(screen.getByText("CH 1 · Now: The News at Six")).toBeTruthy(); + + act(() => useFocusStore.getState().focus("search:live:ch-2")); + press("Enter"); + const [channel, lineup] = (props.onPlayChannel as ReturnType).mock.calls[0]; + expect(channel).toMatchObject({ id: "ch-2", name: "BBC Two", number: 3 }); + expect(lineup.lineup.map((c: { name: string }) => c.name)).toEqual(["BBC One", "BBC Two"]); + expect(lineup.directory).toHaveLength(3); + }); + + it("orders the rows by their best match", async () => { + await seed([record("vod", "1", "News Of The World", "cat-1")], [record("series", "2", "Newsroom", "cat-s")]); + await seedChannels(["Sky News HD"]); + await runIndexJobs(await openCatalogDb(), [{ sourceId: source.id, kind: "live" }], { paceMs: 0, shouldStop: () => false, sleep: () => Promise.resolve() }); + renderSearch(); + type("news of"); + await screen.findByText("News Of The World"); + // Movies has the only title starting with the query; Series and Live TV don't match at all. + expect(screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent)).toEqual(["Movies"]); + + act(() => useFocusStore.getState().focus("kb:clear")); + press("Enter"); + type("news"); + await screen.findByText("Sky News HD"); // found by its second word, once indexed + // Movies and Series start with "news"; the channel only has it as a later word. + await vi.waitFor(() => expect(screen.getAllByRole("heading", { level: 2 }).map((h) => h.textContent)).toEqual(["Movies", "Series", "Live TV"])); + }); + + it("remembers what was searched once a result is opened, and offers it again before anything is typed", async () => { + await seed([record("vod", "1", "Matrix Reloaded", "cat-1")], []); + const first = renderSearch(); + type("mat"); + await screen.findByText("Matrix Reloaded"); + expect(loadRecentSearches(profile.id, source.id)).toEqual([]); // typing alone records nothing + + act(() => useFocusStore.getState().focus("search:vod:1")); + press("Enter"); + expect(loadRecentSearches(profile.id, source.id)).toEqual(["mat"]); + first.unmount(); + __resetSearchMemoryForTests(); + + renderSearch(); + expect(screen.getByRole("heading", { name: "Recent searches" })).toBeTruthy(); + act(() => useFocusStore.getState().focus("search:recent:0")); + press("Enter"); + expect(screen.getByRole("searchbox").textContent).toBe("mat"); + expect(await screen.findByText("Matrix Reloaded")).toBeTruthy(); + }); + + it("shows Recently Watched before anything is typed, and opens from it", async () => { + await seed([], []); + recordWatchHistory({ profileId: profile.id, sourceId: source.id, kind: "movie", contentId: "9", title: "Heat", streamUrl: "http://x/9", positionSeconds: 60, durationSeconds: 600 }); + const { props } = renderSearch(); + expect(await screen.findByRole("heading", { name: "Recently Watched" })).toBeTruthy(); + + act(() => useFocusStore.getState().focus("search:history:movie:9")); + press("Enter"); + expect(props.onPlayMovie).toHaveBeenCalledWith(expect.objectContaining({ id: "9", name: "Heat" }), { resume: true }); + }); +}); diff --git a/src/screens/SearchScreen.tsx b/src/screens/SearchScreen.tsx new file mode 100644 index 0000000..297bea1 --- /dev/null +++ b/src/screens/SearchScreen.tsx @@ -0,0 +1,562 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { Channel, PlatformId, PlaylistSource, Profile, SeriesInfo, WatchHistoryEntry } from "@core"; +import { Search } from "lucide-react"; +import { + FocusCard, + Focusable, + LoadingState, + MeshBackground, + SearchKeyboard, + SECTION_ICONS, + SeeAllCard, + Shelf, + buildGridFocusGraph, + buildShelfFocusGraph, + searchKeyId, + useFocusStore, + useIsFocused, + useRemoteInput, + BROWSE_GAP, + BROWSE_ROW_GAP, + TV_HEADING, + TV_TEXT, + type FocusNode, +} from "@ui"; +import { withChannelNumbers, type ChannelLineup } from "../channel-lineup.js"; +import { loadSeriesCategories, loadVodCategories } from "../content-loader.js"; +import { useContentPolicy } from "../content-policy.js"; +import { loadWatchHistory } from "../profile-store.js"; +import { loadRecentSearches, recordRecentSearch } from "../search/recent-searches.js"; +import { searchCatalog, searchChannels, type SearchCatalogResult } from "../search/search-index.js"; +import { useCachedContent } from "../use-cached-content.js"; +import { useSearchQuery } from "../use-debounced-value.js"; +import { useKidsAllowedKeys } from "../use-kids-allowed.js"; +import { useLocalCatalogReady } from "../use-local-catalog-ready.js"; +import { useNowNext } from "../use-now-next.js"; +import { usePolicyLiveChannels } from "../use-policy-live-channels.js"; +import { ChannelTile } from "./ListTiles.js"; + +export interface SearchScreenProps { + source: PlaylistSource; + platform: PlatformId; + profile: Profile; + onPlayMovie: (movie: Channel, options?: { resume?: boolean }) => void; + onPlayChannel: (channel: Channel, lineup: ChannelLineup) => void; + onOpenSeries: (seriesId: string) => void; + /** Recently Watched: carries on with a series' next episode. */ + onContinueSeries: (entry: WatchHistoryEntry) => void; + onBack: () => void; + /** True while PlayerScreen is open on top of this screen — see VodScreen's identical prop. */ + isPlaybackOpen?: boolean; +} + +type SeriesSummary = Pick; +/** The three kinds of result, each its own row. */ +type ResultKind = "live" | "vod" | "series"; +type ResultItem = Channel | SeriesSummary; + +interface ResultRow { + items: T[]; + hasMore: boolean; + bestScore: number; + isLoading: boolean; +} + +const ROW_LIMIT = 20; +/** A See all grid shows up to this many matches, best first. */ +const SEE_ALL_LIMIT = 200; +const POSTER_CARD_WIDTH = "12rem"; +const CHANNEL_TILE_WIDTH = "18rem"; +const KEYBOARD_PANEL_WIDTH = "30rem"; +/** + * Left inset of everything in the results column. A result row scrolls + * sideways, so it clips whatever sticks out of it — the inset is what gives + * the first card room to grow when it's focused (as BROWSE_CONTENT_LEFT + * does on the browse pages). + */ +const RESULTS_INSET = "2rem"; +const CONTENT_SCOPE = "content"; +const EMPTY_CATEGORIES: Awaited> = []; +const EMPTY_ROW: ResultRow = { items: [], hasMore: false, bestScore: 0, isLoading: false }; +/** Rows tied on their best match keep this order. */ +const KIND_ORDER: ResultKind[] = ["live", "vod", "series"]; +const KIND_TITLES: Record = { live: "Live TV", vod: "Movies", series: "Series" }; +const GRID_COLUMNS: Record = { live: 3, vod: 5, series: 5 }; +const RECENTLY_WATCHED_LIMIT = 20; + +const resultId = (kind: ResultKind, id: string) => `search:${kind}:${id}`; +const seeAllId = (kind: ResultKind) => `search:see-all:${kind}`; +const gridId = (kind: ResultKind, id: string) => `search-grid:${kind}:${id}`; +const recentId = (index: number) => `search:recent:${index}`; +const historyId = (entry: WatchHistoryEntry) => `search:history:${entry.kind}:${entry.contentId}`; + +function historyChannel(entry: WatchHistoryEntry): Channel { + return { id: entry.contentId, name: entry.title, streamUrl: entry.streamUrl ?? "", logoUrl: entry.imageUrl, kind: "live", number: entry.channelNumber }; +} + +/** + * What each profile last searched in each playlist, and the result it had + * focused — so returning from a series' detail page (or the player) puts + * the user back where they were. Module-level, like VodScreen's category + * memory: it outlives the screen, not an app restart. + */ +const searchMemory = new Map(); + +/** Test-only. */ +export function __resetSearchMemoryForTests(): void { + searchMemory.clear(); +} + +/** + * Global search: one place to find Live TV channels, Movies and Series in + * the active playlist, reached from Home's top bar, Kids Home's first tile, + * and the Search button on the Movies and Series pages (see + * docs/global-search-plan.md). + * + * An on-screen keyboard on the left, results on the right updating as the + * user types — one row per kind, the row with the best match first, each + * ending in a See all card that opens that kind's full result grid. Before + * anything is typed: this profile's recent searches in this playlist, and + * its Recently Watched. Reads only local data; a kind that isn't + * downloaded yet is left out rather than fetched. + */ +export function SearchScreen({ source, platform, profile, onPlayMovie, onPlayChannel, onOpenSeries, onContinueSeries, onBack, isPlaybackOpen = false }: SearchScreenProps): JSX.Element { + const setGraph = useFocusStore((state) => state.setGraph); + const clearGraph = useFocusStore((state) => state.clearGraph); + const memoryKey = `${profile.id}:${source.id}`; + const [query, setQuery] = useState(() => searchMemory.get(memoryKey)?.query ?? ""); + const returnFocusIdRef = useRef(searchMemory.get(memoryKey)?.focusId ?? null); + const activeQuery = useSearchQuery(query); + const [seeAll, setSeeAll] = useState(null); + + useEffect(() => { + const previous = searchMemory.get(memoryKey); + searchMemory.set(memoryKey, { query, focusId: previous?.focusId ?? null }); + }, [memoryKey, query]); + + // Kids profiles only find what they're allowed to browse (docs/kids-profile.md §6). + const policy = useContentPolicy(profile, source.id); + const hasSeriesApi = source.kind === "xtream"; + const loadMovieCategories = useCallback(() => loadVodCategories(source), [source]); + const loadShowCategories = useCallback(() => loadSeriesCategories(source), [source]); + const { data: movieCategories } = useCachedContent(`vod-categories:${source.id}`, "category", loadMovieCategories, EMPTY_CATEGORIES); + const { data: seriesCategories } = useCachedContent(`series-categories:${source.id}`, "category", loadShowCategories, EMPTY_CATEGORIES, { + enabled: hasSeriesApi, + }); + const movieFilter = useMemo(() => policy.catalogFilter("vod", movieCategories), [policy, movieCategories]); + const seriesFilter = useMemo(() => policy.catalogFilter("series", seriesCategories), [policy, seriesCategories]); + const { channels: allowedChannels } = usePolicyLiveChannels(source, policy); + const channels = useMemo(() => withChannelNumbers(allowedChannels), [allowedChannels]); + + const moviesStatus = useLocalCatalogReady(source.id, "vod"); + const seriesStatus = useLocalCatalogReady(source.id, "series"); + const canSearch: Record = { + live: channels.length > 0, + vod: moviesStatus === "ready", + series: hasSeriesApi && seriesStatus === "ready", + }; + const isCheckingTables = moviesStatus === "checking" || (hasSeriesApi && seriesStatus === "checking"); + + // Result rows, and the See all grid (the same search with a higher limit). + const runSearch = useCallback( + (kind: ResultKind, limit: number): Promise> => { + if (kind === "live") return searchChannels(source.id, channels, activeQuery, { limit }); + if (kind === "vod") return searchCatalog(source.id, "vod", activeQuery, { limit, filter: movieFilter }); + return searchCatalog(source.id, "series", activeQuery, { limit, filter: seriesFilter }); + }, + [source.id, channels, activeQuery, movieFilter, seriesFilter], + ); + const filterKey = `${movieFilter?.key ?? ""}|${seriesFilter?.key ?? ""}|${channels.length}`; + const live = useResult(runSearch, "live", ROW_LIMIT, canSearch.live && seeAll === null, activeQuery, filterKey); + const movies = useResult(runSearch, "vod", ROW_LIMIT, canSearch.vod && seeAll === null, activeQuery, filterKey); + const series = useResult(runSearch, "series", ROW_LIMIT, canSearch.series && seeAll === null, activeQuery, filterKey); + const grid = useResult(runSearch, seeAll ?? "vod", SEE_ALL_LIMIT, seeAll !== null, activeQuery, filterKey); + + // A new query always starts from the result rows. + useEffect(() => setSeeAll(null), [activeQuery]); + + // Before anything is typed: recent searches, and Recently Watched (Kids-filtered, like the Recently Watched page). + const recentSearches = useMemo(() => (activeQuery ? [] : loadRecentSearches(profile.id, source.id)), [profile.id, source.id, activeQuery]); + const fullHistory = useMemo(() => loadWatchHistory(profile.id, source.id), [profile.id, source.id]); + const savedItems = useMemo(() => fullHistory.map((e) => ({ kind: e.kind, id: e.contentId })), [fullHistory]); + const allowedHistoryKeys = useKidsAllowedKeys(source, policy, savedItems); + const recentlyWatched = useMemo( + () => (allowedHistoryKeys ? fullHistory.filter((e) => allowedHistoryKeys.has(`${e.kind}:${e.contentId}`)) : fullHistory).slice(0, RECENTLY_WATCHED_LIMIT), + [fullHistory, allowedHistoryKeys], + ); + + // Opening anything: remember the query and the focused result for coming back, and the query as a recent search. + const beforeOpening = useCallback(() => { + searchMemory.set(memoryKey, { query, focusId: useFocusStore.getState().focusedId }); + if (activeQuery) recordRecentSearch(profile.id, source.id, activeQuery); + }, [memoryKey, query, activeQuery, profile.id, source.id]); + const directory = channels; + const openResult = useCallback( + (kind: ResultKind, item: ResultItem, lineup: Channel[]) => { + beforeOpening(); + if (kind === "live") onPlayChannel(item as Channel, { lineup, directory }); + else if (kind === "vod") onPlayMovie(item as Channel); + else onOpenSeries(item.id); + }, + [beforeOpening, onPlayChannel, onPlayMovie, onOpenSeries, directory], + ); + const openHistory = useCallback( + (entry: WatchHistoryEntry) => { + beforeOpening(); + if (entry.kind === "live") { + const recentChannels = recentlyWatched.filter((e) => e.kind === "live").map(historyChannel); + onPlayChannel(historyChannel(entry), { lineup: recentChannels, directory: recentChannels }); + } else if (entry.kind === "movie") { + onPlayMovie({ id: entry.contentId, name: entry.title, streamUrl: entry.streamUrl ?? "", logoUrl: entry.imageUrl, kind: "movie" }, { resume: !entry.finished }); + } else if (entry.finished) { + onOpenSeries(entry.contentId); + } else { + onContinueSeries(entry); + } + }, + [beforeOpening, recentlyWatched, onPlayChannel, onPlayMovie, onOpenSeries, onContinueSeries], + ); + + const typeCharacter = useCallback((character: string) => setQuery((current) => (current.length === 0 && character === " " ? current : current + character)), []); + const deleteCharacter = useCallback(() => setQuery((current) => current.slice(0, -1)), []); + const clearQuery = useCallback(() => setQuery(""), []); + + // Rows with matches, the best match first; ties keep Live TV, Movies, Series. + const rows = useMemo(() => { + const byKind: Record> = { live, vod: movies, series }; + return KIND_ORDER.filter((kind) => byKind[kind].items.length > 0) + .map((kind) => ({ kind, ...byKind[kind] })) + .sort((a, b) => b.bestScore - a.bestScore || KIND_ORDER.indexOf(a.kind) - KIND_ORDER.indexOf(b.kind)); + }, [live, movies, series]); + + const showEmptyState = activeQuery.length === 0 && seeAll === null; + const gridIds = useMemo(() => (seeAll ? grid.items.map((item) => gridId(seeAll, item.id)) : []), [grid, seeAll]); + + // The focusable content right of the keyboard, as rows of ids with what each does. + const contentRows = useMemo((): Array void }>> => { + if (seeAll) { + const columns = GRID_COLUMNS[seeAll]; + const cells = grid.items.map((item) => ({ id: gridId(seeAll, item.id), onSelect: () => openResult(seeAll, item, grid.items as Channel[]) })); + return Array.from({ length: Math.ceil(cells.length / columns) }, (_, row) => cells.slice(row * columns, (row + 1) * columns)); + } + if (showEmptyState) { + return [ + recentSearches.map((term, index) => ({ id: recentId(index), onSelect: () => setQuery(term) })), + recentlyWatched.map((entry) => ({ id: historyId(entry), onSelect: () => openHistory(entry) })), + ].filter((row) => row.length > 0); + } + return rows.map((row) => [ + ...row.items.map((item) => ({ id: resultId(row.kind, item.id), onSelect: () => openResult(row.kind, item, row.items as Channel[]) })), + ...(row.hasMore ? [{ id: seeAllId(row.kind), onSelect: () => setSeeAll(row.kind) }] : []), + ]); + }, [seeAll, grid, showEmptyState, recentSearches, recentlyWatched, rows, openResult, openHistory]); + const firstContentId = contentRows[0]?.[0]?.id; + + // Focus graph right of the keyboard: Left from each row's first item goes back to the keyboard. + useEffect(() => { + const keyboardEdge = (row: number) => searchKeyId("abcdefghijklmnopqrstuvwxyz0123456789"[Math.min(row, 5) * 6 + 5]); + const actions = new Map(contentRows.flat().map((cell) => [cell.id, cell.onSelect])); + const base: FocusNode[] = seeAll ? buildGridFocusGraph(gridIds, GRID_COLUMNS[seeAll]) : buildShelfFocusGraph(contentRows.map((row) => row.map((cell) => cell.id))); + const rowOf = new Map(contentRows.flatMap((row, index) => row.map((cell) => [cell.id, index] as const))); + const rowStarts = new Set(contentRows.map((row) => row[0]?.id)); + const nodes = base.map((node) => ({ + ...node, + neighbors: { ...node.neighbors, left: rowStarts.has(node.id) ? keyboardEdge(rowOf.get(node.id) ?? 0) : node.neighbors.left }, + onSelect: actions.get(node.id), + })); + setGraph(CONTENT_SCOPE, nodes); + + const { focusedId, nodes: registered } = useFocusStore.getState(); + const returnTo = returnFocusIdRef.current; + if (returnTo && registered[returnTo]) { + returnFocusIdRef.current = null; + useFocusStore.getState().focus(returnTo); + } else if (focusedId === null || !registered[focusedId]) { + // Opening the screen, or the focused result just dropped out of the results: back to the keyboard. + useFocusStore.getState().focus(searchKeyId("a")); + } + }, [contentRows, seeAll, gridIds, setGraph]); + useEffect(() => () => clearGraph(CONTENT_SCOPE), [clearGraph]); + + // A physical keyboard (a desktop browser, or one paired with the TV) types straight into the query. + useEffect(() => { + if (isPlaybackOpen) return; + function onKeyDown(event: KeyboardEvent): void { + if (event.ctrlKey || event.metaKey || event.altKey) return; + if (event.key === "Backspace") { + if (query.length === 0) return; // an empty query: Backspace goes Back as usual + event.preventDefault(); + event.stopImmediatePropagation(); + deleteCharacter(); + return; + } + if (/^[a-z0-9 ]$/i.test(event.key)) { + event.preventDefault(); + typeCharacter(event.key.toLowerCase()); + } + } + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [isPlaybackOpen, query, deleteCharacter, typeCharacter]); + + useRemoteInput( + platform, + { + onBack: () => { + if (seeAll) { + const kind = seeAll; + setSeeAll(null); + returnFocusIdRef.current = seeAllId(kind); + return; + } + searchMemory.set(memoryKey, { query, focusId: null }); + onBack(); + }, + }, + !isPlaybackOpen, + ); + + const isWaitingForResults = activeQuery.length > 0 && (live.isLoading || movies.isLoading || series.isLoading) && rows.length === 0; + const nothingSearchable = !isCheckingTables && !canSearch.live && !canSearch.vod && !canSearch.series; + const searchableKinds = hasSeriesApi ? "channels, movies and series" : "channels and movies"; + + let results: JSX.Element; + if (seeAll) { + results = ; + } else if (nothingSearchable) { + results = Your playlist is still downloading. You can search it once it's ready.; + } else if (activeQuery.length === 0 && query.trim().length > 0) { + results = Keep typing…; + } else if (showEmptyState) { + results = ( + <> + {`Search ${searchableKinds} by title.`} + {recentSearches.length > 0 && ( +
+

Recent searches

+
+ {recentSearches.map((term, index) => ( + setQuery(term)} /> + ))} +
+
+ )} + {recentlyWatched.length > 0 && ( +
+ + entry.kind === "live" ? ( + openHistory(entry)} /> + ) : ( + openHistory(entry)} + /> + ) + } + /> +
+ )} + + ); + } else if (isCheckingTables || isWaitingForResults) { + results = ; + } else if (rows.length === 0) { + results = {`No matches for “${activeQuery}” in ${source.name}. Check the spelling or try fewer letters.`}; + } else { + results = ( + <> + {rows.map((row) => ( + resultId(row.kind, item.id)} + leftInset={RESULTS_INSET} + trailing={ + row.hasMore ? ( + setSeeAll(row.kind)} + /> + ) : undefined + } + trailingId={row.hasMore ? seeAllId(row.kind) : undefined} + renderItem={(item) => openResult(row.kind, item, row.items as Channel[])} />} + /> + ))} + + ); + } + + return ( + + {/* The gap plus RESULTS_INSET is the space between the keyboard and the results. */} +
+ + +
+

Searching “{source.name}”

+ {results} +
+
+
+ ); +} + +/** One kind's matches for the query (a row, or with a higher limit a See all grid) — see search-index.ts. */ +function useResult( + run: (kind: ResultKind, limit: number) => Promise>, + kind: ResultKind, + limit: number, + enabled: boolean, + query: string, + filterKey: string, +): ResultRow { + const [row, setRow] = useState>(EMPTY_ROW); + const runRef = useRef(run); + runRef.current = run; + + useEffect(() => { + if (!enabled || query.length === 0) { + setRow(EMPTY_ROW); + return; + } + let cancelled = false; + setRow((current) => ({ ...current, isLoading: true })); + runRef + .current(kind, limit) + .then(({ items, hasMore, bestScore }) => { + if (!cancelled) setRow({ items, hasMore, bestScore, isLoading: false }); + }) + .catch(() => { + if (!cancelled) setRow(EMPTY_ROW); + }); + return () => { + cancelled = true; + }; + }, [kind, limit, enabled, query, filterKey]); + + return row; +} + +/** A result: a channel tile with what's on now, or a movie or series poster. */ +function ResultCard({ source, kind, id, item, onSelect, fill = false }: { source: PlaylistSource; kind: ResultKind; id: string; item: ResultItem; onSelect: () => void; fill?: boolean }): JSX.Element { + if (kind === "live") return ; + return ( + + ); +} + +function LiveResultTile({ source, id, channel, onSelect, width }: { source: PlaylistSource; id: string; channel: Channel; onSelect: () => void; width: string }): JSX.Element { + const { nowNext } = useNowNext(source, channel); + const number = channel.number !== undefined ? `CH ${channel.number}` : null; + const now = nowNext?.now?.title ? `Now: ${nowNext.now.title}` : null; + return ; +} + +function RecentSearchChip({ id, term, onSelect }: { id: string; term: string; onSelect: () => void }): JSX.Element { + const isFocused = useIsFocused(id); + return ( + + + + ); +} + +function Message({ children }: { children: React.ReactNode }): JSX.Element { + return

{children}

; +} + +/** Every match of one kind (the best SEE_ALL_LIMIT) — what a row's See all card opens. */ +function SeeAllGrid({ source, kind, query, items, hasMore, isLoading }: { source: PlaylistSource; kind: ResultKind; query: string; items: ResultItem[]; hasMore: boolean; isLoading: boolean }): JSX.Element { + return ( +
+

{KIND_TITLES[kind]}

+

+ {isLoading && items.length === 0 + ? `Matches for “${query}”` + : `${hasMore ? `The best ${items.length}` : items.length} ${items.length === 1 ? "match" : "matches"} for “${query}”`} +

+ {isLoading && items.length === 0 ? ( + + ) : ( +
+ {/* Selecting goes through the focus graph (see contentRows), so the cards here only render. */} + {items.map((item) => ( + {}} fill /> + ))} +
+ )} +
+ ); +} diff --git a/src/screens/SeriesScreen.test.tsx b/src/screens/SeriesScreen.test.tsx index c2b3f64..1c48283 100644 --- a/src/screens/SeriesScreen.test.tsx +++ b/src/screens/SeriesScreen.test.tsx @@ -68,16 +68,17 @@ async function resetScreenState(): Promise { describe("SeriesScreen while the series table is still being built", () => { beforeEach(resetScreenState); - it("asks the sync manager for the series table and shows its progress — never downloading the catalog itself", async () => { + it("asks the sync manager for the series table and says it's on its way, without a row count — never downloading the catalog itself", async () => { const { loadSeriesList } = await import("../content-loader.js"); const { syncSource } = await import("../sync/sync-manager.js"); - render( {}} onBack={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} />); await vi.waitFor(() => expect(syncSource).toHaveBeenCalledWith(source, { trigger: "first-run", stages: ["series"] })); expect(await screen.findByText("Getting your series ready…")).not.toBeNull(); act(() => useSyncStore.getState().setStage(source.id, "series", { status: "running", done: 800 })); - expect(screen.getByText("800 so far")).not.toBeNull(); + expect(screen.queryByText(/800/)).toBeNull(); + expect(screen.getByText("Getting your series ready…")).not.toBeNull(); expect(loadSeriesList).not.toHaveBeenCalled(); }); @@ -87,7 +88,7 @@ describe("SeriesScreen while the series table is still being built", () => { Promise.resolve(categoryId === "cat-1" ? [{ id: "s1", name: "Drama Show", groupTitle: "cat-1" }] : []), ); - render( {}} onBack={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} />); fireEvent.click(await screen.findByRole("button", { name: "Drama" })); // the category rail row expect(await screen.findByText("Drama Show")).not.toBeNull(); @@ -100,7 +101,7 @@ describe("SeriesScreen while the series table is still being built", () => { const { syncSource } = await import("../sync/sync-manager.js"); const m3u: PlaylistSource = { kind: "m3u-url", id: "src-m3u", name: "M3U", url: "http://example.com/list.m3u" }; - render( {}} onBack={() => {}} />); + render( {}} source={m3u} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} />); expect(await screen.findByText("This playlist has no series.")).not.toBeNull(); expect(syncSource).not.toHaveBeenCalled(); }); @@ -119,37 +120,28 @@ describe("SeriesScreen with a synced local catalog", () => { const { loadSeriesList } = await import("../content-loader.js"); render( - {}} onBack={() => {}} />, + {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} />, ); expect(await screen.findByText("Drama Show")).not.toBeNull(); expect(loadSeriesList).not.toHaveBeenCalled(); }); - it("search reads a prefix match from the local table", async () => { - const catalogDb = await openCatalogDb(); - await putRecordsBatch(catalogDb, "series", [ - { id: `${source.id}:1`, sourceId: source.id, streamId: "1", name: "Breaking News", nameLower: "breaking news", generation: 1 }, - { id: `${source.id}:2`, sourceId: source.id, streamId: "2", name: "Comedy Hour", nameLower: "comedy hour", generation: 1 }, - ]); - await putSyncMeta(catalogDb, { key: `series:${source.id}`, lastSyncedAt: Date.now(), recordCount: 2, generation: 1 }); - - render( - {}} onBack={() => {}} />, - ); - const searchInput = await screen.findByPlaceholderText("Search series"); - - fireEvent.change(searchInput, { target: { value: "break" } }); - await flush(); + it("the header's Search button opens the Search screen instead of filtering the page", async () => { + await seedSeries([{ id: "1", name: "Breaking News", groupTitle: "cat-1" }]); + const onOpenSearch = vi.fn(); + render( {}} onBack={() => {}} onOpenSearch={onOpenSearch} />); - expect(await screen.findByText("Breaking News")).not.toBeNull(); - expect(screen.queryByText("Comedy Hour")).toBeNull(); + await screen.findByText("Breaking News"); + expect(screen.queryByRole("textbox")).toBeNull(); // no search field in the header any more + fireEvent.click(screen.getByRole("button", { name: "Search" })); + expect(onOpenSearch).toHaveBeenCalledTimes(1); }); }); describe("SeriesScreen at catalog scale", () => { - // Zero-padded ids: the table returns rows in key order, and "s10" would otherwise sort before "s2". - const manySeries = Array.from({ length: 500 }, (_, i) => ({ id: `s${String(i).padStart(3, "0")}`, name: i % 2 ? `Drama Show ${i}` : `Comedy Hour ${i}`, groupTitle: "cat-1" })); + // Ids descend so series 0 is the newest, which pages list first. + const manySeries = Array.from({ length: 500 }, (_, i) => ({ id: `s${999 - i}`, name: i % 2 ? `Drama Show ${i}` : `Comedy Hour ${i}`, groupTitle: "cat-1" })); beforeEach(async () => { await resetScreenState(); @@ -161,13 +153,13 @@ describe("SeriesScreen at catalog scale", () => { Array.from(container.querySelectorAll('[role="button"]')).filter((el) => !el.textContent?.startsWith("See all")).length; it("caps each All Categories shelf at 20 cards instead of mounting the whole catalog", async () => { - const { container } = render( {}} onBack={() => {}} />); + const { container } = render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} />); await screen.findByText("Comedy Hour 0"); expect(cardCount(container)).toBe(20); }); it("renders a picked category a page (60) at a time", async () => { - const { container } = render( {}} onBack={() => {}} />); + const { container } = render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} />); await screen.findByText("Comedy Hour 0"); fireEvent.click(await screen.findByRole("button", { name: "Drama" })); // the category rail row (categories may land after the list) @@ -175,38 +167,22 @@ describe("SeriesScreen at catalog scale", () => { }); it("keeps focus where it is when the next page of a category loads", async () => { - const { container } = render( {}} onBack={() => {}} />); + const { container } = render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} />); await screen.findByText("Comedy Hour 0"); fireEvent.click(await screen.findByRole("button", { name: "Drama" })); await vi.waitFor(() => expect(cardCount(container)).toBe(60)); // The last row of the first page is the "load more" trigger zone. - act(() => useFocusStore.getState().focus("series-grid:s057")); + act(() => useFocusStore.getState().focus("series-grid:s942")); await vi.waitFor(() => expect(cardCount(container)).toBe(120)); - expect(useFocusStore.getState().focusedId).toBe("series-grid:s057"); + expect(useFocusStore.getState().focusedId).toBe("series-grid:s942"); }); - it("debounces search and ignores single-character queries", async () => { - const { container } = render( {}} onBack={() => {}} />); - await screen.findByText("Comedy Hour 0"); - const input = container.querySelector("input") as HTMLInputElement; - const shelfCount = () => container.querySelectorAll("section").length; - - fireEvent.change(input, { target: { value: "d" } }); - await new Promise((resolve) => setTimeout(resolve, 400)); - expect(shelfCount()).toBe(1); // one letter: still browsing shelves, no search ran - - fireEvent.change(input, { target: { value: "dr" } }); - expect(shelfCount()).toBe(1); // not yet — waits for typing to pause - await vi.waitFor(() => expect(shelfCount()).toBe(0)); - expect(await screen.findByText("Drama Show 1")).toBeTruthy(); // read from the table's name index - expect(screen.queryByText("Comedy Hour 0")).toBeNull(); - await vi.waitFor(() => expect(cardCount(container)).toBe(60)); // 250 matches, first page only - }); }); describe("SeriesScreen category rail", () => { - const manySeries = Array.from({ length: 100 }, (_, i) => ({ id: `s${i}`, name: `Drama Show ${i}`, groupTitle: "cat-1" })); + // Ids descend so show 0 is the newest, which pages list first. + const manySeries = Array.from({ length: 100 }, (_, i) => ({ id: `s${999 - i}`, name: `Drama Show ${i}`, groupTitle: "cat-1" })); beforeEach(async () => { await resetScreenState(); @@ -224,21 +200,21 @@ describe("SeriesScreen category rail", () => { Array.from(container.querySelectorAll('[role="button"]')).filter((el) => !el.textContent?.startsWith("See all")).length; it("opens with focus on the content, not the rail", async () => { - render( {}} onBack={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} />); await screen.findByText("Drama Show 0"); - await vi.waitFor(() => expect(focusedId()).toBe("s0")); + await vi.waitFor(() => expect(focusedId()).toBe("s999")); }); it("Left from the first column opens the rail; Back goes content → rail → leaves the screen", async () => { const onBack = vi.fn(); - render( {}} onBack={onBack} />); + render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={onBack} />); await screen.findByText("Drama Show 0"); - await vi.waitFor(() => expect(focusedId()).toBe("s0")); + await vi.waitFor(() => expect(focusedId()).toBe("s999")); press("ArrowLeft"); expect(focusedId()).toBe("rail:__all__"); press("ArrowRight"); - expect(focusedId()).toBe("s0"); + expect(focusedId()).toBe("s999"); press("Escape"); expect(focusedId()).toBe("rail:__all__"); @@ -247,8 +223,52 @@ describe("SeriesScreen category rail", () => { expect(onBack).toHaveBeenCalledTimes(1); }); + /** + * Keeps a readwrite transaction open on the series table, as a sync writing + * batches does — reads queue behind it until release() is called. + */ + async function holdSeriesTableBusy(): Promise<() => void> { + const catalogDb = await openCatalogDb(); + let released = false; + const tx = catalogDb.db.transaction("series", "readwrite"); + const store = tx.objectStore("series"); + const spin = () => { + if (!released) store.count().onsuccess = spin; + }; + spin(); + return () => { + released = true; + }; + } + + it("while a sync keeps the table busy, the rail and Back still work instead of a dead full-screen loader", async () => { + const release = await holdSeriesTableBusy(); + const onBack = vi.fn(); + render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={onBack} />); + + await vi.waitFor(() => expect(focusedId()).toBe("series-search-button")); + expect(screen.getByText("Loading…")).toBeTruthy(); + expect(screen.queryByText("Drama Show 0")).toBeNull(); + + press("ArrowLeft"); + expect(focusedId()).toBe("rail:__all__"); + press("Escape"); + expect(onBack).toHaveBeenCalledTimes(1); + release(); + }); + + it("hands focus to the content once it loads, if the user hasn't moved meanwhile", async () => { + const release = await holdSeriesTableBusy(); + render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} />); + await vi.waitFor(() => expect(focusedId()).toBe("series-search-button")); + + release(); + await screen.findByText("Drama Show 0"); + await vi.waitFor(() => expect(focusedId()).toBe("s999")); + }); + it("ends each shelf with a See all card that opens the category and moves focus into it", async () => { - const { container } = render( {}} onBack={() => {}} />); + const { container } = render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} />); await screen.findByText("Drama Show 0"); expect(screen.getByRole("button", { name: /^See all/ })).toBeTruthy(); @@ -256,18 +276,19 @@ describe("SeriesScreen category rail", () => { press("Enter"); await vi.waitFor(() => expect(posterCount(container)).toBe(60)); - await vi.waitFor(() => expect(focusedId()).toBe("series-grid:s0")); - expect(screen.getByText("100 titles")).toBeTruthy(); + await vi.waitFor(() => expect(focusedId()).toBe("series-grid:s999")); + expect(screen.getByText(/^100 titles · Updated just now$/)).toBeTruthy(); + expect(screen.getByRole("button", { name: "Refresh" })).toBeTruthy(); // one category shown: it can be refreshed on its own }); it("remembers the chosen category when the screen is opened again", async () => { - const first = render( {}} onBack={() => {}} />); + const first = render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} />); await screen.findByText("Drama Show 0"); fireEvent.click(await screen.findByRole("button", { name: "Drama" })); await vi.waitFor(() => expect(posterCount(first.container)).toBe(60)); first.unmount(); - const second = render( {}} onBack={() => {}} />); + const second = render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} />); await vi.waitFor(() => expect(posterCount(second.container)).toBe(60)); expect(second.container.querySelectorAll("section")).toHaveLength(0); // category grid, not Browse shelves }); @@ -295,7 +316,7 @@ describe("SeriesScreen detail page", () => { const focusedId = () => useFocusStore.getState().focusedId; it("starts on Play and chains Down/Up through actions → season tabs → episodes", async () => { - render( {}} onBack={() => {}} initialSelectedId="s0" />); + render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} initialSelectedId="s0" />); await screen.findByRole("button", { name: "Play S1 E1" }); await vi.waitFor(() => expect(focusedId()).toBe("series-hero-play")); @@ -315,7 +336,7 @@ describe("SeriesScreen detail page", () => { localStorage.clear(); const { isFavorite } = await import("../profile-store.js"); const onPlayEpisode = vi.fn(); - render( {}} initialSelectedId="s0" />); + render( {}} source={source} platform="web" profile={profile} onPlayEpisode={onPlayEpisode} onBack={() => {}} initialSelectedId="s0" />); await screen.findByRole("button", { name: "Play S1 E1" }); await vi.waitFor(() => expect(focusedId()).toBe("series-hero-play")); @@ -332,7 +353,7 @@ describe("SeriesScreen detail page", () => { }); it("switching season updates the episode row and Up from it returns to that season's tab", async () => { - render( {}} onBack={() => {}} initialSelectedId="s0" />); + render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} initialSelectedId="s0" />); await screen.findByRole("button", { name: "Play S1 E1" }); act(() => useFocusStore.getState().focus("season-tab:2")); @@ -345,7 +366,7 @@ describe("SeriesScreen detail page", () => { }); it("Back from the detail page returns focus to the series card that opened it", async () => { - render( {}} onBack={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onPlayEpisode={() => {}} onBack={() => {}} />); await screen.findByText("Drama Show 3"); act(() => useFocusStore.getState().focus("s3")); press("Enter"); diff --git a/src/screens/SeriesScreen.tsx b/src/screens/SeriesScreen.tsx index 11cbf3a..1940ce1 100644 --- a/src/screens/SeriesScreen.tsx +++ b/src/screens/SeriesScreen.tsx @@ -1,7 +1,7 @@ -import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { PlatformId, PlaylistSource, Profile, SeriesDetails, SeriesEpisode } from "@core"; import type { FocusNode } from "@ui"; -import { Check, Play, Plus, RotateCcw, Search, Star, X } from "lucide-react"; +import { Check, Play, Plus, RefreshCw, RotateCcw, Star } from "lucide-react"; import { CategoryRail, categoryRailItemId, @@ -12,14 +12,15 @@ import { Focusable, LiftSurface, Shelf, - ShelfRowSkeleton, - Shimmer, + LoadingState, URLImage, buildGridFocusGraph, buildShelfFocusGraph, useFocusStore, useRemoteInput, - glassBlur, + SearchButton, + HeaderButton, + Toast, useIsFocused, BROWSE_CONTENT_LEFT, BROWSE_GAP, @@ -29,20 +30,20 @@ import { POSTER_WIDTH, TV_TEXT, TV_HEADING, - EPISODE_COLUMNS, EPISODE_WIDTH, SECTION_ICONS, } from "@ui"; import { loadSeriesCategories, loadSeriesDetails, loadSeriesList } from "../content-loader.js"; import { toggleFavorite, loadContinueWatching, loadFavorites } from "../profile-store.js"; -import { useSearchQuery } from "../use-debounced-value.js"; import { useIncrementalList } from "../use-incremental-list.js"; import { useCachedContent } from "../use-cached-content.js"; import { useSeriesCatalogPage } from "../use-catalog-page.js"; import { syncSource } from "../sync/sync-manager.js"; import { useSourceSyncState } from "../sync/sync-store.js"; import { SyncNotice } from "./SyncNotice.js"; +import { frequentCategoryIds, recordCategoryUse, splitByFrequency } from "../category-usage-store.js"; import { useLocalCatalogReady } from "../use-local-catalog-ready.js"; +import { useCatalogFreshness } from "../use-catalog-freshness.js"; import { useCatalogShelves } from "../use-catalog-shelves.js"; import { getCatalogPage } from "../catalog-store.js"; import { useFavoritesRevision } from "../use-favorites-revision.js"; @@ -70,6 +71,14 @@ export interface SeriesScreenProps { initialSelectedId?: string; /** Fired whenever the open series changes (selecting one, or backing out to the shelf browser passes null) — lets App.tsx remember the last-viewed series across this screen's unmount/remount on tab switches. */ onSelectionChange?: (seriesId: string | null) => void; + /** + * Called instead of returning to the browse page when Back leaves a + * series' detail page — set when the series was opened from somewhere + * else (Search), so Back goes back there. + */ + onCloseDetail?: () => void; + /** Opens the global Search screen (the header's Search button). */ + onOpenSearch: () => void; /** Bumped by App.tsx every time the player overlay closes after resumable playback — triggers re-reading Continue Watching, which upsertContinueWatching wrote to localStorage during playback without this screen (which stayed mounted underneath the overlay) otherwise finding out. */ continueWatchingVersion?: number; /** True while PlayerScreen is open on top of this screen — disables this screen's own useRemoteInput so a single Back press doesn't both close the player and navigate this screen away. */ @@ -100,7 +109,8 @@ export function __resetCategoryMemoryForTests(): void { } /** Focus id of a shelf's trailing "See all" card. */ const seeAllId = (categoryId: string) => `seeall:${categoryId}`; -const SEARCH_INPUT_ID = "series-search-input"; +const SEARCH_BUTTON_ID = "series-search-button"; +const REFRESH_BUTTON_ID = "series-refresh-button"; export function SeriesScreen({ source, @@ -110,6 +120,8 @@ export function SeriesScreen({ onBack, initialSelectedId, onSelectionChange, + onCloseDetail, + onOpenSearch, continueWatchingVersion, isPlaybackOpen = false, }: SeriesScreenProps): JSX.Element { @@ -125,13 +137,6 @@ export function SeriesScreen({ }, [memoryKey, activeCategoryId]); // Kids profiles only see what the content policy allows (docs/kids-profile.md §6); a standard profile's policy passes everything. const policy = useContentPolicy(profile, source.id); - const [searchQuery, setSearchQuery] = useState(""); - const searchInputRef = useRef(null); - - // The query that actually runs: debounced and ignored below 2 characters - // (see useSearchQuery), so typing doesn't refilter/re-render the catalog - // on every keystroke. searchQuery itself only drives the input's text. - const trimmedQuery = useSearchQuery(searchQuery); const isAllCategories = activeCategoryId === ALL_CATEGORIES_ID; // Every read below comes from the local series table, which the sync @@ -150,7 +155,7 @@ export function SeriesScreen({ // While the table is still being built, a single category is fetched with // the provider's own category filter — see VodScreen's useCategoryFetch. // Never for a Kids profile: unfiltered provider data must not render while the table is built. - const useCategoryFetch = isAwaitingSync && !isAllCategories && !trimmedQuery && !policy.isKids; + const useCategoryFetch = isAwaitingSync && !isAllCategories && !policy.isKids; const loadCategorySeries = useCallback( () => loadSeriesList(source, isAllCategories ? undefined : activeCategoryId), [source, isAllCategories, activeCategoryId], @@ -168,7 +173,7 @@ export function SeriesScreen({ const visibleCategories = useMemo(() => policy.visibleCatalogCategories("series", categories), [policy, categories]); const catalogFilter = useMemo(() => policy.catalogFilter("series", categories), [policy, categories]); - // Local-table path: paginated grid/search reads, grown on demand (see + // Local-table path: paginated category grid reads, grown on demand (see // use-catalog-page.ts) — this is what lets the grid render a very large // catalog without ever holding it all in memory. const { @@ -179,9 +184,8 @@ export function SeriesScreen({ total: localGridTotal, } = useSeriesCatalogPage(source.id, { categoryId: isAllCategories ? undefined : activeCategoryId, - namePrefix: trimmedQuery || undefined, filter: catalogFilter, - enabled: isLocalCatalogReady && (!isAllCategories || trimmedQuery.length > 0), + enabled: isLocalCatalogReady && !isAllCategories, }); // Fixed TV poster density (see tv-metrics.ts) — the grid and the D-pad focus graph share this column count. @@ -200,7 +204,7 @@ export function SeriesScreen({ "series", visibleCategories, mapShelfPage, - isLocalCatalogReady && isAllCategories && !trimmedQuery, + isLocalCatalogReady && isAllCategories, ); // The browse card that opened the detail page, so Back returns focus to @@ -245,13 +249,18 @@ export function SeriesScreen({ ); const { details, episodes } = seriesData; - const categoryItems = useMemo( - () => [ - { id: ALL_CATEGORIES_ID, label: "Browse", count: undefined as number | undefined }, - ...visibleCategories.map((c) => ({ id: c.id, label: c.name, count: undefined as number | undefined })), - ], - [visibleCategories], - ); + // Most-opened categories first, under "Frequently used". Read once per + // visit (the screen remounts on every tab switch) so the rail doesn't + // reshuffle under the user while they browse. + const frequentIds = useMemo(() => frequentCategoryIds(profile.id, source.id, "series"), [profile.id, source.id]); + const { categoryItems, railSections } = useMemo(() => { + const { frequent, rest } = splitByFrequency(visibleCategories, frequentIds); + const toItem = (c: { id: string; name: string }) => ({ id: c.id, label: c.name, count: undefined as number | undefined }); + return { + categoryItems: [{ id: ALL_CATEGORIES_ID, label: "Browse", count: undefined as number | undefined }, ...frequent.map(toItem), ...rest.map(toItem)], + railSections: frequent.length > 0 ? [{ at: 1, label: "Frequently used" }, { at: 1 + frequent.length, label: "Categories" }] : [{ at: 1, label: "Categories" }], + }; + }, [visibleCategories, frequentIds]); const activeCategoryLabel = categoryItems.find((c) => c.id === activeCategoryId)?.label ?? "Browse"; // A remembered category a parent has since hidden falls back to Browse. @@ -260,40 +269,25 @@ export function SeriesScreen({ if (!visibleCategories.some((c) => c.id === activeCategoryId)) setActiveCategoryId(ALL_CATEGORIES_ID); }, [policy.isKids, isAllCategories, categories.length, visibleCategories, activeCategoryId]); - // A category other than "All Categories", or a non-empty search query, - // replaces the shelf browser with a single flat, vertically-scrolling - // grid — Netflix's own "browsing a category"/search-results behavior, - // versus shelves' one-row-per-category layout which doesn't make sense - // once there's only one category (or an arbitrary text match) to show. - // Search takes priority over the category filter when both are active, - // searching within the selected category rather than across all series. + // A category other than "All Categories" replaces the shelf browser with + // a single flat, vertically-scrolling grid — Netflix's own "browsing a + // category" behavior, versus shelves' one-row-per-category layout which + // doesn't make sense once there's only one category to show. Searching + // is the Search screen's job (the header's Search button). // The filtering happens inside useSeriesCatalogPage's IndexedDB query; // the category fetch is rendered a page at a time like the table path. const categoryFetchPage = useIncrementalList(useCategoryFetch ? categoryFetchSeries : null); - const gridSeries = isLocalCatalogReady ? (isAllCategories && !trimmedQuery ? null : localGridSeries) : (categoryFetchPage?.visible ?? null); + const gridSeries = isLocalCatalogReady ? (isAllCategories ? null : localGridSeries) : (categoryFetchPage?.visible ?? null); const gridHasMore = isLocalCatalogReady ? localGridHasMore : (categoryFetchPage?.hasMore ?? false); const loadMoreGrid = isLocalCatalogReady ? loadMoreLocalGrid : categoryFetchPage?.loadMore; + // The grid's query hasn't answered yet — a spinner, never a premature "No series in this category". + const isGridLoading = isLocalCatalogReady ? isLocalGridLoading : useCategoryFetch && isCategoryFetchLoading; + const isShelvesLoading = isLocalCatalogReady && isLocalShelvesLoading && shelves.length === 0; // Nothing to browse yet: the table is still being built (Xtream), or the playlist simply has no series (M3U). const showSyncNotice = isAwaitingSync && !useCategoryFetch; const showNoSeries = localCatalogStatus === "not-synced" && !hasSeriesApi; - const isBrowseLoading = isCheckingLocalCatalog - ? true - : isLocalCatalogReady - ? isAllCategories && !trimmedQuery - ? isLocalShelvesLoading - : isLocalGridLoading - : useCategoryFetch && isCategoryFetchLoading; - - // isBrowseLoading briefly flips true again on the local-table path on - // every query change (each keystroke while searching, or a new category) - // — see VodScreen's identical hasEverShownContentRef comment for why - // gating the whole screen (search input included) on that would reset - // mid-keystroke instead of showing an in-place update. - const hasEverShownBrowseRef = useRef(false); - if (!isBrowseLoading) hasEverShownBrowseRef.current = true; - const showFullScreenBrowseSkeleton = isBrowseLoading && !hasEverShownBrowseRef.current; const seasons = useMemo(() => Array.from(new Set(episodes.map((ep) => ep.season))).sort((a, b) => a - b), [episodes]); const currentSeason = activeSeason ?? seasons[0] ?? null; @@ -334,8 +328,12 @@ export function SeriesScreen({ // Header for the content area: the category (or search) being shown and, // for a single category, how many titles it holds. - const headerTitle = trimmedQuery ? `Results for "${trimmedQuery}"` : isAllCategories ? "Series" : activeCategoryLabel; + const headerTitle = isAllCategories ? "Series" : activeCategoryLabel; const headerCount = gridSeries ? (isLocalCatalogReady ? localGridTotal : categoryFetchSeries.length) : null; + // "Updated 3h ago", and a Refresh for the one category on screen. + const freshness = useCatalogFreshness(source, "series", isAllCategories ? undefined : activeCategoryId, isLocalCatalogReady); + const { canRefresh, refresh: refreshCategory } = freshness; + const headerDetail = [headerCount != null ? `${headerCount} ${headerCount === 1 ? "title" : "titles"}` : null, freshness.updatedLabel].filter(Boolean).join(" · "); const firstContentId = gridSeries ? (gridSeries[0] ? gridItemId(gridSeries[0].id) : undefined) : shelves[0]?.items[0]?.id; const firstContentIdRef = useRef(firstContentId); @@ -346,6 +344,8 @@ export function SeriesScreen({ // focus follows into the new content once its focus graph registers — // see the browse graph effect below. const focusContentOnNextGraphRef = useRef(false); + // Set when focus was parked on the Search button because there was nothing else to hold it (still loading). + const focusParkedRef = useRef(false); const selectCategory = useCallback((id: string) => { if (id === activeCategoryIdRef.current) { @@ -354,28 +354,29 @@ export function SeriesScreen({ if (first) useFocusStore.getState().focus(first); return; } + if (id !== ALL_CATEGORIES_ID) recordCategoryUse(profile.id, source.id, "series", id); focusContentOnNextGraphRef.current = true; setActiveCategoryId(id); - }, []); + }, [profile.id, source.id]); - /** Moves focus into the category rail (which expands it), leaving the search box's native focus if it had it. */ + /** Moves focus into the category rail (which expands it). */ const openCategoryRail = useCallback(() => { - if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); useFocusStore.getState().focus(categoryRailItemId(activeCategoryIdRef.current)); }, []); - // Search input's proxy focus node — see SeriesSearchInput's doc comment - // for why a native needs a Focusable stand-in rather than being a - // spatial-nav node itself. Sits to the right of the category dropdown's - // trigger in the same sticky bar. + // The header's buttons, beside the title — Refresh (one category shown) then Search: Left toward the category rail, Down into the content. useEffect(() => { if (selected) { - clearGraph("chrome:series-search"); // no search box on the detail page + clearGraph("chrome:series-search"); // no Search button on the detail page return; } - const node = { id: SEARCH_INPUT_ID, neighbors: { left: categoryRailItemId(activeCategoryId), down: firstContentId }, onSelect: () => searchInputRef.current?.focus() }; - setGraph("chrome:series-search", [node], undefined, { passive: true }); - }, [selected, activeCategoryId, firstContentId, setGraph, clearGraph]); + const railEntryId = categoryRailItemId(activeCategoryId); + const search = { id: SEARCH_BUTTON_ID, neighbors: { left: canRefresh ? REFRESH_BUTTON_ID : railEntryId, down: firstContentId }, onSelect: onOpenSearch }; + const nodes = canRefresh + ? [{ id: REFRESH_BUTTON_ID, neighbors: { left: railEntryId, right: SEARCH_BUTTON_ID, down: firstContentId }, onSelect: refreshCategory }, search] + : [search]; + setGraph("chrome:series-search", nodes, undefined, { passive: true }); + }, [selected, activeCategoryId, firstContentId, onOpenSearch, canRefresh, refreshCategory, setGraph, clearGraph]); // Rebuilds above replace the scope in place (setGraph is atomic); clearing // it on every rebuild would drop focus for an instant and snap it back to // the first node. Clear only when this component goes away. @@ -384,8 +385,8 @@ export function SeriesScreen({ // Browse mode's focus graph: either the shelf browser (one row per // category, plus a trailing "See all" card) or a single flat grid (one - // category from the rail, or a search) — never both. Up from the top row - // reaches the search box; Left from the first column opens the category + // category from the rail) — never both. Up from the top row reaches the + // Search button; Left from the first column opens the category // rail on the active category. useEffect(() => { if (selected) return; @@ -398,22 +399,39 @@ export function SeriesScreen({ useFocusStore.getState().focus(returnTo); return; } - if (!focusContentOnNextGraphRef.current) return; + const parked = focusParkedRef.current && useFocusStore.getState().focusedId === SEARCH_BUTTON_ID; + focusParkedRef.current = false; + if (!focusContentOnNextGraphRef.current && !parked) return; focusContentOnNextGraphRef.current = false; useFocusStore.getState().focus(id); }; + // Nothing to focus in the content yet: hold focus in the top bar so Left + // (category rail) and Back still work, and hand it to the content once it loads. + const parkFocus = () => { + const { focusedId } = useFocusStore.getState(); + // Only take focus nobody holds — or move focus this screen parked itself. + if (focusedId !== null && !(focusParkedRef.current && focusedId === SEARCH_BUTTON_ID)) return; + if (showSyncNotice || showNoSeries) { + focusParkedRef.current = false; + useFocusStore.getState().focus(railEntryId); + return; + } + useFocusStore.getState().focus(SEARCH_BUTTON_ID); + focusParkedRef.current = true; + }; if (gridSeries) { const ids = gridSeries.map((item) => gridItemId(item.id)); if (ids.length === 0) { setGraph(CONTENT_ENTRY_SCOPE, []); + parkFocus(); return; } const nodes = buildGridFocusGraph(ids, gridColumns).map((node, index) => ({ ...node, neighbors: { ...node.neighbors, - up: index < gridColumns ? SEARCH_INPUT_ID : node.neighbors.up, + up: index < gridColumns ? SEARCH_BUTTON_ID : node.neighbors.up, left: index % gridColumns === 0 ? railEntryId : node.neighbors.left, }, })); @@ -425,8 +443,7 @@ export function SeriesScreen({ const rows = shelves.map((shelf) => [...shelf.items.map((item) => item.id), seeAllId(shelf.id)]); if (rows.length === 0) { setGraph(CONTENT_ENTRY_SCOPE, []); - // Nothing to browse (table still being built, or no series at all): the rail is the only thing to hold focus. - if ((showSyncNotice || showNoSeries) && useFocusStore.getState().focusedId === null) useFocusStore.getState().focus(railEntryId); + parkFocus(); return; } const categoryBySeeAllId = new Map(shelves.map((shelf) => [seeAllId(shelf.id), shelf.id])); @@ -437,7 +454,7 @@ export function SeriesScreen({ ...node, neighbors: { ...node.neighbors, - up: index < rows[0].length ? SEARCH_INPUT_ID : node.neighbors.up, + up: index < rows[0].length ? SEARCH_BUTTON_ID : node.neighbors.up, left: rowStarts.has(node.id) ? railEntryId : node.neighbors.left, }, onSelect: seeAllCategory ? () => selectCategory(seeAllCategory) : undefined, @@ -541,7 +558,10 @@ export function SeriesScreen({ onBack: () => { // Detail view → back to browsing. Browsing: Back from the content // opens the category rail first; Back from the rail leaves the screen. - if (selected) setSelected(null); + if (selected) { + if (onCloseDetail) onCloseDetail(); + else setSelected(null); + } else if (useFocusStore.getState().focusedId?.startsWith("rail:")) onBack(); else openCategoryRail(); }, @@ -550,19 +570,21 @@ export function SeriesScreen({ ); // The browse grid/shelves, memoised so screen-level state that doesn't - // change them — the category menu opening/closing, each raw keystroke in - // the search box — doesn't re-render every card on screen. + // change them — e.g. the category menu opening/closing — doesn't + // re-render every card on screen. const browseContent = useMemo( () => ( <> {showSyncNotice ? ( - 0} canPickCategory /> + ) : showNoSeries ? (

This playlist has no series.

+ ) : isCheckingLocalCatalog || (gridSeries && isGridLoading) || (!gridSeries && isShelvesLoading) ? ( + ) : gridSeries ? ( gridSeries.length === 0 ? (

- {trimmedQuery ? `No series match "${trimmedQuery}".` : "No series in this category."} + No series in this category.

) : (
), - [gridSeries, shelves, trimmedQuery, favoriteSeriesIds, setSelected, selectCategory, showSyncNotice, showNoSeries, syncState], + [gridSeries, shelves, favoriteSeriesIds, setSelected, selectCategory, showSyncNotice, showNoSeries, syncState, isGridLoading, isShelvesLoading, isCheckingLocalCatalog, useCategoryFetch], ); if (selected) { @@ -640,11 +662,7 @@ export function SeriesScreen({
{isEpisodesLoading ? ( -
- {Array.from({ length: EPISODE_COLUMNS }, (_, i) => ( - - ))} -
+ ) : seasons.length === 0 ? (

No episodes available for this series yet.

) : ( @@ -669,13 +687,6 @@ export function SeriesScreen({ ); } - if (showFullScreenBrowseSkeleton) { - return ( - - - - ); - } return ( @@ -690,10 +701,10 @@ export function SeriesScreen({ activeId={activeCategoryId} onSelect={selectCategory} rightEntryId={firstContentId} - sectionBreakAt={1} + sections={railSections} /> - {/* Sticky so the title and search stay visible while shelves or a long grid scroll underneath, instead of scrolling away with the content. */} + {/* Sticky so the title and Search button stay visible while shelves or a long grid scroll underneath, instead of scrolling away with the content. */}
{headerTitle}
- {headerCount != null && ( -
- {headerCount} {headerCount === 1 ? "title" : "titles"} -
+ {headerDetail && ( +
{headerDetail}
)}
-
- +
+ {canRefresh && ( + + )} +
{browseContent}
+ {freshness.result && } ); } @@ -817,13 +836,7 @@ function SeriesHero({ {name} - {isLoading ? ( - <> - - - - - ) : ( + {isLoading ? null : ( <> {hasMetaRow && (
@@ -1048,72 +1061,3 @@ function EpisodeCard({ episode, progress, onSelect }: { episode: SeriesEpisode; ); } -interface SeriesSearchInputProps { - value: string; - onChange: (value: string) => void; -} - -/** - * Plain native for filtering series by title — kept as a normal - * focusable form control rather than a spatial-nav tile, same as - * AddSourceScreen/ProfileForm's text fields: text entry on a TV remote goes - * through the platform's own on-screen keyboard once the input has native - * focus, and useRemoteInput steps aside from arrow keys/Enter while a text - * field has focus (see its isTypingIntoTextField guard) so typing a query - * doesn't fight D-pad navigation. - * - * The SEARCH_INPUT_ID Focusable is a thin proxy: pressing Select while it's - * spatially focused hands off to the real input via its ref (same pattern - * as ProfileForm's name field), rather than the input being a graph node - * itself. - */ -const SeriesSearchInput = forwardRef(function SeriesSearchInput({ value, onChange }, ref) { - const isFocused = useIsFocused(SEARCH_INPUT_ID); - - return ( - -
- - onChange(e.target.value)} - placeholder="Search series" - style={{ - flex: 1, - border: "none", - background: "transparent", - padding: 0, - fontSize: TV_TEXT, - color: "var(--text, #f4f4f6)", - }} - /> - {value && ( - - )} -
-
- ); -}); diff --git a/src/screens/SettingsScreen.test.tsx b/src/screens/SettingsScreen.test.tsx index ee0d2d8..21e5c45 100644 --- a/src/screens/SettingsScreen.test.tsx +++ b/src/screens/SettingsScreen.test.tsx @@ -125,16 +125,19 @@ describe("SettingsScreen (App Settings)", () => { press("ArrowRight"); expect(focusedId()).toBe("settings-update-on-launch:always"); press("ArrowDown"); + expect(focusedId()).toBe("settings-catalog-refresh:72"); + press("ArrowDown"); expect(focusedId()).toBe("settings-guide-refresh:24"); press("ArrowDown"); expect(focusedId()).toBe("settings-guide-days:7"); press("ArrowDown"); - expect(focusedId()).toBe("settings-live-format:ts"); // two options: the column clamps to the last + expect(focusedId()).toBe("settings-live-format:ts"); press("ArrowDown"); - expect(focusedId()).toBe("settings-playback-speed:1"); // …and stays clamped going down + expect(focusedId()).toBe("settings-playback-speed:1.25"); + press("ArrowUp"); press("ArrowUp"); + expect(focusedId()).toBe("settings-guide-days:7"); press("ArrowUp"); - expect(focusedId()).toBe("settings-guide-days:3"); press("ArrowUp"); press("ArrowUp"); press("ArrowUp"); @@ -143,10 +146,10 @@ describe("SettingsScreen (App Settings)", () => { expect(focusedId()).toBe("settings-playlists-refresh:a"); // back into the playlist cards }); - it("has no Video Quality setting, and plays live channels as HLS by default", () => { + it("has no Video Quality setting, and picks the live stream format automatically by default", () => { renderSettings(); expect(screen.queryByText("Video Quality")).toBeNull(); - expect(screen.getByRole("button", { name: "HLS (.m3u8)", pressed: true })).toBeDefined(); + expect(screen.getByRole("button", { name: "Auto (recommended)", pressed: true })).toBeDefined(); act(() => useFocusStore.getState().focus("settings-live-format:ts")); press("Enter"); expect(loadSettings().liveStreamFormat).toBe("ts"); diff --git a/src/screens/SettingsScreen.tsx b/src/screens/SettingsScreen.tsx index ee4fd70..891e71a 100644 --- a/src/screens/SettingsScreen.tsx +++ b/src/screens/SettingsScreen.tsx @@ -6,9 +6,10 @@ import { loadSettings, updateSettings, type AppSettings, + type CatalogRefreshHours, type GuideDaysToKeep, type GuideRefreshHours, - type LiveStreamFormat, + type LiveStreamFormatSetting, type PlaybackSpeed, type UpdateOnLaunch, } from "../settings-store.js"; @@ -21,9 +22,10 @@ const AUTO_REFRESH_ID = "settings-auto-refresh"; const PARENTAL_ID = "settings-parental-controls"; const SCOPE = "settings"; const updateOnLaunchId = (value: UpdateOnLaunch) => `settings-update-on-launch:${value}`; +const catalogRefreshId = (value: CatalogRefreshHours) => `settings-catalog-refresh:${value}`; const guideRefreshId = (value: GuideRefreshHours) => `settings-guide-refresh:${value}`; const guideDaysId = (value: GuideDaysToKeep) => `settings-guide-days:${value}`; -const liveFormatId = (value: LiveStreamFormat) => `settings-live-format:${value}`; +const liveFormatId = (value: LiveStreamFormatSetting) => `settings-live-format:${value}`; const playbackSpeedId = (value: PlaybackSpeed) => `settings-playback-speed:${value}`; interface Option { @@ -37,6 +39,12 @@ const UPDATE_ON_LAUNCH_OPTIONS: Option[] = [ { value: "always", label: "Always" }, ]; +const CATALOG_REFRESH_OPTIONS: Option[] = [ + { value: 24, label: "24 hours" }, + { value: 48, label: "48 hours" }, + { value: 72, label: "72 hours" }, +]; + const GUIDE_REFRESH_OPTIONS: Option[] = [ { value: 6, label: "6 hours" }, { value: 12, label: "12 hours" }, @@ -49,7 +57,8 @@ const GUIDE_DAYS_OPTIONS: Option[] = [ { value: 7, label: "7 days" }, ]; -const LIVE_FORMAT_OPTIONS: Option[] = [ +const LIVE_FORMAT_OPTIONS: Option[] = [ + { value: "auto", label: "Auto (recommended)" }, { value: "m3u8", label: "HLS (.m3u8)" }, { value: "ts", label: "MPEG-TS (.ts)" }, ]; @@ -176,6 +185,7 @@ function SettingsView({ [{ id: AUTO_REFRESH_ID, onSelect: () => patch({ automaticRefresh: !settingsRef.current.automaticRefresh }) }], ...[ pickerRow(UPDATE_ON_LAUNCH_OPTIONS, updateOnLaunchId, "updateOnLaunch"), + pickerRow(CATALOG_REFRESH_OPTIONS, catalogRefreshId, "catalogRefreshHours"), pickerRow(GUIDE_REFRESH_OPTIONS, guideRefreshId, "guideRefreshHours"), pickerRow(GUIDE_DAYS_OPTIONS, guideDaysId, "guideDaysToKeep"), pickerRow(LIVE_FORMAT_OPTIONS, liveFormatId, "liveStreamFormat"), @@ -242,12 +252,24 @@ function SettingsView({ /> patch({ updateOnLaunch: value })} /> + patch({ catalogRefreshHours: value })} + /> 0 - ? `${done.toLocaleString()} so far` - : canPickCategory + : canPickCategory ? "You can already open a category from the list on the left." : "This only takes a moment the first time."; diff --git a/src/screens/VodScreen.test.tsx b/src/screens/VodScreen.test.tsx index be5ba32..70672e4 100644 --- a/src/screens/VodScreen.test.tsx +++ b/src/screens/VodScreen.test.tsx @@ -57,21 +57,22 @@ async function resetScreenState(): Promise { describe("VodScreen while the movie table is still being built", () => { beforeEach(resetScreenState); - it("asks the sync manager for the movie table and shows its progress — never downloading the catalog itself", async () => { + it("asks the sync manager for the movie table and says it's on its way, without a row count — never downloading the catalog itself", async () => { const { loadChannelsByKind } = await import("../content-loader.js"); const { syncSource } = await import("../sync/sync-manager.js"); - render( {}} onBack={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onPlay={() => {}} onBack={() => {}} />); await vi.waitFor(() => expect(syncSource).toHaveBeenCalledWith(source, { trigger: "first-run", stages: ["vod"] })); expect(await screen.findByText("Getting your movies ready…")).not.toBeNull(); act(() => useSyncStore.getState().setStage(source.id, "vod", { status: "running", done: 12400 })); - expect(screen.getByText(`${(12400).toLocaleString()} so far`)).not.toBeNull(); + expect(screen.queryByText(/12,?400/)).toBeNull(); + expect(screen.getByText("Getting your movies ready…")).not.toBeNull(); expect(loadChannelsByKind).not.toHaveBeenCalled(); }); it("says why when the sync failed", async () => { - render( {}} onBack={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onPlay={() => {}} onBack={() => {}} />); await screen.findByText("Getting your movies ready…"); act(() => useSyncStore.getState().setStage(source.id, "vod", { status: "failed", error: "The provider didn't respond within 120 seconds." })); @@ -80,7 +81,7 @@ describe("VodScreen while the movie table is still being built", () => { }); it("lets the rail hold focus while there's nothing to browse", async () => { - render( {}} onBack={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onPlay={() => {}} onBack={() => {}} />); await screen.findByText("Getting your movies ready…"); await vi.waitFor(() => expect(useFocusStore.getState().focusedId).toBe("rail:__all__")); }); @@ -91,7 +92,7 @@ describe("VodScreen while the movie table is still being built", () => { Promise.resolve(categoryId === "cat-1" ? [{ id: "m1", name: "Action Movie", streamUrl: "x", kind: "movie" as const, groupTitle: "cat-1" }] : []), ); - render( {}} onBack={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onPlay={() => {}} onBack={() => {}} />); fireEvent.click(await screen.findByRole("button", { name: "Action" })); // the category rail row expect(await screen.findByText("Action Movie")).not.toBeNull(); @@ -104,7 +105,7 @@ describe("VodScreen while the movie table is still being built", () => { const { syncSource } = await import("../sync/sync-manager.js"); const m3u: PlaylistSource = { kind: "m3u-url", id: "src-m3u", name: "M3U", url: "http://example.com/list.m3u" }; - render( {}} onBack={() => {}} />); + render( {}} source={m3u} platform="web" profile={profile} onPlay={() => {}} onBack={() => {}} />); await vi.waitFor(() => expect(syncSource).toHaveBeenCalledWith(m3u, { trigger: "first-run", stages: ["live"] })); }); }); @@ -122,7 +123,7 @@ describe("VodScreen with a synced local catalog", () => { const { loadChannelsByKind } = await import("../content-loader.js"); const { syncSource } = await import("../sync/sync-manager.js"); - render( {}} onBack={() => {}} />); + render( {}} source={source} platform="web" profile={profile} onPlay={() => {}} onBack={() => {}} />); await flush(); expect(await screen.findByText("Action Movie")).not.toBeNull(); @@ -130,22 +131,14 @@ describe("VodScreen with a synced local catalog", () => { expect(syncSource).not.toHaveBeenCalled(); // keeping it fresh is the scheduler's job }); - it("search reads a prefix match from the local table", async () => { - const catalogDb = await openCatalogDb(); - await putRecordsBatch(catalogDb, "vod", [ - { id: `${source.id}:1`, sourceId: source.id, streamId: "1", name: "Matrix Reloaded", nameLower: "matrix reloaded", generation: 1 }, - { id: `${source.id}:2`, sourceId: source.id, streamId: "2", name: "Inception", nameLower: "inception", generation: 1 }, - ]); - await putSyncMeta(catalogDb, { key: `vod:${source.id}`, lastSyncedAt: Date.now(), recordCount: 2, generation: 1 }); - - render( {}} onBack={() => {}} />); - const searchInput = await screen.findByPlaceholderText("Search movies"); - - fireEvent.change(searchInput, { target: { value: "matrix" } }); - await flush(); + it("the header's Search button opens the Search screen instead of filtering the page", async () => { + await putSyncMeta(await openCatalogDb(), { key: `vod:${source.id}`, lastSyncedAt: Date.now(), recordCount: 0, generation: 1 }); + const onOpenSearch = vi.fn(); + render( {}} onBack={() => {}} onOpenSearch={onOpenSearch} />); - expect(await screen.findByText("Matrix Reloaded")).not.toBeNull(); - expect(screen.queryByText("Inception")).toBeNull(); + expect(screen.queryByRole("textbox")).toBeNull(); // no search field in the header any more + fireEvent.click(await screen.findByRole("button", { name: "Search" })); + expect(onOpenSearch).toHaveBeenCalledTimes(1); }); }); @@ -157,10 +150,11 @@ describe("VodScreen at catalog scale", () => { await putRecordsBatch( catalogDb, "vod", + // Ids descend so movie 000 is the newest, which pages list first. Array.from({ length: 300 }, (_, i) => ({ - id: `${source.id}:m${i}`, + id: `${source.id}:m${999 - i}`, sourceId: source.id, - streamId: `m${i}`, + streamId: `m${999 - i}`, name: `Movie ${String(i).padStart(3, "0")}`, nameLower: `movie ${String(i).padStart(3, "0")}`, groupTitle: "cat-1", @@ -170,7 +164,7 @@ describe("VodScreen at catalog scale", () => { ); await putSyncMeta(catalogDb, { key: `vod:${source.id}`, lastSyncedAt: Date.now(), recordCount: 300, generation: 1 }); - const { container } = render( {}} onBack={() => {}} />); + const { container } = render( {}} source={source} platform="web" profile={profile} onPlay={() => {}} onBack={() => {}} />); await screen.findByText("Movie 000"); const posters = Array.from(container.querySelectorAll('[role="button"]')).filter((el) => !el.textContent?.startsWith("See all")); @@ -200,7 +194,7 @@ describe("VodScreen in a Kids profile", () => { ]); await putSyncMeta(catalogDb, { key: `vod:${source.id}`, lastSyncedAt: Date.now(), recordCount: 3, generation: 1 }); - render( {}} onBack={() => {}} />); + render( {}} source={source} platform="web" profile={kid} onPlay={() => {}} onBack={() => {}} />); expect(await screen.findByText("Frozen")).not.toBeNull(); expect(screen.getByRole("button", { name: "Kids Movies" })).not.toBeNull(); expect(screen.queryByRole("button", { name: "Action" })).toBeNull(); @@ -210,7 +204,7 @@ describe("VodScreen in a Kids profile", () => { it("never falls back to an unfiltered provider fetch while the table is being built", async () => { const { loadChannelsByKind } = await import("../content-loader.js"); - render( {}} onBack={() => {}} />); + render( {}} source={source} platform="web" profile={kid} onPlay={() => {}} onBack={() => {}} />); await screen.findByText("Getting your movies ready…"); await flush(); expect(loadChannelsByKind).not.toHaveBeenCalled(); diff --git a/src/screens/VodScreen.tsx b/src/screens/VodScreen.tsx index 0173a5f..4921176 100644 --- a/src/screens/VodScreen.tsx +++ b/src/screens/VodScreen.tsx @@ -1,22 +1,22 @@ -import { forwardRef, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { RefreshCw } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { Channel, PlatformId, PlaylistSource, Profile } from "@core"; -import { Search, X } from "lucide-react"; import { CategoryRail, categoryRailItemId, SeeAllCard, FavoriteHeart, - Focusable, MeshBackground, FocusCard, Shelf, - ShelfRowSkeleton, + LoadingState, buildGridFocusGraph, buildShelfFocusGraph, useFocusStore, useRemoteInput, - glassBlur, - useIsFocused, + SearchButton, + HeaderButton, + Toast, BROWSE_CONTENT_LEFT, BROWSE_GAP, BROWSE_ROW_GAP, @@ -28,13 +28,14 @@ import { } from "@ui"; import { loadChannelsByKind, loadVodCategories } from "../content-loader.js"; import { loadFavorites, toggleFavorite } from "../profile-store.js"; -import { useSearchQuery } from "../use-debounced-value.js"; import { useIncrementalList } from "../use-incremental-list.js"; import { useCachedContent } from "../use-cached-content.js"; import { useVodCatalogPage } from "../use-catalog-page.js"; import { syncSource } from "../sync/sync-manager.js"; import { useSourceSyncState } from "../sync/sync-store.js"; +import { frequentCategoryIds, recordCategoryUse, splitByFrequency } from "../category-usage-store.js"; import { useLocalCatalogReady } from "../use-local-catalog-ready.js"; +import { useCatalogFreshness } from "../use-catalog-freshness.js"; import { useCatalogShelves } from "../use-catalog-shelves.js"; import { getCatalogPage } from "../catalog-store.js"; import { useFavoritesRevision } from "../use-favorites-revision.js"; @@ -47,6 +48,8 @@ export interface VodScreenProps { profile: Profile; onPlay: (movie: Channel) => void; onBack: () => void; + /** Opens the global Search screen (the header's Search button). */ + onOpenSearch: () => void; /** True while PlayerScreen is open on top of this screen — disables this screen's own useRemoteInput so a single Back press doesn't both close the player and navigate this screen away. */ isPlaybackOpen?: boolean; } @@ -71,9 +74,10 @@ export function __resetCategoryMemoryForTests(): void { } /** Focus id of a shelf's trailing "See all" card. */ const seeAllId = (categoryId: string) => `seeall:${categoryId}`; -const SEARCH_INPUT_ID = "vod-search-input"; +const SEARCH_BUTTON_ID = "vod-search-button"; +const REFRESH_BUTTON_ID = "vod-refresh-button"; -export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybackOpen = false }: VodScreenProps): JSX.Element { +export function VodScreen({ source, platform, profile, onPlay, onBack, onOpenSearch, isPlaybackOpen = false }: VodScreenProps): JSX.Element { const setGraph = useFocusStore((state) => state.setGraph); const clearGraph = useFocusStore((state) => state.clearGraph); const memoryKey = `${profile.id}:${source.id}`; @@ -83,13 +87,6 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac }, [memoryKey, activeCategoryId]); // Kids profiles only see what the content policy allows (docs/kids-profile.md §6); a standard profile's policy passes everything. const policy = useContentPolicy(profile, source.id); - const [searchQuery, setSearchQuery] = useState(""); - const searchInputRef = useRef(null); - - // The query that actually runs: debounced and ignored below 2 characters - // (see useSearchQuery), so typing doesn't refilter/re-render the catalog - // on every keystroke. searchQuery itself only drives the input's text. - const trimmedQuery = useSearchQuery(searchQuery); const isAllCategories = activeCategoryId === ALL_CATEGORIES_ID; // Every read below comes from the local movie table (use-catalog-page.ts/ @@ -115,7 +112,7 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac // category_id filter — small, and it means picking a category isn't a // dead end during a first sync. // Never for a Kids profile: unfiltered provider data must not render while the table is built. - const useCategoryFetch = isAwaitingSync && !isAllCategories && !trimmedQuery && source.kind === "xtream" && !policy.isKids; + const useCategoryFetch = isAwaitingSync && !isAllCategories && source.kind === "xtream" && !policy.isKids; const loadCategoryMovies = useCallback( () => loadChannelsByKind(source, "movie", isAllCategories ? undefined : activeCategoryId), [source, isAllCategories, activeCategoryId], @@ -133,7 +130,7 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac const visibleCategories = useMemo(() => policy.visibleCatalogCategories("vod", categories), [policy, categories]); const catalogFilter = useMemo(() => policy.catalogFilter("vod", categories), [policy, categories]); - // Local-table path: paginated grid/search reads, grown on demand (see + // Local-table path: paginated category grid reads, grown on demand (see // use-catalog-page.ts) — this is what lets the grid render 100k+ catalogs // without ever holding them all in memory. const { @@ -144,9 +141,8 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac total: localGridTotal, } = useVodCatalogPage(source.id, { categoryId: isAllCategories ? undefined : activeCategoryId, - namePrefix: trimmedQuery || undefined, filter: catalogFilter, - enabled: isLocalCatalogReady && (!isAllCategories || trimmedQuery.length > 0), + enabled: isLocalCatalogReady && !isAllCategories, }); // Fixed TV poster density (see tv-metrics.ts) — the grid and the D-pad focus graph share this column count. @@ -181,16 +177,21 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac "vod", visibleCategories, mapShelfPage, - isLocalCatalogReady && isAllCategories && !trimmedQuery, + isLocalCatalogReady && isAllCategories, ); - const categoryItems = useMemo( - () => [ - { id: ALL_CATEGORIES_ID, label: "Browse", count: undefined as number | undefined }, - ...visibleCategories.map((c) => ({ id: c.id, label: c.name, count: undefined as number | undefined })), - ], - [visibleCategories], - ); + // Most-opened categories first, under "Frequently used". Read once per + // visit (the screen remounts on every tab switch) so the rail doesn't + // reshuffle under the user while they browse. + const frequentIds = useMemo(() => frequentCategoryIds(profile.id, source.id, "vod"), [profile.id, source.id]); + const { categoryItems, railSections } = useMemo(() => { + const { frequent, rest } = splitByFrequency(visibleCategories, frequentIds); + const toItem = (c: { id: string; name: string }) => ({ id: c.id, label: c.name, count: undefined as number | undefined }); + return { + categoryItems: [{ id: ALL_CATEGORIES_ID, label: "Browse", count: undefined as number | undefined }, ...frequent.map(toItem), ...rest.map(toItem)], + railSections: frequent.length > 0 ? [{ at: 1, label: "Frequently used" }, { at: 1 + frequent.length, label: "Categories" }] : [{ at: 1, label: "Categories" }], + }; + }, [visibleCategories, frequentIds]); const activeCategoryLabel = categoryItems.find((c) => c.id === activeCategoryId)?.label ?? "Browse"; // A remembered category a parent has since hidden falls back to Browse. @@ -199,48 +200,43 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac if (!visibleCategories.some((c) => c.id === activeCategoryId)) setActiveCategoryId(ALL_CATEGORIES_ID); }, [policy.isKids, isAllCategories, categories.length, visibleCategories, activeCategoryId]); - // A category other than "All Categories", or a non-empty search query, - // replaces the shelf browser with a single flat, vertically-scrolling - // grid — same behavior as SeriesScreen's browse page. Search takes - // priority over the category filter when both are active, searching - // within the selected category. The filtering happens inside + // A category other than "All Categories" replaces the shelf browser with + // a single flat, vertically-scrolling grid — same behavior as + // SeriesScreen's browse page. The filtering happens inside // useVodCatalogPage's IndexedDB query; the category fetch (see // useCategoryFetch) is rendered a page at a time like the table path. + // Searching is the Search screen's job (the header's Search button). const categoryFetchPage = useIncrementalList(useCategoryFetch ? categoryFetchMovies : null); - const gridMovies = isLocalCatalogReady ? (isAllCategories && !trimmedQuery ? null : localGridMovies) : (categoryFetchPage?.visible ?? null); + const gridMovies = isLocalCatalogReady ? (isAllCategories ? null : localGridMovies) : (categoryFetchPage?.visible ?? null); const gridHasMore = isLocalCatalogReady ? localGridHasMore : (categoryFetchPage?.hasMore ?? false); const loadMoreGrid = isLocalCatalogReady ? loadMoreLocalGrid : categoryFetchPage?.loadMore; + // The grid's query hasn't answered yet — a spinner, never a premature "No movies in this category". + const isGridLoading = isLocalCatalogReady ? isLocalGridLoading : useCategoryFetch && isCategoryFetchLoading; + const isShelvesLoading = isLocalCatalogReady && isLocalShelvesLoading && shelves.length === 0; // Nothing to browse yet: the table is still being built and this isn't the category-fetch exception. const showSyncNotice = isAwaitingSync && !useCategoryFetch; + // Nothing to render until the local-catalog check answers. The screen's + // shell (category rail, Search button, Back) is always up meanwhile — a + // full-screen loader with nothing focusable left the user stuck while a + // sync kept the table busy. const isInitialLoading = isCheckingLocalCatalog ? true : isLocalCatalogReady - ? isAllCategories && !trimmedQuery + ? isAllCategories ? isLocalShelvesLoading : isLocalGridLoading : useCategoryFetch && isCategoryFetchLoading; - // isInitialLoading briefly flips true again on the local-table path every - // time the query changes (each keystroke while searching, or picking a - // new category) — useVodCatalogPage's own isInitialLoading resets for - // each new filter. - // Gating the *entire* screen (including the search input the user is - // mid-keystroke in, and the category dropdown) on that would unmount and - // reset them on every character typed — hasEverShownContent latches once - // the shell has rendered at least once, so only the true first paint - // (nothing to show at all yet) blocks on the full-screen skeleton; a - // requery after that renders the existing shell with an in-place grid - // loading state instead (see the grid's own isInitialLoading check below). - const hasEverShownContentRef = useRef(false); - if (!isInitialLoading) hasEverShownContentRef.current = true; - const showFullScreenSkeleton = isInitialLoading && !hasEverShownContentRef.current; - - // Header for the content area: the category (or search) being shown and, + // Header for the content area: the category being shown and, // for a single category, how many titles it holds. - const headerTitle = trimmedQuery ? `Results for "${trimmedQuery}"` : isAllCategories ? "Movies" : activeCategoryLabel; + const headerTitle = isAllCategories ? "Movies" : activeCategoryLabel; const headerCount = gridMovies ? (isLocalCatalogReady ? localGridTotal : categoryFetchMovies.length) : null; + // "Updated 3h ago", and a Refresh for the one category on screen. + const freshness = useCatalogFreshness(source, "vod", isAllCategories ? undefined : activeCategoryId, isLocalCatalogReady); + const { canRefresh, refresh: refreshCategory } = freshness; + const headerDetail = [headerCount != null ? `${headerCount} ${headerCount === 1 ? "title" : "titles"}` : null, freshness.updatedLabel].filter(Boolean).join(" · "); const firstContentId = gridMovies ? (gridMovies[0] ? gridItemId(gridMovies[0].id) : undefined) : shelves[0]?.items[0]?.id; const firstContentIdRef = useRef(firstContentId); @@ -251,6 +247,8 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac // focus follows into the new content once its focus graph registers — // see the browse graph effect below. const focusContentOnNextGraphRef = useRef(false); + // Set when focus was parked on the Search button because there was nothing else to hold it (still loading). + const focusParkedRef = useRef(false); const selectCategory = useCallback((id: string) => { if (id === activeCategoryIdRef.current) { @@ -259,24 +257,25 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac if (first) useFocusStore.getState().focus(first); return; } + if (id !== ALL_CATEGORIES_ID) recordCategoryUse(profile.id, source.id, "vod", id); focusContentOnNextGraphRef.current = true; setActiveCategoryId(id); - }, []); + }, [profile.id, source.id]); - /** Moves focus into the category rail (which expands it), leaving the search box's native focus if it had it. */ + /** Moves focus into the category rail (which expands it). */ const openCategoryRail = useCallback(() => { - if (document.activeElement instanceof HTMLElement) document.activeElement.blur(); useFocusStore.getState().focus(categoryRailItemId(activeCategoryIdRef.current)); }, []); - // Search input's proxy focus node — see VodSearchInput's doc comment for - // why a native needs a Focusable stand-in rather than being a - // spatial-nav node itself. Sits to the right of the category dropdown's - // trigger in the same sticky bar. + // The header's buttons, beside the title — Refresh (one category shown) then Search: Left toward the category rail, Down into the content. useEffect(() => { - const node = { id: SEARCH_INPUT_ID, neighbors: { left: categoryRailItemId(activeCategoryId), down: firstContentId }, onSelect: () => searchInputRef.current?.focus() }; - setGraph("chrome:vod-search", [node], undefined, { passive: true }); - }, [activeCategoryId, firstContentId, setGraph, clearGraph]); + const railEntryId = categoryRailItemId(activeCategoryId); + const search = { id: SEARCH_BUTTON_ID, neighbors: { left: canRefresh ? REFRESH_BUTTON_ID : railEntryId, down: firstContentId }, onSelect: onOpenSearch }; + const nodes = canRefresh + ? [{ id: REFRESH_BUTTON_ID, neighbors: { left: railEntryId, right: SEARCH_BUTTON_ID, down: firstContentId }, onSelect: refreshCategory }, search] + : [search]; + setGraph("chrome:vod-search", nodes, undefined, { passive: true }); + }, [activeCategoryId, firstContentId, onOpenSearch, canRefresh, refreshCategory, setGraph, clearGraph]); // Rebuilds above replace the scope in place (setGraph is atomic); clearing // it on every rebuild would drop focus for an instant and snap it back to // the first node. Clear only when this component goes away. @@ -285,28 +284,45 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac // Browse mode's focus graph: either the shelf browser (one row per // category, plus a trailing "See all" card) or a single flat grid (one - // category from the rail, or a search) — never both. Up from the top row - // reaches the search box; Left from the first column opens the category + // category from the rail) — never both. Up from the top row reaches the + // Search button; Left from the first column opens the category // rail on the active category. useEffect(() => { const railEntryId = categoryRailItemId(activeCategoryId); const claimFocus = (id: string) => { - if (!focusContentOnNextGraphRef.current) return; + const parked = focusParkedRef.current && useFocusStore.getState().focusedId === SEARCH_BUTTON_ID; + focusParkedRef.current = false; + if (!focusContentOnNextGraphRef.current && !parked) return; focusContentOnNextGraphRef.current = false; useFocusStore.getState().focus(id); }; + // Nothing to focus in the content yet: hold focus in the top bar so Left + // (category rail) and Back still work, and hand it to the content once it loads. + const parkFocus = () => { + const { focusedId } = useFocusStore.getState(); + // Only take focus nobody holds — or move focus this screen parked itself. + if (focusedId !== null && !(focusParkedRef.current && focusedId === SEARCH_BUTTON_ID)) return; + if (showSyncNotice) { + focusParkedRef.current = false; + useFocusStore.getState().focus(railEntryId); + return; + } + useFocusStore.getState().focus(SEARCH_BUTTON_ID); + focusParkedRef.current = true; + }; if (gridMovies) { const ids = gridMovies.map((item) => gridItemId(item.id)); if (ids.length === 0) { setGraph(CONTENT_ENTRY_SCOPE, []); + parkFocus(); return; } const nodes = buildGridFocusGraph(ids, gridColumns).map((node, index) => ({ ...node, neighbors: { ...node.neighbors, - up: index < gridColumns ? SEARCH_INPUT_ID : node.neighbors.up, + up: index < gridColumns ? SEARCH_BUTTON_ID : node.neighbors.up, left: index % gridColumns === 0 ? railEntryId : node.neighbors.left, }, })); @@ -318,8 +334,7 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac const rows = shelves.map((shelf) => [...shelf.items.map((item) => item.id), seeAllId(shelf.id)]); if (rows.length === 0) { setGraph(CONTENT_ENTRY_SCOPE, []); - // Nothing to browse yet (the table is still being built): the rail is the only thing to hold focus. - if (showSyncNotice && useFocusStore.getState().focusedId === null) useFocusStore.getState().focus(railEntryId); + parkFocus(); return; } const categoryBySeeAllId = new Map(shelves.map((shelf) => [seeAllId(shelf.id), shelf.id])); @@ -330,7 +345,7 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac ...node, neighbors: { ...node.neighbors, - up: index < rows[0].length ? SEARCH_INPUT_ID : node.neighbors.up, + up: index < rows[0].length ? SEARCH_BUTTON_ID : node.neighbors.up, left: rowStarts.has(node.id) ? railEntryId : node.neighbors.left, }, onSelect: seeAllCategory ? () => selectCategory(seeAllCategory) : undefined, @@ -374,7 +389,7 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac if (isFocusInGridEndZone && gridHasMore) loadMoreGrid?.(); }, [isFocusInGridEndZone, gridMovies, gridHasMore, loadMoreGrid]); - // Lookups (play/favourite) search whichever list is actually on screen. + // Lookups (play/favourite) look in whichever list is actually on screen. const visibleMovies = useMemo(() => gridMovies ?? shelves.flatMap((shelf) => shelf.items), [gridMovies, shelves]); useRemoteInput( @@ -404,17 +419,19 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac // The grid/shelves, memoised so screen-level state that doesn't change - // them — the category menu opening/closing, each raw keystroke in the - // search box — doesn't re-render every card on screen. + // them — e.g. the category menu opening/closing — doesn't re-render every + // card on screen. const browseContent = useMemo( () => ( <> {showSyncNotice ? ( - 0} canPickCategory={source.kind === "xtream"} /> + + ) : isCheckingLocalCatalog || (gridMovies && isGridLoading) || (!gridMovies && isShelvesLoading) ? ( + ) : gridMovies ? ( gridMovies.length === 0 ? (

- {trimmedQuery ? `No movies match "${trimmedQuery}".` : "No movies in this category."} + No movies in this category.

) : (
), - [gridMovies, shelves, trimmedQuery, favoriteMovieIds, onPlay, selectCategory, showSyncNotice, syncState, source.kind], + [gridMovies, shelves, favoriteMovieIds, onPlay, selectCategory, showSyncNotice, syncState, source.kind, isGridLoading, isShelvesLoading, isCheckingLocalCatalog, useCategoryFetch], ); // Only a category fetch that failed with nothing to show is a hard error. @@ -478,13 +495,6 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac ); } - if (showFullScreenSkeleton) { - return ( - - - - ); - } return ( @@ -499,10 +509,10 @@ export function VodScreen({ source, platform, profile, onPlay, onBack, isPlaybac activeId={activeCategoryId} onSelect={selectCategory} rightEntryId={firstContentId} - sectionBreakAt={1} + sections={railSections} /> - {/* Sticky so the title and search stay visible while shelves or a long grid scroll underneath, instead of scrolling away with the content. */} + {/* Sticky so the title and Search button stay visible while shelves or a long grid scroll underneath, instead of scrolling away with the content. */}
{headerTitle}
- {headerCount != null && ( -
- {headerCount} {headerCount === 1 ? "title" : "titles"} -
+ {headerDetail && ( +
{headerDetail}
)}
-
- +
+ {canRefresh && ( + + )} +
{browseContent}
+ {freshness.result && } ); } - -interface VodSearchInputProps { - value: string; - onChange: (value: string) => void; -} - -/** - * Plain native for filtering movies by title — same pattern as - * SeriesScreen's SeriesSearchInput (see its doc comment for the full - * rationale: text entry goes through the platform's on-screen keyboard once - * the input has native focus, useRemoteInput steps aside from arrow - * keys/Enter while a text field has focus, and the SEARCH_INPUT_ID - * Focusable is a thin proxy that hands off to the real input via ref). - */ -const VodSearchInput = forwardRef(function VodSearchInput({ value, onChange }, ref) { - const isFocused = useIsFocused(SEARCH_INPUT_ID); - - return ( - -
- - onChange(e.target.value)} - placeholder="Search movies" - style={{ - flex: 1, - border: "none", - background: "transparent", - padding: 0, - fontSize: TV_TEXT, - color: "var(--text, #f4f4f6)", - }} - /> - {value && ( - - )} -
-
- ); -}); diff --git a/src/search/normalize.test.ts b/src/search/normalize.test.ts new file mode 100644 index 0000000..919b1ed --- /dev/null +++ b/src/search/normalize.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { indexTokens, queryTokens, scoreTitle, stripProviderTags, SCORE_EXACT, SCORE_STARTS_WITH, SCORE_WORDS_ANY_ORDER, SCORE_WORDS_IN_ORDER } from "./normalize.js"; + +describe("stripProviderTags", () => { + it.each([ + ["EN | Breaking Bad", "Breaking Bad"], + ["FR: Amélie", "Amélie"], + ["en | the office", "the office"], + ["[4K] Dune", "Dune"], + ["(MULTI) Dune", "Dune"], + ["4K - Dune Part Two", "Dune Part Two"], + ["UHD HD The Matrix", "The Matrix"], + ["EN | 4K | Avatar", "Avatar"], + ["VIP: DE | Tatort", "Tatort"], + ])("%s → %s", (title, expected) => { + expect(stripProviderTags(title)).toBe(expected); + }); + + it("leaves real words alone", () => { + expect(stripProviderTags("Up: The Movie")).toBe("Up: The Movie"); // "Up" isn't an upper-case code + expect(stripProviderTags("Heat")).toBe("Heat"); + expect(stripProviderTags("HD")).toBe("HD"); // a title that is only a tag keeps it + expect(stripProviderTags("Hdtv Nights")).toBe("Hdtv Nights"); + }); +}); + +describe("tokens", () => { + it("folds case and accents, splits on punctuation, and drops stopwords", () => { + expect(indexTokens("EN | The Lord of the Rings: The Return of the King")).toEqual(["lord", "of", "rings", "return", "king"]); + expect(indexTokens("Amélie")).toEqual(["amelie"]); + expect(indexTokens("Spider-Man: No Way Home (2021)")).toEqual(["spider", "man", "no", "way", "home", "2021"]); + }); + + it("keeps a title or query made only of stopwords", () => { + expect(indexTokens("The The")).toEqual(["the"]); + expect(queryTokens("the")).toEqual(["the"]); + expect(queryTokens("The Matrix")).toEqual(["matrix"]); + }); +}); + +describe("scoreTitle", () => { + const score = (query: string, title: string) => scoreTitle(queryTokens(query), title); + + it("ranks exact, then starts-with, then words in order, then any order", () => { + expect(score("matrix", "The Matrix")).toBe(SCORE_EXACT); + expect(score("matrix re", "The Matrix Reloaded")).toBe(SCORE_STARTS_WITH); + expect(score("mat", "The Matrix")).toBe(SCORE_STARTS_WITH); + expect(score("breaking b", "EN | Breaking Bad")).toBe(SCORE_STARTS_WITH); + expect(score("break bad", "EN | Breaking Bad")).toBe(SCORE_WORDS_IN_ORDER); // "break" only starts "breaking" + expect(score("bad", "EN | Breaking Bad")).toBe(SCORE_WORDS_IN_ORDER); + expect(score("king return", "The Return of the King")).toBe(SCORE_WORDS_ANY_ORDER); + expect(score("xyz", "The Matrix")).toBe(0); + expect(score("atrix", "The Matrix")).toBe(0); // words match from their start only + }); +}); diff --git a/src/search/normalize.ts b/src/search/normalize.ts new file mode 100644 index 0000000..186b911 --- /dev/null +++ b/src/search/normalize.ts @@ -0,0 +1,101 @@ +/** + * How titles and queries are turned into search words, and how well a title + * matches a query — shared by the background indexer (search-indexer.ts, in + * its worker) and the query side (search-index.ts), so both always agree. + * Pure functions, no I/O. + */ + +/** + * Provider tags a title can start with: `EN |`, `FR:`, `[4K]`, `(MULTI)`, + * `4K`, `HD`… Only a leading run of them is stripped. The `XX:`/`XX |` form + * must be upper-case in the original title, so a real word such as "Up:" + * isn't mistaken for one. + */ +const BRACKETED_TAG = /^[[(][^\])]{1,12}[\])]\s*[-|:]?\s*/; +const CODE_TAG = /^[A-Z0-9]{2,4}\s*[|:]\s*/; +const CODE_TAG_PIPE = /^[a-zA-Z0-9]{2,4}\s*\|\s*/; +const QUALITY_TAG = /^(?:4k|uhd|fhd|hd|sd|hevc|vip|multi)(?=[\s\-|:]|$)\s*[-|:]?\s*/i; + +/** Words that carry no meaning in a title search; left out of the index and of queries. */ +const STOPWORDS = new Set(["the", "a", "an", "le", "la", "les", "el", "los", "las", "der", "die", "das"]); + +/** A title's index gets at most this many words, so a very long name can't bloat the index. */ +const MAX_TOKENS = 12; + +/** Removes a leading run of provider tags; returns the title unchanged if that would leave nothing. */ +export function stripProviderTags(title: string): string { + let rest = title.trim(); + for (;;) { + const match = BRACKETED_TAG.exec(rest) ?? CODE_TAG.exec(rest) ?? CODE_TAG_PIPE.exec(rest) ?? QUALITY_TAG.exec(rest); + if (!match || match[0].length === 0 || match[0].length >= rest.length) break; + rest = rest.slice(match[0].length); + } + return rest.length > 0 ? rest : title.trim(); +} + +/** Lower-cased, with accents removed ("Amélie" → "amelie"). */ +export function foldCase(text: string): string { + return text.normalize("NFKD").replace(/\p{M}+/gu, "").toLowerCase(); +} + +function words(text: string): string[] { + return foldCase(text) + .split(/[^\p{L}\p{N}]+/u) + .filter((word) => word.length > 0); +} + +/** A title's words in order, provider tags and stopwords removed. */ +export function titleTokens(title: string): string[] { + const all = words(stripProviderTags(title)); + const meaningful = all.filter((word) => !STOPWORDS.has(word)); + return meaningful.length > 0 ? meaningful : all; +} + +/** The distinct words a title is indexed under. */ +export function indexTokens(title: string): string[] { + return [...new Set(titleTokens(title))].slice(0, MAX_TOKENS); +} + +/** A query's words in order, stopwords removed (unless that leaves nothing). */ +export function queryTokens(query: string): string[] { + const all = words(query); + const meaningful = all.filter((word) => !STOPWORDS.has(word)); + return meaningful.length > 0 ? meaningful : all; +} + +export const SCORE_EXACT = 100; +export const SCORE_STARTS_WITH = 80; +export const SCORE_WORDS_IN_ORDER = 60; +export const SCORE_WORDS_ANY_ORDER = 40; + +/** + * How well `title` matches the query's words (every query word is treated + * as the start of a word, since the last one is usually still being typed): + * + * - 100: the title is the query + * - 80: the title starts with the query + * - 60: every query word starts a title word, in order + * - 40: every query word starts a title word, in any order + * - 0: no match + */ +export function scoreTitle(query: string[], title: string): number { + if (query.length === 0) return 0; + const tokens = titleTokens(title); + const joinedTitle = tokens.join(" "); + const joinedQuery = query.join(" "); + if (joinedTitle === joinedQuery) return SCORE_EXACT; + if (joinedTitle.startsWith(joinedQuery)) return SCORE_STARTS_WITH; + + let from = 0; + let inOrder = true; + for (const word of query) { + const at = tokens.findIndex((token, index) => index >= from && token.startsWith(word)); + if (at === -1) { + inOrder = false; + break; + } + from = at + 1; + } + if (inOrder) return SCORE_WORDS_IN_ORDER; + return query.every((word) => tokens.some((token) => token.startsWith(word))) ? SCORE_WORDS_ANY_ORDER : 0; +} diff --git a/src/search/recent-searches.test.ts b/src/search/recent-searches.test.ts new file mode 100644 index 0000000..f5e145e --- /dev/null +++ b/src/search/recent-searches.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { deleteProfile, removeSourceUserData } from "../profile-store.js"; +import { loadRecentSearches, recordRecentSearch, RECENT_SEARCH_LIMIT } from "./recent-searches.js"; + +beforeEach(() => localStorage.clear()); + +describe("recent searches", () => { + it("keeps the newest first, without repeats, up to the limit", () => { + recordRecentSearch("p1", "s1", "Matrix"); + recordRecentSearch("p1", "s1", "news"); + recordRecentSearch("p1", "s1", " matrix "); + expect(loadRecentSearches("p1", "s1")).toEqual(["matrix", "news"]); + + for (let i = 0; i < RECENT_SEARCH_LIMIT + 5; i++) recordRecentSearch("p1", "s1", `q${i}`); + expect(loadRecentSearches("p1", "s1")).toHaveLength(RECENT_SEARCH_LIMIT); + expect(loadRecentSearches("p1", "s1")[0]).toBe(`q${RECENT_SEARCH_LIMIT + 4}`); + }); + + it("keeps profiles and playlists apart, and is cleared with them", () => { + recordRecentSearch("p1", "s1", "a"); + recordRecentSearch("p1", "s2", "b"); + recordRecentSearch("p2", "s1", "c"); + expect(loadRecentSearches("p2", "s2")).toEqual([]); + + removeSourceUserData("s2"); + expect(loadRecentSearches("p1", "s2")).toEqual([]); + expect(loadRecentSearches("p1", "s1")).toEqual(["a"]); + + deleteProfile("p1"); + expect(loadRecentSearches("p1", "s1")).toEqual([]); + expect(loadRecentSearches("p2", "s1")).toEqual(["c"]); + }); +}); diff --git a/src/search/recent-searches.ts b/src/search/recent-searches.ts new file mode 100644 index 0000000..6c74bc8 --- /dev/null +++ b/src/search/recent-searches.ts @@ -0,0 +1,60 @@ +/** + * What each profile recently searched for in each playlist, newest first — + * the Search screen's empty state offers them again. Stored in localStorage + * (`iptv.recent-searches.v1`), keyed `${profileId}|${sourceId}`. A query is + * recorded when one of its results is opened, never per keystroke, so + * half-typed queries don't show up. + */ + +const RECENT_SEARCHES_KEY = "iptv.recent-searches.v1"; + +/** How many recent searches are kept per profile and playlist. */ +export const RECENT_SEARCH_LIMIT = 10; + +type RecentByScope = Record; + +const scopeKey = (profileId: string, sourceId: string) => `${profileId}|${sourceId}`; + +function read(): RecentByScope { + try { + const raw = localStorage.getItem(RECENT_SEARCHES_KEY); + return raw ? (JSON.parse(raw) as RecentByScope) : {}; + } catch { + return {}; + } +} + +function write(value: RecentByScope): void { + try { + localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(value)); + } catch { + // Storage full or unavailable — the empty state just shows fewer suggestions. + } +} + +export function loadRecentSearches(profileId: string, sourceId: string): string[] { + return read()[scopeKey(profileId, sourceId)] ?? []; +} + +export function recordRecentSearch(profileId: string, sourceId: string, query: string): void { + const normalized = query.trim().toLowerCase(); + if (normalized.length === 0) return; + const all = read(); + const key = scopeKey(profileId, sourceId); + all[key] = [normalized, ...(all[key] ?? []).filter((existing) => existing !== normalized)].slice(0, RECENT_SEARCH_LIMIT); + write(all); +} + +function removeScopesMatching(matches: (profileId: string, sourceId: string) => boolean): void { + const all = read(); + const kept = Object.fromEntries(Object.entries(all).filter(([key]) => !matches(...(key.split("|") as [string, string])))); + if (Object.keys(kept).length !== Object.keys(all).length) write(kept); +} + +export function removeSourceRecentSearches(sourceId: string): void { + removeScopesMatching((_profileId, id) => id === sourceId); +} + +export function removeProfileRecentSearches(profileId: string): void { + removeScopesMatching((id) => id === profileId); +} diff --git a/src/search/search-index-scheduler.test.ts b/src/search/search-index-scheduler.test.ts new file mode 100644 index 0000000..d78fc65 --- /dev/null +++ b/src/search/search-index-scheduler.test.ts @@ -0,0 +1,158 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { __resetSyncStoreForTests, useSyncStore } from "../sync/sync-store.js"; +import type { SearchIndexRequest, SearchIndexResponse } from "../workers/search-index-protocol.js"; +import { pauseSearchIndexing, setSearchIndexPlaybackActive, startSearchIndexScheduler, type SearchIndexWorkerLike } from "./search-index-scheduler.js"; + +/** A stand-in worker that records what it was told and answers when the test says so. */ +class FakeWorker implements SearchIndexWorkerLike { + messages: SearchIndexRequest[] = []; + terminated = false; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: ErrorEvent) => void) | null = null; + postMessage(message: SearchIndexRequest): void { + this.messages.push(message); + } + terminate(): void { + this.terminated = true; + } + reply(message: SearchIndexResponse): void { + this.onmessage?.({ data: message } as MessageEvent); + } + get types(): string[] { + return this.messages.map((m) => m.type); + } +} + +const JOBS = [{ sourceId: "src-1", kind: "vod" as const }]; +let workers: FakeWorker[]; +let stop: () => void; + +function start(): void { + stop = startSearchIndexScheduler({ + getJobs: () => JOBS, + createWorker: () => { + const worker = new FakeWorker(); + workers.push(worker); + return worker; + }, + idleMs: 3000, + pollMs: 1000, + paceMs: 150, + retryAfterMs: 60_000, + }); +} +const latest = () => workers[workers.length - 1]; +const press = () => document.dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowDown" })); + +beforeEach(() => { + vi.useFakeTimers(); + workers = []; + __resetSyncStoreForTests(); + setSearchIndexPlaybackActive(false); +}); +afterEach(() => { + stop(); + vi.useRealTimers(); +}); + +describe("search index scheduler", () => { + it("waits for 3 s of quiet after launch, then runs the jobs in a worker", () => { + start(); + vi.advanceTimersByTime(2000); + expect(workers).toHaveLength(0); + vi.advanceTimersByTime(1000); + expect(latest().messages).toEqual([{ type: "run", jobs: JOBS, paceMs: 150 }]); + }); + + it("a key press pauses the run straight away, and it resumes only after 3 s without one", () => { + start(); + vi.advanceTimersByTime(3000); + press(); + expect(latest().types).toEqual(["run", "pause"]); + latest().reply({ type: "paused" }); + + vi.advanceTimersByTime(2000); + press(); // still using the remote: the quiet period starts again + vi.advanceTimersByTime(2000); + expect(latest().types).toEqual(["run", "pause"]); + vi.advanceTimersByTime(1000); + expect(latest().types).toEqual(["run", "pause", "run"]); + }); + + it("never runs during playback or a sync, or while the app is hidden", () => { + start(); + setSearchIndexPlaybackActive(true); + vi.advanceTimersByTime(10_000); + expect(workers).toHaveLength(0); + setSearchIndexPlaybackActive(false); + + useSyncStore.getState().beginRun("src-1", "launch"); + vi.advanceTimersByTime(10_000); + expect(workers).toHaveLength(0); + useSyncStore.getState().endRun("src-1"); + + Object.defineProperty(document, "visibilityState", { value: "hidden", configurable: true }); + document.dispatchEvent(new Event("visibilitychange")); + vi.advanceTimersByTime(10_000); + expect(workers).toHaveLength(0); + Object.defineProperty(document, "visibilityState", { value: "visible", configurable: true }); + document.dispatchEvent(new Event("visibilitychange")); + + vi.advanceTimersByTime(3000); + expect(latest().types).toEqual(["run"]); + }); + + it("playback or a sync starting mid-run pauses it", () => { + start(); + vi.advanceTimersByTime(3000); + setSearchIndexPlaybackActive(true); + expect(latest().types).toEqual(["run", "pause"]); + latest().reply({ type: "paused" }); + setSearchIndexPlaybackActive(false); + + vi.advanceTimersByTime(3000); + useSyncStore.getState().beginRun("src-1", "interval"); + expect(latest().types).toEqual(["run", "pause", "run", "pause"]); + }); + + it("once everything is indexed the worker is shut down, and a finished sync makes it wanted again", () => { + start(); + vi.advanceTimersByTime(3000); + const first = latest(); + first.reply({ type: "done" }); + expect(first.terminated).toBe(true); + vi.advanceTimersByTime(60_000); + expect(workers).toHaveLength(1); // nothing more to do + + useSyncStore.getState().beginRun("src-1", "interval"); + useSyncStore.getState().endRun("src-1"); + vi.advanceTimersByTime(3000); + expect(workers).toHaveLength(2); + expect(latest().types).toEqual(["run"]); + }); + + it("after a failed run it waits before trying again", () => { + start(); + vi.advanceTimersByTime(3000); + latest().reply({ type: "error", message: "boom" }); + vi.advanceTimersByTime(30_000); + expect(workers).toHaveLength(1); + vi.advanceTimersByTime(30_000); + expect(workers).toHaveLength(2); + }); + + it("pauseSearchIndexing resolves once the worker has stopped", async () => { + start(); + await expect(pauseSearchIndexing()).resolves.toBeUndefined(); // not running: straight away + vi.advanceTimersByTime(3000); + + let resolved = false; + void pauseSearchIndexing().then(() => (resolved = true)); + expect(latest().types).toEqual(["run", "pause"]); + await Promise.resolve(); + expect(resolved).toBe(false); + latest().reply({ type: "paused" }); + await Promise.resolve(); + expect(resolved).toBe(true); + }); +}); diff --git a/src/search/search-index-scheduler.ts b/src/search/search-index-scheduler.ts new file mode 100644 index 0000000..d647b62 --- /dev/null +++ b/src/search/search-index-scheduler.ts @@ -0,0 +1,193 @@ +import { LITE_EFFECTS } from "@ui"; +import { useSyncStore } from "../sync/sync-store.js"; +import type { SearchIndexRequest, SearchIndexResponse } from "../workers/search-index-protocol.js"; +import type { SearchIndexJob } from "./search-indexer.js"; + +/** + * Decides when the background search indexer may run — the lowest-priority + * work in the app (docs/global-search-plan.md, "When the indexer may run"). + * It runs only while all of these hold, and is paused the moment any stops + * holding, resuming once they've all held again for `idleMs`: + * + * - no remote or keyboard input, + * - nothing playing (the player needs the CPU for video), + * - no sync running for any playlist, + * - the app in the foreground. + * + * The main thread does no indexing work: it only tells the worker to run + * or pause. The worker is created on the first idle window and terminated + * once everything is indexed; a finished sync makes it wanted again. + */ + +/** What the scheduler needs from a worker — a real Worker, or a stand-in in tests. */ +export interface SearchIndexWorkerLike { + postMessage(message: SearchIndexRequest): void; + terminate(): void; + onmessage: ((event: MessageEvent) => void) | null; + onerror: ((event: ErrorEvent) => void) | null; +} + +export interface SearchIndexSchedulerOptions { + /** What to index, in order — read each time a run starts. */ + getJobs: () => SearchIndexJob[]; + /** Null where workers aren't available: then nothing is indexed (never on the main thread) and search stays on prefix matching. */ + createWorker?: () => SearchIndexWorkerLike | null; + /** How long everything must have been quiet before a run starts. */ + idleMs?: number; + /** How often to check whether a run may start. */ + pollMs?: number; + /** The worker's pause after each unit. */ + paceMs?: number; + /** After a failed run, wait this long before trying again. */ + retryAfterMs?: number; +} + +function createSearchIndexWorker(): SearchIndexWorkerLike | null { + if (typeof Worker === "undefined") return null; + return new Worker(new URL("../workers/search-index-worker.ts", import.meta.url), { type: "module" }) as unknown as SearchIndexWorkerLike; +} + +let playbackActive = false; +const playbackListeners = new Set<() => void>(); + +/** App reports whether the player is open — indexing never runs during playback. */ +export function setSearchIndexPlaybackActive(active: boolean): void { + if (active === playbackActive) return; + playbackActive = active; + for (const listener of playbackListeners) listener(); +} + +let pauseCurrent: (() => Promise) | null = null; + +/** + * Pauses indexing and resolves once the worker has stopped (or straight + * away if it isn't running) — so removing a playlist can delete its rows + * without a unit still in flight writing some back. + */ +export function pauseSearchIndexing(): Promise { + return pauseCurrent ? pauseCurrent() : Promise.resolve(); +} + +/** How long pauseSearchIndexing waits for the worker before giving up on it. */ +const PAUSE_TIMEOUT_MS = 3000; + +export function startSearchIndexScheduler(options: SearchIndexSchedulerOptions): () => void { + const { + getJobs, + createWorker = createSearchIndexWorker, + idleMs = 3000, + pollMs = 1000, + paceMs = LITE_EFFECTS ? 400 : 150, + retryAfterMs = 60_000, + } = options; + + let state: "idle" | "running" | "pausing" = "idle"; + let wanted = true; + let lastBusyAt = Date.now(); // launch counts as activity: the app's own start-up comes first + let retryAt = 0; + let worker: SearchIndexWorkerLike | null = null; + let settledWaiters: Array<() => void> = []; + + const anySyncRunning = () => Object.values(useSyncStore.getState().sources).some((source) => source.isRunning); + const isBusy = () => playbackActive || anySyncRunning() || document.visibilityState === "hidden"; + + function settle(): void { + state = "idle"; + const waiters = settledWaiters; + settledWaiters = []; + for (const resolve of waiters) resolve(); + } + + function dropWorker(): void { + worker?.terminate(); + worker = null; + } + + function pause(): void { + if (state !== "running" || !worker) return; + state = "pausing"; + worker.postMessage({ type: "pause" }); + } + + /** Something the user (or the app) is doing: pause now, and restart the quiet period. */ + function markBusy(): void { + lastBusyAt = Date.now(); + pause(); + } + + function onResponse(message: SearchIndexResponse): void { + if (message.type === "done") { + wanted = false; + dropWorker(); + } else if (message.type === "error") { + retryAt = Date.now() + retryAfterMs; + dropWorker(); + } + settle(); + } + + function tick(): void { + if (!wanted || state !== "idle") return; + const now = Date.now(); + if (isBusy() || now - lastBusyAt < idleMs || now < retryAt) return; + const jobs = getJobs(); + if (jobs.length === 0) { + wanted = false; + return; + } + if (!worker) { + worker = createWorker(); + if (!worker) { + wanted = false; + return; + } + worker.onmessage = (event) => onResponse(event.data); + worker.onerror = () => onResponse({ type: "error", message: "Search index worker failed" }); + } + state = "running"; + worker.postMessage({ type: "run", jobs, paceMs }); + } + + const onInput = () => markBusy(); + const onVisibility = () => markBusy(); + const onPlayback = () => markBusy(); + document.addEventListener("keydown", onInput, { capture: true, passive: true }); + document.addEventListener("visibilitychange", onVisibility); + playbackListeners.add(onPlayback); + let wasSyncing = anySyncRunning(); + const unsubscribeSync = useSyncStore.subscribe(() => { + const syncing = anySyncRunning(); + if (syncing === wasSyncing) return; + wasSyncing = syncing; + markBusy(); + if (!syncing) wanted = true; // a finished sync may have changed what's indexed + }); + const interval = setInterval(tick, pollMs); + + const pauseThis = () => { + if (state === "idle") return Promise.resolve(); + pause(); + return new Promise((resolve) => { + const timer = setTimeout(() => { + dropWorker(); // unresponsive: a terminated worker's open transaction is rolled back, never half-written + settle(); + }, PAUSE_TIMEOUT_MS); + settledWaiters.push(() => { + clearTimeout(timer); + resolve(); + }); + }); + }; + pauseCurrent = pauseThis; + + return () => { + clearInterval(interval); + document.removeEventListener("keydown", onInput, { capture: true }); + document.removeEventListener("visibilitychange", onVisibility); + playbackListeners.delete(onPlayback); + unsubscribeSync(); + dropWorker(); + settle(); + if (pauseCurrent === pauseThis) pauseCurrent = null; + }; +} diff --git a/src/search/search-index.test.ts b/src/search/search-index.test.ts new file mode 100644 index 0000000..d7454d5 --- /dev/null +++ b/src/search/search-index.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { + __clearCatalogDbForTests, + __resetCatalogDbForTests, + catalogSyncMetaKey, + openCatalogDb, + putRecordsBatch, + putSyncMeta, + queryPage, + SEARCH_TOKENS_STORE, + type CatalogDb, + type CatalogRecord, + writeCatalogChanges, +} from "../core/storage/catalog-db.js"; +import { __clearLiveDbForTests, __resetLiveDbForTests, deleteStaleChannels, openLiveDb, putChannels, putLiveSyncMeta } from "../core/storage/live-db.js"; +import { deleteSearchIndexForSource, getSearchIndexMeta, searchIndexMetaKey } from "../core/storage/search-index-db.js"; +import type { Channel } from "@core"; +import { searchCatalog, searchChannels } from "./search-index.js"; +import { runIndexJobs, runIndexUnit } from "./search-indexer.js"; + +const SOURCE = "src-1"; +const OTHER = "src-2"; + +function movie(streamId: string, name: string, generation = 1, sourceId = SOURCE, groupTitle = "cat-1"): CatalogRecord { + return { id: `${sourceId}:${streamId.padStart(15, "0")}`, sourceId, streamId, name, nameLower: name.toLowerCase(), groupTitle, streamUrl: "x", generation }; +} + +async function seed(catalogDb: CatalogDb, records: CatalogRecord[], generation = 1, sourceId = SOURCE): Promise { + await putRecordsBatch(catalogDb, "vod", records); + await putSyncMeta(catalogDb, { key: catalogSyncMetaKey(sourceId, "vod"), lastSyncedAt: Date.now(), recordCount: records.length, generation }); +} + +const noSleep = () => Promise.resolve(); +async function indexAll(catalogDb: CatalogDb, sourceId = SOURCE): Promise { + await runIndexJobs(catalogDb, [{ sourceId, kind: "vod" }], { paceMs: 0, shouldStop: () => false, sleep: noSleep, unitSize: 2 }); +} +async function names(query: string, limit = 20): Promise { + return (await searchCatalog(SOURCE, "vod", query, { limit })).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); + }); +} + +beforeEach(async () => { + __resetCatalogDbForTests(); + await __clearCatalogDbForTests(); + __resetLiveDbForTests(); + await __clearLiveDbForTests(); +}); + +describe("search index", () => { + it("before indexing, search still finds titles by their start", async () => { + const catalogDb = await openCatalogDb(); + await seed(catalogDb, [movie("1", "Matrix Reloaded"), movie("2", "The Matrix")]); + expect(await names("mat")).toEqual(["Matrix Reloaded"]); + }); + + it("once indexed, any word finds a title — provider tags and articles don't get in the way — best match first", async () => { + const catalogDb = await openCatalogDb(); + await seed(catalogDb, [ + movie("1", "Matrix Reloaded"), + movie("2", "The Matrix"), + movie("3", "EN | Breaking Bad"), + movie("4", "Bad Boys"), + movie("5", "Inception"), + ]); + await indexAll(catalogDb); + + expect(await names("matrix")).toEqual(["The Matrix", "Matrix Reloaded"]); // exact before starts-with + expect(await names("breaking bad")).toEqual(["EN | Breaking Bad"]); + expect(await names("bad")).toEqual(["Bad Boys", "EN | Breaking Bad"]); // starts-with before a later word + expect(await names("zzz")).toEqual([]); + }); + + it("shows a title listed several times (under different ids) once — its best-ranked, newest entry", async () => { + const catalogDb = await openCatalogDb(); + await seed(catalogDb, [movie("1", "The Matrix"), movie("7", "The matrix"), movie("3", "The Matrix Reloaded"), movie("5", "EN | The Matrix")]); + await indexAll(catalogDb); + const result = await searchCatalog(SOURCE, "vod", "matrix", { limit: 20 }); + expect(result.items.map((m) => `${m.id} ${m.name}`)).toEqual(["7 The matrix", "5 EN | The Matrix", "3 The Matrix Reloaded"]); + expect(result.hasMore).toBe(false); + }); + + it("reports when there are more matches than asked for", async () => { + const catalogDb = await openCatalogDb(); + await seed(catalogDb, [movie("1", "Star A"), movie("2", "Star B"), movie("3", "Star C")]); + await indexAll(catalogDb); + const result = await searchCatalog(SOURCE, "vod", "star", { limit: 2 }); + expect(result.items).toHaveLength(2); + expect(result.hasMore).toBe(true); + }); + + it("applies a Kids filter", async () => { + const catalogDb = await openCatalogDb(); + await seed(catalogDb, [movie("1", "Frozen", 1, SOURCE, "kids"), movie("2", "Fright Night", 1, SOURCE, "adult")]); + await indexAll(catalogDb); + const filter = { key: "k", accepts: (r: CatalogRecord) => r.groupTitle === "kids", isCategoryAllowed: () => true, pickedIds: [], moreForKids: false }; + const result = await searchCatalog(SOURCE, "vod", "fr", { limit: 20, filter }); + expect(result.items.map((m) => m.name)).toEqual(["Frozen"]); + }); + + it("works in small units, stops at a unit boundary and resumes from where it stopped", async () => { + const catalogDb = await openCatalogDb(); + await seed(catalogDb, ["1", "2", "3", "4", "5"].map((id) => movie(id, `Film ${id}`))); + + let units = 0; + const result = await runIndexJobs(catalogDb, [{ sourceId: SOURCE, kind: "vod" }], { + paceMs: 0, + sleep: noSleep, + unitSize: 2, + shouldStop: () => units++ >= 2, // two units, then stop + }); + expect(result).toBe("stopped"); + const meta = await getSearchIndexMeta(catalogDb, searchIndexMetaKey(SOURCE, "vod")); + expect(meta).toMatchObject({ phase: "index", lastKey: movie("4", "").id }); // exactly four records written, no partial unit + expect(await countSearchRows(catalogDb)).toBe(4); + + await indexAll(catalogDb); + expect(await countSearchRows(catalogDb)).toBe(5); + expect(await getSearchIndexMeta(catalogDb, searchIndexMetaKey(SOURCE, "vod"))).toMatchObject({ phase: "done" }); + expect(await runIndexUnit(catalogDb, { sourceId: SOURCE, kind: "vod" })).toBe("done"); + }); + + it("a new catalog generation re-indexes from the start and sweeps rows for titles that went away", async () => { + const catalogDb = await openCatalogDb(); + await seed(catalogDb, [movie("1", "Old Film"), movie("2", "Kept Film")]); + await indexAll(catalogDb); + + // The table is rebuilt as generation 2 without "Old Film". + await seed(catalogDb, [movie("2", "Kept Film", 2), movie("3", "New Film", 2)], 2); + await writeCatalogChanges(catalogDb, "vod", { put: [], delete: [movie("1", "Old Film").id] }); + // Until re-indexed: Kept Film is still found through its old row, Old Film's row points at nothing and is dropped, + // and New Film isn't indexed yet (nor does it start with "film"). + expect(await names("film")).toEqual(["Kept Film"]); + + await indexAll(catalogDb); + expect(await names("film")).toEqual(["New Film", "Kept Film"]); + expect(await countSearchRows(catalogDb)).toBe(2); // Old Film's row swept + }); + + it("indexing another playlist doesn't touch this one's, and removing a playlist deletes only its rows", async () => { + const catalogDb = await openCatalogDb(); + await seed(catalogDb, [movie("1", "The Matrix")]); + await seed(catalogDb, [movie("1", "The Matrix", 1, OTHER)], 1, OTHER); + await indexAll(catalogDb); + await indexAll(catalogDb, OTHER); + expect(await countSearchRows(catalogDb)).toBe(2); + + await deleteSearchIndexForSource(catalogDb, OTHER); + expect(await countSearchRows(catalogDb)).toBe(1); + expect(await getSearchIndexMeta(catalogDb, searchIndexMetaKey(OTHER, "vod"))).toBeUndefined(); + expect(await names("matrix")).toEqual(["The Matrix"]); + }); + + it("indexing never blocks reads of the movie table (its own store)", async () => { + const catalogDb = await openCatalogDb(); + await seed(catalogDb, [movie("1", "Heat")]); + // Hold a write transaction open on the search store, as a unit in flight would. + let released = false; + const tx = catalogDb.db.transaction(SEARCH_TOKENS_STORE, "readwrite"); + const store = tx.objectStore(SEARCH_TOKENS_STORE); + const spin = () => { + if (!released) store.count().onsuccess = spin; + }; + spin(); + + const page = await queryPage(catalogDb, "vod", { sourceId: SOURCE, offset: 0, limit: 10 }); + expect(page.map((r) => r.name)).toEqual(["Heat"]); + released = true; + }); +}); + +describe("live channel search", () => { + const list: Channel[] = [ + { id: "a", name: "EN | Sky News HD", streamUrl: "x", kind: "live", number: 1 }, + { id: "b", name: "News 24", streamUrl: "x", kind: "live", number: 2 }, + { id: "c", name: "BBC One", streamUrl: "x", kind: "live", number: 3 }, + { id: "b", name: "News 24", streamUrl: "y", kind: "live", number: 4 }, // an M3U's second entry for the same channel + ]; + + async function seedLive(generation = 1, channels = list): Promise { + const liveDb = await openLiveDb(); + await putChannels(liveDb, channels.map((channel, position) => ({ ...channel, sourceId: SOURCE, position, generation }))); + await putLiveSyncMeta(liveDb, { sourceId: SOURCE, lastSyncedAt: Date.now(), generation, channelCount: channels.length }); + } + const channelNames = async (query: string) => (await searchChannels(SOURCE, list, query, { limit: 20 })).items.map((c) => `${c.number} ${c.name}`); + + it("before indexing, finds channels by the start of their name — once each", async () => { + await seedLive(); + expect(await channelNames("news")).toEqual(["2 News 24"]); + }); + + it("once indexed, any word finds a channel; the best match first, then channel order", async () => { + await seedLive(); + await runIndexJobs(await openCatalogDb(), [{ sourceId: SOURCE, kind: "live" }], { paceMs: 0, shouldStop: () => false, sleep: noSleep, unitSize: 2 }); + expect(await channelNames("news")).toEqual(["2 News 24", "1 EN | Sky News HD"]); + expect(await channelNames("sky")).toEqual(["1 EN | Sky News HD"]); + expect((await searchChannels(SOURCE, list, "news", { limit: 20 })).bestScore).toBe(80); + }); + + it("a channel name listed twice under different ids appears once", async () => { + const twice: Channel[] = [ + { id: "x1", name: "BBC One", streamUrl: "x", kind: "live", number: 1 }, + { id: "x2", name: "BBC One", streamUrl: "y", kind: "live", number: 2 }, + ]; + const result = await searchChannels(SOURCE, twice, "bbc", { limit: 20 }); + expect(result.items.map((c) => c.id)).toEqual(["x1"]); + }); + + it("only channels in the list it's given are returned — a Kids-filtered list stays filtered", async () => { + await seedLive(); + await runIndexJobs(await openCatalogDb(), [{ sourceId: SOURCE, kind: "live" }], { paceMs: 0, shouldStop: () => false, sleep: noSleep }); + const allowed = list.filter((c) => c.id !== "a"); + const result = await searchChannels(SOURCE, allowed, "news", { limit: 20 }); + expect(result.items.map((c) => c.id)).toEqual(["b"]); + }); + + it("a new live list is re-indexed and channels that went away are swept", async () => { + await seedLive(); + const catalogDb = await openCatalogDb(); + await runIndexJobs(catalogDb, [{ sourceId: SOURCE, kind: "live" }], { paceMs: 0, shouldStop: () => false, sleep: noSleep }); + expect(await countSearchRows(catalogDb)).toBe(4); + + await seedLive(2, list.slice(0, 2)); // a shorter list: positions 0–1 rewritten… + await deleteStaleChannels(await openLiveDb(), SOURCE, 2); // …and 2–3 swept from the live table, as the live sync does + await runIndexJobs(catalogDb, [{ sourceId: SOURCE, kind: "live" }], { paceMs: 0, shouldStop: () => false, sleep: noSleep }); + expect(await countSearchRows(catalogDb)).toBe(2); + }); +}); diff --git a/src/search/search-index.ts b/src/search/search-index.ts new file mode 100644 index 0000000..238ef4b --- /dev/null +++ b/src/search/search-index.ts @@ -0,0 +1,125 @@ +import type { Channel, SeriesInfo } from "@core"; +import { recordToChannel, recordToSeriesSummary } from "../catalog-store.js"; +import type { CatalogFilter } from "../content-policy.js"; +import { getRecordsByIds, openCatalogDb, queryPage, type CatalogKind, type CatalogRecord } from "../core/storage/catalog-db.js"; +import { findSearchCandidates } from "../core/storage/search-index-db.js"; +import { foldCase, queryTokens, scoreTitle } from "./normalize.js"; + +/** + * The query side of search (docs/global-search-plan.md, "Querying"): word + * matches from the background index, merged with today's start-of-title + * matches — which are always complete, so search works fully before (and + * while) the index is built. Reads are a few short readonly transactions + * with capped sizes, so a query stays quick during indexing or a sync. + */ + +/** Rows read from the word index per query. */ +const WORD_CANDIDATES = 500; +/** Rows read from the start-of-title index per query. */ +const PREFIX_CANDIDATES = 100; +/** Score for a start-of-title match that the word rules don't cover (e.g. a query that includes a provider tag). */ +const SCORE_RAW_PREFIX = 50; + +type SeriesSummary = Pick; + +/** + * Titles that read the same on screen. Providers often list one film, + * series or channel several times under different ids (in several + * categories, or as backup streams); browsing spreads those across + * categories, but search would show them side by side as identical cards + * — so each title appears once, as its best-ranked entry. + */ +function sameTitleKey(name: string): string { + return foldCase(name).replace(/\s+/g, " ").trim(); +} + +function uniqueByTitle(ranked: T[], nameOf: (entry: T) => string): T[] { + const seen = new Set(); + return ranked.filter((entry) => { + const key = sameTitleKey(nameOf(entry)); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +export interface SearchCatalogOptions { + limit: number; + /** A Kids profile's filter (content-policy.ts); undefined for a standard profile. */ + filter?: CatalogFilter; +} + +export interface SearchCatalogResult { + items: T[]; + /** More than `limit` matched. */ + hasMore: boolean; + /** The best match's score (0 when nothing matched) — the Search screen orders its rows by it. */ + bestScore: number; +} + +export function searchCatalog(sourceId: string, kind: "vod", query: string, options: SearchCatalogOptions): Promise>; +export function searchCatalog(sourceId: string, kind: "series", query: string, options: SearchCatalogOptions): Promise>; +export async function searchCatalog(sourceId: string, kind: CatalogKind, query: string, { limit, filter }: SearchCatalogOptions): Promise> { + const words = queryTokens(query); + const rawPrefix = foldCase(query.trim()); + if (words.length === 0 && rawPrefix.length === 0) return { items: [], hasMore: false, bestScore: 0 }; + + const catalogDb = await openCatalogDb(); + // The longest word is the most selective one to look up; the rest are checked by scoring. + const lookup = words.reduce((longest, word) => (word.length > longest.length ? word : longest), ""); + const [candidateIds, prefixRecords] = await Promise.all([ + lookup ? findSearchCandidates(catalogDb, sourceId, kind, lookup, WORD_CANDIDATES) : Promise.resolve([]), + queryPage(catalogDb, kind, { sourceId, namePrefixLower: query.trim().toLowerCase(), predicate: filter?.accepts, offset: 0, limit: PREFIX_CANDIDATES }), + ]); + + const prefixIds = new Set(prefixRecords.map((record) => record.id)); + const missing = [...new Set(candidateIds)].filter((id) => !prefixIds.has(id)); + // A title removed since it was indexed simply isn't found here. + const wordRecords = await getRecordsByIds(catalogDb, kind, missing); + + const scored: Array<{ record: CatalogRecord; score: number }> = []; + for (const record of [...prefixRecords, ...wordRecords]) { + if (filter && !filter.accepts(record)) continue; + let score = scoreTitle(words, record.name); + if (score === 0 && prefixIds.has(record.id)) score = SCORE_RAW_PREFIX; + if (score > 0) scored.push({ record, score }); + } + // Best match first; equally good ones newest first (record ids sort by stream id). + scored.sort((a, b) => b.score - a.score || (a.record.id < b.record.id ? 1 : a.record.id > b.record.id ? -1 : 0)); + const unique = uniqueByTitle(scored, ({ record }) => record.name); + + const top = unique.slice(0, limit).map(({ record }) => (kind === "vod" ? recordToChannel(record, "movie") : recordToSeriesSummary(record))); + return { items: top, hasMore: unique.length > limit, bestScore: unique[0]?.score ?? 0 }; +} + +/** + * Live channels matching `query`, from the channel list the caller already + * holds (Kids-filtered, numbered): word matches via the index, plus a + * starts-with match on the list itself, which is complete before the + * channels are indexed. Best match first; equally good ones in channel + * order. A channel listed twice (the same id, or the same name) appears once. + */ +export async function searchChannels(sourceId: string, channels: Channel[], query: string, { limit }: { limit: number }): Promise> { + const words = queryTokens(query); + const rawPrefix = query.trim().toLowerCase(); + if (channels.length === 0 || (words.length === 0 && rawPrefix.length === 0)) return { items: [], hasMore: false, bestScore: 0 }; + + const lookup = words.reduce((longest, word) => (word.length > longest.length ? word : longest), ""); + const candidateIds = new Set(lookup ? await findSearchCandidates(await openCatalogDb(), sourceId, "live", lookup, WORD_CANDIDATES) : []); + + const scored: Array<{ channel: Channel; order: number; score: number }> = []; + const seen = new Set(); + channels.forEach((channel, order) => { + if (seen.has(channel.id)) return; + const byPrefix = channel.name.toLowerCase().startsWith(rawPrefix); + if (!byPrefix && !candidateIds.has(channel.id)) return; + let score = scoreTitle(words, channel.name); + if (score === 0 && byPrefix) score = SCORE_RAW_PREFIX; + if (score === 0) return; + seen.add(channel.id); + scored.push({ channel, order, score }); + }); + scored.sort((a, b) => b.score - a.score || a.order - b.order); + const unique = uniqueByTitle(scored, ({ channel }) => channel.name); + return { items: unique.slice(0, limit).map((entry) => entry.channel), hasMore: unique.length > limit, bestScore: unique[0]?.score ?? 0 }; +} diff --git a/src/search/search-indexer.ts b/src/search/search-indexer.ts new file mode 100644 index 0000000..a0ea7cc --- /dev/null +++ b/src/search/search-indexer.ts @@ -0,0 +1,131 @@ +import { catalogSyncMetaKey, getSyncMeta, type CatalogDb } from "../core/storage/catalog-db.js"; +import { getLiveSyncMeta, getSourceChannelsAfter, openLiveDb } from "../core/storage/live-db.js"; +import { + getSearchIndexMeta, + readCatalogRecordsAfter, + searchIndexMetaKey, + searchKey, + sweepStaleSearchRows, + writeSearchRows, + type SearchIndexMeta, + type SearchKind, + type SearchTokenRow, +} from "../core/storage/search-index-db.js"; +import { indexTokens } from "./normalize.js"; + +/** + * The background indexer's work, one small unit at a time (see + * docs/global-search-plan.md, "The indexer"). Runs inside + * workers/search-index-worker.ts, never on the main thread; kept here, free + * of any worker plumbing, so tests can drive it step by step. + */ + +/** Titles per unit: small enough that a pause request is honoured within a moment. */ +export const INDEX_UNIT_SIZE = 100; + +export interface SearchIndexJob { + sourceId: string; + kind: SearchKind; +} + +/** The generation to index against: the catalog's sync_meta, or the live list's. Undefined when it isn't downloaded (yet). */ +async function sourceGeneration(catalogDb: CatalogDb, { sourceId, kind }: SearchIndexJob): Promise { + if (kind === "live") return (await getLiveSyncMeta(await openLiveDb(), sourceId))?.generation; + return (await getSyncMeta(catalogDb, catalogSyncMetaKey(sourceId, kind)))?.generation; +} + +/** The next unit's rows after `lastKey`, and the key to resume after them — empty when the list is finished. */ +async function readUnit(catalogDb: CatalogDb, { sourceId, kind }: SearchIndexJob, generation: number, lastKey: string | number | null, unitSize: number): Promise<{ rows: SearchTokenRow[]; lastKey: string | number | null }> { + if (kind === "live") { + const channels = await getSourceChannelsAfter(await openLiveDb(), sourceId, typeof lastKey === "number" ? lastKey : null, unitSize); + const rows = channels.map((channel) => ({ + id: `live|${sourceId}:${channel.position}`, + sourceId, + kind, + recordId: channel.id, + generation, + keys: indexTokens(channel.name).map((word) => searchKey(sourceId, kind, word)), + })); + return { rows, lastKey: channels.length > 0 ? channels[channels.length - 1].position : null }; + } + const records = await readCatalogRecordsAfter(catalogDb, kind, sourceId, typeof lastKey === "string" ? lastKey : null, unitSize); + const rows = records.map((record) => ({ + id: `${kind}|${record.id}`, + sourceId, + kind, + recordId: record.id, + generation, + keys: indexTokens(record.name).map((word) => searchKey(sourceId, kind, word)), + })); + return { rows, lastKey: records.length > 0 ? records[records.length - 1].id : null }; +} + +/** + * Does one unit of work for a source and kind (a catalog, or the live + * channel list): + * + * - index: tokenizes the next INDEX_UNIT_SIZE records (or channels) and saves + * their rows together with the new position; + * - sweep: once every record is indexed, deletes up to INDEX_UNIT_SIZE rows + * left over from an older catalog generation; + * - done: nothing left until the catalog changes. + * + * A new catalog generation (a sync finished) starts the source and kind + * over from the beginning; its existing rows stay searchable meanwhile and + * are overwritten as it goes. Returns whether there's more to do. + */ +export async function runIndexUnit(catalogDb: CatalogDb, job: SearchIndexJob, unitSize = INDEX_UNIT_SIZE): Promise<"more" | "done"> { + const { sourceId, kind } = job; + const generation = await sourceGeneration(catalogDb, job); + if (generation === undefined) return "done"; // not downloaded (yet): nothing to index + + const key = searchIndexMetaKey(sourceId, kind); + let meta: SearchIndexMeta | undefined = await getSearchIndexMeta(catalogDb, key); + if (!meta || meta.generation !== generation) meta = { key, generation, lastKey: null, phase: "index" }; + if (meta.phase === "done") return "done"; + + if (meta.phase === "index") { + const unit = await readUnit(catalogDb, job, meta.generation, meta.lastKey, unitSize); + if (unit.rows.length === 0) { + await writeSearchRows(catalogDb, [], { ...meta, lastKey: null, phase: "sweep" }); + return "more"; + } + await writeSearchRows(catalogDb, unit.rows, { ...meta, lastKey: unit.lastKey }); + return "more"; + } + + // Sweep: the meta written with the final chunk says "done", in the same transaction as the deletes. + const deleted = await sweepStaleSearchRows(catalogDb, sourceId, kind, meta.generation, unitSize, { ...meta, phase: "sweep" }); + if (deleted < unitSize) { + await sweepStaleSearchRows(catalogDb, sourceId, kind, meta.generation, 0, { ...meta, phase: "done" }); + return "done"; + } + return "more"; +} + +export interface RunIndexJobsOptions { + /** Wait after each unit, so the indexer only ever takes a small share of the CPU. */ + paceMs: number; + /** Checked between units — true stops the run at that boundary. */ + shouldStop: () => boolean; + sleep?: (ms: number) => Promise; + unitSize?: number; +} + +/** + * Works through `jobs` in order, one unit at a time with a pause after + * each, until they're all done or `shouldStop()` says to stop. Never + * interrupted mid-unit, so there's no partial state to repair. + */ +export async function runIndexJobs(catalogDb: CatalogDb, jobs: SearchIndexJob[], options: RunIndexJobsOptions): Promise<"done" | "stopped"> { + const sleep = options.sleep ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + for (const job of jobs) { + for (;;) { + if (options.shouldStop()) return "stopped"; + const result = await runIndexUnit(catalogDb, job, options.unitSize); + if (result === "done") break; + await sleep(options.paceMs); + } + } + return "done"; +} diff --git a/src/settings-store.ts b/src/settings-store.ts index 35119f2..0206a2f 100644 --- a/src/settings-store.ts +++ b/src/settings-store.ts @@ -2,44 +2,63 @@ export type PlaybackSpeed = 0.5 | 1 | 1.25 | 1.5 | 2; /** What the launch sync does: nothing, only the parts that are out of date, or everything. */ export type UpdateOnLaunch = "off" | "when-stale" | "always"; export type GuideRefreshHours = 6 | 12 | 24; +export type CatalogRefreshHours = 24 | 48 | 72; export type GuideDaysToKeep = 1 | 3 | 7; /** Container for Xtream live channels — HLS (.m3u8) or MPEG-TS (.ts). */ export type LiveStreamFormat = "m3u8" | "ts"; +/** "auto" starts with the format that last worked for the playlist and switches by itself if it fails; the others are fixed. */ +export type LiveStreamFormatSetting = "auto" | LiveStreamFormat; export interface AppSettings { /** Background checks while the app runs — every 30 min, on return to the foreground, and when the network comes back (see sync-scheduler.ts). */ automaticRefresh: boolean; updateOnLaunch: UpdateOnLaunch; + /** Xtream movies and series are re-downloaded once they're older than this (see catalog-sync.ts). */ + catalogRefreshHours: CatalogRefreshHours; /** The programme guide is re-downloaded once it's older than this (see epg-sync.ts). */ guideRefreshHours: GuideRefreshHours; /** How many days ahead of the guide are stored; applies from the next guide update. */ guideDaysToKeep: GuideDaysToKeep; playbackSpeed: PlaybackSpeed; - /** Xtream live channels only; the player offers the other format if a channel fails (see live-stream-url.ts). */ - liveStreamFormat: LiveStreamFormat; + /** Xtream live channels only (see live-stream-url.ts). */ + liveStreamFormat: LiveStreamFormatSetting; } const SETTINGS_KEY = "iptv.settings.v1"; +/** Stored with the settings once liveStreamFormat can hold "auto" — see migrate. */ +const LIVE_STREAM_FORMAT_VERSION = 2; export const DEFAULT_SETTINGS: AppSettings = { automaticRefresh: true, updateOnLaunch: "when-stale", + catalogRefreshHours: 48, guideRefreshHours: 6, guideDaysToKeep: 3, playbackSpeed: 1, - liveStreamFormat: "m3u8", + liveStreamFormat: "auto", }; +/** + * Before "auto" existed the default was a stored "m3u8", indistinguishable + * from choosing HLS — so only "ts" (never the default) is kept as a + * deliberate choice, and everything else starts on Auto. + */ +function migrate({ liveStreamFormatVersion, ...stored }: Partial & { liveStreamFormatVersion?: number }): Partial { + if (liveStreamFormatVersion === LIVE_STREAM_FORMAT_VERSION) return stored; + const { liveStreamFormat, ...rest } = stored; + return liveStreamFormat === "ts" ? { ...rest, liveStreamFormat } : rest; +} + /** * App settings, persisted in localStorage. Each is read at the moment it * matters, so a change applies without a restart: sync-scheduler.ts on each - * trigger, epg-sync.ts on each guide check, and PlayerScreen each time a + * trigger, epg-sync.ts and catalog-sync.ts on each due check, and PlayerScreen each time a * stream starts (speed for films/episodes/catch-up — live is always 1x). */ export function loadSettings(): AppSettings { try { const raw = localStorage.getItem(SETTINGS_KEY); - return raw ? { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial) } : DEFAULT_SETTINGS; + return raw ? { ...DEFAULT_SETTINGS, ...migrate(JSON.parse(raw) as Partial) } : DEFAULT_SETTINGS; } catch { return DEFAULT_SETTINGS; } @@ -47,7 +66,7 @@ export function loadSettings(): AppSettings { export function saveSettings(settings: AppSettings): void { try { - localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); + localStorage.setItem(SETTINGS_KEY, JSON.stringify({ ...settings, liveStreamFormatVersion: LIVE_STREAM_FORMAT_VERSION })); } catch { // Storage full or unavailable — the change still applies for this session via the returned value. } diff --git a/src/sync/purge.test.ts b/src/sync/purge.test.ts index ccbb4a6..5c7287f 100644 --- a/src/sync/purge.test.ts +++ b/src/sync/purge.test.ts @@ -11,6 +11,8 @@ import { putSyncMeta, queryPage, } from "../core/storage/catalog-db.js"; +import { findSearchCandidates, getSearchIndexMeta, searchIndexMetaKey } from "../core/storage/search-index-db.js"; +import { runIndexJobs } from "../search/search-indexer.js"; import { __clearEpgDbForTests, __resetEpgDbForTests, getChannelProgrammes, getEpgSyncMeta, openEpgDb, putEpgSyncMeta, putProgrammes } from "../core/storage/epg-db.js"; import { getAllKeys, openIdbStore, clearStore } from "../core/storage/indexeddb-store.js"; import { __clearLiveDbForTests, __resetLiveDbForTests, getLiveSyncMeta, getSourceChannels, openLiveDb, putChannels, putLiveSyncMeta } from "../core/storage/live-db.js"; @@ -35,6 +37,7 @@ async function seed(sourceId: string): Promise { await putRecordsBatch(catalogDb, kind, [{ id: `${sourceId}:9`, sourceId, streamId: "9", name: "Title", nameLower: "title", groupTitle: "7", generation: 1 }]); await putSyncMeta(catalogDb, { key: catalogSyncMetaKey(sourceId, kind), lastSyncedAt: 1, recordCount: 1, generation: 1 }); } + await runIndexJobs(catalogDb, [{ sourceId, kind: "vod" }, { sourceId, kind: "series" }], { paceMs: 0, shouldStop: () => false, sleep: () => Promise.resolve() }); const epgDb = await openEpgDb(); await putProgrammes(epgDb, [{ sourceId, channelId: "bbc", start: 1, stop: 2, title: "Show", generation: 1 }]); @@ -57,6 +60,8 @@ async function storedFor(sourceId: string) { movies: (await queryPage(catalogDb, "vod", { sourceId, offset: 0, limit: 10 })).length, series: (await queryPage(catalogDb, "series", { sourceId, offset: 0, limit: 10 })).length, catalogMeta: [await getSyncMeta(catalogDb, catalogSyncMetaKey(sourceId, "vod")), await getSyncMeta(catalogDb, catalogSyncMetaKey(sourceId, "series"))].filter(Boolean).length, + searchRows: (await findSearchCandidates(catalogDb, sourceId, "vod", "title", 10)).length + (await findSearchCandidates(catalogDb, sourceId, "series", "title", 10)).length, + searchMeta: [await getSearchIndexMeta(catalogDb, searchIndexMetaKey(sourceId, "vod")), await getSearchIndexMeta(catalogDb, searchIndexMetaKey(sourceId, "series"))].filter(Boolean).length, programmes: (await getChannelProgrammes(epgDb, sourceId, "bbc")).length, epgMeta: await getEpgSyncMeta(epgDb, sourceId), cacheKeys: (await getAllKeys(await openIdbStore())).filter((key) => key.includes(sourceId)), @@ -103,6 +108,8 @@ describe("purge", () => { movies: 0, series: 0, catalogMeta: 0, + searchRows: 0, + searchMeta: 0, programmes: 0, epgMeta: undefined, cacheKeys: [], @@ -111,17 +118,24 @@ describe("purge", () => { syncStatus: 0, }), ); - expect(await storedFor(KEPT)).toMatchObject({ channels: 1, movies: 1, series: 1, catalogMeta: 2, programmes: 1, favourites: 2, history: 1, syncStatus: 1 }); + expect(await storedFor(KEPT)).toMatchObject({ channels: 1, movies: 1, series: 1, catalogMeta: 2, searchRows: 2, searchMeta: 2, programmes: 1, favourites: 2, history: 1, syncStatus: 1 }); expect((await storedFor(KEPT)).cacheKeys).toHaveLength(3); expect(m.order.slice(0, 2)).toEqual(["cancel", "idle"]); // stopped before anything was deleted }); - it("reset clears the downloaded data but keeps favourites and history, then re-syncs everything", async () => { + it("reset re-syncs everything without deleting the current data first, keeping favourites and history", async () => { const source: PlaylistSource = { kind: "xtream", id: GONE, name: "P", baseUrl: "http://tv.example", username: "u", password: "p" }; + const before = await storedFor(GONE); + m.syncSource.mockImplementation(async () => { + m.order.push("sync"); + // Still there while the sync runs — the write-then-swap replaces it in place. + expect(await storedFor(GONE)).toMatchObject({ channels: before.channels, movies: before.movies, series: before.series, programmes: before.programmes }); + return { stages: {}, errors: {} }; + }); + await resetSourceData(source); - const after = await storedFor(GONE); - expect(after).toMatchObject({ channels: 0, movies: 0, series: 0, programmes: 0, liveMeta: undefined, epgMeta: undefined, favourites: 2, history: 1 }); + expect(await storedFor(GONE)).toMatchObject({ favourites: 2, history: 1 }); expect(m.syncSource).toHaveBeenCalledWith(source, { trigger: "manual", force: true }); expect(m.order).toEqual(["cancel", "idle", "sync"]); }); diff --git a/src/sync/purge.ts b/src/sync/purge.ts index 6b57d12..103e4ad 100644 --- a/src/sync/purge.ts +++ b/src/sync/purge.ts @@ -1,7 +1,11 @@ import type { PlaylistSource } from "@core"; import { clearCatalogForSource } from "../catalog-sync.js"; -import { clearCachedContentForSource } from "../content-cache.js"; +import { clearCachedContentForSource, clearCachedContentMatching } from "../content-cache.js"; +import { openCatalogDb } from "../core/storage/catalog-db.js"; +import { deleteSearchIndexForSource } from "../core/storage/search-index-db.js"; import { clearEpgForSource } from "../epg-sync.js"; +import { pauseSearchIndexing } from "../search/search-index-scheduler.js"; +import { forgetWorkingLiveStreamFormat } from "../live-stream-url.js"; import { clearLiveForSource } from "../live-sync.js"; import { removeSourceUserData } from "../profile-store.js"; import { removeSourceKidsRules } from "../parental-store.js"; @@ -25,27 +29,41 @@ async function clearDownloadedData(sourceId: string): Promise { } /** - * Manage Playlists → "Reset data": throws away everything downloaded for - * the source and syncs it again from scratch. The user's favourites and - * history are kept — they're still the same playlist. + * Manage Playlists → "Reset data": downloads everything for the source + * again from scratch. The user's favourites and history are kept — they're + * still the same playlist. + * + * Nothing is deleted up front: live channels and the guide write under a + * new generation and sweep the old one only once it has landed, and the + * movie/series catalogs write only what changed (see catalog-sync-core.ts), so + * the current lists stay on screen until their replacements are ready, and + * a stage that fails leaves its previous data in place rather than an empty + * tab. Afterwards the small-item cache is cleared except for the category + * lists and account info the sync itself just refreshed. */ export async function resetSourceData(source: PlaylistSource): Promise { await stopSyncing(source.id); - await clearDownloadedData(source.id); useSyncStore.getState().forget(source.id); - return syncSource(source, { trigger: "manual", force: true }); + const outcome = await syncSource(source, { trigger: "manual", force: true }); + const segment = `:${source.id}`; + clearCachedContentMatching( + (key) => (key.endsWith(segment) || key.includes(`${segment}:`)) && !key.includes("-categories:") && !key.startsWith("playlist-info:"), + ); + return outcome; } /** - * The playlist is being removed: stop its sync, then delete everything - * stored for it — downloaded data, sync status, and every profile's - * favourites and Recently Watched entries for it — so nothing with its id - * is left behind. + * The playlist is being removed: stop its sync and the search indexer, then + * delete everything stored for it — downloaded data, its search index, sync + * status, and every profile's favourites and Recently Watched entries for + * it — so nothing with its id is left behind. */ export async function purgeSourceData(sourceId: string): Promise { - await stopSyncing(sourceId); + await Promise.all([stopSyncing(sourceId), pauseSearchIndexing()]); await clearDownloadedData(sourceId); + await deleteSearchIndexForSource(await openCatalogDb(), sourceId); removeSourceUserData(sourceId); removeSourceKidsRules(sourceId); + forgetWorkingLiveStreamFormat(sourceId); useSyncStore.getState().forget(sourceId); } diff --git a/src/sync/sync-manager.test.ts b/src/sync/sync-manager.test.ts index 217491f..ce4e6ed 100644 --- a/src/sync/sync-manager.test.ts +++ b/src/sync/sync-manager.test.ts @@ -81,16 +81,17 @@ describe("sync manager", () => { m.syncEpg.mockImplementation(tracked("epg", async () => ({ programmeCount: 500, channelCount: 5 }))); }); - it("signs in first, runs live/VOD/series two at a time, and the guide last", async () => { + it("signs in first, then runs Live TV, Series, Movies and the guide one at a time", async () => { const outcome = await syncSource(xtream, { trigger: "launch" }); expect(outcome.stages).toEqual({ auth: "synced", live: "synced", vod: "synced", series: "synced", epg: "synced" }); - expect(m.events[0]).toBe("auth:start"); - expect(m.events[1]).toBe("auth:end"); - // live and vod start together; series waits for one of them to finish. - expect(m.events.slice(2, 4)).toEqual(["live:start", "vod:start"]); - expect(m.events.indexOf("series:start")).toBeGreaterThan(m.events.indexOf("live:end")); - expect(m.events.slice(-2)).toEqual(["epg:start", "epg:end"]); + expect(m.events).toEqual([ + "auth:start", "auth:end", + "live:start", "live:end", + "series:start", "series:end", + "vod:start", "vod:end", + "epg:start", "epg:end", + ]); expect(getCachedContent(`playlist-info:${xtream.id}`)).toEqual({ name: "Provider", expiresAt: null }); expect(getCachedContent(`live-categories:${xtream.id}`)).toEqual([{ id: "1", name: "News", kind: "live" }]); }); diff --git a/src/sync/sync-manager.ts b/src/sync/sync-manager.ts index 0914fd0..3ed243a 100644 --- a/src/sync/sync-manager.ts +++ b/src/sync/sync-manager.ts @@ -1,5 +1,6 @@ import { XtreamAuthError, XtreamClient, type PlaylistSource } from "@core"; import { bumpCacheVersion } from "../cache-invalidation-store.js"; +import { CatalogEmptyError } from "../catalog-sync-core.js"; import { catalogVersionKey, isCatalogSyncDue, syncCatalog } from "../catalog-sync.js"; import { clearCachedContentMatching, isCacheStale, loadCachedEntry, setCachedContent, type CacheKind } from "../content-cache.js"; import { loadLiveCategories, loadSeriesCategories, loadVodCategories } from "../content-loader.js"; @@ -8,7 +9,7 @@ import { epgUrlFor, isEpgSyncDue, syncEpg } from "../epg-sync.js"; import { LiveEmptyError } from "../live-sync-core.js"; import { isLiveSyncDue, syncLiveChannels } from "../live-sync.js"; import { proxyFetch } from "../proxy-fetch.js"; -import { runWithConcurrency, withRetry } from "./retry.js"; +import { withRetry } from "./retry.js"; import { CONTENT_STAGES, useSyncStore, type ContentStage, type StageState, type StageStatus, type SyncStage, type SyncTrigger } from "./sync-store.js"; /** @@ -18,9 +19,9 @@ import { CONTENT_STAGES, useSyncStore, type ContentStage, type StageState, type * checks — now asks this module (via sync-scheduler.ts, Home's Refresh, or * a screen that finds its table empty). * - * A job runs a source's stages in order: sign-in (Xtream), then live, VOD - * and series two at a time, then the guide last (the biggest download and - * the least urgent). Each stage is skipped while its data is fresh unless + * A job runs a source's stages one at a time: sign-in (Xtream), then Live + * TV, Series, Movies and the guide last (CONTENT_STAGES order, whatever + * order the request listed them in). Each stage is skipped while its data is fresh unless * the request forces it, retried with backoff on failure, and reported to * sync-store.ts as it goes. One failing stage doesn't stop the others; a * failed sign-in stops the job, since every later stage would fail the same @@ -36,9 +37,6 @@ import { CONTENT_STAGES, useSyncStore, type ContentStage, type StageState, type /** Freshness of the account info (name/expiry) the sign-in stage refreshes. */ const ACCOUNT_INFO_KEY = (sourceId: string) => `playlist-info:${sourceId}`; -/** How many of live/VOD/series run at once. */ -const CONTENT_CONCURRENCY = 2; - export interface SyncRequest { trigger: SyncTrigger; /** Content stages to consider; all of them when omitted. */ @@ -124,16 +122,14 @@ const STAGE_RUNNERS: Record onProgress((written = n)) }); + const result = await syncCatalog(source, "vod", { onProgress }); await refreshCached(`vod-categories:${source.id}`, "category", () => loadVodCategories(source)); - return written; + return result?.recordCount; }, async series(source, onProgress) { - let written = 0; - await syncCatalog(source, "series", { onProgress: (n) => onProgress((written = n)) }); + const result = await syncCatalog(source, "series", { onProgress }); await refreshCached(`series-categories:${source.id}`, "category", () => loadSeriesCategories(source)); - return written; + return result?.recordCount; }, async epg(source, onProgress) { const result = await syncEpg(source, { onProgress }); @@ -141,9 +137,22 @@ const STAGE_RUNNERS: Record, jo try { const count = await withRetry(() => STAGE_RUNNERS[stage](source, (done) => report(stage, { status: "running", done })), { ...retryConfig, - shouldRetry: isRetryable, + shouldRetry: (err) => isRetryable(stage, err), }); const finishedAt = Date.now(); job.syncedAt[stage] = finishedAt; @@ -186,7 +195,7 @@ async function runJob(source: PlaylistSource, request: Required, jo useSyncStore.getState().beginRun(source.id, request.trigger); try { const toRun: ContentStage[] = []; - for (const stage of request.stages) { + for (const stage of CONTENT_STAGES.filter((s) => request.stages.includes(s))) { if (!appliesTo(source, stage)) report(stage, { status: "not-applicable" }); else if (request.force || (await isStageDue(source, stage))) { toRun.push(stage); @@ -202,12 +211,7 @@ async function runJob(source: PlaylistSource, request: Required, jo } } - await runWithConcurrency( - toRun.filter((stage) => stage !== "epg"), - CONTENT_CONCURRENCY, - async (stage) => void (await runStage(stage)), - ); - if (toRun.includes("epg")) await runStage("epg"); + for (const stage of toRun) await runStage(stage); return outcome; } finally { useSyncStore.getState().endRun(source.id); diff --git a/src/sync/sync-store.ts b/src/sync/sync-store.ts index e26a49c..7f781fd 100644 --- a/src/sync/sync-store.ts +++ b/src/sync/sync-store.ts @@ -1,9 +1,10 @@ import { create } from "zustand"; -/** The stages of one source's sync, in the order they run (auth first; live/vod/series in parallel; the guide last). */ +/** The stages of one source's sync. Auth runs first, then the content stages one at a time in CONTENT_STAGES order. */ export type SyncStage = "auth" | "live" | "vod" | "series" | "epg"; export type ContentStage = Exclude; -export const CONTENT_STAGES: ContentStage[] = ["live", "vod", "series", "epg"]; +/** Run order: Live TV, Series, Movies, then the guide (the biggest download and the least urgent). */ +export const CONTENT_STAGES: ContentStage[] = ["live", "series", "vod", "epg"]; /** What asked for a sync — shown nowhere yet, but recorded so the UI (Phase 3) can word progress differently for a first run vs. a manual refresh. */ export type SyncTrigger = "first-run" | "launch" | "resume" | "interval" | "online" | "manual"; diff --git a/src/sync/sync-summary.test.ts b/src/sync/sync-summary.test.ts index 9c28ec7..39f41ce 100644 --- a/src/sync/sync-summary.test.ts +++ b/src/sync/sync-summary.test.ts @@ -7,7 +7,6 @@ import type { SourceSyncState } from "./sync-store.js"; import { describeRunningSync, describeSyncFailure, formatCounts, formatSyncedAgo, readSyncSummary } from "./sync-summary.js"; const xtream: PlaylistSource = { kind: "xtream", id: "src-1", name: "P", baseUrl: "http://tv.example", username: "u", password: "p" }; -const m3u: PlaylistSource = { kind: "m3u-url", id: "src-2", name: "M", url: "http://tv.example/list.m3u" }; const state = (partial: Partial): SourceSyncState => ({ isRunning: false, stages: {}, ...partial }); describe("sync wording", () => { @@ -20,11 +19,10 @@ describe("sync wording", () => { expect(formatSyncedAgo(now - 80 * 3_600_000, now)).toBe("3 days ago"); }); - it("names the running stage and its progress", () => { + it("says a sync is in progress, without naming the stage or counting rows", () => { expect(describeRunningSync(state({}))).toBeNull(); - expect(describeRunningSync(state({ isRunning: true, stages: { auth: { status: "running" } } }))).toBe("Signing in…"); - expect(describeRunningSync(state({ isRunning: true, stages: { epg: { status: "running", done: 0 } } }))).toBe("Syncing TV guide…"); - expect(describeRunningSync(state({ isRunning: true, stages: { live: { status: "running", done: 300 } } }), m3u)).toBe("Syncing Channels & movies… 300"); + expect(describeRunningSync(state({ isRunning: true, stages: { auth: { status: "running" } } }))).toBe("Sync in progress…"); + expect(describeRunningSync(state({ isRunning: true, stages: { live: { status: "running", done: 300 } } }))).toBe("Sync in progress…"); }); it("explains a failed run — sign-in errors on their own, otherwise naming what failed", () => { diff --git a/src/sync/sync-summary.ts b/src/sync/sync-summary.ts index 92d3d4b..dba0dc4 100644 --- a/src/sync/sync-summary.ts +++ b/src/sync/sync-summary.ts @@ -87,15 +87,9 @@ export function formatSyncedAgo(timestamp: number, now = Date.now()): string { return `${days} ${days === 1 ? "day" : "days"} ago`; } -/** "Syncing Movies… 12,400" for the stage that's running, or null when nothing is. */ -export function describeRunningSync(state: SourceSyncState, source?: PlaylistSource): string | null { - if (!state.isRunning) return null; - const running = (Object.entries(state.stages) as Array<[SyncStage, SourceSyncState["stages"][SyncStage]]>).find(([, stage]) => stage?.status === "running"); - if (!running) return "Syncing…"; - const [stage, stageState] = running; - if (stage === "auth") return "Signing in…"; - const done = stageState?.done ?? 0; - return `Syncing ${stageLabel(stage, source)}…${done > 0 ? ` ${done.toLocaleString()}` : ""}`; +/** A generic "Sync in progress…" while the source syncs, or null when it isn't — no per-stage names or counts. */ +export function describeRunningSync(state: SourceSyncState): string | null { + return state.isRunning ? "Sync in progress…" : null; } /** The stages the last run failed or skipped, with the first error — null when it went fine. */ diff --git a/src/ui/components/CategoryRail.tsx b/src/ui/components/CategoryRail.tsx index a8721ce..c0c23cb 100644 --- a/src/ui/components/CategoryRail.tsx +++ b/src/ui/components/CategoryRail.tsx @@ -3,7 +3,7 @@ import { ChevronRight, LayoutList, type LucideIcon } from "lucide-react"; import { Focusable } from "../focus/Focusable.js"; import { buildListFocusGraph } from "../focus/build-grid-graph.js"; import { useFocusStore, useIsFocused } from "../focus/focus-store.js"; -import { CATEGORY_RAIL_COLLAPSED_WIDTH, CATEGORY_RAIL_EXPANDED_WIDTH, TV_HEADING, TV_TEXT } from "../tv-metrics.js"; +import { CATEGORY_RAIL_COLLAPSED_WIDTH, CATEGORY_RAIL_EXPANDED_WIDTH } from "../tv-metrics.js"; export interface CategoryRailItem { id: string; @@ -25,11 +25,24 @@ export interface CategoryRailProps { rightEntryId?: string; /** Rows before this index are "fixed" entries (e.g. Browse); a "Categories" label separates them from the rest. */ sectionBreakAt?: number; + /** Labelled breaks before the given row indexes, e.g. "Frequently used" then "Categories". Takes precedence over sectionBreakAt. */ + sections?: RailSection[]; +} + +export interface RailSection { + /** Index of the first row in this section. */ + at: number; + label: string; } const SCOPE = "chrome:category-rail"; const RAIL_PREFIX = "rail:"; const RAIL_TRANSITION = "280ms cubic-bezier(0.2, 0.9, 0.3, 1)"; +/** The rail's own type scale — larger than body text, since category names are read from across the room while scrolling a long list. */ +const RAIL_TITLE_SIZE = "2.25rem"; +const RAIL_ROW_SIZE = "1.75rem"; +const RAIL_COUNT_SIZE = "1.375rem"; +const RAIL_SECTION_SIZE = "1.125rem"; /** Focus id of a rail row — prefixed so a category id can never collide with a content item's id. */ export function categoryRailItemId(categoryId: string): string { @@ -51,7 +64,8 @@ export function categoryRailItemId(categoryId: string): string { * its own focus state, so moving through a long category list re-renders * two rows per press. */ -export function CategoryRail({ title, items, activeId, onSelect, rightEntryId, sectionBreakAt }: CategoryRailProps): JSX.Element { +export function CategoryRail({ title, items, activeId, onSelect, rightEntryId, sectionBreakAt, sections }: CategoryRailProps): JSX.Element { + const sectionLabels = new Map((sections ?? (sectionBreakAt !== undefined ? [{ at: sectionBreakAt, label: "Categories" }] : [])).map((s) => [s.at, s.label])); const setGraph = useFocusStore((state) => state.setGraph); const clearGraph = useFocusStore((state) => state.clearGraph); const isExpanded = useFocusStore((state) => state.focusedId?.startsWith(RAIL_PREFIX) ?? false); @@ -148,24 +162,11 @@ export function CategoryRail({ title, items, activeId, onSelect, rightEntryId, s transition: `transform ${RAIL_TRANSITION}`, }} > -
{title}
+
{title}
{items.map((item, index) => (
- {sectionBreakAt !== undefined && index === sectionBreakAt && ( -
- Categories -
- )} + {sectionLabels.has(index) && }
))} @@ -175,6 +176,32 @@ export function CategoryRail({ title, items, activeId, onSelect, rightEntryId, s ); } +/** + * A labelled break in the list ("Frequently used", "Categories"): accent + * coloured, with a rule running to the edge, so the groups stand apart + * from the rows rather than reading as one more dim row. + */ +function RailSectionHeader({ label }: { label: string }): JSX.Element { + return ( +
+ {label} + +
+ ); +} + /** * One rail row. Focused: a bright, solid row (the tvOS list focus * convention — unmistakable from across the room). Active (the category @@ -204,12 +231,12 @@ const CategoryRailRow = memo(function CategoryRailRow({ justifyContent: "space-between", gap: "1rem", width: "100%", - padding: "0.875rem 1.25rem", + padding: "1rem 1.25rem", marginBottom: "0.25rem", border: "none", borderRadius: "0.875rem", textAlign: "left", - fontSize: TV_TEXT, + fontSize: RAIL_ROW_SIZE, fontWeight: isActive || isFocused ? 700 : 500, background: isFocused ? "rgba(255,255,255,0.94)" : "transparent", color: isFocused ? "#0b0c10" : isActive ? "#ffffff" : "rgba(235,236,242,0.7)", @@ -223,10 +250,10 @@ const CategoryRailRow = memo(function CategoryRailRow({ /> )} - {item.icon && } + {item.icon && } {item.label} - {item.count !== undefined && {item.count}} + {item.count !== undefined && {item.count}} ); diff --git a/src/ui/components/ChannelSidebar.tsx b/src/ui/components/ChannelSidebar.tsx index c7009de..78ec83b 100644 --- a/src/ui/components/ChannelSidebar.tsx +++ b/src/ui/components/ChannelSidebar.tsx @@ -23,6 +23,8 @@ export interface ChannelSidebarProps { leftEntryId?: string; /** Focus id to jump to when the user presses right from any row, if anything sits to the right. */ rightEntryId?: string; + /** Focus id Up goes to from the first row — e.g. a button in the header above the list. */ + topEntryId?: string; } const SCOPE = "content:channel-sidebar"; @@ -60,6 +62,7 @@ export function ChannelSidebar({ numberById, leftEntryId, rightEntryId, + topEntryId, }: ChannelSidebarProps): JSX.Element { const setGraph = useFocusStore((state) => state.setGraph); const clearGraph = useFocusStore((state) => state.clearGraph); @@ -95,14 +98,14 @@ export function ChannelSidebar({ previousIdsRef.current = ids; const nodes = buildListFocusGraph(ids).map((node, index) => ({ ...node, - neighbors: { ...node.neighbors, left: leftEntryId, right: rightEntryId }, + neighbors: { ...node.neighbors, left: leftEntryId, right: rightEntryId, up: index === 0 ? topEntryId : node.neighbors.up }, onSelect: () => onSelectRef.current(channels[index]), })); setGraph(SCOPE, nodes); keepFocusRef.current = previousIndex >= 0 && ids.length > 0; // The focused row went away: its neighbour (whatever now sits at its place) takes focus, not the top of the list. if (keepFocusRef.current && focusedBefore && !ids.includes(focusedBefore)) focus(ids[Math.min(previousIndex, ids.length - 1)]); - }, [channels, leftEntryId, rightEntryId, setGraph, focus]); + }, [channels, leftEntryId, rightEntryId, topEntryId, setGraph, focus]); // Rebuilds above replace the scope in place (setGraph is atomic); clearing // it on every rebuild would drop focus for an instant and snap it back to diff --git a/src/ui/components/HeaderButton.tsx b/src/ui/components/HeaderButton.tsx new file mode 100644 index 0000000..e54f951 --- /dev/null +++ b/src/ui/components/HeaderButton.tsx @@ -0,0 +1,53 @@ +import type { LucideIcon } from "lucide-react"; +import { Focusable } from "../focus/Focusable.js"; +import { useIsFocused } from "../focus/focus-store.js"; + +export interface HeaderButtonProps { + /** Focus id — the owner wires it into its focus graph with `onSelect` as the node's onSelect. */ + id: string; + label: string; + icon: LucideIcon; + onSelect: () => void; + /** Dimmed while its action runs (the owner ignores selects meanwhile). */ + busy?: boolean; +} + +/** + * A pill button for a browse screen's header bar, beside the title — styled + * like Home's playlist chip so the header's buttons read as one set. + */ +export function HeaderButton({ id, label, icon: Icon, onSelect, busy = false }: HeaderButtonProps): JSX.Element { + const isFocused = useIsFocused(id); + return ( + + + + ); +} diff --git a/src/ui/components/LoadingState.tsx b/src/ui/components/LoadingState.tsx new file mode 100644 index 0000000..735be52 --- /dev/null +++ b/src/ui/components/LoadingState.tsx @@ -0,0 +1,40 @@ +import { LoaderCircle } from "lucide-react"; +import { SlowLoadHint } from "./SlowLoadHint.js"; + +export interface LoadingStateProps { + /** + * Centre on the screen (fixed to the viewport, so a sticky header or the + * scroll position can't push it off-centre) rather than inside the + * surrounding section — e.g. the episode row under a series' details. + */ + centered?: boolean; + /** + * Show "Still loading…" if this takes a while — only for a first load + * with nothing stored yet (a network fetch). Reads from the local tables + * are quick, and a hint there would only suggest the app is struggling. + */ + showSlowHint?: boolean; +} + +/** A plain spinner with "Loading…" — what the Movies and Series screens show while there's nothing to render yet. */ +export function LoadingState({ centered = false, showSlowHint = false }: LoadingStateProps): JSX.Element { + return ( +
+ + Loading… + {showSlowHint && } + +
+ ); +} diff --git a/src/ui/components/PlaybackControls.tsx b/src/ui/components/PlaybackControls.tsx index 2ba4029..a1cb50d 100644 --- a/src/ui/components/PlaybackControls.tsx +++ b/src/ui/components/PlaybackControls.tsx @@ -27,6 +27,14 @@ export const NEXT_EPISODE_WINDOW_SECONDS = 120; const audioOptionId = (id: number) => `player-audio:${id}`; const subtitleOptionId = (id: number | null) => `player-subtitle:${id ?? "off"}`; +export const streamFormatOptionId = (id: string) => `player-stream-format:${id}`; + +/** The stream-format choices the menu offers (live channels that come in more than one format). */ +export interface StreamFormatChoice { + options: Array<{ id: string; label: string }>; + activeId: string; + onSelect: (id: string) => void; +} export interface PlaybackControlsProps { title: string; @@ -67,6 +75,8 @@ export interface PlaybackControlsProps { onSelectAudioTrack: (id: number) => void; onSelectSubtitleTrack: (id: number | null) => void; onNextEpisode: () => void; + /** Adds a Stream Format column to the Audio & Subtitles panel — the manual way to switch a live channel between HLS and MPEG-TS. */ + streamFormat?: StreamFormatChoice; } export function formatPlaybackTime(totalSeconds: number): string { @@ -368,9 +378,10 @@ function SeekBadge({ deltaSeconds }: { deltaSeconds: number }): JSX.Element { } /** - * Netflix-style two-column Audio | Subtitles panel on the right. Its rows - * are a focus scope of their own: Up/Down within a column, Left/Right - * across at the same row, OK picks, Back (handled by PlayerScreen) closes. + * Netflix-style Audio | Subtitles panel on the right, plus a Stream Format + * column for live channels that offer one. Its rows are a focus scope of + * their own: Up/Down within a column, Left/Right across at the same row, + * OK picks, Back (handled by PlayerScreen) closes. */ function AudioSubtitlesPanel({ audioTracks, @@ -379,6 +390,7 @@ function AudioSubtitlesPanel({ activeSubtitleTrackId, onSelectAudioTrack, onSelectSubtitleTrack, + streamFormat, }: PlaybackControlsProps): JSX.Element { const setGraph = useFocusStore((state) => state.setGraph); const clearGraph = useFocusStore((state) => state.clearGraph); @@ -387,28 +399,32 @@ function AudioSubtitlesPanel({ // The engine doesn't report its starting audio track; until one is picked, the stream's default (first) track is the current one. const audioIds = audioTracks.map((track) => audioOptionId(track.id)); const subtitleIds = [subtitleOptionId(null), ...subtitleTracks.map((track) => subtitleOptionId(track.id))]; + const formatIds = (streamFormat?.options ?? []).map((option) => streamFormatOptionId(option.id)); - const latestRef = useRef({ audioTracks, subtitleTracks, onSelectAudioTrack, onSelectSubtitleTrack }); - latestRef.current = { audioTracks, subtitleTracks, onSelectAudioTrack, onSelectSubtitleTrack }; - const shapeKey = `${audioIds.join(",")}|${subtitleIds.join(",")}`; + const latestRef = useRef({ audioTracks, subtitleTracks, onSelectAudioTrack, onSelectSubtitleTrack, streamFormat }); + latestRef.current = { audioTracks, subtitleTracks, onSelectAudioTrack, onSelectSubtitleTrack, streamFormat }; + const shapeKey = `${audioIds.join(",")}|${subtitleIds.join(",")}|${formatIds.join(",")}`; useEffect(() => { - const column = (ids: string[], other: string[], side: "left" | "right"): FocusNode[] => + const select = (id: string) => { + const current = latestRef.current; + const audio = current.audioTracks.find((track) => audioOptionId(track.id) === id); + const format = current.streamFormat?.options.find((option) => streamFormatOptionId(option.id) === id); + if (audio) current.onSelectAudioTrack(audio.id); + else if (format) current.streamFormat?.onSelect(format.id); + else current.onSelectSubtitleTrack(current.subtitleTracks.find((track) => subtitleOptionId(track.id) === id)?.id ?? null); + }; + // Left/Right go to the neighbouring column at the same row (or its last row). + const columns = [audioIds, subtitleIds, formatIds].filter((ids) => ids.length > 0); + const rowIn = (ids: string[] | undefined, index: number) => ids?.[Math.min(index, ids.length - 1)]; + const nodes: FocusNode[] = columns.flatMap((ids, c) => ids.map((id, index) => ({ id, - neighbors: { - up: ids[index - 1], - down: ids[index + 1], - [side]: other.length > 0 ? other[Math.min(index, other.length - 1)] : undefined, - }, - onSelect: () => { - const current = latestRef.current; - const audio = current.audioTracks.find((track) => audioOptionId(track.id) === id); - if (audio) current.onSelectAudioTrack(audio.id); - else current.onSelectSubtitleTrack(current.subtitleTracks.find((track) => subtitleOptionId(track.id) === id)?.id ?? null); - }, - })); - setGraph(MENU_SCOPE, [...column(audioIds, subtitleIds, "right"), ...column(subtitleIds, audioIds, "left")]); + neighbors: { up: ids[index - 1], down: ids[index + 1], left: rowIn(columns[c - 1], index), right: rowIn(columns[c + 1], index) }, + onSelect: () => select(id), + })), + ); + setGraph(MENU_SCOPE, nodes); // eslint-disable-next-line react-hooks/exhaustive-deps }, [shapeKey, setGraph]); @@ -430,7 +446,7 @@ function AudioSubtitlesPanel({ top: 0, right: 0, bottom: 0, - width: "52rem", + width: streamFormat ? "72rem" : "52rem", boxSizing: "border-box", padding: `3.5rem ${BROWSE_SIDE_PADDING} 3rem 3rem`, display: "flex", @@ -440,7 +456,7 @@ function AudioSubtitlesPanel({ zIndex: 60, }} > -

Audio & Subtitles

+

{streamFormat ? "Audio, Subtitles & Stream" : "Audio & Subtitles"}

Press Back to return to the video

@@ -470,6 +486,19 @@ function AudioSubtitlesPanel({ /> ))} + {streamFormat && ( + + {streamFormat.options.map((option) => ( + streamFormat.onSelect(option.id)} + /> + ))} + + )}
diff --git a/src/ui/components/SearchButton.tsx b/src/ui/components/SearchButton.tsx new file mode 100644 index 0000000..127d05f --- /dev/null +++ b/src/ui/components/SearchButton.tsx @@ -0,0 +1,17 @@ +import { Search } from "lucide-react"; +import { HeaderButton } from "./HeaderButton.js"; + +export interface SearchButtonProps { + /** Focus id — the owner wires it into its focus graph with `onSelect` as the node's onSelect. */ + id: string; + onSelect: () => void; +} + +/** + * Opens the global Search screen. A button, not a text field: search always + * covers the whole playlist, so it lives on its own screen rather than in a + * header next to a category it doesn't filter. + */ +export function SearchButton({ id, onSelect }: SearchButtonProps): JSX.Element { + return ; +} diff --git a/src/ui/components/SearchKeyboard.test.tsx b/src/ui/components/SearchKeyboard.test.tsx new file mode 100644 index 0000000..3d8bece --- /dev/null +++ b/src/ui/components/SearchKeyboard.test.tsx @@ -0,0 +1,42 @@ +import { act, fireEvent, render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { useFocusStore } from "../focus/focus-store.js"; +import { SearchKeyboard, searchKeyId } from "./SearchKeyboard.js"; + +afterEach(() => useFocusStore.getState().clearGraph("search-keyboard")); + +const neighbors = (key: string) => useFocusStore.getState().nodes[searchKeyId(key)]?.neighbors; + +describe("SearchKeyboard", () => { + it("lays out A–Z and 0–9 six to a row, then Space, Delete and Clear", () => { + const { getAllByRole } = render( {}} onDelete={() => {}} onClear={() => {}} />); + const labels = getAllByRole("button").map((b) => b.getAttribute("aria-label")); + expect(labels.slice(0, 6)).toEqual(["A", "B", "C", "D", "E", "F"]); + expect(labels.slice(-3)).toEqual(["Space", "Delete", "Clear"]); + expect(labels).toHaveLength(39); + }); + + it("moves in a grid; the bottom row's wide keys sit under two columns each", () => { + render( {}} onDelete={() => {}} onClear={() => {}} rightExitId="first-result" />); + expect(neighbors("a")).toMatchObject({ right: searchKeyId("b"), down: searchKeyId("g"), up: undefined, left: undefined }); + expect(neighbors("f")?.right).toBe("first-result"); + expect(neighbors("5")?.down).toBe(searchKeyId("space")); // the last row is 4–9, so "5" is its second column + expect(neighbors("6")?.down).toBe(searchKeyId("delete")); // third column + expect(neighbors("delete")).toMatchObject({ up: searchKeyId("6"), left: searchKeyId("space"), right: searchKeyId("clear") }); + expect(neighbors("clear")?.right).toBe("first-result"); + }); + + it("keys type, Delete deletes and Clear clears", () => { + const onInput = vi.fn(); + const onDelete = vi.fn(); + const onClear = vi.fn(); + const { getByRole } = render(); + fireEvent.click(getByRole("button", { name: "Q" })); + fireEvent.click(getByRole("button", { name: "Space" })); + act(() => useFocusStore.getState().nodes[searchKeyId("delete")]?.onSelect?.()); + fireEvent.click(getByRole("button", { name: "Clear" })); + expect(onInput.mock.calls).toEqual([["q"], [" "]]); + expect(onDelete).toHaveBeenCalledTimes(1); + expect(onClear).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/ui/components/SearchKeyboard.tsx b/src/ui/components/SearchKeyboard.tsx new file mode 100644 index 0000000..e5510b7 --- /dev/null +++ b/src/ui/components/SearchKeyboard.tsx @@ -0,0 +1,125 @@ +import { useEffect, useRef } from "react"; +import { Delete, Space, X } from "lucide-react"; +import { Focusable } from "../focus/Focusable.js"; +import { useFocusStore, useIsFocused, type FocusNode } from "../focus/focus-store.js"; + +const SCOPE = "search-keyboard"; +const COLUMNS = 6; +const CHARACTERS = "abcdefghijklmnopqrstuvwxyz0123456789".split(""); +const ACTIONS = ["space", "delete", "clear"] as const; +type KeyboardAction = (typeof ACTIONS)[number]; + +/** Focus id of a key: a character (`"a"`, `"7"`) or one of the bottom-row actions. */ +export function searchKeyId(key: string): string { + return `kb:${key}`; +} + +export interface SearchKeyboardProps { + onInput: (character: string) => void; + onDelete: () => void; + onClear: () => void; + /** Where Right goes from the keyboard's right edge — the first result, when there is one. */ + rightExitId?: string; +} + +/** + * On-screen A–Z/0–9 keyboard for the Search screen — six keys a row, then + * Space, Delete and Clear, each two columns wide. Used instead of the TV's + * own on-screen keyboard, which covers half the screen and hides the results + * that update as the user types. + */ +export function SearchKeyboard({ onInput, onDelete, onClear, rightExitId }: SearchKeyboardProps): JSX.Element { + const setGraph = useFocusStore((state) => state.setGraph); + const clearGraph = useFocusStore((state) => state.clearGraph); + const handlersRef = useRef({ onInput, onDelete, onClear }); + handlersRef.current = { onInput, onDelete, onClear }; + + useEffect(() => { + setGraph(SCOPE, buildKeyboardGraph(handlersRef, rightExitId), undefined, { passive: true }); + }, [setGraph, rightExitId]); + useEffect(() => () => clearGraph(SCOPE), [clearGraph]); + + return ( +
+ {CHARACTERS.map((character) => ( + onInput(character)} /> + ))} + } span={2} onSelect={() => onInput(" ")} /> + } span={2} onSelect={onDelete} /> + } span={2} onSelect={onClear} /> +
+ ); +} + +function buildKeyboardGraph( + handlersRef: React.MutableRefObject<{ onInput: (character: string) => void; onDelete: () => void; onClear: () => void }>, + rightExitId: string | undefined, +): FocusNode[] { + const rows = Math.ceil(CHARACTERS.length / COLUMNS); + const at = (row: number, column: number) => searchKeyId(CHARACTERS[row * COLUMNS + column]); + const actionId = (column: number) => searchKeyId(ACTIONS[Math.floor(column / 2)]); + + const characterNodes: FocusNode[] = CHARACTERS.map((character, index) => { + const row = Math.floor(index / COLUMNS); + const column = index % COLUMNS; + return { + id: searchKeyId(character), + neighbors: { + up: row > 0 ? at(row - 1, column) : undefined, + down: row < rows - 1 ? at(row + 1, column) : actionId(column), + left: column > 0 ? at(row, column - 1) : undefined, + right: column < COLUMNS - 1 ? at(row, column + 1) : rightExitId, + }, + onSelect: () => handlersRef.current.onInput(character), + }; + }); + + const actionNodes: FocusNode[] = ACTIONS.map((action: KeyboardAction, index) => ({ + id: searchKeyId(action), + neighbors: { + up: at(rows - 1, index * 2), + left: index > 0 ? searchKeyId(ACTIONS[index - 1]) : undefined, + right: index < ACTIONS.length - 1 ? searchKeyId(ACTIONS[index + 1]) : rightExitId, + }, + onSelect: () => { + if (action === "space") handlersRef.current.onInput(" "); + else if (action === "delete") handlersRef.current.onDelete(); + else handlersRef.current.onClear(); + }, + })); + + return [...characterNodes, ...actionNodes]; +} + +function Key({ id, label, icon, span = 1, onSelect }: { id: string; label: string; icon?: React.ReactNode; span?: number; onSelect: () => void }): JSX.Element { + const isFocused = useIsFocused(id); + return ( + + + + ); +} diff --git a/src/ui/components/SeeAllCard.tsx b/src/ui/components/SeeAllCard.tsx index fabff25..db22195 100644 --- a/src/ui/components/SeeAllCard.tsx +++ b/src/ui/components/SeeAllCard.tsx @@ -9,35 +9,41 @@ export interface SeeAllCardProps { /** Category name, shown under "See all". */ label: string; onSelect: () => void; + /** Defaults to POSTER_WIDTH; screens with a narrower content area pass their own card width. */ + width?: number | string; + /** "poster" (2:3) matches a poster row; "wide" (16:9) matches a row of channel tiles, with its contents side by side. */ + shape?: "poster" | "wide"; } /** * Last card of a browse shelf: opens the shelf's full category. Shelves only * show a category's first few titles, so without this the row just ends and - * there's no way to reach the rest from where you are. Poster-sized and - * lifted on focus exactly like a FocusCard, so it reads as part of the row. + * there's no way to reach the rest from where you are. Sized like the row's + * cards (a poster, or a wide channel tile) and lifted on focus exactly like + * a FocusCard, so it reads as part of the row. */ -export function SeeAllCard({ id, label, onSelect }: SeeAllCardProps): JSX.Element { +export function SeeAllCard({ id, label, onSelect, width = POSTER_WIDTH, shape = "poster" }: SeeAllCardProps): JSX.Element { const isFocused = useIsFocused(id); + const wide = shape === "wide"; return (
- + +
+
+
See all
+
{label}
-
See all
-
{label}
); diff --git a/src/ui/components/SlowLoadHint.tsx b/src/ui/components/SlowLoadHint.tsx index 3654825..9c91fea 100644 --- a/src/ui/components/SlowLoadHint.tsx +++ b/src/ui/components/SlowLoadHint.tsx @@ -1,9 +1,11 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, type CSSProperties } from "react"; export interface SlowLoadHintProps { /** Milliseconds to wait before showing the hint — long enough that it never flashes on a normal, fast load. */ delayMs?: number; message?: string; + /** Overrides the default placement (inset at the top-left of a skeleton). */ + style?: CSSProperties; } const DEFAULT_DELAY_MS = 4000; @@ -19,7 +21,7 @@ const DEFAULT_MESSAGE = "Still loading — this can take a moment on a slower co * stuck rather than genuinely still working. Never appears on a normal, * fast load since it mounts and unmounts within delayMs. */ -export function SlowLoadHint({ delayMs = DEFAULT_DELAY_MS, message = DEFAULT_MESSAGE }: SlowLoadHintProps): JSX.Element | null { +export function SlowLoadHint({ delayMs = DEFAULT_DELAY_MS, message = DEFAULT_MESSAGE, style }: SlowLoadHintProps): JSX.Element | null { const [isVisible, setIsVisible] = useState(false); useEffect(() => { @@ -37,6 +39,7 @@ export function SlowLoadHint({ delayMs = DEFAULT_DELAY_MS, message = DEFAULT_MES color: "var(--text-dim, #9a9aa4)", opacity: 0, animation: "iptv-slow-load-hint-fade-in 400ms ease-out forwards", + ...style, }} > {message} diff --git a/src/ui/components/SplashScreen.tsx b/src/ui/components/SplashScreen.tsx deleted file mode 100644 index 4616b38..0000000 --- a/src/ui/components/SplashScreen.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { useEffect, useState } from "react"; - -export interface SplashScreenProps { - /** Fires once the exit fade-out has fully finished, so the caller can unmount this component. */ - onExited: () => void; -} - -/** - * ZenPlay's animated launch splash — an in-app overlay (not webOS's own - * static splashBackground, which the OS shows between tap-to-launch and - * this app's first paint and can't animate) that bridges the moment React - * takes over. See public/brand/zenplay-splash.svg for the editable master. - * - * Held for at least MIN_VISIBLE_MS so the ring-draw/logo/wordmark animation - * always completes even if the app's first real screen paints instantly — - * a splash that vanishes mid-draw reads as a glitch, not a brand moment. - * Deliberately NOT tied to any data fetch (auth, catalogs, etc.): those can - * take several seconds on a slow provider, and stacking that wait on top of - * this splash would turn a ~2s brand beat into an indefinite blocking - * screen. App.tsx hides this on first render of a real screen instead. - */ -const MIN_VISIBLE_MS = 1900; -const FADE_OUT_MS = 700; - -export function SplashScreen({ onExited }: SplashScreenProps): JSX.Element { - const [isHidden, setIsHidden] = useState(false); - - useEffect(() => { - const shownAt = Date.now(); - // The parent decides *when* to start hiding (via unmounting its own - // "still splashing" state), but this component still owns the minimum - // hold + fade timing itself so the animation never gets cut short. - const wait = Math.max(0, MIN_VISIBLE_MS - (Date.now() - shownAt)); - const hideTimer = setTimeout(() => setIsHidden(true), wait); - return () => clearTimeout(hideTimer); - }, []); - - useEffect(() => { - if (!isHidden) return; - const exitTimer = setTimeout(onExited, FADE_OUT_MS); - return () => clearTimeout(exitTimer); - }, [isHidden, onExited]); - - return ( -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- ); -} diff --git a/src/ui/components/URLImage.test.tsx b/src/ui/components/URLImage.test.tsx index ae39d54..87170c2 100644 --- a/src/ui/components/URLImage.test.tsx +++ b/src/ui/components/URLImage.test.tsx @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { act } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { URLImage } from "./URLImage.js"; @@ -37,6 +38,51 @@ describe("URLImage", () => { expect(document.querySelector("svg")).toBeTruthy(); }); + it("shows an image the browser already has cached as soon as it mounts, without waiting for load", () => { + const complete = vi.spyOn(HTMLImageElement.prototype, "complete", "get").mockReturnValue(true); + const width = vi.spyOn(HTMLImageElement.prototype, "naturalWidth", "get").mockReturnValue(300); + try { + render(); + expect((screen.getByRole("img", { hidden: true }) as HTMLImageElement).style.opacity).toBe("1"); + } finally { + complete.mockRestore(); + width.mockRestore(); + } + }); + + it("retries a failed image a couple of times before settling on the placeholder", () => { + vi.useFakeTimers(); + try { + render(); + const first = screen.getByRole("img", { hidden: true }) as HTMLImageElement; + fireEvent.error(first); + act(() => vi.advanceTimersByTime(3000)); + const second = screen.getByRole("img", { hidden: true }) as HTMLImageElement; + expect(second).not.toBe(first); // a fresh , so the browser asks again + fireEvent.load(second); + expect(second.style.opacity).toBe("1"); + } finally { + vi.useRealTimers(); + } + }); + + it("gives up after its retries", () => { + vi.useFakeTimers(); + try { + render(); + for (const wait of [3000, 6000]) { + fireEvent.error(screen.getByRole("img", { hidden: true })); + act(() => vi.advanceTimersByTime(wait)); + } + const last = screen.getByRole("img", { hidden: true }); + fireEvent.error(last); + act(() => vi.advanceTimersByTime(60_000)); + expect(screen.getByRole("img", { hidden: true })).toBe(last); + } finally { + vi.useRealTimers(); + } + }); + it("resets to the loading/placeholder state when src changes", () => { const { rerender } = render(); const imgA = screen.getByRole("img", { hidden: true }) as HTMLImageElement; diff --git a/src/ui/components/URLImage.tsx b/src/ui/components/URLImage.tsx index 1678e69..d965c9c 100644 --- a/src/ui/components/URLImage.tsx +++ b/src/ui/components/URLImage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { Clapperboard, Film, type LucideIcon } from "lucide-react"; import { LITE_EFFECTS } from "../perf-tier.js"; @@ -42,6 +42,10 @@ const PLACEHOLDER_VARIANTS: Array<{ gradient: string; icon: typeof Film }> = [ { gradient: "linear-gradient(160deg, #1f2e28 0%, #17211c 55%, #0f1512 100%)", icon: Clapperboard }, ]; +/** Extra tries for an image that failed to load; the waits are 3s, then 6s. */ +const MAX_RETRIES = 2; +const RETRY_DELAY_MS = 3000; + function hashSeed(seed: string): number { let hash = 0; for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) | 0; @@ -61,9 +65,14 @@ function pickVariant(seed: string): (typeof PLACEHOLDER_VARIANTS)[number] { * FocusCard being the main one — so this fallback behavior only needs to * be right in one place. * - * Resets to "loading" whenever `src` changes (rather than only on mount) so - * scrolling a recycled/keyed list back onto a different item's URL doesn't - * keep showing the previous item's error state. + * Status is kept per `src` (rather than reset by an effect when it + * changes): a poster served from the browser's cache can fire `load` before + * a post-mount effect runs, and a reset landing after it would hide an + * image that's already there — the "loads once, blank the next time" bug. + * An image the browser already has decoded is also caught at mount via + * `complete`/`naturalWidth`. A failed load is retried a couple of times + * after a short wait (a provider throttling a burst of poster requests + * fails some of them transiently) before settling on the placeholder. * * The loaded image crossfades in over the placeholder (opacity + a slight * scale-in) rather than popping in the instant it decodes — a hard swap @@ -77,11 +86,25 @@ function pickVariant(seed: string): (typeof PLACEHOLDER_VARIANTS)[number] { * cached response resolves within a frame or two of the others. */ export function URLImage({ src, alt = "", seed, loading = "lazy", style, className, objectFit = "cover", placeholderIcon }: URLImageProps): JSX.Element { - const [status, setStatus] = useState<"loading" | "loaded" | "error">(src ? "loading" : "error"); + const [state, setState] = useState<{ src?: string; status: "loading" | "loaded" | "error"; attempt: number }>({ src, status: "loading", attempt: 0 }); + // A different src starts over; the state only ever describes the src it was set for. + const current = state.src === src ? state : { src, status: "loading" as const, attempt: 0 }; + const status = src ? current.status : "error"; + const imgRef = useRef(null); + + // Already in the browser's cache and decoded: no need to wait for (or risk missing) `load`. + useLayoutEffect(() => { + const img = imgRef.current; + if (src && img?.complete && img.naturalWidth > 0) setState((prev) => (prev.src === src && prev.status === "loaded" ? prev : { src, status: "loaded", attempt: current.attempt })); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [src, current.attempt]); + // A failed load gets another try after a pause, remounting the so the browser requests it again. useEffect(() => { - setStatus(src ? "loading" : "error"); - }, [src]); + if (!src || current.status !== "error" || current.attempt >= MAX_RETRIES) return; + const timer = setTimeout(() => setState({ src, status: "loading", attempt: current.attempt + 1 }), RETRY_DELAY_MS * (current.attempt + 1)); + return () => clearTimeout(timer); + }, [src, current.status, current.attempt]); const isLoaded = status === "loaded"; const variant = pickVariant(seed ?? alt ?? src ?? "placeholder"); @@ -109,11 +132,14 @@ export function URLImage({ src, alt = "", seed, loading = "lazy", style, classNa
{src && ( {alt} setStatus("loaded")} - onError={() => setStatus("error")} + decoding="async" + onLoad={() => setState({ src, status: "loaded", attempt: current.attempt })} + onError={() => setState({ src, status: "error", attempt: current.attempt })} style={{ position: "absolute", inset: 0, diff --git a/src/ui/index.ts b/src/ui/index.ts index f18664c..8d23649 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -21,8 +21,10 @@ export * from "./components/MeshBackground.js"; export * from "./components/FavoriteHeart.js"; export * from "./components/CategoryRail.js"; export * from "./components/SeeAllCard.js"; +export * from "./components/HeaderButton.js"; +export * from "./components/SearchButton.js"; +export * from "./components/SearchKeyboard.js"; export * from "./components/SlowLoadHint.js"; -export * from "./components/SplashScreen.js"; export * from "./components/PlaybackControls.js"; export * from "./perf-tier.js"; export * from "./tv-metrics.js"; @@ -34,3 +36,4 @@ export * from "./components/ProfileAvatarTile.js"; export * from "./components/LiveGuideInfo.js"; export * from "./components/Toast.js"; export * from "./components/SyncPill.js"; +export * from "./components/LoadingState.js"; diff --git a/src/ui/section-icons.ts b/src/ui/section-icons.ts index e5636a8..afb922c 100644 --- a/src/ui/section-icons.ts +++ b/src/ui/section-icons.ts @@ -1,4 +1,4 @@ -import { Clapperboard, Film, Heart, History, ListVideo, Settings, Tv, type LucideIcon } from "lucide-react"; +import { Clapperboard, Film, Heart, History, ListVideo, Search, Settings, Tv, type LucideIcon } from "lucide-react"; /** * One icon per app section, shared by Home's menu tiles and each section's @@ -13,4 +13,5 @@ export const SECTION_ICONS = { favourites: Heart, history: History, settings: Settings, + search: Search, } satisfies Record; diff --git a/src/ui/tv-metrics.ts b/src/ui/tv-metrics.ts index d8df27f..39a9bef 100644 --- a/src/ui/tv-metrics.ts +++ b/src/ui/tv-metrics.ts @@ -15,7 +15,7 @@ export const BROWSE_SIDE_PADDING = "3.5rem"; /** Width of the collapsed category rail (see CategoryRail) — always on screen at the left edge. */ export const CATEGORY_RAIL_COLLAPSED_WIDTH = "5rem"; /** Width of the category rail when expanded over the content. */ -export const CATEGORY_RAIL_EXPANDED_WIDTH = "28rem"; +export const CATEGORY_RAIL_EXPANDED_WIDTH = "32rem"; /** Where browse content starts: after the collapsed rail, plus breathing room. */ export const BROWSE_CONTENT_LEFT = `calc(${CATEGORY_RAIL_COLLAPSED_WIDTH} + 2rem)`; /** diff --git a/src/use-catalog-freshness.ts b/src/use-catalog-freshness.ts new file mode 100644 index 0000000..1b873c8 --- /dev/null +++ b/src/use-catalog-freshness.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { PlaylistSource } from "@core"; +import { useCacheInvalidationStore } from "./cache-invalidation-store.js"; +import { catalogVersionKey, getCatalogSyncedAt, getCategoryRefreshedAt, refreshCatalogCategory, type CatalogKind } from "./catalog-sync.js"; +import { formatSyncedAgo } from "./sync/sync-summary.js"; + +export interface CatalogFreshness { + /** "Updated 3h ago" — undefined until the catalog has synced. */ + updatedLabel?: string; + /** Whether the shown category can be refreshed on its own (an Xtream catalog that has synced, one category on screen). */ + canRefresh: boolean; + isRefreshing: boolean; + refresh: () => void; + /** The last refresh's result, for a Toast — cleared by dismissResult. */ + result: { message: string; tone: "success" | "error" } | null; + dismissResult: () => void; +} + +/** + * How fresh the catalog a Movies/Series screen shows is, and its "Refresh" + * for the one category on screen (catalog-sync.ts's refreshCatalogCategory) + * — new titles in a category without waiting for the next full sync. + * Re-reads whenever the catalog changes (a sync or a refresh finished). + */ +export function useCatalogFreshness(source: PlaylistSource, kind: CatalogKind, categoryId: string | undefined, isCatalogReady: boolean): CatalogFreshness { + const version = useCacheInvalidationStore((state) => state.versions[catalogVersionKey(source.id, kind)]); + const [syncedAt, setSyncedAt] = useState(undefined); + const [isRefreshing, setIsRefreshing] = useState(false); + const [result, setResult] = useState(null); + + useEffect(() => { + if (!isCatalogReady) return; + let cancelled = false; + void getCatalogSyncedAt(source.id, kind).then((at) => { + if (!cancelled) setSyncedAt(at); + }); + return () => { + cancelled = true; + }; + }, [source.id, kind, isCatalogReady, version]); + + const categoryAt = categoryId !== undefined ? getCategoryRefreshedAt(source.id, kind, categoryId) : undefined; + const updatedAt = syncedAt !== undefined || categoryAt !== undefined ? Math.max(syncedAt ?? 0, categoryAt ?? 0) : undefined; + + // Selects arrive through the focus graph's onSelect, which may be a render behind. + const busyRef = useRef(false); + const refresh = useCallback(() => { + if (busyRef.current || categoryId === undefined) return; + busyRef.current = true; + setIsRefreshing(true); + refreshCatalogCategory(source, kind, categoryId) + .then(({ written, deleted }) => + setResult({ message: written + deleted === 0 ? "Already up to date" : `Updated · ${written} new or changed, ${deleted} removed`, tone: "success" }), + ) + .catch((err: unknown) => setResult({ message: `Couldn't refresh: ${err instanceof Error ? err.message : String(err)}`, tone: "error" })) + .finally(() => { + busyRef.current = false; + setIsRefreshing(false); + }); + }, [source, kind, categoryId]); + const dismissResult = useCallback(() => setResult(null), []); + + return { + updatedLabel: isCatalogReady && updatedAt !== undefined ? `Updated ${formatSyncedAgo(updatedAt)}` : undefined, + canRefresh: isCatalogReady && source.kind === "xtream" && categoryId !== undefined, + isRefreshing, + refresh, + result, + dismissResult, + }; +} diff --git a/src/use-catalog-page.test.ts b/src/use-catalog-page.test.ts index ae79683..a183b6a 100644 --- a/src/use-catalog-page.test.ts +++ b/src/use-catalog-page.test.ts @@ -32,6 +32,35 @@ describe("useVodCatalogPage", () => { expect(result.current.hasMore).toBe(false); }); + it("reports loading — never an empty result — from the very render a query is enabled or changed", async () => { + const catalogDb = await openCatalogDb(); + await putRecordsBatch(catalogDb, "vod", [vodRecord("1", "Alpha", "Action"), vodRecord("2", "Beta", "Drama")]); + + const { result, rerender } = renderHook(({ enabled, categoryId }) => useVodCatalogPage("source-1", { enabled, categoryId }), { + initialProps: { enabled: false, categoryId: "Action" }, + }); + expect(result.current.isInitialLoading).toBe(false); + + rerender({ enabled: true, categoryId: "Action" }); + expect(result.current).toMatchObject({ isInitialLoading: true, items: [] }); + await waitFor(() => expect(result.current.items.map((m) => m.name)).toEqual(["Alpha"])); + + rerender({ enabled: true, categoryId: "Drama" }); + // Not Action's titles under the new category, and not "nothing here" either. + expect(result.current).toMatchObject({ isInitialLoading: true, items: [] }); + await waitFor(() => expect(result.current.items.map((m) => m.name)).toEqual(["Beta"])); + }); + + it("keeps showing the current items while a background sync's refresh reloads them", async () => { + const catalogDb = await openCatalogDb(); + await putRecordsBatch(catalogDb, "vod", [vodRecord("1", "Alpha")]); + const { result } = renderHook(() => useVodCatalogPage("source-1")); + await waitFor(() => expect(result.current.items).toHaveLength(1)); + + act(() => bumpCacheVersion(catalogVersionKey("source-1", "vod"))); + expect(result.current).toMatchObject({ isInitialLoading: false, items: [expect.objectContaining({ name: "Alpha" })] }); + }); + it("hasMore is true when more records exist beyond the first page", async () => { const catalogDb = await openCatalogDb(); await putRecordsBatch( diff --git a/src/use-catalog-page.ts b/src/use-catalog-page.ts index ce05c82..ad8cb65 100644 --- a/src/use-catalog-page.ts +++ b/src/use-catalog-page.ts @@ -26,6 +26,8 @@ export interface CatalogPageState { total: number | null; } +const EMPTY: never[] = []; + type SeriesSummary = Pick; /** @@ -60,7 +62,15 @@ function useCatalogPageImpl( ): CatalogPageState { const { categoryId, namePrefix, filter: kidsFilter, enabled = true } = options; const [items, setItems] = useState([]); - const [isInitialLoading, setIsInitialLoading] = useState(enabled); + const [isFetching, setIsFetching] = useState(enabled); + // Which query `items` answers. Until the current one has loaded, the hook + // reports loading with no items — otherwise the render between a filter + // change (or `enabled` turning on) and the effect below would show the + // previous result, or an empty one ("No series in this category"), for a + // moment. A background sync's version bump isn't part of the key: the + // existing items stay up while the refreshed ones load. + const queryKey = enabled ? JSON.stringify([sourceId, kind, categoryId ?? null, namePrefix ?? null, kidsFilter?.key ?? null]) : null; + const [loadedKey, setLoadedKey] = useState(null); const [hasMore, setHasMore] = useState(true); const [error, setError] = useState(null); const [total, setTotal] = useState(null); @@ -89,12 +99,13 @@ function useCatalogPageImpl( if (!enabled) { setItems([]); - setIsInitialLoading(false); + setIsFetching(false); setHasMore(false); + setLoadedKey(null); return; } - setIsInitialLoading(true); + setIsFetching(true); Promise.all([fetchPageRef.current({ ...filter, offset: 0, limit: PAGE_SIZE }), getCatalogCount(sourceId, kind, filter)]) .then(([page, total]) => { if (cancelled) return; @@ -102,17 +113,21 @@ function useCatalogPageImpl( setItems(page); setTotal(total); setHasMore(page.length < total); - setIsInitialLoading(false); + setLoadedKey(queryKey); + setIsFetching(false); }) .catch((err: unknown) => { if (cancelled) return; setError(err instanceof Error ? err.message : String(err)); - setIsInitialLoading(false); + setLoadedKey(queryKey); + setIsFetching(false); }); return () => { cancelled = true; }; + // queryKey is derived from these same inputs. + // eslint-disable-next-line react-hooks/exhaustive-deps }, [sourceId, kind, filter, enabled, version]); const loadMore = useCallback(() => { @@ -135,5 +150,7 @@ function useCatalogPageImpl( }); }, [filter, hasMore, enabled]); - return { items, isInitialLoading, hasMore, loadMore, error, total }; + const isCurrent = enabled && loadedKey === queryKey; + const isInitialLoading = enabled && (!isCurrent || (isFetching && items.length === 0)); + return { items: isCurrent ? items : (EMPTY as T[]), isInitialLoading, hasMore: isCurrent && hasMore, loadMore, error, total: isCurrent ? total : null }; } diff --git a/src/use-local-catalog-ready.ts b/src/use-local-catalog-ready.ts index 8cbc69e..3c86d06 100644 --- a/src/use-local-catalog-ready.ts +++ b/src/use-local-catalog-ready.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useCacheInvalidationStore } from "./cache-invalidation-store.js"; import { catalogVersionKey, type CatalogKind } from "./catalog-sync.js"; import { hasLocalCatalog } from "./catalog-store.js"; @@ -13,10 +13,11 @@ export type LocalCatalogStatus = "checking" | "ready" | "not-synced"; * its progress (typically a brand-new source, right after it was added). * * Starts "checking" (neither path enabled yet) and settles to "ready" or - * "not-synced" once the IndexedDB check resolves, or flips back to - * "checking" whenever catalog-sync.ts bumps this source+kind's version (a - * sync just completed) so the screen re-checks rather than assuming - * "not-synced" forever. The three-state shape (as opposed to a plain + * "not-synced" once the IndexedDB check resolves. Whenever catalog-sync.ts + * bumps this source+kind's version (a sync just completed) it re-checks in + * the background, keeping the last answer meanwhile — dropping back to + * "checking" would blank an already-populated screen after every refresh. + * Only a change of source or kind starts over from "checking". The three-state shape (as opposed to a plain * boolean defaulting to false) matters here: a plain false-until-proven-true * would flash the "getting your movies ready" state (and request a sync) * on every mount before the already-synced case has had a chance to resolve. @@ -24,10 +25,15 @@ export type LocalCatalogStatus = "checking" | "ready" | "not-synced"; export function useLocalCatalogReady(sourceId: string, kind: CatalogKind): LocalCatalogStatus { const [status, setStatus] = useState("checking"); const version = useCacheInvalidationStore((state) => state.versions[catalogVersionKey(sourceId, kind)]); + const checkedKey = useRef(null); useEffect(() => { let cancelled = false; - setStatus("checking"); + const key = `${sourceId}:${kind}`; + if (checkedKey.current !== key) { + checkedKey.current = key; + setStatus("checking"); + } hasLocalCatalog(sourceId, kind).then((ready) => { if (!cancelled) setStatus(ready ? "ready" : "not-synced"); }); diff --git a/src/workers/catalog-fetch-worker.ts b/src/workers/catalog-fetch-worker.ts index f263055..6d3921c 100644 --- a/src/workers/catalog-fetch-worker.ts +++ b/src/workers/catalog-fetch-worker.ts @@ -22,17 +22,9 @@ import { proxyDownloadFetch } from "../proxy-fetch.js"; * and no reason to carry XtreamClient's request-dedupe/auth machinery, * which belongs to the main thread's screens. * - * Two response shapes come out of one fetch, chosen by the caller - * (catalog-worker-client.ts): a single `ok`/`data` reply for a caller that - * wants the whole (typically category-scoped, so already small) list at - * once, or a `progress`/`done` stream of batches for a full-catalog sync - * (see catalog-sync.ts) — `response.json()` still parses the entire payload - * in one call (Chrome 79/webOS has no native incremental JSON parser to - * stream against), but slicing the *mapped* result into ~2000-record - * batches before posting means the main thread's IndexedDB writes (see - * catalog-db.ts's putRecordsBatch) can start, and its structured-clone - * cost per message stays small, well before the full 129k-entry array would - * otherwise finish serializing as one postMessage. + * Serves screens' category-scoped lists (content-loader.ts) — the full + * catalog sync runs in the sync worker instead (catalog-sync-core.ts), + * which writes straight into IndexedDB rather than posting rows back. */ export interface CatalogWorkerCredentials { @@ -48,18 +40,12 @@ export interface CatalogWorkerRequest { credentials: CatalogWorkerCredentials; action: CatalogWorkerAction; categoryId?: string; - /** When set, the worker posts `progress` batches of this size followed by `done` instead of one `ok`/`data` reply — see syncCatalog in catalog-worker-client.ts. */ - streamBatchSize?: number; } export type CatalogWorkerResponse = | { id: string; ok: true; data: unknown[] } - | { id: string; type: "progress"; batch: unknown[] } - | { id: string; type: "done"; total: number } | { id: string; ok: false; error: string }; -const DEFAULT_STREAM_BATCH_SIZE = 2000; - function stripTrailingSlash(url: string): string { return url.endsWith("/") ? url.slice(0, -1) : url; } @@ -97,21 +83,8 @@ async function fetchAndMap(request: CatalogWorkerRequest): Promise { self.onmessage = async (event: MessageEvent) => { const request = event.data; try { - const data = await fetchAndMap(request); - - if (request.streamBatchSize === undefined) { - const response: CatalogWorkerResponse = { id: request.id, ok: true, data }; - self.postMessage(response); - return; - } - - const batchSize = request.streamBatchSize || DEFAULT_STREAM_BATCH_SIZE; - for (let i = 0; i < data.length; i += batchSize) { - const progress: CatalogWorkerResponse = { id: request.id, type: "progress", batch: data.slice(i, i + batchSize) }; - self.postMessage(progress); - } - const done: CatalogWorkerResponse = { id: request.id, type: "done", total: data.length }; - self.postMessage(done); + const response: CatalogWorkerResponse = { id: request.id, ok: true, data: await fetchAndMap(request) }; + self.postMessage(response); } catch (err) { const response: CatalogWorkerResponse = { id: request.id, ok: false, error: err instanceof Error ? err.message : String(err) }; self.postMessage(response); diff --git a/src/workers/catalog-worker-client.test.ts b/src/workers/catalog-worker-client.test.ts index 0e33c75..7682ddb 100644 --- a/src/workers/catalog-worker-client.test.ts +++ b/src/workers/catalog-worker-client.test.ts @@ -170,58 +170,4 @@ describe("createCatalogWorkerClient", () => { expect(MockWorker.instances).toHaveLength(2); expect(MockWorker.instances[0].terminated).toBe(true); }); - - describe("syncCatalog", () => { - it("invokes onBatch for each progress message and resolves with the total once done", async () => { - const client = createCatalogWorkerClient(); - const onBatch = vi.fn(); - const promise = client.syncCatalog({ credentials: { baseUrl: "http://x", username: "u", password: "p" }, action: "get_vod_streams" }, onBatch); - - const worker = MockWorker.instances[0]; - const requestId = worker.posted[0].id; - worker.respond({ id: requestId, type: "progress", batch: ["a", "b"] }); - worker.respond({ id: requestId, type: "progress", batch: ["c"] }); - worker.respond({ id: requestId, type: "done", total: 3 }); - - await expect(promise).resolves.toEqual({ total: 3 }); - expect(onBatch).toHaveBeenNthCalledWith(1, ["a", "b"]); - expect(onBatch).toHaveBeenNthCalledWith(2, ["c"]); - }); - - it("rejects when the worker responds with ok:false for a streaming request", async () => { - const client = createCatalogWorkerClient(); - const promise = client.syncCatalog({ credentials: { baseUrl: "http://x", username: "u", password: "p" }, action: "get_series" }, vi.fn()); - - const worker = MockWorker.instances[0]; - worker.respond({ id: worker.posted[0].id, ok: false, error: "sync failed" }); - - await expect(promise).rejects.toThrow("sync failed"); - }); - - it("passes a streamBatchSize through to the worker request", () => { - const client = createCatalogWorkerClient(); - void client.syncCatalog({ credentials: { baseUrl: "http://x", username: "u", password: "p" }, action: "get_vod_streams" }, vi.fn()); - - expect(MockWorker.instances[0].posted[0].streamBatchSize).toBeGreaterThan(0); - }); - - it("rejects an in-flight syncCatalog when the worker itself errors", async () => { - const client = createCatalogWorkerClient(); - const promise = client.syncCatalog({ credentials: { baseUrl: "http://x", username: "u", password: "p" }, action: "get_vod_streams" }, vi.fn()); - - MockWorker.instances[0].fail("module load failed"); - - await expect(promise).rejects.toThrow("module load failed"); - }); - - it("does not dedupe against fetchCatalog — syncCatalog always posts its own request", () => { - const client = createCatalogWorkerClient(); - const credentials = { baseUrl: "http://x", username: "u", password: "p" }; - - void client.fetchCatalog({ credentials, action: "get_vod_streams" }); - void client.syncCatalog({ credentials, action: "get_vod_streams" }, vi.fn()); - - expect(MockWorker.instances[0].posted).toHaveLength(2); - }); - }); }); diff --git a/src/workers/catalog-worker-client.ts b/src/workers/catalog-worker-client.ts index 3aba69f..e286e48 100644 --- a/src/workers/catalog-worker-client.ts +++ b/src/workers/catalog-worker-client.ts @@ -2,21 +2,6 @@ import type { CatalogWorkerAction, CatalogWorkerCredentials, CatalogWorkerReques export interface CatalogWorkerClient { fetchCatalog(request: { credentials: CatalogWorkerCredentials; action: CatalogWorkerAction; categoryId?: string }): Promise; - /** - * Streaming counterpart to fetchCatalog for a full-catalog background sync - * (see catalog-sync.ts): `onBatch` fires once per ~2000-record chunk as - * the worker posts `progress` messages, and the returned promise resolves - * with the total record count once the worker posts `done` — see - * catalog-fetch-worker.ts's doc comment for why this exists instead of - * always resolving one big array. Deliberately not deduped like - * fetchCatalog — a sync is already guarded against overlap by - * catalog-sync.ts's own in-flight tracking, keyed by source+kind rather - * than the exact request shape. - */ - syncCatalog( - request: { credentials: CatalogWorkerCredentials; action: CatalogWorkerAction; categoryId?: string }, - onBatch: (batch: unknown[]) => void, - ): Promise<{ total: number }>; terminate(): void; } @@ -37,9 +22,6 @@ let requestCounter = 0; */ const REQUEST_DEDUPE_MS = 3000; -/** Matches catalog-fetch-worker.ts's own DEFAULT_STREAM_BATCH_SIZE — passed explicitly rather than relying on the worker's default so this client's chunking behavior doesn't silently drift from what it thinks it asked for. */ -const DEFAULT_SYNC_BATCH_SIZE = 2000; - function catalogRequestKey(request: { credentials: CatalogWorkerCredentials; action: CatalogWorkerAction; categoryId?: string }): string { return `${request.credentials.baseUrl}:${request.credentials.username}:${request.action}:${request.categoryId ?? ""}`; } @@ -60,14 +42,11 @@ function catalogRequestKey(request: { credentials: CatalogWorkerCredentials; act export function createCatalogWorkerClient(): CatalogWorkerClient { let worker: Worker | undefined; const pending = new Map void; reject: (err: Error) => void }>(); - const streaming = new Map void; resolve: (result: { total: number }) => void; reject: (err: Error) => void }>(); const requestCache = new Map; cachedAt: number }>(); function rejectAllPending(error: Error): void { for (const entry of pending.values()) entry.reject(error); pending.clear(); - for (const entry of streaming.values()) entry.reject(error); - streaming.clear(); } function ensureWorker(): Worker { @@ -76,25 +55,6 @@ export function createCatalogWorkerClient(): CatalogWorkerClient { worker.onmessage = (event: MessageEvent) => { const response = event.data; - const streamEntry = streaming.get(response.id); - if (streamEntry) { - if ("type" in response && response.type === "progress") { - streamEntry.onBatch(response.batch); - return; - } - if ("type" in response && response.type === "done") { - streaming.delete(response.id); - streamEntry.resolve({ total: response.total }); - return; - } - if ("ok" in response && !response.ok) { - streaming.delete(response.id); - streamEntry.reject(new Error(response.error)); - return; - } - return; - } - const entry = pending.get(response.id); if (!entry) return; // Stale/unknown response (e.g. after terminate()) — nothing to resolve. pending.delete(response.id); @@ -134,19 +94,10 @@ export function createCatalogWorkerClient(): CatalogWorkerClient { promise.catch(() => requestCache.delete(key)); return promise; }, - syncCatalog(request, onBatch) { - return new Promise<{ total: number }>((resolve, reject) => { - const id = `catalog-sync-${++requestCounter}`; - streaming.set(id, { onBatch, resolve, reject }); - const message: CatalogWorkerRequest = { id, ...request, streamBatchSize: DEFAULT_SYNC_BATCH_SIZE }; - ensureWorker().postMessage(message); - }); - }, terminate() { worker?.terminate(); worker = undefined; pending.clear(); - streaming.clear(); requestCache.clear(); }, }; diff --git a/src/workers/search-index-protocol.ts b/src/workers/search-index-protocol.ts new file mode 100644 index 0000000..ff8da5a --- /dev/null +++ b/src/workers/search-index-protocol.ts @@ -0,0 +1,11 @@ +import type { SearchIndexJob } from "../search/search-indexer.js"; + +/** Main thread → search-index worker. */ +export type SearchIndexRequest = + /** Work through `jobs` (resuming each from where it stopped), pausing `paceMs` after every unit. */ + | { type: "run"; jobs: SearchIndexJob[]; paceMs: number } + /** Stop at the next unit boundary. Answered with "paused" (or "done", if it finished first). */ + | { type: "pause" }; + +/** Search-index worker → main thread. */ +export type SearchIndexResponse = { type: "done" } | { type: "paused" } | { type: "error"; message: string }; diff --git a/src/workers/search-index-worker.ts b/src/workers/search-index-worker.ts new file mode 100644 index 0000000..f037889 --- /dev/null +++ b/src/workers/search-index-worker.ts @@ -0,0 +1,42 @@ +/// +import { openCatalogDb } from "../core/storage/catalog-db.js"; +import { runIndexJobs } from "../search/search-indexer.js"; +import type { SearchIndexRequest, SearchIndexResponse } from "./search-index-protocol.js"; + +/** + * Builds the search index in the background (docs/global-search-plan.md, + * "The indexer"). Its own worker rather than the sync worker, so a long run + * can never delay a sync job queued behind it; it opens the catalog + * database itself, so the main thread does no indexing work at all. + * search/search-index-scheduler.ts decides when it may run. + */ + +let running = false; +let pauseRequested = false; + +function post(message: SearchIndexResponse): void { + self.postMessage(message); +} + +self.onmessage = (event: MessageEvent) => { + const message = event.data; + if (message.type === "pause") { + pauseRequested = true; + if (!running) post({ type: "paused" }); + return; + } + if (running) return; + running = true; + pauseRequested = false; + void (async () => { + try { + const catalogDb = await openCatalogDb(); + const result = await runIndexJobs(catalogDb, message.jobs, { paceMs: message.paceMs, shouldStop: () => pauseRequested }); + post({ type: result === "done" ? "done" : "paused" }); + } catch (err) { + post({ type: "error", message: err instanceof Error ? err.message : String(err) }); + } finally { + running = false; + } + })(); +}; diff --git a/src/workers/sync-jobs.ts b/src/workers/sync-jobs.ts index 6275f6e..d15a8ac 100644 --- a/src/workers/sync-jobs.ts +++ b/src/workers/sync-jobs.ts @@ -1,13 +1,17 @@ +import { runCatalogSync } from "../catalog-sync-core.js"; import { runEpgSync } from "../epg-sync-core.js"; import { runLiveSync } from "../live-sync-core.js"; import { createProxyFetch, DOWNLOAD_TIMEOUT_MS } from "../proxy-fetch.js"; import type { SyncJobOptions } from "../sync-job.js"; -import { EPG_DOWNLOAD_TIMEOUT_MS, type SyncJobName, type SyncJobs } from "./sync-worker-protocol.js"; +import { CATALOG_DOWNLOAD_TIMEOUT_MS, EPG_DOWNLOAD_TIMEOUT_MS, type SyncJobName, type SyncJobs } from "./sync-worker-protocol.js"; /** Runs one named sync job with its download deadline — shared by the worker and the main-thread fallback so both behave identically. */ export function runSyncJob(job: J, request: SyncJobs[J]["request"], options: Omit): Promise { if (job === "epg") { return runEpgSync(request as SyncJobs["epg"]["request"], { ...options, fetchImpl: createProxyFetch(EPG_DOWNLOAD_TIMEOUT_MS) }); } + if (job === "catalog") { + return runCatalogSync(request as SyncJobs["catalog"]["request"], { ...options, fetchImpl: createProxyFetch(CATALOG_DOWNLOAD_TIMEOUT_MS) }); + } return runLiveSync(request as SyncJobs["live"]["request"], { ...options, fetchImpl: createProxyFetch(DOWNLOAD_TIMEOUT_MS) }); } diff --git a/src/workers/sync-worker-protocol.ts b/src/workers/sync-worker-protocol.ts index 023a834..3354f97 100644 --- a/src/workers/sync-worker-protocol.ts +++ b/src/workers/sync-worker-protocol.ts @@ -1,13 +1,17 @@ +import type { CatalogSyncRequest, CatalogSyncResult } from "../catalog-sync-core.js"; import type { EpgSyncRequest, EpgSyncResult } from "../epg-sync-core.js"; import type { LiveSyncRequest, LiveSyncResult } from "../live-sync-core.js"; /** Whole-download deadline for an XMLTV guide — they can reach 100MB, well past proxy-fetch.ts's general DOWNLOAD_TIMEOUT_MS on a slow connection. */ export const EPG_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000; +/** Whole-download deadline for a full movie/series list — a slow panel can take minutes to build a 100k-title answer. */ +export const CATALOG_DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1000; /** Every job the sync worker runs, with its request and result shapes. */ export interface SyncJobs { epg: { request: EpgSyncRequest; result: EpgSyncResult }; live: { request: LiveSyncRequest; result: LiveSyncResult }; + catalog: { request: CatalogSyncRequest; result: CatalogSyncResult }; } export type SyncJobName = keyof SyncJobs; diff --git a/src/workers/sync-worker.ts b/src/workers/sync-worker.ts index e965500..d5f5819 100644 --- a/src/workers/sync-worker.ts +++ b/src/workers/sync-worker.ts @@ -4,8 +4,9 @@ import { runSyncJob } from "./sync-jobs.js"; import type { SyncWorkerRequest, SyncWorkerResponse } from "./sync-worker-protocol.js"; /** - * Runs the heavy sync jobs — the XMLTV guide (epg-sync-core.ts) and the live - * channel list (live-sync-core.ts) — entirely off the main thread: download, + * Runs the heavy sync jobs — the XMLTV guide (epg-sync-core.ts), the live + * channel list (live-sync-core.ts) and the movie/series catalogs + * (catalog-sync-core.ts) — entirely off the main thread: download, * parse, map and the IndexedDB writes. On webOS's WebKit, parsing a 50MB+ * guide or a 20k-channel JSON list takes seconds of CPU; on the main thread * that froze the remote (see docs/sync-architecture-plan.md §1.1/§1.2). diff --git a/webos-meta/appinfo.json b/webos-meta/appinfo.json index a88d703..a828356 100644 --- a/webos-meta/appinfo.json +++ b/webos-meta/appinfo.json @@ -1,15 +1,15 @@ { - "id": "com.stacktips.zenplay", + "id": "com.spannable.zenplay", "title": "ZenPlay", "appDescription": "Live TV, movies and series from your own playlists", "type": "web", "main": "index.html", - "version": "0.1.0", - "vendor": "stacktips", + "version": "1.0.1", + "vendor": "Spannable Labs", "icon": "icon.png", "largeIcon": "largeIcon.png", "iconColor": "#0F1226", - "bgImage": "bgImage.png", + "bgImage": "splash.png", "splashBackground": "splash.png", "resolution": "1920x1080", "disableBackHistoryAPI": true, diff --git a/webos-meta/icon.png b/webos-meta/icon.png index f6921bc..f5f04cf 100644 Binary files a/webos-meta/icon.png and b/webos-meta/icon.png differ diff --git a/webos-meta/largeIcon.png b/webos-meta/largeIcon.png index 95551ec..f5f04cf 100644 Binary files a/webos-meta/largeIcon.png and b/webos-meta/largeIcon.png differ