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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Felix Arbucias
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,208 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
1
+ # spotify-turntable
2
+
3
+ A little record player that shows what you're playing on Spotify, live. The record spins and the tonearm drops while music plays. When you're not listening, it can play from a playlist (or your Liked Songs) like a radio station, so visitors always see something spinning. Visitors can press play to listen right on your site.
4
+
5
+ Made for personal sites and portfolios. React on the front, any JavaScript server on the back, and no database.
6
+
7
+ - **Live:** polls Spotify every ~15 s and moves the progress bar smoothly between polls.
8
+ - **House rotation:** when you're idle, every visitor hears the same song at the same moment, like a radio station.
9
+ - **Cheap on rate limits:** results are cached in memory and at the CDN, so many visitors share a handful of Spotify calls.
10
+ - **Secrets stay on the server:** the browser only ever sees track names and artwork.
11
+ - **Accessible:** keyboard support, honours `prefers-reduced-motion`, and turns into a compact pill on phones.
12
+
13
+ ## Try it in two minutes
14
+
15
+ ```bash
16
+ git clone https://github.com/felixArbucias/portfolio
17
+ cd portfolio/packages/spotify-turntable
18
+ npm install
19
+ npm run auth # connect your Spotify account (walks you through it)
20
+ npm run dev # open the printed URL
21
+ ```
22
+
23
+ No Spotify yet? Open `?demo=live` or `?demo=rotation` to see it with sample songs.
24
+
25
+ ## Add it to your site
26
+
27
+ ### 1. Install
28
+
29
+ ```bash
30
+ npm install spotify-turntable
31
+ ```
32
+
33
+ ### 2. Connect your Spotify account (once)
34
+
35
+ ```bash
36
+ npx spotify-turntable auth
37
+ ```
38
+
39
+ It walks you through it:
40
+
41
+ 1. Create an app at <https://developer.spotify.com/dashboard>. Add the redirect URI `http://127.0.0.1:8888/callback` and tick **Web API**.
42
+ 2. Paste the **Client ID** and **Client Secret** when asked.
43
+ 3. Approve access in the browser tab that opens.
44
+
45
+ It then writes everything to `.env.local`:
46
+
47
+ ```bash
48
+ SPOTIFY_CLIENT_ID=…
49
+ SPOTIFY_CLIENT_SECRET=…
50
+ SPOTIFY_REFRESH_TOKEN=…
51
+ SPOTIFY_FALLBACK_PLAYLIST=liked # optional, see "House rotation"
52
+ ```
53
+
54
+ Make sure `.env.local` is in your `.gitignore` (the command warns you if it isn't). When you deploy, add the same four values as environment variables on your host (Vercel, Netlify, …).
55
+
56
+ > New Spotify apps run in development mode. That works for your own account, which is all a personal site needs, but Spotify currently requires the app owner to have Premium.
57
+
58
+ ### 3. Add the endpoint
59
+
60
+ The component asks your own server for `/api/now-playing`, and the server talks to Spotify. Pick your setup:
61
+
62
+ **Vite (dev and preview)**
63
+
64
+ ```js
65
+ // vite.config.js
66
+ import react from "@vitejs/plugin-react";
67
+ import { spotifyTurntable } from "spotify-turntable/vite";
68
+
69
+ export default { plugins: [react(), spotifyTurntable()] };
70
+ ```
71
+
72
+ **Vercel (Vite, Create React App, plain HTML…)**
73
+
74
+ ```js
75
+ // api/now-playing.js
76
+ import { nowPlayingHandler } from "spotify-turntable/server";
77
+ export default nowPlayingHandler();
78
+ ```
79
+
80
+ **Next.js (App Router)**
81
+
82
+ ```js
83
+ // app/api/now-playing/route.js
84
+ import { nowPlayingFetchHandler } from "spotify-turntable/server";
85
+ export const GET = nowPlayingFetchHandler();
86
+ export const dynamic = "force-dynamic";
87
+ ```
88
+
89
+ **Express**
90
+
91
+ ```js
92
+ import { nowPlayingHandler } from "spotify-turntable/server";
93
+ app.get("/api/now-playing", nowPlayingHandler());
94
+ ```
95
+
96
+ **Anything else.** `nowPlayingFetchHandler()` is a standard `(Request) => Response` handler: use it with Netlify Functions, Cloudflare Workers, Deno or Bun. Or call `getNowPlaying(process.env)` yourself and return the JSON.
97
+
98
+ ### 4. Drop in the component
99
+
100
+ ```jsx
101
+ import { Turntable } from "spotify-turntable";
102
+ import "spotify-turntable/style.css";
103
+
104
+ export default function Layout() {
105
+ return <Turntable floating />; // pinned to the top-right corner
106
+ }
107
+ ```
108
+
109
+ That's it. Restart your dev server and it shows whatever you're playing.
110
+
111
+ ## Options
112
+
113
+ ```jsx
114
+ <Turntable
115
+ floating // pin to the top-right corner (otherwise it renders in place)
116
+ endpoint="/api/now-playing"
117
+ labels={{ rotation: "Felix's rotation", playing: "On the deck" }}
118
+ fallbackUrl="https://open.spotify.com/user/you" // where the card links when nothing has played
119
+ demo="rotation" // "live" | "rotation": sample data, no Spotify needed
120
+ />
121
+ ```
122
+
123
+ All label keys: `loading`, `playing`, `rotation`, `paused`, `recent`, `idle`, `unconfigured`, `offline`.
124
+
125
+ **Share one poller.** If other parts of your page also want to know what's playing:
126
+
127
+ ```jsx
128
+ import { Turntable, useNowPlaying } from "spotify-turntable";
129
+
130
+ const np = useNowPlaying(); // { state, track, playlist, progressMs, playedAt, demo }
131
+ <Turntable np={np} />
132
+ <MyOtherWidget song={np.track} />
133
+ ```
134
+
135
+ **Theme.** Override the CSS variables on `.deck`:
136
+
137
+ ```css
138
+ .deck {
139
+ --deck-card: #fffdf9; /* card background */
140
+ --deck-edge: #ece7df; /* border */
141
+ --deck-ink: #1a1a1a; /* text */
142
+ --deck-soft: #6b6b6b; /* secondary text */
143
+ --deck-line: #e7e3dc; /* progress track */
144
+ --deck-label: #e9dfcf; /* record centre label */
145
+ --deck-vinyl: 150px; /* overall size */
146
+ }
147
+ ```
148
+
149
+ ## House rotation
150
+
151
+ Set `SPOTIFY_FALLBACK_PLAYLIST` to keep the record spinning while you're offline:
152
+
153
+ | Value | What happens |
154
+ |---|---|
155
+ | `liked` | Rotates through your **Liked Songs**. Visitors can play each song, but Liked Songs has no public playlist link. |
156
+ | a playlist link or ID | Rotates through that playlist and offers an "Open the whole rotation" link. The playlist must belong to (or be collaborative with) the connected account. |
157
+ | empty | Shows what you paused on, or the last song you played. |
158
+
159
+ ## What the card shows
160
+
161
+ 1. **Now spinning:** what you're playing right now (songs and podcasts).
162
+ 2. **House rotation:** nothing playing, so it plays from your fallback playlist.
163
+ 3. **On pause / Last spun:** no rotation set, so it shows the last thing you played.
164
+ 4. **Not connected:** no credentials yet (in development it tells you to run the auth command).
165
+
166
+ ## The JSON
167
+
168
+ `GET /api/now-playing` (add `?demo=live` or `?demo=rotation` for sample data):
169
+
170
+ ```json
171
+ {
172
+ "configured": true,
173
+ "mode": "live",
174
+ "isPlaying": true,
175
+ "track": {
176
+ "id": "…", "type": "track", "title": "…", "artist": "…", "album": "…",
177
+ "albumArt": "https://i.scdn.co/…", "url": "https://open.spotify.com/track/…",
178
+ "durationMs": 214000, "progressMs": 81234
179
+ },
180
+ "playlist": null,
181
+ "playedAt": null,
182
+ "fetchedAt": 1793370000000
183
+ }
184
+ ```
185
+
186
+ `mode` is one of `live`, `rotation`, `paused`, `recent`, `idle`, `unconfigured` or `error`.
187
+
188
+ ## How it works
189
+
190
+ It uses only the official [Spotify Web API](https://developer.spotify.com/documentation/web-api). There's no SDK and nothing in between:
191
+
192
+ - `npx spotify-turntable auth` runs Spotify's standard login flow once and saves a **refresh token**.
193
+ - On each request, the server swaps that for a short-lived access token (cached for about an hour). It then reads `currently-playing`, falls back to your rotation (`/playlists/{id}/items` or `/me/tracks`), and finally to `recently-played`.
194
+ - In-memory caches (player about 12 s, recent about 60 s, playlist about 6 h) and `Cache-Control` headers keep Spotify calls low. If Spotify rate-limits it, it waits and keeps showing the last good result.
195
+ - The play button opens Spotify's official embed player.
196
+
197
+ Scopes requested: `user-read-currently-playing`, `user-read-recently-played`, `user-read-playback-state`, `playlist-read-private`, `playlist-read-collaborative`, `user-library-read`. Everything is read-only.
198
+
199
+ ## Develop
200
+
201
+ ```bash
202
+ npm test # mocked Spotify, never hits the network
203
+ npm run dev # example app on http://localhost:5173
204
+ ```
205
+
206
+ ## License
207
+
208
+ MIT © Felix Arbucias. Not affiliated with Spotify.
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env node
2
+ /*
3
+ * npx spotify-turntable auth
4
+ *
5
+ * One-time setup: asks for your Spotify app's Client ID / Secret (if they aren't already in your
6
+ * env file), opens Spotify's consent screen, and writes SPOTIFY_REFRESH_TOKEN (plus the rest)
7
+ * into .env.local. Nothing is sent anywhere except Spotify.
8
+ */
9
+ import http from "node:http";
10
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
11
+ import { spawn } from "node:child_process";
12
+ import { randomBytes } from "node:crypto";
13
+ import { createInterface } from "node:readline/promises";
14
+ import { stdin, stdout, argv, exit } from "node:process";
15
+ import { parseEnv, upsertEnv } from "../src/env-file.js";
16
+
17
+ const SCOPES = [
18
+ "user-read-currently-playing",
19
+ "user-read-recently-played",
20
+ "user-read-playback-state",
21
+ "playlist-read-private", // private / collaborative SPOTIFY_FALLBACK_PLAYLIST
22
+ "playlist-read-collaborative",
23
+ "user-library-read", // SPOTIFY_FALLBACK_PLAYLIST=liked
24
+ ].join(" ");
25
+
26
+ const HELP = `spotify-turntable
27
+
28
+ Usage
29
+ npx spotify-turntable auth [options] Connect your Spotify account (writes .env.local)
30
+ npx spotify-turntable help
31
+
32
+ Options
33
+ --env-file <path> File to read and update (default: .env.local)
34
+ --port <n> Local callback port (default: 8888)
35
+ --print Only print the values; don't write any file
36
+ `;
37
+
38
+ function flag(name, fallback) {
39
+ const i = argv.indexOf(`--${name}`);
40
+ return i > -1 && argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : fallback;
41
+ }
42
+
43
+ const command = argv[2] && !argv[2].startsWith("--") ? argv[2] : "auth";
44
+ if (command === "help" || argv.includes("--help") || argv.includes("-h")) {
45
+ stdout.write(HELP);
46
+ exit(0);
47
+ }
48
+ if (command !== "auth") {
49
+ console.error(`Unknown command "${command}".\n`);
50
+ stdout.write(HELP);
51
+ exit(1);
52
+ }
53
+
54
+ const ENV_FILE = flag("env-file", ".env.local");
55
+ const PORT = Number(flag("port", 8888));
56
+ const PRINT_ONLY = argv.includes("--print");
57
+ const REDIRECT_URI = `http://127.0.0.1:${PORT}/callback`;
58
+
59
+ const read = (file) => (existsSync(file) ? readFileSync(file, "utf8") : "");
60
+
61
+ function isGitIgnored(file) {
62
+ const rules = read(".gitignore").split(/\r?\n/).map((l) => l.trim());
63
+ const name = file.replace(/^\.\//, "");
64
+ return rules.some((r) => r === name || r === `/${name}` || r === "*.local" || r === ".env*" || r === ".env.*");
65
+ }
66
+
67
+ function openBrowser(url) {
68
+ try {
69
+ const [cmd, args] =
70
+ process.platform === "win32"
71
+ ? ["cmd", ["/c", "start", "", url.replace(/&/g, "^&")]]
72
+ : [process.platform === "darwin" ? "open" : "xdg-open", [url]];
73
+ const child = spawn(cmd, args, { stdio: "ignore", detached: true });
74
+ child.on("error", () => {});
75
+ child.unref();
76
+ } catch {
77
+ // The URL is printed too.
78
+ }
79
+ }
80
+
81
+ async function exchangeCode(code, clientId, clientSecret) {
82
+ const res = await fetch("https://accounts.spotify.com/api/token", {
83
+ method: "POST",
84
+ headers: {
85
+ Authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
86
+ "Content-Type": "application/x-www-form-urlencoded",
87
+ },
88
+ body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI }).toString(),
89
+ });
90
+ const data = await res.json().catch(() => ({}));
91
+ if (!res.ok || !data.refresh_token) {
92
+ const reason = data.error_description || data.error || "no refresh_token";
93
+ const hint = data.error === "invalid_client" ? " (check the Client ID / Secret)" : "";
94
+ throw new Error(`Token exchange failed (${res.status}): ${reason}${hint}`);
95
+ }
96
+ return data.refresh_token;
97
+ }
98
+
99
+ function authorize(clientId, clientSecret) {
100
+ const state = randomBytes(12).toString("hex");
101
+ const url =
102
+ "https://accounts.spotify.com/authorize?" +
103
+ new URLSearchParams({ response_type: "code", client_id: clientId, scope: SCOPES, redirect_uri: REDIRECT_URI, state, show_dialog: "true" });
104
+
105
+ return new Promise((resolve, reject) => {
106
+ const server = http.createServer(async (req, res) => {
107
+ const u = new URL(req.url, REDIRECT_URI);
108
+ if (u.pathname !== "/callback") return res.writeHead(404).end();
109
+ const finish = (status, body, err, token) => {
110
+ res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" }).end(body);
111
+ server.close();
112
+ if (err) reject(err);
113
+ else resolve(token);
114
+ };
115
+ if (u.searchParams.get("error")) return finish(400, "Authorization was cancelled. You can close this tab.", new Error(`Spotify said: ${u.searchParams.get("error")}`));
116
+ if (u.searchParams.get("state") !== state) return finish(400, "State mismatch. Run the command again.", new Error("State mismatch"));
117
+ try {
118
+ const token = await exchangeCode(u.searchParams.get("code") || "", clientId, clientSecret);
119
+ finish(200, "Connected! Head back to your terminal. You can close this tab.", null, token);
120
+ } catch (err) {
121
+ finish(500, "Token exchange failed. Check your terminal.", err);
122
+ }
123
+ });
124
+ server.on("error", (err) => reject(err.code === "EADDRINUSE" ? new Error(`Port ${PORT} is busy. Try --port 8889 (and add that redirect URI in Spotify).`) : err));
125
+ server.listen(PORT, "127.0.0.1", () => {
126
+ console.log("\nOpening Spotify so you can approve access (or open this link yourself):\n");
127
+ console.log(` ${url}\n`);
128
+ openBrowser(url);
129
+ });
130
+ });
131
+ }
132
+
133
+ async function main() {
134
+ const fileEnv = { ...parseEnv(read(".env")), ...parseEnv(read(ENV_FILE)) };
135
+ const env = { ...fileEnv, ...process.env };
136
+ let clientId = env.SPOTIFY_CLIENT_ID;
137
+ let clientSecret = env.SPOTIFY_CLIENT_SECRET;
138
+ let playlist = env.SPOTIFY_FALLBACK_PLAYLIST;
139
+
140
+ console.log("spotify-turntable · connect your Spotify account\n");
141
+ const rl = createInterface({ input: stdin, output: stdout });
142
+ try {
143
+ if (!clientId || !clientSecret) {
144
+ console.log("1. Go to https://developer.spotify.com/dashboard and create an app.");
145
+ console.log(`2. In the app's settings, add this Redirect URI: ${REDIRECT_URI}`);
146
+ console.log(" (tick \"Web API\" when it asks which APIs you'll use)");
147
+ console.log("3. Copy the Client ID and Client Secret below.\n");
148
+ while (!clientId) clientId = (await rl.question("Client ID: ")).trim();
149
+ while (!clientSecret) clientSecret = (await rl.question("Client Secret: ")).trim();
150
+ } else {
151
+ console.log(`Using the Client ID from ${ENV_FILE}. Make sure ${REDIRECT_URI} is a Redirect URI in your Spotify app.`);
152
+ }
153
+ if (playlist === undefined && stdin.isTTY) {
154
+ console.log("\nWhen you're not playing anything, the turntable can play from a rotation like a radio station.");
155
+ playlist = (await rl.question("Paste a playlist link, type \"liked\" for your Liked Songs, or press Enter to skip: ")).trim();
156
+ }
157
+ } finally {
158
+ rl.close();
159
+ }
160
+
161
+ const refreshToken = await authorize(clientId, clientSecret);
162
+ const values = { SPOTIFY_CLIENT_ID: clientId, SPOTIFY_CLIENT_SECRET: clientSecret, SPOTIFY_REFRESH_TOKEN: refreshToken };
163
+ if (playlist) values.SPOTIFY_FALLBACK_PLAYLIST = playlist;
164
+
165
+ if (PRINT_ONLY) {
166
+ console.log("\nConnected. Add these to your environment (keep them secret):\n");
167
+ for (const [k, v] of Object.entries(values)) console.log(`${k}=${v}`);
168
+ } else {
169
+ writeFileSync(ENV_FILE, upsertEnv(read(ENV_FILE), values));
170
+ console.log(`\nConnected! Saved your credentials to ${ENV_FILE}.`);
171
+ if (existsSync(".git") && !isGitIgnored(ENV_FILE)) {
172
+ console.log(`\n⚠ ${ENV_FILE} doesn't look git-ignored. Add it to .gitignore so you never commit your secret.`);
173
+ }
174
+ }
175
+ console.log("\nRestart your dev server and the turntable will be live.");
176
+ console.log("Deploying? Add the same SPOTIFY_* values as environment variables on your host (Vercel, Netlify…).");
177
+ }
178
+
179
+ main().catch((err) => {
180
+ console.error(`\n${err.message}`);
181
+ exit(1);
182
+ });
package/package.json CHANGED
@@ -1,6 +1,69 @@
1
- {
2
- "name": "spotify-turntable",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ {
2
+ "name": "spotify-turntable",
3
+ "version": "0.1.0",
4
+ "description": "A live record-player card for whatever you're playing on Spotify. Drop in a React component, add your credentials, done.",
5
+ "keywords": [
6
+ "spotify",
7
+ "now-playing",
8
+ "react",
9
+ "turntable",
10
+ "portfolio",
11
+ "widget",
12
+ "vite",
13
+ "nextjs"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "Felix Arbucias",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/felixArbucias/portfolio.git",
20
+ "directory": "packages/spotify-turntable"
21
+ },
22
+ "type": "module",
23
+ "sideEffects": [
24
+ "*.css"
25
+ ],
26
+ "exports": {
27
+ ".": "./src/client/index.js",
28
+ "./server": "./src/server/index.js",
29
+ "./vite": "./src/vite.js",
30
+ "./style.css": "./src/style.css",
31
+ "./package.json": "./package.json"
32
+ },
33
+ "main": "./src/client/index.js",
34
+ "bin": {
35
+ "spotify-turntable": "bin/spotify-turntable.mjs"
36
+ },
37
+ "files": [
38
+ "src",
39
+ "bin",
40
+ "README.md",
41
+ "LICENSE"
42
+ ],
43
+ "scripts": {
44
+ "auth": "node bin/spotify-turntable.mjs auth",
45
+ "dev": "vite example",
46
+ "test": "node --test test/*.test.mjs"
47
+ },
48
+ "engines": {
49
+ "node": ">=18"
50
+ },
51
+ "peerDependencies": {
52
+ "react": ">=18",
53
+ "vite": ">=4"
54
+ },
55
+ "peerDependenciesMeta": {
56
+ "react": {
57
+ "optional": true
58
+ },
59
+ "vite": {
60
+ "optional": true
61
+ }
62
+ },
63
+ "devDependencies": {
64
+ "@vitejs/plugin-react": "^4.3.1",
65
+ "react": "^18.3.1",
66
+ "react-dom": "^18.3.1",
67
+ "vite": "^5.4.1"
68
+ }
69
+ }