소스 검색

now playing stuff

simo 3 주 전
부모
커밋
2eacc402ce

+ 3 - 3
astro.config.mjs

@@ -5,6 +5,6 @@ import sitemap from "@astrojs/sitemap";
 
 // https://astro.build/config
 export default defineConfig({
-  site: 'https://simo.ng',
-  integrations: [tailwind(), sitemap()]
-});
+	site: "https://simo.ng",
+	integrations: [tailwind(), sitemap()],
+});

+ 332 - 0
src/components/nowplaying.astro

@@ -0,0 +1,332 @@
+---
+import Win98Window from "./win98window.astro";
+
+const layout = {
+  mobile: { left: "2%", top: "60%", width: "96%", height: "auto" },
+  desktop: { left: "66%", top: "8%", width: "320px", height: "auto" },
+};
+---
+
+<Win98Window title="Now Playing" layout={layout}, shown=true>
+  <div class="np" id="nowplaying">
+    <div class="np-row">
+      <div class="np-art" aria-hidden="true">
+        <div class="np-art-inner" id="np-art-inner"></div>
+      </div>
+      <div class="np-meta">
+        <div class="np-title" id="np-title">Not connected</div>
+        <div class="np-artist" id="np-artist">Spotify</div>
+        <div class="np-album" id="np-album"></div>
+      </div>
+    </div>
+
+    <div class="np-progress">
+      <div class="np-time" id="np-time">--:-- / --:--</div>
+      <div class="np-bar" role="progressbar" aria-label="track progress">
+        <div class="np-bar-fill" id="np-bar-fill"></div>
+      </div>
+    </div>
+
+    <div class="np-controls">
+      <button id="np-open" disabled>Open</button>
+      <button id="np-refresh">Refresh</button>
+    </div>
+  </div>
+</Win98Window>
+
+<style>
+  .np {
+    width: 100%;
+    max-width: 100%;
+  }
+
+  .np-row {
+    display: flex;
+    gap: 10px;
+    align-items: center;
+  }
+
+  .np-art {
+    width: 56px;
+    height: 56px;
+    background: silver;
+    box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey,
+      inset 2px 2px #dfdfdf;
+    padding: 3px;
+    flex: 0 0 auto;
+  }
+
+  .np-art-inner {
+    width: 100%;
+    height: 100%;
+    background: #000;
+    background-size: cover;
+    background-position: center;
+  }
+
+  .np-meta {
+    min-width: 0;
+    flex: 1 1 auto;
+  }
+
+  .np-title {
+    font-weight: 700;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+  }
+
+  .np-artist,
+  .np-album {
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    color: #222;
+  }
+
+  .np-album {
+    color: #444;
+  }
+
+  .np-progress {
+    margin-top: 10px;
+  }
+
+  .np-time {
+    font-size: 11px;
+    margin-bottom: 4px;
+  }
+
+  .np-bar {
+    height: 14px;
+    background: white;
+    box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf,
+      inset 2px 2px grey;
+    padding: 2px;
+  }
+
+  .np-bar-fill {
+    height: 100%;
+    width: 0%;
+    background: navy;
+  }
+
+  .np-controls {
+    margin-top: 10px;
+    display: flex;
+    gap: 8px;
+  }
+
+  .np-controls button {
+    min-width: 80px;
+  }
+
+  .np-hint {
+    margin-top: 10px;
+    font-size: 11px;
+    color: #333;
+  }
+</style>
+
+<script>
+  const apiUrl = "/api/spotify/now-playing.json";
+  const pollMs = 8000;
+
+  const elTitle = document.getElementById("np-title");
+  const elArtist = document.getElementById("np-artist");
+  const elAlbum = document.getElementById("np-album");
+  const elTime = document.getElementById("np-time");
+  const elFill = document.getElementById("np-bar-fill");
+  const elArt = document.getElementById("np-art-inner");
+  const elHint = document.getElementById("np-hint");
+  const btnOpen = document.getElementById("np-open");
+  const btnRefresh = document.getElementById("np-refresh");
+
+  let lastTrackUrl = null;
+  let timer = null;
+  let tickTimer = null;
+
+  let lastKnown = {
+    title: null,
+    artist: null,
+    album: null,
+    albumArtUrl: null,
+    trackUrl: null,
+    durationMs: null,
+    stoppedAtMs: null,
+    stoppedProgressMs: null,
+  };
+
+  let lastProgressMs = null;
+  let lastDurationMs = null;
+  let lastIsPlaying = false;
+  let lastTrackKey = null;
+  let lastSyncAt = 0;
+
+  function fmtTime(ms) {
+    if (typeof ms !== "number" || !Number.isFinite(ms)) return "--:--";
+    const s = Math.max(0, Math.floor(ms / 1000));
+    const m = Math.floor(s / 60);
+    const r = s % 60;
+    return `${m}:${String(r).padStart(2, "0")}`;
+  }
+
+  function fmtRelative(tsMs) {
+    if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "";
+    const diffMs = Date.now() - tsMs;
+    if (!Number.isFinite(diffMs) || diffMs < 0) return "";
+
+    const sec = Math.floor(diffMs / 1000);
+    const min = Math.floor(sec / 60);
+    const hr = Math.floor(min / 60);
+
+    try {
+      const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
+      if (sec < 60) return rtf.format(-sec, "second");
+      if (min < 60) return rtf.format(-min, "minute");
+      return rtf.format(-hr, "hour");
+    } catch {
+      if (sec < 60) return `${sec}s ago`;
+      if (min < 60) return `${min}m ago`;
+      return `${hr}h ago`;
+    }
+  }
+
+  function setProgress(progressMs, durationMs) {
+    if (typeof progressMs !== "number" || typeof durationMs !== "number" || durationMs <= 0) {
+      elFill.style.width = "0%";
+      elTime.textContent = "--:-- / --:--";
+      return;
+    }
+    const pct = Math.max(0, Math.min(100, (progressMs / durationMs) * 100));
+    elFill.style.width = `${pct.toFixed(1)}%`;
+    elTime.textContent = `${fmtTime(progressMs)} / ${fmtTime(durationMs)}`;
+  }
+
+  function startTicking() {
+    if (tickTimer) return;
+    tickTimer = setInterval(() => {
+      if (!lastIsPlaying) return;
+      if (typeof lastProgressMs !== "number" || typeof lastDurationMs !== "number") return;
+      const elapsed = Date.now() - lastSyncAt;
+      const est = lastProgressMs + elapsed;
+      setProgress(est, lastDurationMs);
+    }, 250);
+  }
+
+  function stopTicking() {
+    if (tickTimer) {
+      clearInterval(tickTimer);
+      tickTimer = null;
+    }
+  }
+
+  function setArt(url) {
+    if (!url) {
+      elArt.style.backgroundImage = "none";
+      return;
+    }
+    elArt.style.backgroundImage = `url(${url})`;
+  }
+
+  async function refresh() {
+    try {
+      const res = await fetch(apiUrl, { cache: "no-store" });
+      const data = await res.json();
+
+      if (!data || data.isPlaying === false || !data.title) {
+        if (lastKnown.title) {
+          elTitle.textContent = `Last played: ${lastKnown.title}`;
+          elArtist.textContent = lastKnown.artist || "";
+          const when = fmtRelative(lastKnown.stoppedAtMs);
+          elAlbum.textContent = when
+            ? `${lastKnown.album || ""} (${when})`
+            : lastKnown.album || "";
+          setArt(lastKnown.albumArtUrl);
+          setProgress(lastKnown.stoppedProgressMs, lastKnown.durationMs);
+          lastTrackUrl = lastKnown.trackUrl;
+          if (btnOpen instanceof HTMLButtonElement) btnOpen.disabled = !lastTrackUrl;
+        } else {
+          elTitle.textContent = "(Nothing playing)";
+          elArtist.textContent = "Spotify";
+          elAlbum.textContent = "";
+          setArt(null);
+          setProgress(null, null);
+          if (btnOpen instanceof HTMLButtonElement) btnOpen.disabled = true;
+          lastTrackUrl = null;
+        }
+
+        stopTicking();
+
+        if (lastIsPlaying && lastKnown.title && !lastKnown.stoppedAtMs) {
+          lastKnown.stoppedAtMs = Date.now();
+          if (typeof lastProgressMs === "number") lastKnown.stoppedProgressMs = lastProgressMs;
+        }
+
+        lastIsPlaying = false;
+        lastProgressMs = null;
+        lastDurationMs = null;
+        lastTrackKey = null;
+        lastSyncAt = 0;
+        elHint.textContent = data?.error ? `Status: ${data.error}` : "";
+        return;
+      }
+
+      elTitle.textContent = data.title;
+      elArtist.textContent = data.artist || "";
+      elAlbum.textContent = data.album || "";
+      setArt(data.albumArtUrl);
+
+      lastKnown = {
+        title: data.title ?? null,
+        artist: data.artist ?? null,
+        album: data.album ?? null,
+        albumArtUrl: data.albumArtUrl ?? null,
+        trackUrl: data.trackUrl ?? null,
+        durationMs: typeof data.durationMs === "number" ? data.durationMs : null,
+        stoppedAtMs: null,
+        stoppedProgressMs: null,
+      };
+
+      const trackKey = `${data.title}::${data.artist}::${data.album}`;
+      const durationMs = typeof data.durationMs === "number" ? data.durationMs : null;
+      const progressMs = typeof data.progressMs === "number" ? data.progressMs : null;
+      const isPlaying = Boolean(data.isPlaying);
+
+      if (lastTrackKey && trackKey !== lastTrackKey) {
+        lastProgressMs = null;
+        lastSyncAt = 0;
+      }
+
+      lastTrackKey = trackKey;
+      lastDurationMs = durationMs;
+      lastProgressMs = progressMs;
+      lastIsPlaying = isPlaying;
+      lastSyncAt = Date.now();
+
+      setProgress(progressMs, durationMs);
+
+      if (isPlaying) startTicking();
+      else stopTicking();
+
+      lastTrackUrl = data.trackUrl || null;
+      if (btnOpen instanceof HTMLButtonElement) btnOpen.disabled = !lastTrackUrl;
+      elHint.textContent = "";
+    } catch (e) {
+      elHint.textContent = "Unable to load now playing.";
+    }
+  }
+
+  btnOpen?.addEventListener("click", () => {
+    if (lastTrackUrl) window.open(lastTrackUrl, "_blank", "noopener,noreferrer");
+  });
+  btnRefresh?.addEventListener("click", () => refresh());
+
+  refresh();
+  timer = setInterval(refresh, pollMs);
+
+  window.addEventListener("beforeunload", () => {
+    if (timer) clearInterval(timer);
+    if (tickTimer) clearInterval(tickTimer);
+  });
+</script>

