Files
tv-control/tv/src/App.tsx

283 lines
9.6 KiB
TypeScript

import { useEffect, useState } from "react";
interface TextState {
showing: boolean;
title: string;
}
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]);
}
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;
}
// ─── Data Card Widget ─────────────────────────────────────────────────────────
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));
});
};
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 fontSize = card.config.display_options?.font_size ?? 16;
const textColor = card.config.display_options?.text_color ?? "#ffffff";
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>
);
}
function App() {
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[]>([]);
useEffect(() => {
const handleFullscreenChange = () => {
if (!document.fullscreenElement) {
setScreenStatus("notfullscreen");
} else {
setScreenStatus("fullscreen");
}
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener("fullscreenchange", handleFullscreenChange);
};
}, []);
useEffect(() => {
if (screenStatus === "fullscreen") {
const handlePullState = () => {
fetch(
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329/pull_full",
)
.then((response) => response.json())
.then((data) => {
const state = data.state ?? {};
setTextState(state.text ?? { showing: false, title: "" });
setImagePopup(state.image_popup ?? { showing: false, image_url: "", caption: "" });
setDataCards(state.data_cards ?? []);
})
.catch((error) => {
console.error("Error pulling state:", error);
});
};
handlePullState();
const interval = setInterval(handlePullState, 5000);
return () => clearInterval(interval);
}
}, [screenStatus]);
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>
<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>
)}
</div>
)}
</>
);
}
export default App;