@naxodev/opencode-music-player 0.2.5 → 0.3.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/dist/tui.js ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./index.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naxodev/opencode-music-player",
3
- "version": "0.2.5",
3
+ "version": "0.3.0",
4
4
  "description": "macOS system Now Playing controls for the OpenCode 2 TUI",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -27,22 +27,12 @@
27
27
  "media-control"
28
28
  ],
29
29
  "exports": {
30
- ".": "./index.tsx",
31
- "./tui": "./tui.tsx"
30
+ ".": "./dist/index.js",
31
+ "./tui": "./dist/tui.js"
32
32
  },
33
33
  "files": [
34
34
  "CHANGELOG.md",
35
- "artwork-placement.ts",
36
- "artwork.ts",
37
- "artwork.tsx",
38
- "index.tsx",
39
- "tui.tsx",
40
- "kitty-graphics.ts",
41
- "ui.tsx",
42
- "system-media.ts",
43
- "tmux-offset.ts",
44
- "types.ts",
45
- "waveform.tsx",
35
+ "dist",
46
36
  "SUPPORT.md",
47
37
  "LICENSE",
48
38
  "README.md"
@@ -58,6 +48,8 @@
58
48
  "darwin"
59
49
  ],
60
50
  "scripts": {
51
+ "build": "bun run scripts/build.ts",
52
+ "prepack": "bun run build",
61
53
  "format": "prettier --write .",
62
54
  "format:check": "prettier --check .",
63
55
  "check": "tsc --noEmit",
@@ -68,18 +60,34 @@
68
60
  "validate": "bun run format:check && bun run check && bun run test && bun run pack:check"
69
61
  },
70
62
  "dependencies": {
71
- "@naxodev/music-core": "^0.1.1",
72
- "@opencode-ai/plugin": "0.0.0-next-17444",
73
- "@opentui/core": "0.5.4",
74
- "@opentui/solid": "0.5.4",
75
- "pngjs": "^7.0.0",
76
- "solid-js": "1.9.15"
63
+ "@naxodev/music-core": "^0.1.4",
64
+ "pngjs": "^7.0.0"
65
+ },
66
+ "peerDependencies": {
67
+ "@opencode/plugin": "2.0.3",
68
+ "@opentui/core": "0.5.10",
69
+ "@opentui/solid": "0.5.10"
77
70
  },
78
71
  "devDependencies": {
79
- "@opencode-ai/theme": "0.0.0",
72
+ "@opencode/plugin": "2.0.3",
73
+ "@opentui/core": "0.5.10",
74
+ "@opentui/solid": "0.5.10",
75
+ "solid-js": "1.9.15",
76
+ "@opencode/theme": "2.0.3",
80
77
  "@types/bun": "^1.3.14",
81
78
  "@types/pngjs": "^6.0.5",
82
79
  "prettier": "3.9.6",
83
80
  "typescript": "7.0.2"
81
+ },
82
+ "peerDependenciesMeta": {
83
+ "@opencode/plugin": {
84
+ "optional": true
85
+ },
86
+ "@opentui/core": {
87
+ "optional": true
88
+ },
89
+ "@opentui/solid": {
90
+ "optional": true
91
+ }
84
92
  }
85
93
  }
