@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/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/SUPPORT.md +7 -0
- package/artwork-placement.ts +169 -0
- package/artwork.ts +424 -0
- package/artwork.tsx +221 -0
- package/index.tsx +428 -0
- package/kitty-graphics.ts +90 -0
- package/package.json +80 -0
- package/system-media.ts +568 -0
- package/tmux-offset.ts +120 -0
- package/types.ts +115 -0
- package/ui.tsx +627 -0
- package/waveform.tsx +205 -0
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
|
+
}
|