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:
23
docker-compose.yml
Normal file
23
docker-compose.yml
Normal 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:
|
||||||
@@ -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":
|
||||||
|
|||||||
@@ -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 ── */}
|
|
||||||
<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 landscape image (wider than tall).
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<ActivityIndicator color={colors.accent} style={{ marginTop: 12 }} />
|
<ActivityIndicator color={colors.accent} style={{ marginTop: 24 }} />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Preview */}
|
{/* ── Background Image ───────────────────────────────────────────── */}
|
||||||
{previewUri && (
|
<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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
|
{/* ── Background Colour ──────────────────────────────────────────── */}
|
||||||
|
<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).
|
||||||
|
</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 && (
|
{status && (
|
||||||
<Text style={[shared.hint, styles.statusText]}>{status}</Text>
|
<Text style={[shared.hint, styles.statusText]}>{status}</Text>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</View>
|
|
||||||
</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",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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">
|
||||||
|
{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">
|
<p className="text-gray-600 text-lg">
|
||||||
Waiting for content
|
Waiting for content
|
||||||
<span className="dot-1">.</span>
|
<span className="dot-1">.</span>
|
||||||
<span className="dot-2">.</span>
|
<span className="dot-2">.</span>
|
||||||
<span className="dot-3">.</span>
|
<span className="dot-3">.</span>
|
||||||
</p>
|
</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>
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user