feat: implement background color and night mode settings in the TV control app
All checks were successful
Build App / build (push) Successful in 7m10s
All checks were successful
Build App / build (push) Successful in 7m10s
This commit is contained in:
@@ -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<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: "" });
|
||||
const [settings, setSettings] = useState<SettingsState>(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 <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;
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen relative">
|
||||
<div className="w-screen h-screen relative" style={{ backgroundColor: bgColor }}>
|
||||
{fetchError && (
|
||||
<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>
|
||||
@@ -123,7 +169,7 @@ function App() {
|
||||
<p className="text-gray-400 text-2xl mt-3">Retrying every 5 seconds…</p>
|
||||
</div>
|
||||
)}
|
||||
{settings.background_url && (
|
||||
{showBgImage && (
|
||||
<img
|
||||
src={settings.background_url}
|
||||
className="absolute inset-0 w-full h-full object-cover z-0"
|
||||
@@ -131,16 +177,25 @@ function App() {
|
||||
/>
|
||||
)}
|
||||
<TextPopup state={textState} />
|
||||
<ImagePopup state={imagePopup} />
|
||||
{!dimBackground && <ImagePopup state={imagePopup} />}
|
||||
|
||||
{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>
|
||||
{isNightActive ? (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<span className="text-6xl select-none">🌙</span>
|
||||
<p className="text-gray-400 text-2xl font-semibold">
|
||||
{settings.night_mode?.message || "Good Night"}
|
||||
</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>
|
||||
)}
|
||||
|
||||
@@ -164,7 +219,12 @@ function App() {
|
||||
minHeight: 0,
|
||||
}}
|
||||
>
|
||||
<DataCardWidget card={card} layout={resolvedLayout} />
|
||||
<DataCardWidget
|
||||
card={card}
|
||||
layout={resolvedLayout}
|
||||
isNightMode={isNightActive}
|
||||
nightMessage={settings.night_mode?.message}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -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 (
|
||||
<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) {
|
||||
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 <StaticTextWidget 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
|
||||
return <CustomJsonWidget card={card as CustomJsonCard} layout={layout} />;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user