mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
feat(website): turn the screenshot showcase into a channel switcher (#1540)
* 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>
This commit is contained in:
1 parent
249cd38a66
commit
eb96b4c9f2
5 files changed
+722
-90
No files matched your search
@@ -64,6 +64,17 @@ resolved version.
|
||||
canonicals, direct asset links, JSON-LD, cross-links and sitemap entries
|
||||
without depending on a specific version.
|
||||
|
||||
`tools/testing/website-screenshot-showcase.test.mjs` drives the built site in
|
||||
a real browser: the home page channel switcher (autoplay, hover/focus pausing,
|
||||
keyboard navigation, deferred frame sources). It relies on
|
||||
`tools/testing/website-browser-support.mjs`, which serves `dist/apps/website`
|
||||
on a loopback port and launches Chromium from the Playwright download or,
|
||||
failing that, the system Chrome/Chromium channel. Without any Chromium the
|
||||
browser half is **skipped locally** (the structural checks still run) and
|
||||
**fails in CI**, so a green local run only proves the interactions when a
|
||||
browser was found — run `pnpm exec playwright install chromium` once if the
|
||||
skip shows up in your output.
|
||||
|
||||
## Guides
|
||||
|
||||
Evergreen how-to posts live in the blog collection next to release notes
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"executor": "nx:run-commands",
|
||||
"dependsOn": ["build"],
|
||||
"options": {
|
||||
"command": "node --test tools/testing/website-giscus-comments.test.mjs tools/testing/website-download-pages.test.mjs tools/testing/website-guides.test.mjs tools/testing/website-feature-pages.test.mjs tools/testing/website-compare-pages.test.mjs tools/testing/website-blog-tags.test.mjs"
|
||||
"command": "node --test tools/testing/website-giscus-comments.test.mjs tools/testing/website-download-pages.test.mjs tools/testing/website-guides.test.mjs tools/testing/website-feature-pages.test.mjs tools/testing/website-compare-pages.test.mjs tools/testing/website-blog-tags.test.mjs tools/testing/website-screenshot-showcase.test.mjs"
|
||||
}
|
||||
},
|
||||
"serve": {
|
||||
|
||||
@@ -1,159 +1,414 @@
|
||||
---
|
||||
const tabs = [
|
||||
/**
|
||||
* "Flip through the app": the screenshot showcase as a channel switcher.
|
||||
*
|
||||
* The list on the left is the channel list of a TV player — number, name,
|
||||
* one line about what the screen is for — and the screen on the right shows
|
||||
* the selected shot. Channels advance on their own (a progress hairline runs
|
||||
* under the active row) until the visitor hovers, focuses or scrolls the block
|
||||
* out of view; `prefers-reduced-motion` turns autoplay off entirely. Arrow keys
|
||||
* move between channels like a remote.
|
||||
*
|
||||
* Only the first frame ships with a `src`; the inactive panels sit stacked
|
||||
* under it with opacity 0, so native lazy loading would fetch all six at
|
||||
* once. The script assigns `src` from `data-src` when a channel is shown and
|
||||
* preloads the one after it, so at most two frames are in flight.
|
||||
*
|
||||
* The list precedes the screen in the DOM at every breakpoint (tabs before
|
||||
* their panels, focus order equals reading order); on phones the list simply
|
||||
* hides the per-channel descriptions and the keyboard hint so the screen
|
||||
* stays close.
|
||||
*
|
||||
* Hover and focus only pause while they last, which is no use to a touch or
|
||||
* screen-reader visitor, so the list footer carries a "Pause auto-advance"
|
||||
* toggle that stays paused until pressed again (WCAG 2.2.2). It is removed
|
||||
* under reduced motion, where there is nothing to pause. The transient pauses
|
||||
* watch the channel list and the screen only, so pressing Resume restarts
|
||||
* autoplay at once even though the button keeps the pointer and the focus.
|
||||
*/
|
||||
const channels = [
|
||||
{
|
||||
id: 'dashboard',
|
||||
label: 'Dashboard',
|
||||
description: 'Continue watching, recent channels and favorites across every source.',
|
||||
image: '/iptvnator/screenshots/dashboard-with-content.webp',
|
||||
alt: 'Dashboard with recently watched content and global favorites',
|
||||
alt: 'Dashboard with continue watching, recently watched and global favorites',
|
||||
width: 2072,
|
||||
height: 1602,
|
||||
link: { href: '/iptvnator/features/', label: 'Every feature' },
|
||||
},
|
||||
{
|
||||
id: 'live-tv',
|
||||
label: 'Live TV',
|
||||
description: 'Channel list with the current program, the player and the EPG timeline side by side.',
|
||||
image: '/iptvnator/screenshots/screenshot-player.webp',
|
||||
alt: 'Live channels with the inline player and EPG',
|
||||
alt: 'Live channels with the inline player and the program timeline',
|
||||
width: 2442,
|
||||
height: 1806,
|
||||
link: { href: '/iptvnator/features/m3u-player/', label: 'M3U playlist player' },
|
||||
},
|
||||
{
|
||||
id: 'epg',
|
||||
label: 'EPG Guide',
|
||||
label: 'Program guide',
|
||||
description: 'A multi-channel grid with a now-line you can scrub through the day.',
|
||||
image: '/iptvnator/screenshots/multi-epg-view.webp',
|
||||
alt: 'Multi-channel electronic program guide',
|
||||
width: 2180,
|
||||
height: 1448,
|
||||
link: { href: '/iptvnator/features/epg/', label: 'TV guide (EPG)' },
|
||||
},
|
||||
{
|
||||
id: 'movies',
|
||||
label: 'Movies & series',
|
||||
description: 'Detail pages with cast, seasons and the position you stopped at.',
|
||||
image: '/iptvnator/screenshots/vod-details.webp',
|
||||
alt: 'Movie detail page with poster, cast and play button',
|
||||
width: 2260,
|
||||
height: 1490,
|
||||
link: { href: '/iptvnator/features/xtream-codes-player/', label: 'Xtream Codes player' },
|
||||
},
|
||||
{
|
||||
id: 'downloads',
|
||||
label: 'Downloads',
|
||||
description: 'Offline library, download queue and live-TV recordings in one place.',
|
||||
image: '/iptvnator/screenshots/download-manager.webp',
|
||||
alt: 'Download manager with an offline library and a queue',
|
||||
width: 2500,
|
||||
height: 1602,
|
||||
link: { href: '/iptvnator/blog/v0-23-release-notes/', label: 'Download manager notes' },
|
||||
},
|
||||
{
|
||||
id: 'settings',
|
||||
label: 'Settings',
|
||||
description: 'Player engine, EPG, TMDB metadata, remote control and backup.',
|
||||
image: '/iptvnator/screenshots/settings.webp',
|
||||
alt: 'Application settings in the dark theme',
|
||||
width: 2500,
|
||||
height: 1602,
|
||||
link: { href: '/iptvnator/features/remote-control/', label: 'Phone remote control' },
|
||||
},
|
||||
{
|
||||
id: 'add-playlist',
|
||||
label: 'Add Playlist',
|
||||
image: '/iptvnator/screenshots/add-playlist.webp',
|
||||
alt: 'Add playlist dialog for M3U, Xtream, and Stalker sources',
|
||||
width: 2260,
|
||||
height: 1490,
|
||||
},
|
||||
];
|
||||
] as const;
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
---
|
||||
|
||||
<section id="screenshots" class="relative py-28">
|
||||
<hr class="section-divider mb-28" />
|
||||
|
||||
<div class="mx-auto max-w-6xl px-6">
|
||||
<!-- Editorial header: right-aligned for variety -->
|
||||
<div class="ml-auto max-w-xl text-right">
|
||||
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">
|
||||
Interface
|
||||
</span>
|
||||
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
|
||||
See it in action
|
||||
</h2>
|
||||
<p class="mt-4 text-lg text-surface-400">
|
||||
A clean, intuitive interface designed for the best viewing experience.
|
||||
<div class="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="max-w-xl">
|
||||
<span class="font-mono text-xs uppercase tracking-[0.2em] text-accent-500">
|
||||
Interface
|
||||
</span>
|
||||
<h2 class="mt-4 font-display text-3xl font-bold tracking-tight text-surface-50 sm:text-4xl lg:text-5xl">
|
||||
Flip through the app
|
||||
</h2>
|
||||
</div>
|
||||
<p class="max-w-sm text-base text-surface-400 sm:text-right">
|
||||
Six screens, one player. Pick a channel or let it run.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-14">
|
||||
<!-- Tab buttons with underline style -->
|
||||
<div class="flex flex-wrap justify-center gap-1 rounded-lg border border-surface-700/60 bg-surface-900/50 p-1.5" role="tablist">
|
||||
<div class="mt-12 grid gap-5 lg:grid-cols-[300px_minmax(0,1fr)]" data-channel-switcher>
|
||||
<!-- Channel list -->
|
||||
<div class="flex flex-col overflow-hidden rounded-xl border border-surface-800 bg-surface-900/40">
|
||||
<div class="flex items-center justify-between border-b border-surface-800 px-4 py-3 font-mono text-[11px] uppercase tracking-[0.15em] text-surface-500">
|
||||
<span>Channels</span>
|
||||
<span>{pad(channels.length)}</span>
|
||||
</div>
|
||||
<div role="tablist" aria-label="App screens" aria-orientation="vertical" class="flex flex-col">
|
||||
{
|
||||
tabs.map((tab, i) => (
|
||||
channels.map((channel, i) => (
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`screen-tab-${channel.id}`}
|
||||
aria-selected={i === 0 ? 'true' : 'false'}
|
||||
data-tab={tab.id}
|
||||
class:list={[
|
||||
'screenshot-tab rounded-md px-5 py-2 font-mono text-xs uppercase tracking-[0.1em] transition-all duration-200',
|
||||
i === 0
|
||||
? 'bg-accent-500/15 text-accent-400'
|
||||
: 'text-surface-500 hover:text-surface-300',
|
||||
]}
|
||||
aria-controls={`screen-panel-${channel.id}`}
|
||||
tabindex={i === 0 ? 0 : -1}
|
||||
data-channel={channel.id}
|
||||
class="channel-tab group relative grid grid-cols-[38px_minmax(0,1fr)] gap-3 border-t border-surface-800 px-4 py-3 text-left sm:py-3.5 transition-colors duration-200 first-of-type:border-t-0 hover:bg-white/[0.025] focus-visible:outline focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-accent-400"
|
||||
>
|
||||
{tab.label}
|
||||
<span class="channel-number pt-0.5 font-mono text-xs tabular-nums text-surface-500 transition-colors duration-200">
|
||||
{pad(i + 1)}
|
||||
</span>
|
||||
<span>
|
||||
<span class="block font-display text-[15px] font-semibold tracking-tight text-surface-100">
|
||||
{channel.label}
|
||||
</span>
|
||||
<span class="mt-0.5 hidden text-[12.5px] leading-snug text-surface-500 sm:block lg:hidden xl:block">
|
||||
{channel.description}
|
||||
</span>
|
||||
</span>
|
||||
<span class="pointer-events-none absolute inset-x-0 -bottom-px h-0.5" aria-hidden="true">
|
||||
<span class="channel-progress block h-full w-0 bg-accent-500" />
|
||||
</span>
|
||||
</button>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
<div class="mt-auto flex items-center justify-between gap-3 border-t border-surface-800 px-4 py-2.5 font-mono text-[11px] text-surface-500">
|
||||
<span class="hidden lg:inline">
|
||||
<kbd class="rounded border border-surface-700 px-1.5 py-px text-surface-400">↑</kbd>
|
||||
<kbd class="rounded border border-surface-700 px-1.5 py-px text-surface-400">↓</kbd>
|
||||
<span class="ml-1.5">channel</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="channel-autoplay ml-auto inline-flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-md border border-surface-700 px-2.5 py-1 text-surface-300 transition-colors hover:border-surface-500 hover:text-surface-100 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-accent-400"
|
||||
aria-label="Pause auto-advance"
|
||||
data-autoplay-toggle
|
||||
>
|
||||
<svg class="h-3 w-3" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true" data-icon-pause>
|
||||
<rect x="2" y="1.5" width="3" height="9" rx="0.5" /><rect x="7" y="1.5" width="3" height="9" rx="0.5" />
|
||||
</svg>
|
||||
<svg class="hidden h-3 w-3" viewBox="0 0 12 12" fill="currentColor" aria-hidden="true" data-icon-play>
|
||||
<path d="M2.5 1.5v9l7.5-4.5z" />
|
||||
</svg>
|
||||
<span data-autoplay-label>Pause</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Screenshots with window chrome -->
|
||||
<div class="relative mt-8">
|
||||
<!-- Screen -->
|
||||
<div
|
||||
class="relative aspect-[4/3] overflow-hidden rounded-xl border border-surface-700/60 bg-surface-950 shadow-2xl shadow-black/40 lg:aspect-auto lg:min-h-[460px]"
|
||||
data-channel-screen
|
||||
>
|
||||
{
|
||||
tabs.map((tab, i) => (
|
||||
channels.map((channel, i) => (
|
||||
<div
|
||||
data-panel={tab.id}
|
||||
role="tabpanel"
|
||||
id={`screen-panel-${channel.id}`}
|
||||
aria-labelledby={`screen-tab-${channel.id}`}
|
||||
aria-hidden={i === 0 ? 'false' : 'true'}
|
||||
data-channel-panel={channel.id}
|
||||
class:list={[
|
||||
'screenshot-panel transition-opacity duration-300',
|
||||
i !== 0 && 'hidden',
|
||||
'channel-panel absolute inset-0 transition-opacity duration-300 motion-reduce:transition-none',
|
||||
i !== 0 && 'pointer-events-none opacity-0',
|
||||
]}
|
||||
>
|
||||
<div class="relative mx-auto max-w-4xl">
|
||||
<!-- Ambient glow -->
|
||||
<div class="absolute -inset-6 rounded-2xl bg-accent-500/[0.03] blur-2xl" />
|
||||
|
||||
<!-- Window chrome -->
|
||||
<div class="relative overflow-hidden rounded-xl border border-surface-700/60 bg-surface-900 shadow-2xl shadow-black/30">
|
||||
<div class="flex items-center gap-2 border-b border-surface-700/60 px-4 py-2.5">
|
||||
<div class="flex gap-1.5">
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-red-500/70" />
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-yellow-500/70" />
|
||||
<span class="h-2.5 w-2.5 rounded-full bg-green-500/70" />
|
||||
</div>
|
||||
<span class="ml-2 font-mono text-[11px] text-surface-500">{tab.label}</span>
|
||||
</div>
|
||||
<img
|
||||
src={tab.image}
|
||||
alt={tab.alt}
|
||||
width={tab.width}
|
||||
height={tab.height}
|
||||
class="h-auto w-full"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<img
|
||||
src={i === 0 ? channel.image : undefined}
|
||||
data-src={channel.image}
|
||||
alt={channel.alt}
|
||||
width={channel.width}
|
||||
height={channel.height}
|
||||
class="h-full w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
|
||||
<!-- On-screen display: shows the channel number for a moment after a switch -->
|
||||
<div
|
||||
class="channel-osd pointer-events-none absolute left-4 top-4 flex -translate-y-1 items-center gap-2.5 rounded-lg border border-white/10 bg-surface-950/75 px-3 py-2 font-mono text-xs text-surface-100 opacity-0 backdrop-blur transition-all duration-200 motion-reduce:transition-none"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<span class="font-medium text-accent-400" data-osd-number>CH 01</span>
|
||||
<span data-osd-label>{channels[0].label}</span>
|
||||
</div>
|
||||
|
||||
<!-- Caption -->
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-5 bg-gradient-to-t from-surface-950/90 via-surface-950/60 to-transparent px-5 pb-4 pt-14">
|
||||
<div class="min-w-0">
|
||||
<span class="block font-display text-[15px] font-semibold text-surface-50" data-caption-label>{channels[0].label}</span>
|
||||
<span class="mt-0.5 hidden text-[13px] text-surface-300 sm:block" data-caption-description>{channels[0].description}</span>
|
||||
</div>
|
||||
<a
|
||||
href={channels[0].link.href}
|
||||
class="pointer-events-auto shrink-0 whitespace-nowrap font-mono text-[11px] uppercase tracking-[0.1em] text-accent-400 transition-colors hover:text-accent-300"
|
||||
data-caption-link
|
||||
>
|
||||
{channels[0].link.label} →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const tabs = document.querySelectorAll('.screenshot-tab');
|
||||
const panels = document.querySelectorAll('.screenshot-panel');
|
||||
<script define:vars={{ channels: channels.map((c) => ({ id: c.id, label: c.label, description: c.description, link: c.link })) }}>
|
||||
const root = document.querySelector('[data-channel-switcher]');
|
||||
if (root) {
|
||||
const tabs = Array.from(root.querySelectorAll('.channel-tab'));
|
||||
const panels = Array.from(root.querySelectorAll('.channel-panel'));
|
||||
const osd = root.querySelector('.channel-osd');
|
||||
const osdNumber = root.querySelector('[data-osd-number]');
|
||||
const osdLabel = root.querySelector('[data-osd-label]');
|
||||
const captionLabel = root.querySelector('[data-caption-label]');
|
||||
const captionDescription = root.querySelector('[data-caption-description]');
|
||||
const captionLink = root.querySelector('[data-caption-link]');
|
||||
|
||||
tabs.forEach((tab) => {
|
||||
tab.addEventListener('click', () => {
|
||||
const target = (tab as HTMLElement).dataset.tab;
|
||||
const DWELL_MS = 7000;
|
||||
// Live, not a snapshot: a visitor may switch the OS preference while the
|
||||
// page is open, and the scheduler follows it (see the change listener).
|
||||
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
let reduceMotion = motionQuery.matches;
|
||||
// Hover and focus pause while they last; the toggle pauses until the
|
||||
// visitor resumes. Autoplay runs only when none of the three holds.
|
||||
let hovered = false;
|
||||
let focused = false;
|
||||
let stopped = false;
|
||||
const paused = () => hovered || focused || stopped;
|
||||
let current = 0;
|
||||
let startedAt = 0;
|
||||
let pausedAt = 0;
|
||||
let frame = 0;
|
||||
let osdTimer = 0;
|
||||
|
||||
// Reset all tabs
|
||||
tabs.forEach((t) => {
|
||||
t.classList.remove('bg-accent-500/15', 'text-accent-400');
|
||||
t.classList.add('text-surface-500');
|
||||
t.setAttribute('aria-selected', 'false');
|
||||
const progressOf = (i) => tabs[i].querySelector('.channel-progress');
|
||||
|
||||
// Inactive frames carry their URL in data-src; give a frame its src the
|
||||
// first time it is needed (the shown channel and the one after it).
|
||||
function loadFrame(i) {
|
||||
const img = panels[(i + panels.length) % panels.length].querySelector('img');
|
||||
if (img && !img.getAttribute('src')) img.setAttribute('src', img.dataset.src ?? '');
|
||||
}
|
||||
|
||||
function show(index, { focus = false, announce = true } = {}) {
|
||||
current = (index + tabs.length) % tabs.length;
|
||||
tabs.forEach((tab, i) => {
|
||||
const active = i === current;
|
||||
tab.setAttribute('aria-selected', active ? 'true' : 'false');
|
||||
tab.tabIndex = active ? 0 : -1;
|
||||
progressOf(i).style.width = '0%';
|
||||
});
|
||||
loadFrame(current);
|
||||
if (!reduceMotion && !stopped) loadFrame(current + 1);
|
||||
panels.forEach((panel, i) => {
|
||||
const active = i === current;
|
||||
panel.classList.toggle('opacity-0', !active);
|
||||
panel.classList.toggle('pointer-events-none', !active);
|
||||
panel.setAttribute('aria-hidden', active ? 'false' : 'true');
|
||||
});
|
||||
const channel = channels[current];
|
||||
captionLabel.textContent = channel.label;
|
||||
captionDescription.textContent = channel.description;
|
||||
captionLink.textContent = `${channel.link.label} →`;
|
||||
captionLink.setAttribute('href', channel.link.href);
|
||||
if (announce) {
|
||||
osdNumber.textContent = `CH ${String(current + 1).padStart(2, '0')}`;
|
||||
osdLabel.textContent = channel.label;
|
||||
osd.classList.remove('opacity-0', '-translate-y-1');
|
||||
window.clearTimeout(osdTimer);
|
||||
osdTimer = window.setTimeout(() => osd.classList.add('opacity-0', '-translate-y-1'), 1400);
|
||||
}
|
||||
// A fresh dwell for the new channel. When the loop is not running the
|
||||
// channel starts out paused right now, so time spent paused before
|
||||
// Resume is not counted against it.
|
||||
startedAt = performance.now();
|
||||
pausedAt = frame ? 0 : startedAt;
|
||||
if (focus) tabs[current].focus({ preventScroll: true });
|
||||
}
|
||||
|
||||
// Activate clicked tab
|
||||
tab.classList.add('bg-accent-500/15', 'text-accent-400');
|
||||
tab.classList.remove('text-surface-500');
|
||||
tab.setAttribute('aria-selected', 'true');
|
||||
// One scheduler. The frame loop runs only while the block is on screen
|
||||
// and nothing pauses it; every state change calls sync(), which starts or
|
||||
// cancels the loop. The dwell clock stops while paused: the time spent
|
||||
// paused is added to the start mark on resume, so the bar continues from
|
||||
// where it froze. Only a channel change resets the dwell. A paused block
|
||||
// schedules no frames at all.
|
||||
let visible = false;
|
||||
|
||||
// Show/hide panels
|
||||
panels.forEach((panel) => {
|
||||
if ((panel as HTMLElement).dataset.panel === target) {
|
||||
panel.classList.remove('hidden');
|
||||
} else {
|
||||
panel.classList.add('hidden');
|
||||
function loop(now) {
|
||||
const progress = (now - startedAt) / DWELL_MS;
|
||||
if (progress >= 1) show(current + 1);
|
||||
else progressOf(current).style.width = `${progress * 100}%`;
|
||||
frame = window.requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
function sync() {
|
||||
// Background tabs throttle animation frames while the clock keeps
|
||||
// running, so a hidden document counts as a pause too.
|
||||
const shouldRun = visible && !document.hidden && !reduceMotion && !paused();
|
||||
if (shouldRun && !frame) {
|
||||
const now = performance.now();
|
||||
if (!startedAt) startedAt = now;
|
||||
if (pausedAt) {
|
||||
startedAt += now - pausedAt;
|
||||
pausedAt = 0;
|
||||
}
|
||||
});
|
||||
loadFrame(current + 1); // autoplay is on, so the next frame will be needed
|
||||
frame = window.requestAnimationFrame(loop);
|
||||
} else if (!shouldRun && frame) {
|
||||
window.cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
pausedAt = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
tabs.forEach((tab, i) => tab.addEventListener('click', () => show(i, { focus: true })));
|
||||
|
||||
root.addEventListener('keydown', (event) => {
|
||||
if (!(event.target instanceof HTMLElement) || event.target.getAttribute('role') !== 'tab') return;
|
||||
const keys = { ArrowDown: current + 1, ArrowUp: current - 1, Home: 0, End: tabs.length - 1 };
|
||||
if (!(event.key in keys)) return;
|
||||
event.preventDefault();
|
||||
show(keys[event.key], { focus: true });
|
||||
});
|
||||
});
|
||||
|
||||
// A visitor who is reading the list or the screen keeps the current
|
||||
// channel. The footer with the Pause/Resume control is deliberately not
|
||||
// one of these regions: after pressing Resume the pointer and the focus
|
||||
// are still on that button, and autoplay must visibly restart right away.
|
||||
const regions = [root.querySelector('[role="tablist"]'), root.querySelector('[data-channel-screen]')].filter(Boolean);
|
||||
const insideRegion = (node) => node instanceof Node && regions.some((region) => region.contains(node));
|
||||
for (const region of regions) {
|
||||
region.addEventListener('mouseenter', () => { hovered = true; sync(); });
|
||||
region.addEventListener('mouseleave', () => { hovered = false; sync(); });
|
||||
region.addEventListener('focusin', () => { focused = true; sync(); });
|
||||
region.addEventListener('focusout', (event) => {
|
||||
if (insideRegion(event.relatedTarget)) return;
|
||||
focused = false;
|
||||
sync();
|
||||
});
|
||||
}
|
||||
|
||||
// Nothing to pause under reduced motion: the loop never starts, so the
|
||||
// control is hidden (not removed — the preference can change at runtime).
|
||||
const toggle = root.querySelector('[data-autoplay-toggle]');
|
||||
const reflectMotionPreference = () => {
|
||||
toggle?.classList.toggle('hidden', reduceMotion);
|
||||
sync();
|
||||
};
|
||||
toggle?.addEventListener('click', () => {
|
||||
stopped = !stopped;
|
||||
// An action button, not a toggle: the name says what pressing it does next.
|
||||
toggle.querySelector('[data-autoplay-label]').textContent = stopped ? 'Resume' : 'Pause';
|
||||
toggle.setAttribute('aria-label', stopped ? 'Resume auto-advance' : 'Pause auto-advance');
|
||||
toggle.querySelector('[data-icon-pause]').classList.toggle('hidden', stopped);
|
||||
toggle.querySelector('[data-icon-play]').classList.toggle('hidden', !stopped);
|
||||
sync();
|
||||
});
|
||||
motionQuery.addEventListener('change', (event) => {
|
||||
reduceMotion = event.matches;
|
||||
reflectMotionPreference();
|
||||
});
|
||||
reflectMotionPreference();
|
||||
document.addEventListener('visibilitychange', sync);
|
||||
|
||||
// Only run while the block is on screen.
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
visible = entry.isIntersecting;
|
||||
});
|
||||
sync();
|
||||
},
|
||||
{ threshold: 0.25 },
|
||||
);
|
||||
observer.observe(root);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.channel-tab[aria-selected='true'] {
|
||||
@apply bg-surface-850;
|
||||
}
|
||||
.channel-tab[aria-selected='true'] .channel-number {
|
||||
@apply text-accent-400;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { createServer } from 'node:http';
|
||||
import { extname, resolve, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Shared plumbing for the website tests that drive the built site in a real
|
||||
* browser: a loopback static server for `dist/apps/website` mounted under the
|
||||
* GitHub Pages base path, and a Chromium launcher.
|
||||
*
|
||||
* The server only ever hands out files below the build directory: the
|
||||
* requested path is resolved against the root and anything that escapes it is
|
||||
* answered with 404. Only local tests talk to it, but the guard keeps the
|
||||
* helper honest and static analysis quiet.
|
||||
*
|
||||
* Chromium comes from the Playwright download when one exists, otherwise from
|
||||
* the system Chrome/Chromium channel (GitHub's Ubuntu runners ship Google
|
||||
* Chrome). In CI a missing browser is a failure, never a silent skip, so the
|
||||
* interaction assertions cannot rot unnoticed; locally the caller may skip.
|
||||
*/
|
||||
|
||||
export const distRoot = fileURLToPath(new URL('../../dist/apps/website/', import.meta.url));
|
||||
export const BASE_PATH = '/iptvnator';
|
||||
|
||||
const MIME = {
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'text/javascript',
|
||||
'.webp': 'image/webp',
|
||||
'.png': 'image/png',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.xml': 'text/xml',
|
||||
};
|
||||
|
||||
/** Maps a request URL to a file below `distRoot`, or `null` when it escapes the root. */
|
||||
export function resolveDistFile(url) {
|
||||
let pathname;
|
||||
try {
|
||||
pathname = decodeURIComponent(new URL(url, 'http://localhost').pathname);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (pathname.startsWith(BASE_PATH)) {
|
||||
pathname = pathname.slice(BASE_PATH.length) || '/';
|
||||
}
|
||||
const root = resolve(distRoot);
|
||||
const candidate = resolve(root, `.${pathname}`);
|
||||
if (candidate !== root && !candidate.startsWith(root + sep)) {
|
||||
return null;
|
||||
}
|
||||
const file = existsSync(candidate) && statSync(candidate).isDirectory() ? resolve(candidate, 'index.html') : candidate;
|
||||
return existsSync(file) ? file : null;
|
||||
}
|
||||
|
||||
/** Serves the build on a random loopback port; resolves to `{ server, origin }`. */
|
||||
export function serveDist() {
|
||||
const server = createServer((req, res) => {
|
||||
const file = resolveDistFile(req.url ?? '/');
|
||||
if (!file) {
|
||||
res.statusCode = 404;
|
||||
res.end('not found');
|
||||
return;
|
||||
}
|
||||
res.setHeader('content-type', MIME[extname(file)] ?? 'application/octet-stream');
|
||||
createReadStream(file).pipe(res);
|
||||
});
|
||||
return new Promise((done) =>
|
||||
server.listen(0, '127.0.0.1', () => done({ server, origin: `http://127.0.0.1:${server.address().port}` })),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches Chromium, trying the Playwright download first and the system
|
||||
* channels next. Returns `null` when none is available — except in CI, where
|
||||
* that is thrown as an error so the browser half of a test cannot be skipped.
|
||||
*/
|
||||
export async function launchBrowser() {
|
||||
let playwright;
|
||||
try {
|
||||
playwright = await import('@playwright/test');
|
||||
} catch (error) {
|
||||
return unavailable(`@playwright/test is not installed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
const attempts = [];
|
||||
for (const options of [{}, { channel: 'chrome' }, { channel: 'chromium' }]) {
|
||||
try {
|
||||
return await playwright.chromium.launch(options);
|
||||
} catch (error) {
|
||||
attempts.push(`${JSON.stringify(options)}: ${error instanceof Error ? error.message.split('\n')[0] : String(error)}`);
|
||||
}
|
||||
}
|
||||
return unavailable(`no Chromium could be launched (${attempts.join('; ')})`);
|
||||
}
|
||||
|
||||
function unavailable(reason) {
|
||||
if (process.env.CI) {
|
||||
throw new Error(`Website browser tests must run in CI, but ${reason}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { BASE_PATH as BASE, distRoot, launchBrowser, serveDist } from './website-browser-support.mjs';
|
||||
|
||||
/**
|
||||
* The home page "Flip through the app" channel switcher.
|
||||
*
|
||||
* The structural half reads the built HTML: a vertical tablist with one
|
||||
* selected channel, roving tabindex, and a panel per channel. The
|
||||
* interaction half serves the build over HTTP and drives it in Chromium:
|
||||
* autoplay advances, hover and focus pause independently, arrow keys move
|
||||
* the selection together with the panel, caption and on-screen badge, and
|
||||
* `prefers-reduced-motion` turns autoplay off. Without a Chromium the browser
|
||||
* half is skipped locally and fails in CI (see website-browser-support.mjs).
|
||||
*/
|
||||
|
||||
const CHANNELS = ['dashboard', 'live-tv', 'epg', 'movies', 'downloads', 'settings'];
|
||||
|
||||
test('showcase markup: a vertical tablist with one selected channel and a panel each', async () => {
|
||||
const html = await readFile(join(distRoot, 'index.html'), 'utf8');
|
||||
assert.match(html, /<section id="screenshots"/);
|
||||
assert.match(html, /role="tablist"[^>]*aria-orientation="vertical"/);
|
||||
|
||||
const tabs = [...html.matchAll(/<button[^>]*role="tab"[^>]*>/g)].map((m) => m[0]);
|
||||
assert.equal(tabs.length, CHANNELS.length, 'one tab per channel');
|
||||
for (const [i, tab] of tabs.entries()) {
|
||||
assert.match(tab, new RegExp(`data-channel="${CHANNELS[i]}"`));
|
||||
assert.match(tab, new RegExp(`aria-controls="screen-panel-${CHANNELS[i]}"`));
|
||||
assert.match(tab, new RegExp(`aria-selected="${i === 0 ? 'true' : 'false'}"`));
|
||||
assert.match(tab, new RegExp(`tabindex="${i === 0 ? '0' : '-1'}"`));
|
||||
}
|
||||
|
||||
const panels = [...html.matchAll(/<div[^>]*role="tabpanel"[^>]*>/g)].map((m) => m[0]);
|
||||
assert.equal(panels.length, CHANNELS.length, 'one panel per channel');
|
||||
for (const [i, panel] of panels.entries()) {
|
||||
assert.match(panel, new RegExp(`id="screen-panel-${CHANNELS[i]}"`));
|
||||
assert.match(panel, new RegExp(`aria-labelledby="screen-tab-${CHANNELS[i]}"`));
|
||||
assert.match(panel, new RegExp(`aria-hidden="${i === 0 ? 'false' : 'true'}"`));
|
||||
}
|
||||
assert.match(html, /href="\/iptvnator\/features\/epg\/"/, 'captions link into the feature pages');
|
||||
const toggleTag = html.match(/<button[^>]*data-autoplay-toggle[^>]*>/)?.[0];
|
||||
assert.ok(toggleTag && /aria-label="Pause auto-advance"/.test(toggleTag), 'a persistent pause control ships in the markup');
|
||||
assert.doesNotMatch(toggleTag, /aria-pressed/, 'the control is an action button, not a toggle with a changing name');
|
||||
assert.match(html, /channel-osd[^"]*motion-reduce:transition-none/, 'the OSD slide is off under reduced motion');
|
||||
assert.match(html, /channel-panel[^"]*motion-reduce:transition-none/, 'the panel fade is off under reduced motion');
|
||||
const tablist = html.match(/<div role="tablist"[\s\S]*?<\/div>/)?.[0];
|
||||
assert.ok(tablist, 'tablist element');
|
||||
assert.equal((tablist.match(/<button/g) ?? []).length, CHANNELS.length, 'the tablist holds exactly the tabs');
|
||||
assert.doesNotMatch(tablist, /data-autoplay-toggle/, 'the pause control lives outside the tablist');
|
||||
|
||||
const frames = panels.map((panel) => html.slice(html.indexOf(panel)).match(/<img[^>]*>/)[0]);
|
||||
assert.equal(frames.filter((img) => / src="/.test(img)).length, 1, 'only the first frame ships with a src');
|
||||
assert.ok(frames.every((img) => /data-src="\/iptvnator\/screenshots\//.test(img)), 'every frame carries its data-src');
|
||||
});
|
||||
|
||||
const selectedChannel = (page) => page.$eval('.channel-tab[aria-selected="true"]', (el) => el.dataset.channel);
|
||||
const progressWidth = (page) =>
|
||||
page.$eval('.channel-tab[aria-selected="true"] .channel-progress', (el) => parseFloat(el.style.width) || 0);
|
||||
const hiddenPanels = (page) => page.$$eval('.channel-panel', (els) => els.map((el) => el.getAttribute('aria-hidden')));
|
||||
|
||||
test('showcase interaction: autoplay, pausing, keyboard and synchronized state', async (t) => {
|
||||
const browser = await launchBrowser();
|
||||
if (!browser) {
|
||||
t.skip('no Chromium available for the browser half');
|
||||
return;
|
||||
}
|
||||
const { server, origin } = await serveDist();
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(String(error)));
|
||||
// Count scheduled animation frames so a paused switcher can be shown to schedule none.
|
||||
await page.addInitScript(() => {
|
||||
const raf = window.requestAnimationFrame.bind(window);
|
||||
window.__rafCount = 0;
|
||||
window.requestAnimationFrame = (cb) => {
|
||||
window.__rafCount += 1;
|
||||
return raf(cb);
|
||||
};
|
||||
});
|
||||
const rafCount = (p) => p.evaluate(() => window.__rafCount);
|
||||
await page.goto(`${origin}${BASE}/`, { waitUntil: 'networkidle' });
|
||||
await page.locator('#screenshots').scrollIntoViewIfNeeded();
|
||||
// Park the pointer away from the block so hover cannot pause autoplay.
|
||||
await page.mouse.move(5, 5);
|
||||
|
||||
await page.waitForFunction(
|
||||
() => parseFloat(document.querySelector('.channel-tab[aria-selected="true"] .channel-progress').style.width) > 5,
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
assert.equal(await selectedChannel(page), 'dashboard', 'autoplay starts on the first channel');
|
||||
const loaded = (p) => p.$$eval('.channel-panel img', (els) => els.map((el) => Boolean(el.getAttribute('src'))));
|
||||
assert.deepEqual(await loaded(page), [true, true, false, false, false, false], 'only the shown frame and the next one have a src');
|
||||
|
||||
// The dwell really rolls over: tab, panel, caption and badge all move to channel 02.
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('.channel-tab[aria-selected="true"]')?.dataset.channel === 'live-tv',
|
||||
null,
|
||||
{ timeout: 9000 },
|
||||
);
|
||||
assert.deepEqual(await hiddenPanels(page), ['true', 'false', 'true', 'true', 'true', 'true'], 'the panel follows autoplay');
|
||||
assert.equal(await page.$eval('[data-caption-label]', (el) => el.textContent.trim()), 'Live TV');
|
||||
assert.equal(await page.$eval('[data-osd-number]', (el) => el.textContent.trim()), 'CH 02');
|
||||
assert.deepEqual((await loaded(page)).slice(0, 3), [true, true, true], 'autoplay preloads the frame after the new channel');
|
||||
|
||||
// The toggle pauses until pressed again, whatever the pointer and focus do.
|
||||
const toggle = page.locator('[data-autoplay-toggle]');
|
||||
await toggle.click();
|
||||
assert.equal(await toggle.getAttribute('aria-label'), 'Resume auto-advance');
|
||||
await page.mouse.move(5, 5);
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
const stoppedAt = await progressWidth(page);
|
||||
await page.waitForTimeout(200);
|
||||
const framesAtPause = await rafCount(page);
|
||||
await page.waitForTimeout(600);
|
||||
assert.ok(Math.abs((await progressWidth(page)) - stoppedAt) < 0.5, 'the toggle keeps autoplay paused after mouseleave and blur');
|
||||
assert.equal(await rafCount(page), framesAtPause, 'a paused switcher schedules no animation frames');
|
||||
|
||||
// A channel picked while paused does not preload its successor; resuming fetches it.
|
||||
await page.locator('.channel-tab').nth(3).click();
|
||||
assert.equal(await selectedChannel(page), 'movies');
|
||||
assert.deepEqual((await loaded(page)).slice(3, 5), [true, false], 'paused: the picked frame loads, its successor does not');
|
||||
await page.mouse.move(5, 5);
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
await page.waitForTimeout(300);
|
||||
const pausedOnMovies = await progressWidth(page);
|
||||
assert.ok(pausedOnMovies < 0.5, 'still paused on the newly picked channel');
|
||||
|
||||
// Scrolling away and back while paused must not fetch the successor either.
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(400);
|
||||
await page.locator('#screenshots').scrollIntoViewIfNeeded();
|
||||
await page.mouse.move(5, 5);
|
||||
await page.waitForTimeout(400);
|
||||
assert.deepEqual((await loaded(page)).slice(3, 5), [true, false], 'returning to a paused switcher loads no successor');
|
||||
assert.ok((await progressWidth(page)) < 0.5, 'still paused after returning');
|
||||
|
||||
// Resume restarts autoplay at once, with the pointer and the focus still on the button.
|
||||
await toggle.click();
|
||||
assert.equal(await toggle.getAttribute('aria-label'), 'Pause auto-advance');
|
||||
assert.deepEqual((await loaded(page)).slice(3, 5), [true, true], 'resuming preloads the successor');
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.hasAttribute('data-autoplay-toggle')), true, 'the button keeps focus');
|
||||
assert.ok((await progressWidth(page)) < 3, 'time spent paused is not counted against the picked channel');
|
||||
await page.waitForTimeout(500);
|
||||
assert.ok((await progressWidth(page)) > pausedOnMovies + 2, 'autoplay runs while the toggle is focused and hovered');
|
||||
await page.mouse.move(5, 5);
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
|
||||
// Hover pauses.
|
||||
await page.locator('.channel-tab').nth(2).hover();
|
||||
await page.waitForTimeout(150);
|
||||
const pausedAt = await progressWidth(page);
|
||||
await page.waitForTimeout(700);
|
||||
assert.ok(Math.abs((await progressWidth(page)) - pausedAt) < 0.5, 'progress holds while hovered');
|
||||
await page.mouse.move(5, 5);
|
||||
await page.waitForTimeout(400);
|
||||
const resumed = await progressWidth(page);
|
||||
assert.ok(resumed >= pausedAt && resumed < pausedAt + 15, `progress resumes from where it paused (${pausedAt} → ${resumed})`);
|
||||
|
||||
// Clicking selects immediately and focus keeps the pause after the pointer leaves.
|
||||
await page.locator('.channel-tab').nth(2).hover();
|
||||
await page.locator('.channel-tab').nth(2).click();
|
||||
assert.equal(await selectedChannel(page), 'epg');
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.dataset.channel), 'epg', 'click moves focus to the tab');
|
||||
await page.mouse.move(5, 5);
|
||||
await page.waitForTimeout(900);
|
||||
assert.equal(await progressWidth(page), 0, 'a focused tab keeps autoplay paused after mouseleave');
|
||||
assert.equal(await selectedChannel(page), 'epg');
|
||||
|
||||
// Keyboard: ArrowDown / ArrowUp / End / Home move selection, focus, panel, caption and badge together.
|
||||
await page.keyboard.press('ArrowDown');
|
||||
assert.equal(await selectedChannel(page), 'movies');
|
||||
assert.equal(await page.evaluate(() => document.activeElement?.dataset.channel), 'movies');
|
||||
assert.deepEqual(await hiddenPanels(page), ['true', 'true', 'true', 'false', 'true', 'true']);
|
||||
assert.equal(await page.$eval('[data-caption-label]', (el) => el.textContent.trim()), 'Movies & series');
|
||||
assert.equal(await page.$eval('[data-osd-number]', (el) => el.textContent.trim()), 'CH 04');
|
||||
assert.deepEqual((await loaded(page)).slice(2, 5), [true, true, true], 'a shown frame and its successor get their src');
|
||||
assert.equal(await page.$eval('.channel-osd', (el) => el.classList.contains('opacity-0')), false, 'badge shows on switch');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
assert.equal(await selectedChannel(page), 'epg');
|
||||
await page.keyboard.press('End');
|
||||
assert.equal(await selectedChannel(page), 'settings');
|
||||
await page.keyboard.press('ArrowDown');
|
||||
assert.equal(await selectedChannel(page), 'dashboard', 'ArrowDown wraps around');
|
||||
await page.keyboard.press('Home');
|
||||
assert.equal(await selectedChannel(page), 'dashboard');
|
||||
const tabIndexes = await page.$$eval('.channel-tab', (els) => els.map((el) => el.tabIndex));
|
||||
assert.deepEqual(tabIndexes, [0, -1, -1, -1, -1, -1], 'roving tabindex follows the selection');
|
||||
|
||||
// Blurring the list resumes autoplay.
|
||||
await page.evaluate(() => document.activeElement?.blur());
|
||||
await page.waitForFunction(
|
||||
() => parseFloat(document.querySelector('.channel-tab[aria-selected="true"] .channel-progress').style.width) > 5,
|
||||
null,
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
|
||||
// Scrolling the block away pauses the dwell; scrolling back resumes it from the same mark.
|
||||
await page.evaluate(() => window.scrollTo(0, 0));
|
||||
await page.waitForTimeout(400); // let the IntersectionObserver report the exit
|
||||
const whileAway = await progressWidth(page);
|
||||
await page.waitForTimeout(700);
|
||||
const stillAway = await progressWidth(page);
|
||||
assert.ok(Math.abs(stillAway - whileAway) < 0.5, `progress holds while offscreen (${whileAway} → ${stillAway})`);
|
||||
await page.locator('#screenshots').scrollIntoViewIfNeeded();
|
||||
await page.mouse.move(5, 5);
|
||||
await page.waitForTimeout(400);
|
||||
const afterReturn = await progressWidth(page);
|
||||
assert.ok(afterReturn >= whileAway && afterReturn < whileAway + 15, `progress resumes after scrolling back (${whileAway} → ${afterReturn})`);
|
||||
// A hidden document pauses the dwell: no frames, progress frozen, resumes on return.
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => true });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await page.waitForTimeout(150);
|
||||
const hiddenAt = await progressWidth(page);
|
||||
const framesWhileHidden = await rafCount(page);
|
||||
await page.waitForTimeout(600);
|
||||
assert.ok(Math.abs((await progressWidth(page)) - hiddenAt) < 0.5, 'progress holds while the document is hidden');
|
||||
assert.equal(await rafCount(page), framesWhileHidden, 'no frames while the document is hidden');
|
||||
await page.evaluate(() => {
|
||||
Object.defineProperty(document, 'hidden', { configurable: true, get: () => false });
|
||||
document.dispatchEvent(new Event('visibilitychange'));
|
||||
});
|
||||
await page.waitForTimeout(400);
|
||||
assert.ok((await progressWidth(page)) > hiddenAt, 'the dwell resumes when the document is visible again');
|
||||
|
||||
// Reduced motion switched on at runtime stops autoplay and hides the control; switching it off restores both.
|
||||
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||
await page.waitForTimeout(150);
|
||||
const reducedAt = await progressWidth(page);
|
||||
await page.waitForTimeout(600);
|
||||
assert.ok(Math.abs((await progressWidth(page)) - reducedAt) < 0.5, 'autoplay stops when reduced motion is enabled at runtime');
|
||||
assert.equal(await page.locator('[data-autoplay-toggle]').isVisible(), false, 'the pause control hides under reduced motion');
|
||||
await page.emulateMedia({ reducedMotion: 'no-preference' });
|
||||
await page.waitForTimeout(400);
|
||||
assert.ok((await progressWidth(page)) > reducedAt, 'autoplay resumes when the preference is cleared');
|
||||
assert.equal(await page.locator('[data-autoplay-toggle]').isVisible(), true, 'the pause control returns');
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
await page.close();
|
||||
|
||||
// Reduced motion: no autoplay at all.
|
||||
const calm = await browser.newPage({ viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' });
|
||||
await calm.goto(`${origin}${BASE}/`, { waitUntil: 'networkidle' });
|
||||
await calm.locator('#screenshots').scrollIntoViewIfNeeded();
|
||||
await calm.mouse.move(5, 5);
|
||||
await calm.waitForTimeout(1500);
|
||||
assert.equal(await progressWidth(calm), 0, 'no progress under prefers-reduced-motion');
|
||||
assert.deepEqual(await loaded(calm), [true, false, false, false, false, false], 'no prefetch when autoplay is off');
|
||||
assert.equal(await selectedChannel(calm), 'dashboard');
|
||||
assert.equal(await calm.locator('[data-autoplay-toggle]').isVisible(), false, 'no pause control when nothing advances');
|
||||
await calm.keyboard.press('Tab');
|
||||
await calm.locator('.channel-tab').nth(1).click();
|
||||
assert.equal(await selectedChannel(calm), 'live-tv', 'manual switching still works');
|
||||
assert.deepEqual(await loaded(calm), [true, true, false, false, false, false], 'a manual switch loads only the chosen frame under reduced motion');
|
||||
await calm.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user