1 Commits

Author SHA1 Message Date
eb4e7ea26a feat: implement background color and night mode settings in the TV control app
All checks were successful
Build App / build (push) Successful in 7m10s
2026-03-01 17:53:41 +01:00
6 changed files with 541 additions and 72 deletions

23
docker-compose.yml Normal file
View File

@@ -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:

View File

@@ -18,7 +18,15 @@ DEFAULT_STATE = {
}, },
"data_cards": [], "data_cards": [],
"settings": { "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) _write_state(current)
return {"status": "success"} return {"status": "success"}
elif route == "push_settings": elif route == "push_settings":
bg_url = body.get("background_url", "")
current = _read_state() 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) _write_state(current)
return {"status": "success"} return {"status": "success"}
elif route == "push_upload_images": elif route == "push_upload_images":

View File

@@ -5,7 +5,9 @@ import {
Image, Image,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
Switch,
Text, Text,
TextInput,
TouchableOpacity, TouchableOpacity,
View, View,
} from "react-native"; } from "react-native";
@@ -14,13 +16,52 @@ import { colors, shared } from "../styles";
const BASE_URL = const BASE_URL =
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329"; "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 ──────────────────────────────────────────────────────────── // ─── Settings page ────────────────────────────────────────────────────────────
export function SettingsPage() { export function SettingsPage() {
// ── Background image
const [currentBgUrl, setCurrentBgUrl] = useState<string>(""); const [currentBgUrl, setCurrentBgUrl] = useState<string>("");
const [pendingUri, setPendingUri] = useState<string | null>(null); const [pendingUri, setPendingUri] = useState<string | null>(null);
const [pendingBase64, setPendingBase64] = useState<string | null>(null); const [pendingBase64, setPendingBase64] = useState<string | null>(null);
const [pendingExt, setPendingExt] = useState<string>("jpg"); const [pendingExt, setPendingExt] = useState<string>("jpg");
// ── Background colour
const [bgColor, setBgColor] = useState<string>("#000000");
const [bgColorInput, setBgColorInput] = useState<string>("#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 [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [clearing, setClearing] = useState(false); const [clearing, setClearing] = useState(false);
@@ -32,14 +73,28 @@ export function SettingsPage() {
fetch(`${BASE_URL}/pull_full`, { method: "POST" }) fetch(`${BASE_URL}/pull_full`, { method: "POST" })
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { .then((data) => {
const bg = data.state?.settings?.background_url ?? ""; const s = data.state?.settings ?? {};
setCurrentBgUrl(bg); 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(() => {}) .catch(() => {})
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
// ── Pick a landscape image from the gallery // ──────────────────────────────────────────────────────────────────────────────
// Background image handlers
// ──────────────────────────────────────────────────────────────────────────────
const handlePickBackground = async () => { const handlePickBackground = async () => {
const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync(); const { granted } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (!granted) { if (!granted) {
@@ -75,12 +130,11 @@ export function SettingsPage() {
}; };
// ── Upload pending image and save as background // ── Upload pending image and save as background
const handleSave = async () => { const handleSaveBgImage = async () => {
if (!pendingBase64) { setStatus("No image selected."); return; } if (!pendingBase64) { setStatus("No image selected."); return; }
setSaving(true); setSaving(true);
setStatus(null); setStatus(null);
try { try {
// Upload to MinIO via push_upload_images
const uploadRes = await fetch(`${BASE_URL}/push_upload_images`, { const uploadRes = await fetch(`${BASE_URL}/push_upload_images`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -92,21 +146,18 @@ export function SettingsPage() {
} }
const url: string = uploadData.urls[0]; const url: string = uploadData.urls[0];
// Persist as background URL
const settingsRes = await fetch(`${BASE_URL}/push_settings`, { const settingsRes = await fetch(`${BASE_URL}/push_settings`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ background_url: url }), body: JSON.stringify({ background_url: url }),
}); });
const settingsData = await settingsRes.json(); const settingsData = await settingsRes.json();
if (settingsData.status !== "success") { if (settingsData.status !== "success") throw new Error(settingsData.message ?? "Save failed");
throw new Error(settingsData.message ?? "Save failed");
}
setCurrentBgUrl(url); setCurrentBgUrl(url);
setPendingUri(null); setPendingUri(null);
setPendingBase64(null); setPendingBase64(null);
setStatus("Background saved!"); setStatus("Background image saved!");
} catch (err) { } catch (err) {
setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`); setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally { } finally {
@@ -114,8 +165,8 @@ export function SettingsPage() {
} }
}; };
// ── Clear the TV background // ── Clear the TV background image
const handleClear = async () => { const handleClearBgImage = async () => {
setClearing(true); setClearing(true);
setStatus(null); setStatus(null);
try { try {
@@ -129,7 +180,7 @@ export function SettingsPage() {
setCurrentBgUrl(""); setCurrentBgUrl("");
setPendingUri(null); setPendingUri(null);
setPendingBase64(null); setPendingBase64(null);
setStatus("Background cleared."); setStatus("Background image cleared.");
} catch (err) { } catch (err) {
setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`); setStatus(`Error: ${err instanceof Error ? err.message : String(err)}`);
} finally { } 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 previewUri = pendingUri ?? (currentBgUrl || null);
const hasPending = !!pendingUri; const hasPending = !!pendingUri;
const hasCurrent = !!currentBgUrl; const hasCurrent = !!currentBgUrl;
@@ -150,19 +266,18 @@ export function SettingsPage() {
<Text style={shared.pageTitle}>Settings</Text> <Text style={shared.pageTitle}>Settings</Text>
<Text style={shared.subtitle}>Configure global TV display options.</Text> <Text style={shared.subtitle}>Configure global TV display options.</Text>
{/* ── Background Image ── */} {loading ? (
<View style={styles.section}> <ActivityIndicator color={colors.accent} style={{ marginTop: 24 }} />
<Text style={shared.sectionLabel}>TV Background</Text> ) : (
<Text style={shared.hint}> <>
Displayed behind all content on the TV. Must be a landscape image (wider than tall). {/* ── Background Image ───────────────────────────────────────────── */}
</Text> <View style={styles.section}>
<Text style={shared.sectionLabel}>TV Background Image</Text>
<Text style={shared.hint}>
Displayed behind all content. Must be landscape (wider than tall).
</Text>
{loading ? ( {previewUri ? (
<ActivityIndicator color={colors.accent} style={{ marginTop: 12 }} />
) : (
<>
{/* Preview */}
{previewUri && (
<View style={styles.previewWrap}> <View style={styles.previewWrap}>
<Image source={{ uri: previewUri }} style={styles.preview} resizeMode="cover" /> <Image source={{ uri: previewUri }} style={styles.preview} resizeMode="cover" />
{hasPending && ( {hasPending && (
@@ -171,15 +286,12 @@ export function SettingsPage() {
</View> </View>
)} )}
</View> </View>
)} ) : (
{!previewUri && (
<View style={styles.emptyPreview}> <View style={styles.emptyPreview}>
<Text style={styles.emptyPreviewText}>No background set</Text> <Text style={styles.emptyPreviewText}>No background image set</Text>
</View> </View>
)} )}
{/* Actions */}
<View style={shared.actionsRow}> <View style={shared.actionsRow}>
<TouchableOpacity <TouchableOpacity
style={[shared.btnSecondary, shared.actionFlex]} style={[shared.btnSecondary, shared.actionFlex]}
@@ -194,15 +306,11 @@ export function SettingsPage() {
{hasPending && ( {hasPending && (
<TouchableOpacity <TouchableOpacity
style={[shared.btnPrimary, shared.actionFlex, saving && shared.btnDisabled]} style={[shared.btnPrimary, shared.actionFlex, saving && shared.btnDisabled]}
onPress={handleSave} onPress={handleSaveBgImage}
activeOpacity={0.8} activeOpacity={0.8}
disabled={saving} disabled={saving}
> >
{saving ? ( {saving ? <ActivityIndicator color="#fff" /> : <Text style={shared.btnPrimaryText}>Save</Text>}
<ActivityIndicator color="#fff" />
) : (
<Text style={shared.btnPrimaryText}>Save</Text>
)}
</TouchableOpacity> </TouchableOpacity>
)} )}
</View> </View>
@@ -210,24 +318,165 @@ export function SettingsPage() {
{(hasCurrent || hasPending) && ( {(hasCurrent || hasPending) && (
<TouchableOpacity <TouchableOpacity
style={[shared.btnDanger, clearing && shared.btnDisabled]} style={[shared.btnDanger, clearing && shared.btnDisabled]}
onPress={handleClear} onPress={handleClearBgImage}
activeOpacity={0.8} activeOpacity={0.8}
disabled={clearing} disabled={clearing}
> >
{clearing ? ( {clearing ? <ActivityIndicator color="#fff" /> : <Text style={shared.btnDangerText}>Clear Image</Text>}
<ActivityIndicator color="#fff" />
) : (
<Text style={shared.btnDangerText}>Clear Background</Text>
)}
</TouchableOpacity> </TouchableOpacity>
)} )}
</View>
{status && ( {/* ── Background Colour ──────────────────────────────────────────── */}
<Text style={[shared.hint, styles.statusText]}>{status}</Text> <View style={styles.section}>
)} <Text style={shared.sectionLabel}>Background Colour</Text>
</> <Text style={shared.hint}>
)} Solid colour shown when no background image is set (or when night mode dims the display).
</View> </Text>
<View style={styles.colorRow}>
<View style={[styles.colorSwatch, { backgroundColor: isValidHex(bgColorInput) ? bgColorInput : bgColor }]} />
<TextInput
style={[shared.input, { flex: 1 }]}
value={bgColorInput}
onChangeText={setBgColorInput}
placeholder="#000000"
placeholderTextColor={colors.placeholderColor}
autoCapitalize="none"
autoCorrect={false}
maxLength={7}
/>
<TouchableOpacity
style={[shared.btnPrimary, styles.colorSaveBtn, savingColor && shared.btnDisabled]}
onPress={() => handleSaveBgColor()}
activeOpacity={0.8}
disabled={savingColor}
>
{savingColor ? <ActivityIndicator color="#fff" size="small" /> : <Text style={shared.btnPrimaryText}>Apply</Text>}
</TouchableOpacity>
</View>
<View style={styles.swatchRow}>
{COLOR_PRESETS.map((p) => (
<TouchableOpacity
key={p.value}
onPress={() => { setBgColorInput(p.value); handleSaveBgColor(p.value); }}
activeOpacity={0.75}
style={[
styles.swatchBtn,
{ backgroundColor: p.value },
bgColor === p.value && styles.swatchSelected,
]}
/>
))}
</View>
<View style={styles.swatchLabelsRow}>
{COLOR_PRESETS.map((p) => (
<Text key={p.value} style={styles.swatchLabel} numberOfLines={1}>{p.label}</Text>
))}
</View>
</View>
{/* ── Night Mode ─────────────────────────────────────────────────── */}
<View style={styles.section}>
<Text style={shared.sectionLabel}>Night Mode</Text>
<Text style={shared.hint}>
Automatically switch the TV to a quiet night display between set hours.
</Text>
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Enable Night Mode</Text>
<Switch
value={nightEnabled}
onValueChange={setNightEnabled}
trackColor={{ false: colors.border, true: colors.accent }}
thumbColor="#fff"
/>
</View>
<View style={[styles.nightFields, !nightEnabled && styles.nightFieldsDisabled]}>
<View style={styles.timeRow}>
<View style={styles.timeField}>
<Text style={styles.fieldLabel}>Start (24h)</Text>
<TextInput
style={shared.input}
value={nightStart}
onChangeText={setNightStart}
placeholder="22:00"
placeholderTextColor={colors.placeholderColor}
keyboardType="numbers-and-punctuation"
maxLength={5}
editable={nightEnabled}
/>
</View>
<Text style={styles.timeArrow}></Text>
<View style={styles.timeField}>
<Text style={styles.fieldLabel}>End (24h)</Text>
<TextInput
style={shared.input}
value={nightEnd}
onChangeText={setNightEnd}
placeholder="07:00"
placeholderTextColor={colors.placeholderColor}
keyboardType="numbers-and-punctuation"
maxLength={5}
editable={nightEnabled}
/>
</View>
</View>
<View>
<Text style={styles.fieldLabel}>Night Message</Text>
<Text style={shared.hint}>
Shown on image rotator cards and the idle screen during night mode.
</Text>
<TextInput
style={shared.input}
value={nightMessage}
onChangeText={setNightMessage}
placeholder="Good Night"
placeholderTextColor={colors.placeholderColor}
editable={nightEnabled}
/>
</View>
<View style={styles.toggleRow}>
<View style={{ flex: 1 }}>
<Text style={styles.toggleLabel}>Dim Background</Text>
<Text style={[shared.hint, { marginTop: 0 }]}>
Hide background image/colour and darken the display.
</Text>
</View>
<Switch
value={nightDim}
onValueChange={setNightDim}
trackColor={{ false: colors.border, true: colors.accent }}
thumbColor="#fff"
disabled={!nightEnabled}
/>
</View>
</View>
<TouchableOpacity
style={[shared.btnPrimary, savingNight && shared.btnDisabled]}
onPress={handleSaveNightMode}
activeOpacity={0.8}
disabled={savingNight}
>
{savingNight ? (
<ActivityIndicator color="#fff" />
) : (
<Text style={shared.btnPrimaryText}>Save Night Mode</Text>
)}
</TouchableOpacity>
</View>
{/* ── Status message ─────────────────────────────────────────────── */}
{status && (
<Text style={[shared.hint, styles.statusText]}>{status}</Text>
)}
</>
)}
</ScrollView> </ScrollView>
); );
} }
@@ -235,12 +484,14 @@ export function SettingsPage() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
container: { container: {
padding: 24, padding: 24,
gap: 20, gap: 28,
paddingBottom: 40, paddingBottom: 48,
}, },
section: { section: {
gap: 12, gap: 12,
}, },
// ── Background image preview
previewWrap: { previewWrap: {
borderRadius: 14, borderRadius: 14,
overflow: "hidden", overflow: "hidden",
@@ -283,8 +534,98 @@ const styles = StyleSheet.create({
color: colors.textMuted, color: colors.textMuted,
fontSize: 14, 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: { statusText: {
marginTop: 4,
color: colors.textSecondary, color: colors.textSecondary,
textAlign: "center",
}, },
}); });

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react"; 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 { DataCardWidget } from "./components/DataCardWidget";
import { TextPopup } from "./components/TextPopup"; import { TextPopup } from "./components/TextPopup";
import { ImagePopup } from "./components/ImagePopup"; 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() { 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 [textState, setTextState] = useState<TextState>({ showing: false, title: "" });
const [imagePopup, setImagePopup] = useState<ImagePopupState>({ showing: false, image_url: "", caption: "" }); const [imagePopup, setImagePopup] = useState<ImagePopupState>({ showing: false, image_url: "", caption: "" });
const [dataCards, setDataCards] = useState<DataCard[]>([]); const [dataCards, setDataCards] = useState<DataCard[]>([]);
const [settings, setSettings] = useState<SettingsState>({ background_url: "" }); const [settings, setSettings] = useState<SettingsState>(DEFAULT_SETTINGS);
const [fetchError, setFetchError] = useState(false); const [fetchError, setFetchError] = useState(false);
// Re-evaluate night mode every minute
const [nowMinute, setNowMinute] = useState(() => Math.floor(Date.now() / 60000));
useEffect(() => { useEffect(() => {
const handleFullscreenChange = () => { const handleFullscreenChange = () => {
@@ -74,6 +105,18 @@ function App() {
return () => document.removeEventListener("fullscreenchange", handleFullscreenChange); 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(() => { useEffect(() => {
if (screenStatus === "fullscreen") { if (screenStatus === "fullscreen") {
const handlePullState = () => { const handlePullState = () => {
@@ -86,7 +129,7 @@ function App() {
setTextState(state.text ?? { showing: false, title: "" }); setTextState(state.text ?? { showing: false, title: "" });
setImagePopup(state.image_popup ?? { showing: false, image_url: "", caption: "" }); setImagePopup(state.image_popup ?? { showing: false, image_url: "", caption: "" });
setDataCards(state.data_cards ?? []); 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); setFetchError(false);
}) })
.catch((error) => { .catch((error) => {
@@ -112,10 +155,13 @@ function App() {
return <NotFullscreen />; return <NotFullscreen />;
} }
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; const isIdle = !textState.showing && !imagePopup.showing && dataCards.length === 0;
return ( return (
<div className="w-screen h-screen relative"> <div className="w-screen h-screen relative" style={{ backgroundColor: bgColor }}>
{fetchError && ( {fetchError && (
<div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/80 backdrop-blur-sm"> <div className="absolute inset-0 z-50 flex flex-col items-center justify-center bg-black/80 backdrop-blur-sm">
<span className="text-red-500 text-[10rem] leading-none select-none">!</span> <span className="text-red-500 text-[10rem] leading-none select-none">!</span>
@@ -123,7 +169,7 @@ function App() {
<p className="text-gray-400 text-2xl mt-3">Retrying every 5 seconds</p> <p className="text-gray-400 text-2xl mt-3">Retrying every 5 seconds</p>
</div> </div>
)} )}
{settings.background_url && ( {showBgImage && (
<img <img
src={settings.background_url} src={settings.background_url}
className="absolute inset-0 w-full h-full object-cover z-0" className="absolute inset-0 w-full h-full object-cover z-0"
@@ -131,16 +177,25 @@ function App() {
/> />
)} )}
<TextPopup state={textState} /> <TextPopup state={textState} />
<ImagePopup state={imagePopup} /> {!dimBackground && <ImagePopup state={imagePopup} />}
{isIdle && ( {isIdle && (
<div className="flex items-center justify-center w-full h-full"> <div className="flex items-center justify-center w-full h-full">
<p className="text-gray-600 text-lg"> {isNightActive ? (
Waiting for content <div className="flex flex-col items-center gap-3">
<span className="dot-1">.</span> <span className="text-6xl select-none">🌙</span>
<span className="dot-2">.</span> <p className="text-gray-400 text-2xl font-semibold">
<span className="dot-3">.</span> {settings.night_mode?.message || "Good Night"}
</p> </p>
</div>
) : (
<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> </div>
)} )}
@@ -164,7 +219,12 @@ function App() {
minHeight: 0, minHeight: 0,
}} }}
> >
<DataCardWidget card={card} layout={resolvedLayout} /> <DataCardWidget
card={card}
layout={resolvedLayout}
isNightMode={isNightActive}
nightMessage={settings.night_mode?.message}
/>
</div> </div>
))} ))}
</div> </div>

View File

@@ -234,12 +234,13 @@ function ClockWidget({ card, layout }: { card: ClockCard; layout?: CardLayout })
// ─── image_rotator widget ───────────────────────────────────────────────────── // ─── 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 images = card.config.images ?? [];
const [index, setIndex] = useState(0); const [index, setIndex] = useState(0);
const [fade, setFade] = useState(true); const [fade, setFade] = useState(true);
useEffect(() => { useEffect(() => {
if (isNightMode) return;
if (images.length <= 1) return; if (images.length <= 1) return;
const ms = Math.max(2000, (card.config.interval ?? 10) * 1000); const ms = Math.max(2000, (card.config.interval ?? 10) * 1000);
const timer = setInterval(() => { const timer = setInterval(() => {
@@ -250,7 +251,20 @@ function ImageRotatorWidget({ card }: { card: ImageRotatorCard }) {
}, 400); }, 400);
}, ms); }, ms);
return () => clearInterval(timer); return () => clearInterval(timer);
}, [images.length, card.config.interval]); }, [images.length, card.config.interval, isNightMode]);
// ── Night mode overlay ────────────────────────────────────────────────────
if (isNightMode) {
return (
<div className="relative w-full h-full overflow-hidden rounded-2xl flex flex-col items-center justify-center bg-black/80">
<span className="text-5xl mb-3 select-none">🌙</span>
<span className="text-white text-xl font-semibold text-center px-4">
{nightMessage || "Good Night"}
</span>
<span className="text-white/30 text-xs mt-3 uppercase tracking-widest">{card.name}</span>
</div>
);
}
if (images.length === 0) { if (images.length === 0) {
return ( return (
@@ -292,10 +306,10 @@ function ImageRotatorWidget({ card }: { card: ImageRotatorCard }) {
// ─── Dispatcher ─────────────────────────────────────────────────────────────── // ─── 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 <StaticTextWidget card={card} layout={layout} />; if (card.type === "static_text") return <StaticTextWidget card={card} layout={layout} />;
if (card.type === "clock") return <ClockWidget card={card} layout={layout} />; if (card.type === "clock") return <ClockWidget card={card} layout={layout} />;
if (card.type === "image_rotator") return <ImageRotatorWidget card={card} />; if (card.type === "image_rotator") return <ImageRotatorWidget card={card} isNightMode={isNightMode} nightMessage={nightMessage} />;
// default: custom_json // default: custom_json
return <CustomJsonWidget card={card as CustomJsonCard} layout={layout} />; return <CustomJsonWidget card={card as CustomJsonCard} layout={layout} />;
} }

View File

@@ -3,9 +3,25 @@ export interface TextState {
title: string; 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 { export interface SettingsState {
/** Portrait background image URL (height > width). Empty string = no background. */ /** Landscape background image URL. Empty string = no background. */
background_url: string; background_url: string;
/** CSS colour string for the TV background. Default "#000000". */
background_color: string;
night_mode: NightModeSettings;
} }
export interface ImagePopupState { export interface ImagePopupState {