@naxodev/opencode-music-player 0.2.6 → 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.6",
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",
@@ -69,17 +61,33 @@
69
61
  },
70
62
  "dependencies": {
71
63
  "@naxodev/music-core": "^0.1.4",
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"
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,240 +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
- import {
7
- acquireCatalogArtwork,
8
- MAX_ARTWORK_BYTES,
9
- selectCatalogResolution,
10
- type CatalogTarget as TrackIdentity,
11
- type ArtworkFetcher as Fetcher,
12
- } from "@naxodev/music-core"
13
- export {
14
- selectCatalogResolution,
15
- selectCatalogTrack,
16
- readLimitedResponse,
17
- downloadCatalogImage,
18
- type CatalogTrack,
19
- } from "@naxodev/music-core"
20
-
21
- const MAX_CONVERTED_PNG_BYTES = 1_000_000
22
- const MAX_IMAGE_DIMENSION = 4_096
23
- const MAX_IMAGE_PIXELS = 12_000_000
24
- const CONVERSION_TIMEOUT_MS = 3_000
25
-
26
- export function selectArtworkUrl(
27
- target: TrackIdentity,
28
- results: import("@naxodev/music-core").CatalogTrack[],
29
- ): string | null {
30
- return selectCatalogResolution(target, results).artworkUrl
31
- }
32
-
33
- export function imageDimensionsAreSafe(width: number, height: number): boolean {
34
- return (
35
- Number.isInteger(width) &&
36
- Number.isInteger(height) &&
37
- width > 0 &&
38
- height > 0 &&
39
- width <= MAX_IMAGE_DIMENSION &&
40
- height <= MAX_IMAGE_DIMENSION &&
41
- width * height <= MAX_IMAGE_PIXELS
42
- )
43
- }
44
-
45
- export async function runCommandWithTimeout(
46
- command: string[],
47
- timeoutMs: number,
48
- ): Promise<{ code: number; out: string; timed_out: boolean }> {
49
- const proc = Bun.spawn(command, { stdout: "pipe", stderr: "ignore" })
50
- let timedOut = false
51
- const timer = setTimeout(() => {
52
- timedOut = true
53
- proc.kill(9)
54
- }, timeoutMs)
55
- try {
56
- const [out, code] = await Promise.all([
57
- new Response(proc.stdout).text(),
58
- proc.exited,
59
- ])
60
- return { code, out, timed_out: timedOut }
61
- } finally {
62
- clearTimeout(timer)
63
- }
64
- }
65
-
66
- function dimensionsFromSips(
67
- output: string,
68
- ): { width: number; height: number } | null {
69
- const width = Number(output.match(/pixelWidth:\s*(\d+)/)?.[1])
70
- const height = Number(output.match(/pixelHeight:\s*(\d+)/)?.[1])
71
- return imageDimensionsAreSafe(width, height) ? { width, height } : null
72
- }
73
-
74
- async function squarePng(
75
- bytes: Uint8Array,
76
- size: number,
77
- ): Promise<Uint8Array | null> {
78
- if (bytes.byteLength === 0 || bytes.byteLength > MAX_ARTWORK_BYTES)
79
- return null
80
- const dir = await mkdtemp(join(tmpdir(), "opencode-music-art-"))
81
- const input = join(dir, "input")
82
- const output = join(dir, "art.png")
83
- try {
84
- await Bun.write(input, bytes)
85
- const inspected = await runCommandWithTimeout(
86
- ["sips", "-g", "pixelWidth", "-g", "pixelHeight", input],
87
- CONVERSION_TIMEOUT_MS,
88
- )
89
- if (
90
- inspected.timed_out ||
91
- inspected.code !== 0 ||
92
- !dimensionsFromSips(inspected.out)
93
- ) {
94
- return null
95
- }
96
-
97
- const converted = await runCommandWithTimeout(
98
- [
99
- "sips",
100
- "-z",
101
- String(size),
102
- String(size),
103
- "-s",
104
- "format",
105
- "png",
106
- input,
107
- "--out",
108
- output,
109
- ],
110
- CONVERSION_TIMEOUT_MS,
111
- )
112
- if (converted.timed_out || converted.code !== 0) return null
113
- const file = Bun.file(output)
114
- if (file.size === 0 || file.size > MAX_CONVERTED_PNG_BYTES) return null
115
- return new Uint8Array(await file.arrayBuffer())
116
- } finally {
117
- await rm(dir, { recursive: true, force: true })
118
- }
119
- }
120
-
121
- const hex = (r: number, g: number, b: number) =>
122
- `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`
123
-
124
- function presentation(pngBytes: Uint8Array): Pick<Artwork, "cells" | "accent"> {
125
- const image = PNG.sync.read(Buffer.from(pngBytes))
126
- const cells: Artwork["cells"] = []
127
- let accent = "#7aa2f7"
128
- let accentScore = -1
129
-
130
- for (let y = 0; y < image.height; y += 2) {
131
- const row: Artwork["cells"][number] = []
132
- for (let x = 0; x < image.width; x++) {
133
- const colors = [y, Math.min(y + 1, image.height - 1)].map((py) => {
134
- const offset = (py * image.width + x) * 4
135
- const r = image.data[offset] ?? 0
136
- const g = image.data[offset + 1] ?? 0
137
- const b = image.data[offset + 2] ?? 0
138
- const max = Math.max(r, g, b) / 255
139
- const min = Math.min(r, g, b) / 255
140
- const light = (max + min) / 2
141
- const score = (max - min) * (1 - Math.abs(light - 0.5) * 2)
142
- if (score > accentScore) {
143
- accentScore = score
144
- accent = hex(r, g, b)
145
- }
146
- return hex(r, g, b)
147
- })
148
- row.push({ upper: colors[0]!, lower: colors[1]! })
149
- }
150
- cells.push(row)
151
- }
152
- return { cells, accent }
153
- }
154
-
155
- export type ArtworkResolution = {
156
- artwork: Artwork | null
157
- duration_ms: number
158
- }
159
-
160
- export async function resolveArtworkDetails(
161
- id: string,
162
- target: TrackIdentity,
163
- nativeBase64: string | null,
164
- legacyId = id,
165
- fetcher: Fetcher = fetch,
166
- signal?: AbortSignal,
167
- ): Promise<ArtworkResolution> {
168
- if (signal?.aborted) return { artwork: null, duration_ms: target.duration_ms }
169
- const candidates: Uint8Array[] = []
170
- if (nativeBase64) {
171
- try {
172
- const maxBase64Length = Math.ceil(MAX_ARTWORK_BYTES / 3) * 4
173
- if (nativeBase64.length <= maxBase64Length) {
174
- const bytes = new Uint8Array(Buffer.from(nativeBase64, "base64"))
175
- if (bytes.byteLength <= MAX_ARTWORK_BYTES) candidates.push(bytes)
176
- }
177
- } catch {
178
- // Invalid system metadata falls through to the catalog lookup.
179
- }
180
- }
181
-
182
- for (const bytes of candidates) {
183
- try {
184
- const nativePng = await squarePng(bytes, 300)
185
- const thumbnail = nativePng ? await squarePng(nativePng, 24) : null
186
- if (nativePng && thumbnail) {
187
- return {
188
- artwork: {
189
- id,
190
- legacy_id: legacyId,
191
- png_base64: Buffer.from(nativePng).toString("base64"),
192
- ...presentation(thumbnail),
193
- },
194
- duration_ms: target.duration_ms,
195
- }
196
- }
197
- } catch {
198
- // Try the catalog when an app publishes unsupported artwork data.
199
- }
200
- }
201
-
202
- const catalog = await acquireCatalogArtwork(target, {
203
- fetch: fetcher,
204
- signal,
205
- })
206
- if (catalog.kind !== "available" || signal?.aborted)
207
- return {
208
- artwork: null,
209
- duration_ms: catalog.duration_ms ?? target.duration_ms,
210
- }
211
- if (!catalog.bytes) {
212
- return { artwork: null, duration_ms: catalog.duration_ms }
213
- }
214
- try {
215
- const nativePng = await squarePng(catalog.bytes, 300)
216
- const thumbnail = nativePng ? await squarePng(nativePng, 24) : null
217
- if (!nativePng || !thumbnail) {
218
- return { artwork: null, duration_ms: catalog.duration_ms }
219
- }
220
- return {
221
- artwork: {
222
- id,
223
- legacy_id: legacyId,
224
- png_base64: Buffer.from(nativePng).toString("base64"),
225
- ...presentation(thumbnail),
226
- },
227
- duration_ms: catalog.duration_ms,
228
- }
229
- } catch {
230
- return { artwork: null, duration_ms: catalog.duration_ms }
231
- }
232
- }
233
-
234
- export async function resolveArtwork(
235
- id: string,
236
- target: TrackIdentity,
237
- nativeBase64: string | null,
238
- ): Promise<Artwork | null> {
239
- return (await resolveArtworkDetails(id, target, nativeBase64)).artwork
240
- }