Added spotify and moved away from hardcoded urls
Some checks failed
Build App / build (push) Has been cancelled
Some checks failed
Build App / build (push) Has been cancelled
This commit is contained in:
@@ -121,7 +121,7 @@ function App() {
|
||||
if (screenStatus === "fullscreen") {
|
||||
const handlePullState = () => {
|
||||
fetch(
|
||||
"https://shsf-api.reversed.dev/api/exec/15/1c44d8b5-4065-4a54-b259-748561021329/pull_full",
|
||||
`${import.meta.env.VITE_INSTANCE_URL}/pull_full`,
|
||||
)
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
CustomJsonCard,
|
||||
DataCard,
|
||||
ImageRotatorCard,
|
||||
SpotifyCard,
|
||||
StaticTextCard,
|
||||
} from "../types";
|
||||
import { evaluatePath } from "../utils/evaluatePath";
|
||||
@@ -304,12 +305,151 @@ function ImageRotatorWidget({ card, isNightMode, nightMessage }: { card: ImageRo
|
||||
);
|
||||
}
|
||||
|
||||
// ─── spotify widget ──────────────────────────────────────────────────────────
|
||||
|
||||
interface SpotifyItem {
|
||||
name: string;
|
||||
duration_ms: number;
|
||||
artists: Array<{ name: string }>;
|
||||
album: {
|
||||
name: string;
|
||||
images: Array<{ url: string; width: number; height: number }>;
|
||||
};
|
||||
}
|
||||
|
||||
interface SpotifyPayload {
|
||||
is_playing: boolean;
|
||||
progress_ms?: number;
|
||||
item?: SpotifyItem;
|
||||
}
|
||||
|
||||
function SpotifyWidget({ card }: { card: SpotifyCard }) {
|
||||
const [payload, setPayload] = useState<SpotifyPayload | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [pulling, setPulling] = useState(false);
|
||||
const [dots, setDots] = useState(0);
|
||||
// live progress ticker — resets to 0 on each successful fetch
|
||||
const [tickMs, setTickMs] = useState(0);
|
||||
|
||||
// Advance local progress every second
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setTickMs((t) => t + 1000), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pulling) return;
|
||||
const d = setInterval(() => setDots((x) => (x + 1) % 4), 400);
|
||||
return () => clearInterval(d);
|
||||
}, [pulling]);
|
||||
|
||||
useEffect(() => {
|
||||
const doFetch = () => {
|
||||
setPulling(true);
|
||||
setDots(0);
|
||||
fetch(card.config.url)
|
||||
.then((r) => r.json())
|
||||
.then((raw) => {
|
||||
// Support both bare payload and { status, data } wrapper
|
||||
const p: SpotifyPayload = raw?.data ?? raw;
|
||||
setPayload(p);
|
||||
setTickMs(0);
|
||||
setError(null);
|
||||
setPulling(false);
|
||||
})
|
||||
.catch((e) => {
|
||||
setError(String(e));
|
||||
setPulling(false);
|
||||
});
|
||||
};
|
||||
doFetch();
|
||||
const ms = Math.max(5000, (card.config.refresh_interval ?? 30) * 1000);
|
||||
const iv = setInterval(doFetch, ms);
|
||||
return () => clearInterval(iv);
|
||||
}, [card.id, card.config.url, card.config.refresh_interval]);
|
||||
|
||||
const item = payload?.item;
|
||||
const isPlaying = payload?.is_playing ?? false;
|
||||
const progressMs = Math.min(
|
||||
item?.duration_ms ?? 0,
|
||||
(payload?.progress_ms ?? 0) + (isPlaying ? tickMs : 0),
|
||||
);
|
||||
const durationMs = item?.duration_ms ?? 0;
|
||||
const progressPct = durationMs > 0 ? (progressMs / durationMs) * 100 : 0;
|
||||
|
||||
const albumArt = item?.album.images?.[0]?.url;
|
||||
const trackName = item?.name ?? "";
|
||||
const artistNames = item?.artists?.map((a) => a.name).join(", ") ?? "";
|
||||
const albumName = item?.album?.name ?? "";
|
||||
|
||||
const fmtMs = (ms: number) => {
|
||||
const s = Math.floor(ms / 1000);
|
||||
const m = Math.floor(s / 60);
|
||||
return `${m}:${String(s % 60).padStart(2, "0")}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<CardShell
|
||||
name={card.name}
|
||||
footer={
|
||||
pulling ? (
|
||||
<span className="text-gray-500 text-xs">syncing{".".repeat(dots)}</span>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{error ? (
|
||||
<span className="text-red-400 text-sm break-all">⚠ {error}</span>
|
||||
) : !payload ? (
|
||||
<span className="text-gray-500 text-sm">Loading…</span>
|
||||
) : !isPlaying || !item ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 opacity-50">
|
||||
<span className="text-4xl">⏸</span>
|
||||
<span className="text-gray-400 text-sm">Nothing playing</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-full gap-2 min-h-0">
|
||||
{/* Album art + info row */}
|
||||
<div className="flex gap-3 items-center min-h-0 flex-1 overflow-hidden">
|
||||
{albumArt && (
|
||||
<img
|
||||
src={albumArt}
|
||||
alt={albumName}
|
||||
className="rounded-lg shrink-0 object-cover"
|
||||
style={{ width: 56, height: 56 }}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-col justify-center overflow-hidden">
|
||||
<span className="text-white font-semibold text-sm leading-tight truncate">{trackName}</span>
|
||||
<span className="text-gray-400 text-xs truncate">{artistNames}</span>
|
||||
<span className="text-gray-600 text-xs truncate mt-0.5">{albumName}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar */}
|
||||
<div className="shrink-0">
|
||||
<div className="w-full h-1 rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-1 rounded-full bg-green-500"
|
||||
style={{ width: `${progressPct}%`, transition: "width 1s linear" }}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-between mt-0.5">
|
||||
<span className="text-gray-600 text-xs tabular-nums">{fmtMs(progressMs)}</span>
|
||||
<span className="text-gray-600 text-xs tabular-nums">{fmtMs(durationMs)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Dispatcher ───────────────────────────────────────────────────────────────
|
||||
|
||||
export function DataCardWidget({ card, layout, isNightMode, nightMessage }: { card: DataCard; layout?: CardLayout; isNightMode?: boolean; nightMessage?: string }) {
|
||||
if (card.type === "static_text") return <StaticTextWidget card={card} layout={layout} />;
|
||||
if (card.type === "clock") return <ClockWidget card={card} layout={layout} />;
|
||||
if (card.type === "image_rotator") return <ImageRotatorWidget card={card} isNightMode={isNightMode} nightMessage={nightMessage} />;
|
||||
if (card.type === "spotify") return <SpotifyWidget card={card} />;
|
||||
// default: custom_json
|
||||
return <CustomJsonWidget card={card as CustomJsonCard} layout={layout} />;
|
||||
}
|
||||
|
||||
@@ -117,6 +117,22 @@ export interface ImageRotatorCard {
|
||||
layout?: CardLayout;
|
||||
}
|
||||
|
||||
// ─── spotify ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SpotifyConfig {
|
||||
/** URL of a Spotify currently-playing proxy that returns the standard shape */
|
||||
url: string;
|
||||
refresh_interval: number;
|
||||
}
|
||||
|
||||
export interface SpotifyCard {
|
||||
id: string;
|
||||
type: "spotify";
|
||||
name: string;
|
||||
config: SpotifyConfig;
|
||||
layout?: CardLayout;
|
||||
}
|
||||
|
||||
// ─── Union ────────────────────────────────────────────────────────────────────
|
||||
|
||||
export type DataCard = CustomJsonCard | StaticTextCard | ClockCard | ImageRotatorCard;
|
||||
export type DataCard = CustomJsonCard | StaticTextCard | ClockCard | ImageRotatorCard | SpotifyCard;
|
||||
|
||||
@@ -4,4 +4,5 @@ import tailwindcss from '@tailwindcss/vite'
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
envDir: '../',
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user