pi-spotify-widget 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,122 @@
1
+ import type { PlaybackSnapshot } from "./playback-snapshot.ts";
2
+
3
+ type ThemeColor = "accent" | "dim" | "muted" | "success" | "error";
4
+ type Theme = {
5
+ fg: (color: ThemeColor, text: string) => string;
6
+ };
7
+
8
+ const defaultTheme: Theme = {
9
+ fg: (_color, text) => text,
10
+ };
11
+
12
+ const SEEK_BAR_WIDTH = 18;
13
+ const MAX_META_LEN = 48;
14
+
15
+ function ageText(fetchedAt: number, now = Date.now()): string {
16
+ const seconds = Math.max(0, Math.floor((now - fetchedAt) / 1000));
17
+ return `updated ${seconds}s ago`;
18
+ }
19
+
20
+ function staleHint(stale: boolean, theme: Theme): string {
21
+ return stale ? theme.fg("dim", " (stale)") : "";
22
+ }
23
+
24
+ function truncate(text: string, max: number): string {
25
+ const trimmed = text.trim();
26
+ if (trimmed.length <= max) return trimmed;
27
+ return `${trimmed.slice(0, Math.max(0, max - 1))}…`;
28
+ }
29
+
30
+ export function formatClock(ms: number): string {
31
+ const totalSec = Math.max(0, Math.floor(ms / 1000));
32
+ const hours = Math.floor(totalSec / 3600);
33
+ const minutes = Math.floor((totalSec % 3600) / 60);
34
+ const seconds = totalSec % 60;
35
+ const sec = seconds.toString().padStart(2, "0");
36
+ if (hours > 0) {
37
+ return `${hours}:${minutes.toString().padStart(2, "0")}:${sec}`;
38
+ }
39
+ return `${minutes}:${sec}`;
40
+ }
41
+
42
+ export function effectiveProgressMs(snapshot: PlaybackSnapshot, now = Date.now()): number | undefined {
43
+ if (typeof snapshot.progressMs !== "number" || !Number.isFinite(snapshot.progressMs)) {
44
+ return undefined;
45
+ }
46
+ if (!snapshot.isPlaying) return Math.max(0, snapshot.progressMs);
47
+ const elapsed = Math.max(0, now - snapshot.fetchedAt);
48
+ const live = snapshot.progressMs + elapsed;
49
+ if (typeof snapshot.durationMs === "number" && snapshot.durationMs > 0) {
50
+ return Math.min(snapshot.durationMs, live);
51
+ }
52
+ return live;
53
+ }
54
+
55
+ export function renderSeekBar(
56
+ progressMs: number,
57
+ durationMs: number,
58
+ options?: { width?: number; isPlaying?: boolean },
59
+ ): string {
60
+ const width = options?.width ?? SEEK_BAR_WIDTH;
61
+ const headChar = options?.isPlaying === false ? "◆" : "●";
62
+
63
+ if (durationMs <= 0) return "─".repeat(width);
64
+
65
+ const ratio = Math.min(1, Math.max(0, progressMs / durationMs));
66
+ const head = Math.min(width - 1, Math.round(ratio * (width - 1)));
67
+ const chars: string[] = [];
68
+ for (let i = 0; i < width; i += 1) {
69
+ if (i < head) chars.push("━");
70
+ else if (i === head) chars.push(headChar);
71
+ else chars.push("─");
72
+ }
73
+ return chars.join("");
74
+ }
75
+
76
+ export function renderProgressLine(snapshot: PlaybackSnapshot, now = Date.now()): string | null {
77
+ const progressMs = effectiveProgressMs(snapshot, now);
78
+ if (progressMs === undefined) return null;
79
+
80
+ const current = formatClock(progressMs);
81
+ if (typeof snapshot.durationMs !== "number" || snapshot.durationMs <= 0) {
82
+ return `${current} elapsed`;
83
+ }
84
+
85
+ const bar = renderSeekBar(progressMs, snapshot.durationMs, { isPlaying: snapshot.isPlaying });
86
+ return `${current} ${bar} ${formatClock(snapshot.durationMs)}`;
87
+ }
88
+
89
+ export function renderNoAuth(theme: Theme = defaultTheme): string[] {
90
+ return [theme.fg("dim", "♫ Run /spotify:login")];
91
+ }
92
+
93
+ export function renderIdle(snapshot: PlaybackSnapshot | null, theme: Theme = defaultTheme, options?: { stale?: boolean; now?: number }): string[] {
94
+ const stale = options?.stale ?? false;
95
+ return [theme.fg("dim", `♫ Nothing playing${stale ? " (stale)" : ""}`)];
96
+ }
97
+
98
+ export function renderSnapshot(snapshot: PlaybackSnapshot, theme: Theme = defaultTheme, options?: { stale?: boolean; now?: number }): string[] {
99
+ const now = options?.now ?? Date.now();
100
+ const stale = options?.stale ?? false;
101
+ const icon = snapshot.isPlaying ? "▶" : "⏸";
102
+ const meta = truncate(`${snapshot.track} — ${snapshot.artist}`, MAX_META_LEN);
103
+ const progressLine = renderProgressLine(snapshot, now);
104
+
105
+ const lines = [theme.fg("accent", `♫ ${icon} ${meta}${staleHint(stale, theme)}`)];
106
+ if (progressLine) {
107
+ lines.push(theme.fg("muted", progressLine));
108
+ }
109
+ return lines;
110
+ }
111
+
112
+ export function renderError(message: string, theme: Theme = defaultTheme): string[] {
113
+ return [theme.fg("error", `♫ Spotify: ${message}`)];
114
+ }
115
+
116
+ export const __testing = {
117
+ ageText,
118
+ effectiveProgressMs,
119
+ formatClock,
120
+ renderProgressLine,
121
+ renderSeekBar,
122
+ };
package/lib/x-share.ts ADDED
@@ -0,0 +1,211 @@
1
+ import { createHmac, randomBytes } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import type { PlaybackSnapshot } from "./playback-snapshot.ts";
4
+
5
+ export interface XCredentials {
6
+ apiKey: string;
7
+ apiSecret: string;
8
+ accessToken: string;
9
+ accessTokenSecret: string;
10
+ }
11
+
12
+ export type ShareMode = "intent" | "api";
13
+
14
+ export interface ShareResult {
15
+ mode: ShareMode;
16
+ text: string;
17
+ tweetUrl?: string;
18
+ intentUrl?: string;
19
+ }
20
+
21
+ export const PI_INSTALL_URL = "https://pi.dev";
22
+ export const SPOTIFY_PACKAGE_INSTALL = "pi install npm:pi-spotify-widget";
23
+
24
+ const X_TWEET_ENDPOINT = "https://api.twitter.com/2/tweets";
25
+ const X_INTENT_BASE = "https://twitter.com/intent/tweet";
26
+
27
+ export function resolveXCredentials(env: NodeJS.ProcessEnv = process.env): XCredentials | null {
28
+ const apiKey = env.PI_X_API_KEY?.trim() || env.X_API_KEY?.trim();
29
+ const apiSecret = env.PI_X_API_SECRET?.trim() || env.X_API_SECRET?.trim();
30
+ const accessToken = env.PI_X_ACCESS_TOKEN?.trim() || env.X_ACCESS_TOKEN?.trim();
31
+ const accessTokenSecret = env.PI_X_ACCESS_TOKEN_SECRET?.trim() || env.X_ACCESS_TOKEN_SECRET?.trim();
32
+ if (!apiKey || !apiSecret || !accessToken || !accessTokenSecret) return null;
33
+ return { apiKey, apiSecret, accessToken, accessTokenSecret };
34
+ }
35
+
36
+ export function buildNowPlayingShareText(
37
+ snapshot: PlaybackSnapshot,
38
+ customPrefix?: string,
39
+ ): string {
40
+ const icon = snapshot.isPlaying ? "▶" : "⏸";
41
+ const core = `${icon} ${snapshot.track} — ${snapshot.artist}`;
42
+ const withUrl = snapshot.spotifyUrl ? `${core}\n${snapshot.spotifyUrl}` : core;
43
+ const prefix = customPrefix?.trim();
44
+ if (!prefix) return withUrl;
45
+ return `${prefix}\n\n${withUrl}`;
46
+ }
47
+
48
+ export function buildShareIntentUrl(text: string): string {
49
+ return `${X_INTENT_BASE}?text=${encodeURIComponent(text)}`;
50
+ }
51
+
52
+
53
+ /** Soccer-widget style X post: headline, track, install steps, note, hashtags. */
54
+ export function buildSpotifySharePostText(
55
+ snapshot: PlaybackSnapshot,
56
+ customPrefix?: string,
57
+ ): string {
58
+ const icon = snapshot.isPlaying ? "▶" : "⏸";
59
+ const headline = customPrefix?.trim() || "Now playing on Pi";
60
+ const lines: string[] = [
61
+ `${headline} 🎧`,
62
+ `${icon} ${snapshot.track} — ${snapshot.artist}`,
63
+ ];
64
+ if (snapshot.spotifyUrl) lines.push(snapshot.spotifyUrl);
65
+ lines.push(
66
+ "Try it:",
67
+ `1 install ${PI_INSTALL_URL}`,
68
+ `2 ${SPOTIFY_PACKAGE_INSTALL}`,
69
+ "3 /spotify:login",
70
+ "4 /spotify:share",
71
+ "BYO Spotify Client ID. Login once in Pi.",
72
+ "#NowPlaying #Spotify",
73
+ );
74
+ return lines.join("\n");
75
+ }
76
+
77
+ export function openSpotifySharePost(
78
+ snapshot: PlaybackSnapshot,
79
+ options: { customPrefix?: string; openUrl?: (url: string) => void } = {},
80
+ ): ShareResult {
81
+ const text = buildSpotifySharePostText(snapshot, options.customPrefix);
82
+ return openShareIntent(text, options);
83
+ }
84
+
85
+ /** Same browser launch as pi-soccer-widget (cmd start on Windows). */
86
+ export function openBrowserUrl(url: string): void {
87
+ if (process.env.PI_SPOTIFY_DISABLE_OPEN === "1") return;
88
+
89
+ const platform = process.platform;
90
+ const command = platform === "win32" ? "cmd" : platform === "darwin" ? "open" : "xdg-open";
91
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
92
+ const child = spawn(command, args, { detached: true, stdio: "ignore", shell: false });
93
+ child.unref?.();
94
+ }
95
+
96
+ export function openShareIntent(
97
+ text: string,
98
+ options: { openUrl?: (url: string) => void } = {},
99
+ ): ShareResult {
100
+ const intentUrl = buildShareIntentUrl(text);
101
+ (options.openUrl ?? openBrowserUrl)(intentUrl);
102
+ return { mode: "intent", text, intentUrl };
103
+ }
104
+
105
+ function percentEncode(value: string): string {
106
+ return encodeURIComponent(value).replace(/[!'()*]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
107
+ }
108
+
109
+ function oauth1Signature(
110
+ method: string,
111
+ url: string,
112
+ oauthParams: Record<string, string>,
113
+ credentials: XCredentials,
114
+ ): string {
115
+ const params = new URLSearchParams();
116
+ for (const [key, value] of Object.entries(oauthParams)) params.append(key, value);
117
+ const paramString = Array.from(params.entries())
118
+ .sort(([aKey, aVal], [bKey, bVal]) => (aKey === bKey ? aVal.localeCompare(bVal) : aKey.localeCompare(bKey)))
119
+ .map(([key, value]) => `${percentEncode(key)}=${percentEncode(value)}`)
120
+ .join("&");
121
+
122
+ const base = [
123
+ method.toUpperCase(),
124
+ percentEncode(url),
125
+ percentEncode(paramString),
126
+ ].join("&");
127
+
128
+ const signingKey = `${percentEncode(credentials.apiSecret)}&${percentEncode(credentials.accessTokenSecret)}`;
129
+ return createHmac("sha1", signingKey).update(base).digest("base64");
130
+ }
131
+
132
+ function oauth1Header(method: string, url: string, credentials: XCredentials): string {
133
+ const oauthParams: Record<string, string> = {
134
+ oauth_consumer_key: credentials.apiKey,
135
+ oauth_nonce: randomBytes(16).toString("hex"),
136
+ oauth_signature_method: "HMAC-SHA1",
137
+ oauth_timestamp: Math.floor(Date.now() / 1000).toString(),
138
+ oauth_token: credentials.accessToken,
139
+ oauth_version: "1.0",
140
+ };
141
+ oauthParams.oauth_signature = oauth1Signature(method, url, oauthParams, credentials);
142
+
143
+ const header = Object.entries(oauthParams)
144
+ .sort(([a], [b]) => a.localeCompare(b))
145
+ .map(([key, value]) => `${percentEncode(key)}="${percentEncode(value)}"`)
146
+ .join(", ");
147
+
148
+ return `OAuth ${header}`;
149
+ }
150
+
151
+ export async function postToXApi(
152
+ text: string,
153
+ options: { credentials?: XCredentials | null; fetchFn?: typeof fetch } = {},
154
+ ): Promise<ShareResult> {
155
+ const credentials = options.credentials ?? resolveXCredentials();
156
+ const fetchFn = options.fetchFn ?? fetch;
157
+ if (!credentials) {
158
+ throw new Error("X API credentials are not configured. Set PI_X_API_KEY and related env vars, or use /spotify:share without 'api'.");
159
+ }
160
+
161
+ const res = await fetchFn(X_TWEET_ENDPOINT, {
162
+ method: "POST",
163
+ headers: {
164
+ Authorization: oauth1Header("POST", X_TWEET_ENDPOINT, credentials),
165
+ "Content-Type": "application/json",
166
+ },
167
+ body: JSON.stringify({ text }),
168
+ });
169
+
170
+ if (!res.ok) {
171
+ const body = await res.text().catch(() => "");
172
+ throw new Error(`X post failed (HTTP ${res.status})${body ? `: ${body.slice(0, 160)}` : ""}`);
173
+ }
174
+
175
+ const payload = await res.json() as { data?: { id?: string } };
176
+ const id = payload.data?.id;
177
+ return {
178
+ mode: "api",
179
+ text,
180
+ tweetUrl: id ? `https://x.com/i/web/status/${id}` : undefined,
181
+ };
182
+ }
183
+
184
+ export async function shareToX(
185
+ text: string,
186
+ options: { mode?: ShareMode; credentials?: XCredentials | null; fetchFn?: typeof fetch; openUrl?: (url: string) => void } = {},
187
+ ): Promise<ShareResult> {
188
+ const mode = options.mode ?? "intent";
189
+ if (mode === "intent") return openShareIntent(text, options);
190
+ return postToXApi(text, options);
191
+ }
192
+
193
+ /** @deprecated use shareToX */
194
+ export async function postToX(
195
+ text: string,
196
+ options: { credentials?: XCredentials | null; fetchFn?: typeof fetch; openUrl?: (url: string) => void } = {},
197
+ ): Promise<ShareResult> {
198
+ return shareToX(text, { ...options, mode: "intent" });
199
+ }
200
+
201
+ export const __testing = {
202
+ buildNowPlayingShareText,
203
+ buildShareIntentUrl,
204
+ oauth1Signature,
205
+ buildSpotifySharePostText,
206
+ openBrowserUrl,
207
+ openShareIntent,
208
+ openSpotifySharePost,
209
+ resolveXCredentials,
210
+ shareToX,
211
+ };
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "pi-spotify-widget",
3
+ "version": "0.1.0",
4
+ "description": "Lightweight Spotify Now Playing widget for Pi Coding Agent.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "eiei114",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi",
11
+ "agent-skill",
12
+ "typescript"
13
+ ],
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/eiei114/pi-spotify-widget.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/eiei114/pi-spotify-widget/issues"
20
+ },
21
+ "homepage": "https://github.com/eiei114/pi-spotify-widget#readme",
22
+ "files": [
23
+ "extensions/",
24
+ "lib/",
25
+ "docs/",
26
+ "README.md",
27
+ "LICENSE",
28
+ "CHANGELOG.md",
29
+ "skills/"
30
+ ],
31
+ "scripts": {
32
+ "typecheck": "tsc --noEmit",
33
+ "test": "node --test tests/*.test.mjs",
34
+ "ci": "npm run typecheck && npm test && npm run pack:check",
35
+ "pack:check": "npm pack --dry-run"
36
+ },
37
+ "pi": {
38
+ "extensions": [
39
+ "./extensions"
40
+ ],
41
+ "skills": [
42
+ "./skills/spotify-playback"
43
+ ]
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ },
48
+ "peerDependencies": {
49
+ "@earendil-works/pi-ai": "*",
50
+ "@earendil-works/pi-coding-agent": "*",
51
+ "@earendil-works/pi-tui": "*",
52
+ "typebox": "*"
53
+ },
54
+ "devDependencies": {
55
+ "@earendil-works/pi-ai": "latest",
56
+ "@earendil-works/pi-coding-agent": "latest",
57
+ "@earendil-works/pi-tui": "latest",
58
+ "typebox": "latest",
59
+ "@types/node": "^22.0.0",
60
+ "typescript": "^6.0.3"
61
+ }
62
+ }
@@ -0,0 +1,28 @@
1
+ ---
2
+ name: spotify-playback
3
+ description: Control Spotify playback and share now playing to X from Pi.
4
+ ---
5
+
6
+ # Spotify Playback & Share
7
+
8
+ When the user asks about music control or sharing, run the matching Pi command.
9
+
10
+ ## Playback
11
+
12
+ | User intent | Run |
13
+ |---|---|
14
+ | 前の曲 / previous | `/spotify:prev` |
15
+ | 次の曲 / skip / next | `/spotify:next` |
16
+ | 止めて / stop / pause | `/spotify:pause` |
17
+ | 再生 / resume / play | `/spotify:play` |
18
+
19
+ If the user wants to play a specific track by URL, run `/spotify:play-uri <spotify-url>`.
20
+
21
+ ## Share to X (soccer-widget style)
22
+
23
+ | User intent | Run |
24
+ |---|---|
25
+ | Xに投稿 / シェア / tweet this song | `/spotify:share` |
26
+ | コメント付きでシェア | `/spotify:share <your message>` |
27
+
28
+ Opens X compose with prefilled text (track, install steps, hashtags). Screenshot upload is manual.