+ 0 - 28
src/components/searchwidget.astro

@@ -1,28 +0,0 @@
----
-import Win98Window from "./win98window.astro";
----
-
-<Win98Window
-  title="SimoSearch"
-  layout={{
-    mobile:  { left: '5%', top: '12%', width: '0%', height: '0%' },
-    desktop: { left: '62%', top: '10%', width: '35%', height: 'auto' }
-  }}
->
-  <iframe
-    title="search.simo.ng"
-    src="https://search.simo.ng/#embed"
-    loading="lazy"
-    referrerpolicy="no-referrer"
-    allowfullscreen
-  />
-</Win98Window>
-
-<style>
-  iframe {
-    display: block;
-    width: 100%;
-    height: 100%;
-    border: 0;
-  }
-</style>

+ 3 - 2
src/components/win98window.astro

@@ -1,5 +1,5 @@
 ---
-const { title, layout = {} } = Astro.props;
+const { title, layout = {}, shown = false } = Astro.props;
 
 const defaultLayout = {
   mobile:   { left: '2%', top: '5%', width: '96%', height: 'auto' },
@@ -18,7 +18,8 @@ const mergedLayout = {
     id={title}
     class="window"
     data-redirectUrl="test"
-    style={title === 'SimoSearch' ? 'display: block;' : 'display: none;'}
+    style={shown ? 'display: block;' : 'display: none;'}
+
 >
     <div id={`${title}header`} class="title-bar">
         <div class="title-bar-text">{title}</div>

+ 111 - 0
src/pages/api/spotify/callback.json.ts

@@ -0,0 +1,111 @@
+export const prerender = false;
+
+const TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token";
+
+function requiredEnv(name: string): string {
+	const v = import.meta.env[name];
+	if (!v) throw new Error(`Missing env var: ${name}`);
+	return v;
+}
+
+export async function GET({ request }: { request: Request }) {
+	try {
+		const reqUrl = new URL(request.url);
+		const code = reqUrl.searchParams.get("code");
+
+		if (!code) {
+			return new Response(
+				[
+					"Missing ?code",
+					"",
+					"If your browser shows ?code=..., but this endpoint doesn't receive it,",
+					"something in front of Astro is stripping the query string.",
+					"",
+					`request.url: ${request.url}`,
+				].join("\n"),
+				{
+					status: 400,
+					headers: { "Content-Type": "text/plain; charset=utf-8" },
+				},
+			);
+		}
+
+		const client_id = requiredEnv("SPOTIFY_CLIENT_ID");
+		const client_secret = requiredEnv("SPOTIFY_CLIENT_SECRET");
+		const redirect_uri =
+			import.meta.env.SPOTIFY_REDIRECT_URI ??
+			`${reqUrl.origin}/api/spotify/callback.json`;
+
+		const basic = Buffer.from(`${client_id}:${client_secret}`).toString(
+			"base64",
+		);
+
+		const res = await fetch(TOKEN_ENDPOINT, {
+			method: "POST",
+			headers: {
+				Authorization: `Basic ${basic}`,
+				"Content-Type": "application/x-www-form-urlencoded",
+			},
+			body: new URLSearchParams({
+				grant_type: "authorization_code",
+				code,
+				redirect_uri,
+			}),
+		});
+
+		const text = await res.text();
+		if (!res.ok) {
+			return new Response(
+				`Spotify token exchange failed (${res.status}).\n\n${text}`,
+				{
+					status: 500,
+					headers: { "Content-Type": "text/plain; charset=utf-8" },
+				},
+			);
+		}
+
+		const data = JSON.parse(text) as {
+			refresh_token?: string;
+			access_token?: string;
+			expires_in?: number;
+			scope?: string;
+			token_type?: string;
+		};
+
+		const refresh = data.refresh_token;
+		if (!refresh) {
+			return new Response(
+				[
+					"No refresh_token returned.",
+					"",
+					"This usually means you already authorized this app before.",
+					"Go to https://www.spotify.com/account/apps/ and remove access, then try again.",
+					"",
+					"Raw response:",
+					text,
+				].join("\n"),
+				{
+					status: 500,
+					headers: { "Content-Type": "text/plain; charset=utf-8" },
+				},
+			);
+		}
+
+		return new Response(
+			[
+				"Refresh token generated successfully.",
+				"",
+				`SPOTIFY_REFRESH_TOKEN=${refresh}`,
+				"",
+				"Put that into your .env (server-side).",
+			].join("\n"),
+			{ status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } },
+		);
+	} catch (err) {
+		const message = err instanceof Error ? err.message : String(err);
+		return new Response(message, {
+			status: 500,
+			headers: { "Content-Type": "text/plain; charset=utf-8" },
+		});
+	}
+}

+ 101 - 0
src/pages/api/spotify/callback.ts

@@ -0,0 +1,101 @@
+export const prerender = false;
+
+const TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token";
+
+function requiredEnv(name: string): string {
+	const v = import.meta.env[name];
+	if (!v) throw new Error(`Missing env var: ${name}`);
+	return v;
+}
+
+export async function GET({ request, url }: { request: Request; url: URL }) {
+	try {
+		const reqUrl = new URL(request.url);
+		const code =
+			reqUrl.searchParams.get("code") ?? url.searchParams.get("code");
+		if (!code) {
+			return new Response(
+				[
+					"Missing ?code",
+					"",
+					`request.url: ${request.url}`,
+					`astro.url: ${url.toString()}`,
+				].join("\n"),
+				{
+					status: 400,
+					headers: { "Content-Type": "text/plain; charset=utf-8" },
+				},
+			);
+		}
+
+		const client_id = requiredEnv("SPOTIFY_CLIENT_ID");
+		const client_secret = requiredEnv("SPOTIFY_CLIENT_SECRET");
+		const redirect_uri =
+			import.meta.env.SPOTIFY_REDIRECT_URI ??
+			`${reqUrl.origin}/api/spotify/callback`;
+
+		const basic = Buffer.from(`${client_id}:${client_secret}`).toString(
+			"base64",
+		);
+
+		const res = await fetch(TOKEN_ENDPOINT, {
+			method: "POST",
+			headers: {
+				Authorization: `Basic ${basic}`,
+				"Content-Type": "application/x-www-form-urlencoded",
+			},
+			body: new URLSearchParams({
+				grant_type: "authorization_code",
+				code,
+				redirect_uri,
+			}),
+		});
+
+		const text = await res.text();
+		if (!res.ok) {
+			return new Response(
+				`Spotify token exchange failed (${res.status}).\n\n${text}`,
+				{
+					status: 500,
+					headers: { "Content-Type": "text/plain; charset=utf-8" },
+				},
+			);
+		}
+
+		const data = JSON.parse(text) as {
+			access_token?: string;
+			refresh_token?: string;
+			scope?: string;
+			expires_in?: number;
+			token_type?: string;
+		};
+
+		const refresh = data.refresh_token;
+		if (!refresh) {
+			return new Response(
+				`No refresh_token returned.\n\nThis usually means you already authorized this app before.\nGo to https://www.spotify.com/account/apps/ and remove access, then try again.\n\nRaw response:\n${text}`,
+				{
+					status: 500,
+					headers: { "Content-Type": "text/plain; charset=utf-8" },
+				},
+			);
+		}
+
+		return new Response(
+			[
+				"Refresh token generated successfully.",
+				"",
+				`SPOTIFY_REFRESH_TOKEN=${refresh}`,
+				"",
+				"Put that into your .env (server-side).",
+			].join("\n"),
+			{ status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } },
+		);
+	} catch (err) {
+		const message = err instanceof Error ? err.message : String(err);
+		return new Response(message, {
+			status: 500,
+			headers: { "Content-Type": "text/plain; charset=utf-8" },
+		});
+	}
+}

