Compare commits
7 Commits
build-2e64
...
build-cf13
| Author | SHA1 | Date | |
|---|---|---|---|
| cf134f8981 | |||
| 6cb367d99d | |||
| 6f66342dea | |||
| d29a2a565d | |||
| 16e4e3f0ae | |||
| eb4e7ea26a | |||
| 37bf9c74cb |
@@ -50,11 +50,15 @@ jobs:
|
|||||||
--profile=preview
|
--profile=preview
|
||||||
working-directory: mobile
|
working-directory: mobile
|
||||||
|
|
||||||
|
# Neuer Schritt: Rename das Binary, damit es wie eine echte App aussieht
|
||||||
|
- name: 📝 Rename build to APK
|
||||||
|
run: mv mobile/app-build mobile/app-release.apk
|
||||||
|
|
||||||
- name: 📤 Upload build artifact
|
- name: 📤 Upload build artifact
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
with:
|
with:
|
||||||
name: android-preview-build.zip
|
name: android-preview-build
|
||||||
path: mobile/app-build
|
path: mobile/app-release.apk
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|
||||||
- name: 🏷 Create tag
|
- name: 🏷 Create tag
|
||||||
@@ -69,7 +73,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
tag_name: ${{ env.RELEASE_TAG }}
|
tag_name: ${{ env.RELEASE_TAG }}
|
||||||
name: ${{ env.RELEASE_TAG }}
|
name: ${{ env.RELEASE_TAG }}
|
||||||
files: mobile/app-build
|
files: mobile/app-release.apk
|
||||||
generate_release_notes: true
|
generate_release_notes: true
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -3,7 +3,7 @@ dist
|
|||||||
build
|
build
|
||||||
.expo
|
.expo
|
||||||
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
|
||||||
|
.env
|
||||||
# dependencies
|
# dependencies
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
|
|||||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
services:
|
||||||
|
tv:
|
||||||
|
image: node:22
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./tv:/app
|
||||||
|
- tv_node_modules:/app/node_modules
|
||||||
|
ports:
|
||||||
|
- "4173:4173"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
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:
|
||||||
1
example.env
Normal file
1
example.env
Normal file
@@ -0,0 +1 @@
|
|||||||
|
VITE_INSTANCE_URL=http://localhost:8000
|
||||||
@@ -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":
|
||||||
|
|||||||
@@ -1,6 +1,153 @@
|
|||||||
|
import base64
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import requests
|
||||||
|
import time
|
||||||
|
import redis
|
||||||
|
|
||||||
|
REDIRECT_URL = "https://shsf-api.reversed.dev/api/exec/15/e7f4f8a2-5bef-413d-85fe-6c47a6cfc1e2/spoti"
|
||||||
|
SPOTIFY_TOKEN_URL = "https://accounts.spotify.com/api/token"
|
||||||
|
|
||||||
|
# Separate Redis DB from the control function (which uses db=12)
|
||||||
|
r = redis.Redis(host='194.15.36.205', username="default", port=12004, db=13, password=os.getenv("REDIS"))
|
||||||
|
|
||||||
|
SPOTIFY_TOKENS_KEY = "data-sources:spotify-tokens"
|
||||||
|
|
||||||
|
|
||||||
|
def _save_tokens(access_token: str, refresh_token: str, expires_in: int):
|
||||||
|
payload = {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"expires_at": int(time.time()) + expires_in,
|
||||||
|
}
|
||||||
|
r.set(SPOTIFY_TOKENS_KEY, json.dumps(payload))
|
||||||
|
|
||||||
|
|
||||||
|
def _read_tokens():
|
||||||
|
raw = r.get(SPOTIFY_TOKENS_KEY)
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
return json.loads(raw)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
return None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_access_token(refresh_token: str):
|
||||||
|
client_id = os.getenv("SPOTIFY_CLIENT_ID")
|
||||||
|
client_secret = os.getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
|
auth = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||||
|
resp = requests.post(
|
||||||
|
SPOTIFY_TOKEN_URL,
|
||||||
|
headers={"Authorization": f"Basic {auth}", "Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
data={"grant_type": "refresh_token", "refresh_token": refresh_token},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
_save_tokens(
|
||||||
|
data["access_token"],
|
||||||
|
data.get("refresh_token", refresh_token), # Spotify may or may not return a new refresh token
|
||||||
|
data.get("expires_in", 3600),
|
||||||
|
)
|
||||||
|
return data["access_token"]
|
||||||
|
|
||||||
|
|
||||||
|
def _get_valid_access_token():
|
||||||
|
"""Return a valid access token, refreshing if needed. Returns None if no tokens stored."""
|
||||||
|
tokens = _read_tokens()
|
||||||
|
if not tokens:
|
||||||
|
return None
|
||||||
|
# Refresh 60s before expiry
|
||||||
|
if time.time() >= tokens["expires_at"] - 60:
|
||||||
|
return _refresh_access_token(tokens["refresh_token"])
|
||||||
|
return tokens["access_token"]
|
||||||
|
|
||||||
|
|
||||||
def main(args):
|
def main(args):
|
||||||
route = args.get("route")
|
route = args.get("route")
|
||||||
|
|
||||||
if route == "test":
|
if route == "test":
|
||||||
return {"status": "success", "text": "Text Test Success!"}
|
return {"status": "success", "text": "Text Test Success!"}
|
||||||
return {"status": "success", "message": "Data sources endpoint is up!"}
|
|
||||||
|
if route == "auth_spoti":
|
||||||
|
client_id = os.getenv("SPOTIFY_CLIENT_ID")
|
||||||
|
url = (
|
||||||
|
f"https://accounts.spotify.com/authorize"
|
||||||
|
f"?client_id={client_id}"
|
||||||
|
f"&response_type=code"
|
||||||
|
f"&redirect_uri={REDIRECT_URL}"
|
||||||
|
f"&scope=user-read-playback-state%20user-modify-playback-state%20user-read-currently-playing"
|
||||||
|
)
|
||||||
|
return {"_code": 302, "_location": url, "_shsf": "v2"}
|
||||||
|
|
||||||
|
if route == "spoti":
|
||||||
|
code = args.get("queries", {}).get("code")
|
||||||
|
if not code:
|
||||||
|
return {"status": "error", "message": "Missing code parameter"}
|
||||||
|
|
||||||
|
client_id = os.getenv("SPOTIFY_CLIENT_ID")
|
||||||
|
client_secret = os.getenv("SPOTIFY_CLIENT_SECRET")
|
||||||
|
if not client_id or not client_secret:
|
||||||
|
return {"status": "error", "message": "Missing Spotify credentials in environment"}
|
||||||
|
|
||||||
|
# Exchange authorization code for tokens
|
||||||
|
auth = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
SPOTIFY_TOKEN_URL,
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Basic {auth}",
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
|
},
|
||||||
|
data={
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"code": code,
|
||||||
|
"redirect_uri": REDIRECT_URL,
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
except requests.RequestException as e:
|
||||||
|
return {"status": "error", "message": f"Token exchange failed: {e}"}
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
if "error" in data:
|
||||||
|
return {"status": "error", "message": data.get("error_description", data["error"])}
|
||||||
|
|
||||||
|
_save_tokens(data["access_token"], data["refresh_token"], data.get("expires_in", 3600))
|
||||||
|
return {"status": "success", "message": "Spotify authenticated and tokens saved."}
|
||||||
|
|
||||||
|
if route == "get_spoti_tokens":
|
||||||
|
tokens = _read_tokens()
|
||||||
|
if not tokens:
|
||||||
|
return {"status": "error", "message": "No Spotify tokens stored"}
|
||||||
|
return {"status": "success", "tokens": tokens}
|
||||||
|
|
||||||
|
if route == "get_spoti_now_playing":
|
||||||
|
try:
|
||||||
|
access_token = _get_valid_access_token()
|
||||||
|
except requests.RequestException as e:
|
||||||
|
return {"status": "error", "message": f"Token refresh failed: {e}"}
|
||||||
|
|
||||||
|
if not access_token:
|
||||||
|
return {"status": "error", "message": "Not authenticated with Spotify"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
"https://api.spotify.com/v1/me/player/currently-playing",
|
||||||
|
headers={"Authorization": f"Bearer {access_token}"},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
return {"status": "error", "message": f"Spotify API request failed: {e}"}
|
||||||
|
|
||||||
|
if resp.status_code == 204:
|
||||||
|
return {"status": "success", "playing": False, "message": "Nothing currently playing"}
|
||||||
|
if not resp.ok:
|
||||||
|
return {"status": "error", "message": f"Spotify API error {resp.status_code}"}
|
||||||
|
|
||||||
|
return {"status": "success", "playing": True, "data": resp.json()}
|
||||||
|
|
||||||
|
return {"status": "success", "message": "Data sources endpoint is up!"}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
requests
|
requests
|
||||||
|
redis
|
||||||
@@ -9,6 +9,7 @@ import { IndexPage } from "./src/pages/index";
|
|||||||
import { SettingsPage } from "./src/pages/settings";
|
import { SettingsPage } from "./src/pages/settings";
|
||||||
import { TextPage } from "./src/pages/text";
|
import { TextPage } from "./src/pages/text";
|
||||||
import { Route, RouterProvider, useRouter } from "./src/router";
|
import { Route, RouterProvider, useRouter } from "./src/router";
|
||||||
|
import { InstanceUrlProvider } from "./src/instanceUrl";
|
||||||
interface Tab {
|
interface Tab {
|
||||||
label: string;
|
label: string;
|
||||||
route: Route;
|
route: Route;
|
||||||
@@ -47,15 +48,17 @@ function Screen() {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<RouterProvider>
|
<InstanceUrlProvider>
|
||||||
<View style={styles.container}>
|
<RouterProvider>
|
||||||
<StatusBar style="light" />
|
<View style={styles.container}>
|
||||||
<View style={{ flex: 1, marginTop: 12 }}>
|
<StatusBar style="light" />
|
||||||
<Screen />
|
<View style={{ flex: 1, marginTop: 12 }}>
|
||||||
|
<Screen />
|
||||||
|
</View>
|
||||||
|
<BottomNav />
|
||||||
</View>
|
</View>
|
||||||
<BottomNav />
|
</RouterProvider>
|
||||||
</View>
|
</InstanceUrlProvider>
|
||||||
</RouterProvider>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
"@react-native-community/datetimepicker": "^8.6.0",
|
"@react-native-community/datetimepicker": "^8.6.0",
|
||||||
"expo": "~54.0.33",
|
"expo": "~54.0.33",
|
||||||
"expo-image-picker": "^55.0.10",
|
"expo-image-picker": "^55.0.10",
|
||||||
|
"expo-secure-store": "^55.0.8",
|
||||||
"expo-status-bar": "~3.0.9",
|
"expo-status-bar": "~3.0.9",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5"
|
"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:
|
expo-image-picker:
|
||||||
specifier: ^55.0.10
|
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))
|
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:
|
expo-status-bar:
|
||||||
specifier: ~3.0.9
|
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)
|
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: '*'
|
||||||
react-native: '*'
|
react-native: '*'
|
||||||
|
|
||||||
|
expo-secure-store@55.0.8:
|
||||||
|
resolution: {integrity: sha512-8w9tQe8U6oRo5YIzqCqVhRrOnfoODNDoitBtLXEx+zS6WLUnkRq5kH7ViJuOgiM7PzLr9pvAliRiDOKyvFbTuQ==}
|
||||||
|
peerDependencies:
|
||||||
|
expo: '*'
|
||||||
|
|
||||||
expo-server@1.0.5:
|
expo-server@1.0.5:
|
||||||
resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==}
|
resolution: {integrity: sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA==}
|
||||||
engines: {node: '>=20.16.0'}
|
engines: {node: '>=20.16.0'}
|
||||||
@@ -4253,6 +4261,10 @@ snapshots:
|
|||||||
react: 19.1.0
|
react: 19.1.0
|
||||||
react-native: 0.81.5(@babel/core@7.29.0)(@types/react@19.1.17)(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-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):
|
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,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { useBaseUrl } from "../instanceUrl";
|
||||||
import { useRouter } from "../router";
|
import { useRouter } from "../router";
|
||||||
import { colors } from "../styles";
|
import { colors } from "../styles";
|
||||||
|
|
||||||
const BASE_URL =
|
|
||||||
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
|
|
||||||
|
|
||||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
// ─── 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 */
|
/** 4-column × 4-row grid, 1-based */
|
||||||
export interface CardLayout {
|
export interface CardLayout {
|
||||||
@@ -73,7 +71,14 @@ interface ImageRotatorCard {
|
|||||||
layout?: CardLayout;
|
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 ──────────────────────────────────────────────────────────
|
// ─── Flat form state ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -101,6 +106,7 @@ interface FormState {
|
|||||||
image_urls: string[];
|
image_urls: string[];
|
||||||
image_interval: string;
|
image_interval: string;
|
||||||
image_fit: "cover" | "contain";
|
image_fit: "cover" | "contain";
|
||||||
|
// spotify — reuses url and refresh_interval from above
|
||||||
}
|
}
|
||||||
|
|
||||||
const EMPTY_FORM: FormState = {
|
const EMPTY_FORM: FormState = {
|
||||||
@@ -182,6 +188,13 @@ function cardToForm(card: DataCard): FormState {
|
|||||||
image_fit: card.config.fit ?? "cover",
|
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
|
// custom_json
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
@@ -235,6 +248,16 @@ function formToCard(form: FormState, id: string): DataCard {
|
|||||||
layout,
|
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
|
// custom_json
|
||||||
return {
|
return {
|
||||||
id, type: "custom_json", name: form.name.trim(),
|
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: "static_text", label: "Static Text", icon: "📝", desc: "Fixed text or note" },
|
||||||
{ type: "clock", label: "Clock / Timer", icon: "🕐", desc: "Live time or countdown" },
|
{ type: "clock", label: "Clock / Timer", icon: "🕐", desc: "Live time or countdown" },
|
||||||
{ type: "image_rotator", label: "Image Slideshow", icon: "🖼", desc: "Rotating image gallery" },
|
{ type: "image_rotator", label: "Image Slideshow", icon: "🖼", desc: "Rotating image gallery" },
|
||||||
|
{ type: "spotify", label: "Spotify", icon: "🎵", desc: "Now playing from Spotify" },
|
||||||
];
|
];
|
||||||
|
|
||||||
interface TypeSelectorProps {
|
interface TypeSelectorProps {
|
||||||
@@ -748,6 +772,7 @@ interface ImageRotatorFieldsProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ImageRotatorFields({ form, onChange, onUrlsChange }: ImageRotatorFieldsProps) {
|
function ImageRotatorFields({ form, onChange, onUrlsChange }: ImageRotatorFieldsProps) {
|
||||||
|
const { baseUrl: BASE_URL } = useBaseUrl();
|
||||||
const [urlInput, setUrlInput] = useState("");
|
const [urlInput, setUrlInput] = useState("");
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
@@ -873,6 +898,39 @@ const imgStyles = StyleSheet.create({
|
|||||||
pickBtnText: { fontSize: 15, fontWeight: "500", color: colors.textPrimary },
|
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 ───────────────────────────────────────────────────────────
|
// ─── Full form view ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
interface FormViewProps {
|
interface FormViewProps {
|
||||||
@@ -890,7 +948,7 @@ interface FormViewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function FormView({ form, onChange, onBoolChange, onLayoutChange, onTypeChange, onUrlsChange, onSave, onCancel, saving, isEdit, otherLayouts }: 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 (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<View style={styles.header}>
|
<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)} />
|
<TextInput style={styles.input} placeholder="My Widget" placeholderTextColor={colors.placeholderColor} value={form.name} onChangeText={(v) => onChange("name", v)} />
|
||||||
</View>
|
</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 === "custom_json" && <CustomJsonFields form={form} onChange={onChange} />}
|
||||||
{form.type === "static_text" && <StaticTextFields 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 === "clock" && <ClockFields form={form} onChange={onChange} onBoolChange={onBoolChange} />}
|
||||||
{form.type === "image_rotator" && <ImageRotatorFields form={form} onChange={onChange} onUrlsChange={onUrlsChange} />}
|
{form.type === "image_rotator" && <ImageRotatorFields form={form} onChange={onChange} onUrlsChange={onUrlsChange} />}
|
||||||
|
{form.type === "spotify" && <SpotifyFields form={form} onChange={onChange} />}
|
||||||
|
|
||||||
{showDisplayOptions && (
|
{showDisplayOptions && (
|
||||||
<>
|
<>
|
||||||
@@ -954,16 +1013,18 @@ const CARD_TYPE_ICONS: Record<CardType, string> = {
|
|||||||
static_text: "📝",
|
static_text: "📝",
|
||||||
clock: "🕐",
|
clock: "🕐",
|
||||||
image_rotator: "🖼",
|
image_rotator: "🖼",
|
||||||
|
spotify: "🎵",
|
||||||
};
|
};
|
||||||
|
|
||||||
function cardSubtitle(card: DataCard): string {
|
function cardSubtitle(card: DataCard): string {
|
||||||
if (card.type === "static_text") return card.config.text.slice(0, 80) || "(empty)";
|
if (card.type === "static_text") return card.config.text.slice(0, 80) || "(empty)";
|
||||||
if (card.type === "clock") {
|
if (card.type === "clock") {
|
||||||
const c = card.config;
|
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"}`;
|
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 === "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;
|
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 === "custom_json") return `Refresh: ${(card as CustomJsonCard).config.refresh_interval}s`;
|
||||||
if (card.type === "image_rotator") return `Fit: ${card.config.fit}`;
|
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 === "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 "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1013,6 +1075,7 @@ type PageView = "list" | "form";
|
|||||||
|
|
||||||
export function DataCardsPage() {
|
export function DataCardsPage() {
|
||||||
const { navigate } = useRouter();
|
const { navigate } = useRouter();
|
||||||
|
const { baseUrl: BASE_URL } = useBaseUrl();
|
||||||
const [pageView, setPageView] = useState<PageView>("list");
|
const [pageView, setPageView] = useState<PageView>("list");
|
||||||
const [cards, setCards] = useState<DataCard[]>([]);
|
const [cards, setCards] = useState<DataCard[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import * as ImagePicker from "expo-image-picker";
|
import * as ImagePicker from "expo-image-picker";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ActivityIndicator, Image, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, Image, StyleSheet, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useBaseUrl } from "../instanceUrl";
|
||||||
import { colors, shared } from "../styles";
|
import { colors, shared } from "../styles";
|
||||||
|
|
||||||
const BASE_URL =
|
|
||||||
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
|
|
||||||
|
|
||||||
export function ImagePage() {
|
export function ImagePage() {
|
||||||
|
const { baseUrl: BASE_URL } = useBaseUrl();
|
||||||
const [caption, setCaption] = useState("");
|
const [caption, setCaption] = useState("");
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
const [dismissing, setDismissing] = useState(false);
|
const [dismissing, setDismissing] = useState(false);
|
||||||
|
|||||||
@@ -5,22 +5,62 @@ import {
|
|||||||
Image,
|
Image,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
StyleSheet,
|
StyleSheet,
|
||||||
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
TouchableOpacity,
|
TouchableOpacity,
|
||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
|
import { useBaseUrl } from "../instanceUrl";
|
||||||
import { colors, shared } from "../styles";
|
import { colors, shared } from "../styles";
|
||||||
|
|
||||||
const BASE_URL =
|
// ─── Preset background colours ────────────────────────────────────────────────
|
||||||
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
|
|
||||||
|
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() {
|
||||||
|
const { baseUrl: BASE_URL, resetUrl } = useBaseUrl();
|
||||||
|
// ── 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 +72,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 +129,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 +145,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 +164,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 +179,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 +187,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 +265,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 ── */}
|
{loading ? (
|
||||||
<View style={styles.section}>
|
<ActivityIndicator color={colors.accent} style={{ marginTop: 24 }} />
|
||||||
<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).
|
{/* ── Background Image ───────────────────────────────────────────── */}
|
||||||
</Text>
|
<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 ? (
|
{previewUri ? (
|
||||||
<ActivityIndicator color={colors.accent} style={{ marginTop: 12 }} />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{/* Preview */}
|
|
||||||
{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 +285,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 +305,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 +317,178 @@ 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>
|
||||||
|
|
||||||
{status && (
|
{/* ── Background Colour ──────────────────────────────────────────── */}
|
||||||
<Text style={[shared.hint, styles.statusText]}>{status}</Text>
|
<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).
|
||||||
</View>
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ── 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>
|
</ScrollView>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -235,12 +496,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 +546,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,11 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ActivityIndicator, Text, TextInput, TouchableOpacity, View } from "react-native";
|
import { ActivityIndicator, Text, TextInput, TouchableOpacity, View } from "react-native";
|
||||||
|
import { useBaseUrl } from "../instanceUrl";
|
||||||
import { colors, shared } from "../styles";
|
import { colors, shared } from "../styles";
|
||||||
|
|
||||||
const BASE_URL =
|
|
||||||
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329";
|
|
||||||
|
|
||||||
export function TextPage() {
|
export function TextPage() {
|
||||||
|
const { baseUrl: BASE_URL } = useBaseUrl();
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [dismissing, setDismissing] = useState(false);
|
const [dismissing, setDismissing] = useState(false);
|
||||||
|
|||||||
@@ -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,11 +105,23 @@ 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 = () => {
|
||||||
fetch(
|
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((response) => response.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -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">
|
||||||
<p className="text-gray-600 text-lg">
|
{isNightActive ? (
|
||||||
Waiting for content
|
<div className="flex flex-col items-center gap-3">
|
||||||
<span className="dot-1">.</span>
|
<span className="text-6xl select-none">🌙</span>
|
||||||
<span className="dot-2">.</span>
|
<p className="text-gray-400 text-2xl font-semibold">
|
||||||
<span className="dot-3">.</span>
|
{settings.night_mode?.message || "Good Night"}
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-gray-600 text-lg">
|
||||||
|
Waiting for content
|
||||||
|
<span className="dot-1">.</span>
|
||||||
|
<span className="dot-2">.</span>
|
||||||
|
<span className="dot-3">.</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type {
|
|||||||
CustomJsonCard,
|
CustomJsonCard,
|
||||||
DataCard,
|
DataCard,
|
||||||
ImageRotatorCard,
|
ImageRotatorCard,
|
||||||
|
SpotifyCard,
|
||||||
StaticTextCard,
|
StaticTextCard,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { evaluatePath } from "../utils/evaluatePath";
|
import { evaluatePath } from "../utils/evaluatePath";
|
||||||
@@ -234,12 +235,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 +252,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 (
|
||||||
@@ -290,12 +305,151 @@ function ImageRotatorWidget({ card }: { card: ImageRotatorCard }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 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 ───────────────────────────────────────────────────────────────
|
// ─── 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} />;
|
||||||
|
if (card.type === "spotify") return <SpotifyWidget card={card} />;
|
||||||
// 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 {
|
||||||
@@ -101,6 +117,22 @@ export interface ImageRotatorCard {
|
|||||||
layout?: CardLayout;
|
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 ────────────────────────────────────────────────────────────────────
|
// ─── 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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react(), tailwindcss()],
|
plugins: [react(), tailwindcss()],
|
||||||
|
envDir: '../',
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user