From eb4e7ea26a46730e425dc6955a9147a1d10ef2ea Mon Sep 17 00:00:00 2001 From: space Date: Sun, 1 Mar 2026 17:53:41 +0100 Subject: [PATCH] feat: implement background color and night mode settings in the TV control app --- docker-compose.yml | 23 ++ functions/control/main.py | 21 +- mobile/src/pages/settings.tsx | 443 ++++++++++++++++++++++++--- tv/src/App.tsx | 86 +++++- tv/src/components/DataCardWidget.tsx | 22 +- tv/src/types.ts | 18 +- 6 files changed, 541 insertions(+), 72 deletions(-) create mode 100644 docker-compose.yml diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2d5e803 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,23 @@ +services: + tv: + image: node:22 + working_dir: /app + volumes: + - ./tv:/app + - tv_node_modules:/app/node_modules + ports: + - "4173:4173" + command: > + sh -c " + npm install -g pnpm --silent && + pnpm install && + pnpm build && + IP=$(hostname -I | awk '{print $1}') && + echo \"\" && + echo \"==> TV UI available at http://$$IP:4173\" && + echo \"\" && + pnpm preview --host 0.0.0.0 --port 4173 + " + +volumes: + tv_node_modules: diff --git a/functions/control/main.py b/functions/control/main.py index 073e8eb..1563cb9 100644 --- a/functions/control/main.py +++ b/functions/control/main.py @@ -18,7 +18,15 @@ DEFAULT_STATE = { }, "data_cards": [], "settings": { - "background_url": "" + "background_url": "", + "background_color": "#000000", + "night_mode": { + "enabled": False, + "start_time": "22:00", + "end_time": "07:00", + "message": "Good Night", + "dim_background": True + } } } @@ -106,9 +114,16 @@ def main(args): _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 + settings = current.setdefault("settings", {}) + # Merge scalar settings keys + for key in ["background_url", "background_color"]: + if key in body: + settings[key] = body[key] + # Deep-merge night_mode sub-object + if "night_mode" in body: + nm = settings.setdefault("night_mode", {}) + nm.update(body["night_mode"]) _write_state(current) return {"status": "success"} elif route == "push_upload_images": diff --git a/mobile/src/pages/settings.tsx b/mobile/src/pages/settings.tsx index 47033df..1d12a0c 100644 --- a/mobile/src/pages/settings.tsx +++ b/mobile/src/pages/settings.tsx @@ -5,7 +5,9 @@ import { Image, ScrollView, StyleSheet, + Switch, Text, + TextInput, TouchableOpacity, View, } from "react-native"; @@ -14,13 +16,52 @@ import { colors, shared } from "../styles"; const BASE_URL = "https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329"; +// ─── Preset background colours ──────────────────────────────────────────────── + +const COLOR_PRESETS = [ + { label: "Black", value: "#000000" }, + { label: "Dark Navy", value: "#0a0a1a" }, + { label: "Dark Blue", value: "#0d1b2a" }, + { label: "Dark Green", value: "#0a1a0a" }, + { label: "Dark Purple", value: "#1a0a2e" }, + { label: "Dark Red", value: "#1a0505" }, + { label: "Charcoal", value: "#1a1a1a" }, + { label: "Slate", value: "#1e2530" }, +]; + +// ─── Helpers ─────────────────────────────────────────────────────────────────── + +function isValidTime(t: string) { + return /^\d{2}:\d{2}$/.test(t); +} + +function isValidHex(c: string) { + return /^#[0-9a-fA-F]{6}$/.test(c); +} + // ─── Settings page ──────────────────────────────────────────────────────────── export function SettingsPage() { + // ── Background image const [currentBgUrl, setCurrentBgUrl] = useState(""); const [pendingUri, setPendingUri] = useState(null); const [pendingBase64, setPendingBase64] = useState(null); const [pendingExt, setPendingExt] = useState("jpg"); + + // ── Background colour + const [bgColor, setBgColor] = useState("#000000"); + const [bgColorInput, setBgColorInput] = useState("#000000"); + const [savingColor, setSavingColor] = useState(false); + + // ── Night mode + const [nightEnabled, setNightEnabled] = useState(false); + const [nightStart, setNightStart] = useState("22:00"); + const [nightEnd, setNightEnd] = useState("07:00"); + const [nightMessage, setNightMessage] = useState("Good Night"); + const [nightDim, setNightDim] = useState(true); + const [savingNight, setSavingNight] = useState(false); + + // ── Shared const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [clearing, setClearing] = useState(false); @@ -32,14 +73,28 @@ export function SettingsPage() { fetch(`${BASE_URL}/pull_full`, { method: "POST" }) .then((r) => r.json()) .then((data) => { - const bg = data.state?.settings?.background_url ?? ""; - setCurrentBgUrl(bg); + const s = data.state?.settings ?? {}; + setCurrentBgUrl(s.background_url ?? ""); + + const col = s.background_color ?? "#000000"; + setBgColor(col); + setBgColorInput(col); + + const nm = s.night_mode ?? {}; + setNightEnabled(nm.enabled ?? false); + setNightStart(nm.start_time ?? "22:00"); + setNightEnd(nm.end_time ?? "07:00"); + setNightMessage(nm.message ?? "Good Night"); + setNightDim(nm.dim_background ?? true); }) .catch(() => {}) .finally(() => setLoading(false)); }, []); - // ── Pick a landscape image from the gallery + // ────────────────────────────────────────────────────────────────────────────── + // Background image handlers + // ────────────────────────────────────────────────────────────────────────────── + const handlePickBackground = async () => { const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync(); if (!granted) { @@ -75,12 +130,11 @@ export function SettingsPage() { }; // ── Upload pending image and save as background - const handleSave = async () => { + const handleSaveBgImage = async () => { if (!pendingBase64) { setStatus("No image selected."); 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" }, @@ -92,21 +146,18 @@ export function SettingsPage() { } 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"); - } + if (settingsData.status !== "success") throw new Error(settingsData.message ?? "Save failed"); setCurrentBgUrl(url); setPendingUri(null); setPendingBase64(null); - setStatus("Background saved!"); + setStatus("Background image saved!"); } catch (err) { setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`); } finally { @@ -114,8 +165,8 @@ export function SettingsPage() { } }; - // ── Clear the TV background - const handleClear = async () => { + // ── Clear the TV background image + const handleClearBgImage = async () => { setClearing(true); setStatus(null); try { @@ -129,7 +180,7 @@ export function SettingsPage() { setCurrentBgUrl(""); setPendingUri(null); setPendingBase64(null); - setStatus("Background cleared."); + setStatus("Background image cleared."); } catch (err) { setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`); } finally { @@ -137,6 +188,71 @@ export function SettingsPage() { } }; + // ────────────────────────────────────────────────────────────────────────────── + // Background colour handlers + // ────────────────────────────────────────────────────────────────────────────── + + const handleSaveBgColor = async (colorValue?: string) => { + const col = colorValue ?? bgColorInput.trim(); + if (!isValidHex(col)) { + setStatus("Enter a valid hex colour, e.g. #1a2b3c"); + return; + } + setSavingColor(true); + setStatus(null); + try { + const res = await fetch(`${BASE_URL}/push_settings`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ background_color: col }), + }); + const data = await res.json(); + if (data.status !== "success") throw new Error(data.message ?? "Save failed"); + setBgColor(col); + setBgColorInput(col); + setStatus("Background colour saved!"); + } catch (err) { + setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setSavingColor(false); + } + }; + + // ────────────────────────────────────────────────────────────────────────────── + // Night mode handler + // ────────────────────────────────────────────────────────────────────────────── + + const handleSaveNightMode = async () => { + if (!isValidTime(nightStart) || !isValidTime(nightEnd)) { + setStatus("Night mode times must be in HH:MM format."); + return; + } + setSavingNight(true); + setStatus(null); + try { + const res = await fetch(`${BASE_URL}/push_settings`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + night_mode: { + enabled: nightEnabled, + start_time: nightStart, + end_time: nightEnd, + message: nightMessage, + dim_background: nightDim, + }, + }), + }); + const data = await res.json(); + if (data.status !== "success") throw new Error(data.message ?? "Save failed"); + setStatus("Night mode saved!"); + } catch (err) { + setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`); + } finally { + setSavingNight(false); + } + }; + const previewUri = pendingUri ?? (currentBgUrl || null); const hasPending = !!pendingUri; const hasCurrent = !!currentBgUrl; @@ -150,19 +266,18 @@ export function SettingsPage() { Settings Configure global TV display options. - {/* ── Background Image ── */} - - TV Background - - Displayed behind all content on the TV. Must be a landscape image (wider than tall). - + {loading ? ( + + ) : ( + <> + {/* ── Background Image ───────────────────────────────────────────── */} + + TV Background Image + + Displayed behind all content. Must be landscape (wider than tall). + - {loading ? ( - - ) : ( - <> - {/* Preview */} - {previewUri && ( + {previewUri ? ( {hasPending && ( @@ -171,15 +286,12 @@ export function SettingsPage() { )} - )} - - {!previewUri && ( + ) : ( - No background set + No background image set )} - {/* Actions */} - {saving ? ( - - ) : ( - Save - )} + {saving ? : Save} )} @@ -210,24 +318,165 @@ export function SettingsPage() { {(hasCurrent || hasPending) && ( - {clearing ? ( - - ) : ( - Clear Background - )} + {clearing ? : Clear Image} )} + - {status && ( - {status} - )} - - )} - + {/* ── Background Colour ──────────────────────────────────────────── */} + + Background Colour + + Solid colour shown when no background image is set (or when night mode dims the display). + + + + + + handleSaveBgColor()} + activeOpacity={0.8} + disabled={savingColor} + > + {savingColor ? : Apply} + + + + + {COLOR_PRESETS.map((p) => ( + { setBgColorInput(p.value); handleSaveBgColor(p.value); }} + activeOpacity={0.75} + style={[ + styles.swatchBtn, + { backgroundColor: p.value }, + bgColor === p.value && styles.swatchSelected, + ]} + /> + ))} + + + {COLOR_PRESETS.map((p) => ( + {p.label} + ))} + + + + {/* ── Night Mode ─────────────────────────────────────────────────── */} + + Night Mode + + Automatically switch the TV to a quiet night display between set hours. + + + + Enable Night Mode + + + + + + + Start (24h) + + + + + End (24h) + + + + + + Night Message + + Shown on image rotator cards and the idle screen during night mode. + + + + + + + Dim Background + + Hide background image/colour and darken the display. + + + + + + + + {savingNight ? ( + + ) : ( + Save Night Mode + )} + + + + {/* ── Status message ─────────────────────────────────────────────── */} + {status && ( + {status} + )} + + )} ); } @@ -235,12 +484,14 @@ export function SettingsPage() { const styles = StyleSheet.create({ container: { padding: 24, - gap: 20, - paddingBottom: 40, + gap: 28, + paddingBottom: 48, }, section: { gap: 12, }, + + // ── Background image preview previewWrap: { borderRadius: 14, overflow: "hidden", @@ -283,8 +534,98 @@ const styles = StyleSheet.create({ color: colors.textMuted, fontSize: 14, }, + + // ── Background colour + colorRow: { + flexDirection: "row", + alignItems: "center", + gap: 10, + }, + colorSwatch: { + width: 44, + height: 44, + borderRadius: 10, + borderWidth: 1, + borderColor: colors.border, + }, + colorSaveBtn: { + paddingHorizontal: 16, + height: 44, + }, + swatchRow: { + flexDirection: "row", + gap: 8, + }, + swatchBtn: { + flex: 1, + aspectRatio: 1, + borderRadius: 8, + borderWidth: 1, + borderColor: colors.border, + }, + swatchSelected: { + borderWidth: 2, + borderColor: colors.accent, + }, + swatchLabelsRow: { + flexDirection: "row", + gap: 8, + }, + swatchLabel: { + flex: 1, + fontSize: 9, + color: colors.textMuted, + textAlign: "center", + }, + + // ── Night mode + toggleRow: { + flexDirection: "row", + alignItems: "center", + justifyContent: "space-between", + gap: 12, + }, + toggleLabel: { + fontSize: 15, + fontWeight: "600", + color: colors.textPrimary, + flex: 1, + }, + nightFields: { + gap: 16, + borderLeftWidth: 2, + borderLeftColor: colors.accentDim, + paddingLeft: 14, + }, + nightFieldsDisabled: { + opacity: 0.45, + }, + timeRow: { + flexDirection: "row", + alignItems: "flex-end", + gap: 8, + }, + timeField: { + flex: 1, + gap: 6, + }, + timeArrow: { + color: colors.textMuted, + fontSize: 18, + paddingBottom: 10, + }, + fieldLabel: { + fontSize: 12, + fontWeight: "600", + color: colors.textMuted, + textTransform: "uppercase", + letterSpacing: 0.4, + marginBottom: 2, + }, + + // ── Status statusText: { - marginTop: 4, color: colors.textSecondary, + textAlign: "center", }, }); diff --git a/tv/src/App.tsx b/tv/src/App.tsx index be87b22..9c0f265 100644 --- a/tv/src/App.tsx +++ b/tv/src/App.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from "react"; -import type { CardLayout, DataCard, ImagePopupState, SettingsState, TextState } from "./types"; +import type { CardLayout, DataCard, ImagePopupState, NightModeSettings, SettingsState, TextState } from "./types"; import { DataCardWidget } from "./components/DataCardWidget"; import { TextPopup } from "./components/TextPopup"; import { ImagePopup } from "./components/ImagePopup"; @@ -58,13 +58,44 @@ function assignLayouts(cards: DataCard[]): Array<{ card: DataCard; resolvedLayou }); } +function isInNightWindow(nm: NightModeSettings): boolean { + if (!nm.enabled) return false; + const now = new Date(); + const nowMins = now.getHours() * 60 + now.getMinutes(); + const [sh, sm] = nm.start_time.split(":").map(Number); + const [eh, em] = nm.end_time.split(":").map(Number); + const startMins = (sh || 0) * 60 + (sm || 0); + const endMins = (eh || 0) * 60 + (em || 0); + if (startMins <= endMins) { + return nowMins >= startMins && nowMins < endMins; + } + // Overnight range (e.g. 22:00 → 07:00) + return nowMins >= startMins || nowMins < endMins; +} + +const DEFAULT_NIGHT_MODE: NightModeSettings = { + enabled: false, + start_time: "22:00", + end_time: "07:00", + message: "Good Night", + dim_background: true, +}; + +const DEFAULT_SETTINGS: SettingsState = { + background_url: "", + background_color: "#000000", + night_mode: DEFAULT_NIGHT_MODE, +}; + function App() { const [screenStatus, setScreenStatus] = useState<"notfullscreen" | "fullscreen">("notfullscreen"); const [textState, setTextState] = useState({ showing: false, title: "" }); const [imagePopup, setImagePopup] = useState({ showing: false, image_url: "", caption: "" }); const [dataCards, setDataCards] = useState([]); - const [settings, setSettings] = useState({ background_url: "" }); + const [settings, setSettings] = useState(DEFAULT_SETTINGS); const [fetchError, setFetchError] = useState(false); + // Re-evaluate night mode every minute + const [nowMinute, setNowMinute] = useState(() => Math.floor(Date.now() / 60000)); useEffect(() => { const handleFullscreenChange = () => { @@ -74,6 +105,18 @@ function App() { return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); }, []); + // Tick every minute to re-evaluate night mode window + useEffect(() => { + const timer = setInterval(() => setNowMinute(Math.floor(Date.now() / 60000)), 60000); + return () => clearInterval(timer); + }, []); + + const isNightActive = useMemo( + () => isInNightWindow(settings.night_mode ?? DEFAULT_NIGHT_MODE), + // eslint-disable-next-line react-hooks/exhaustive-deps + [nowMinute, settings.night_mode], + ); + useEffect(() => { if (screenStatus === "fullscreen") { const handlePullState = () => { @@ -86,7 +129,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: "" }); + setSettings({ ...DEFAULT_SETTINGS, ...(state.settings ?? {}), night_mode: { ...DEFAULT_NIGHT_MODE, ...(state.settings?.night_mode ?? {}) } }); setFetchError(false); }) .catch((error) => { @@ -112,10 +155,13 @@ function App() { return ; } + const dimBackground = isNightActive && (settings.night_mode?.dim_background ?? true); + const bgColor = dimBackground ? "#050505" : (settings.background_color || "#000000"); + const showBgImage = !dimBackground && !!settings.background_url; const isIdle = !textState.showing && !imagePopup.showing && dataCards.length === 0; return ( -
+
{fetchError && (
! @@ -123,7 +169,7 @@ function App() {

Retrying every 5 seconds…

)} - {settings.background_url && ( + {showBgImage && ( )} - + {!dimBackground && } {isIdle && (
-

- Waiting for content - . - . - . -

+ {isNightActive ? ( +
+ 🌙 +

+ {settings.night_mode?.message || "Good Night"} +

+
+ ) : ( +

+ Waiting for content + . + . + . +

+ )}
)} @@ -164,7 +219,12 @@ function App() { minHeight: 0, }} > - +
))}
diff --git a/tv/src/components/DataCardWidget.tsx b/tv/src/components/DataCardWidget.tsx index 619c115..c68f5c6 100644 --- a/tv/src/components/DataCardWidget.tsx +++ b/tv/src/components/DataCardWidget.tsx @@ -234,12 +234,13 @@ function ClockWidget({ card, layout }: { card: ClockCard; layout?: CardLayout }) // ─── image_rotator widget ───────────────────────────────────────────────────── -function ImageRotatorWidget({ card }: { card: ImageRotatorCard }) { +function ImageRotatorWidget({ card, isNightMode, nightMessage }: { card: ImageRotatorCard; isNightMode?: boolean; nightMessage?: string }) { const images = card.config.images ?? []; const [index, setIndex] = useState(0); const [fade, setFade] = useState(true); useEffect(() => { + if (isNightMode) return; if (images.length <= 1) return; const ms = Math.max(2000, (card.config.interval ?? 10) * 1000); const timer = setInterval(() => { @@ -250,7 +251,20 @@ function ImageRotatorWidget({ card }: { card: ImageRotatorCard }) { }, 400); }, ms); return () => clearInterval(timer); - }, [images.length, card.config.interval]); + }, [images.length, card.config.interval, isNightMode]); + + // ── Night mode overlay ──────────────────────────────────────────────────── + if (isNightMode) { + return ( +
+ 🌙 + + {nightMessage || "Good Night"} + + {card.name} +
+ ); + } if (images.length === 0) { return ( @@ -292,10 +306,10 @@ function ImageRotatorWidget({ card }: { card: ImageRotatorCard }) { // ─── Dispatcher ─────────────────────────────────────────────────────────────── -export function DataCardWidget({ card, layout }: { card: DataCard; layout?: CardLayout }) { +export function DataCardWidget({ card, layout, isNightMode, nightMessage }: { card: DataCard; layout?: CardLayout; isNightMode?: boolean; nightMessage?: string }) { if (card.type === "static_text") return ; if (card.type === "clock") return ; - if (card.type === "image_rotator") return ; + if (card.type === "image_rotator") return ; // default: custom_json return ; } diff --git a/tv/src/types.ts b/tv/src/types.ts index 2bc7352..4acca17 100644 --- a/tv/src/types.ts +++ b/tv/src/types.ts @@ -3,9 +3,25 @@ export interface TextState { title: string; } +export interface NightModeSettings { + /** Whether night mode scheduling is active */ + enabled: boolean; + /** Start of night window, "HH:MM" 24-hour */ + start_time: string; + /** End of night window (exclusive), "HH:MM" 24-hour */ + end_time: string; + /** Text shown on image rotator cards during night mode */ + message: string; + /** When true, hide background image/colour and use near-black */ + dim_background: boolean; +} + export interface SettingsState { - /** Portrait background image URL (height > width). Empty string = no background. */ + /** Landscape background image URL. Empty string = no background. */ background_url: string; + /** CSS colour string for the TV background. Default "#000000". */ + background_color: string; + night_mode: NightModeSettings; } export interface ImagePopupState {