+ 26 - 0
src/pages/api/spotify/login.ts

@@ -0,0 +1,26 @@
+export const prerender = false;
+
+export async function GET({ url }: { url: URL }) {
+	const client_id = import.meta.env.SPOTIFY_CLIENT_ID;
+	if (!client_id) {
+		return new Response("Missing SPOTIFY_CLIENT_ID", { status: 500 });
+	}
+
+	// Allow overriding in case you use a different host/port locally.
+	const redirect_uri =
+		import.meta.env.SPOTIFY_REDIRECT_URI ??
+		`${url.origin}/api/spotify/callback.json`;
+
+	const scopes = [
+		"user-read-currently-playing",
+		"user-read-playback-state",
+	].join(" ");
+
+	const authUrl = new URL("https://accounts.spotify.com/authorize");
+	authUrl.searchParams.set("client_id", client_id);
+	authUrl.searchParams.set("response_type", "code");
+	authUrl.searchParams.set("redirect_uri", redirect_uri);
+	authUrl.searchParams.set("scope", scopes);
+
+	return Response.redirect(authUrl.toString(), 302);
+}

+ 177 - 0
src/pages/api/spotify/now-playing.json.ts

@@ -0,0 +1,177 @@
+export const prerender = false;
+
+const NOW_PLAYING_ENDPOINT =
+	"https://api.spotify.com/v1/me/player/currently-playing";
+const TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token";
+
+type SpotifyNowPlaying = {
+	is_playing?: boolean;
+	progress_ms?: number;
+	item?: {
+		name?: string;
+		duration_ms?: number;
+		artists?: { name?: string }[];
+		album?: {
+			name?: string;
+			images?: { url: string; height?: number; width?: number }[];
+		};
+		external_urls?: { spotify?: string };
+	};
+};
+
+function requiredEnv(name: string): string {
+	const v = import.meta.env[name];
+	if (!v) throw new Error(`Missing env var: ${name}`);
+	return v;
+}
+
+let cachedAccessToken: { token: string; expiresAtMs: number } | null = null;
+
+async function getAccessToken(): Promise<string> {
+	const now = Date.now();
+	if (
+		cachedAccessToken &&
+		cachedAccessAccessTokenStillValid(cachedAccessToken, now)
+	) {
+		return cachedAccessToken.token;
+	}
+
+	const client_id = requiredEnv("SPOTIFY_CLIENT_ID");
+	const client_secret = requiredEnv("SPOTIFY_CLIENT_SECRET");
+	const refresh_token = requiredEnv("SPOTIFY_REFRESH_TOKEN");
+
+	const basic = Buffer.from(`${client_id}:${client_secret}`).toString("base64");
+
+	const res = await fetch(TOKEN_ENDPOINT, {
+		method: "POST",
+		headers: {
+			Authorization: `Basic ${basic}`,
+			"Content-Type": "application/x-www-form-urlencoded",
+		},
+		body: new URLSearchParams({
+			grant_type: "refresh_token",
+			refresh_token,
+		}),
+	});
+
+	if (!res.ok) {
+		const text = await res.text().catch(() => "");
+		throw new Error(`Spotify token error: ${res.status} ${text}`);
+	}
+
+	const data = (await res.json()) as {
+		access_token: string;
+		expires_in: number;
+	};
+	// expires_in is seconds; subtract a small skew so we refresh early.
+	cachedAccessToken = {
+		token: data.access_token,
+		expiresAtMs: Date.now() + data.expires_in * 1000 - 30_000,
+	};
+	return cachedAccessToken.token;
+}
+
+function cachedAccessAccessTokenStillValid(
+	cached: { token: string; expiresAtMs: number },
+	nowMs: number,
+): boolean {
+	return Boolean(cached.token) && cached.expiresAtMs > nowMs;
+}
+
+export async function GET() {
+	try {
+		const accessToken = await getAccessToken();
+
+		const res = await fetch(NOW_PLAYING_ENDPOINT, {
+			headers: {
+				Authorization: `Bearer ${accessToken}`,
+			},
+		});
+
+		// 204 No Content means nothing is currently playing
+		if (res.status === 204) {
+			return new Response(JSON.stringify({ isPlaying: false }), {
+				status: 200,
+				headers: {
+					"Content-Type": "application/json; charset=utf-8",
+					"Cache-Control": "no-store",
+				},
+			});
+		}
+
+		if (res.status === 401) {
+			// Token might be expired; clear cache and let client retry on next poll.
+			cachedAccessToken = null;
+			return new Response(
+				JSON.stringify({ isPlaying: false, error: "unauthorized" }),
+				{
+					status: 200,
+					headers: {
+						"Content-Type": "application/json; charset=utf-8",
+						"Cache-Control": "no-store",
+					},
+				},
+			);
+		}
+
+		if (!res.ok) {
+			const text = await res.text().catch(() => "");
+			return new Response(
+				JSON.stringify({
+					isPlaying: false,
+					error: `spotify_${res.status}`,
+					detail: text,
+				}),
+				{
+					status: 200,
+					headers: {
+						"Content-Type": "application/json; charset=utf-8",
+						"Cache-Control": "no-store",
+					},
+				},
+			);
+		}
+
+		const data = (await res.json()) as SpotifyNowPlaying;
+		const item = data.item;
+
+		const payload = {
+			isPlaying: Boolean(data.is_playing),
+			title: item?.name ?? null,
+			artist:
+				item?.artists
+					?.map((a) => a?.name)
+					.filter(Boolean)
+					.join(", ") ?? null,
+			album: item?.album?.name ?? null,
+			albumArtUrl: item?.album?.images?.[0]?.url ?? null,
+			progressMs: data.progress_ms ?? null,
+			durationMs: item?.duration_ms ?? null,
+			trackUrl: item?.external_urls?.spotify ?? null,
+		};
+
+		return new Response(JSON.stringify(payload), {
+			status: 200,
+			headers: {
+				"Content-Type": "application/json; charset=utf-8",
+				"Cache-Control": "no-store",
+			},
+		});
+	} catch (err) {
+		const message = err instanceof Error ? err.message : String(err);
+		return new Response(
+			JSON.stringify({
+				isPlaying: false,
+				error: "server_error",
+				detail: message,
+			}),
+			{
+				status: 200,
+				headers: {
+					"Content-Type": "application/json; charset=utf-8",
+					"Cache-Control": "no-store",
+				},
+			},
+		);
+	}
+}

