Correct the guide query's JSDoc: it runs one query accepting the union
of requested-source and unsourced legacy rows, unlike EpgQueryService's
two-query scoped-then-legacy fallback. Replace the hand-maintained
plain-SQL twin of the Drizzle overlap predicate with a rendered copy of
the real predicate (SQLiteSyncDialect().sqlToQuery) in the spec, add a
source-scoping case, and drop the now-redundant operator-sequence test.
warnIfTruncated reuses uniqueTrimmedStrings and names which read
(programme/coverage) was truncated. Rename epg-query.service.ts's local
EpgProgramRow to EpgProgramSelectRow so it isn't confused with the
shared EpgProgramRow type, and document getProgramCoverage like its
sibling.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
- Scoped guide programme/coverage queries now include legacy
(unsourced) rows via source_url IN (...) OR IS NULL OR '',
mirroring EpgQueryService's legacy fallback.
- getProgramsForChannels/getProgramCoverage build their result from
the normalized, capped window.channelIds instead of the raw
request, so a key cut by the cap is absent rather than [] — an
invalid window now returns {}. Truncation logs counts only.
- Split the 100-channel guide cap from a new 2000-key coverage cap,
and cap sourceUrls at 50; normalizeGuideWindow takes the cap as a
parameter and moved (with guideWindowOverlapSqlText) into
epg-guide-window.util.ts.
- Extracted shared row mapping (toEpgProgramFromRow/isValidEpgProgram)
into epg-program-row.util.ts, used by both EpgQueryService and
EpgGuideQueryService so invalid start/stop rows are dropped
identically in both.
- Added a real-SQLite-backed test for the overlap predicate's exact
text, plus per-key array copies in the response.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(stalker): keep live search within the selected category
* test(stalker): assert retained video ownership without source timing
* test(stalker): distinguish paged All Items from the initial cache grid
* fix(stalker): reveal remote selections in uncached search results
* test(stalker): wait for category rows and retain settled playback
* feat(website): rebuild the hero, features, download and support sections
Third landing redesign PR: the home page now speaks the app's own
language instead of a template's.
- Hero: left-aligned headline, a primary button that follows the
visitor's OS (server-rendered fallback goes to /download/), an "All
platforms" button and a text link for self-hosting. Project numbers
(stars, downloads, languages, license) are set in the page's own
typography from the GitHub API at build time (`lib/github-stats.ts`);
anything the build cannot resolve is left out rather than faked. A
small "Now playing" card with fictional demo content replaces the
mascot, badges and social chips; the screenshot is lit by a blurred
copy of itself, like the player's ambient mode.
- Features: a bento of interface fragments (EPG rows with progress,
posters with a resume bar, a download queue with a REC dot, a remote
D-pad) instead of numbered cards with icon watermarks and a marquee.
The marquee CSS is gone.
- Download: one row per platform with the release's file names and a
"Detected" badge for the visitor's OS (`lib/detect-platform.ts`),
package-manager commands in the same panel.
- Support becomes a single row; the disclaimer moves into the footer,
which also gets the mascot and a link list.
- Home sections drop the decorative eyebrows and the divider rules; the
unused broadcast-wave, support-signal and watermark illustrations are
removed.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(website): cover the rebuilt home sections and the OS-aware download CTA
`website-home-sections.test.mjs` (in the website test target) reads the
built home page — generic hero CTA to /download/, project facts, no
badge images or dashed borders, every feature page linked from the
bento, one download row per platform with release file names and the
Detected badge hidden, the disclaimer and mascot in the footer — and
then drives it in Chromium under Windows, macOS, Linux and Android user
agents: the primary button and the Detected row follow the platform,
the phone keeps the generic markup, and the copy button writes the
command to the clipboard.
`detectPlatform()` now consults the user-agent string before the
client-hints platform and the deprecated `navigator.platform`, so the
source a visitor's tools actually change decides first; silent sources
fall through instead of vetoing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): keep iPads on the generic download path
iPadOS asks for desktop sites with a Macintosh user agent and a
`MacIntel` platform, so the OS guess sent iPad visitors to the macOS
installers. A "mac" verdict is now trusted only on a device without
touch points: no Mac has a touch screen, every iPad reports several.
The browser test adds an iPad-in-desktop-mode row to the generic-device
matrix next to the Android phone.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* fix(migration): recover legacy desktop sources without replacing current data
* test(migration): cover legacy recovery IPC contracts
* test(migration): use static legacy Electron bootstrap
* feat(website): turn the screenshot showcase into a channel switcher
Second landing redesign PR. "See it in action" was a tab strip in a
dashed frame showing one screenshot inside a drawn window chrome that
duplicated the chrome already in the shot. It is now a channel list:
number, screen name and one line about what the screen is for on the
left, a single frame on the right, and a caption linking to the matching
feature page.
- Channels advance on their own with a progress hairline under the
active row; hovering, focusing or scrolling the block out of view
pauses it and `prefers-reduced-motion` disables autoplay entirely.
- A brief on-screen "CH 03" badge confirms every switch.
- Arrow keys, Home and End move between channels; the list is a proper
vertical tablist with roving tabindex, panels carry `aria-hidden`.
- Six screens: Dashboard, Live TV, Program guide, Movies & series,
Downloads, Settings. Same files from `public/screenshots`; the
add-playlist shot is replaced by the movie detail and the download
manager.
- On phones the screen comes first and the list follows.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(website): cover the channel switcher, keep focus pausing after mouseleave
Hover and focus now pause autoplay independently: clicking a channel
focused it, but moving the pointer away resumed the timer and seven
seconds later the selection moved under a still-focused tab.
New `website-screenshot-showcase.test.mjs` (in the website test target):
a structural half over the built HTML (vertical tablist, roving
tabindex, one aria-hidden panel per channel) and a browser half that
serves the build and drives it in Chromium — autoplay advances, hover
pauses, click + mouseleave keeps the pause while focused, arrow/Home/End
keys move selection, focus, panel, caption and badge together, blur
resumes, and `prefers-reduced-motion` disables autoplay. The browser
half falls back to the system Chrome channel and skips when no Chromium
exists, so the structural checks run everywhere.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* perf(website): lazy-load every showcase screenshot
The block sits below the hero and the feature grid, so an eager first
frame only competed with above-the-fold assets for visitors who never
scroll to it. Native look-ahead loading brings it in well before the
switcher is on screen.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* test(website): share the browser harness, fail in CI without Chromium
`website-browser-support.mjs` owns the loopback static server and the
Chromium launcher for the website browser tests. The server resolves
every request against the build root and answers 404 for anything that
escapes it (CodeQL js/path-injection on the previous inline copy). The
launcher tries the Playwright download, then the system Chrome and
Chromium channels; when none launches it returns null locally so the
structural half still runs, and throws under `CI` so the interaction
assertions can never turn into a silent skip on the runner.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* perf(website): defer inactive showcase frames, document the browser tests
The six frames are stacked with opacity, so native lazy loading treated
all of them as near-viewport and fetched every screenshot at once. Only
the first frame now ships with a `src`; the switcher assigns it from
`data-src` when a channel is shown and preloads the one after it, so at
most two frames are ever in flight. The showcase test asserts the
markup and the runtime behaviour.
The website README now describes the browser-dependent suites, the
shared harness, and the skip-locally / fail-in-CI rule.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* docs(website): describe only the suite this PR adds
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): resume the switcher from where it paused, keep DOM order on phones
The dwell clock now stops while the switcher is hovered or focused and
the pause time is added back on resume, so the progress hairline
continues from its frozen position instead of snapping to zero and
granting a fresh seven seconds. The test asserts the resume.
On phones the list stays before the screen in the DOM and on screen
(tabs before their panels, focus order equals reading order); it hides
the per-channel descriptions and the keyboard hint there so the screen
stays close instead of being reordered with `order-first`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): never crop a showcase frame
The screen box stretches to the channel list's row height, and with
`object-cover` a wide screenshot lost its right side just above the
`lg` breakpoint. Frames are now contained on a dark stage (a letterbox,
as on a TV), and the per-channel descriptions are hidden between `lg`
and `xl` so the row stays close to the frame's own height.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): pause the switcher dwell while offscreen instead of resetting it
Leaving the viewport is now a pause like hover and focus: the frame
loop stops, the progress hairline keeps its width, and the clock
resumes from the same mark when the block scrolls back in. Only a
channel change resets the dwell. The interaction test scrolls away and
back to assert it.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* perf(website): no next-frame prefetch when reduced motion disables autoplay
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat(website): persistent pause control for the channel switcher
Hover and focus only pause while they last, which is no use to touch or
screen-reader visitors, so the list footer now carries a Pause/Resume
toggle (`aria-pressed`, full `aria-label`) that keeps autoplay stopped
until pressed again (WCAG 2.2.2). It is removed under reduced motion,
where nothing advances.
The interaction test now waits for the seven-second rollover and checks
that tab, panel, caption, badge and the preloaded frame all move to
channel 02, and that the toggle holds through mouseleave and blur.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): keep the tablist to its tabs, no successor prefetch when autoplay is off
The channel list's header and the Pause/Resume control sat inside the
`role="tablist"` container; the tablist now wraps only the six tabs so
assistive technology reads the toggle as an ordinary button beside the
list. `show()` preloads the next frame only while autoplay can reach it
(neither reduced motion nor the toggle has stopped it). Tests assert
both.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): preload the successor when auto-advance resumes
A channel picked while the switcher is paused deliberately skips its
successor; resuming now fetches that frame so the next automatic switch
does not land on a blank screen. The interaction test covers
pause → manual selection → resume.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): Resume restarts autoplay at once; same-channel progress in the test
The transient hover/focus pauses now watch only the channel list and the
screen. The footer with the Pause/Resume control is not one of those
regions, so after pressing Resume — with the pointer and the focus still
on the button — autoplay visibly restarts instead of waiting for the
visitor to leave the whole block.
The interaction test compares progress within one channel (a paused
Movies before and after Resume) rather than across channels, which
could fail on a slow runner, and asserts that autoplay runs while the
toggle keeps focus and hover.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* perf(website): one scheduler for the switcher, no frames while paused
Every pause reason (hover, focus, the toggle, leaving the viewport) now
goes through a single `sync()`: the animation-frame loop runs only while
the block is visible and nothing pauses it, and is cancelled otherwise,
so a switcher left paused schedules no frames at all. The dwell clock
still resumes from where it froze, and the successor frame is fetched
only when the loop actually starts — so scrolling away and back under a
persistent pause loads nothing. Tests count scheduled frames while
paused and cover pause → manual selection → offscreen → return.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): a channel picked while paused gets its full dwell after Resume
`show()` reset the pause mark, so the time a visitor spent paused after
picking a channel counted toward that channel's dwell and Resume could
advance immediately. When the loop is not running the new channel now
starts out paused at that moment. The interaction test asserts the
progress is still near zero right after Resume.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): follow reduced-motion changes and hidden tabs in the switcher
The reduced-motion preference is read from a live MediaQueryList: when
it changes while the page is open the scheduler stops or restarts and
the Pause/Resume control is hidden or shown (it is hidden, not removed,
for that reason). A hidden document counts as a pause too — background
tabs throttle animation frames while the clock keeps running, so
without it the first frame back would skip a channel. The interaction
test flips both at runtime.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(website): plain action button for the switcher pause, no manual animations under reduced motion
`aria-pressed` on a button whose name changes between "Pause
auto-advance" and "Resume auto-advance" announced the wrong thing; the
control is now an ordinary action button whose name says what pressing
it does next. The OSD slide and the panel fade get
`motion-reduce:transition-none`, so a manual selection under reduced
motion moves nothing.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Tags were decorative: a plain span on every card and post header, with no
page, filter or search behind them, and a vocabulary of 32 slugs across 16
posts where 22 slugs appeared once. They now form a closed vocabulary of ten
topics (src/lib/blog-tags.ts) that the content collection schema enforces, so
an unknown slug fails the build instead of minting a new tag.
Every used tag gets a hub at /blog/tag/<tag>/ with CollectionPage and
BreadcrumbList structured data; the blog index and the hubs show a "Topics"
rail with post counts; and every chip links to its hub. The cards become
<article> elements with the title link stretched over the card, because chip
links cannot nest inside a card that is one big <a>.
Posts are retagged to the new vocabulary. A structural test covers the rail,
each hub, the chip targets, the sitemap and the absence of nested anchors.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The blog card kept the date and every tag in one non-wrapping flex row, so a
post with five or six tags overflowed the card frame and the date broke across
three lines inside the three-column grid. The date now sits on its own
non-wrapping line, tags wrap below it, and only the first three render as chips
while the rest collapse into a "+N" chip whose tooltip lists them. The featured
card still shows every tag.
The homepage rendered two cards under the featured post and left the third grid
column empty; it now shows three.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Publish /download/docker/ so "Self-host it with Docker" no longer sends
visitors off-site to the developer README. The page covers what the
image contains, a four-step quick start with the repository compose
command and an image-only compose snippet, the environment variables
and port that matter, the published tag patterns with the update
command, what the browser version leaves out, and a seven-question FAQ.
It links to docker/README.md for the full reference, to the desktop vs
browser comparison and to the setup guides; the README links back.
The download hub gains a fourth card, the homepage and hub links point
at the page, the platform switcher shows a Docker card on the OS pages,
and the comparison page links both the page and the README. The page
emits SoftwareApplication / FAQPage / BreadcrumbList JSON-LD and is
covered by website-download-pages.test.mjs.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(playback): apply themes to player and EPG panels
* test(playback): verify active recording icon theme
* fix(epg): keep loading shimmer visible in both themes
* fix(playback): close legacy picture-in-picture on video replacement
* test(playback): wait for the selected video before PiP setup
* test(playback): await changed settings before PiP navigation
* fix(playback): release legacy WebKit picture-in-picture
* style(website): solid hairlines, Bricolage Grotesque display face, drop TV effects
First of three landing redesign PRs. This one only touches tokens and
surface treatment so every page (home, blog, /features, /compare,
/download) benefits without any structural change:
- Replace every dashed border with a solid hairline; `.card-dotted`
becomes `.card-panel` (bg step + hairline, lighter hairline on hover)
and the section divider is solid `surface-800`.
- Swap the display face from Playfair Display to Bricolage Grotesque
(DM Sans body and IBM Plex Mono unchanged). The italic accent word
that every heading repeated is gone; only page-level h1s keep the
accent, and as colour rather than italic.
- Remove the CRT/TV effects: the `.btn-tv` block and its nine keyframes,
the scanline overlay on the hero screenshot, the CRT hover overlay and
teal glow shadows on feature cards, the pulsing mascot, and the unused
scan/tv-* Tailwind animations.
- The hero download button and the header Download link are now solid
accent buttons; the self-host link is a plain text link.
- Blog tag chips use neutral surface borders so teal stays reserved for
actions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* style(website): finish the dashed sweep in prose and package rows
The blanket `border-dashed ` removal ate the utility but not the
variant prefix in the blog prose classes, leaving
`prose-pre:prose-pre:...` and `prose-hr:prose-hr:...`; and the
Linux package rows still used `divide-dashed`.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
`epgTimestampMs` in the dashboard live-EPG helpers returned
`EpgProgram.startTimestamp`/`stopTimestamp` as-is, but those fields are
unix SECONDS everywhere else (`getProgramTimeMs` in `@iptvnator/ui/epg`
multiplies by 1000). The bug was latent: the backend
`GET_CURRENT_PROGRAMS_BATCH` rows never set the fields, so the ISO
fallback always ran.
Scale a positive finite timestamp by 1000 and treat zero/non-finite
values as absent, mirroring `getProgramTimeMs`. Add a dedicated spec
that feeds seconds-based timestamps and asserts the formatted range and
progress, and fix the rails component spec fixture, which stored
millisecond values in the seconds fields and so encoded the old bug.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Publish /compare/ with three pages answering the choices the app asks
users to make: M3U vs Xtream Codes vs Stalker portal, the built-in web
players vs embedded MPV vs external MPV/VLC, and the desktop app vs the
self-hosted browser version. Each opens with a one-paragraph verdict,
carries a feature matrix whose cells are yes, no or a qualifier, and
emits WebPage / FAQPage / BreadcrumbList JSON-LD — not
SoftwareApplication, because guidance is not a product listing.
These compare IPTVnator's own options against each other rather than
naming other products, so every cell is checkable against this
repository. Pages that name competitors remain phase 3's open half; the
plan now records what has to be decided first.
A registry in src/lib/comparisons.ts drives the hub, the switcher and
the tests. The header gains a Compare entry and the features hub links
across. tools/testing/website-compare-pages.test.mjs checks canonical
URLs, the verdict block, the table, schema, cross-links and sitemap
entries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The native-view addons and the external MPV launch path already run with
`ytdl=no`, but the frame-copy helper left mpv's default, so a refused HTTP
open fell through to ytdl_hook and yt-dlp before failing. That delayed the
`error` transition the auto-reconnect policy waits for and could leave the
session error reading "youtube-dl failed: unexpected error occurred"
instead of the load error.
Set `ytdl=no` in the helper's built-in block, before the stdin
`mpv-options` loop, so a user `ytdl=yes` session option still overrides
it. Document the helper's built-in block next to the other session-option
transports and add a release note.
Verified by rebuilding the helper against Homebrew libmpv and driving the
binary by hand against a connection-refused URL: the built-in default logs
only the ffmpeg/stream errors, while a `ytdl=yes` stdin line spawns yt-dlp.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Four open Dependabot alerts, all on transitive npm dependencies, so no
direct dependency changes:
- browserslist 4.28.1 -> 4.28.8 (GHSA-73wf-gq98-2v4g, high)
- @xmldom/xmldom 0.8.13 -> 0.8.15 (GHSA-6gmq-8vp8-gcm6)
- @humanfs/node 0.16.7 -> 0.16.8 (GHSA-p498-v437-472g)
- postcss-selector-parser 6.1.2 -> 6.1.4 (GHSA-w9m9-85wc-3x92)
@xmldom/xmldom already had an override, but its pinned target 0.8.13 had
itself fallen into the widened advisory range (<= 0.8.14), so that entry
is bumped rather than added.
browserslist is pinned to 4.28.8 rather than the advisory's 4.28.7 because
4.28.8 was already resolved elsewhere in the tree; collapsing onto it takes
browserslist from three copies to one and drops the duplicate
caniuse-lite/electron-to-chromium/update-browserslist-db trees with it, so
the lockfile is a net reduction. postcss-selector-parser 6.0.10 is left
alone: it sits below the advisory's >= 6.1.0 lower bound.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>