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:
@@ -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<string>("");
|
||||
const [pendingUri, setPendingUri] = useState<string | null>(null);
|
||||
const [pendingBase64, setPendingBase64] = useState<string | null>(null);
|
||||
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 [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() {
|
||||
<Text style={shared.pageTitle}>Settings</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 ? (
|
||||
<ActivityIndicator color={colors.accent} style={{ marginTop: 24 }} />
|
||||
) : (
|
||||
<>
|
||||
{/* ── Background Image ───────────────────────────────────────────── */}
|
||||
<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 ? (
|
||||
<ActivityIndicator color={colors.accent} style={{ marginTop: 12 }} />
|
||||
) : (
|
||||
<>
|
||||
{/* Preview */}
|
||||
{previewUri && (
|
||||
{previewUri ? (
|
||||
<View style={styles.previewWrap}>
|
||||
<Image source={{ uri: previewUri }} style={styles.preview} resizeMode="cover" />
|
||||
{hasPending && (
|
||||
@@ -171,15 +286,12 @@ export function SettingsPage() {
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{!previewUri && (
|
||||
) : (
|
||||
<View style={styles.emptyPreview}>
|
||||
<Text style={styles.emptyPreviewText}>No background set</Text>
|
||||
<Text style={styles.emptyPreviewText}>No background image set</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<View style={shared.actionsRow}>
|
||||
<TouchableOpacity
|
||||
style={[shared.btnSecondary, shared.actionFlex]}
|
||||
@@ -194,15 +306,11 @@ export function SettingsPage() {
|
||||
{hasPending && (
|
||||
<TouchableOpacity
|
||||
style={[shared.btnPrimary, shared.actionFlex, saving && shared.btnDisabled]}
|
||||
onPress={handleSave}
|
||||
onPress={handleSaveBgImage}
|
||||
activeOpacity={0.8}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={shared.btnPrimaryText}>Save</Text>
|
||||
)}
|
||||
{saving ? <ActivityIndicator color="#fff" /> : <Text style={shared.btnPrimaryText}>Save</Text>}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
@@ -210,24 +318,165 @@ export function SettingsPage() {
|
||||
{(hasCurrent || hasPending) && (
|
||||
<TouchableOpacity
|
||||
style={[shared.btnDanger, clearing && shared.btnDisabled]}
|
||||
onPress={handleClear}
|
||||
onPress={handleClearBgImage}
|
||||
activeOpacity={0.8}
|
||||
disabled={clearing}
|
||||
>
|
||||
{clearing ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={shared.btnDangerText}>Clear Background</Text>
|
||||
)}
|
||||
{clearing ? <ActivityIndicator color="#fff" /> : <Text style={shared.btnDangerText}>Clear Image</Text>}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{status && (
|
||||
<Text style={[shared.hint, styles.statusText]}>{status}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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 && (
|
||||
<Text style={[shared.hint, styles.statusText]}>{status}</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user