@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/README.md +13 -14
- package/SUPPORT.md +1 -1
- package/dist/index.js +2272 -0
- package/dist/tui.js +1 -0
- package/package.json +28 -20
- package/artwork-placement.ts +0 -169
- package/artwork.ts +0 -240
- package/artwork.tsx +0 -246
- package/index.tsx +0 -493
- package/kitty-graphics.ts +0 -105
- package/system-media.ts +0 -593
- package/tmux-offset.ts +0 -120
- package/tui.tsx +0 -1
- package/types.ts +0 -144
- package/ui.tsx +0 -695
- package/waveform.tsx +0 -205
package/index.tsx
DELETED
|
@@ -1,493 +0,0 @@
|
|
|
1
|
-
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import { Plugin } from "@opencode-ai/plugin/tui"
|
|
3
|
-
import { createSessionSystemMedia, openNowPlayingApp } from "./system-media.ts"
|
|
4
|
-
import {
|
|
5
|
-
isMac,
|
|
6
|
-
mergeArtworkCompletion,
|
|
7
|
-
mergePlayerSnapshot,
|
|
8
|
-
type SessionMedia,
|
|
9
|
-
} from "./types.ts"
|
|
10
|
-
import {
|
|
11
|
-
CompactPlayer,
|
|
12
|
-
sanitizeTerminalText,
|
|
13
|
-
SidebarPlayer,
|
|
14
|
-
type UiState,
|
|
15
|
-
} from "./ui.tsx"
|
|
16
|
-
|
|
17
|
-
type Context = Plugin.Context
|
|
18
|
-
type SessionStore = UiState & { loadingOwners?: string[] }
|
|
19
|
-
type SeekIntent = {
|
|
20
|
-
positionMs: number
|
|
21
|
-
resolves: Array<() => void>
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export type Controller = {
|
|
25
|
-
session: SessionStore
|
|
26
|
-
subscribe: (listener: (session: SessionStore) => void) => () => void
|
|
27
|
-
openApp: () => Promise<void>
|
|
28
|
-
refreshAll: () => Promise<void>
|
|
29
|
-
refreshArtwork: () => Promise<void>
|
|
30
|
-
playPause: () => Promise<void>
|
|
31
|
-
seek: (positionMs: number) => Promise<void>
|
|
32
|
-
next: () => Promise<void>
|
|
33
|
-
prev: () => Promise<void>
|
|
34
|
-
dispose: () => void
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
export type ControllerDependencies = {
|
|
38
|
-
createSessionMedia: () => SessionMedia
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* Seeking to the exact duration finishes the track immediately, so a click
|
|
43
|
-
* near the end of a seek bar would skip songs instead of seeking. Clamp to
|
|
44
|
-
* just short of the end; skipping is what the next button is for.
|
|
45
|
-
*/
|
|
46
|
-
export const SEEK_END_MARGIN_MS = 1_000
|
|
47
|
-
|
|
48
|
-
function seekTarget(positionMs: number, durationMs?: number): number | null {
|
|
49
|
-
if (
|
|
50
|
-
!Number.isFinite(positionMs) ||
|
|
51
|
-
typeof durationMs !== "number" ||
|
|
52
|
-
!Number.isFinite(durationMs) ||
|
|
53
|
-
durationMs <= 0
|
|
54
|
-
)
|
|
55
|
-
return null
|
|
56
|
-
const ceiling = Math.max(0, durationMs - SEEK_END_MARGIN_MS)
|
|
57
|
-
return Math.max(0, Math.min(ceiling, Math.round(positionMs)))
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const controllerDependencies: ControllerDependencies = {
|
|
61
|
-
createSessionMedia: createSessionSystemMedia,
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function errMsg(error: unknown): string {
|
|
65
|
-
return error && typeof error === "object" && "message" in error
|
|
66
|
-
? String((error as { message: unknown }).message)
|
|
67
|
-
: String(error)
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export function createController(
|
|
71
|
-
context: Context,
|
|
72
|
-
dependencies: ControllerDependencies = controllerDependencies,
|
|
73
|
-
): Controller {
|
|
74
|
-
const [session, setSessionStore] = context.storage.memory<SessionStore>(
|
|
75
|
-
"music-player.session.v6",
|
|
76
|
-
{
|
|
77
|
-
initial: {
|
|
78
|
-
loading: false,
|
|
79
|
-
loadingOwners: [],
|
|
80
|
-
error: null,
|
|
81
|
-
player: null,
|
|
82
|
-
},
|
|
83
|
-
},
|
|
84
|
-
)
|
|
85
|
-
const sessionListeners = new Set<(session: SessionStore) => void>()
|
|
86
|
-
const setSession = (update: (draft: SessionStore) => void) => {
|
|
87
|
-
setSessionStore(update)
|
|
88
|
-
for (const listener of [...sessionListeners]) {
|
|
89
|
-
try {
|
|
90
|
-
listener(session)
|
|
91
|
-
} catch {
|
|
92
|
-
// One mounted presentation cannot block controller state.
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
const loadingOwner = crypto.randomUUID()
|
|
97
|
-
const media = dependencies.createSessionMedia()
|
|
98
|
-
let disposed = false
|
|
99
|
-
let lifecycleGeneration = 0
|
|
100
|
-
let eventDisposer: (() => void) | null = null
|
|
101
|
-
let presentationDisposer: (() => void) | null = null
|
|
102
|
-
let lifecycleError: {
|
|
103
|
-
message: string | null
|
|
104
|
-
source: "connection" | "provider" | "acquisition" | undefined
|
|
105
|
-
} = { message: null, source: undefined }
|
|
106
|
-
let transportError: string | null = null
|
|
107
|
-
let receivedSnapshot = false
|
|
108
|
-
let snapshotEpoch = 0
|
|
109
|
-
let playbackIntent:
|
|
110
|
-
| {
|
|
111
|
-
readonly target: boolean
|
|
112
|
-
readonly startedAtEpoch: number
|
|
113
|
-
settled: boolean
|
|
114
|
-
}
|
|
115
|
-
| undefined
|
|
116
|
-
let activeSeek: SeekIntent | null = null
|
|
117
|
-
let latestSeek: SeekIntent | null = null
|
|
118
|
-
const pending = new Set<() => void>()
|
|
119
|
-
const isActive = () => !disposed
|
|
120
|
-
|
|
121
|
-
const publishError = () => {
|
|
122
|
-
if (!isActive()) return
|
|
123
|
-
const message =
|
|
124
|
-
lifecycleError.source === "connection" && lifecycleError.message
|
|
125
|
-
? lifecycleError.message
|
|
126
|
-
: (transportError ?? lifecycleError.message)
|
|
127
|
-
setSession((draft) => {
|
|
128
|
-
draft.error = message
|
|
129
|
-
})
|
|
130
|
-
}
|
|
131
|
-
const setLifecycleError = (
|
|
132
|
-
message: string | null,
|
|
133
|
-
source: "connection" | "provider" | "acquisition" | undefined,
|
|
134
|
-
) => {
|
|
135
|
-
lifecycleError = { message, source }
|
|
136
|
-
publishError()
|
|
137
|
-
}
|
|
138
|
-
const setTransportError = (message: string | null) => {
|
|
139
|
-
transportError = message
|
|
140
|
-
publishError()
|
|
141
|
-
}
|
|
142
|
-
const setLoadingOwner = (active: boolean) => {
|
|
143
|
-
setSession((draft) => {
|
|
144
|
-
const owners = new Set(draft.loadingOwners ?? [])
|
|
145
|
-
if (active) owners.add(loadingOwner)
|
|
146
|
-
else owners.delete(loadingOwner)
|
|
147
|
-
draft.loadingOwners = [...owners]
|
|
148
|
-
draft.loading = owners.size > 0
|
|
149
|
-
})
|
|
150
|
-
}
|
|
151
|
-
const updateLoading = () => {
|
|
152
|
-
if (isActive()) setLoadingOwner(pending.size > 0)
|
|
153
|
-
}
|
|
154
|
-
const settle = (resolve: () => void) => {
|
|
155
|
-
if (!pending.delete(resolve)) return
|
|
156
|
-
resolve()
|
|
157
|
-
updateLoading()
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
const runCommand = (
|
|
161
|
-
command: () => Promise<unknown>,
|
|
162
|
-
onSettled?: (succeeded: boolean) => void,
|
|
163
|
-
) => {
|
|
164
|
-
if (!isActive()) return Promise.resolve()
|
|
165
|
-
const generation = lifecycleGeneration
|
|
166
|
-
return new Promise<void>((resolve) => {
|
|
167
|
-
pending.add(resolve)
|
|
168
|
-
updateLoading()
|
|
169
|
-
let result: Promise<unknown> | undefined
|
|
170
|
-
try {
|
|
171
|
-
result = command()
|
|
172
|
-
} catch (error) {
|
|
173
|
-
result = Promise.reject(error)
|
|
174
|
-
}
|
|
175
|
-
let succeeded = false
|
|
176
|
-
void Promise.resolve(result)
|
|
177
|
-
.then(
|
|
178
|
-
() => {
|
|
179
|
-
succeeded = true
|
|
180
|
-
if (!isActive() || generation !== lifecycleGeneration) return
|
|
181
|
-
if (transportError !== null) setTransportError(null)
|
|
182
|
-
},
|
|
183
|
-
(error) => {
|
|
184
|
-
if (!isActive() || generation !== lifecycleGeneration) return
|
|
185
|
-
const message = sanitizeTerminalText(errMsg(error))
|
|
186
|
-
setTransportError(message)
|
|
187
|
-
context.ui.toast.show({ title: "Music", message, variant: "error" })
|
|
188
|
-
},
|
|
189
|
-
)
|
|
190
|
-
.finally(() => {
|
|
191
|
-
try {
|
|
192
|
-
onSettled?.(succeeded)
|
|
193
|
-
} finally {
|
|
194
|
-
settle(resolve)
|
|
195
|
-
}
|
|
196
|
-
})
|
|
197
|
-
})
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const runSeek = (intent: SeekIntent) => {
|
|
201
|
-
activeSeek = intent
|
|
202
|
-
void runCommand(
|
|
203
|
-
() => media.seek(intent.positionMs),
|
|
204
|
-
() => {
|
|
205
|
-
if (activeSeek !== intent) return
|
|
206
|
-
activeSeek = null
|
|
207
|
-
for (const resolve of intent.resolves) resolve()
|
|
208
|
-
intent.resolves = []
|
|
209
|
-
const next = latestSeek
|
|
210
|
-
latestSeek = null
|
|
211
|
-
if (isActive() && next) runSeek(next)
|
|
212
|
-
else next?.resolves.splice(0).forEach((resolve) => resolve())
|
|
213
|
-
},
|
|
214
|
-
)
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const refreshAll = async () => {
|
|
218
|
-
if (!isActive()) return
|
|
219
|
-
const generation = lifecycleGeneration
|
|
220
|
-
try {
|
|
221
|
-
const player = await media.player()
|
|
222
|
-
if (!isActive() || generation !== lifecycleGeneration || receivedSnapshot)
|
|
223
|
-
return
|
|
224
|
-
setSession((draft) => {
|
|
225
|
-
draft.player = player
|
|
226
|
-
})
|
|
227
|
-
} catch (error) {
|
|
228
|
-
if (isActive() && generation === lifecycleGeneration)
|
|
229
|
-
setTransportError(errMsg(error))
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
const playPause = () => {
|
|
234
|
-
const nextPlaying = !(
|
|
235
|
-
playbackIntent?.target ?? !!session.player?.is_playing
|
|
236
|
-
)
|
|
237
|
-
const intent = {
|
|
238
|
-
target: nextPlaying,
|
|
239
|
-
startedAtEpoch: snapshotEpoch,
|
|
240
|
-
settled: false,
|
|
241
|
-
}
|
|
242
|
-
playbackIntent = intent
|
|
243
|
-
return runCommand(
|
|
244
|
-
() => (nextPlaying ? media.play() : media.pause()),
|
|
245
|
-
(succeeded) => {
|
|
246
|
-
if (playbackIntent !== intent) return
|
|
247
|
-
if (!succeeded) {
|
|
248
|
-
playbackIntent = undefined
|
|
249
|
-
return
|
|
250
|
-
}
|
|
251
|
-
intent.settled = true
|
|
252
|
-
if (
|
|
253
|
-
snapshotEpoch > intent.startedAtEpoch &&
|
|
254
|
-
session.player?.is_playing === intent.target
|
|
255
|
-
)
|
|
256
|
-
playbackIntent = undefined
|
|
257
|
-
},
|
|
258
|
-
)
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
const seek = (positionMs: number) => {
|
|
262
|
-
const target = seekTarget(positionMs, session.player?.track?.duration_ms)
|
|
263
|
-
if (!session.player?.track || target === null || !isActive())
|
|
264
|
-
return Promise.resolve()
|
|
265
|
-
return new Promise<void>((resolve) => {
|
|
266
|
-
if (activeSeek) {
|
|
267
|
-
if (latestSeek) {
|
|
268
|
-
latestSeek.positionMs = target
|
|
269
|
-
latestSeek.resolves.push(resolve)
|
|
270
|
-
} else {
|
|
271
|
-
latestSeek = { positionMs: target, resolves: [resolve] }
|
|
272
|
-
}
|
|
273
|
-
return
|
|
274
|
-
}
|
|
275
|
-
runSeek({ positionMs: target, resolves: [resolve] })
|
|
276
|
-
})
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
eventDisposer = media.subscribe((event) => {
|
|
280
|
-
if (!isActive()) return
|
|
281
|
-
if (event?.type === "snapshot") {
|
|
282
|
-
receivedSnapshot = true
|
|
283
|
-
snapshotEpoch++
|
|
284
|
-
setSession((draft) => {
|
|
285
|
-
draft.player = mergePlayerSnapshot(draft.player, event.state)
|
|
286
|
-
})
|
|
287
|
-
if (
|
|
288
|
-
playbackIntent?.settled &&
|
|
289
|
-
snapshotEpoch > playbackIntent.startedAtEpoch &&
|
|
290
|
-
event.state.is_playing === playbackIntent.target
|
|
291
|
-
)
|
|
292
|
-
playbackIntent = undefined
|
|
293
|
-
return
|
|
294
|
-
}
|
|
295
|
-
if (event?.type === "lifecycle") {
|
|
296
|
-
// Lifecycle and transport feedback are retained independently. A
|
|
297
|
-
// connection loss takes precedence, a transport failure temporarily
|
|
298
|
-
// takes precedence over provider status, and clearing it restores the
|
|
299
|
-
// latest daemon lifecycle state without requiring a repeated event.
|
|
300
|
-
setLifecycleError(event.message, event.source)
|
|
301
|
-
if (event.source === "connection" && event.message !== null)
|
|
302
|
-
playbackIntent = undefined
|
|
303
|
-
if (event.message !== null && latestSeek) {
|
|
304
|
-
latestSeek.resolves.splice(0).forEach((resolve) => resolve())
|
|
305
|
-
latestSeek = null
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
})
|
|
309
|
-
presentationDisposer = media.subscribePresentation((event) => {
|
|
310
|
-
if (!isActive()) return
|
|
311
|
-
setSession((draft) => {
|
|
312
|
-
draft.player = mergeArtworkCompletion(draft.player, event)
|
|
313
|
-
})
|
|
314
|
-
})
|
|
315
|
-
void refreshAll()
|
|
316
|
-
|
|
317
|
-
return {
|
|
318
|
-
session,
|
|
319
|
-
subscribe(listener) {
|
|
320
|
-
sessionListeners.add(listener)
|
|
321
|
-
listener(session)
|
|
322
|
-
return () => sessionListeners.delete(listener)
|
|
323
|
-
},
|
|
324
|
-
refreshAll,
|
|
325
|
-
refreshArtwork: async () => {
|
|
326
|
-
if (!isActive()) return
|
|
327
|
-
try {
|
|
328
|
-
await media.refreshArtwork()
|
|
329
|
-
} catch (error) {
|
|
330
|
-
if (isActive()) setTransportError(errMsg(error))
|
|
331
|
-
}
|
|
332
|
-
},
|
|
333
|
-
async openApp() {
|
|
334
|
-
if (!isActive()) return
|
|
335
|
-
try {
|
|
336
|
-
openNowPlayingApp()
|
|
337
|
-
context.ui.toast.show({
|
|
338
|
-
title: "Music",
|
|
339
|
-
message: "Play in any app — the sidebar uses system media",
|
|
340
|
-
variant: "info",
|
|
341
|
-
})
|
|
342
|
-
} catch (error) {
|
|
343
|
-
setTransportError(errMsg(error))
|
|
344
|
-
}
|
|
345
|
-
},
|
|
346
|
-
playPause,
|
|
347
|
-
seek,
|
|
348
|
-
next: () => runCommand(() => media.next()),
|
|
349
|
-
prev: () => runCommand(() => media.previous()),
|
|
350
|
-
dispose() {
|
|
351
|
-
if (disposed) return
|
|
352
|
-
disposed = true
|
|
353
|
-
lifecycleGeneration++
|
|
354
|
-
eventDisposer?.()
|
|
355
|
-
eventDisposer = null
|
|
356
|
-
presentationDisposer?.()
|
|
357
|
-
presentationDisposer = null
|
|
358
|
-
latestSeek?.resolves.splice(0).forEach((resolve) => resolve())
|
|
359
|
-
latestSeek = null
|
|
360
|
-
activeSeek?.resolves.splice(0).forEach((resolve) => resolve())
|
|
361
|
-
activeSeek = null
|
|
362
|
-
for (const resolve of [...pending]) settle(resolve)
|
|
363
|
-
void media.dispose().catch(() => {})
|
|
364
|
-
setLoadingOwner(false)
|
|
365
|
-
sessionListeners.clear()
|
|
366
|
-
},
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
function AppHost(props: { context: Context; ctrl: Controller }) {
|
|
371
|
-
const { context, ctrl } = props
|
|
372
|
-
context.keymap.layer(() => ({
|
|
373
|
-
mode: "global",
|
|
374
|
-
priority: 200,
|
|
375
|
-
commands: [
|
|
376
|
-
{
|
|
377
|
-
id: "music.playpause",
|
|
378
|
-
title: "Play / pause",
|
|
379
|
-
group: "Music",
|
|
380
|
-
bind: "ctrl+shift+p",
|
|
381
|
-
palette: true,
|
|
382
|
-
run: () => void ctrl.playPause(),
|
|
383
|
-
},
|
|
384
|
-
{
|
|
385
|
-
id: "music.next",
|
|
386
|
-
title: "Next track",
|
|
387
|
-
group: "Music",
|
|
388
|
-
bind: "ctrl+shift+right",
|
|
389
|
-
palette: true,
|
|
390
|
-
run: () => void ctrl.next(),
|
|
391
|
-
},
|
|
392
|
-
{
|
|
393
|
-
id: "music.prev",
|
|
394
|
-
title: "Previous track",
|
|
395
|
-
group: "Music",
|
|
396
|
-
bind: "ctrl+shift+left",
|
|
397
|
-
palette: true,
|
|
398
|
-
run: () => void ctrl.prev(),
|
|
399
|
-
},
|
|
400
|
-
{
|
|
401
|
-
id: "music.open-app",
|
|
402
|
-
title: "Open music site",
|
|
403
|
-
group: "Music",
|
|
404
|
-
palette: true,
|
|
405
|
-
slash: { name: "music-app" },
|
|
406
|
-
run: () => void ctrl.openApp(),
|
|
407
|
-
},
|
|
408
|
-
{
|
|
409
|
-
id: "music.refresh-artwork",
|
|
410
|
-
title: "Refresh artwork",
|
|
411
|
-
group: "Music",
|
|
412
|
-
palette: true,
|
|
413
|
-
slash: { name: "music-artwork" },
|
|
414
|
-
run: () => void ctrl.refreshArtwork(),
|
|
415
|
-
},
|
|
416
|
-
],
|
|
417
|
-
}))
|
|
418
|
-
return (
|
|
419
|
-
<CompactPlayer
|
|
420
|
-
context={context}
|
|
421
|
-
state={ctrl.session}
|
|
422
|
-
onPlayPause={() => void ctrl.playPause()}
|
|
423
|
-
onSeek={(positionMs) => void ctrl.seek(positionMs)}
|
|
424
|
-
/>
|
|
425
|
-
)
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
export function createMusicPlayerPlugin(options?: {
|
|
429
|
-
createController?: (context: Context) => Controller
|
|
430
|
-
/** Test-only factory override; production always uses the session adapter. */
|
|
431
|
-
createSessionMedia?: () => SessionMedia
|
|
432
|
-
}) {
|
|
433
|
-
return Plugin.define({
|
|
434
|
-
id: "music-player",
|
|
435
|
-
setup(context: Context) {
|
|
436
|
-
if (!isMac()) {
|
|
437
|
-
context.ui.toast.show({
|
|
438
|
-
title: "Music",
|
|
439
|
-
message: "System media control is macOS-only",
|
|
440
|
-
variant: "warning",
|
|
441
|
-
})
|
|
442
|
-
}
|
|
443
|
-
const ctrl =
|
|
444
|
-
options?.createController?.(context) ??
|
|
445
|
-
createController(
|
|
446
|
-
context,
|
|
447
|
-
options?.createSessionMedia
|
|
448
|
-
? {
|
|
449
|
-
...controllerDependencies,
|
|
450
|
-
createSessionMedia: options.createSessionMedia,
|
|
451
|
-
}
|
|
452
|
-
: controllerDependencies,
|
|
453
|
-
)
|
|
454
|
-
// Slot renderers must observe host-owned state before package components run.
|
|
455
|
-
const observeSession = () => {
|
|
456
|
-
void ctrl.session.loading
|
|
457
|
-
void ctrl.session.error
|
|
458
|
-
void ctrl.session.player
|
|
459
|
-
}
|
|
460
|
-
const unsubApp = context.ui.slot({
|
|
461
|
-
append: "session.composer.top",
|
|
462
|
-
render: () => {
|
|
463
|
-
observeSession()
|
|
464
|
-
return <AppHost context={context} ctrl={ctrl} />
|
|
465
|
-
},
|
|
466
|
-
})
|
|
467
|
-
const unsubSidebar = context.ui.slot({
|
|
468
|
-
append: "sidebar.content",
|
|
469
|
-
render: () => {
|
|
470
|
-
observeSession()
|
|
471
|
-
return (
|
|
472
|
-
<SidebarPlayer
|
|
473
|
-
context={context}
|
|
474
|
-
state={ctrl.session}
|
|
475
|
-
subscribe={ctrl.subscribe}
|
|
476
|
-
onPlayPause={() => void ctrl.playPause()}
|
|
477
|
-
onNext={() => void ctrl.next()}
|
|
478
|
-
onPrev={() => void ctrl.prev()}
|
|
479
|
-
onSeek={(positionMs) => void ctrl.seek(positionMs)}
|
|
480
|
-
/>
|
|
481
|
-
)
|
|
482
|
-
},
|
|
483
|
-
})
|
|
484
|
-
return () => {
|
|
485
|
-
unsubApp()
|
|
486
|
-
unsubSidebar()
|
|
487
|
-
ctrl.dispose()
|
|
488
|
-
}
|
|
489
|
-
},
|
|
490
|
-
})
|
|
491
|
-
}
|
|
492
|
-
|
|
493
|
-
export default createMusicPlayerPlugin()
|
package/kitty-graphics.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import type { CliRenderer } from "@opentui/core"
|
|
2
|
-
|
|
3
|
-
const APC = "\x1b_G"
|
|
4
|
-
const ST = "\x1b\\"
|
|
5
|
-
const CHUNK_SIZE = 4096
|
|
6
|
-
|
|
7
|
-
export function tmuxPassthrough(data: string): string {
|
|
8
|
-
return `\x1bPtmux;${data.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function kittyTransmitPng(pngBase64: string, imageId: number): string[] {
|
|
12
|
-
const chunks: string[] = []
|
|
13
|
-
for (let offset = 0; offset < pngBase64.length; offset += CHUNK_SIZE) {
|
|
14
|
-
const chunk = pngBase64.slice(offset, offset + CHUNK_SIZE)
|
|
15
|
-
const more = offset + CHUNK_SIZE < pngBase64.length ? 1 : 0
|
|
16
|
-
const control =
|
|
17
|
-
offset === 0 ? `a=t,f=100,i=${imageId},q=2,m=${more}` : `m=${more}`
|
|
18
|
-
chunks.push(`${APC}${control};${chunk}${ST}`)
|
|
19
|
-
}
|
|
20
|
-
return chunks
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function kittyDisplayPng(
|
|
24
|
-
pngBase64: string,
|
|
25
|
-
imageId: number,
|
|
26
|
-
x: number,
|
|
27
|
-
y: number,
|
|
28
|
-
width: number,
|
|
29
|
-
height: number,
|
|
30
|
-
): string[] {
|
|
31
|
-
const chunks = kittyTransmitPng(pngBase64, imageId)
|
|
32
|
-
return chunks.map((command, index) => {
|
|
33
|
-
let next = command
|
|
34
|
-
if (index === 0) {
|
|
35
|
-
next = next.replace(
|
|
36
|
-
`${APC}a=t,f=100,i=${imageId},q=2,`,
|
|
37
|
-
`\x1b7\x1b[${y + 1};${x + 1}H${APC}a=T,f=100,i=${imageId},p=${imageId},q=2,C=1,c=${width},r=${height},z=1,`,
|
|
38
|
-
)
|
|
39
|
-
}
|
|
40
|
-
return index === chunks.length - 1 ? `${next}\x1b8` : next
|
|
41
|
-
})
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function kittyPlace(
|
|
45
|
-
imageId: number,
|
|
46
|
-
placementId: number,
|
|
47
|
-
x: number,
|
|
48
|
-
y: number,
|
|
49
|
-
width: number,
|
|
50
|
-
height: number,
|
|
51
|
-
): string {
|
|
52
|
-
return `\x1b7\x1b[${y + 1};${x + 1}H${APC}a=p,i=${imageId},p=${placementId},q=2,c=${width},r=${height},z=1;${ST}\x1b8`
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function kittyDelete(imageId: number): string {
|
|
56
|
-
return `${APC}a=d,d=I,i=${imageId},q=2;${ST}`
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function kittyDeletePlacement(imageId: number): string {
|
|
60
|
-
return `${APC}a=d,d=i,i=${imageId},q=2;${ST}`
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function kittyImageId(key: string): number {
|
|
64
|
-
let hash = 2166136261
|
|
65
|
-
for (let i = 0; i < key.length; i++) {
|
|
66
|
-
hash ^= key.charCodeAt(i)
|
|
67
|
-
hash = Math.imul(hash, 16777619)
|
|
68
|
-
}
|
|
69
|
-
return hash >>> 0 || 1
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
type RendererWriter = {
|
|
73
|
-
stdout?: NodeJS.WriteStream
|
|
74
|
-
realStdoutWrite?: NodeJS.WriteStream["write"]
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** OpenTUI detects Kitty graphics but does not yet expose a raw output API. */
|
|
78
|
-
export function writeGraphics(
|
|
79
|
-
renderer: CliRenderer,
|
|
80
|
-
data: string | readonly string[],
|
|
81
|
-
): boolean {
|
|
82
|
-
const target = renderer as unknown as RendererWriter
|
|
83
|
-
if (!target.stdout || typeof target.realStdoutWrite !== "function")
|
|
84
|
-
return false
|
|
85
|
-
const output = (value: string) =>
|
|
86
|
-
process.env.TMUX && !process.env.HERDR_ENV ? tmuxPassthrough(value) : value
|
|
87
|
-
let cursorSaved = false
|
|
88
|
-
try {
|
|
89
|
-
for (const command of typeof data === "string" ? [data] : data) {
|
|
90
|
-
if (command.includes("\x1b7")) cursorSaved = true
|
|
91
|
-
target.realStdoutWrite.call(target.stdout, output(command))
|
|
92
|
-
if (command.includes("\x1b8")) cursorSaved = false
|
|
93
|
-
}
|
|
94
|
-
return true
|
|
95
|
-
} catch {
|
|
96
|
-
if (cursorSaved) {
|
|
97
|
-
try {
|
|
98
|
-
target.realStdoutWrite.call(target.stdout, output("\x1b8"))
|
|
99
|
-
} catch {
|
|
100
|
-
// The original transaction still failed; restoration is best effort.
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return false
|
|
104
|
-
}
|
|
105
|
-
}
|