diff --git a/.env b/.env new file mode 100644 index 0000000..3ea5b8c --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_INSTANCE_URL=https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 2d5e803..0e28cce 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 && diff --git a/example.env b/example.env new file mode 100644 index 0000000..370f63f --- /dev/null +++ b/example.env @@ -0,0 +1 @@ +VITE_INSTANCE_URL=http://localhost:8000 \ No newline at end of file diff --git a/mobile/App.tsx b/mobile/App.tsx index 82bc56e..6e332dc 100644 --- a/mobile/App.tsx +++ b/mobile/App.tsx @@ -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 ( - - - - - + + + + + + + + - - - + + ); } diff --git a/mobile/package.json b/mobile/package.json index 6eca003..ee715e5 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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" diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index 5ba323f..cbb419f 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -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): diff --git a/mobile/src/instanceUrl.tsx b/mobile/src/instanceUrl.tsx new file mode 100644 index 0000000..d0cd94d --- /dev/null +++ b/mobile/src/instanceUrl.tsx @@ -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(null); + +export function useBaseUrl(): InstanceUrlContextValue { + const ctx = useContext(InstanceUrlContext); + if (!ctx) throw new Error("useBaseUrl must be used inside "); + return ctx; +} + +// ─── Validation helper ──────────────────────────────────────────────────────── + +async function validateUrl(url: string): Promise { + 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(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 ( + + + Server Setup + + Enter your instance URL to get started. The app will verify it before + saving. + + + { setInput(t); setError(null); }} + editable={!loading} + /> + + {error && {error}} + + + {loading ? ( + + ) : ( + Validate & Save + )} + + + + ); +} + +// ─── Provider ───────────────────────────────────────────────────────────────── + +export function InstanceUrlProvider({ children }: { children: React.ReactNode }) { + const [baseUrl, setBaseUrl] = useState(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 ( + + + + ); + } + + if (!configured) { + return ; + } + + return ( + + {children} + + ); +} + +// ─── 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, + }, +}); diff --git a/mobile/src/pages/datacards.tsx b/mobile/src/pages/datacards.tsx index 6e60b12..b075555 100644 --- a/mobile/src/pages/datacards.tsx +++ b/mobile/src/pages/datacards.tsx @@ -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 ( + <> + + + URL of your Spotify currently-playing proxy endpoint. + onChange("url", v)} + autoCapitalize="none" + keyboardType="url" + /> + + + + onChange("refresh_interval", v)} + keyboardType="numeric" + /> + + > + ); +} + // ─── 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 ( @@ -910,11 +968,12 @@ function FormView({ form, onChange, onBoolChange, onLayoutChange, onTypeChange, onChange("name", v)} /> - + {form.type === "custom_json" && } {form.type === "static_text" && } {form.type === "clock" && } {form.type === "image_rotator" && } + {form.type === "spotify" && } {showDisplayOptions && ( <> @@ -954,16 +1013,18 @@ const CARD_TYPE_ICONS: Record = { 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("list"); const [cards, setCards] = useState([]); const [loading, setLoading] = useState(true); diff --git a/mobile/src/pages/image.tsx b/mobile/src/pages/image.tsx index a037edd..e252bb0 100644 --- a/mobile/src/pages/image.tsx +++ b/mobile/src/pages/image.tsx @@ -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); diff --git a/mobile/src/pages/settings.tsx b/mobile/src/pages/settings.tsx index 1d12a0c..9467c80 100644 --- a/mobile/src/pages/settings.tsx +++ b/mobile/src/pages/settings.tsx @@ -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(""); const [pendingUri, setPendingUri] = useState(null); @@ -475,6 +474,19 @@ export function SettingsPage() { {status && ( {status} )} + + {/* ── Server ─────────────────────────────────────────────────────── */} + + Server + {BASE_URL} + + Change Server URL + + > )} diff --git a/mobile/src/pages/text.tsx b/mobile/src/pages/text.tsx index 646ae22..6474590 100644 --- a/mobile/src/pages/text.tsx +++ b/mobile/src/pages/text.tsx @@ -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); diff --git a/tv/src/App.tsx b/tv/src/App.tsx index 9c0f265..0cc4a5d 100644 --- a/tv/src/App.tsx +++ b/tv/src/App.tsx @@ -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) => { diff --git a/tv/src/components/DataCardWidget.tsx b/tv/src/components/DataCardWidget.tsx index c68f5c6..350da50 100644 --- a/tv/src/components/DataCardWidget.tsx +++ b/tv/src/components/DataCardWidget.tsx @@ -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(null); + const [error, setError] = useState(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 ( + syncing{".".repeat(dots)} + ) : undefined + } + > + {error ? ( + ⚠ {error} + ) : !payload ? ( + Loading… + ) : !isPlaying || !item ? ( + + ⏸ + Nothing playing + + ) : ( + + {/* Album art + info row */} + + {albumArt && ( + + )} + + {trackName} + {artistNames} + {albumName} + + + {/* Progress bar */} + + + + + + {fmtMs(progressMs)} + {fmtMs(durationMs)} + + + + )} + + ); +} + // ─── Dispatcher ─────────────────────────────────────────────────────────────── export function DataCardWidget({ card, layout, isNightMode, nightMessage }: { card: DataCard; layout?: CardLayout; isNightMode?: boolean; nightMessage?: string }) { if (card.type === "static_text") return ; if (card.type === "clock") return ; if (card.type === "image_rotator") return ; + if (card.type === "spotify") return ; // default: custom_json return ; } diff --git a/tv/src/types.ts b/tv/src/types.ts index 4acca17..56355ea 100644 --- a/tv/src/types.ts +++ b/tv/src/types.ts @@ -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; diff --git a/tv/vite.config.ts b/tv/vite.config.ts index e4d40b5..26ad628 100644 --- a/tv/vite.config.ts +++ b/tv/vite.config.ts @@ -4,4 +4,5 @@ import tailwindcss from '@tailwindcss/vite' // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + envDir: '../', })