@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 +21 -0
- package/README.md +44 -0
- package/images/screenshot.png +0 -0
- package/package.json +24 -0
- package/plugin.tsx +265 -0
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 }
|