@naxodev/opencode-music-player 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.
package/artwork.ts ADDED
@@ -0,0 +1,424 @@
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
+ }
package/artwork.tsx ADDED
@@ -0,0 +1,221 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import type { BoxRenderable } from "@opentui/core"
3
+ import { For, onCleanup, onMount } from "solid-js"
4
+ import type { Plugin } from "@opencode-ai/plugin/tui"
5
+ import type { Artwork } from "./types.ts"
6
+ import {
7
+ planNativeArtworkPlacement,
8
+ type NativeArtworkPlacementAction,
9
+ type NativeArtworkPlacementPlan,
10
+ type NativeArtworkState,
11
+ } from "./artwork-placement.ts"
12
+ import {
13
+ kittyDelete,
14
+ kittyDeletePlacement,
15
+ kittyDisplayPng,
16
+ kittyImageId,
17
+ kittyPlace,
18
+ writeGraphics,
19
+ } from "./kitty-graphics.ts"
20
+ import {
21
+ createTmuxOffsetCache,
22
+ resolveTerminalOffset,
23
+ type SlotGeometry,
24
+ } from "./tmux-offset.ts"
25
+
26
+ type Context = Plugin.Context
27
+ const tmuxOffsetCache = createTmuxOffsetCache()
28
+ const nativeArtworkImageId = kittyImageId("opencode-music-player:artwork")
29
+ let latestNativeArtworkOwner = 0
30
+
31
+ export type NativeArtworkOwnership = { isCurrent: () => boolean }
32
+
33
+ export function claimNativeArtworkOwnership(): NativeArtworkOwnership {
34
+ const owner = ++latestNativeArtworkOwner
35
+ return { isCurrent: () => owner === latestNativeArtworkOwner }
36
+ }
37
+
38
+ export function cleanupNativeArtwork(
39
+ ownership: NativeArtworkOwnership,
40
+ remove: () => void,
41
+ ): void {
42
+ if (ownership.isCurrent()) remove()
43
+ }
44
+
45
+ export function imageIdForArtwork(_artworkId: string): number {
46
+ return nativeArtworkImageId
47
+ }
48
+
49
+ export function legacyImageIdForArtwork(artworkId: string): number {
50
+ return kittyImageId(artworkId)
51
+ }
52
+
53
+ export function legacyImageIdForResolvedArtwork(
54
+ artwork: Pick<Artwork, "id" | "legacy_id">,
55
+ ): number {
56
+ return legacyImageIdForArtwork(artwork.legacy_id ?? artwork.id)
57
+ }
58
+
59
+ function terminalOffset(slot: SlotGeometry | null) {
60
+ return resolveTerminalOffset({
61
+ slot,
62
+ cache: tmuxOffsetCache,
63
+ })
64
+ }
65
+
66
+ function supportsKittyGraphics(context: Context): boolean {
67
+ return (
68
+ !!context.renderer.capabilities?.kitty_graphics ||
69
+ process.env.TERM_PROGRAM?.toLowerCase() === "ghostty"
70
+ )
71
+ }
72
+
73
+ function copyState(next: NativeArtworkState): NativeArtworkState {
74
+ return {
75
+ transmitted: next.transmitted,
76
+ placement: next.placement ? { ...next.placement } : null,
77
+ }
78
+ }
79
+
80
+ export function AlbumArtwork(props: { context: Context; artwork: Artwork }) {
81
+ const ownership = claimNativeArtworkOwnership()
82
+ let container: BoxRenderable | undefined
83
+ let state: NativeArtworkState = { transmitted: 0, placement: null }
84
+ let artworkIdentity = ""
85
+ let paintPending = false
86
+ let disposed = false
87
+
88
+ const applyAction = (action: NativeArtworkPlacementAction): boolean => {
89
+ const renderer = props.context.renderer
90
+ switch (action.type) {
91
+ case "delete-image":
92
+ return writeGraphics(renderer, kittyDelete(action.imageId))
93
+ case "delete-placement":
94
+ return writeGraphics(renderer, kittyDeletePlacement(action.imageId))
95
+ case "transmit-and-display": {
96
+ const commands = kittyDisplayPng(
97
+ props.artwork.png_base64,
98
+ action.imageId,
99
+ action.x,
100
+ action.y,
101
+ action.width,
102
+ action.height,
103
+ )
104
+ return commands.every((command) => writeGraphics(renderer, command))
105
+ }
106
+ case "place":
107
+ return writeGraphics(
108
+ renderer,
109
+ kittyPlace(
110
+ action.imageId,
111
+ action.placementId,
112
+ action.x,
113
+ action.y,
114
+ action.width,
115
+ action.height,
116
+ ),
117
+ )
118
+ }
119
+ }
120
+
121
+ // Partial success must not commit: stop on first failed write and keep prior state.
122
+ const applyPlan = (plan: NativeArtworkPlacementPlan): boolean => {
123
+ for (const action of plan.actions) {
124
+ if (!applyAction(action)) return false
125
+ }
126
+ return true
127
+ }
128
+
129
+ const commitPlan = (plan: NativeArtworkPlacementPlan) => {
130
+ state = copyState(plan.nextState)
131
+ }
132
+
133
+ const paintNativeImage = () => {
134
+ if (!ownership.isCurrent()) return
135
+ if (artworkIdentity !== props.artwork.id) {
136
+ artworkIdentity = props.artwork.id
137
+ writeGraphics(
138
+ props.context.renderer,
139
+ kittyDelete(legacyImageIdForResolvedArtwork(props.artwork)),
140
+ )
141
+ state = { transmitted: 0, placement: null }
142
+ }
143
+ const kittySupported = supportsKittyGraphics(props.context)
144
+ const slotValid =
145
+ !!container &&
146
+ !container.isDestroyed &&
147
+ container.width >= 1 &&
148
+ container.height >= 1
149
+
150
+ const slot: SlotGeometry | null = container
151
+ ? {
152
+ screenX: container.screenX,
153
+ screenY: container.screenY,
154
+ width: container.width,
155
+ height: container.height,
156
+ }
157
+ : null
158
+ const offset = terminalOffset(slot)
159
+ const imageId = imageIdForArtwork(props.artwork.id)
160
+ const x = slot ? slot.screenX + offset.x : 0
161
+ const y = slot ? slot.screenY + offset.y : 0
162
+ const width = slot?.width ?? 0
163
+ const height = slot?.height ?? 0
164
+
165
+ const plan = planNativeArtworkPlacement({
166
+ state,
167
+ imageId,
168
+ x,
169
+ y,
170
+ width,
171
+ height,
172
+ kittySupported,
173
+ slotValid,
174
+ disposed,
175
+ })
176
+
177
+ if (plan.actions.length === 0) return
178
+ if (applyPlan(plan)) commitPlan(plan)
179
+ }
180
+
181
+ const scheduleNativeImage = () => {
182
+ if (paintPending || disposed) return
183
+ paintPending = true
184
+ void props.context.renderer.idle().then(() => {
185
+ paintPending = false
186
+ if (!disposed && ownership.isCurrent()) paintNativeImage()
187
+ })
188
+ }
189
+
190
+ onMount(() => props.context.renderer.on("frame", scheduleNativeImage))
191
+ onCleanup(() => {
192
+ disposed = true
193
+ props.context.renderer.off("frame", scheduleNativeImage)
194
+ // Clear even if this owner unmounts before its first native paint.
195
+ cleanupNativeArtwork(ownership, () => {
196
+ writeGraphics(props.context.renderer, kittyDelete(nativeArtworkImageId))
197
+ })
198
+ })
199
+
200
+ return (
201
+ <box
202
+ ref={(value) => (container = value)}
203
+ width={24}
204
+ height={12}
205
+ flexDirection="column"
206
+ overflow="hidden"
207
+ >
208
+ <For each={props.artwork.cells}>
209
+ {(row) => (
210
+ <text>
211
+ <For each={row}>
212
+ {(cell) => (
213
+ <span style={{ fg: cell.upper, bg: cell.lower }}>▀</span>
214
+ )}
215
+ </For>
216
+ </text>
217
+ )}
218
+ </For>
219
+ </box>
220
+ )
221
+ }