diff --git a/.gitignore b/.gitignore
index 999a3ab..5132fbe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -33,7 +33,3 @@ secrets.yaml
# OS specific
.DS_Store
Thumbs.db
-
-#Build Tools
-build-release.sh
-VERSION
diff --git a/README.md b/README.md
index c5902f8..ee7b7a5 100644
--- a/README.md
+++ b/README.md
@@ -1,81 +1,121 @@
-# RelayTV Home Assistant Integration
+
+
+
-
+Your TV, native in Home Assistant.
-RelayTV for Home Assistant adds your self-hosted RelayTV servers as Home Assistant entities and services, making it easy to control playback, launch media, trigger overlays, and integrate RelayTV into automations and dashboards.
+
+ Add your self-hosted RelayTV screens to Home Assistant as real media-player
+ devices — control playback, launch media, trigger overlays, and drop RelayTV
+ into automations, dashboards, and voice.
+
-RelayTV server:
-https://github.com/mcgeezy/relaytv
+
+ Install ·
+ See it in action ·
+ Services ·
+ RelayTV server
+
-Android companion app:
-https://github.com/mcgeezy/relaytv-android
+
+
+
+
+
+
-Support the project:
-https://buymeacoffee.com/relaytv
+
+
+
----
+
+ Local-first · No RelayTV account · Bearer-token auth ·
+ Credential-safe playback state
+
-## What This Integration Adds
+## One integration. Every way you control the TV.
-RelayTV integrates with Home Assistant as a local `media_player` plus RelayTV-specific services.
+### 🎛️ Control it
-### Core features
+Every RelayTV server becomes a Home Assistant `media_player`: play, pause,
+stop, next, previous, seek, volume, mute, turn on/off — with live artwork,
+title, and progress on your dashboards, the mobile app, and voice assistants.
-- Creates a `media_player` entity for each RelayTV config entry
-- Supports multiple RelayTV servers
-- Uses RelayTV live event updates plus `/status` refresh fallback
-- Supports an optional RelayTV API bearer token with reauthentication
-- Adds a Home Assistant sidebar panel for the RelayTV web UI
-- Exposes RelayTV-specific services for smart play, temporary playback, overlays, snapshots, synchronized playback, upload/play, upload/enqueue, and resume behavior
-- Supports automation-friendly control from scripts, dashboards, and mobile workflows
+### 🤖 Automate it
-### Supported media controls
+Call RelayTV-specific actions from scripts and automations — smart play,
+temporary interrupt-and-resume, overlays, snapshots, synchronized multi-screen
+start, and direct uploads from Home Assistant media to the TV.
-- Play
-- Pause
-- Stop
-- Next
-- Previous
-- Seek
-- Set volume
-- Mute and unmute
-- Turn on
-- Turn off
+### 🧩 Embed it
-### Advanced RelayTV features
+Pin the full RelayTV web UI as a Home Assistant sidebar panel, so the remote,
+queue, and Jellyfin/Emby library are always one click away — without leaving
+Home Assistant.
-- Smart queue/play behavior
-- Temporary interrupt + resume playback
-- Text and image overlays
-- Snapshot capture
-- Multi-target synchronized playback
-- Resume-position support
-- Direct media upload from Home Assistant local media/files to RelayTV ingest endpoints
-- Optional sensor-to-stream mapping triggers
+RelayTV keeps the playback engine beside the TV while Home Assistant stays the
+control plane. Live event updates keep entity state fresh, with a `/status`
+poll as the reconnect fallback.
----
+## See it in action
-## RelayTV Services
+
+
+
-| Service | RelayTV endpoint | Notes |
-| --- | --- | --- |
-| `relaytv.smart_url` | `POST /smart` | Smart play/enqueue behavior |
-| `relaytv.play_now` | `POST /play` | Immediate playback; clears queue |
-| `relaytv.announce` | `POST /play` | Alias of `play_now` |
-| `relaytv.play_temporary` | `POST /play_temporary` | Temporary interrupt + resume flow |
-| `relaytv.overlay` | `POST /overlay` | Text/image overlay |
-| `relaytv.play_synced` | `POST /play_at` | Multi-entity time-aligned start |
-| `relaytv.snapshot` | `POST /snapshot` (fallback `GET /snapshot`) | Captures current frame |
-| `relaytv.play_with_resume` | `POST /play` + `POST /seek_abs` | Resume per-URL saved position |
-| `relaytv.upload_media` | `POST /ingest/media` | Upload local HA media/file and return RelayTV media URL |
-| `relaytv.upload_media_play` | `POST /ingest/media/play` | Upload local HA media/file and start playback |
-| `relaytv.upload_media_enqueue` | `POST /ingest/media/enqueue` | Upload local HA media/file and append to queue |
+
+
+
----
+
+
+
-## Home Assistant Companion App Share Automation
+> The images above are captured from a live Home Assistant instance with
+> [`scripts/readme-screenshots.mjs`](scripts/readme-screenshots.mjs).
-Add this automation to share links to the Home Assistant Phone App and play them on TV
+## Install in minutes
+
+### HACS
+
+1. Open **HACS** in Home Assistant.
+2. Add this repository as a custom repository with category **Integration**.
+3. Install **RelayTV** and restart Home Assistant.
+4. Add the integration from **Settings → Devices & Services → Add Integration**.
+
+### Manual
+
+1. Copy `custom_components/relaytv` into your Home Assistant config:
+
+ ```text
+ /config/custom_components/relaytv/
+ ```
+
+2. Restart Home Assistant.
+3. Go to **Settings → Devices & Services → Add Integration** and search for **RelayTV**.
+
+### Configure
+
+Enter:
+
+- **RelayTV base URL** — for example `http://relaytv-host:8787`
+- **Display name** — how this RelayTV instance appears in Home Assistant
+- **RelayTV API token** — only if `RELAYTV_API_TOKEN` is enabled on the server
+
+Use **Reconfigure** on the integration entry to change the server address,
+display name, or API token. The token is stored in the Home Assistant config
+entry and sent only as a bearer authorization header to RelayTV.
+
+From the integration **options** you can also configure:
+
+- `panel_enabled` — enable or disable the sidebar panel
+- `panel_target_entry_id` — which RelayTV server the shared panel points at
+- `sensor_stream_mappings` — map sensors to temporary playback URLs
+
+## Send links from your phone
+
+Add this automation to relay links shared from the Home Assistant mobile app
+straight to the TV:
```yaml
alias: RelayTV - Smart play from share
@@ -98,69 +138,25 @@ variables:
(trigger.event.data.text if trigger.event.data.text is defined else '') }}
```
----
+## RelayTV Services
-## Installation
+| Service | RelayTV endpoint | Notes |
+| --- | --- | --- |
+| `relaytv.smart_url` | `POST /smart` | Smart play/enqueue behavior |
+| `relaytv.play_now` | `POST /play` | Immediate playback; clears queue |
+| `relaytv.announce` | `POST /play` | Alias of `play_now` |
+| `relaytv.play_temporary` | `POST /play_temporary` | Temporary interrupt + resume flow |
+| `relaytv.overlay` | `POST /overlay` | Text/image overlay |
+| `relaytv.play_synced` | `POST /play_at` | Multi-entity time-aligned start |
+| `relaytv.snapshot` | `POST /snapshot` (fallback `GET /snapshot`) | Captures current frame |
+| `relaytv.play_with_resume` | `POST /play` + `POST /seek_abs` | Resume per-URL saved position |
+| `relaytv.upload_media` | `POST /ingest/media` | Upload local HA media/file and return a RelayTV media URL |
+| `relaytv.upload_media_play` | `POST /ingest/media/play` | Upload local HA media/file and start playback |
+| `relaytv.upload_media_enqueue` | `POST /ingest/media/enqueue` | Upload local HA media/file and append to queue |
-### HACS
+### Example service calls
-1. Open HACS in Home Assistant
-2. Add this repository as a custom repository with category `Integration`
-3. Install `RelayTV`
-4. Restart Home Assistant
-5. Add the `RelayTV` integration from **Settings → Devices & Services**
-
-### Manual
-
-1. Copy `custom_components/relaytv` into your Home Assistant config:
-
- ```text
- /config/custom_components/relaytv/
- ```
-
-2. Restart Home Assistant
-3. Go to **Settings → Devices & Services → Add Integration**
-4. Search for **RelayTV**
-5. Enter:
- - RelayTV base URL, for example `http://relaytv-host:8787`
- - Display name for this RelayTV instance
- - RelayTV API token, if `RELAYTV_API_TOKEN` is enabled on the server
-
-Use **Reconfigure** on the integration entry to change the server address,
-display name, or API token. The token is stored in the Home Assistant config
-entry and is sent only as a bearer authorization header to RelayTV.
-
----
-
-## Configuration Options
-
-From the integration options flow, you can configure:
-
-- `panel_enabled` — enable or disable sidebar panel registration
-- `panel_target_entry_id` — choose which RelayTV server is used by the shared sidebar panel
-- `sensor_stream_mappings` — map sensors to temporary playback URLs
-
-## Security and Upgrading to 0.4.0
-
-Version 0.4.0 sanitizes credential-bearing playback URLs before they reach
-Home Assistant state or resume storage. Existing resume data is migrated and
-limited to the 500 most recent media keys automatically.
-
-Home Assistant recorder history written by an older version is not rewritten.
-If a previous media identifier contained an upstream Jellyfin/Emby API key or
-signed URL, rotate that credential and consider purging old history for the
-RelayTV media-player entity.
-
-For defense in depth, update the RelayTV server to a version that redacts
-private playback fields from public status, queue, history, and SSE payloads.
-The matching server update also allows Home Assistant to process RelayTV
-thumbnails across origins without browser CORS errors.
-
----
-
-## Example Service Calls
-
-### Smart play / enqueue
+Smart play / enqueue:
```yaml
service: relaytv.smart_url
@@ -170,19 +166,7 @@ data:
url: https://www.youtube.com/watch?v=dQw4w9WgXcQ
```
-### Immediate playback
-
-```yaml
-service: relaytv.play_now
-target:
- entity_id: media_player.relaytv_living_room
-data:
- url: https://www.youtube.com/watch?v=dQw4w9WgXcQ
- use_ytdlp: true
- cec: false
-```
-
-### Temporary playback
+Temporary playback (interrupt, then resume what was playing):
```yaml
service: relaytv.play_temporary
@@ -194,7 +178,7 @@ data:
volume: 0.6
```
-### Overlay message
+Overlay a message on the TV:
```yaml
service: relaytv.overlay
@@ -206,7 +190,7 @@ data:
position: top-right
```
-### Upload media and play
+Upload a local file and play it:
```yaml
service: relaytv.upload_media_play
@@ -217,42 +201,57 @@ data:
title: Shared Clip
```
-`file_path` must be readable by Home Assistant and allowed by `allowlist_external_dirs`.
-When using the service UI, the `file` field can also select a local Home Assistant media source item.
+`file_path` must be readable by Home Assistant and allowed by
+`allowlist_external_dirs`. In the service UI, the `file` field can also select a
+local Home Assistant media-source item.
----
-
-## Typical Use Cases
+## Typical use cases
- Send shared links from Home Assistant automations to a RelayTV screen
- Upload and play local Home Assistant media files on RelayTV
-- Launch temporary doorbell or announcement media, then resume previous playback
+- Launch temporary doorbell or announcement media, then resume playback
- Display overlay messages on TVs around the home
-- Add RelayTV as a dashboard-accessible media target
+- Add RelayTV as a dashboard- and voice-accessible media target
- Keep multiple RelayTV devices available in one Home Assistant setup
- Start synchronized playback across more than one RelayTV screen
----
+## Security and upgrading to 0.4.0
-## Known Limitations
+Version 0.4.0 sanitizes credential-bearing playback URLs before they reach Home
+Assistant state or resume storage. Existing resume data is migrated and limited
+to the 500 most recent media keys automatically.
-- `relaytv.play_now` currently maps to RelayTV `POST /play` (queue-clearing behavior)
-- RelayTV also exposes `POST /play_now`, but this integration does not currently use its preserve-current behavior
-- No dedicated `clear_queue` Home Assistant service is currently registered by this integration
-- Upload services require a local media source item or a file path available inside the Home Assistant container
+Home Assistant recorder history written by an older version is not rewritten. If
+a previous media identifier contained an upstream Jellyfin/Emby API key or
+signed URL, rotate that credential and consider purging old history for the
+RelayTV media-player entity.
+
+For defense in depth, update the RelayTV server to a version that redacts
+private playback fields from public status, queue, history, and SSE payloads.
+The matching server update also lets Home Assistant process RelayTV thumbnails
+across origins without browser CORS errors.
+
+## Known limitations
+
+- `relaytv.play_now` maps to RelayTV `POST /play` (queue-clearing behavior);
+ the server's preserve-current `POST /play_now` is not yet used
+- No dedicated `clear_queue` Home Assistant service is registered
+- Upload services require a local media-source item or a file path available
+ inside the Home Assistant container
- Overlay calls must include at least `text` or `image_url`
- Snapshots require active playback on the RelayTV server
-- `/ui/events` is treated as a live push stream, not a replay log; `/status` remains the reconnect/bootstrap fallback
-- RelayTV releases predating `POST /auth/check` remain compatible, but the
- current server release is recommended for public-payload redaction and
- cross-origin thumbnails
+- `/ui/events` is treated as a live push stream, not a replay log; `/status`
+ remains the reconnect/bootstrap fallback
+- Releases predating `POST /auth/check` remain compatible, but the current
+ server release is recommended for public-payload redaction and cross-origin
+ thumbnails
----
+## Companion projects
-## Companion Projects
-
-- RelayTV server: https://github.com/mcgeezy/relaytv
-- RelayTV Android app: https://github.com/mcgeezy/relaytv-android
+- **[RelayTV server](https://github.com/mcgeezy/relaytv)** — the local playback
+ engine and web UI.
+- **[RelayTV for Android](https://github.com/mcgeezy/relaytv-android)** — share
+ links directly to the TV and control playback from your phone.
### Planned / work in progress
@@ -260,20 +259,9 @@ When using the service UI, the `file` field can also select a local Home Assista
- Continued multi-device and automation improvements
- Ongoing UX polish across the RelayTV ecosystem
----
-
-## Support The Project
-
-If RelayTV is useful to you, donations help support continued development of the server, Home Assistant integration, Android app, and future companion apps.
-
-Buy me a coffee:
-https://buymeacoffee.com/relaytv
-
----
-
## Compatibility
-Version 0.4.0 requires Home Assistant 2026.7 or newer. It is automatically
+Version 0.4.0 requires **Home Assistant 2026.7 or newer** and is automatically
tested against Home Assistant 2026.7.2 and the current RelayTV server API.
For local verification:
@@ -284,7 +272,25 @@ ruff check custom_components tests
python -m pytest -q
```
+To regenerate the README images from a live Home Assistant instance:
+
+```bash
+HA_TOKEN= node scripts/readme-screenshots.mjs \
+ --ha=http://homeassistant.local:8123 \
+ --device= \
+ --entity=media_player.living_room_tv \
+ --ws=ws://127.0.0.1:3000/
+```
+
+## Support the project
+
+If RelayTV is useful to you, donations help fund continued development of the
+server, this Home Assistant integration, the Android app, and future companion
+apps.
+
+Buy me a coffee: https://buymeacoffee.com/relaytv
+
## License
-RelayTV Home Assistant is licensed under the GNU General Public License,
-version 3 only (`GPL-3.0-only`). See [LICENSE](LICENSE).
+RelayTV Home Assistant is licensed under the GNU General Public License, version
+3 only (`GPL-3.0-only`). See [LICENSE](LICENSE).
diff --git a/docs/images/readme/control-phone.png b/docs/images/readme/control-phone.png
new file mode 100644
index 0000000..284ae1d
Binary files /dev/null and b/docs/images/readme/control-phone.png differ
diff --git a/docs/images/readme/device-desktop.png b/docs/images/readme/device-desktop.png
new file mode 100644
index 0000000..a934a75
Binary files /dev/null and b/docs/images/readme/device-desktop.png differ
diff --git a/docs/images/readme/hero.png b/docs/images/readme/hero.png
new file mode 100644
index 0000000..14fcbc4
Binary files /dev/null and b/docs/images/readme/hero.png differ
diff --git a/docs/images/readme/panel-desktop.png b/docs/images/readme/panel-desktop.png
new file mode 100644
index 0000000..8aa7106
Binary files /dev/null and b/docs/images/readme/panel-desktop.png differ
diff --git a/docs/images/readme/relaytv-banner.png b/docs/images/readme/relaytv-banner.png
new file mode 100644
index 0000000..278f465
Binary files /dev/null and b/docs/images/readme/relaytv-banner.png differ
diff --git a/relaytv-ha.png b/relaytv-ha.png
deleted file mode 100644
index f3f2263..0000000
Binary files a/relaytv-ha.png and /dev/null differ
diff --git a/scripts/readme-screenshots.mjs b/scripts/readme-screenshots.mjs
new file mode 100644
index 0000000..e8903e8
--- /dev/null
+++ b/scripts/readme-screenshots.mjs
@@ -0,0 +1,234 @@
+#!/usr/bin/env node
+// SPDX-License-Identifier: GPL-3.0-only
+
+/*
+ * Capture and compose the README product images for the RelayTV Home Assistant
+ * integration. Screenshots are taken from a live Home Assistant instance that
+ * already has RelayTV set up and playing something (so the media card is rich),
+ * then framed in browser/phone chrome for the README and release/ad assets.
+ *
+ * Auth: pass a Home Assistant long-lived (or short-lived) access token via the
+ * HA_TOKEN environment variable. The token is injected into the frontend's
+ * localStorage so the capture runs as that user; it is never written to disk.
+ *
+ * Example:
+ * HA_TOKEN=xxxxx node scripts/readme-screenshots.mjs \
+ * --ws=ws://127.0.0.1:3000/ \
+ * --ha=http://homeassistant.local:8123 \
+ * --device= \
+ * --entity=media_player.living_room_tv \
+ * --output=docs/images/readme
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { chromium } from 'playwright';
+
+function option(name, fallback) {
+ const prefix = `--${name}=`;
+ const found = process.argv.find((arg) => arg.startsWith(prefix));
+ return found ? found.slice(prefix.length) : fallback;
+}
+
+const WS = option('ws', 'ws://127.0.0.1:3000/');
+const HA = option('ha', 'http://homeassistant.local:8123').replace(/\/$/, '');
+const DEVICE_ID = option('device', '');
+const ENTITY = option('entity', 'media_player.living_room_tv');
+const PANEL = option('panel', 'relaytv');
+const OUTPUT = path.resolve(option('output', 'docs/images/readme'));
+const LOGO = path.resolve(option('logo', 'docs/images/readme/relaytv-banner.png'));
+const TOKEN = process.env.HA_TOKEN;
+const REFRESH = process.env.HA_REFRESH || '';
+
+if (!TOKEN) {
+ process.stderr.write('HA_TOKEN environment variable is required.\n');
+ process.exit(2);
+}
+fs.mkdirSync(OUTPUT, { recursive: true });
+
+const tokens = {
+ hassUrl: HA,
+ clientId: `${HA}/`,
+ access_token: TOKEN,
+ refresh_token: REFRESH,
+ token_type: 'Bearer',
+ expires_in: 1800,
+ ha_auth_provider: 'homeassistant',
+ expires: Date.now() + 1800 * 1000,
+};
+
+const uri = (buffer) => `data:image/png;base64,${buffer.toString('base64')}`;
+
+async function newContext(browser, viewport) {
+ const ctx = await browser.newContext({ viewport, colorScheme: 'dark', deviceScaleFactor: 2 });
+ await ctx.addInitScript((t) => {
+ try { localStorage.setItem('hassTokens', JSON.stringify(t)); } catch (_e) {}
+ try { localStorage.setItem('selectedTheme', JSON.stringify({ dark: true })); } catch (_e) {}
+ }, tokens);
+ return ctx;
+}
+
+async function waitApp(page) {
+ await page.waitForFunction(() => !!document.querySelector('home-assistant'), { timeout: 20000 });
+ await page.waitForTimeout(2500);
+}
+
+async function captureMoreInfo(browser) {
+ const ctx = await newContext(browser, { width: 460, height: 940 });
+ const page = await ctx.newPage();
+ await page.goto(`${HA}/lovelace/0`, { waitUntil: 'domcontentloaded' }).catch(() => {});
+ await waitApp(page);
+ await page.evaluate((entityId) => {
+ document.querySelector('home-assistant').dispatchEvent(
+ new CustomEvent('hass-more-info', { detail: { entityId }, bubbles: true, composed: true }));
+ }, ENTITY);
+ await page.waitForTimeout(2500);
+ const buffer = await page.screenshot({ type: 'png' });
+ await ctx.close();
+ return buffer;
+}
+
+async function captureDesktop(browser, url, settle = 1500) {
+ const ctx = await newContext(browser, { width: 1500, height: 950 });
+ const page = await ctx.newPage();
+ await page.goto(url, { waitUntil: 'domcontentloaded' }).catch(() => {});
+ await waitApp(page);
+ await page.waitForTimeout(settle);
+ const buffer = await page.screenshot({ type: 'png' });
+ await ctx.close();
+ return buffer;
+}
+
+const FONT = "Inter,ui-sans-serif,system-ui,-apple-system,'Segoe UI',sans-serif";
+const BG = `
+ radial-gradient(circle at 78% 14%,rgba(65,189,245,.26),transparent 30%),
+ radial-gradient(circle at 16% 88%,rgba(56,189,248,.22),transparent 36%),
+ linear-gradient(150deg,#050b16,#08182b 55%,#060f1d)`;
+const GRID = 'linear-gradient(rgba(255,255,255,.12) 1px,transparent 1px),linear-gradient(90deg,rgba(255,255,255,.12) 1px,transparent 1px)';
+
+const CSS = `
+ *{box-sizing:border-box;margin:0} html,body{width:100%;height:100%;overflow:hidden}
+ body{position:relative;font-family:${FONT};color:#eef6ff;background:${BG}}
+ .grid{position:absolute;inset:0;opacity:.11;background-image:${GRID};background-size:54px 54px}
+ .flare{position:absolute;border-radius:50%;filter:blur(80px)}
+ .win{position:relative;border-radius:16px;overflow:hidden;background:#0b1420;
+ border:1px solid rgba(150,200,255,.22);box-shadow:0 44px 96px rgba(0,0,0,.55),0 0 60px rgba(45,150,235,.12)}
+ .bar{display:flex;align-items:center;gap:9px;height:44px;padding:0 16px;background:#111c2b;border-bottom:1px solid rgba(150,200,255,.14)}
+ .dot{width:13px;height:13px;border-radius:50%}.dot.r{background:#ff5f57}.dot.y{background:#febc2e}.dot.g{background:#28c840}
+ .addr{flex:1;margin-left:12px;height:26px;border-radius:8px;background:#0a1420;border:1px solid rgba(150,200,255,.16);
+ color:#8fb2d6;font-size:13px;font-weight:600;display:flex;align-items:center;padding:0 14px}
+ .winimg{display:block;width:100%}
+ .phone{position:relative;padding:11px;border-radius:46px;background:#02060d;
+ border:1px solid rgba(255,255,255,.26);box-shadow:0 40px 82px rgba(0,0,0,.62),0 0 0 5px rgba(65,189,245,.10)}
+ .phoneimg{display:block;width:100%;border-radius:36px}
+ .island{position:absolute;z-index:3;top:22px;left:50%;width:104px;height:26px;transform:translateX(-50%);border-radius:16px;background:#02060d}
+ .brand{position:absolute;z-index:8;display:flex;align-items:center;gap:16px}
+ .brand img{height:60px;filter:drop-shadow(0 10px 22px rgba(0,0,0,.4))}
+ .hapill{display:inline-flex;align-items:center;gap:9px;padding:9px 16px;border-radius:999px;
+ background:rgba(65,189,245,.12);border:1px solid rgba(65,189,245,.4);color:#bfe8ff;font-size:15px;font-weight:700}
+ .hadot{width:11px;height:11px;border-radius:50%;background:#41BDF5;box-shadow:0 0 12px #41BDF5}
+ .tagline{position:absolute;z-index:8;font-weight:760;letter-spacing:-.025em}
+ .pills{display:flex;gap:11px;margin-top:16px}
+ .pill{padding:9px 15px;border:1px solid rgba(131,213,255,.26);border-radius:999px;background:rgba(9,24,44,.72);
+ color:#cdeeff;font-size:13px;font-weight:740;letter-spacing:.09em;text-transform:uppercase}
+ .eyebrow{color:#65ddff;font-size:15px;font-weight:800;letter-spacing:.16em;text-transform:uppercase}
+ .label{margin-top:9px;font-size:30px;font-weight:780;letter-spacing:-.03em}
+ .sub{margin-top:12px;font-size:17px;line-height:1.5;color:#a9c6e0;max-width:88%}
+ .copy{position:absolute;z-index:8}
+`;
+
+function browserFrame(img, url, w) {
+ return `
+
+

`;
+}
+
+function phoneFrame(img, w) {
+ return `
+

`;
+}
+
+function heroHtml(panel, moreinfo, logo) {
+ return `
+
+
Works with Home Assistant
+ ${browserFrame(panel, 'homeassistant.local:8123 / relaytv', 1000)}
+ ${phoneFrame(moreinfo, 316)}
+ Your TV.
Native in Home Assistant.
+
ControlAutomateEmbed
+ `;
+}
+
+function cardHtml({ img, kind, w, eyebrow, label, sub }) {
+ const frame = kind === 'phone' ? phoneFrame(img, w) : browserFrame(img, 'homeassistant.local:8123', w);
+ const place = kind === 'phone'
+ ? '.frame{position:absolute;z-index:4;right:64px;top:50%;transform:translateY(-50%)}'
+ : '.frame{position:absolute;z-index:4;right:56px;top:50%;transform:translateY(-50%)}';
+ return `
+
+ ${eyebrow}
${label}
${sub}
+
Home Assistant
+ ${frame}
+ `;
+}
+
+async function render(browser, html, out, viewport) {
+ const ctx = await browser.newContext({ viewport, deviceScaleFactor: 1 });
+ const page = await ctx.newPage();
+ await page.setContent(html, { waitUntil: 'load' });
+ await page.waitForTimeout(400);
+ await page.screenshot({ path: out, type: 'png' });
+ await ctx.close();
+}
+
+async function main() {
+ const browser = await chromium.connect(WS);
+ try {
+ const moreinfo = uri(await captureMoreInfo(browser));
+ const device = uri(await captureDesktop(browser, `${HA}/config/devices/device/${DEVICE_ID}`));
+ const panel = uri(await captureDesktop(browser, `${HA}/${PANEL}`, 4000));
+ const logo = uri(fs.readFileSync(LOGO));
+
+ await render(browser, heroHtml(panel, moreinfo, logo), path.join(OUTPUT, 'hero.png'), { width: 1600, height: 1000 });
+ await render(browser, cardHtml({
+ img: moreinfo, kind: 'phone', w: 360,
+ eyebrow: 'Control', label: 'Native media controls',
+ sub: 'Play, pause, seek, set volume, and mute from any Home Assistant dashboard, the mobile app, or a voice assistant — with live artwork and progress.',
+ }), path.join(OUTPUT, 'control-phone.png'), { width: 1500, height: 760 });
+ await render(browser, cardHtml({
+ img: device, kind: 'browser', w: 900,
+ eyebrow: 'Automate', label: 'A first-class Home Assistant device',
+ sub: 'RelayTV registers as a real device with a media_player entity and RelayTV-specific actions, ready for automations, scripts, and dashboards.',
+ }), path.join(OUTPUT, 'device-desktop.png'), { width: 1600, height: 820 });
+ await render(browser, cardHtml({
+ img: panel, kind: 'browser', w: 900,
+ eyebrow: 'Embed', label: 'Your RelayTV remote in the sidebar',
+ sub: 'Pin the full RelayTV web UI as a Home Assistant panel so the remote, queue, and Jellyfin/Emby library live one click away.',
+ }), path.join(OUTPUT, 'panel-desktop.png'), { width: 1600, height: 820 });
+
+ process.stdout.write(`${JSON.stringify({
+ ok: true, ha: HA, output: OUTPUT,
+ files: ['hero.png', 'control-phone.png', 'device-desktop.png', 'panel-desktop.png'],
+ }, null, 2)}\n`);
+ } finally {
+ await browser.close();
+ }
+}
+
+main().catch((error) => {
+ process.stderr.write(`README screenshot generation failed: ${error.stack || error}\n`);
+ process.exit(1);
+});