diff --git a/CLAUDE.md b/CLAUDE.md
index b6886e2f1..87ebe2d94 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -41,7 +41,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
- Pushes to `master` and `v*` can publish Docker images. A `v*` tag build creates a draft GitHub release.
- `pnpm run release:verify:draft` waits for that tag build (polling until the run is indexed, then `gh run watch`) and verifies the draft's status, authored body, and complete required asset set. It is read-only and deliberately fails on an already-published release, because it is the gate that runs before publication.
- Publishing the GitHub release verifies its Snap assets and automatically uploads them to `edge`; installed-Snap smoke and candidate/stable promotion remain manual.
-- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. Website guide screenshots use the same script: manifest shots with `"group": "guides"` are captured only by `pnpm release:screenshots --group guides` and land in `apps/website/public/blog/guides/screenshots/`.
+- Release-post screenshots come only from the release capture script running against the mock servers. Never add a screenshot taken from a real playlist or account to `apps/website/public/blog/**` — real streams, logos, and metadata are copyrighted, and credentials must never reach a published image. Website guide screenshots use the same script: manifest shots with `"group": "guides"` are captured only by `pnpm release:screenshots --group guides` and land in `apps/website/public/blog/guides/screenshots/`. A manifest shot may carry `browser: {url, viewport}` to frame a loopback page the app serves (the remote-control phone view) in a separate mobile-sized Chromium instead of the Electron window, behind the same network and content guards; the Xtream mock's `marketing`/`marketing2` scenarios serve movie, episode and live stream URLs from local bytes so download and playback shots never leave the machine.
- Final task summaries should state whether a release note was added or why it was skipped.
## AppImage Manager Metadata
diff --git a/apps/website/README.md b/apps/website/README.md
index 93063381f..95701aa20 100644
--- a/apps/website/README.md
+++ b/apps/website/README.md
@@ -81,8 +81,8 @@ skip shows up in your output.
Evergreen how-to posts live in the blog collection next to release notes
(`xtream-codes-setup-guide.mdx`, `stalker-portal-setup-guide.mdx`,
-`m3u-playlist-epg-setup-guide.mdx`, `offline-downloads-guide.mdx` and
-`alternative-sources-guide.mdx` in
+`m3u-playlist-epg-setup-guide.mdx`, `offline-downloads-guide.mdx`,
+`alternative-sources-guide.mdx` and `remote-control-guide.mdx` in
`apps/website/src/content/blog/`). Three conventions set them apart:
- **`ContentDisclaimer`.** Every guide opens with
@@ -112,6 +112,13 @@ Evergreen how-to posts live in the blog collection next to release notes
`marketing2` scenario (identical catalog, "Fictional Xtream Backup"), which is
what makes the Sources chip appear; like the Stalker portal it is added only
for shots that walk into it, because it adds a card to the dashboard.
+ The remote-control phone view is a `browser` shot: the manifest entry names
+ a loopback URL and a mobile viewport, and the capture frames it in a separate
+ Chromium page instead of the Electron window, behind the same network and
+ content guards (`captureBrowserShot` in
+ `tools/release/capture-release-screenshots.ts`). Its setup selects a live
+ channel and saves the remote-control setting, so the app's own server answers
+ on port 8765 for the duration of the run.
`tools/testing/website-guides.test.mjs` (part of `pnpm nx test website`) checks
each guide for the FAQPage schema, a link to the download hub and the presence
diff --git a/apps/website/public/blog/guides/screenshots/guide-remote-phone-dark.png b/apps/website/public/blog/guides/screenshots/guide-remote-phone-dark.png
new file mode 100644
index 000000000..2517c58c1
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-remote-phone-dark.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-remote-phone-light.png b/apps/website/public/blog/guides/screenshots/guide-remote-phone-light.png
new file mode 100644
index 000000000..0f9591ef8
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-remote-phone-light.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-remote-settings-dark.png b/apps/website/public/blog/guides/screenshots/guide-remote-settings-dark.png
new file mode 100644
index 000000000..bab21fc4d
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-remote-settings-dark.png differ
diff --git a/apps/website/public/blog/guides/screenshots/guide-remote-settings-light.png b/apps/website/public/blog/guides/screenshots/guide-remote-settings-light.png
new file mode 100644
index 000000000..003f24c96
Binary files /dev/null and b/apps/website/public/blog/guides/screenshots/guide-remote-settings-light.png differ
diff --git a/apps/website/public/llms.txt b/apps/website/public/llms.txt
index 51131e654..350b5a7a3 100644
--- a/apps/website/public/llms.txt
+++ b/apps/website/public/llms.txt
@@ -20,6 +20,7 @@
- Guide, M3U playlist and EPG setup: https://4gray.github.io/iptvnator/blog/m3u-playlist-epg-setup-guide/
- Guide, offline viewing with the download manager (desktop): https://4gray.github.io/iptvnator/blog/offline-downloads-guide/
- Guide, alternative sources for a movie across your own playlists (desktop): https://4gray.github.io/iptvnator/blog/alternative-sources-guide/
+- Guide, phone remote control setup (desktop): https://4gray.github.io/iptvnator/blog/remote-control-guide/
- Features overview: https://4gray.github.io/iptvnator/features/
- Feature, M3U playlist player: https://4gray.github.io/iptvnator/features/m3u-player/
- Feature, Xtream Codes player: https://4gray.github.io/iptvnator/features/xtream-codes-player/
diff --git a/apps/website/src/content/blog/remote-control-guide.mdx b/apps/website/src/content/blog/remote-control-guide.mdx
new file mode 100644
index 000000000..6245aefab
--- /dev/null
+++ b/apps/website/src/content/blog/remote-control-guide.mdx
@@ -0,0 +1,130 @@
+---
+title: How to Control IPTVnator from Your Phone
+description: Turn the desktop app into a TV you can zap from the couch. Enable the remote in the settings, scan the QR code with any phone on the same Wi-Fi, and switch channels, jump to a number and see what is playing, without installing anything.
+pubDate: 2026-09-06
+author: 4gray
+heroImage: /iptvnator/blog/guides/screenshots/guide-remote-settings-dark.png
+tags:
+ - guide
+draft: false
+faq:
+- q: Do I need to install an app on my phone?
+ a: No. The remote is a web page that the IPTVnator desktop app serves on your local network. Open the address from the settings, or scan its QR code, in any phone or tablet browser. Adding the page to the home screen gives it an icon like an app.
+- q: Does the remote work away from home?
+ a: No, and it should not. The page is meant for the Wi-Fi your computer is on and has no login or encryption. Do not forward the port through your router; anyone who could reach it could change channels on your player.
+- q: The page does not load on my phone. What should I check?
+ a: That both devices are on the same network and not on a guest Wi-Fi that isolates clients, that a VPN on either side is off, and that the computer's firewall allows the port, 8765 unless you changed it. When the settings list several addresses, try each; a machine with Docker or a VPN adapter shows addresses that lead nowhere.
+- q: What does a channel number mean on the remote?
+ a: The position in the list the app is showing right now, counted from one, the same number the sidebar shows next to the channel. It is not the number a provider may print in its guide.
+- q: Why are the volume buttons greyed out?
+ a: The remote can change the volume only for the built-in players in the M3U player. Xtream and Stalker views and the favorites and recent collections currently pass channel commands only, and MPV, VLC and Embedded MPV keep their own volume.
+- q: Can several phones use the remote at once?
+ a: Yes. Every page that is open polls the same status and sends the same commands; the last command wins.
+- q: Does it work with the browser version of IPTVnator?
+ a: No. Only the desktop app on Windows, macOS or Linux can host the small web server behind the remote.
+- q: Which sources can I control?
+ a: Live TV from M3U playlists, Xtream Codes and Stalker portals, Stalker radio, and the favorites, recent and global collections. Movies and series are not part of the remote.
+---
+
+import Alert from '../../components/blog/Alert.astro';
+import ContentDisclaimer from '../../components/blog/ContentDisclaimer.astro';
+import PostButton from '../../components/blog/PostButton.astro';
+import StepRail from '../../components/blog/StepRail.astro';
+import LinkCards from '../../components/blog/LinkCards.astro';
+
+A laptop plugged into the TV is a fine IPTV player until you want to change the channel from
+the sofa. The desktop app solves that without a second app: it serves a small web remote to
+any phone or tablet on the same Wi-Fi, with channel up and down, a number pad, volume for the
+built-in player and a card that shows what is on right now.
+
+This guide walks through enabling the remote, opening it on a phone, what each control does,
+and what to check when the page does not load. It applies to the desktop app on Windows, macOS
+and Linux.
+
+
+
+## Turn the remote on
+
+
+
+
+
+The address is your computer's local IP address plus the port, for example
+`http://192.168.1.20:8765`. Computers with several network adapters list several addresses;
+the one on your home Wi-Fi is usually the `192.168.` or `10.` one.
+
+## Open it on the phone
+
+Scan the QR code with the phone camera, or type the address into the browser. The remote
+opens as a plain web page; nothing is installed and no account is created.
+
+
+ Use "Add to Home Screen" in your phone browser. The remote then opens full screen from its
+ own icon, without the address bar.
+
+
+
+

