docs(website): illustrate feature guides with app screenshots

This commit is contained in:
4gray committed 2026-09-27 22:09:17 +02:00
1 parent 887ac64d18
commit 807b5ea259
24 files changed
+268 -3

No files matched your search

+3 -1
View File
@@ -89,10 +89,12 @@ export function writeM3uFixture(dataDir: string): string {
['Culture', 'Atlas Culture', 'atlas-culture'],
['Culture', 'Night Music', 'night-music'],
];
const stream = `${XTREAM_MOCK_ORIGIN}/live/marketing/marketing/52000.m3u8`;
const lines = ['#EXTM3U'];
channels.forEach(([group, title, slug], index) => {
// Distinct URLs keep the player from highlighting every demo channel
// as the current one when a fullscreen list is captured.
const stream = `${XTREAM_MOCK_ORIGIN}/live/marketing/marketing/${52000 + index}.m3u8`;
lines.push(
`#EXTINF:-1 tvg-id="demo-${index + 1}" tvg-name="${title}" tvg-logo="${XTREAM_MOCK_ORIGIN}/assets/marketing/logo/${slug}.svg?size=256x256" group-title="${group}",${title}`,
stream
@@ -0,0 +1,94 @@
/** Real UI captures for the 0.23/0.24 feature guides, using local demo media. */
import { readFileSync } from 'node:fs';
import path from 'node:path';
import type { Page } from '@playwright/test';
import { type CaptureAction, openXtreamSection } from './capture-navigation-helpers';
import { openM3uGroups } from './capture-navigation-portal-actions';
import { openSettings } from './capture-navigation-setup-actions';
async function openSettingsSection(page: Page, section: string): Promise<void> {
await openSettings(page);
await page.locator(`[data-test-id="settings-section-${section}"]`).click();
await page.locator(`#${section}`).waitFor({ state: 'visible' });
}
async function openUpdateChannel(page: Page): Promise<void> {
await openSettingsSection(page, 'about');
await page.locator('[data-test-id="select-update-channel"]').click();
await page.locator('[data-test-id="update-channel-nightly"]').waitFor();
}
async function openBackup(page: Page): Promise<void> {
await openSettingsSection(page, 'backup');
}
async function openLibraryWatched(page: Page): Promise<void> {
await openXtreamSection(page, 'vod', 'Action & Mystery');
const watched = page.locator('[data-testid="vod-watched-toggle"]');
await watched.waitFor();
if ((await watched.getAttribute('aria-label')) === 'Mark as Watched') {
await watched.click();
}
await page.getByRole('button', { name: 'Mark as Unwatched', exact: true }).waitFor();
}
async function openDemoPlayback(page: Page): Promise<void> {
await openSettingsSection(page, 'playback');
const player = page.locator('[data-test-id="select-video-player"]');
if (!(await player.innerText()).includes('HTML5')) {
await player.click();
await page.locator('[data-test-id="html5"]').click();
await page.locator('[data-test-id="save-settings"]').click();
await page.locator('[data-test-id="save-settings"]').waitFor({ state: 'hidden' });
}
// A local generated H.264 slate, never a provider stream. Use MPEG-TS
// segments so the normal M3U/HLS player path decodes it without overrides.
const fixture = path.resolve('tools/release/fixtures/guide-demo.mpegts');
await page.route('**/live/marketing/marketing/*.m3u8', (route) =>
route.fulfill({
contentType: 'application/vnd.apple.mpegurl',
body: '#EXTM3U\n#EXT-X-VERSION:3\n#EXT-X-TARGETDURATION:20\n#EXT-X-MEDIA-SEQUENCE:0\n#EXTINF:20.0,\nhttp://localhost:3211/demo/guide-demo.ts\n#EXT-X-ENDLIST\n',
})
);
await page.route('**/demo/guide-demo.ts', (route) =>
route.fulfill({ contentType: 'video/mp2t', body: readFileSync(fixture) })
);
await openM3uGroups(page);
await page.locator('[data-test-id="channel-item"]').first().click();
await page.waitForFunction(() => {
const video = document.querySelector('video');
return video && video.readyState >= 2 && video.videoWidth === 1280;
}, undefined, { timeout: 25_000 });
// Freeze decoded media, not the interface, for a repeatable illustration.
await page.locator('video').evaluate((video: HTMLVideoElement) => video.pause());
await page.locator('video').hover();
}
async function openFullscreenChannels(page: Page): Promise<void> {
await openDemoPlayback(page);
await page.getByRole('button', { name: 'Enter fullscreen', exact: true }).click();
await page.keyboard.press('c');
await page.locator('[data-test-id="fullscreen-channel-panel"][aria-hidden="false"]').waitFor();
await page.locator('[data-test-id="m3u-fullscreen-view-all"]').click();
}
async function openSubtitles(page: Page): Promise<void> {
await openDemoPlayback(page);
await page.getByRole('button', { name: 'Subtitles', exact: true }).click();
await page.locator('[data-test-id="player-controls-load-subtitle"]').waitFor();
}
async function openStreamInfo(page: Page): Promise<void> {
await openDemoPlayback(page);
await page.locator('[data-test-id="player-controls-stream-info-button"]').click();
await page.locator('[data-test-id="player-controls-stream-info-panel"]').waitFor();
}
export const FEATURE_ACTIONS: Readonly<Record<string, CaptureAction>> = {
'open-update-channel': openUpdateChannel,
'open-backup': openBackup,
'open-library-watched': openLibraryWatched,
'open-fullscreen-channels': openFullscreenChannels,
'open-player-subtitles': openSubtitles,
'open-stream-info': openStreamInfo,
};
+8
View File
@@ -25,6 +25,7 @@ import { DOWNLOAD_ACTIONS } from './capture-navigation-download-actions';
import { EPG_ACTIONS } from './capture-navigation-epg-actions';
import { PORTAL_ACTIONS } from './capture-navigation-portal-actions';
import { SETUP_ACTIONS } from './capture-navigation-setup-actions';
import { FEATURE_ACTIONS } from './capture-navigation-feature-actions';
export {
clickDialogOption,
@@ -41,6 +42,7 @@ const ACTIONS: Readonly<Record<string, CaptureAction>> = {
...PORTAL_ACTIONS,
...DOWNLOAD_ACTIONS,
...EPG_ACTIONS,
...FEATURE_ACTIONS,
};
/* ------------------------------------------------------------------ */
@@ -93,6 +95,12 @@ export async function runAction(
// dialog open or a dirty settings form. Clear whatever the previous step
// left behind before this one starts navigating: a dialog backdrop
// swallows every click, and unsaved settings raise a leave prompt.
await page.evaluate(async () => {
if (document.fullscreenElement) await document.exitFullscreen();
});
if (await page.locator('.cdk-overlay-backdrop-showing').count()) {
await page.keyboard.press('Escape');
}
await dismissDialogs(page);
await discardUnsavedSettings(page);
await run(page, param);
+27
View File
@@ -0,0 +1,27 @@
# Guide playback fixture
`guide-demo.mpegts` is an original, synthetic 20-second H.264 video slate at
1280×720 / 25 fps, with no audio or third-party footage. Feature-guide captures
serve it through a local HLS manifest, decode it with the real HTML5 player,
and pause the decoded frame for repeatability. No player state or statistics
are fabricated. Subtitle menus use the app's normal file-selection flow.
To regenerate on macOS with FFmpeg (Helvetica is a system font):
```sh
ffmpeg -f lavfi -i 'gradients=s=1280x720:r=25:c0=0x14243e:c1=0x507f9f:n=2:d=20:speed=0.01:seed=24' \
-vf "drawtext=fontfile=/System/Library/Fonts/Helvetica.ttc:text='IPTVnator':fontcolor=white:fontsize=52:x=(w-text_w)/2:y=(h-text_h)/2-30,drawtext=fontfile=/System/Library/Fonts/Helvetica.ttc:text='DEMO STREAM':fontcolor=white@0.6:fontsize=20:x=(w-text_w)/2:y=(h-text_h)/2+35" \
-c:v libx264 -preset fast -crf 28 -pix_fmt yuv420p -g 50 -an \
-f mpegts tools/release/fixtures/guide-demo.mpegts
```
Capture the six feature-guide screens with:
```sh
pnpm nx run electron-backend:build-e2e
pnpm release:screenshots --group feature-guides --theme dark
```
The EPG article uses the original GitHub release image selected by the
maintainer, not this fixture. Its source is documented in
`apps/website/public/blog/feature-guides/SOURCES.md`.
Binary file not shown.
+6
View File
@@ -40,6 +40,12 @@ export const HOST_RESOLVER_RULES =
/** Setup actions the driver implements. Manifest steps must match. */
export const KNOWN_ACTIONS = [
'open-update-channel',
'open-backup',
'open-library-watched',
'open-fullscreen-channels',
'open-player-subtitles',
'open-stream-info',
'open-dashboard',
'open-settings',
'open-xtream-vod',
+48
View File
@@ -203,6 +203,54 @@
"setup": [
"open-settings-tmdb"
]
},
{
"slug": "guide-update-channel",
"group": "feature-guides",
"description": "update channel",
"setup": [
"open-update-channel"
]
},
{
"slug": "guide-backup",
"group": "feature-guides",
"description": "backup",
"setup": [
"open-backup"
]
},
{
"slug": "guide-library-watched",
"group": "feature-guides",
"description": "library watched state",
"setup": [
"open-library-watched"
]
},
{
"slug": "guide-player-subtitles",
"group": "feature-guides",
"description": "player subtitles",
"setup": [
"open-player-subtitles"
]
},
{
"slug": "guide-stream-info",
"group": "feature-guides",
"description": "stream info",
"setup": [
"open-stream-info"
]
},
{
"slug": "guide-fullscreen-channels",
"group": "feature-guides",
"description": "fullscreen channels",
"setup": [
"open-fullscreen-channels"
]
}
]
}
+26 -1
View File
@@ -17,7 +17,7 @@ const SITE = 'https://4gray.github.io/iptvnator';
const GUIDES = [
{
slug: 'm3u-programme-guide',
screenshots: [],
screenshots: ['blog/feature-guides/multi-epg-v0-24.jpg'],
},
{
slug: 'xtream-codes-setup-guide',
@@ -80,6 +80,31 @@ const GUIDES = [
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');
const FEATURE_GUIDE_SHOTS = {
'stable-nightly-updates-guide': 'guide-update-channel',
'playlist-backup-restore-guide': 'guide-backup',
'library-organization-guide': 'guide-library-watched',
'player-controls-guide': 'guide-player-subtitles',
'stream-info-diagnostics-guide': 'guide-stream-info',
'fullscreen-channel-episode-guide': 'guide-fullscreen-channels',
};
if (process.env.PUBLIC_INCLUDE_DRAFTS === 'true') {
for (const [slug, shot] of Object.entries(FEATURE_GUIDE_SHOTS)) {
GUIDES.push({ slug, screenshots: [`blog/feature-guides/screenshots/${shot}-dark.png`] });
}
}
test('feature guide drafts reference screenshots that ship with the site', async () => {
for (const [slug, shot] of Object.entries(FEATURE_GUIDE_SHOTS)) {
const source = await readFile(new URL(`../../apps/website/src/content/blog/${slug}.mdx`, import.meta.url), 'utf8');
const screenshot = `blog/feature-guides/screenshots/${shot}-dark.png`;
assert.ok(source.includes(`](/iptvnator/${screenshot})`), `${slug}: inline screenshot`);
assert.ok(source.includes(`heroImage: /iptvnator/${screenshot}`), `${slug}: card image`);
await access(new URL(screenshot, distRoot));
}
});
const HUB_GROUPS = ['getting-started', 'live-tv-epg', 'playback', 'library', 'updates'];
const DRAFT_GUIDES = [
'm3u-programme-guide', 'stable-nightly-updates-guide', 'fullscreen-channel-episode-guide',