-
-
-
- TV View
-
-
- Enter fullscreen mode to start displaying content.
-
-
+
+ {settings.background_url && (
+

+ )}
+
+
-
-
- ) : (
-
- {/* Text popup modal */}
- {textState.showing && (
-
-
-
- {textState.title}
-
-
-
- )}
-
- {/* Image popup modal */}
- {imagePopup.showing && imagePopup.image_url && (
-
-
- {imagePopup.caption && (
-
- {imagePopup.caption}
-
- )}
-

-
-
- )}
-
- {/* Background idle state */}
- {!textState.showing && !imagePopup.showing && dataCards.length === 0 && (
-
-
- Waiting for content
- .
- .
- .
-
-
- )}
-
- {/* Data cards — persistent widgets at the bottom */}
- {dataCards.length > 0 && (
-
- {dataCards.map((card) => (
-
- ))}
-
- )}
+ {isIdle && (
+
+
+ Waiting for content
+ .
+ .
+ .
+
)}
- >
+
+ {resolvedCards.length > 0 && (
+
+ {resolvedCards.map(({ card, resolvedLayout }) => (
+
+
+
+ ))}
+
+ )}
+
);
}
diff --git a/tv/src/components/DataCardWidget.tsx b/tv/src/components/DataCardWidget.tsx
new file mode 100644
index 0000000..619c115
--- /dev/null
+++ b/tv/src/components/DataCardWidget.tsx
@@ -0,0 +1,301 @@
+import { useEffect, useState } from "react";
+import type {
+ CardLayout,
+ ClockCard,
+ CustomJsonCard,
+ DataCard,
+ ImageRotatorCard,
+ StaticTextCard,
+} from "../types";
+import { evaluatePath } from "../utils/evaluatePath";
+
+// ─── Shared card shell ────────────────────────────────────────────────────────
+
+function CardShell({ name, children, footer }: { name: string; children: React.ReactNode; footer?: React.ReactNode }) {
+ return (
+
+
+ {name}
+
+
{children}
+ {footer &&
{footer}
}
+
+ );
+}
+
+// ─── custom_json widget ───────────────────────────────────────────────────────
+
+function CustomJsonWidget({ card, layout }: { card: CustomJsonCard; layout?: CardLayout }) {
+ const [value, setValue] = useState
("…");
+ const [lastUpdated, setLastUpdated] = useState(null);
+ const [error, setError] = useState(null);
+ const [now, setNow] = useState(() => new Date());
+ const [pulling, setPulling] = useState(false);
+ const [dots, setDots] = useState(0);
+ const [responseMs, setResponseMs] = useState(null);
+
+ useEffect(() => {
+ const ticker = setInterval(() => setNow(new Date()), 1000);
+ return () => clearInterval(ticker);
+ }, []);
+
+ useEffect(() => {
+ if (!pulling) return;
+ const dotsTimer = setInterval(() => setDots((d) => (d + 1) % 4), 400);
+ return () => clearInterval(dotsTimer);
+ }, [pulling]);
+
+ useEffect(() => {
+ const fetchData = () => {
+ setPulling(true);
+ setDots(0);
+ const start = performance.now();
+ const headers: Record = {
+ Accept: "application/json",
+ ...(card.config.additional_headers ?? {}),
+ };
+ fetch(card.config.url, { headers })
+ .then((r) => r.json())
+ .then((data) => {
+ const result = card.config.take ? evaluatePath(data, card.config.take) : data;
+ const display =
+ result === null || result === undefined
+ ? "(null)"
+ : typeof result === "object"
+ ? JSON.stringify(result, null, 2)
+ : String(result);
+ setValue(display);
+ setLastUpdated(new Date());
+ setResponseMs(Math.round(performance.now() - start));
+ setError(null);
+ setPulling(false);
+ })
+ .catch((err) => {
+ setResponseMs(Math.round(performance.now() - start));
+ setError(String(err));
+ setPulling(false);
+ });
+ };
+
+ fetchData();
+ const ms = Math.max(5000, (card.config.refresh_interval ?? 60) * 1000);
+ const interval = setInterval(fetchData, ms);
+ return () => clearInterval(interval);
+ }, [card.id, card.config.url, card.config.refresh_interval, card.config.take]);
+
+ const refreshMs = Math.max(5000, (card.config.refresh_interval ?? 60) * 1000);
+ const secAgo = lastUpdated ? Math.floor((now.getTime() - lastUpdated.getTime()) / 1000) : null;
+ const nextIn = lastUpdated
+ ? Math.max(0, Math.ceil((lastUpdated.getTime() + refreshMs - now.getTime()) / 1000))
+ : null;
+
+ const baseFontSize = card.config.display_options?.font_size ?? 16;
+ const textColor = card.config.display_options?.text_color ?? "#ffffff";
+ const colSpan = layout?.col_span ?? 1;
+ const rowSpan = layout?.row_span ?? 1;
+ const fontSize = Math.round(baseFontSize * Math.sqrt(colSpan * rowSpan));
+ const centered = colSpan > 1 || rowSpan > 1;
+
+ return (
+ pulling{"." .repeat(dots)}
+ ) : secAgo !== null && nextIn !== null ? (
+
+ {secAgo}s ago · next in {nextIn}s{responseMs !== null ? ` · ${responseMs}ms` : ""}
+
+ ) : undefined
+ }
+ >
+ {error ? (
+ ⚠ {error}
+ ) : (
+
+
+ {value}
+
+
+ )}
+
+ );
+}
+
+// ─── static_text widget ───────────────────────────────────────────────────────
+
+function StaticTextWidget({ card, layout }: { card: StaticTextCard; layout?: CardLayout }) {
+ const baseFontSize = card.config.font_size ?? 16;
+ const textColor = card.config.text_color ?? "#ffffff";
+ const colSpan = layout?.col_span ?? 1;
+ const rowSpan = layout?.row_span ?? 1;
+ const fontSize = Math.round(baseFontSize * Math.sqrt(colSpan * rowSpan));
+ const centered = colSpan > 1 || rowSpan > 1;
+
+ return (
+
+
+
+ {card.config.text}
+
+
+
+ );
+}
+
+// ─── clock widget ─────────────────────────────────────────────────────────────
+
+function formatDuration(totalSeconds: number): string {
+ if (totalSeconds <= 0) return "00:00:00";
+ const d = Math.floor(totalSeconds / 86400);
+ const h = Math.floor((totalSeconds % 86400) / 3600);
+ const m = Math.floor((totalSeconds % 3600) / 60);
+ const s = totalSeconds % 60;
+ const timePart = [String(h).padStart(2, "0"), String(m).padStart(2, "0"), String(s).padStart(2, "0")].join(":");
+ return d > 0 ? `${d}d ${timePart}` : timePart;
+}
+
+function ClockWidget({ card, layout }: { card: ClockCard; layout?: CardLayout }) {
+ const [now, setNow] = useState(() => new Date());
+
+ useEffect(() => {
+ const ticker = setInterval(() => setNow(new Date()), 1000);
+ return () => clearInterval(ticker);
+ }, []);
+
+ const baseFontSize = card.config.font_size ?? 48;
+ const textColor = card.config.text_color ?? "#ffffff";
+ const colSpan = layout?.col_span ?? 1;
+ const rowSpan = layout?.row_span ?? 1;
+ const fontSize = Math.round(baseFontSize * Math.sqrt(colSpan * rowSpan));
+ const centered = colSpan > 1 || rowSpan > 1;
+
+ let display = "";
+ let subtitle = "";
+
+ if (card.config.mode === "time") {
+ const tz = card.config.timezone;
+ const showSec = card.config.show_seconds !== false;
+ try {
+ display = new Intl.DateTimeFormat("en-GB", {
+ hour: "2-digit",
+ minute: "2-digit",
+ second: showSec ? "2-digit" : undefined,
+ hour12: false,
+ timeZone: tz || undefined,
+ }).format(now);
+ } catch {
+ display = now.toLocaleTimeString();
+ }
+ if (tz) subtitle = tz.replace("_", " ");
+ } else {
+ // timer / countdown
+ const target = card.config.target_iso ? new Date(card.config.target_iso) : null;
+ if (!target || isNaN(target.getTime())) {
+ display = "invalid target";
+ } else {
+ const diff = Math.max(0, Math.floor((target.getTime() - now.getTime()) / 1000));
+ if (diff === 0) {
+ display = "00:00";
+ subtitle = "Time's up!";
+ } else {
+ display = formatDuration(diff);
+ subtitle = `until ${new Intl.DateTimeFormat("en-GB", {
+ dateStyle: "medium",
+ timeStyle: "short",
+ timeZone: card.config.timezone || undefined,
+ }).format(target)}`;
+ }
+ }
+ }
+
+ return (
+
+
+
+ {display}
+
+ {subtitle && (
+ {subtitle}
+ )}
+
+
+ );
+}
+
+// ─── image_rotator widget ─────────────────────────────────────────────────────
+
+function ImageRotatorWidget({ card }: { card: ImageRotatorCard }) {
+ const images = card.config.images ?? [];
+ const [index, setIndex] = useState(0);
+ const [fade, setFade] = useState(true);
+
+ useEffect(() => {
+ if (images.length <= 1) return;
+ const ms = Math.max(2000, (card.config.interval ?? 10) * 1000);
+ const timer = setInterval(() => {
+ setFade(false);
+ setTimeout(() => {
+ setIndex((i) => (i + 1) % images.length);
+ setFade(true);
+ }, 400);
+ }, ms);
+ return () => clearInterval(timer);
+ }, [images.length, card.config.interval]);
+
+ if (images.length === 0) {
+ return (
+
+ No images configured.
+
+ );
+ }
+
+ const fit = card.config.fit === "contain" ? "contain" : "cover";
+
+ return (
+
+

+ {/* Name overlay */}
+
+ {card.name}
+ {images.length > 1 && (
+
+ {index + 1}/{images.length}
+
+ )}
+
+
+ );
+}
+
+// ─── Dispatcher ───────────────────────────────────────────────────────────────
+
+export function DataCardWidget({ card, layout }: { card: DataCard; layout?: CardLayout }) {
+ if (card.type === "static_text") return ;
+ if (card.type === "clock") return ;
+ if (card.type === "image_rotator") return ;
+ // default: custom_json
+ return ;
+}
diff --git a/tv/src/components/ImagePopup.tsx b/tv/src/components/ImagePopup.tsx
new file mode 100644
index 0000000..b0c1517
--- /dev/null
+++ b/tv/src/components/ImagePopup.tsx
@@ -0,0 +1,23 @@
+import { type ImagePopupState } from "../types";
+
+export function ImagePopup({ state }: { state: ImagePopupState }) {
+ if (!state.showing || !state.image_url) return null;
+
+ return (
+
+
+ {state.caption && (
+
+ {state.caption}
+
+ )}
+

+
+
+ );
+}
diff --git a/tv/src/components/NotFullscreen.tsx b/tv/src/components/NotFullscreen.tsx
new file mode 100644
index 0000000..6318b3d
--- /dev/null
+++ b/tv/src/components/NotFullscreen.tsx
@@ -0,0 +1,51 @@
+export function NotFullscreen() {
+ return (
+
+
+
+
+ TV View
+
+
+ Enter fullscreen mode to start displaying content.
+
+
+
+
+
+ );
+}
diff --git a/tv/src/components/TextPopup.tsx b/tv/src/components/TextPopup.tsx
new file mode 100644
index 0000000..0a5737f
--- /dev/null
+++ b/tv/src/components/TextPopup.tsx
@@ -0,0 +1,15 @@
+import { type TextState } from "../types";
+
+export function TextPopup({ state }: { state: TextState }) {
+ if (!state.showing) return null;
+
+ return (
+
+
+
+ {state.title}
+
+
+
+ );
+}
diff --git a/tv/src/types.ts b/tv/src/types.ts
new file mode 100644
index 0000000..2bc7352
--- /dev/null
+++ b/tv/src/types.ts
@@ -0,0 +1,106 @@
+export interface TextState {
+ showing: boolean;
+ title: string;
+}
+
+export interface SettingsState {
+ /** Portrait background image URL (height > width). Empty string = no background. */
+ background_url: string;
+}
+
+export interface ImagePopupState {
+ showing: boolean;
+ image_url: string;
+ caption: string;
+}
+
+/** Grid layout: 1-based col/row, 4-column × 4-row grid */
+export interface CardLayout {
+ grid_col: number; // 1–4
+ grid_row: number; // 1–4
+ col_span: number; // 1–4
+ row_span: number; // 1–4
+}
+
+// ─── custom_json ───────────────────────────────────────────────────────────────
+
+export interface DisplayOptions {
+ font_size: number;
+ text_color: string;
+}
+
+export interface DataCardConfig {
+ url: string;
+ refresh_interval: number;
+ display_options: DisplayOptions;
+ take?: string;
+ additional_headers?: Record;
+}
+
+export interface CustomJsonCard {
+ id: string;
+ type: "custom_json";
+ name: string;
+ config: DataCardConfig;
+ layout?: CardLayout;
+}
+
+// ─── static_text ──────────────────────────────────────────────────────────────
+
+export interface StaticTextConfig {
+ text: string;
+ font_size: number;
+ text_color: string;
+}
+
+export interface StaticTextCard {
+ id: string;
+ type: "static_text";
+ name: string;
+ config: StaticTextConfig;
+ layout?: CardLayout;
+}
+
+// ─── clock ────────────────────────────────────────────────────────────────────
+
+export interface ClockConfig {
+ /** "time" = live clock, "timer" = countdown to target_iso */
+ mode: "time" | "timer";
+ /** IANA timezone string, e.g. "Europe/Berlin" */
+ timezone?: string;
+ /** ISO-8601 target datetime for mode="timer", e.g. "2026-12-31T23:59:59" */
+ target_iso?: string;
+ font_size: number;
+ text_color: string;
+ show_seconds?: boolean;
+}
+
+export interface ClockCard {
+ id: string;
+ type: "clock";
+ name: string;
+ config: ClockConfig;
+ layout?: CardLayout;
+}
+
+// ─── image_rotator ────────────────────────────────────────────────────────────
+
+export interface ImageRotatorConfig {
+ /** List of public image URLs to cycle through */
+ images: string[];
+ /** Seconds between transitions */
+ interval: number;
+ fit: "cover" | "contain";
+}
+
+export interface ImageRotatorCard {
+ id: string;
+ type: "image_rotator";
+ name: string;
+ config: ImageRotatorConfig;
+ layout?: CardLayout;
+}
+
+// ─── Union ────────────────────────────────────────────────────────────────────
+
+export type DataCard = CustomJsonCard | StaticTextCard | ClockCard | ImageRotatorCard;
diff --git a/tv/src/utils/evaluatePath.ts b/tv/src/utils/evaluatePath.ts
new file mode 100644
index 0000000..c96b2a1
--- /dev/null
+++ b/tv/src/utils/evaluatePath.ts
@@ -0,0 +1,21 @@
+export function evaluatePath(data: unknown, path: string): unknown {
+ if (!path || !path.startsWith("$out")) return data;
+ const expr = path.slice(4); // strip "$out"
+ const tokens: string[] = [];
+ const regex = /\.([a-zA-Z_][a-zA-Z0-9_]*)|\[(\d+)\]/g;
+ let match: RegExpExecArray | null;
+ while ((match = regex.exec(expr)) !== null) {
+ tokens.push(match[1] ?? match[2]);
+ }
+ let current: unknown = data;
+ for (const token of tokens) {
+ if (current == null) return null;
+ const idx = parseInt(token, 10);
+ if (!isNaN(idx) && Array.isArray(current)) {
+ current = current[idx];
+ } else {
+ current = (current as Record)[token];
+ }
+ }
+ return current;
+}