+ 31 - 3
src/pages/index.astro

@@ -10,7 +10,7 @@ import Pgp from "../components/pgp.astro";
 import Projects from "../components/projects.astro";
 import Duolingo from "../components/duolingo.astro";
 import GitHubGraph from "../components/githubgraph.astro";
-import SearchWidget from "../components/searchwidget.astro";
+import NowPlaying from "../components/nowplaying.astro";
 
 // Projects
 import Summize from "../components/projects/summize.astro";
@@ -254,7 +254,25 @@ apps = apps.map((item) => {
 		<Aboutme />
 		<Redirect />
 		<Contact />
-		<SearchWidget />
+		<iframe
+			id="SimoSearch"
+			title="SimoSearch"
+			src="https://search.simo.ng/#embed"
+			loading="lazy"
+			referrerpolicy="no-referrer"
+			allowfullscreen
+			style="
+				display: block;
+				position: absolute;
+				left: 62%;
+				top: 10%;
+				height: 420px;
+				border: 0;
+				outline: 0;
+				background: transparent;
+			"
+		></iframe>
+		<NowPlaying />
 		<Projects projects={projectsArr} />
 		<Summize />
 		<Notion />
@@ -378,6 +396,15 @@ apps = apps.map((item) => {
 		});
 	});
 
