MAJOR UPDATE ALERT!!!!!
This commit is contained in:
338
tv/src/App.tsx
338
tv/src/App.tsx
@@ -1,149 +1,76 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { CardLayout, DataCard, ImagePopupState, SettingsState, TextState } from "./types";
|
||||
import { DataCardWidget } from "./components/DataCardWidget";
|
||||
import { TextPopup } from "./components/TextPopup";
|
||||
import { ImagePopup } from "./components/ImagePopup";
|
||||
import { NotFullscreen } from "./components/NotFullscreen";
|
||||
|
||||
interface TextState {
|
||||
showing: boolean;
|
||||
title: string;
|
||||
}
|
||||
const GRID_COLS = 4;
|
||||
const GRID_ROWS = 4;
|
||||
|
||||
interface ImagePopupState {
|
||||
showing: boolean;
|
||||
image_url: string;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
interface DisplayOptions {
|
||||
font_size: number;
|
||||
text_color: string;
|
||||
}
|
||||
|
||||
interface DataCardConfig {
|
||||
url: string;
|
||||
refresh_interval: number;
|
||||
display_options: DisplayOptions;
|
||||
take?: string;
|
||||
additional_headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface DataCard {
|
||||
id: string;
|
||||
type: "custom_json";
|
||||
name: string;
|
||||
config: DataCardConfig;
|
||||
}
|
||||
|
||||
// ─── Path evaluator for "$out.foo.bar[0].baz" ────────────────────────────────
|
||||
|
||||
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]);
|
||||
/** Deterministic hash of a string → non-negative integer */
|
||||
function hashStr(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
h = Math.imul(31, h) + s.charCodeAt(i) | 0;
|
||||
}
|
||||
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<string, unknown>)[token];
|
||||
}
|
||||
}
|
||||
return current;
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
// ─── Data Card Widget ─────────────────────────────────────────────────────────
|
||||
/**
|
||||
* Process cards in insertion order.
|
||||
* If two cards share the same starting (grid_col, grid_row), the later one
|
||||
* is relocated to a free starting position chosen deterministically via its id.
|
||||
*/
|
||||
function assignLayouts(cards: DataCard[]): Array<{ card: DataCard; resolvedLayout: CardLayout }> {
|
||||
const occupied = new Set<string>();
|
||||
|
||||
function DataCardWidget({ card }: { card: DataCard }) {
|
||||
const [value, setValue] = useState<string>("…");
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchData = () => {
|
||||
const headers: Record<string, string> = {
|
||||
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());
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(String(err));
|
||||
});
|
||||
return cards.map((card) => {
|
||||
const layout: CardLayout = card.layout ?? {
|
||||
grid_col: 1,
|
||||
grid_row: 4,
|
||||
col_span: 1,
|
||||
row_span: 1,
|
||||
};
|
||||
const key = `${layout.grid_col},${layout.grid_row}`;
|
||||
|
||||
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]);
|
||||
if (!occupied.has(key)) {
|
||||
occupied.add(key);
|
||||
return { card, resolvedLayout: layout };
|
||||
}
|
||||
|
||||
const fontSize = card.config.display_options?.font_size ?? 16;
|
||||
const textColor = card.config.display_options?.text_color ?? "#ffffff";
|
||||
// Collect all free starting positions
|
||||
const free: Array<[number, number]> = [];
|
||||
for (let r = 1; r <= GRID_ROWS; r++) {
|
||||
for (let c = 1; c <= GRID_COLS; c++) {
|
||||
if (!occupied.has(`${c},${r}`)) free.push([c, r]);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1 bg-black/40 backdrop-blur-sm rounded-2xl border border-white/10 px-5 py-4 min-w-[160px] max-w-[320px]">
|
||||
<span className="text-gray-400 text-xs font-semibold uppercase tracking-wider">
|
||||
{card.name}
|
||||
</span>
|
||||
{error ? (
|
||||
<span className="text-red-400 text-sm break-all">⚠ {error}</span>
|
||||
) : (
|
||||
<span
|
||||
className="font-mono break-all whitespace-pre-wrap leading-snug"
|
||||
style={{ fontSize, color: textColor }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
{lastUpdated && (
|
||||
<span className="text-gray-600 text-xs mt-1">
|
||||
{lastUpdated.toLocaleTimeString()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
if (free.length === 0) {
|
||||
// Every slot taken – let it overlap at its original position
|
||||
return { card, resolvedLayout: layout };
|
||||
}
|
||||
|
||||
const [rc, rr] = free[hashStr(card.id) % free.length];
|
||||
occupied.add(`${rc},${rr}`);
|
||||
return { card, resolvedLayout: { ...layout, grid_col: rc, grid_row: rr } };
|
||||
});
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [screenStatus, setScreenStatus] = useState<
|
||||
"notfullscreen" | "fullscreen"
|
||||
>("notfullscreen");
|
||||
const [screenStatus, setScreenStatus] = useState<"notfullscreen" | "fullscreen">("notfullscreen");
|
||||
const [textState, setTextState] = useState<TextState>({ showing: false, title: "" });
|
||||
const [imagePopup, setImagePopup] = useState<ImagePopupState>({ showing: false, image_url: "", caption: "" });
|
||||
const [dataCards, setDataCards] = useState<DataCard[]>([]);
|
||||
const [settings, setSettings] = useState<SettingsState>({ background_url: "" });
|
||||
|
||||
useEffect(() => {
|
||||
const handleFullscreenChange = () => {
|
||||
if (!document.fullscreenElement) {
|
||||
setScreenStatus("notfullscreen");
|
||||
} else {
|
||||
setScreenStatus("fullscreen");
|
||||
}
|
||||
setScreenStatus(document.fullscreenElement ? "fullscreen" : "notfullscreen");
|
||||
};
|
||||
|
||||
document.addEventListener("fullscreenchange", handleFullscreenChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||
};
|
||||
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -158,6 +85,7 @@ function App() {
|
||||
setTextState(state.text ?? { showing: false, title: "" });
|
||||
setImagePopup(state.image_popup ?? { showing: false, image_url: "", caption: "" });
|
||||
setDataCards(state.data_cards ?? []);
|
||||
setSettings(state.settings ?? { background_url: "" });
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error pulling state:", error);
|
||||
@@ -166,116 +94,72 @@ function App() {
|
||||
|
||||
handlePullState();
|
||||
const interval = setInterval(handlePullState, 5000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}
|
||||
}, [screenStatus]);
|
||||
|
||||
// Stable: only recompute when the serialised card list changes
|
||||
const resolvedCards = useMemo(
|
||||
() => assignLayouts(dataCards),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[JSON.stringify(dataCards)],
|
||||
);
|
||||
|
||||
if (screenStatus === "notfullscreen") {
|
||||
return <NotFullscreen />;
|
||||
}
|
||||
|
||||
const isIdle = !textState.showing && !imagePopup.showing && dataCards.length === 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{screenStatus === "notfullscreen" ? (
|
||||
<div className="flex flex-col items-center gap-8 px-8 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gray-800 border border-gray-700 flex items-center justify-center mb-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-8 h-8 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 8V6a2 2 0 012-2h8a2 2 0 012 2v2M6 16v2a2 2 0 002 2h8a2 2 0 002-2v-2M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-white">
|
||||
TV View
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm max-w-xs">
|
||||
Enter fullscreen mode to start displaying content.
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-screen h-screen relative">
|
||||
{settings.background_url && (
|
||||
<img
|
||||
src={settings.background_url}
|
||||
className="absolute inset-0 w-full h-full object-cover z-0"
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
<TextPopup state={textState} />
|
||||
<ImagePopup state={imagePopup} />
|
||||
|
||||
<button
|
||||
onClick={() => document.documentElement.requestFullscreen()}
|
||||
className="group flex items-center gap-2 bg-white text-gray-900 font-medium px-6 py-3 rounded-xl hover:bg-gray-200 active:scale-95 transition-all duration-150 cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 8V4m0 0h4M4 4l5 5m11-5h-4m4 0v4m0-4l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
Go Fullscreen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-screen h-screen relative">
|
||||
{/* Text popup modal */}
|
||||
{textState.showing && (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-3xl px-16 py-12 shadow-2xl max-w-3xl w-full mx-8">
|
||||
<h1 className="text-6xl font-bold tracking-tight text-white text-center">
|
||||
{textState.title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image popup modal */}
|
||||
{imagePopup.showing && imagePopup.image_url && (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/75 backdrop-blur-sm p-12">
|
||||
<div className="flex flex-col items-center gap-5 p-6 bg-gray-950/80 rounded-3xl border border-white/10 shadow-2xl max-w-[82vw]">
|
||||
{imagePopup.caption && (
|
||||
<h2 className="text-4xl font-bold text-white tracking-tight text-center">
|
||||
{imagePopup.caption}
|
||||
</h2>
|
||||
)}
|
||||
<img
|
||||
src={imagePopup.image_url}
|
||||
alt="TV display"
|
||||
className="max-w-full max-h-[72vh] rounded-2xl object-contain shadow-xl"
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background idle state */}
|
||||
{!textState.showing && !imagePopup.showing && dataCards.length === 0 && (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<p className="text-gray-600 text-lg">
|
||||
Waiting for content
|
||||
<span className="dot-1">.</span>
|
||||
<span className="dot-2">.</span>
|
||||
<span className="dot-3">.</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data cards — persistent widgets at the bottom */}
|
||||
{dataCards.length > 0 && (
|
||||
<div className="absolute bottom-0 left-0 right-0 z-0 flex flex-row flex-wrap gap-4 p-6 items-end">
|
||||
{dataCards.map((card) => (
|
||||
<DataCardWidget key={card.id} card={card} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isIdle && (
|
||||
<div className="flex items-center justify-center w-full h-full">
|
||||
<p className="text-gray-600 text-lg">
|
||||
Waiting for content
|
||||
<span className="dot-1">.</span>
|
||||
<span className="dot-2">.</span>
|
||||
<span className="dot-3">.</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
{resolvedCards.length > 0 && (
|
||||
<div
|
||||
className="absolute inset-0 z-0 p-4"
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: `repeat(${GRID_COLS}, 1fr)`,
|
||||
gridTemplateRows: `repeat(${GRID_ROWS}, 1fr)`,
|
||||
gap: "12px",
|
||||
}}
|
||||
>
|
||||
{resolvedCards.map(({ card, resolvedLayout }) => (
|
||||
<div
|
||||
key={card.id}
|
||||
style={{
|
||||
gridColumn: `${resolvedLayout.grid_col} / span ${resolvedLayout.col_span}`,
|
||||
gridRow: `${resolvedLayout.grid_row} / span ${resolvedLayout.row_span}`,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<DataCardWidget card={card} layout={resolvedLayout} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
301
tv/src/components/DataCardWidget.tsx
Normal file
301
tv/src/components/DataCardWidget.tsx
Normal file
@@ -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 (
|
||||
<div className="flex flex-col gap-1 bg-black/40 backdrop-blur-sm rounded-2xl border border-white/10 px-5 py-4 w-full h-full overflow-hidden">
|
||||
<span className="text-gray-400 text-xs font-semibold uppercase tracking-wider truncate shrink-0">
|
||||
{name}
|
||||
</span>
|
||||
<div className="flex-1 overflow-hidden min-h-0">{children}</div>
|
||||
{footer && <div className="shrink-0">{footer}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── custom_json widget ───────────────────────────────────────────────────────
|
||||
|
||||
function CustomJsonWidget({ card, layout }: { card: CustomJsonCard; layout?: CardLayout }) {
|
||||
const [value, setValue] = useState<string>("…");
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [now, setNow] = useState<Date>(() => new Date());
|
||||
const [pulling, setPulling] = useState(false);
|
||||
const [dots, setDots] = useState(0);
|
||||
const [responseMs, setResponseMs] = useState<number | null>(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<string, string> = {
|
||||
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 (
|
||||
<CardShell
|
||||
name={card.name}
|
||||
footer={
|
||||
pulling ? (
|
||||
<span className="text-gray-500 text-xs">pulling{"." .repeat(dots)}</span>
|
||||
) : secAgo !== null && nextIn !== null ? (
|
||||
<span className="text-gray-600 text-xs">
|
||||
{secAgo}s ago · next in {nextIn}s{responseMs !== null ? ` · ${responseMs}ms` : ""}
|
||||
</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<span className="text-red-400 text-sm break-all">⚠ {error}</span>
|
||||
) : (
|
||||
<div className={`flex h-full overflow-hidden${centered ? " items-center justify-center" : ""}`}>
|
||||
<span
|
||||
className={`font-mono break-all whitespace-pre-wrap leading-snug${centered ? " text-center" : " block"}`}
|
||||
style={{ fontSize, color: textColor }}
|
||||
>
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<CardShell name={card.name}>
|
||||
<div className={`flex h-full overflow-hidden${centered ? " items-center justify-center" : ""}`}>
|
||||
<span
|
||||
className={`whitespace-pre-wrap break-words leading-snug${centered ? " text-center" : " block"}`}
|
||||
style={{ fontSize, color: textColor }}
|
||||
>
|
||||
{card.config.text}
|
||||
</span>
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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<Date>(() => 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 (
|
||||
<CardShell name={card.name}>
|
||||
<div className={`flex flex-col justify-center h-full gap-1${centered ? " items-center" : ""}`}>
|
||||
<span
|
||||
className={`font-mono tabular-nums leading-none${centered ? " text-center" : ""}`}
|
||||
style={{ fontSize, color: textColor }}
|
||||
>
|
||||
{display}
|
||||
</span>
|
||||
{subtitle && (
|
||||
<span className={`text-gray-500 text-xs mt-1${centered ? " text-center" : ""}`}>{subtitle}</span>
|
||||
)}
|
||||
</div>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<CardShell name={card.name}>
|
||||
<span className="text-gray-500 text-sm">No images configured.</span>
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
const fit = card.config.fit === "contain" ? "contain" : "cover";
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden rounded-2xl">
|
||||
<img
|
||||
key={images[index]}
|
||||
src={images[index]}
|
||||
alt=""
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
objectFit: fit,
|
||||
transition: "opacity 0.4s ease",
|
||||
opacity: fade ? 1 : 0,
|
||||
display: "block",
|
||||
}}
|
||||
/>
|
||||
{/* Name overlay */}
|
||||
<div className="absolute bottom-0 left-0 right-0 px-4 py-2 bg-gradient-to-t from-black/70 to-transparent">
|
||||
<span className="text-white text-xs font-semibold truncate">{card.name}</span>
|
||||
{images.length > 1 && (
|
||||
<span className="text-white/50 text-xs ml-2">
|
||||
{index + 1}/{images.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dispatcher ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function DataCardWidget({ card, layout }: { card: DataCard; layout?: CardLayout }) {
|
||||
if (card.type === "static_text") return <StaticTextWidget card={card} layout={layout} />;
|
||||
if (card.type === "clock") return <ClockWidget card={card} layout={layout} />;
|
||||
if (card.type === "image_rotator") return <ImageRotatorWidget card={card} />;
|
||||
// default: custom_json
|
||||
return <CustomJsonWidget card={card as CustomJsonCard} layout={layout} />;
|
||||
}
|
||||
23
tv/src/components/ImagePopup.tsx
Normal file
23
tv/src/components/ImagePopup.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { type ImagePopupState } from "../types";
|
||||
|
||||
export function ImagePopup({ state }: { state: ImagePopupState }) {
|
||||
if (!state.showing || !state.image_url) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-20 flex items-center justify-center bg-black/75 backdrop-blur-sm p-12">
|
||||
<div className="flex flex-col items-center gap-5 p-6 bg-gray-950/80 rounded-3xl border border-white/10 shadow-2xl max-w-[82vw]">
|
||||
{state.caption && (
|
||||
<h2 className="text-4xl font-bold text-white tracking-tight text-center">
|
||||
{state.caption}
|
||||
</h2>
|
||||
)}
|
||||
<img
|
||||
src={state.image_url}
|
||||
alt="TV display"
|
||||
className="max-w-full max-h-[72vh] rounded-2xl object-contain shadow-xl"
|
||||
style={{ display: "block" }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
tv/src/components/NotFullscreen.tsx
Normal file
51
tv/src/components/NotFullscreen.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
export function NotFullscreen() {
|
||||
return (
|
||||
<div className="flex flex-col items-center gap-8 px-8 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-16 h-16 rounded-2xl bg-gray-800 border border-gray-700 flex items-center justify-center mb-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-8 h-8 text-gray-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M6 8V6a2 2 0 012-2h8a2 2 0 012 2v2M6 16v2a2 2 0 002 2h8a2 2 0 002-2v-2M3 12h18"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-semibold tracking-tight text-white">
|
||||
TV View
|
||||
</h1>
|
||||
<p className="text-gray-500 text-sm max-w-xs">
|
||||
Enter fullscreen mode to start displaying content.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => document.documentElement.requestFullscreen()}
|
||||
className="group flex items-center gap-2 bg-white text-gray-900 font-medium px-6 py-3 rounded-xl hover:bg-gray-200 active:scale-95 transition-all duration-150 cursor-pointer"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="w-4 h-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M4 8V4m0 0h4M4 4l5 5m11-5h-4m4 0v4m0-4l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4"
|
||||
/>
|
||||
</svg>
|
||||
Go Fullscreen
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
tv/src/components/TextPopup.tsx
Normal file
15
tv/src/components/TextPopup.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { type TextState } from "../types";
|
||||
|
||||
export function TextPopup({ state }: { state: TextState }) {
|
||||
if (!state.showing) return null;
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 z-10 flex items-center justify-center bg-black/70 backdrop-blur-sm">
|
||||
<div className="bg-gray-900 border border-gray-700 rounded-3xl px-16 py-12 shadow-2xl max-w-3xl mx-8">
|
||||
<h1 className="text-6xl font-bold tracking-tight text-white text-center">
|
||||
{state.title}
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
106
tv/src/types.ts
Normal file
106
tv/src/types.ts
Normal file
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
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;
|
||||
21
tv/src/utils/evaluatePath.ts
Normal file
21
tv/src/utils/evaluatePath.ts
Normal file
@@ -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<string, unknown>)[token];
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
Reference in New Issue
Block a user