nowplaying.astro 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332
  1. ---
  2. import Win98Window from "./win98window.astro";
  3. const layout = {
  4. mobile: { left: "2%", top: "60%", width: "96%", height: "auto" },
  5. desktop: { left: "66%", top: "8%", width: "320px", height: "auto" },
  6. };
  7. ---
  8. <Win98Window title="Now Playing" layout={layout}, shown=true>
  9. <div class="np" id="nowplaying">
  10. <div class="np-row">
  11. <div class="np-art" aria-hidden="true">
  12. <div class="np-art-inner" id="np-art-inner"></div>
  13. </div>
  14. <div class="np-meta">
  15. <div class="np-title" id="np-title">Not connected</div>
  16. <div class="np-artist" id="np-artist">Spotify</div>
  17. <div class="np-album" id="np-album"></div>
  18. </div>
  19. </div>
  20. <div class="np-progress">
  21. <div class="np-time" id="np-time">--:-- / --:--</div>
  22. <div class="np-bar" role="progressbar" aria-label="track progress">
  23. <div class="np-bar-fill" id="np-bar-fill"></div>
  24. </div>
  25. </div>
  26. <div class="np-controls">
  27. <button id="np-open" disabled>Open</button>
  28. <button id="np-refresh">Refresh</button>
  29. </div>
  30. </div>
  31. </Win98Window>
  32. <style>
  33. .np {
  34. width: 100%;
  35. max-width: 100%;
  36. }
  37. .np-row {
  38. display: flex;
  39. gap: 10px;
  40. align-items: center;
  41. }
  42. .np-art {
  43. width: 56px;
  44. height: 56px;
  45. background: silver;
  46. box-shadow: inset -1px -1px #0a0a0a, inset 1px 1px #fff, inset -2px -2px grey,
  47. inset 2px 2px #dfdfdf;
  48. padding: 3px;
  49. flex: 0 0 auto;
  50. }
  51. .np-art-inner {
  52. width: 100%;
  53. height: 100%;
  54. background: #000;
  55. background-size: cover;
  56. background-position: center;
  57. }
  58. .np-meta {
  59. min-width: 0;
  60. flex: 1 1 auto;
  61. }
  62. .np-title {
  63. font-weight: 700;
  64. white-space: nowrap;
  65. overflow: hidden;
  66. text-overflow: ellipsis;
  67. }
  68. .np-artist,
  69. .np-album {
  70. white-space: nowrap;
  71. overflow: hidden;
  72. text-overflow: ellipsis;
  73. color: #222;
  74. }
  75. .np-album {
  76. color: #444;
  77. }
  78. .np-progress {
  79. margin-top: 10px;
  80. }
  81. .np-time {
  82. font-size: 11px;
  83. margin-bottom: 4px;
  84. }
  85. .np-bar {
  86. height: 14px;
  87. background: white;
  88. box-shadow: inset -1px -1px #fff, inset 1px 1px #0a0a0a, inset -2px -2px #dfdfdf,
  89. inset 2px 2px grey;
  90. padding: 2px;
  91. }
  92. .np-bar-fill {
  93. height: 100%;
  94. width: 0%;
  95. background: navy;
  96. }
  97. .np-controls {
  98. margin-top: 10px;
  99. display: flex;
  100. gap: 8px;
  101. }
  102. .np-controls button {
  103. min-width: 80px;
  104. }
  105. .np-hint {
  106. margin-top: 10px;
  107. font-size: 11px;
  108. color: #333;
  109. }
  110. </style>
  111. <script>
  112. const apiUrl = "/api/spotify/now-playing.json";
  113. const pollMs = 8000;
  114. const elTitle = document.getElementById("np-title");
  115. const elArtist = document.getElementById("np-artist");
  116. const elAlbum = document.getElementById("np-album");
  117. const elTime = document.getElementById("np-time");
  118. const elFill = document.getElementById("np-bar-fill");
  119. const elArt = document.getElementById("np-art-inner");
  120. const elHint = document.getElementById("np-hint");
  121. const btnOpen = document.getElementById("np-open");
  122. const btnRefresh = document.getElementById("np-refresh");
  123. let lastTrackUrl = null;
  124. let timer = null;
  125. let tickTimer = null;
  126. let lastKnown = {
  127. title: null,
  128. artist: null,
  129. album: null,
  130. albumArtUrl: null,
  131. trackUrl: null,
  132. durationMs: null,
  133. stoppedAtMs: null,
  134. stoppedProgressMs: null,
  135. };
  136. let lastProgressMs = null;
  137. let lastDurationMs = null;
  138. let lastIsPlaying = false;
  139. let lastTrackKey = null;
  140. let lastSyncAt = 0;
  141. function fmtTime(ms) {
  142. if (typeof ms !== "number" || !Number.isFinite(ms)) return "--:--";
  143. const s = Math.max(0, Math.floor(ms / 1000));
  144. const m = Math.floor(s / 60);
  145. const r = s % 60;
  146. return `${m}:${String(r).padStart(2, "0")}`;
  147. }
  148. function fmtRelative(tsMs) {
  149. if (typeof tsMs !== "number" || !Number.isFinite(tsMs)) return "";
  150. const diffMs = Date.now() - tsMs;
  151. if (!Number.isFinite(diffMs) || diffMs < 0) return "";
  152. const sec = Math.floor(diffMs / 1000);
  153. const min = Math.floor(sec / 60);
  154. const hr = Math.floor(min / 60);
  155. try {
  156. const rtf = new Intl.RelativeTimeFormat(undefined, { numeric: "auto" });
  157. if (sec < 60) return rtf.format(-sec, "second");
  158. if (min < 60) return rtf.format(-min, "minute");
  159. return rtf.format(-hr, "hour");
  160. } catch {
  161. if (sec < 60) return `${sec}s ago`;
  162. if (min < 60) return `${min}m ago`;
  163. return `${hr}h ago`;
  164. }
  165. }
  166. function setProgress(progressMs, durationMs) {
  167. if (typeof progressMs !== "number" || typeof durationMs !== "number" || durationMs <= 0) {
  168. elFill.style.width = "0%";
  169. elTime.textContent = "--:-- / --:--";
  170. return;
  171. }
  172. const pct = Math.max(0, Math.min(100, (progressMs / durationMs) * 100));
  173. elFill.style.width = `${pct.toFixed(1)}%`;
  174. elTime.textContent = `${fmtTime(progressMs)} / ${fmtTime(durationMs)}`;
  175. }
  176. function startTicking() {
  177. if (tickTimer) return;
  178. tickTimer = setInterval(() => {
  179. if (!lastIsPlaying) return;
  180. if (typeof lastProgressMs !== "number" || typeof lastDurationMs !== "number") return;
  181. const elapsed = Date.now() - lastSyncAt;
  182. const est = lastProgressMs + elapsed;
  183. setProgress(est, lastDurationMs);
  184. }, 250);
  185. }
  186. function stopTicking() {
  187. if (tickTimer) {
  188. clearInterval(tickTimer);
  189. tickTimer = null;
  190. }
  191. }
  192. function setArt(url) {
  193. if (!url) {
  194. elArt.style.backgroundImage = "none";
  195. return;
  196. }
  197. elArt.style.backgroundImage = `url(${url})`;
  198. }
  199. async function refresh() {
  200. try {
  201. const res = await fetch(apiUrl, { cache: "no-store" });
  202. const data = await res.json();
  203. if (!data || data.isPlaying === false || !data.title) {
  204. if (lastKnown.title) {
  205. elTitle.textContent = `Last played: ${lastKnown.title}`;
  206. elArtist.textContent = lastKnown.artist || "";
  207. const when = fmtRelative(lastKnown.stoppedAtMs);
  208. elAlbum.textContent = when
  209. ? `${lastKnown.album || ""} (${when})`
  210. : lastKnown.album || "";
  211. setArt(lastKnown.albumArtUrl);
  212. setProgress(lastKnown.stoppedProgressMs, lastKnown.durationMs);
  213. lastTrackUrl = lastKnown.trackUrl;
  214. if (btnOpen instanceof HTMLButtonElement) btnOpen.disabled = !lastTrackUrl;
  215. } else {
  216. elTitle.textContent = "(Nothing playing)";
  217. elArtist.textContent = "Spotify";
  218. elAlbum.textContent = "";
  219. setArt(null);
  220. setProgress(null, null);
  221. if (btnOpen instanceof HTMLButtonElement) btnOpen.disabled = true;
  222. lastTrackUrl = null;
  223. }
  224. stopTicking();
  225. if (lastIsPlaying && lastKnown.title && !lastKnown.stoppedAtMs) {
  226. lastKnown.stoppedAtMs = Date.now();
  227. if (typeof lastProgressMs === "number") lastKnown.stoppedProgressMs = lastProgressMs;
  228. }
  229. lastIsPlaying = false;
  230. lastProgressMs = null;
  231. lastDurationMs = null;
  232. lastTrackKey = null;
  233. lastSyncAt = 0;
  234. elHint.textContent = data?.error ? `Status: ${data.error}` : "";
  235. return;
  236. }
  237. elTitle.textContent = data.title;
  238. elArtist.textContent = data.artist || "";
  239. elAlbum.textContent = data.album || "";
  240. setArt(data.albumArtUrl);
  241. lastKnown = {
  242. title: data.title ?? null,
  243. artist: data.artist ?? null,
  244. album: data.album ?? null,
  245. albumArtUrl: data.albumArtUrl ?? null,
  246. trackUrl: data.trackUrl ?? null,
  247. durationMs: typeof data.durationMs === "number" ? data.durationMs : null,
  248. stoppedAtMs: null,
  249. stoppedProgressMs: null,
  250. };
  251. const trackKey = `${data.title}::${data.artist}::${data.album}`;
  252. const durationMs = typeof data.durationMs === "number" ? data.durationMs : null;
  253. const progressMs = typeof data.progressMs === "number" ? data.progressMs : null;
  254. const isPlaying = Boolean(data.isPlaying);
  255. if (lastTrackKey && trackKey !== lastTrackKey) {
  256. lastProgressMs = null;
  257. lastSyncAt = 0;
  258. }
  259. lastTrackKey = trackKey;
  260. lastDurationMs = durationMs;
  261. lastProgressMs = progressMs;
  262. lastIsPlaying = isPlaying;
  263. lastSyncAt = Date.now();
  264. setProgress(progressMs, durationMs);
  265. if (isPlaying) startTicking();
  266. else stopTicking();
  267. lastTrackUrl = data.trackUrl || null;
  268. if (btnOpen instanceof HTMLButtonElement) btnOpen.disabled = !lastTrackUrl;
  269. elHint.textContent = "";
  270. } catch (e) {
  271. elHint.textContent = "Unable to load now playing.";
  272. }
  273. }
  274. btnOpen?.addEventListener("click", () => {
  275. if (lastTrackUrl) window.open(lastTrackUrl, "_blank", "noopener,noreferrer");
  276. });
  277. btnRefresh?.addEventListener("click", () => refresh());
  278. refresh();
  279. timer = setInterval(refresh, pollMs);
  280. window.addEventListener("beforeunload", () => {
  281. if (timer) clearInterval(timer);
  282. if (tickTimer) clearInterval(tickTimer);
  283. });
  284. </script>