+	// Make the SimoSearch iframe draggable too (borderless widget).
+	const simoSearch = document.getElementById('SimoSearch');
+	if (simoSearch) {
+		dragElement(simoSearch);
+		simoSearch.addEventListener('click', () => {
+			simoSearch.style.zIndex = '9999';
+		});
+	}
+
 	apps.forEach((app) => {
 		let itemName = app.name.replace(" ", "");
 
@@ -552,7 +579,8 @@ apps = apps.map((item) => {
 			const w = document.getElementById('SimoSearch');
 			if (!w) return;
 			w.style.display = 'block';
-			raiseUpSpec('SimoSearch');
+			// SimoSearch is an iframe, not a Win98 window.
+			w.style.zIndex = '9999';
 		});
 	}
 </script>

+ 10 - 0
temp

@@ -0,0 +1,10 @@
+CODE="AQAlhlxxKq6CziG_CWA8TXjjOL9J8v-D8ijr-9o7jPSKWqjL169mJgntvGApUsmaGGTAilY1FeyeSFBzit_IIorMicFH6FDLL2bQlh-PSpnR5KF7fa-6fxvVV0IJrO5EuvlnLmjFzQeDY_5wN4zd2eHFvM1U1r0cj4d-0K8B4Rl5IJWTaUjlvKsLf_ykONtiupYhQW8buXgkKefUaKiH-l9z-Y5u_HFhZqfiqnsP-L-unA6Zl4rFOTYe7tCdSG2rmnQdMH4"
+REDIRECT_URI="http://127.0.0.1:4321/api/spotify/callback"
+CLIENT_SECRET="3ebc996495774e27a8fe279e89e1c2cd"
+CLIENT_ID="86b57478283e4fa08dec17a50a307a8c"
+curl -s -X POST "https://accounts.spotify.com/api/token" \
+  -H "Authorization: Basic $(printf "%s:%s" "$CLIENT_ID" "$CLIENT_SECRET" | base64 -w 0)" \
+  -H "Content-Type: application/x-www-form-urlencoded" \
+  --data-urlencode "grant_type=authorization_code" \
+  --data-urlencode "code=$CODE" \
+  --data-urlencode "redirect_uri=$REDIRECT_URI"

+ 13 - 0
temp.js

@@ -0,0 +1,13 @@
+const client_id = "86b57478283e4fa08dec17a50a307a8c";
+const redirect_uri = "http://127.0.0.1:4321/api/spotify/callback.json";
+const scopes = "user-read-currently-playing user-read-playback-state";
+
+const authUrl = new URL("https://accounts.spotify.com/authorize");
+authUrl.searchParams.append("client_id", client_id);
+authUrl.searchParams.append("response_type", "code");
+authUrl.searchParams.append("redirect_uri", redirect_uri);
+authUrl.searchParams.append("scope", scopes);
+
+// Redirect user to this URL
+console.log("Visit this URL to authorize:", authUrl.toString());
+