Added spotify and moved away from hardcoded urls
Some checks failed
Build App / build (push) Has been cancelled
Some checks failed
Build App / build (push) Has been cancelled
This commit is contained in:
1
.env
Normal file
1
.env
Normal file
@@ -0,0 +1 @@
|
||||
VITE_INSTANCE_URL=https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329
|
||||
@@ -7,6 +7,8 @@ services:
|
||||
- tv_node_modules:/app/node_modules
|
||||
ports:
|
||||
- "4173:4173"
|
||||
env_file:
|
||||
- .env
|
||||
command: >
|
||||
sh -c "
|
||||
npm install -g pnpm --silent &&
|
||||
|
||||
1
example.env
Normal file
1
example.env
Normal file
@@ -0,0 +1 @@
|
||||
VITE_INSTANCE_URL=http://localhost:8000
|
||||
@@ -9,6 +9,7 @@ import { IndexPage } from "./src/pages/index";
|
||||
import { SettingsPage } from "./src/pages/settings";
|
||||
import { TextPage } from "./src/pages/text";
|
||||
import { Route, RouterProvider, useRouter } from "./src/router";
|
||||
import { InstanceUrlProvider } from "./src/instanceUrl";
|
||||
interface Tab {
|
||||
label: string;
|
||||
route: Route;
|
||||
@@ -47,15 +48,17 @@ function Screen() {
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<RouterProvider>
|
||||
<View style={styles.container}>
|
||||
<StatusBar style="light" />
|
||||
<View style={{ flex: 1, marginTop: 12 }}>
|
||||
<Screen />
|
||||
<InstanceUrlProvider>
|
||||
<RouterProvider>
|
||||
<View style={styles.container}>
|
||||
<StatusBar style="light" />
|
||||
<View style={{ flex: 1, marginTop: 12 }}>
|
||||
<Screen />
|
||||
</View>
|
||||
<BottomNav />
|
||||
</View>
|
||||
<BottomNav />
|
||||
</View>
|
||||
</RouterProvider>
|
||||
</RouterProvider>
|
||||
</InstanceUrlProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@react-native-community/datetimepicker": "^8.6.0",
|
||||
"expo": "~54.0.33",
|
||||
"expo-image-picker": "^55.0.10",
|
||||
"expo-secure-store": "^55.0.8",
|
||||
"expo-status-bar": "~3.0.9",
|
||||
"react": "19.1.0",
|
||||
"react-native": "0.81.5"
|
||||
|
||||
12
mobile/pnpm-lock.yaml
generated
12
mobile/pnpm-lock.yaml
generated
@@ -17,6 +17,9 @@ importers:
|
||||
expo-image-picker:
|
||||
specifier: ^55.0.10
|
||||
version: 55.0.10(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))
|
||||
expo-secure-store:
|
||||
specifier: ^55.0.8
|
||||
version: 55.0.8(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))
|
||||
expo-status-bar:
|
||||
specifier: ~3.0.9
|
||||
version: 3.0.9(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
@@ -1321,6 +1324,11 @@ packages:
|
||||
react: '*'
|
||||
react-native: '*'
|
||||
|
||||
expo-secure-store@55.0.8:
|
||||
resolution: {integrity: sha512-8w9tQe8U6oRo5YIzqCqVhRrOnfoODNDoitBtLXEx+zS6WLUnkRq5kH7ViJuOgiM7PzLr9pvAliRiDOKyvFbTuQ==}
|
||||
peerDependencies:
|
||||
expo: '*'
|
||||
|
||||
expo-server@1.0.5:
|
||||
resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==}
|
||||
engines: {node: '>=20.16.0'}
|
||||
@@ -4253,6 +4261,10 @@ snapshots:
|
||||
react: 19.1.0
|
||||
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0)
|
||||
|
||||
expo-secure-store@55.0.8(expo@54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)):
|
||||
dependencies:
|
||||
expo: 54.0.33(@babel/core@7.29.0)(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
|
||||
|
||||
expo-server@1.0.5: {}
|
||||
|
||||
expo-status-bar@3.0.9(react-native@0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
|
||||
|
||||
239
mobile/src/instanceUrl.tsx
Normal file
239
mobile/src/instanceUrl.tsx
Normal file
@@ -0,0 +1,239 @@
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import React, {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
ActivityIndicator,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { colors } from "./styles";
|
||||
|
||||
const STORE_KEY = "instance_url";
|
||||
|
||||
// ─── Context ──────────────────────────────────────────────────────────────────
|
||||
|
||||
interface InstanceUrlContextValue {
|
||||
baseUrl: string;
|
||||
/** Call to clear the stored URL and re-show the setup screen. */
|
||||
resetUrl: () => void;
|
||||
}
|
||||
|
||||
const InstanceUrlContext = createContext<InstanceUrlContextValue | null>(null);
|
||||
|
||||
export function useBaseUrl(): InstanceUrlContextValue {
|
||||
const ctx = useContext(InstanceUrlContext);
|
||||
if (!ctx) throw new Error("useBaseUrl must be used inside <InstanceUrlProvider>");
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// ─── Validation helper ────────────────────────────────────────────────────────
|
||||
|
||||
async function validateUrl(url: string): Promise<void> {
|
||||
const trimmed = url.replace(/\/+$/, "");
|
||||
const res = await fetch(`${trimmed}/pull_full`, { method: "POST" });
|
||||
if (!res.ok) throw new Error(`Server returned ${res.status}`);
|
||||
const data = await res.json();
|
||||
if (typeof data !== "object" || data === null || !("state" in data)) {
|
||||
throw new Error("Unexpected response format");
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Setup screen ─────────────────────────────────────────────────────────────
|
||||
|
||||
function SetupScreen({ onSaved }: { onSaved: (url: string) => void }) {
|
||||
const [input, setInput] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = input.trim().replace(/\/+$/, "");
|
||||
if (!trimmed) {
|
||||
setError("Please enter a URL.");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await validateUrl(trimmed);
|
||||
await SecureStore.setItemAsync(STORE_KEY, trimmed);
|
||||
onSaved(trimmed);
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(`Could not connect: ${msg}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<KeyboardAvoidingView
|
||||
style={styles.screen}
|
||||
behavior={Platform.OS === "ios" ? "padding" : undefined}
|
||||
>
|
||||
<View style={styles.card}>
|
||||
<Text style={styles.title}>Server Setup</Text>
|
||||
<Text style={styles.body}>
|
||||
Enter your instance URL to get started. The app will verify it before
|
||||
saving.
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
style={[styles.input, !!error && styles.inputError]}
|
||||
placeholder="https://your-instance/api/exec/…"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
keyboardType="url"
|
||||
value={input}
|
||||
onChangeText={(t) => { setInput(t); setError(null); }}
|
||||
editable={!loading}
|
||||
/>
|
||||
|
||||
{error && <Text style={styles.errorText}>{error}</Text>}
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.button, loading && styles.buttonDisabled]}
|
||||
onPress={handleSave}
|
||||
disabled={loading}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
{loading ? (
|
||||
<ActivityIndicator color="#fff" />
|
||||
) : (
|
||||
<Text style={styles.buttonText}>Validate & Save</Text>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Provider ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function InstanceUrlProvider({ children }: { children: React.ReactNode }) {
|
||||
const [baseUrl, setBaseUrl] = useState<string | null>(null); // null = loading
|
||||
const [configured, setConfigured] = useState(false);
|
||||
|
||||
// Load from storage on mount
|
||||
useEffect(() => {
|
||||
SecureStore.getItemAsync(STORE_KEY)
|
||||
.then((stored) => {
|
||||
if (stored) {
|
||||
setBaseUrl(stored);
|
||||
setConfigured(true);
|
||||
} else {
|
||||
setBaseUrl(""); // not configured
|
||||
setConfigured(false);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setBaseUrl("");
|
||||
setConfigured(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resetUrl = useCallback(async () => {
|
||||
await SecureStore.deleteItemAsync(STORE_KEY);
|
||||
setBaseUrl("");
|
||||
setConfigured(false);
|
||||
}, []);
|
||||
|
||||
const handleSaved = useCallback((url: string) => {
|
||||
setBaseUrl(url);
|
||||
setConfigured(true);
|
||||
}, []);
|
||||
|
||||
// Still loading from storage
|
||||
if (baseUrl === null) {
|
||||
return (
|
||||
<View style={styles.screen}>
|
||||
<ActivityIndicator size="large" color={colors.accent} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (!configured) {
|
||||
return <SetupScreen onSaved={handleSaved} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<InstanceUrlContext.Provider value={{ baseUrl: baseUrl!, resetUrl }}>
|
||||
{children}
|
||||
</InstanceUrlContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Styles ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
screen: {
|
||||
flex: 1,
|
||||
backgroundColor: colors.bg,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
padding: 24,
|
||||
},
|
||||
card: {
|
||||
backgroundColor: colors.surface,
|
||||
borderRadius: 16,
|
||||
padding: 24,
|
||||
width: "100%",
|
||||
maxWidth: 480,
|
||||
},
|
||||
title: {
|
||||
fontSize: 32,
|
||||
fontWeight: "800",
|
||||
color: colors.textPrimary,
|
||||
marginTop: 8,
|
||||
marginBottom: 8,
|
||||
},
|
||||
body: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
input: {
|
||||
backgroundColor: colors.bg,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: 8,
|
||||
color: colors.textPrimary,
|
||||
fontSize: 14,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 10,
|
||||
marginBottom: 12,
|
||||
},
|
||||
inputError: {
|
||||
borderColor: "#e55",
|
||||
},
|
||||
errorText: {
|
||||
color: "#e55",
|
||||
fontSize: 13,
|
||||
marginBottom: 12,
|
||||
},
|
||||
button: {
|
||||
backgroundColor: colors.accent,
|
||||
borderRadius: 8,
|
||||
paddingVertical: 12,
|
||||
alignItems: "center",
|
||||
},
|
||||
buttonDisabled: {
|
||||
opacity: 0.5,
|
||||
},
|
||||
buttonText: {
|
||||
color: "#fff",
|
||||
fontWeight: "600",
|
||||
fontSize: 15,
|
||||
},
|
||||
});
|
||||
@@ -14,15 +14,13 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useBaseUrl } from "../instanceUrl";
|
||||
import { useRouter } from "../router";
|
||||
import { colors } from "../styles";
|
||||
|
||||
const BASE_URL =
|
||||
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type CardType = "custom_json" | "static_text" | "clock" | "image_rotator";
|
||||
export type CardType = "custom_json" | "static_text" | "clock" | "image_rotator" | "spotify";
|
||||
|
||||
/** 4-column × 4-row grid, 1-based */
|
||||
export interface CardLayout {
|
||||
@@ -73,7 +71,14 @@ interface ImageRotatorCard {
|
||||
layout?: CardLayout;
|
||||
}
|
||||
|
||||
export type DataCard = CustomJsonCard | StaticTextCard | ClockCard | ImageRotatorCard;
|
||||
// spotify
|
||||
interface SpotifyCard {
|
||||
id: string; type: "spotify"; name: string;
|
||||
config: { url: string; refresh_interval: number };
|
||||
layout?: CardLayout;
|
||||
}
|
||||
|
||||
export type DataCard = CustomJsonCard | StaticTextCard | ClockCard | ImageRotatorCard | SpotifyCard;
|
||||
|
||||
// ─── Flat form state ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -101,6 +106,7 @@ interface FormState {
|
||||
image_urls: string[];
|
||||
image_interval: string;
|
||||
image_fit: "cover" | "contain";
|
||||
// spotify — reuses url and refresh_interval from above
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
@@ -182,6 +188,13 @@ function cardToForm(card: DataCard): FormState {
|
||||
image_fit: card.config.fit ?? "cover",
|
||||
};
|
||||
}
|
||||
if (card.type === "spotify") {
|
||||
return {
|
||||
...base,
|
||||
url: (card as SpotifyCard).config.url,
|
||||
refresh_interval: String((card as SpotifyCard).config.refresh_interval ?? 30),
|
||||
};
|
||||
}
|
||||
// custom_json
|
||||
return {
|
||||
...base,
|
||||
@@ -235,6 +248,16 @@ function formToCard(form: FormState, id: string): DataCard {
|
||||
layout,
|
||||
};
|
||||
}
|
||||
if (form.type === "spotify") {
|
||||
return {
|
||||
id, type: "spotify", name: form.name.trim(),
|
||||
config: {
|
||||
url: form.url.trim(),
|
||||
refresh_interval: Math.max(5, parseInt(form.refresh_interval, 10) || 30),
|
||||
},
|
||||
layout,
|
||||
};
|
||||
}
|
||||
// custom_json
|
||||
return {
|
||||
id, type: "custom_json", name: form.name.trim(),
|
||||
@@ -499,6 +522,7 @@ const CARD_TYPES: { type: CardType; label: string; icon: string; desc: string }[
|
||||
{ type: "static_text", label: "Static Text", icon: "📝", desc: "Fixed text or note" },
|
||||
{ type: "clock", label: "Clock / Timer", icon: "🕐", desc: "Live time or countdown" },
|
||||
{ type: "image_rotator", label: "Image Slideshow", icon: "🖼", desc: "Rotating image gallery" },
|
||||
{ type: "spotify", label: "Spotify", icon: "🎵", desc: "Now playing from Spotify" },
|
||||
];
|
||||
|
||||
interface TypeSelectorProps {
|
||||
@@ -748,6 +772,7 @@ interface ImageRotatorFieldsProps {
|
||||
}
|
||||
|
||||
function ImageRotatorFields({ form, onChange, onUrlsChange }: ImageRotatorFieldsProps) {
|
||||
const { baseUrl: BASE_URL } = useBaseUrl();
|
||||
const [urlInput, setUrlInput] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
@@ -873,6 +898,39 @@ const imgStyles = StyleSheet.create({
|
||||
pickBtnText: { fontSize: 15, fontWeight: "500", color: colors.textPrimary },
|
||||
});
|
||||
|
||||
// ─── Spotify Fields ───────────────────────────────────────────────────────────
|
||||
|
||||
function SpotifyFields({ form, onChange }: { form: FormState; onChange: (k: keyof FormState, v: string) => void }) {
|
||||
return (
|
||||
<>
|
||||
<View style={styles.field}>
|
||||
<FieldLabel text="Now Playing URL *" />
|
||||
<Text style={styles.hint}>URL of your Spotify currently-playing proxy endpoint.</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="https://your-proxy.example.com/spotify/now-playing"
|
||||
placeholderTextColor={colors.placeholderColor}
|
||||
value={form.url}
|
||||
onChangeText={(v) => onChange("url", v)}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
/>
|
||||
</View>
|
||||
<View style={styles.field}>
|
||||
<FieldLabel text="Refresh Interval (seconds)" />
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="30"
|
||||
placeholderTextColor={colors.placeholderColor}
|
||||
value={form.refresh_interval}
|
||||
onChangeText={(v) => onChange("refresh_interval", v)}
|
||||
keyboardType="numeric"
|
||||
/>
|
||||
</View>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Full form view ───────────────────────────────────────────────────────────
|
||||
|
||||
interface FormViewProps {
|
||||
@@ -890,7 +948,7 @@ interface FormViewProps {
|
||||
}
|
||||
|
||||
function FormView({ form, onChange, onBoolChange, onLayoutChange, onTypeChange, onUrlsChange, onSave, onCancel, saving, isEdit, otherLayouts }: FormViewProps) {
|
||||
const showDisplayOptions = form.type !== "image_rotator";
|
||||
const showDisplayOptions = form.type !== "image_rotator" && form.type !== "spotify";
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<View style={styles.header}>
|
||||
@@ -910,11 +968,12 @@ function FormView({ form, onChange, onBoolChange, onLayoutChange, onTypeChange,
|
||||
<TextInput style={styles.input} placeholder="My Widget" placeholderTextColor={colors.placeholderColor} value={form.name} onChangeText={(v) => onChange("name", v)} />
|
||||
</View>
|
||||
|
||||
<SectionLabel text={form.type === "custom_json" ? "Data Source" : form.type === "static_text" ? "Content" : form.type === "clock" ? "Clock Settings" : "Images"} />
|
||||
<SectionLabel text={form.type === "custom_json" ? "Data Source" : form.type === "static_text" ? "Content" : form.type === "clock" ? "Clock Settings" : form.type === "spotify" ? "Spotify Settings" : "Images"} />
|
||||
{form.type === "custom_json" && <CustomJsonFields form={form} onChange={onChange} />}
|
||||
{form.type === "static_text" && <StaticTextFields form={form} onChange={onChange} />}
|
||||
{form.type === "clock" && <ClockFields form={form} onChange={onChange} onBoolChange={onBoolChange} />}
|
||||
{form.type === "image_rotator" && <ImageRotatorFields form={form} onChange={onChange} onUrlsChange={onUrlsChange} />}
|
||||
{form.type === "spotify" && <SpotifyFields form={form} onChange={onChange} />}
|
||||
|
||||
{showDisplayOptions && (
|
||||
<>
|
||||
@@ -954,16 +1013,18 @@ const CARD_TYPE_ICONS: Record<CardType, string> = {
|
||||
static_text: "📝",
|
||||
clock: "🕐",
|
||||
image_rotator: "🖼",
|
||||
spotify: "🎵",
|
||||
};
|
||||
|
||||
function cardSubtitle(card: DataCard): string {
|
||||
if (card.type === "static_text") return card.config.text.slice(0, 80) || "(empty)";
|
||||
if (card.type === "clock") {
|
||||
const c = card.config;
|
||||
if (c.mode === "timer") return `Countdown → ${c.target_iso ?? "?"}`;
|
||||
if (c.mode === "timer") return `Countdown → ${c.target_iso ?? "?"}` ;
|
||||
return `Live time · ${c.timezone ?? "local"}`;
|
||||
}
|
||||
if (card.type === "image_rotator") return `${card.config.images.length} image(s) · ${card.config.interval}s rotation`;
|
||||
if (card.type === "spotify") return (card as SpotifyCard).config.url;
|
||||
return (card as CustomJsonCard).config.url;
|
||||
}
|
||||
|
||||
@@ -971,6 +1032,7 @@ function cardMeta(card: DataCard): string {
|
||||
if (card.type === "custom_json") return `Refresh: ${(card as CustomJsonCard).config.refresh_interval}s`;
|
||||
if (card.type === "image_rotator") return `Fit: ${card.config.fit}`;
|
||||
if (card.type === "clock") return card.config.mode === "time" ? "Mode: live time" : "Mode: countdown";
|
||||
if (card.type === "spotify") return `Refresh: ${(card as SpotifyCard).config.refresh_interval}s`;
|
||||
return "";
|
||||
}
|
||||
|
||||
@@ -1013,6 +1075,7 @@ type PageView = "list" | "form";
|
||||
|
||||
export function DataCardsPage() {
|
||||
const { navigate } = useRouter();
|
||||
const { baseUrl: BASE_URL } = useBaseUrl();
|
||||
const [pageView, setPageView] = useState<PageView>("list");
|
||||
const [cards, setCards] = useState<DataCard[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import * as ImagePicker from "expo-image-picker";
|
||||
import { useState } from "react";
|
||||
import { ActivityIndicator, Image, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||
import { useBaseUrl } from "../instanceUrl";
|
||||
import { colors, shared } from "../styles";
|
||||
|
||||
const BASE_URL =
|
||||
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
|
||||
|
||||
export function ImagePage() {
|
||||
const { baseUrl: BASE_URL } = useBaseUrl();
|
||||
const [caption, setCaption] = useState("");
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dismissing, setDismissing] = useState(false);
|
||||
|
||||
@@ -11,11 +11,9 @@ import {
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { useBaseUrl } from "../instanceUrl";
|
||||
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 = [
|
||||
@@ -42,6 +40,7 @@ function isValidHex(c: string) {
|
||||
// ─── Settings page ────────────────────────────────────────────────────────────
|
||||
|
||||
export function SettingsPage() {
|
||||
const { baseUrl: BASE_URL, resetUrl } = useBaseUrl();
|
||||
// ── Background image
|
||||
const [currentBgUrl, setCurrentBgUrl] = useState<string>("");
|
||||
const [pendingUri, setPendingUri] = useState<string | null>(null);
|
||||
@@ -475,6 +474,19 @@ export function SettingsPage() {
|
||||
{status && (
|
||||
<Text style={[shared.hint, styles.statusText]}>{status}</Text>
|
||||
)}
|
||||
|
||||
{/* ── Server ─────────────────────────────────────────────────────── */}
|
||||
<View style={styles.section}>
|
||||
<Text style={shared.sectionLabel}>Server</Text>
|
||||
<Text style={shared.hint} numberOfLines={2}>{BASE_URL}</Text>
|
||||
<TouchableOpacity
|
||||
style={shared.btnSecondary}
|
||||
onPress={resetUrl}
|
||||
activeOpacity={0.8}
|
||||
>
|
||||
<Text style={shared.btnSecondaryText}>Change Server URL</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { useState } from "react";
|
||||
import { ActivityIndicator, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||
import { useBaseUrl } from "../instanceUrl";
|
||||
import { colors, shared } from "../styles";
|
||||
|
||||
const BASE_URL =
|
||||
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
|
||||
|
||||
export function TextPage() {
|
||||
const { baseUrl: BASE_URL } = useBaseUrl();
|
||||
const [title, setTitle] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [dismissing, setDismissing] = useState(false);
|
||||
|
||||
@@ -121,7 +121,7 @@ function App() {
|
||||
if (screenStatus === "fullscreen") {
|
||||
const handlePullState = () => {
|
||||
fetch(
|
||||
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329/pull_full",
|
||||
`${import.meta.env.VITE_INSTANCE_URL}/pull_full`,
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CustomJsonCard,
|
||||
DataCard,
|
||||
ImageRotatorCard,
|
||||
SpotifyCard,
|
||||
StaticTextCard,
|
||||
} from "../types";
|
||||
import { evaluatePath } from "../utils/evaluatePath";
|
||||
@@ -304,12 +305,151 @@ function ImageRotatorWidget({ card, isNightMode, nightMessage }: { card: ImageRo
|
||||
);
|
||||
}
|
||||
|
||||
// ─── spotify widget ──────────────────────────────────────────────────────────
|
||||
|
||||
interface SpotifyItem {
|
||||
name: string;
|
||||
duration_ms: number;
|
||||
artists: Array<{ name: string }>;
|
||||
album: {
|
||||
name: string;
|
||||
images: Array<{ url: string; width: number; height: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface SpotifyPayload {
|
||||
is_playing: boolean;
|
||||
progress_ms?: number;
|
||||
item?: SpotifyItem;
|
||||
}
|
||||
|
||||
function SpotifyWidget({ card }: { card: SpotifyCard }) {
|
||||
const [payload, setPayload] = useState<SpotifyPayload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pulling, setPulling] = useState(false);
|
||||
const [dots, setDots] = useState(0);
|
||||
// live progress ticker — resets to 0 on each successful fetch
|
||||
const [tickMs, setTickMs] = useState(0);
|
||||
|
||||
// Advance local progress every second
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTickMs((t) => t + 1000), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pulling) return;
|
||||
const d = setInterval(() => setDots((x) => (x + 1) % 4), 400);
|
||||
return () => clearInterval(d);
|
||||
}, [pulling]);
|
||||
|
||||
useEffect(() => {
|
||||
const doFetch = () => {
|
||||
setPulling(true);
|
||||
setDots(0);
|
||||
fetch(card.config.url)
|
||||
.then((r) => r.json())
|
||||
.then((raw) => {
|
||||
// Support both bare payload and { status, data } wrapper
|
||||
const p: SpotifyPayload = raw?.data ?? raw;
|
||||
setPayload(p);
|
||||
setTickMs(0);
|
||||
setError(null);
|
||||
setPulling(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(String(e));
|
||||
setPulling(false);
|
||||
});
|
||||
};
|
||||
doFetch();
|
||||
const ms = Math.max(5000, (card.config.refresh_interval ?? 30) * 1000);
|
||||
const iv = setInterval(doFetch, ms);
|
||||
return () => clearInterval(iv);
|
||||
}, [card.id, card.config.url, card.config.refresh_interval]);
|
||||
|
||||
const item = payload?.item;
|
||||
const isPlaying = payload?.is_playing ?? false;
|
||||
const progressMs = Math.min(
|
||||
item?.duration_ms ?? 0,
|
||||
(payload?.progress_ms ?? 0) + (isPlaying ? tickMs : 0),
|
||||
);
|
||||
const durationMs = item?.duration_ms ?? 0;
|
||||
const progressPct = durationMs > 0 ? (progressMs / durationMs) * 100 : 0;
|
||||
|
||||
const albumArt = item?.album.images?.[0]?.url;
|
||||
const trackName = item?.name ?? "";
|
||||
const artistNames = item?.artists?.map((a) => a.name).join(", ") ?? "";
|
||||
const albumName = item?.album?.name ?? "";
|
||||
|
||||
const fmtMs = (ms: number) => {
|
||||
const s = Math.floor(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}:${String(s % 60).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
name={card.name}
|
||||
footer={
|
||||
pulling ? (
|
||||
<span className="text-gray-500 text-xs">syncing{".".repeat(dots)}</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<span className="text-red-400 text-sm break-all">⚠ {error}</span>
|
||||
) : !payload ? (
|
||||
<span className="text-gray-500 text-sm">Loading…</span>
|
||||
) : !isPlaying || !item ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 opacity-50">
|
||||
<span className="text-4xl">⏸</span>
|
||||
<span className="text-gray-400 text-sm">Nothing playing</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full gap-2 min-h-0">
|
||||
{/* Album art + info row */}
|
||||
<div className="flex gap-3 items-center min-h-0 flex-1 overflow-hidden">
|
||||
{albumArt && (
|
||||
<img
|
||||
src={albumArt}
|
||||
alt={albumName}
|
||||
className="rounded-lg shrink-0 object-cover"
|
||||
style={{ width: 56, height: 56 }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col justify-center overflow-hidden">
|
||||
<span className="text-white font-semibold text-sm leading-tight truncate">{trackName}</span>
|
||||
<span className="text-gray-400 text-xs truncate">{artistNames}</span>
|
||||
<span className="text-gray-600 text-xs truncate mt-0.5">{albumName}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="shrink-0">
|
||||
<div className="w-full h-1 rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-1 rounded-full bg-green-500"
|
||||
style={{ width: `${progressPct}%`, transition: "width 1s linear" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-0.5">
|
||||
<span className="text-gray-600 text-xs tabular-nums">{fmtMs(progressMs)}</span>
|
||||
<span className="text-gray-600 text-xs tabular-nums">{fmtMs(durationMs)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dispatcher ───────────────────────────────────────────────────────────────
|
||||
|
||||
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} isNightMode={isNightMode} nightMessage={nightMessage} />;
|
||||
if (card.type === "spotify") return <SpotifyWidget card={card} />;
|
||||
// default: custom_json
|
||||
return <CustomJsonWidget card={card as CustomJsonCard} layout={layout} />;
|
||||
}
|
||||
|
||||
@@ -117,6 +117,22 @@ export interface ImageRotatorCard {
|
||||
layout?: CardLayout;
|
||||
}
|
||||
|
||||
// ─── spotify ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SpotifyConfig {
|
||||
/** URL of a Spotify currently-playing proxy that returns the standard shape */
|
||||
url: string;
|
||||
refresh_interval: number;
|
||||
}
|
||||
|
||||
export interface SpotifyCard {
|
||||
id: string;
|
||||
type: "spotify";
|
||||
name: string;
|
||||
config: SpotifyConfig;
|
||||
layout?: CardLayout;
|
||||
}
|
||||
|
||||
// ─── Union ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type DataCard = CustomJsonCard | StaticTextCard | ClockCard | ImageRotatorCard;
|
||||
export type DataCard = CustomJsonCard | StaticTextCard | ClockCard | ImageRotatorCard | SpotifyCard;
|
||||
|
||||
@@ -4,4 +4,5 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
envDir: '../',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user