MAJOR UPDATE ALERT!!!!!

This commit is contained in:
2026-03-01 14:57:18 +01:00
parent bb46330af8
commit 6497eb9770
21 changed files with 2374 additions and 915 deletions

View File

@@ -5,45 +5,77 @@
Three-component system for remote-controlling a TV display from a mobile phone:
- **`mobile/`** — Expo React Native app (the "remote control"). Sends commands to the backend.
- **`tv/`** — Vite + React web app (the TV display). Runs in a browser, polls backend every 5s when fullscreen.
- **`functions/control/`** — Python serverless function deployed on `shsf-api.reversed.dev`. Acts as state store; persists to `/app/state.json` on the container.
- **`tv/`** — Vite + React web app (the TV display). Runs in a browser on the TV, polls backend every 5s when fullscreen.
- **`functions/control/`** — Python serverless function on `shsf-api.reversed.dev`. Single state store; persists to `/app/state.json` on the container.
- **`functions/data-sources/`** — Second serverless function (stub); currently unused beyond a `test` route.
## API / Backend
All communication goes through a single endpoint:
All communication goes through one base URL:
```
https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329/{route}
```
Routes (all `POST` except pull routes which are `GET` implicitly via `fetch`):
- `push_text` — body: `{ title: string }`
- `push_dismiss_text`
- `push_image` — body: `{ image_b64: string, caption: string }`; uploads to MinIO, stores public URL
- `push_dismiss_image`
- `pull_full` — returns full state `{ text, image_popup }`
All routes use `POST` (fetch with no body is still POST). `pull_full` returns `{ status, state }` — the state object is under the `state` key, not the root.
Images are stored in MinIO at `content2.reversed.dev`, bucket `tv-control`.
| Route | Body | Notes |
|---|---|---|
| `push_text` | `{ title }` | |
| `push_dismiss_text` | — | |
| `push_image` | `{ image_b64, caption }` | Uploads to MinIO, stores URL |
| `push_dismiss_image` | — | |
| `push_data_card` | `{ card: DataCard }` | Upserts by `card.id` |
| `push_delete_data_card` | `{ id }` | |
| `push_upload_images` | `{ images: [{image_b64, ext}] }` | Returns `{ urls[] }`, no state change |
| `push_settings` | `{ background_url }` | Persists global TV settings |
| `pull_full` | — | Returns `{ state: { text, image_popup, data_cards[], settings } }` |
| `pull_data_cards` | — | Returns `{ data_cards[] }` |
Images/rotator assets stored in MinIO at `content2.reversed.dev`, bucket `tv-control`.
## Global Settings
`settings` is a persistent object with global TV display configuration (typed as `SettingsState` in `tv/src/types.ts`).
- **`background_url`** — a portrait image (height > width) rendered as a full-screen `object-cover` background behind all TV content. Empty string = no background.
- Set via `push_settings { background_url }`, cleared by passing `""`.
- The constraint (portrait orientation) is enforced in the mobile picker (`mobile/src/pages/settings.tsx`) — `asset.width >= asset.height` is rejected.
- Images are uploaded first via `push_upload_images` to get a MinIO URL, then saved via `push_settings`.
- The Settings tab is visible in the mobile bottom nav (no `hideInNav`).
## Data Cards System
`data_cards` is a persistent array of typed card objects displayed on the TV in a **4-column × 4-row CSS grid**. Each card has `{ id, type, name, config, layout? }`.
Four card types (defined in `tv/src/types.ts` and mirrored in `mobile/src/pages/datacards.tsx`):
- **`custom_json`** — polls an external URL; `config.take` uses `$out.path[0].field` syntax (handled by `tv/src/utils/evaluatePath.ts`) to extract a value
- **`static_text`** — fixed text with font size/color
- **`clock`** — live clock (`mode: "time"`) or countdown (`mode: "timer"`) with optional `timezone` (IANA) and `target_iso`
- **`image_rotator`** — cycles through an array of image URLs
`layout` defaults to `{ grid_col: 1, grid_row: 4, col_span: 1, row_span: 1 }` when absent. `assignLayouts()` in `tv/src/App.tsx` resolves collisions deterministically via a string hash of `card.id`.
## Adding a New Content Type
1. Add state shape to `DEFAULT_STATE` in `functions/control/main.py`
2. Add `push_*` / `pull_*` handlers in `main(args)` in the same file
3. Add a TypeScript interface and `useState` in `tv/src/App.tsx`; render the popup in the fullscreen branch
1. Add state to `DEFAULT_STATE` in `functions/control/main.py`; add `push_*`/`pull_*` handlers in `main(args)`
2. Add TypeScript types to `tv/src/types.ts`
3. Add `useState` and rendering in `tv/src/App.tsx` (fullscreen branch)
4. Add a page in `mobile/src/pages/`
5. Register it in the `TABS` array in `mobile/App.tsx` with `hideInNav: true` (prevents it appearing in `BottomNav`, navigation is triggered programmatically via `navigate()`)
5. Add a tab to `TABS` in `mobile/App.tsx` with `hideInNav: true`; update the `Route` union in `mobile/src/router.tsx`
## Mobile App Conventions
- Uses a **custom in-memory router** (`mobile/src/router.tsx`) — no expo-router or react-navigation. `Route` type is a string union: `"home" | "text" | "image"`.
- `TABS` in `mobile/App.tsx` is the single source of truth for routes and pages. `BottomNav` imports `TABS` directly from `App.tsx`.
- Tabs with `hideInNav: true` are reachable only via `navigate(route)`, not from the bottom bar.
- Styling uses React Native `StyleSheet` throughout — no CSS or Tailwind.
- **Custom in-memory router** (`mobile/src/router.tsx`) — no expo-router or react-navigation. `Route = "home" | "text" | "image" | "datacards"`. History stack backed by a `useRef`; Android back button wired via `BackHandler`.
- `TABS` in `mobile/App.tsx` is the single source of truth for routes/pages. `BottomNav` imports it directly.
- `hideInNav: true` tabs are reachable only via `navigate(route)`.
- Styling uses React Native `StyleSheet` — no CSS or Tailwind.
## TV App Conventions
- Uses **Tailwind CSS v4** via `@tailwindcss/vite` (no `tailwind.config.js` config lives in `vite.config.ts`).
- Polling only starts when the browser enters fullscreen (`screenStatus === "fullscreen"`).
- State shape mirrors `DEFAULT_STATE` from the Python function exactly.
- **Tailwind CSS v4** via `@tailwindcss/vite` no `tailwind.config.js`; config lives in `vite.config.ts`.
- Polling only starts when fullscreen (`screenStatus === "fullscreen"`).
- Data cards grid uses **inline CSS grid styles** (not Tailwind) because `gridColumn`/`gridRow` values are dynamic. All card widgets wrap in the `CardShell` component (`tv/src/components/DataCardWidget.tsx`).
- State shape in `tv/src/App.tsx` must exactly mirror `DEFAULT_STATE` in the Python function.
## Dev Workflows
@@ -57,4 +89,4 @@ cd mobile && pnpm android # Android emulator
cd mobile && pnpm ios # iOS simulator
```
Package manager: **pnpm** for both `tv/` and `mobile/`. Python function has no local runner — deploy changes directly.
Package manager: **pnpm** for both `tv/` and `mobile/`. Python functions have no local runner — deploy changes directly.

View File

@@ -14,7 +14,10 @@ DEFAULT_STATE = {
"image_url": "",
"caption": ""
},
"data_cards": []
"data_cards": [],
"settings": {
"background_url": ""
}
}
MINIO_CLIENT = Minio(
@@ -97,6 +100,12 @@ def main(args):
current["data_cards"] = [c for c in current.get("data_cards", []) if c["id"] != card_id]
_write_state(current)
return {"status": "success"}
elif route == "push_settings":
bg_url = body.get("background_url", "")
current = _read_state()
current.setdefault("settings", {})["background_url"] = bg_url
_write_state(current)
return {"status": "success"}
elif route == "push_upload_images":
# Upload multiple images to MinIO; returns their public URLs (no state change)
images = body.get("images", [])

View File

@@ -1,10 +1,12 @@
import { StatusBar } from "expo-status-bar";
import { StyleSheet, View } from "react-native";
import { BottomNav } from "./src/components/BottomNav";
import { colors } from "./src/styles";
import { NotFoundPage } from "./src/pages/NotFound";
import { DataCardsPage } from "./src/pages/datacards";
import { ImagePage } from "./src/pages/image";
import { IndexPage } from "./src/pages/index";
import { SettingsPage } from "./src/pages/settings";
import { TextPage } from "./src/pages/text";
import { Route, RouterProvider, useRouter } from "./src/router";
interface Tab {
@@ -17,7 +19,13 @@ const TABS: Tab[] = [
{ label: "Home", route: "home", page: IndexPage },
{ label: "Text", route: "text", page: TextPage, hideInNav: true },
{ label: "Image", route: "image", page: ImagePage, hideInNav: true },
{ label: "Data Cards", route: "datacards", page: DataCardsPage, hideInNav: true },
{
label: "Data Cards",
route: "datacards",
page: DataCardsPage,
hideInNav: true,
},
{ label: "Settings", route: "settings", page: SettingsPage },
];
export { TABS, type Tab };
@@ -41,8 +49,10 @@ export default function App() {
return (
<RouterProvider>
<View style={styles.container}>
<StatusBar style="auto" />
<Screen />
<StatusBar style="light" />
<View style={{ flex: 1, marginTop: 12 }}>
<Screen />
</View>
<BottomNav />
</View>
</RouterProvider>
@@ -52,6 +62,6 @@ export default function App() {
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#fff",
backgroundColor: colors.bg,
},
});

View File

@@ -9,6 +9,7 @@
"web": "expo start --web"
},
"dependencies": {
"@react-native-community/datetimepicker": "^8.6.0",
"expo": "~54.0.33",
"expo-image-picker": "^55.0.10",
"expo-status-bar": "~3.0.9",

28
mobile/pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@react-native-community/datetimepicker':
specifier: ^8.6.0
version: 8.6.0(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
expo:
specifier: ~54.0.33
version: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -702,6 +705,19 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
'@react-native-community/datetimepicker@8.6.0':
resolution: {integrity: sha512-yxPSqNfxgpGaqHQIpatqe6ykeBdU/1pdsk/G3x01mY2bpTflLpmVTLqFSJYd3MiZzxNZcMs/j1dQakUczSjcYA==}
peerDependencies:
expo: '>=52.0.0'
react: '*'
react-native: '*'
react-native-windows: '*'
peerDependenciesMeta:
expo:
optional: true
react-native-windows:
optional: true
'@react-native/assets-registry@0.81.5':
resolution: {integrity: sha512-705B6x/5Kxm1RKRvSv0ADYWm5JOnoiQ1ufW7h8uu2E6G9Of/eE6hP/Ivw3U5jI16ERqZxiKQwk34VJbB0niX9w==}
engines: {node: '>= 20.19.4'}
@@ -1630,28 +1646,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -3546,6 +3558,14 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@react-native-community/datetimepicker@8.6.0(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)':
dependencies:
invariant: 2.2.4
react: 19.1.0
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
optionalDependencies:
expo: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
'@react-native/assets-registry@0.81.5': {}
'@react-native/babel-plugin-codegen@0.81.5(@babel/core@7.29.0)':

View File

@@ -1,6 +1,7 @@
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { useRouter } from "../router";
import { TABS } from "../../App";
import { colors } from "../styles";
export function BottomNav() {
@@ -32,9 +33,9 @@ const styles = StyleSheet.create({
bar: {
flexDirection: "row",
height: 74,
borderTopWidth:1,
borderTopColor: "#e5e5e5",
backgroundColor: "#fff",
borderTopWidth: 1,
borderTopColor: colors.border,
backgroundColor: colors.surface,
},
tab: {
flex: 1,
@@ -43,11 +44,11 @@ const styles = StyleSheet.create({
},
label: {
fontSize: 13,
color: "#aaa",
color: colors.textMuted,
fontWeight: "500",
},
labelActive: {
color: "#1a1a1a",
color: colors.textPrimary,
fontWeight: "700",
},
indicator: {
@@ -56,6 +57,6 @@ const styles = StyleSheet.create({
width: 32,
height: 3,
borderRadius: 2,
backgroundColor: "#1a1a1a",
backgroundColor: colors.accent,
},
});

View File

@@ -1,4 +1,5 @@
import { StyleSheet, Text, View } from "react-native";
import { colors } from "../styles";
export function NotFoundPage() {
return (
@@ -15,12 +16,12 @@ const styles = StyleSheet.create({
flex: 1,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#f9f9f9",
backgroundColor: colors.bg,
},
code: {
fontSize: 72,
fontWeight: "800",
color: "#ccc",
color: colors.border,
lineHeight: 80,
},
title: {
@@ -28,9 +29,11 @@ const styles = StyleSheet.create({
fontWeight: "600",
marginTop: 8,
marginBottom: 6,
color: colors.textPrimary,
},
subtitle: {
fontSize: 14,
color: "#999",
color: colors.textSecondary,
},
});

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,7 @@
import * as ImagePicker from "expo-image-picker";
import { useState } from "react";
import { ActivityIndicator, Button, Image, StyleSheet, Text, TextInput, View } from "react-native";
import { ActivityIndicator, Image, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native";
import { colors, shared } from "../styles";
const BASE_URL =
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
@@ -8,27 +9,12 @@ const BASE_URL =
export function ImagePage() {
const [caption, setCaption] = useState("");
const [uploading, setUploading] = useState(false);
const [dismissing, setDismissing] = useState(false);
const [previewUri, setPreviewUri] = useState<string | null>(null);
const handleTakePhoto = async () => {
const { granted } = await ImagePicker.requestCameraPermissionsAsync();
if (!granted) {
alert("Camera permission is required to take photos.");
return;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: "images",
quality: 1,
base64: true,
});
if (result.canceled) return;
const asset = result.assets[0];
const uploadAsset = async (asset: ImagePicker.ImagePickerAsset) => {
setPreviewUri(asset.uri);
setUploading(true);
try {
const res = await fetch(`${BASE_URL}/push_image`, {
method: "POST",
@@ -45,7 +31,42 @@ export function ImagePage() {
}
};
const handleTakePhoto = async () => {
const { granted } = await ImagePicker.requestCameraPermissionsAsync();
if (!granted) {
alert("Camera permission is required to take photos.");
return;
}
const result = await ImagePicker.launchCameraAsync({
mediaTypes: "images",
quality: 1,
base64: true,
});
if (result.canceled) return;
await uploadAsset(result.assets[0]);
};
const handlePickFromGallery = async () => {
const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!granted) {
alert("Media library permission is required to pick photos.");
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: "images",
quality: 1,
base64: true,
});
if (result.canceled) return;
await uploadAsset(result.assets[0]);
};
const handleDismiss = () => {
setDismissing(true);
fetch(`${BASE_URL}/push_dismiss_image`, { method: "POST" })
.then((r) => r.json())
.then((data) => {
@@ -54,19 +75,21 @@ export function ImagePage() {
.catch((error) => {
console.error("Error dismissing image:", error);
alert("Error dismissing image.");
});
})
.finally(() => setDismissing(false));
};
return (
<View style={styles.container}>
<Text style={styles.title}>Image Popup</Text>
<Text style={styles.subtitle}>Show a photo on the TV with an optional caption.</Text>
<View style={shared.screenPadded}>
<Text style={shared.pageTitle}>Image Popup</Text>
<Text style={shared.subtitle}>Show a photo on the TV with an optional caption.</Text>
<View style={styles.field}>
<Text style={styles.label}>Caption (optional)</Text>
<View style={shared.field}>
<Text style={shared.label}>Caption (optional)</Text>
<TextInput
style={styles.input}
style={shared.input}
placeholder="Add a caption..."
placeholderTextColor={colors.placeholderColor}
value={caption}
onChangeText={setCaption}
/>
@@ -77,69 +100,50 @@ export function ImagePage() {
)}
{uploading ? (
<ActivityIndicator size="large" style={{ marginTop: 8 }} />
<ActivityIndicator size="large" color={colors.accent} style={{ marginTop: 8 }} />
) : (
<View style={styles.actions}>
<View style={styles.actionBtn}>
<Button title="Take Photo & Show" onPress={handleTakePhoto} />
<>
<View style={shared.actionsRow}>
<TouchableOpacity
style={[shared.btnPrimary, shared.actionFlex]}
onPress={handleTakePhoto}
activeOpacity={0.8}
>
<Text style={shared.btnPrimaryText}>Take Photo & Show</Text>
</TouchableOpacity>
<TouchableOpacity
style={[shared.btnPrimary, shared.actionFlex]}
onPress={handlePickFromGallery}
activeOpacity={0.8}
>
<Text style={shared.btnPrimaryText}>Pick from Gallery</Text>
</TouchableOpacity>
</View>
<View style={styles.actionBtn}>
<Button title="Dismiss" onPress={handleDismiss} color="#e55" />
<View style={shared.actionsRow}>
<TouchableOpacity
style={[shared.btnDanger, shared.actionFlex, dismissing && shared.btnDisabled]}
onPress={handleDismiss}
activeOpacity={0.8}
disabled={dismissing}
>
{dismissing ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={shared.btnDangerText}>Dismiss</Text>
)}
</TouchableOpacity>
</View>
</View>
</>
)}
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
backgroundColor: "#f9f9f9",
gap: 20,
},
title: {
fontSize: 26,
fontWeight: "700",
color: "#111",
marginTop: 8,
},
subtitle: {
fontSize: 14,
color: "#888",
marginTop: -12,
},
field: {
gap: 6,
},
label: {
fontSize: 13,
fontWeight: "600",
color: "#555",
textTransform: "uppercase",
letterSpacing: 0.5,
},
input: {
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 10,
padding: 12,
fontSize: 16,
backgroundColor: "#fff",
},
preview: {
width: "100%",
height: 200,
borderRadius: 10,
borderRadius: 12,
resizeMode: "cover",
},
actions: {
flexDirection: "row",
gap: 12,
marginTop: 4,
},
actionBtn: {
flex: 1,
},
});

View File

@@ -1,31 +1,32 @@
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
import { useRouter } from "../router";
import { colors, shared } from "../styles";
export function IndexPage() {
const { navigate } = useRouter();
return (
<View style={styles.container}>
<Text style={styles.title}>TV Control</Text>
<Text style={styles.subtitle}>Choose what to send to the TV.</Text>
<View style={shared.screenPadded}>
<Text style={shared.pageTitleLarge}>TV Control</Text>
<Text style={shared.subtitle}>Your TV, Your Control.</Text>
<View style={styles.cards}>
<TouchableOpacity style={styles.card} onPress={() => navigate("text")} activeOpacity={0.8}>
<Text style={styles.cardIcon}>💬</Text>
<Text style={styles.cardTitle}>Text Popup</Text>
<Text style={styles.cardDesc}>Display a message on the TV screen.</Text>
<TouchableOpacity style={shared.card} onPress={() => navigate("text")} activeOpacity={0.7}>
<Text style={shared.cardIcon}>💬</Text>
<Text style={shared.cardTitle}>Text Popup</Text>
<Text style={shared.cardDesc}>Display a message on the TV screen.</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.card} onPress={() => navigate("image")} activeOpacity={0.8}>
<Text style={styles.cardIcon}>📷</Text>
<Text style={styles.cardTitle}>Image Popup</Text>
<Text style={styles.cardDesc}>Take a photo and show it on the TV.</Text>
<TouchableOpacity style={shared.card} onPress={() => navigate("image")} activeOpacity={0.7}>
<Text style={shared.cardIcon}>📷</Text>
<Text style={shared.cardTitle}>Image Popup</Text>
<Text style={shared.cardDesc}>Take a photo and show it on the TV.</Text>
</TouchableOpacity>
<TouchableOpacity style={styles.card} onPress={() => navigate("datacards")} activeOpacity={0.8}>
<Text style={styles.cardIcon}>📊</Text>
<Text style={styles.cardTitle}>Data Cards</Text>
<Text style={styles.cardDesc}>Display live data from custom JSON sources on the TV.</Text>
<TouchableOpacity style={shared.card} onPress={() => navigate("datacards")} activeOpacity={0.7}>
<Text style={shared.cardIcon}>📊</Text>
<Text style={shared.cardTitle}>Data Cards</Text>
<Text style={shared.cardDesc}>Display live data from custom JSON sources on the TV.</Text>
</TouchableOpacity>
</View>
</View>
@@ -33,50 +34,9 @@ export function IndexPage() {
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
backgroundColor: "#f9f9f9",
gap: 20,
},
title: {
fontSize: 30,
fontWeight: "700",
color: "#111",
marginTop: 8,
},
subtitle: {
fontSize: 15,
color: "#888",
marginTop: -12,
},
cards: {
gap: 16,
gap: 14,
marginTop: 8,
},
card: {
backgroundColor: "#fff",
borderRadius: 16,
borderWidth: 1,
borderColor: "#e8e8e8",
padding: 20,
gap: 6,
shadowColor: "#000",
shadowOffset: { width: 0, height: 1 },
shadowOpacity: 0.06,
shadowRadius: 4,
elevation: 2,
},
cardIcon: {
fontSize: 28,
},
cardTitle: {
fontSize: 18,
fontWeight: "600",
color: "#111",
},
cardDesc: {
fontSize: 14,
color: "#888",
},
});

View File

@@ -0,0 +1,286 @@
import * as ImagePicker from "expo-image-picker";
import { useEffect, useState } from "react";
import {
ActivityIndicator,
Image,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { colors, shared } from "../styles";
const BASE_URL =
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
// ─── Settings page ────────────────────────────────────────────────────────────
export function SettingsPage() {
const [currentBgUrl, setCurrentBgUrl] = useState<string>("");
const [pendingUri, setPendingUri] = useState<string | null>(null);
const [pendingBase64, setPendingBase64] = useState<string | null>(null);
const [pendingExt, setPendingExt] = useState<string>("jpg");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [clearing, setClearing] = useState(false);
const [status, setStatus] = useState<string | null>(null);
// ── Load current settings on mount
useEffect(() => {
setLoading(true);
fetch(`${BASE_URL}/pull_full`, { method: "POST" })
.then((r) => r.json())
.then((data) => {
const bg = data.state?.settings?.background_url ?? "";
setCurrentBgUrl(bg);
})
.catch(() => {})
.finally(() => setLoading(false));
}, []);
// ── Pick a portrait image from the gallery
const handlePickBackground = async () => {
const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!granted) {
setStatus("Media library permission is required.");
return;
}
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: "images",
quality: 0.85,
base64: true,
});
if (result.canceled) return;
const asset = result.assets[0];
// Enforce portrait orientation (height must exceed width)
if (asset.width >= asset.height) {
setStatus("Please pick a portrait image (height must be greater than width).");
return;
}
const ext = (asset.uri.split(".").pop() ?? "jpg").toLowerCase();
setPendingUri(asset.uri);
setPendingBase64(asset.base64 ?? null);
setPendingExt(ext);
setStatus(null);
};
// ── Upload pending image and save as background
const handleSave = async () => {
if (!pendingBase64) return;
setSaving(true);
setStatus(null);
try {
// Upload to MinIO via push_upload_images
const uploadRes = await fetch(`${BASE_URL}/push_upload_images`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ images: [{ image_b64: pendingBase64, ext: pendingExt }] }),
});
const uploadData = await uploadRes.json();
if (uploadData.status !== "success" || !uploadData.urls?.[0]) {
throw new Error(uploadData.message ?? "Upload failed");
}
const url: string = uploadData.urls[0];
// Persist as background URL
const settingsRes = await fetch(`${BASE_URL}/push_settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ background_url: url }),
});
const settingsData = await settingsRes.json();
if (settingsData.status !== "success") {
throw new Error(settingsData.message ?? "Save failed");
}
setCurrentBgUrl(url);
setPendingUri(null);
setPendingBase64(null);
setStatus("Background saved!");
} catch (err) {
setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setSaving(false);
}
};
// ── Clear the TV background
const handleClear = async () => {
setClearing(true);
setStatus(null);
try {
const res = await fetch(`${BASE_URL}/push_settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ background_url: "" }),
});
const data = await res.json();
if (data.status !== "success") throw new Error(data.message ?? "Clear failed");
setCurrentBgUrl("");
setPendingUri(null);
setPendingBase64(null);
setStatus("Background cleared.");
} catch (err) {
setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally {
setClearing(false);
}
};
const previewUri = pendingUri ?? (currentBgUrl || null);
const hasPending = !!pendingUri;
const hasCurrent = !!currentBgUrl;
return (
<ScrollView
style={shared.screen}
contentContainerStyle={styles.container}
keyboardShouldPersistTaps="handled"
>
<Text style={shared.pageTitle}>Settings</Text>
<Text style={shared.subtitle}>Configure global TV display options.</Text>
{/* ── Background Image ── */}
<View style={styles.section}>
<Text style={shared.sectionLabel}>TV Background</Text>
<Text style={shared.hint}>
Displayed behind all content on the TV. Must be a portrait image (taller than wide).
</Text>
{loading ? (
<ActivityIndicator color={colors.accent} style={{ marginTop: 12 }} />
) : (
<>
{/* Preview */}
{previewUri && (
<View style={styles.previewWrap}>
<Image source={{ uri: previewUri }} style={styles.preview} resizeMode="cover" />
{hasPending && (
<View style={styles.pendingBadge}>
<Text style={styles.pendingBadgeText}>Unsaved</Text>
</View>
)}
</View>
)}
{!previewUri && (
<View style={styles.emptyPreview}>
<Text style={styles.emptyPreviewText}>No background set</Text>
</View>
)}
{/* Actions */}
<View style={shared.actionsRow}>
<TouchableOpacity
style={[shared.btnSecondary, shared.actionFlex]}
onPress={handlePickBackground}
activeOpacity={0.8}
>
<Text style={shared.btnSecondaryText}>
{hasCurrent || hasPending ? "Replace" : "Pick Image"}
</Text>
</TouchableOpacity>
{hasPending && (
<TouchableOpacity
style={[shared.btnPrimary, shared.actionFlex, saving && shared.btnDisabled]}
onPress={handleSave}
activeOpacity={0.8}
disabled={saving}
>
{saving ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={shared.btnPrimaryText}>Save</Text>
)}
</TouchableOpacity>
)}
</View>
{(hasCurrent || hasPending) && (
<TouchableOpacity
style={[shared.btnDanger, clearing && shared.btnDisabled]}
onPress={handleClear}
activeOpacity={0.8}
disabled={clearing}
>
{clearing ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={shared.btnDangerText}>Clear Background</Text>
)}
</TouchableOpacity>
)}
{status && (
<Text style={[shared.hint, styles.statusText]}>{status}</Text>
)}
</>
)}
</View>
</ScrollView>
);
}
const styles = StyleSheet.create({
container: {
padding: 24,
gap: 20,
paddingBottom: 40,
},
section: {
gap: 12,
},
previewWrap: {
borderRadius: 14,
overflow: "hidden",
borderWidth: 1,
borderColor: colors.border,
position: "relative",
},
preview: {
width: "100%",
aspectRatio: 9 / 16,
},
pendingBadge: {
position: "absolute",
top: 10,
right: 10,
backgroundColor: colors.accent,
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 4,
},
pendingBadgeText: {
color: "#fff",
fontSize: 11,
fontWeight: "700",
textTransform: "uppercase",
letterSpacing: 0.5,
},
emptyPreview: {
width: "100%",
aspectRatio: 9 / 16,
borderRadius: 14,
borderWidth: 1,
borderColor: colors.border,
borderStyle: "dashed",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.surface,
},
emptyPreviewText: {
color: colors.textMuted,
fontSize: 14,
},
statusText: {
marginTop: 4,
color: colors.textSecondary,
},
});

View File

@@ -1,18 +1,21 @@
import { useState } from "react";
import { Button, StyleSheet, Text, TextInput, View } from "react-native";
import { ActivityIndicator, Text, TextInput, TouchableOpacity, View } from "react-native";
import { colors, shared } from "../styles";
const BASE_URL =
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
export function TextPage() {
const [title, setTitle] = useState("");
const [sending, setSending] = useState(false);
const [dismissing, setDismissing] = useState(false);
const handleShow = () => {
if (title.trim() === "") {
alert("Please enter some text before sending.");
return;
}
setSending(true);
fetch(`${BASE_URL}/push_text`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -25,10 +28,12 @@ export function TextPage() {
.catch((error) => {
console.error("Error sending text:", error);
alert("Error sending text.");
});
})
.finally(() => setSending(false));
};
const handleDismiss = () => {
setDismissing(true);
fetch(`${BASE_URL}/push_dismiss_text`, { method: "POST" })
.then((r) => r.json())
.then((data) => {
@@ -37,81 +42,53 @@ export function TextPage() {
.catch((error) => {
console.error("Error dismissing text:", error);
alert("Error dismissing text.");
});
})
.finally(() => setDismissing(false));
};
return (
<View style={styles.container}>
<Text style={styles.title}>Text Popup</Text>
<Text style={styles.subtitle}>Display a text message on the TV.</Text>
<View style={shared.screenPadded}>
<Text style={shared.pageTitle}>Text Popup</Text>
<Text style={shared.subtitle}>Display a text message on the TV.</Text>
<View style={styles.field}>
<Text style={styles.label}>Message</Text>
<View style={shared.field}>
<Text style={shared.label}>Message</Text>
<TextInput
style={styles.input}
style={[shared.input, shared.inputMultiline]}
placeholder="Type something..."
placeholderTextColor={colors.placeholderColor}
value={title}
onChangeText={setTitle}
multiline
/>
</View>
<View style={styles.actions}>
<View style={styles.actionBtn}>
<Button title="Show on TV" onPress={handleShow} />
</View>
<View style={styles.actionBtn}>
<Button title="Dismiss" onPress={handleDismiss} color="#e55" />
</View>
<View style={shared.actionsRow}>
<TouchableOpacity
style={[shared.btnPrimary, shared.actionFlex, sending && shared.btnDisabled]}
onPress={handleShow}
activeOpacity={0.8}
disabled={sending}
>
{sending ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={shared.btnPrimaryText}>Show on TV</Text>
)}
</TouchableOpacity>
<TouchableOpacity
style={[shared.btnDanger, shared.actionFlex, dismissing && shared.btnDisabled]}
onPress={handleDismiss}
activeOpacity={0.8}
disabled={dismissing}
>
{dismissing ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={shared.btnDangerText}>Dismiss</Text>
)}
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 24,
backgroundColor: "#f9f9f9",
gap: 20,
},
title: {
fontSize: 26,
fontWeight: "700",
color: "#111",
marginTop: 8,
},
subtitle: {
fontSize: 14,
color: "#888",
marginTop: -12,
},
field: {
gap: 6,
},
label: {
fontSize: 13,
fontWeight: "600",
color: "#555",
textTransform: "uppercase",
letterSpacing: 0.5,
},
input: {
borderWidth: 1,
borderColor: "#ddd",
borderRadius: 10,
padding: 12,
fontSize: 16,
backgroundColor: "#fff",
minHeight: 80,
textAlignVertical: "top",
},
actions: {
flexDirection: "row",
gap: 12,
marginTop: 4,
},
actionBtn: {
flex: 1,
},
});

View File

@@ -1,19 +1,43 @@
import React, { createContext, useContext, useState } from "react";
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
import { BackHandler } from "react-native";
export type Route = "home" | "text" | "image" | "datacards";
export type Route = "home" | "text" | "image" | "datacards" | "settings";
interface RouterContextValue {
route: Route;
navigate: (to: Route) => void;
goBack: () => boolean;
}
const RouterContext = createContext<RouterContextValue | null>(null);
export function RouterProvider({ children }: { children: React.ReactNode }) {
const [route, setRoute] = useState<Route>("home");
const history = useRef<Route[]>([]);
const navigate = useCallback((to: Route) => {
setRoute((prev) => {
history.current.push(prev);
return to;
});
}, []);
const goBack = useCallback((): boolean => {
const prev = history.current.pop();
if (prev !== undefined) {
setRoute(prev);
return true;
}
return false;
}, []);
useEffect(() => {
const sub = BackHandler.addEventListener("hardwareBackPress", goBack);
return () => sub.remove();
}, [goBack]);
return (
<RouterContext.Provider value={{ route, navigate: setRoute }}>
<RouterContext.Provider value={{ route, navigate, goBack }}>
{children}
</RouterContext.Provider>
);

203
mobile/src/styles.ts Normal file
View File

@@ -0,0 +1,203 @@
import { StyleSheet } from "react-native";
// ─── Design Tokens ─────────────────────────────────────────────────────────────
export const colors = {
bg: "#0d0d0d",
surface: "#1a1a1a",
surfaceElevated: "#222",
border: "#2e2e2e",
borderSubtle: "#232323",
textPrimary: "#f0f0f0",
textSecondary: "#888",
textMuted: "#505050",
placeholderColor: "#666",
accent: "#7c6fff",
accentDim: "#3a3570",
danger: "#ff453a",
dangerBg: "#2a1111",
dangerBorder: "#5a2020",
dangerText: "#ff6b63",
} as const;
// ─── Shared Styles ─────────────────────────────────────────────────────────────
export const shared = StyleSheet.create({
// ── Screens
screen: {
flex: 1,
backgroundColor: colors.bg,
},
screenPadded: {
flex: 1,
backgroundColor: colors.bg,
padding: 24,
gap: 20,
},
// ── Typography
pageTitle: {
fontSize: 28,
fontWeight: "700",
color: colors.textPrimary,
marginTop: 8,
},
pageTitleLarge: {
fontSize: 32,
fontWeight: "800",
color: colors.textPrimary,
marginTop: 8,
},
subtitle: {
fontSize: 14,
color: colors.textSecondary,
marginTop: -12,
},
label: {
fontSize: 13,
fontWeight: "600",
color: colors.textMuted,
textTransform: "uppercase",
letterSpacing: 0.6,
},
sectionLabel: {
fontSize: 11,
fontWeight: "700",
color: colors.textMuted,
textTransform: "uppercase",
letterSpacing: 1.2,
marginTop: 8,
marginBottom: -4,
},
hint: {
fontSize: 12,
color: colors.textMuted,
lineHeight: 17,
},
code: {
fontFamily: "monospace",
fontSize: 12,
color: colors.accent,
},
// ── Form
field: {
gap: 6,
},
input: {
backgroundColor: colors.surface,
borderRadius: 10,
borderWidth: 1,
borderColor: colors.border,
paddingHorizontal: 14,
paddingVertical: 11,
fontSize: 15,
color: colors.textPrimary,
},
inputMultiline: {
minHeight: 80,
textAlignVertical: "top",
paddingTop: 10,
},
row: {
flexDirection: "row",
gap: 12,
},
flex1: {
flex: 1,
},
// ── Buttons
btnPrimary: {
backgroundColor: colors.accent,
borderRadius: 14,
paddingVertical: 14,
alignItems: "center",
},
btnPrimaryText: {
fontSize: 16,
fontWeight: "600",
color: "#fff",
},
btnDanger: {
backgroundColor: colors.dangerBg,
borderRadius: 14,
paddingVertical: 14,
alignItems: "center",
borderWidth: 1,
borderColor: colors.dangerBorder,
},
btnDangerText: {
fontSize: 16,
fontWeight: "600",
color: colors.dangerText,
},
btnSecondary: {
backgroundColor: colors.surface,
borderRadius: 14,
paddingVertical: 14,
alignItems: "center",
borderWidth: 1,
borderColor: colors.border,
},
btnSecondaryText: {
fontSize: 16,
fontWeight: "600",
color: colors.textPrimary,
},
btnDisabled: {
opacity: 0.45,
},
actionsRow: {
flexDirection: "row",
gap: 12,
marginTop: 4,
},
actionFlex: {
flex: 1,
},
// ── Page header (with back button)
pageHeader: {
paddingTop: 16,
paddingHorizontal: 24,
paddingBottom: 4,
gap: 4,
},
backBtn: {
alignSelf: "flex-start",
paddingVertical: 4,
},
backBtnText: {
fontSize: 14,
color: colors.accent,
fontWeight: "500",
},
// ── Cards
card: {
backgroundColor: colors.surface,
borderRadius: 16,
borderWidth: 1,
borderColor: colors.border,
padding: 20,
gap: 6,
shadowColor: "#000",
shadowOffset: { width: 0, height: 2 },
shadowOpacity: 0.35,
shadowRadius: 6,
elevation: 4,
},
cardIcon: {
fontSize: 28,
},
cardTitle: {
fontSize: 18,
fontWeight: "600",
color: colors.textPrimary,
},
cardDesc: {
fontSize: 14,
color: colors.textSecondary,
},
});

View File

@@ -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>
);
}

View 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} />;
}

View 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>
);
}

View 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>
);
}

View 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
View 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; // 14
grid_row: number; // 14
col_span: number; // 14
row_span: number; // 14
}
// ─── 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;

View 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;
}