@@ -1,169 +0,0 @@
1
- /**
2
- * Pure planner for native Kitty album-artwork placement.
3
- *
4
- * Decides which cleanup/draw actions to run when slot geometry or image
5
- * identity changes. No I/O, no PNG bytes, no terminal writes.
6
- *
7
- * **Write-failure / `nextState` commit rule (Phase 2 executor):**
8
- * `nextState` is the state to commit **only after every planned action is
9
- * applied successfully**. On any failed write, the executor must keep the
10
- * previous `state` so the next frame retries from a known-good snapshot.
11
- * This module only proposes the post-success state; it does not simulate I/O.
12
- */
13
-
14
- export type NativeArtworkPlacement = {
15
- imageId: number
16
- x: number
17
- y: number
18
- width: number
19
- height: number
20
- }
21
-
22
- export type NativeArtworkState = {
23
- /** Kitty image id currently believed transmitted (`0` = none). */
24
- transmitted: number
25
- /** Last successfully applied geometry, or `null`. */
26
- placement: NativeArtworkPlacement | null
27
- }
28
-
29
- export type NativeArtworkPlacementInput = {
30
- state: NativeArtworkState
31
- /** Desired Kitty image id for current artwork; ignored when clearing. */
32
- imageId: number
33
- /** Desired absolute cell geometry (caller applies any tmux offset). */
34
- x: number
35
- y: number
36
- width: number
37
- height: number
38
- /** Host can show native graphics. */
39
- kittySupported: boolean
40
- /** Container present, not destroyed, `width >= 1`, `height >= 1`. */
41
- slotValid: boolean
42
- /** Component tearing down. */
43
- disposed: boolean
44
- }
45
-
46
- export type NativeArtworkPlacementAction =
47
- | { type: "delete-image"; imageId: number }
48
- | { type: "delete-placement"; imageId: number }
49
- | {
50
- type: "transmit-and-display"
51
- imageId: number
52
- x: number
53
- y: number
54
- width: number
55
- height: number
56
- }
57
- | {
58
- type: "place"
59
- imageId: number
60
- placementId: number
61
- x: number
62
- y: number
63
- width: number
64
- height: number
65
- }
66
-
67
- export type NativeArtworkPlacementPlan = {
68
- actions: NativeArtworkPlacementAction[]
69
- /**
70
- * Proposed state after successful apply of every action.
71
- * Do not commit on partial/failed writes — see module JSDoc.
72
- */
73
- nextState: NativeArtworkState
74
- }
75
-
76
- const CLEARED_STATE: NativeArtworkState = {
77
- transmitted: 0,
78
- placement: null,
79
- }
80
-
81
- /** Stable key matching the historical `imageId:x:y:width:height` string shape. */
82
- export function placementKey(placement: NativeArtworkPlacement): string {
83
- return [
84
- placement.imageId,
85
- placement.x,
86
- placement.y,
87
- placement.width,
88
- placement.height,
89
- ].join(":")
90
- }
91
-
92
- function samePlacement(
93
- a: NativeArtworkPlacement | null,
94
- b: NativeArtworkPlacement,
95
- ): boolean {
96
- return a !== null && placementKey(a) === placementKey(b)
97
- }
98
-
99
- /**
100
- * Plan Kitty cleanup/draw actions for the next native-artwork frame.
101
- *
102
- * Decision table:
103
- * 1. disposed / unsupported / invalid slot → delete transmitted image (if any)
104
- * 2. different image id (incl. first paint) → delete old image, then transmit-and-display
105
- * 3. same image id, geometry changed → delete-placement then place
106
- * 4. same image id, same geometry → no-op
107
- */
108
- export function planNativeArtworkPlacement(
109
- input: NativeArtworkPlacementInput,
110
- ): NativeArtworkPlacementPlan {
111
- const { state, imageId, x, y, width, height } = input
112
- const desired: NativeArtworkPlacement = { imageId, x, y, width, height }
113
-
114
- if (input.disposed || !input.kittySupported || !input.slotValid) {
115
- if (state.transmitted !== 0) {
116
- return {
117
- actions: [{ type: "delete-image", imageId: state.transmitted }],
118
- nextState: CLEARED_STATE,
119
- }
120
- }
121
- return { actions: [], nextState: state }
122
- }
123
-
124
- if (state.transmitted !== imageId) {
125
- const actions: NativeArtworkPlacementAction[] = []
126
- if (state.transmitted !== 0) {
127
- actions.push({ type: "delete-image", imageId: state.transmitted })
128
- } else {
129
- // A remount loses local state, but the terminal may still hold this ID.
130
- actions.push({ type: "delete-image", imageId })
131
- }
132
- actions.push({
133
- type: "transmit-and-display",
134
- imageId,
135
- x,
136
- y,
137
- width,
138
- height,
139
- })
140
- return {
141
- actions,
142
- nextState: { transmitted: imageId, placement: desired },
143
- }
144
- }
145
-
146
- // Same image id.
147
- if (samePlacement(state.placement, desired)) {
148
- return { actions: [], nextState: state }
149
- }
150
-
151
- const actions: NativeArtworkPlacementAction[] = []
152
- // Applied placement or transmitted image → clear old placement first.
153
- if (state.placement !== null || state.transmitted !== 0) {
154
- actions.push({ type: "delete-placement", imageId })
155
- }
156
- actions.push({
157
- type: "place",
158
- imageId,
159
- placementId: imageId,
160
- x,
161
- y,
162
- width,
163
- height,
164
- })
165
- return {
166
- actions,
167
- nextState: { transmitted: imageId, placement: desired },
168
- }
169
- }
package/artwork.ts DELETED
@@ -1,424 +0,0 @@
1
- import { mkdtemp, rm } from "node:fs/promises"
2
- import { tmpdir } from "node:os"
3
- import { join } from "node:path"
4
- import { PNG } from "pngjs"
5
- import type { Artwork } from "./types.ts"
6
-
7
- const MAX_ARTWORK_BYTES = 3_000_000
8
- const MAX_CATALOG_RESPONSE_BYTES = 512_000
9
- const MAX_CONVERTED_PNG_BYTES = 1_000_000
10
- const MAX_IMAGE_DIMENSION = 4_096
11
- const MAX_IMAGE_PIXELS = 12_000_000
12
- const FETCH_TIMEOUT_MS = 4_000
13
- const CONVERSION_TIMEOUT_MS = 3_000
14
- // MediaRemote may expose whole seconds while catalogs retain milliseconds.
15
- const DURATION_TOLERANCE_MS = 1_000
16
- const MAX_CATALOG_DURATION_MS = 24 * 60 * 60 * 1_000
17
-
18
- type TrackIdentity = {
19
- title: string
20
- artist: string
21
- album: string
22
- duration_ms: number
23
- }
24
-
25
- export type CatalogTrack = {
26
- trackName?: string
27
- artistName?: string
28
- collectionName?: string
29
- trackTimeMillis?: number
30
- artworkUrl100?: string
31
- }
32
-
33
- type Fetcher = (
34
- input: string | URL | Request,
35
- init?: RequestInit,
36
- ) => Promise<Response>
37
-
38
- function normalized(value: string | undefined): string {
39
- return (value ?? "")
40
- .normalize("NFKC")
41
- .replace(/’/g, "'")
42
- .replace(/\s+/g, " ")
43
- .trim()
44
- .toLowerCase()
45
- }
46
-
47
- export function selectArtworkUrl(
48
- target: TrackIdentity,
49
- results: CatalogTrack[],
50
- ): string | null {
51
- return selectCatalogResolution(target, results).artworkUrl
52
- }
53
-
54
- function matchingCatalogTracks(
55
- target: TrackIdentity,
56
- results: CatalogTrack[],
57
- ): CatalogTrack[] {
58
- const title = normalized(target.title)
59
- const artist = normalized(target.artist)
60
- const album = normalized(target.album)
61
- if (!title || !artist) return []
62
-
63
- let matches = results.filter(
64
- (item) =>
65
- normalized(item.trackName) === title &&
66
- normalized(item.artistName) === artist,
67
- )
68
- if (album) {
69
- matches = matches.filter(
70
- (item) => normalized(item.collectionName) === album,
71
- )
72
- }
73
- return matches
74
- }
75
-
76
- function validCatalogDuration(value: number | undefined): value is number {
77
- return (
78
- typeof value === "number" &&
79
- Number.isFinite(value) &&
80
- value > 0 &&
81
- value <= MAX_CATALOG_DURATION_MS
82
- )
83
- }
84
-
85
- function selectCatalogCandidate(
86
- target: TrackIdentity,
87
- results: CatalogTrack[],
88
- ): CatalogTrack | null {
89
- let matches = matchingCatalogTracks(target, results)
90
- if (target.duration_ms > 0) {
91
- matches = matches.filter(
92
- (item) =>
93
- validCatalogDuration(item.trackTimeMillis) &&
94
- Math.abs(item.trackTimeMillis! - target.duration_ms) <=
95
- DURATION_TOLERANCE_MS,
96
- )
97
- } else if (matches.length !== 1) {
98
- return null
99
- }
100
-
101
- return matches[0] ?? null
102
- }
103
-
104
- export function selectCatalogTrack(
105
- target: TrackIdentity,
106
- results: CatalogTrack[],
107
- ): CatalogTrack | null {
108
- const match = selectCatalogCandidate(target, results)
109
- return validCatalogDuration(match?.trackTimeMillis) ? match : null
110
- }
111
-
112
- export function selectCatalogResolution(
113
- target: TrackIdentity,
114
- results: CatalogTrack[],
115
- ): { artworkUrl: string | null; duration_ms: number } {
116
- const match = selectCatalogCandidate(target, results)
117
- return {
118
- artworkUrl:
119
- match?.artworkUrl100?.replace(/100x100(?=[a-z]*\.)/, "300x300") ?? null,
120
- duration_ms: validCatalogDuration(match?.trackTimeMillis)
121
- ? match.trackTimeMillis
122
- : target.duration_ms,
123
- }
124
- }
125
-
126
- function allowedCatalogImageUrl(raw: string | URL): URL | null {
127
- try {
128
- const url = new URL(raw)
129
- if (url.protocol !== "https:" || !url.hostname.endsWith(".mzstatic.com")) {
130
- return null
131
- }
132
- return url
133
- } catch {
134
- return null
135
- }
136
- }
137
-
138
- export async function readLimitedResponse(
139
- response: Response,
140
- maxBytes: number,
141
- ): Promise<Uint8Array | null> {
142
- const declared = Number(response.headers.get("content-length"))
143
- if (Number.isFinite(declared) && declared > maxBytes) return null
144
- if (!response.body) return new Uint8Array()
145
-
146
- const bytes = new Uint8Array(maxBytes)
147
- const reader = response.body.getReader()
148
- let total = 0
149
- try {
150
- while (true) {
151
- const { done, value } = await reader.read()
152
- if (done) break
153
- if (total + value.byteLength > maxBytes) {
154
- await reader.cancel("artwork exceeds byte limit")
155
- return null
156
- }
157
- bytes.set(value, total)
158
- total += value.byteLength
159
- }
160
- } finally {
161
- reader.releaseLock()
162
- }
163
-
164
- return bytes.slice(0, total)
165
- }
166
-
167
- export async function downloadCatalogImage(
168
- rawUrl: string,
169
- fetcher: Fetcher = fetch,
170
- ): Promise<Uint8Array | null> {
171
- const url = allowedCatalogImageUrl(rawUrl)
172
- if (!url) return null
173
- try {
174
- const response = await fetcher(url, {
175
- redirect: "error",
176
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
177
- })
178
- if (!response.ok || response.redirected) return null
179
- if (response.url && !allowedCatalogImageUrl(response.url)) return null
180
- return readLimitedResponse(response, MAX_ARTWORK_BYTES)
181
- } catch {
182
- return null
183
- }
184
- }
185
-
186
- async function catalogArtwork(
187
- target: TrackIdentity,
188
- fetcher: Fetcher = fetch,
189
- ): Promise<{ bytes: Uint8Array | null; duration_ms: number } | null> {
190
- if (!target.title || !target.artist) return null
191
- const term = [target.artist, target.title, target.album]
192
- .filter(Boolean)
193
- .join(" ")
194
- const url = new URL("https://itunes.apple.com/search")
195
- url.searchParams.set("term", term)
196
- url.searchParams.set("entity", "song")
197
- url.searchParams.set("limit", "10")
198
-
199
- try {
200
- const result = await fetcher(url, {
201
- redirect: "error",
202
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
203
- })
204
- if (!result.ok || result.redirected) return null
205
- const responseBytes = await readLimitedResponse(
206
- result,
207
- MAX_CATALOG_RESPONSE_BYTES,
208
- )
209
- if (!responseBytes) return null
210
- const payload = JSON.parse(new TextDecoder().decode(responseBytes)) as {
211
- results?: CatalogTrack[]
212
- }
213
- const results = payload.results ?? []
214
- const resolution = selectCatalogResolution(target, results)
215
- const bytes = resolution.artworkUrl
216
- ? await downloadCatalogImage(resolution.artworkUrl, fetcher)
217
- : null
218
- if (!resolution.artworkUrl && resolution.duration_ms === target.duration_ms)
219
- return null
220
- return { bytes, duration_ms: resolution.duration_ms }
221
- } catch {
222
- return null
223
- }
224
- }
225
-
226
- export function imageDimensionsAreSafe(width: number, height: number): boolean {
227
- return (
228
- Number.isInteger(width) &&
229
- Number.isInteger(height) &&
230
- width > 0 &&
231
- height > 0 &&
232
- width <= MAX_IMAGE_DIMENSION &&
233
- height <= MAX_IMAGE_DIMENSION &&
234
- width * height <= MAX_IMAGE_PIXELS
235
- )
236
- }
237
-
238
- export async function runCommandWithTimeout(
239
- command: string[],
240
- timeoutMs: number,
241
- ): Promise<{ code: number; out: string; timed_out: boolean }> {
242
- const proc = Bun.spawn(command, { stdout: "pipe", stderr: "ignore" })
243
- let timedOut = false
244
- const timer = setTimeout(() => {
245
- timedOut = true
246
- proc.kill(9)
247
- }, timeoutMs)
248
- try {
249
- const [out, code] = await Promise.all([
250
- new Response(proc.stdout).text(),
251
- proc.exited,
252
- ])
253
- return { code, out, timed_out: timedOut }
254
- } finally {
255
- clearTimeout(timer)
256
- }
257
- }
258
-
259
- function dimensionsFromSips(
260
- output: string,
261
- ): { width: number; height: number } | null {
262
- const width = Number(output.match(/pixelWidth:\s*(\d+)/)?.[1])
263
- const height = Number(output.match(/pixelHeight:\s*(\d+)/)?.[1])
264
- return imageDimensionsAreSafe(width, height) ? { width, height } : null
265
- }
266
-
267
- async function squarePng(
268
- bytes: Uint8Array,
269
- size: number,
270
- ): Promise<Uint8Array | null> {
271
- if (bytes.byteLength === 0 || bytes.byteLength > MAX_ARTWORK_BYTES)
272
- return null
273
- const dir = await mkdtemp(join(tmpdir(), "opencode-music-art-"))
274
- const input = join(dir, "input")
275
- const output = join(dir, "art.png")
276
- try {
277
- await Bun.write(input, bytes)
278
- const inspected = await runCommandWithTimeout(
279
- ["sips", "-g", "pixelWidth", "-g", "pixelHeight", input],
280
- CONVERSION_TIMEOUT_MS,
281
- )
282
- if (
283
- inspected.timed_out ||
284
- inspected.code !== 0 ||
285
- !dimensionsFromSips(inspected.out)
286
- ) {
287
- return null
288
- }
289
-
290
- const converted = await runCommandWithTimeout(
291
- [
292
- "sips",
293
- "-z",
294
- String(size),
295
- String(size),
296
- "-s",
297
- "format",
298
- "png",
299
- input,
300
- "--out",
301
- output,
302
- ],
303
- CONVERSION_TIMEOUT_MS,
304
- )
305
- if (converted.timed_out || converted.code !== 0) return null
306
- const file = Bun.file(output)
307
- if (file.size === 0 || file.size > MAX_CONVERTED_PNG_BYTES) return null
308
- return new Uint8Array(await file.arrayBuffer())
309
- } finally {
310
- await rm(dir, { recursive: true, force: true })
311
- }
312
- }
313
-
314
- const hex = (r: number, g: number, b: number) =>
315
- `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`
316
-
317
- function presentation(pngBytes: Uint8Array): Pick<Artwork, "cells" | "accent"> {
318
- const image = PNG.sync.read(Buffer.from(pngBytes))
319
- const cells: Artwork["cells"] = []
320
- let accent = "#7aa2f7"
321
- let accentScore = -1
322
-
323
- for (let y = 0; y < image.height; y += 2) {
324
- const row: Artwork["cells"][number] = []
325
- for (let x = 0; x < image.width; x++) {
326
- const colors = [y, Math.min(y + 1, image.height - 1)].map((py) => {
327
- const offset = (py * image.width + x) * 4
328
- const r = image.data[offset] ?? 0
329
- const g = image.data[offset + 1] ?? 0
330
- const b = image.data[offset + 2] ?? 0
331
- const max = Math.max(r, g, b) / 255
332
- const min = Math.min(r, g, b) / 255
333
- const light = (max + min) / 2
334
- const score = (max - min) * (1 - Math.abs(light - 0.5) * 2)
335
- if (score > accentScore) {
336
- accentScore = score
337
- accent = hex(r, g, b)
338
- }
339
- return hex(r, g, b)
340
- })
341
- row.push({ upper: colors[0]!, lower: colors[1]! })
342
- }
343
- cells.push(row)
344
- }
345
- return { cells, accent }
346
- }
347
-
348
- export type ArtworkResolution = {
349
- artwork: Artwork | null
350
- duration_ms: number
351
- }
352
-
353
- export async function resolveArtworkDetails(
354
- id: string,
355
- target: TrackIdentity,
356
- nativeBase64: string | null,
357
- legacyId = id,
358
- fetcher: Fetcher = fetch,
359
- ): Promise<ArtworkResolution> {
360
- const candidates: Uint8Array[] = []
361
- if (nativeBase64) {
362
- try {
363
- const maxBase64Length = Math.ceil(MAX_ARTWORK_BYTES / 3) * 4
364
- if (nativeBase64.length <= maxBase64Length) {
365
- const bytes = new Uint8Array(Buffer.from(nativeBase64, "base64"))
366
- if (bytes.byteLength <= MAX_ARTWORK_BYTES) candidates.push(bytes)
367
- }
368
- } catch {
369
- // Invalid system metadata falls through to the catalog lookup.
370
- }
371
- }
372
-
373
- for (const bytes of candidates) {
374
- try {
375
- const nativePng = await squarePng(bytes, 300)
376
- const thumbnail = nativePng ? await squarePng(nativePng, 24) : null
377
- if (nativePng && thumbnail) {
378
- return {
379
- artwork: {
380
- id,
381
- legacy_id: legacyId,
382
- png_base64: Buffer.from(nativePng).toString("base64"),
383
- ...presentation(thumbnail),
384
- },
385
- duration_ms: target.duration_ms,
386
- }
387
- }
388
- } catch {
389
- // Try the catalog when an app publishes unsupported artwork data.
390
- }
391
- }
392
-
393
- const catalog = await catalogArtwork(target, fetcher)
394
- if (!catalog) return { artwork: null, duration_ms: target.duration_ms }
395
- if (!catalog.bytes) {
396
- return { artwork: null, duration_ms: catalog.duration_ms }
397
- }
398
- try {
399
- const nativePng = await squarePng(catalog.bytes, 300)
400
- const thumbnail = nativePng ? await squarePng(nativePng, 24) : null
401
- if (!nativePng || !thumbnail) {
402
- return { artwork: null, duration_ms: catalog.duration_ms }
403
- }
404
- return {
405
- artwork: {
406
- id,
407
- legacy_id: legacyId,
408
- png_base64: Buffer.from(nativePng).toString("base64"),
409
- ...presentation(thumbnail),
410
- },
411
- duration_ms: catalog.duration_ms,
412
- }
413
- } catch {
414
- return { artwork: null, duration_ms: catalog.duration_ms }
415
- }
416
- }
417
-
418
- export async function resolveArtwork(
419
- id: string,
420
- target: TrackIdentity,
421
- nativeBase64: string | null,
422
- ): Promise<Artwork | null> {
423
- return (await resolveArtworkDetails(id, target, nativeBase64)).artwork
424
- }