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.