|
@@ -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>
|