docs: redesign README as a product landing page (#4)

* docs: redesign README as a product landing page

Rebuild the README as a product landing page mirroring the RelayTV server
repo: centered logo/tagline, badges, a device-framed hero, a Control /
Automate / Embed overview, and a "See it in action" gallery. Existing
reference content (install, services table, examples, security, limitations,
compatibility) is preserved below the fold.

Add scripts/readme-screenshots.mjs to reproducibly capture the Home Assistant
media-player more-info dialog, RelayTV device page, and embedded sidebar panel
from a live instance (token injected into frontend localStorage, never written
to disk), then frame them in browser/phone chrome for the README and
release/ad assets.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: remove stale relaytv-ha.png montage

The old README montage is superseded by the device-framed hero and gallery
images and is no longer referenced anywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: use the RelayTV brand banner in the README and hero

Replace the plain logo.png with the polished RelayTV banner asset (shared with
the RelayTV server repo) at the top of the README and as the hero brand mark,
and regenerate hero.png. The screenshot script now defaults to the banner.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* chore: drop legacy build-release.sh/VERSION from .gitignore

The gitignored build-release.sh and root VERSION file were an unused release
path (VERSION stuck at 0.0.7, and a differently-named zip than HACS expects).
Real releases run from git tags + custom_components/relaytv/manifest.json via
.github/workflows/release.yml, which builds relaytv.zip on tag push. Removed
the local scripts and their now-pointless ignore entries.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-19 22:12:09 -05:00
committed by GitHub
co-authored by Claude Fable 5
parent 96429e6b29
commit 3a78317e1f
9 changed files with 409 additions and 173 deletions
-4
View File
@@ -33,7 +33,3 @@ secrets.yaml
# OS specific
.DS_Store
Thumbs.db
#Build Tools
build-release.sh
VERSION
+175 -169
View File
@@ -1,81 +1,121 @@
# RelayTV Home Assistant Integration
<p align="center">
<img src="docs/images/readme/relaytv-banner.png" alt="RelayTV" width="620">
</p>
![RelayTV Screenshots](https://raw.githubusercontent.com/mcgeezy/relaytv-ha/main/relaytv-ha.png)
<h1 align="center">Your TV, native in Home Assistant.</h1>
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.
<p align="center">
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.
</p>
RelayTV server:
https://github.com/mcgeezy/relaytv
<p align="center">
<a href="#install-in-minutes"><strong>Install</strong></a> ·
<a href="#see-it-in-action"><strong>See it in action</strong></a> ·
<a href="#relaytv-services"><strong>Services</strong></a> ·
<a href="https://github.com/mcgeezy/relaytv"><strong>RelayTV server</strong></a>
</p>
Android companion app:
https://github.com/mcgeezy/relaytv-android
<p align="center">
<a href="https://github.com/mcgeezy/relaytv-ha/actions"><img src="https://img.shields.io/github/actions/workflow/status/mcgeezy/relaytv-ha/ci.yml?branch=main&label=build&logo=github" alt="Build status"></a>
<a href="https://github.com/hacs/integration"><img src="https://img.shields.io/badge/HACS-custom-41BDF5?logo=homeassistantcommunitystore&logoColor=white" alt="HACS custom repository"></a>
<a href="#compatibility"><img src="https://img.shields.io/badge/Home%20Assistant-2026.7%2B-41BDF5?logo=homeassistant&logoColor=white" alt="Home Assistant 2026.7 or newer"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-GPL--3.0--only-blue" alt="GPL-3.0-only license"></a>
</p>
Support the project:
https://buymeacoffee.com/relaytv
<p align="center">
<img src="docs/images/readme/hero.png" alt="RelayTV running as a Home Assistant media player, panel, and mobile control" width="100%">
</p>
---
<p align="center">
<strong>Local-first</strong> · No RelayTV account · Bearer-token auth ·
Credential-safe playback state
</p>
## 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
<p align="center">
<img src="docs/images/readme/control-phone.png" alt="RelayTV media-player more-info dialog with artwork, seek, and volume controls" width="100%">
</p>
| 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 |
<p align="center">
<img src="docs/images/readme/device-desktop.png" alt="RelayTV device page in Home Assistant with device info, controls, and activity" width="100%">
</p>
---
<p align="center">
<img src="docs/images/readme/panel-desktop.png" alt="The RelayTV web UI embedded as a Home Assistant sidebar panel" width="100%">
</p>
## 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=<long-lived-token> node scripts/readme-screenshots.mjs \
--ha=http://homeassistant.local:8123 \
--device=<relaytv_device_id> \
--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).
Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 796 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 406 KiB

+234
View File
@@ -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=<relaytv_device_id> \
* --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 `<div class="win" style="width:${w}px">
<div class="bar"><span class="dot r"></span><span class="dot y"></span><span class="dot g"></span>
<div class="addr">${url}</div></div>
<img class="winimg" src="${img}" alt=""></div>`;
}
function phoneFrame(img, w) {
return `<div class="phone" style="width:${w}px"><div class="island"></div>
<img class="phoneimg" src="${img}" alt=""></div>`;
}
function heroHtml(panel, moreinfo, logo) {
return `<!doctype html><html><head><meta charset="utf-8"><style>${CSS}
.flare.a{right:-40px;top:20px;width:600px;height:680px;background:rgba(65,189,245,.16)}
.flare.b{left:-80px;bottom:-80px;width:540px;height:540px;background:rgba(56,189,248,.13)}
.brand{left:64px;top:56px}
.stage{position:absolute;z-index:4;left:64px;top:158px;width:1000px}
.fphone{position:absolute;z-index:6;right:96px;top:224px;transform:rotate(3deg)}
.tagline{left:70px;bottom:72px;font-size:40px;line-height:1.06;max-width:1000px}
.tagline .thin{color:#a9c6e0;font-weight:600}
</style></head><body>
<div class="grid"></div><div class="flare a"></div><div class="flare b"></div>
<div class="brand"><img src="${logo}" alt="RelayTV"><span class="hapill"><span class="hadot"></span>Works with Home Assistant</span></div>
<div class="stage">${browserFrame(panel, 'homeassistant.local:8123 / relaytv', 1000)}</div>
<div class="fphone">${phoneFrame(moreinfo, 316)}</div>
<div class="tagline">Your TV. <span class="thin">Native in Home Assistant.</span>
<div class="pills"><span class="pill">Control</span><span class="pill">Automate</span><span class="pill">Embed</span></div></div>
</body></html>`;
}
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 `<!doctype html><html><head><meta charset="utf-8"><style>${CSS}
.flare.a{right:120px;top:60px;width:520px;height:560px;background:rgba(65,189,245,.14)}
${place}
.copy{left:52px;top:48px;max-width:${kind === 'phone' ? 620 : 560}px}
</style></head><body>
<div class="grid"></div><div class="flare a"></div>
<div class="copy"><div class="eyebrow">${eyebrow}</div><div class="label">${label}</div><div class="sub">${sub}</div>
<div class="pills"><span class="pill">Home Assistant</span></div></div>
<div class="frame">${frame}</div>
</body></html>`;
}
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);
});