spotify-turntable 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,230 @@
1
+ "use client";
2
+ /* global process -- read only inside try; bundlers inline process.env.NODE_ENV */
3
+ import { createElement as h, Fragment, useEffect, useId, useRef, useState } from "react";
4
+ import { useNowPlaying } from "./useNowPlaying.js";
5
+ import { formatMs, timeAgo } from "./format.js";
6
+
7
+ export const DEFAULT_LABELS = {
8
+ loading: "Warming up the needle",
9
+ playing: "Now spinning",
10
+ rotation: "House rotation",
11
+ paused: "On pause",
12
+ recent: "Last spun",
13
+ idle: "Nothing on the platter",
14
+ unconfigured: "Not connected",
15
+ offline: "Turntable offline",
16
+ };
17
+
18
+ const IS_DEV = (() => {
19
+ try {
20
+ if (import.meta.env) return !!import.meta.env.DEV;
21
+ } catch {
22
+ /* not Vite */
23
+ }
24
+ try {
25
+ return process.env.NODE_ENV !== "production";
26
+ } catch {
27
+ return false;
28
+ }
29
+ })();
30
+
31
+ function embedFor(track, playlist) {
32
+ if (track?.id && (track.type === "track" || track.type === "episode")) {
33
+ return { src: `https://open.spotify.com/embed/${track.type}/${track.id}?utm_source=generator`, what: track.title };
34
+ }
35
+ if (playlist?.id) return { src: `https://open.spotify.com/embed/playlist/${playlist.id}?utm_source=generator`, what: playlist.name };
36
+ return null;
37
+ }
38
+
39
+ const svg = (props, ...children) => h("svg", { "aria-hidden": "true", focusable: "false", ...props }, ...children);
40
+ const cross = (width) => h("path", { d: "M4 4l8 8M12 4l-8 8", stroke: "currentColor", strokeWidth: width, strokeLinecap: "round" });
41
+
42
+ function Vinyl({ track, empty }) {
43
+ const id = useId().replace(/:/g, "");
44
+ const grooves = [45, 41, 37, 33.5, 30, 26.5, 23].map((r) =>
45
+ h("circle", { key: r, cx: 52, cy: 52, r, fill: "none", stroke: "#fff", strokeOpacity: 0.06, strokeWidth: 0.5 }),
46
+ );
47
+ return svg(
48
+ { className: "deck-svg", viewBox: "0 0 120 104" },
49
+ h(
50
+ "defs",
51
+ null,
52
+ h("radialGradient", { id: `${id}-v` }, h("stop", { offset: 0.35, stopColor: "#1d1d1d" }), h("stop", { offset: 1, stopColor: "#090909" })),
53
+ h("clipPath", { id: `${id}-l` }, h("circle", { cx: 52, cy: 52, r: 16 })),
54
+ ),
55
+ h(
56
+ "g",
57
+ { className: `deck-record${empty ? " is-empty" : ""}` },
58
+ h("circle", { cx: 52, cy: 52, r: 50, fill: `url(#${id}-v)` }),
59
+ ...grooves,
60
+ h("circle", { cx: 52, cy: 52, r: 16, fill: "var(--deck-label, #e9dfcf)" }),
61
+ track?.albumArt &&
62
+ h("image", { href: track.albumArt, x: 36, y: 36, width: 32, height: 32, clipPath: `url(#${id}-l)`, preserveAspectRatio: "xMidYMid slice" }),
63
+ h("circle", { cx: 52, cy: 52, r: 1.6, fill: "#f5f1ea" }),
64
+ ),
65
+ // Static sheen so the spin reads as light moving over grooves.
66
+ h("path", { d: "M17 32 A41 41 0 0 1 36 14", stroke: "#fff", strokeOpacity: 0.14, strokeWidth: 3, fill: "none", strokeLinecap: "round" }),
67
+ h("path", { d: "M87 72 A41 41 0 0 1 68 90", stroke: "#fff", strokeOpacity: 0.08, strokeWidth: 3, fill: "none", strokeLinecap: "round" }),
68
+ h(
69
+ "g",
70
+ { className: "deck-arm" },
71
+ h("circle", { cx: 108, cy: 12, r: 6, fill: "#d9d3c8" }),
72
+ h("circle", { cx: 108, cy: 12, r: 2.5, fill: "#8f877a" }),
73
+ h("path", { d: "M108 12 L108 58 L95 74", fill: "none", stroke: "#bdb5a8", strokeWidth: 2.4, strokeLinecap: "round", strokeLinejoin: "round" }),
74
+ h("rect", { x: 88, y: 70, width: 10, height: 7, rx: 1.5, transform: "rotate(-48 93 73.5)", fill: "#2b2b2b" }),
75
+ ),
76
+ );
77
+ }
78
+
79
+ /**
80
+ * A record-player card for whatever your Spotify account is playing.
81
+ *
82
+ * <Turntable floating /> // polls /api/now-playing, pinned top-right
83
+ * <Turntable endpoint="/api/np" labels={{ rotation: "Felix's rotation" }} />
84
+ * <Turntable np={useNowPlaying()} /> // share one poller with other widgets
85
+ */
86
+ export function Turntable({
87
+ np: external,
88
+ endpoint,
89
+ demo = null,
90
+ pollMs,
91
+ labels,
92
+ fallbackUrl = "https://open.spotify.com",
93
+ setupHint = "Run npx spotify-turntable auth",
94
+ floating = false,
95
+ className = "",
96
+ }) {
97
+ const own = useNowPlaying({ endpoint, demo, pollMs, enabled: !external });
98
+ const { state, track, playedAt, playlist, progressMs, demo: isDemo } = external || own;
99
+ const text = { ...DEFAULT_LABELS, ...labels };
100
+
101
+ const [open, setOpen] = useState(false);
102
+ const rootRef = useRef(null);
103
+ const playRef = useRef(null);
104
+ const closeRef = useRef(null);
105
+ const popId = useId();
106
+
107
+ const moving = state === "playing" || state === "rotation";
108
+ const pct = track?.durationMs ? Math.min(1, progressMs / track.durationMs) : 0;
109
+ const href = track?.url || playlist?.url || fallbackUrl;
110
+ const embed = embedFor(track, state === "rotation" ? playlist : null);
111
+ const playlistName = state === "rotation" && playlist?.name ? playlist.name : null;
112
+
113
+ let status = text[state] || text.idle;
114
+ if (state === "recent" && playedAt) status = `${text.recent} · ${timeAgo(playedAt)}`;
115
+ const aria = track
116
+ ? `${status}${playlistName ? ` from ${playlistName}` : ""}: ${track.title} by ${track.artist}. Open in Spotify.`
117
+ : `${status}. Open Spotify.`;
118
+
119
+ useEffect(() => {
120
+ if (!open) return;
121
+ closeRef.current?.focus();
122
+ const onKey = (e) => {
123
+ if (e.key === "Escape") {
124
+ setOpen(false);
125
+ playRef.current?.focus();
126
+ }
127
+ };
128
+ const onDown = (e) => {
129
+ if (!rootRef.current?.contains(e.target)) setOpen(false);
130
+ };
131
+ document.addEventListener("keydown", onKey);
132
+ document.addEventListener("pointerdown", onDown);
133
+ return () => {
134
+ document.removeEventListener("keydown", onKey);
135
+ document.removeEventListener("pointerdown", onDown);
136
+ };
137
+ }, [open]);
138
+
139
+ const hasEmbed = !!embed;
140
+ useEffect(() => {
141
+ if (!hasEmbed) setOpen(false);
142
+ }, [hasEmbed]);
143
+
144
+ const close = () => {
145
+ setOpen(false);
146
+ playRef.current?.focus();
147
+ };
148
+
149
+ const ticket = h(
150
+ "span",
151
+ { className: "deck-ticket", "aria-hidden": "true" },
152
+ h(
153
+ "span",
154
+ { className: "deck-status", title: playlistName || undefined },
155
+ h("i", { className: "deck-led" }),
156
+ h("span", { className: "deck-status-text" }, status, isDemo && " (demo)"),
157
+ ),
158
+ track
159
+ ? h(
160
+ Fragment,
161
+ null,
162
+ h("span", { className: "deck-title" }, track.title),
163
+ h("span", { className: "deck-artist" }, track.artist),
164
+ moving &&
165
+ track.durationMs > 0 &&
166
+ h(
167
+ "span",
168
+ { className: "deck-time" },
169
+ h("span", { className: "deck-bar" }, h("span", { style: { transform: `scaleX(${pct})` } })),
170
+ h("span", { className: "deck-clock" }, `${formatMs(progressMs)} / ${formatMs(track.durationMs)}`),
171
+ ),
172
+ )
173
+ : h("span", { className: "deck-artist deck-note" }, state === "unconfigured" && IS_DEV && setupHint ? setupHint : "Check back soon"),
174
+ );
175
+
176
+ const card = h(
177
+ "div",
178
+ {
179
+ ref: rootRef,
180
+ className: `deck is-${state}${moving ? " is-moving" : ""}${embed ? " has-play" : ""}${className ? ` ${className}` : ""}`,
181
+ },
182
+ h("a", { className: "deck-link", href, target: "_blank", rel: "noreferrer", "aria-label": aria }, h(Vinyl, { track, empty: !track }), ticket),
183
+ embed &&
184
+ h(
185
+ "button",
186
+ {
187
+ ref: playRef,
188
+ type: "button",
189
+ className: "deck-play",
190
+ "aria-expanded": open,
191
+ "aria-controls": open ? popId : undefined,
192
+ "aria-label": open ? "Close Spotify player" : `Listen to ${embed.what} here`,
193
+ onClick: () => setOpen((v) => !v),
194
+ },
195
+ svg({ viewBox: "0 0 16 16" }, open ? cross(1.8) : h("path", { d: "M5 3.2v9.6L13 8z", fill: "currentColor" })),
196
+ ),
197
+ open &&
198
+ embed &&
199
+ h(
200
+ "div",
201
+ { id: popId, className: "deck-pop", role: "dialog", "aria-label": "Spotify player" },
202
+ h(
203
+ "div",
204
+ { className: "deck-pop-head" },
205
+ h("span", { className: "deck-pop-title" }, playlistName || "Listen on Spotify"),
206
+ h(
207
+ "button",
208
+ { ref: closeRef, type: "button", className: "deck-pop-close", onClick: close, "aria-label": "Close player" },
209
+ svg({ viewBox: "0 0 16 16" }, cross(1.6)),
210
+ ),
211
+ ),
212
+ h("iframe", {
213
+ className: "deck-embed",
214
+ title: `Spotify player: ${embed.what}`,
215
+ src: embed.src,
216
+ width: "100%",
217
+ height: 152,
218
+ loading: "lazy",
219
+ allow: "autoplay; clipboard-write; encrypted-media; fullscreen; picture-in-picture",
220
+ }),
221
+ state === "rotation" &&
222
+ playlist?.url &&
223
+ h("a", { className: "deck-pop-link", href: playlist.url, target: "_blank", rel: "noreferrer" }, "Open the whole rotation ↗"),
224
+ ),
225
+ );
226
+
227
+ return floating ? h("div", { className: "deck-slot" }, card) : card;
228
+ }
229
+
230
+ export default Turntable;
@@ -0,0 +1,16 @@
1
+ export function timeAgo(iso) {
2
+ if (!iso) return "";
3
+ const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
4
+ if (s < 60) return "just now";
5
+ const m = s / 60;
6
+ if (m < 60) return `${Math.round(m)} min ago`;
7
+ const h = m / 60;
8
+ if (h < 24) return `${Math.round(h)} hr ago`;
9
+ const d = Math.round(h / 24);
10
+ return d === 1 ? "yesterday" : `${d} days ago`;
11
+ }
12
+
13
+ export function formatMs(ms) {
14
+ const t = Math.max(0, Math.floor((ms || 0) / 1000));
15
+ return `${Math.floor(t / 60)}:${String(t % 60).padStart(2, "0")}`;
16
+ }
@@ -0,0 +1,4 @@
1
+ "use client";
2
+ export { Turntable, DEFAULT_LABELS, default } from "./Turntable.js";
3
+ export { useNowPlaying } from "./useNowPlaying.js";
4
+ export { formatMs, timeAgo } from "./format.js";
@@ -0,0 +1,103 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+
3
+ const POLL_MS = 15000;
4
+ const MAX_AGE_MS = 30000;
5
+
6
+ function stateOf(data, error) {
7
+ if (!data) return error ? "offline" : "loading";
8
+ if (!data.configured) return "unconfigured";
9
+ const mode = data.mode || (data.isPlaying ? "live" : data.track ? "recent" : "idle");
10
+ if (mode === "live") return data.track ? "playing" : "idle";
11
+ if (mode === "error") return "offline";
12
+ if (["rotation", "paused", "recent"].includes(mode)) return data.track ? mode : "idle";
13
+ return "idle";
14
+ }
15
+
16
+ function demoQuery(demo) {
17
+ if (!demo) return "";
18
+ return demo === "rotation" ? "?demo=rotation" : "?demo=1";
19
+ }
20
+
21
+ /**
22
+ * Polls the now-playing endpoint. Progress is extrapolated locally between polls so the record
23
+ * and bar move smoothly, and the next poll is scheduled for when the current track ends.
24
+ *
25
+ * Returns { state, track, playedAt, playlist, progressMs, demo } where state is one of
26
+ * loading | playing | rotation | paused | recent | idle | unconfigured | offline.
27
+ */
28
+ export function useNowPlaying({ endpoint = "/api/now-playing", demo = null, pollMs = POLL_MS, enabled = true } = {}) {
29
+ const [data, setData] = useState(null);
30
+ const [error, setError] = useState(false);
31
+ const base = useRef({ at: 0, progress: 0 });
32
+ const url = `${endpoint}${demoQuery(demo)}`;
33
+
34
+ useEffect(() => {
35
+ if (!enabled) return;
36
+ let alive = true;
37
+ let timer;
38
+
39
+ const load = async () => {
40
+ let next = pollMs;
41
+ try {
42
+ const res = await fetch(url, { headers: { accept: "application/json" } });
43
+ if (!res.ok) throw new Error(String(res.status));
44
+ const json = await res.json();
45
+ if (!alive) return;
46
+ const now = Date.now();
47
+ const moving = json.mode === "rotation" || json.isPlaying;
48
+ // Responses can sit in a CDN for a few seconds; catch up using the server timestamp.
49
+ const age = moving && json.fetchedAt ? Math.min(MAX_AGE_MS, Math.max(0, now - json.fetchedAt)) : 0;
50
+ base.current = { at: now, progress: (json.track?.progressMs || 0) + age };
51
+ if (moving && json.track?.durationMs) {
52
+ const remaining = json.track.durationMs - base.current.progress;
53
+ next = Math.max(2000, Math.min(pollMs, remaining + 1500));
54
+ }
55
+ setData(json);
56
+ setError(false);
57
+ } catch {
58
+ if (alive) setError(true);
59
+ }
60
+ if (alive) timer = setTimeout(load, document.hidden ? pollMs * 4 : next);
61
+ };
62
+
63
+ const onVisible = () => {
64
+ if (!document.hidden) {
65
+ clearTimeout(timer);
66
+ load();
67
+ }
68
+ };
69
+
70
+ load();
71
+ document.addEventListener("visibilitychange", onVisible);
72
+ return () => {
73
+ alive = false;
74
+ clearTimeout(timer);
75
+ document.removeEventListener("visibilitychange", onVisible);
76
+ };
77
+ }, [url, pollMs, enabled]);
78
+
79
+ const state = stateOf(data, error);
80
+ const moving = state === "playing" || state === "rotation";
81
+
82
+ const [, tick] = useState(0);
83
+ useEffect(() => {
84
+ if (!moving) return;
85
+ const id = setInterval(() => tick((n) => n + 1), 1000);
86
+ return () => clearInterval(id);
87
+ }, [moving]);
88
+
89
+ const track = data?.track || null;
90
+ let progressMs = track?.progressMs || 0;
91
+ if (moving && track) {
92
+ progressMs = Math.min(track.durationMs, base.current.progress + (Date.now() - base.current.at));
93
+ }
94
+
95
+ return {
96
+ state,
97
+ track,
98
+ playedAt: data?.playedAt || null,
99
+ playlist: data?.playlist || null,
100
+ progressMs,
101
+ demo: !!data?.demo,
102
+ };
103
+ }
@@ -0,0 +1,37 @@
1
+ // Tiny .env reader/writer used by the auth CLI.
2
+
3
+ export function parseEnv(text) {
4
+ const out = {};
5
+ for (const line of text.split(/\r?\n/)) {
6
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$/);
7
+ if (!m) continue;
8
+ let value = m[2].trim();
9
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) value = value.slice(1, -1);
10
+ out[m[1]] = value;
11
+ }
12
+ return out;
13
+ }
14
+
15
+
16
+
17
+ /** Updates KEY=value lines in place and appends the ones that are missing. */
18
+ export function upsertEnv(text, values) {
19
+ const lines = text ? text.split(/\r?\n/) : [];
20
+ const done = new Set();
21
+ const next = lines.map((line) => {
22
+ const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/);
23
+ if (m && m[1] in values) {
24
+ done.add(m[1]);
25
+ return `${m[1]}=${values[m[1]]}`;
26
+ }
27
+ return line;
28
+ });
29
+ while (next.length && next[next.length - 1] === "") next.pop();
30
+ const missing = Object.keys(values).filter((k) => !done.has(k));
31
+ if (missing.length) {
32
+ if (next.length) next.push("");
33
+ next.push("# Spotify turntable (npx spotify-turntable auth)");
34
+ for (const k of missing) next.push(`${k}=${values[k]}`);
35
+ }
36
+ return next.join("\n") + "\n";
37
+ }
@@ -0,0 +1,66 @@
1
+ import { getNowPlaying, parseDemo } from "./spotify.js";
2
+
3
+ export { getNowPlaying, parseDemo, parsePlaylistId, pickRotation, mapItem, resetSpotifyCache, LIKED, ROTATION_EPOCH } from "./spotify.js";
4
+
5
+ const getEnv = () => (typeof process !== "undefined" ? process.env : {});
6
+
7
+ /** Cache-Control that lets a CDN absorb traffic without making the turntable feel stale. */
8
+ export function cacheControlFor(data, demo) {
9
+ if (demo) return "no-store";
10
+ if (data.mode === "error") return "public, max-age=0, s-maxage=5";
11
+ return "public, max-age=0, s-maxage=10, stale-while-revalidate=20";
12
+ }
13
+
14
+ async function load(env, demoValue) {
15
+ const demo = parseDemo(demoValue);
16
+ let data;
17
+ try {
18
+ data = await getNowPlaying(env, { demo });
19
+ } catch (err) {
20
+ data = { configured: true, mode: "error", isPlaying: false, track: null, playedAt: null, playlist: null, fetchedAt: Date.now(), error: err?.message };
21
+ }
22
+ return { data, demo };
23
+ }
24
+
25
+ /**
26
+ * Node-style handler: `(req, res) => void`.
27
+ * Works as a Vercel function, an Express / Connect route, or plain `http.createServer`.
28
+ * export default nowPlayingHandler(); // api/now-playing.js on Vercel
29
+ * app.get("/api/now-playing", nowPlayingHandler()); // Express
30
+ */
31
+ export function nowPlayingHandler({ env, cacheControl = cacheControlFor } = {}) {
32
+ return async function handler(req, res) {
33
+ if (req.method && req.method !== "GET" && req.method !== "HEAD") {
34
+ res.statusCode = 405;
35
+ res.setHeader("Allow", "GET, HEAD");
36
+ res.end();
37
+ return;
38
+ }
39
+ let value = req.query?.demo;
40
+ if (value === undefined && req.url) value = new URL(req.url, "http://x").searchParams.get("demo") ?? undefined;
41
+ const { data, demo } = await load(env || getEnv(), value);
42
+ res.statusCode = 200;
43
+ res.setHeader("Content-Type", "application/json; charset=utf-8");
44
+ res.setHeader("Cache-Control", cacheControl(data, demo));
45
+ res.end(req.method === "HEAD" ? undefined : JSON.stringify(data));
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Web-standard handler: `(Request) => Response`.
51
+ * For Next.js route handlers, Netlify / Cloudflare / Deno / Bun.
52
+ * export const GET = nowPlayingFetchHandler(); // app/api/now-playing/route.js
53
+ */
54
+ export function nowPlayingFetchHandler({ env, cacheControl = cacheControlFor } = {}) {
55
+ return async function handler(request) {
56
+ if (request.method !== "GET" && request.method !== "HEAD") {
57
+ return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
58
+ }
59
+ const value = new URL(request.url).searchParams.get("demo") ?? undefined;
60
+ const { data, demo } = await load(env || getEnv(), value);
61
+ return new Response(request.method === "HEAD" ? null : JSON.stringify(data), {
62
+ status: 200,
63
+ headers: { "Content-Type": "application/json; charset=utf-8", "Cache-Control": cacheControl(data, demo) },
64
+ });
65
+ };
66
+ }