+
+
+## What the remote does
+
+- **Now playing** shows the source, the channel name and its number, and the current program
+ from the guide. It refreshes every two seconds, so a channel changed on the computer appears
+ on the phone right after.
+- **CH +** and **CH −** step through the list the app is showing: the whole playlist in the
+ M3U player, the selected category in an Xtream or Stalker source, the filtered list in
+ favorites, recent and the global collections.
+- **Number pad** jumps to a position in that list, the way a set-top box remote does. Type the
+ number and press OK. The number is the one shown next to the channel in the sidebar, not a
+ channel number from a provider's guide.
+- **Volume** works for the built-in players in the M3U player. Everywhere else the buttons are
+ disabled, and the page says so under the meter.
+
+
+ The remote navigates what the app currently shows. Open the source and the category you want
+ to zap through on the computer first; a remote command on a movie page or the dashboard has
+ nothing to act on.
+
+
+## When the page does not load
+
+Work down this list; one of these covers almost every case.
+
+1. **Same network.** Phone and computer must share a Wi-Fi, and it must not be a guest network
+ that isolates its clients from each other.
+2. **Firewall.** Windows asks whether to allow the app through the firewall the first time the
+ remote starts; if that prompt was dismissed, allow the port in the firewall settings. macOS
+ users with the firewall on need to allow incoming connections for IPTVnator.
+3. **VPN.** A VPN on the computer or the phone usually routes traffic away from the local
+ network. Pause it while you use the remote.
+4. **Several addresses.** If the settings list more than one, try the others. Docker, VPN and
+ virtual-machine adapters show addresses the phone cannot reach.
+5. **Port taken.** If another program already uses the port, change it in the settings and
+ save; the address and the QR code update with it.
+
+## Keep it local
+
+The remote has no password and no encryption; it trusts the network it runs on. That is fine
+on a home Wi-Fi and not fine anywhere else: do not open the port on your router, and turn the
+remote off in the settings when you use the computer on a network you do not control.
+
+## Related
+
+
+
+
diff --git a/apps/website/src/lib/features.ts b/apps/website/src/lib/features.ts
index ceccb60e8..c1d92fdb1 100644
--- a/apps/website/src/lib/features.ts
+++ b/apps/website/src/lib/features.ts
@@ -80,7 +80,7 @@ export const FEATURES: readonly FeatureEntry[] = [
'The desktop app serves a web remote to any phone on your network: channel up and down, direct numbers, volume, and a now-playing panel.',
href: '/iptvnator/features/remote-control/',
icon: 'M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z',
- guide: { href: '/iptvnator/blog/m3u-playlist-epg-setup-guide/', label: 'M3U and EPG setup guide' },
+ guide: { href: '/iptvnator/blog/remote-control-guide/', label: 'Phone remote control guide' },
},
];
diff --git a/apps/xtream-mock-server/README.md b/apps/xtream-mock-server/README.md
index 73b4f06b9..e9ef764a5 100644
--- a/apps/xtream-mock-server/README.md
+++ b/apps/xtream-mock-server/README.md
@@ -232,7 +232,7 @@ application code.
- **Dedicated EPG fixture**: `epg:epg` returns stable live channels plus deterministic `get_short_epg` and `get_simple_data_table` payloads for timezone-focused tests
- **Release screenshot fixture**: `marketing:marketing` returns fictional live, VOD, and series data with local generated artwork under `apps/xtream-mock-server/public/marketing`
- **Alternative-source fixture**: `marketing2:marketing2` returns the identical marketing catalog under a second credential pair, so a movie added from both looks like the same film in two playlists (the premise of the VOD multi-source chip); guide screenshots seed it as a "backup subscription"
-- **Local download media**: `marketing:marketing` also serves `/movie/...` and `/series/...` stream URLs from generated bytes (`downloadStreamFixture: 'local-media'`; movies finish in under a second, episodes trickle for about 20 s) so release and guide screenshots of the download manager complete without any request leaving the machine. Other scenarios keep redirecting streams to the public HLS stub.
+- **Local download media**: `marketing:marketing` also serves `/movie/...`, `/series/...` and `/live/...` stream URLs from generated bytes (`downloadStreamFixture: 'local-media'`; movies finish in under a second, episodes trickle for about 20 s) so release and guide screenshots of the download manager complete without any request leaving the machine. Other scenarios keep redirecting streams to the public HLS stub.
- **Performance fixture**: `performance:performance` returns exactly 100,000
local-only catalog items from index-derived values; it does not use Faker,
`Date.now()`, `Math.random()`, external artwork, or external media URLs
diff --git a/apps/xtream-mock-server/src/app/server.spec.ts b/apps/xtream-mock-server/src/app/server.spec.ts
index d00181479..9ee3a6201 100644
--- a/apps/xtream-mock-server/src/app/server.spec.ts
+++ b/apps/xtream-mock-server/src/app/server.spec.ts
@@ -186,12 +186,16 @@ describe('Xtream mock server factory', () => {
`${running.origin}/series/marketing/marketing/80000.mkv`,
{ redirect: 'manual' }
);
+ const live = await fetch(
+ `${running.origin}/live/marketing/marketing/10001.m3u8`,
+ { redirect: 'manual' }
+ );
const ordinaryMovie = await fetch(
`${running.origin}/movie/user1/pass1/62000.mp4`,
{ redirect: 'manual' }
);
- for (const local of [movie, episode]) {
+ for (const local of [movie, episode, live]) {
expect(local.status).toBe(200);
expect(local.headers.get('content-type')).toContain(
'video/mp4'
diff --git a/apps/xtream-mock-server/src/app/server.ts b/apps/xtream-mock-server/src/app/server.ts
index fd93862fb..cb001deba 100644
--- a/apps/xtream-mock-server/src/app/server.ts
+++ b/apps/xtream-mock-server/src/app/server.ts
@@ -18,6 +18,7 @@ import { dispatchAction } from './routes/dispatch.js';
import { getScenario, type ScenarioConfig } from './scenarios.js';
import {
LOCAL_MEDIA_EPISODE_DOWNLOAD_OPTIONS,
+ LOCAL_MEDIA_LIVE_STREAM_OPTIONS,
LOCAL_MEDIA_MOVIE_DOWNLOAD_OPTIONS,
streamSlowSeriesDownload,
} from './slow-series-download.js';
@@ -257,6 +258,14 @@ function installStreamRoutes(
response.status(410).json({ error: 'performance-media-disabled' });
return;
}
+ if (downloadStreamFixtureOf(request) === 'local-media') {
+ streamSlowSeriesDownload(
+ request,
+ response,
+ LOCAL_MEDIA_LIVE_STREAM_OPTIONS
+ );
+ return;
+ }
response.redirect(HLS_STUB);
};
const movieResponse = (request: Request, response: Response) => {
diff --git a/apps/xtream-mock-server/src/app/slow-series-download.ts b/apps/xtream-mock-server/src/app/slow-series-download.ts
index b859c6a3a..5bb9f1b08 100644
--- a/apps/xtream-mock-server/src/app/slow-series-download.ts
+++ b/apps/xtream-mock-server/src/app/slow-series-download.ts
@@ -23,6 +23,13 @@ export const LOCAL_MEDIA_MOVIE_DOWNLOAD_OPTIONS: SlowSeriesDownloadOptions = {
totalBytes: 96 * 1024 * 1024,
};
+/** A live channel under `local-media`: a few local bytes so selecting a channel never leaves the machine. */
+export const LOCAL_MEDIA_LIVE_STREAM_OPTIONS: SlowSeriesDownloadOptions = {
+ chunkSize: 512 * 1024,
+ intervalMs: 2,
+ totalBytes: 4 * 1024 * 1024,
+};
+
export const LOCAL_MEDIA_EPISODE_DOWNLOAD_OPTIONS: SlowSeriesDownloadOptions = {
chunkSize: 256 * 1024,
intervalMs: 100,
diff --git a/tools/release/capture-fixtures.ts b/tools/release/capture-fixtures.ts
index 393b6be3e..a06c9a0e7 100644
--- a/tools/release/capture-fixtures.ts
+++ b/tools/release/capture-fixtures.ts
@@ -41,6 +41,14 @@ export const XTREAM_SECONDARY_FIXTURE_CREDENTIALS = {
password: 'marketing2',
} as const;
+/**
+ * Port the remote-control guide shots enable in the app's settings. The app's
+ * default; the phone-view shot opens `http://127.0.0.1:/` in a mobile
+ * viewport. A capture fails with a clear message when something else holds it.
+ */
+export const CAPTURE_REMOTE_CONTROL_PORT = 8765;
+export const CAPTURE_REMOTE_CONTROL_URL = `http://127.0.0.1:${CAPTURE_REMOTE_CONTROL_PORT}/`;
+
/**
* Download folder the guide shots authorize inside the isolated data dir. The
* capture stubs Electron's folder dialog to return it, so no download ever
diff --git a/tools/release/capture-navigation.ts b/tools/release/capture-navigation.ts
index f993bd05e..9766c87a5 100644
--- a/tools/release/capture-navigation.ts
+++ b/tools/release/capture-navigation.ts
@@ -9,6 +9,8 @@ import type { Page } from '@playwright/test';
import {
AUTO_DETECT_FIXTURE_MESSAGE,
CAPTURE_DOWNLOAD_FOLDER_NAME,
+ CAPTURE_REMOTE_CONTROL_PORT,
+ CAPTURE_REMOTE_CONTROL_URL,
EPG_FIXTURE_URL,
M3U_FIXTURE_PLAYLIST_TITLE,
M3U_FIXTURE_PLAYLIST_URL,
@@ -354,6 +356,45 @@ export async function runAction(
await page.waitForTimeout(500);
return;
}
+ case 'open-xtream-live-channel': {
+ // Unlike `open-xtream-live`, this selects a channel: the marketing
+ // scenario serves live streams from local bytes (`local-media`),
+ // so playback never reaches a public stream. The player shows a
+ // format error, which the phone-view shot never frames; what it
+ // needs is the remote status the selection publishes.
+ await runAction(page, 'open-xtream-live', param);
+ const channel = page.locator('app-channel-list-item').first();
+
+ await channel.click();
+ await page.waitForTimeout(1500);
+ return;
+ }
+ case 'open-settings-remote-control': {
+ await openRemoteControlSettings(page);
+ const section = page.locator('#remote-control');
+ const qrButton = section.locator('.url-row button').first();
+
+ await qrButton.waitFor({ state: 'visible', timeout: 15_000 });
+ await qrButton.click();
+ await section
+ .locator('qrcode canvas, qrcode img')
+ .first()
+ .waitFor({ state: 'visible', timeout: 15_000 });
+ await page.waitForTimeout(500);
+ return;
+ }
+ case 'enable-remote-control': {
+ await openRemoteControlSettings(page);
+ const save = page.locator('[data-test-id="save-settings"]').first();
+
+ if (await save.isEnabled().catch(() => false)) {
+ await save.click();
+ await settleUi(page);
+ }
+
+ await waitForRemoteControlServer();
+ return;
+ }
case 'open-xtream-vod-sources': {
await openXtreamVodWithSources(page, param ?? 'Action & Mystery');
return;
@@ -481,6 +522,76 @@ async function goHome(page: Page): Promise {
await settleUi(page);
}
+/* ------------------------------------------------------------------ */
+/* Remote control (guide shots) */
+/* ------------------------------------------------------------------ */
+
+/**
+ * Opens Settings › Remote control with the feature switched on and the
+ * capture port in the field. The form is left dirty unless a later
+ * `enable-remote-control` step saves it; `discardUnsavedSettings` clears it
+ * before the next action.
+ */
+async function openRemoteControlSettings(page: Page): Promise {
+ await runAction(page, 'open-settings', null);
+ const sectionLink = page
+ .locator('[data-test-id="settings-section-remote-control"]')
+ .first();
+
+ await sectionLink.waitFor({ state: 'visible', timeout: 15_000 });
+ await sectionLink.click({ timeout: 10_000 });
+ await page.waitForURL(/\/workspace\/settings\/remote-control/, {
+ timeout: 15_000,
+ });
+
+ const section = page.locator('#remote-control');
+ await section.waitFor({ state: 'visible', timeout: 15_000 });
+
+ const toggle = section.locator(
+ '[data-test-id="remote-control-enabled"] input[type="checkbox"]'
+ );
+
+ if (!(await toggle.isChecked())) {
+ await section.locator('[data-test-id="remote-control-enabled"]').click();
+ }
+
+ const port = section.locator('[data-test-id="remote-control-port"]');
+ await port.waitFor({ state: 'visible', timeout: 10_000 });
+
+ if ((await port.inputValue()) !== String(CAPTURE_REMOTE_CONTROL_PORT)) {
+ await port.fill(String(CAPTURE_REMOTE_CONTROL_PORT));
+ }
+
+ await section
+ .locator('.remote-control-url')
+ .first()
+ .waitFor({ state: 'visible', timeout: 15_000 });
+}
+
+/** Polls the status endpoint the phone view reads until the app's server answers. */
+async function waitForRemoteControlServer(): Promise {
+ const statusUrl = `${CAPTURE_REMOTE_CONTROL_URL}api/remote-control/status`;
+ const deadline = Date.now() + 15_000;
+
+ while (Date.now() < deadline) {
+ try {
+ const response = await fetch(statusUrl);
+
+ if (response.ok) {
+ return;
+ }
+ } catch {
+ // not up yet
+ }
+
+ await new Promise((resolve) => setTimeout(resolve, 300));
+ }
+
+ throw new Error(
+ `Remote control server did not answer at ${statusUrl} — is port ${CAPTURE_REMOTE_CONTROL_PORT} held by another IPTVnator instance?`
+ );
+}
+
/* ------------------------------------------------------------------ */
/* Alternative sources (guide shots) */
/* ------------------------------------------------------------------ */
diff --git a/tools/release/capture-release-screenshots.ts b/tools/release/capture-release-screenshots.ts
index 59ce29171..a1ec79cbc 100644
--- a/tools/release/capture-release-screenshots.ts
+++ b/tools/release/capture-release-screenshots.ts
@@ -31,7 +31,7 @@ import { accessSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import path from 'node:path';
import process from 'node:process';
-import type { Page } from '@playwright/test';
+import { chromium, type Page } from '@playwright/test';
import {
DEFAULT_SHOT_GROUP,
@@ -248,7 +248,17 @@ async function main(): Promise {
await runAction(page, action, param);
}
- await captureShot(page, stagingDir, shot.slug, theme);
+ if (shot.browser) {
+ await captureBrowserShot(
+ shot.browser,
+ stagingDir,
+ shot.slug,
+ theme,
+ blockedRequests
+ );
+ } else {
+ await captureShot(page, stagingDir, shot.slug, theme);
+ }
captured += 1;
}
}
@@ -338,6 +348,107 @@ async function main(): Promise {
);
}
+interface BrowserShot {
+ url: string;
+ viewport: { width: number; height: number };
+}
+
+/**
+ * A `browser` shot frames a page the app serves to other devices — the
+ * remote control's phone view — in a separate mobile-sized Chromium instead of
+ * the Electron window. It sits behind the same gates: the page may only load
+ * from loopback (deny-by-default route, violations feed G3), and its DOM is
+ * inspected by G4 before the frame is written.
+ */
+async function captureBrowserShot(
+ shot: BrowserShot,
+ outputRoot: string,
+ slug: string,
+ theme: Theme,
+ blocked: string[]
+): Promise {
+ const browser = await chromium.launch();
+
+ try {
+ const context = await browser.newContext({
+ viewport: shot.viewport,
+ deviceScaleFactor: 2,
+ isMobile: true,
+ hasTouch: true,
+ colorScheme: theme,
+ });
+ const page = await context.newPage();
+
+ await page.route('**/*', async (route) => {
+ const url = route.request().url();
+
+ if (isAllowedRequestUrl(url)) {
+ await route.continue();
+ return;
+ }
+
+ blocked.push(url);
+ await route.abort();
+ });
+
+ await page.goto(shot.url, { waitUntil: 'networkidle', timeout: 30_000 });
+ // The remote polls its status every two seconds; give the first
+ // answer time to replace the "No live stream selected" placeholder
+ // when a channel is playing, without failing shots that frame the
+ // idle state on purpose.
+ await page
+ .locator('.now-card__channel')
+ .filter({ hasNotText: 'No live stream selected' })
+ .first()
+ .waitFor({ state: 'visible', timeout: 8_000 })
+ .catch(() => undefined);
+ await page.waitForTimeout(500);
+
+ const report = await page.evaluate(collectFrameReport);
+ const violations = evaluateFrameReport(report);
+
+ if (violations.length > 0) {
+ throw new Error(
+ `G4 failed on ${slug} (${theme}):\n ${violations.join('\n ')}`
+ );
+ }
+
+ // Not `fullPage`: the remote scrolls inside its own container, so a
+ // full-page frame only pads the document with empty background while
+ // the container stays clipped. The manifest viewport is tall enough
+ // for the whole remote instead.
+ await page.screenshot({
+ path: path.join(outputRoot, `${slug}-${theme}.png`),
+ type: 'png',
+ });
+ console.log(` ✓ ${slug} (${theme}, browser ${shot.viewport.width}×${shot.viewport.height})`);
+ } finally {
+ await browser.close();
+ }
+}
+
+/** Runs inside the page: every image/background URL plus the visible text, for G4. */
+function collectFrameReport(): { resourceUrls: string[]; bodyText: string } {
+ const urls = new Set();
+
+ document.querySelectorAll('img[src]').forEach((img) => {
+ urls.add((img as HTMLImageElement).src);
+ });
+ document.querySelectorAll('*').forEach((element) => {
+ const background = getComputedStyle(element).backgroundImage;
+ const match = background?.match(/url\("?([^")]+)"?\)/);
+
+ if (match) {
+ urls.add(match[1]);
+ }
+ });
+
+ return {
+ resourceUrls: [...urls],
+ bodyText: document.body.innerText,
+ };
+}
+
async function captureShot(
page: Page,
outputRoot: string,
diff --git a/tools/release/screenshot-guards.mjs b/tools/release/screenshot-guards.mjs
index f46187e03..89b04f86f 100644
--- a/tools/release/screenshot-guards.mjs
+++ b/tools/release/screenshot-guards.mjs
@@ -56,6 +56,9 @@ export const KNOWN_ACTIONS = [
'open-downloads-offline-movie',
'open-xtream-vod-sources',
'open-xtream-vod-sources-menu',
+ 'open-xtream-live-channel',
+ 'open-settings-remote-control',
+ 'enable-remote-control',
];
/**
@@ -157,6 +160,42 @@ export function validateManifest(manifest) {
);
}
}
+
+ if (shot.browser !== undefined) {
+ errors.push(...validateBrowserShot(shot.browser, label));
+ }
+ }
+
+ return errors;
+}
+
+/**
+ * A `browser` shot is framed in a separate Chromium page instead of the
+ * Electron window — the phone view of the remote control. The page may only
+ * open something the app itself serves on loopback: any other origin would
+ * turn the capture into a screenshot of the internet.
+ *
+ * @param {unknown} browser the shot's `browser` field
+ * @param {string} label shot slug for messages
+ * @returns {string[]} problems; empty when valid
+ */
+function validateBrowserShot(browser, label) {
+ const errors = [];
+ const url = typeof browser?.url === 'string' ? browser.url : '';
+
+ if (!/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?\//.test(url)) {
+ errors.push(
+ `shot "${label}": browser.url must be a loopback http URL, got ${JSON.stringify(browser?.url)}`
+ );
+ }
+
+ const viewport = browser?.viewport;
+ const isSize = (value) => Number.isInteger(value) && value >= 200 && value <= 4000;
+
+ if (!viewport || !isSize(viewport.width) || !isSize(viewport.height)) {
+ errors.push(
+ `shot "${label}": browser.viewport must carry integer width and height between 200 and 4000`
+ );
}
return errors;
diff --git a/tools/release/screenshot-guards.test.mjs b/tools/release/screenshot-guards.test.mjs
index 624303620..fa61b90b3 100644
--- a/tools/release/screenshot-guards.test.mjs
+++ b/tools/release/screenshot-guards.test.mjs
@@ -126,6 +126,41 @@ describe('manifest validation', () => {
);
});
+ it('accepts a loopback browser shot and rejects any other origin', () => {
+ const manifest = validManifest();
+ manifest.shots.push({
+ slug: 'guide-remote-phone',
+ title: 'Phone view',
+ group: 'guides',
+ setup: ['enable-remote-control'],
+ browser: { url: 'http://127.0.0.1:8765/', viewport: { width: 390, height: 844 } },
+ });
+
+ assert.deepEqual(validateManifest(manifest), []);
+
+ manifest.shots.push(
+ {
+ slug: 'guide-remote-elsewhere',
+ title: 'x',
+ group: 'guides',
+ setup: ['enable-remote-control'],
+ browser: { url: 'https://example.com/', viewport: { width: 390, height: 844 } },
+ },
+ {
+ slug: 'guide-remote-tiny',
+ title: 'x',
+ group: 'guides',
+ setup: ['enable-remote-control'],
+ browser: { url: 'http://localhost:8765/', viewport: { width: 10 } },
+ }
+ );
+
+ const errors = validateManifest(manifest);
+
+ assert.ok(errors.some((error) => /browser\.url must be a loopback/.test(error)));
+ assert.ok(errors.some((error) => /browser\.viewport must carry/.test(error)));
+ });
+
it('accepts guide shots that name a group and the actions they use', () => {
const manifest = validManifest();
manifest.shots.push(
diff --git a/tools/release/screenshots.manifest.json b/tools/release/screenshots.manifest.json
index 2f5826ae9..cb5d448c9 100644
--- a/tools/release/screenshots.manifest.json
+++ b/tools/release/screenshots.manifest.json
@@ -147,6 +147,30 @@
"setup": [
"open-xtream-vod-sources-menu=Action & Mystery"
]
+ },
+ {
+ "slug": "guide-remote-settings",
+ "title": "Settings: remote control with QR code",
+ "group": "guides",
+ "setup": [
+ "open-settings-remote-control"
+ ]
+ },
+ {
+ "slug": "guide-remote-phone",
+ "title": "Remote control phone view",
+ "group": "guides",
+ "setup": [
+ "enable-remote-control",
+ "open-xtream-live-channel=Newsroom"
+ ],
+ "browser": {
+ "url": "http://127.0.0.1:8765/",
+ "viewport": {
+ "width": 390,
+ "height": 1100
+ }
+ }
}
]
}
diff --git a/tools/testing/website-guides.test.mjs b/tools/testing/website-guides.test.mjs
index 3adf54b8f..cd51708e7 100644
--- a/tools/testing/website-guides.test.mjs
+++ b/tools/testing/website-guides.test.mjs
@@ -50,6 +50,13 @@ const GUIDES = [
'blog/guides/screenshots/guide-sources-menu-dark.png',
],
},
+ {
+ slug: 'remote-control-guide',
+ screenshots: [
+ 'blog/guides/screenshots/guide-remote-settings-dark.png',
+ 'blog/guides/screenshots/guide-remote-phone-dark.png',
+ ],
+ },
];
const readDist = (relativePath) => readFile(new URL(relativePath, distRoot), 'utf8');