@teakmirror113/now-playing 1.0.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
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 ADDED
@@ -0,0 +1,44 @@
1
+ # Now Playing
2
+
3
+ OpenCode TUI plugin that shows your currently playing track in the sidebar. Supports Apple Music and Spotify.
4
+
5
+ <img src="images/screenshot.png" width="33%">
6
+
7
+ ## Features
8
+
9
+ - Shows now-playing info (track, artist, album) from Apple Music or Spotify
10
+ - Collapsible UI — click `▶`/`▼` to hide/show the entire plugin block
11
+ - Album art rendered as ASCII in the sidebar via Python + Pillow (click `[a]` to toggle)
12
+ - Progress bar with elapsed/total time
13
+ - Playback controls (⏮ play/pause ⏭)
14
+ - 2-second polling for smooth progress updates
15
+ - Prefers actively playing app when both are running
16
+ - Caches album art per track (only re-converts on track change)
17
+
18
+ ## Requirements
19
+
20
+ - macOS with Apple Music or Spotify
21
+ - [OpenCode](https://opencode.ai) with TUI enabled (`opencode tui enable`)
22
+ - Python 3 with [Pillow](https://python-pillow.org/) (`pip3 install Pillow`)
23
+
24
+ ## Installation
25
+
26
+ ```sh
27
+ opencode plugin add @teakmirror113/now-playing
28
+ ```
29
+
30
+ Or add manually to `~/.config/opencode/tui.json`:
31
+
32
+ ```json
33
+ {
34
+ "plugin": ["@teakmirror113/now-playing"]
35
+ }
36
+ ```
37
+
38
+ ## How it works
39
+
40
+ Uses `osascript` (JXA) to query Music.app and Spotify simultaneously. If one app is actively playing while the other is paused, the playing app takes priority. Album art is extracted via AppleScript (Music) or downloaded via `curl` (Spotify), then converted to grayscale ASCII using Python Pillow.
41
+
42
+ ## License
43
+
44
+ MIT
Binary file
package/package.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "name": "@teakmirror113/now-playing",
3
+ "version": "1.0.0",
4
+ "description": "Show currently playing track (Apple Music / Spotify) in OpenCode TUI sidebar with album art",
5
+ "type": "module",
6
+ "exports": {
7
+ "./tui": "./plugin.tsx"
8
+ },
9
+ "publishConfig": {
10
+ "access": "public"
11
+ },
12
+ "keywords": [
13
+ "opencode",
14
+ "plugin",
15
+ "tui",
16
+ "spotify"
17
+ ],
18
+ "license": "MIT",
19
+ "author": "teakmirror113",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/Ansh-Sonkusare/now-playing.git"
23
+ }
24
+ }
package/plugin.tsx ADDED
@@ -0,0 +1,265 @@
1
+ /** @jsxImportSource solid-js */
2
+ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
3
+ import { createSignal, onCleanup, Show, For } from "solid-js"
4
+ import { execFile } from "node:child_process"
5
+
6
+ interface NowPlaying {
7
+ source: "Music" | "Spotify"
8
+ state: "playing" | "paused"
9
+ track: string
10
+ artist: string
11
+ album: string
12
+ position: number
13
+ duration: number
14
+ art: string[]
15
+ }
16
+
17
+ const PY_SCRIPT = [
18
+ "from PIL import Image",
19
+ "import sys",
20
+ "path = sys.argv[1]",
21
+ "img = Image.open(path)",
22
+ "w, h = 36, 18",
23
+ "img = img.resize((w, h), Image.LANCZOS)",
24
+ 'img = img.convert("L")',
25
+ 'chars = "\u00a0-:=+*#%@"',
26
+ "for y in range(h):",
27
+ ' line = ""',
28
+ " for x in range(w):",
29
+ " v = img.getpixel((x, y))",
30
+ " line += chars[v * (len(chars) - 1) // 256]",
31
+ " print(line)",
32
+ ].join("\n")
33
+
34
+ const JXA_QUERY = `
35
+ function getMusicInfo() {
36
+ var app = Application("Music");
37
+ if (!app.running()) return null;
38
+ var state = app.playerState();
39
+ if (state !== "playing" && state !== "paused") return null;
40
+ var t = app.currentTrack;
41
+ return {
42
+ source: "Music",
43
+ state: state,
44
+ track: String(t.name()),
45
+ artist: String(t.artist()),
46
+ album: String(t.album()),
47
+ position: Number(app.playerPosition()),
48
+ duration: Number(t.duration()),
49
+ id: String(t.persistentID())
50
+ };
51
+ }
52
+ function getSpotifyInfo() {
53
+ var app = Application("Spotify");
54
+ if (!app.running()) return null;
55
+ var state = app.playerState();
56
+ if (state !== "playing" && state !== "paused") return null;
57
+ var t = app.currentTrack;
58
+ return {
59
+ source: "Spotify",
60
+ state: state,
61
+ track: String(t.name()),
62
+ artist: String(t.artist()),
63
+ album: String(t.album()),
64
+ position: Number(app.playerPosition()),
65
+ duration: Number(t.duration()) / 1000,
66
+ id: String(t.id()),
67
+ artworkUrl: String(t.artworkUrl())
68
+ };
69
+ }
70
+ var music = getMusicInfo();
71
+ var spotify = getSpotifyInfo();
72
+ var candidates = [music, spotify].filter(function(x) { return x !== null; });
73
+ var playing = candidates.filter(function(c) { return c.state === "playing"; });
74
+ var info = playing.length > 0 ? playing[0] : (candidates.length > 0 ? candidates[0] : null);
75
+ if (info) console.log(JSON.stringify(info));
76
+ `.trim()
77
+
78
+ const ART_PATH = "/tmp/album-art-tmp.jpg"
79
+
80
+ function jxa(script: string): Promise<string> {
81
+ return new Promise((resolve, reject) => {
82
+ execFile("osascript", ["-l", "JavaScript", "-e", script], { encoding: "utf-8", timeout: 5000 }, (err, stdout, stderr) => {
83
+ if (err) reject(err)
84
+ else resolve((stderr || stdout).trim())
85
+ })
86
+ })
87
+ }
88
+
89
+ function osa(script: string): Promise<string> {
90
+ return new Promise((resolve, reject) => {
91
+ execFile("osascript", ["-e", script], { encoding: "utf-8", timeout: 5000 }, (err, stdout) => {
92
+ if (err) reject(err)
93
+ else resolve(stdout.trim())
94
+ })
95
+ })
96
+ }
97
+
98
+ function py(code: string, arg: string): Promise<string> {
99
+ return new Promise((resolve, reject) => {
100
+ execFile("python3", ["-c", code, arg], { encoding: "utf-8", timeout: 10000 }, (err, stdout) => {
101
+ if (err) reject(err)
102
+ else resolve(stdout.trim())
103
+ })
104
+ })
105
+ }
106
+
107
+ let lastId = ""
108
+ let cachedArt: string[] = []
109
+
110
+ async function fetchArtwork(source: string, artworkUrl?: string): Promise<string[]> {
111
+ try {
112
+ if (source === "Music") {
113
+ const artScript = [
114
+ 'tell application "Music"',
115
+ " set art to artwork 1 of current track",
116
+ " set artData to raw data of art",
117
+ ` set outFile to (POSIX file "${ART_PATH}")`,
118
+ " set fileRef to open for access outFile with write permission",
119
+ " write artData to fileRef",
120
+ " close access fileRef",
121
+ "end tell",
122
+ ].join("\n")
123
+ await osa(artScript)
124
+ } else if (source === "Spotify" && artworkUrl) {
125
+ await new Promise<void>((resolve, reject) => {
126
+ execFile("curl", ["-sL", artworkUrl, "-o", ART_PATH], { encoding: "utf-8", timeout: 10000 }, (err) => {
127
+ if (err) reject(err)
128
+ else resolve()
129
+ })
130
+ })
131
+ }
132
+ const ascii = await py(PY_SCRIPT, ART_PATH)
133
+ return ascii.split("\n")
134
+ } catch {
135
+ return []
136
+ }
137
+ }
138
+
139
+ async function fetchNowPlaying(): Promise<NowPlaying | null> {
140
+ try {
141
+ const out = await jxa(JXA_QUERY)
142
+ if (!out) return null
143
+ const raw = JSON.parse(out)
144
+ if (!raw.source || (raw.state !== "playing" && raw.state !== "paused")) return null
145
+
146
+ const trackId = raw.id as string
147
+ if (trackId !== lastId) {
148
+ lastId = trackId
149
+ cachedArt = []
150
+ cachedArt = await fetchArtwork(raw.source, raw.artworkUrl)
151
+ }
152
+
153
+ return {
154
+ source: raw.source,
155
+ state: raw.state,
156
+ track: String(raw.track),
157
+ artist: String(raw.artist),
158
+ album: String(raw.album),
159
+ position: Number(raw.position) || 0,
160
+ duration: Number(raw.duration) || 0,
161
+ art: cachedArt,
162
+ }
163
+ } catch {
164
+ return null
165
+ }
166
+ }
167
+
168
+ function fmt(s: number): string {
169
+ const m = Math.floor(s / 60)
170
+ const sec = Math.floor(s % 60)
171
+ return `${m}:${sec.toString().padStart(2, "0")}`
172
+ }
173
+
174
+ const SOURCE_ICON: Record<string, string> = { Music: "♫", Spotify: "◉" }
175
+
176
+ function View(props: { api: TuiPluginApi }) {
177
+ const [np, setNp] = createSignal<NowPlaying | null>(null)
178
+ const [collapsed, setCollapsed] = createSignal(false)
179
+ const [showArt, setShowArt] = createSignal(true)
180
+ const theme = () => props.api.theme.current
181
+
182
+ fetchNowPlaying().then(setNp)
183
+ const timer = setInterval(() => {
184
+ fetchNowPlaying().then(setNp)
185
+ }, 2000)
186
+ onCleanup(() => clearInterval(timer))
187
+
188
+ const cur = () => np()?.source ?? "Music"
189
+ const playpause = () => osa(`tell application "${cur()}" to playpause`).catch(() => {})
190
+ const next = () => osa(`tell application "${cur()}" to next track`).catch(() => {})
191
+ const prev = () => osa(`tell application "${cur()}" to previous track`).catch(() => {})
192
+
193
+ return (
194
+ <box>
195
+ <box flexDirection="row" gap={1}>
196
+ <text fg={theme().text} onMouseDown={() => setCollapsed(!collapsed())}>
197
+ {collapsed() ? "▶" : "▼"}
198
+ </text>
199
+ <text fg={theme().text}>
200
+ <b>Now Playing</b>
201
+ </text>
202
+ <Show when={!collapsed()}>
203
+ <text fg={theme().textMuted} onMouseDown={() => setShowArt(!showArt())}>
204
+ [{showArt() ? "a" : " "}]
205
+ </text>
206
+ </Show>
207
+ </box>
208
+ <Show when={!collapsed() && np()}>
209
+ {(data) => (
210
+ <>
211
+ <Show when={showArt() && data().art.length > 0}>
212
+ <box>
213
+ <For each={data().art}>
214
+ {(line) => <text fg={theme().textMuted}>{line}</text>}
215
+ </For>
216
+ </box>
217
+ </Show>
218
+ <text fg={theme().textMuted}>{data().track}</text>
219
+ <text fg={theme().textMuted}>{data().artist}</text>
220
+ <box marginTop={1}>
221
+ <ProgressBar pos={data().position} dur={data().duration} fg={theme().textMuted} />
222
+ </box>
223
+ <box flexDirection="row" gap={2} marginTop={1}>
224
+ <text fg={theme().text} onMouseDown={prev}>⏮</text>
225
+ <text fg={theme().text} onMouseDown={playpause}>
226
+ {data().state === "playing" ? "⏸" : "▶"}
227
+ </text>
228
+ <text fg={theme().text} onMouseDown={next}>⏭</text>
229
+ </box>
230
+ </>
231
+ )}
232
+ </Show>
233
+ <Show when={!collapsed() && !np()}>
234
+ <text fg={theme().textMuted}>No music playing</text>
235
+ </Show>
236
+ </box>
237
+ )
238
+ }
239
+
240
+ const BAR_WIDTH = 24
241
+
242
+ function ProgressBar(props: { pos: number; dur: number; fg: string }) {
243
+ const pct = props.dur > 0 ? Math.min(Math.max(props.pos / props.dur, 0), 1) : 0
244
+ const filled = Math.floor(pct * BAR_WIDTH)
245
+ const empty = BAR_WIDTH - filled
246
+ const bar = (filled > 0 ? "━".repeat(filled) : "") + (empty > 0 ? "─".repeat(empty) : "")
247
+ return (
248
+ <text fg={props.fg}>
249
+ {fmt(props.pos)} {bar} {fmt(props.dur)}
250
+ </text>
251
+ )
252
+ }
253
+
254
+ const tui: TuiPlugin = async (api) => {
255
+ api.slots.register({
256
+ order: 50,
257
+ slots: {
258
+ sidebar_content() {
259
+ return <View api={api} />
260
+ },
261
+ },
262
+ })
263
+ }
264
+
265
+ export default { id: "now-playing", tui }