@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/index.tsx
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/** @jsxImportSource @opentui/solid */
|
|
2
|
+
import { Plugin } from "@opencode-ai/plugin/tui"
|
|
3
|
+
import { createSessionSystemMedia, openNowPlayingApp } from "./system-media.ts"
|
|
4
|
+
import { isMac, mergeArtworkCompletion, type SessionMedia } from "./types.ts"
|
|
5
|
+
import { CompactPlayer, SidebarPlayer, type UiState } from "./ui.tsx"
|
|
6
|
+
|
|
7
|
+
type Context = Plugin.Context
|
|
8
|
+
type SessionStore = UiState
|
|
9
|
+
type SeekIntent = {
|
|
10
|
+
positionMs: number
|
|
11
|
+
resolves: Array<() => void>
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export type Controller = {
|
|
15
|
+
session: SessionStore
|
|
16
|
+
openApp: () => Promise<void>
|
|
17
|
+
refreshAll: () => Promise<void>
|
|
18
|
+
playPause: () => Promise<void>
|
|
19
|
+
seek: (positionMs: number) => Promise<void>
|
|
20
|
+
next: () => Promise<void>
|
|
21
|
+
prev: () => Promise<void>
|
|
22
|
+
dispose: () => void
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type ControllerDependencies = {
|
|
26
|
+
createSessionMedia: () => SessionMedia
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Apply successful daemon transport state until the next authoritative replay. */
|
|
30
|
+
export function optimisticPlayerState(
|
|
31
|
+
player: SessionStore["player"],
|
|
32
|
+
playing: boolean,
|
|
33
|
+
now = Date.now(),
|
|
34
|
+
): SessionStore["player"] {
|
|
35
|
+
if (!player) return player
|
|
36
|
+
const progress = player.is_playing
|
|
37
|
+
? Math.min(
|
|
38
|
+
player.track?.duration_ms || Number.POSITIVE_INFINITY,
|
|
39
|
+
Math.max(0, player.progress_ms + (now - player.fetched_at)),
|
|
40
|
+
)
|
|
41
|
+
: player.progress_ms
|
|
42
|
+
return {
|
|
43
|
+
...player,
|
|
44
|
+
progress_ms: progress,
|
|
45
|
+
is_playing: playing,
|
|
46
|
+
fetched_at: now,
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function optimisticSeekPlayerState(
|
|
51
|
+
player: SessionStore["player"],
|
|
52
|
+
positionMs: number,
|
|
53
|
+
now = Date.now(),
|
|
54
|
+
): SessionStore["player"] {
|
|
55
|
+
const target = seekTarget(positionMs, player?.track?.duration_ms)
|
|
56
|
+
if (!player?.track || target === null) return player
|
|
57
|
+
return { ...player, progress_ms: target, fetched_at: now }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function seekTarget(positionMs: number, durationMs?: number): number | null {
|
|
61
|
+
if (
|
|
62
|
+
!Number.isFinite(positionMs) ||
|
|
63
|
+
typeof durationMs !== "number" ||
|
|
64
|
+
!Number.isFinite(durationMs) ||
|
|
65
|
+
durationMs <= 0
|
|
66
|
+
)
|
|
67
|
+
return null
|
|
68
|
+
return Math.max(0, Math.min(durationMs, Math.round(positionMs)))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const controllerDependencies: ControllerDependencies = {
|
|
72
|
+
createSessionMedia: createSessionSystemMedia,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function errMsg(error: unknown): string {
|
|
76
|
+
return error && typeof error === "object" && "message" in error
|
|
77
|
+
? String((error as { message: unknown }).message)
|
|
78
|
+
: String(error)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createController(
|
|
82
|
+
context: Context,
|
|
83
|
+
dependencies: ControllerDependencies = controllerDependencies,
|
|
84
|
+
): Controller {
|
|
85
|
+
const [session, setSession] = context.storage.memory<SessionStore>(
|
|
86
|
+
"music-player.session.v5",
|
|
87
|
+
{ initial: { loading: false, error: null, player: null } },
|
|
88
|
+
)
|
|
89
|
+
const media = dependencies.createSessionMedia()
|
|
90
|
+
let disposed = false
|
|
91
|
+
let lifecycleGeneration = 0
|
|
92
|
+
let eventDisposer: (() => void) | null = null
|
|
93
|
+
let presentationDisposer: (() => void) | null = null
|
|
94
|
+
let lifecycleError: {
|
|
95
|
+
message: string | null
|
|
96
|
+
source: "connection" | "provider" | "acquisition" | undefined
|
|
97
|
+
} = { message: null, source: undefined }
|
|
98
|
+
let transportError: string | null = null
|
|
99
|
+
let receivedSnapshot = false
|
|
100
|
+
// An authority fence for optimistic UI projection, not a host sampling lane.
|
|
101
|
+
let snapshotEpoch = 0
|
|
102
|
+
let playbackTarget: boolean | undefined
|
|
103
|
+
let playbackOperations = 0
|
|
104
|
+
let activeSeek: SeekIntent | null = null
|
|
105
|
+
let latestSeek: SeekIntent | null = null
|
|
106
|
+
const pending = new Set<() => void>()
|
|
107
|
+
|
|
108
|
+
const isActive = () => !disposed
|
|
109
|
+
const publishError = () => {
|
|
110
|
+
if (!isActive()) return
|
|
111
|
+
const message =
|
|
112
|
+
lifecycleError.source === "connection" && lifecycleError.message
|
|
113
|
+
? lifecycleError.message
|
|
114
|
+
: (transportError ?? lifecycleError.message)
|
|
115
|
+
setSession((draft) => {
|
|
116
|
+
draft.error = message
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
const setLifecycleError = (
|
|
120
|
+
message: string | null,
|
|
121
|
+
source: "connection" | "provider" | "acquisition" | undefined,
|
|
122
|
+
) => {
|
|
123
|
+
lifecycleError = { message, source }
|
|
124
|
+
publishError()
|
|
125
|
+
}
|
|
126
|
+
const setTransportError = (message: string | null) => {
|
|
127
|
+
transportError = message
|
|
128
|
+
publishError()
|
|
129
|
+
}
|
|
130
|
+
const updateLoading = () => {
|
|
131
|
+
if (!isActive()) return
|
|
132
|
+
setSession((draft) => {
|
|
133
|
+
draft.loading = pending.size > 0
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
const settle = (resolve: () => void) => {
|
|
137
|
+
if (!pending.delete(resolve)) return
|
|
138
|
+
resolve()
|
|
139
|
+
updateLoading()
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const runCommand = (
|
|
143
|
+
command: () => Promise<unknown>,
|
|
144
|
+
afterSuccess?: () => void,
|
|
145
|
+
) => {
|
|
146
|
+
if (!isActive()) return Promise.resolve()
|
|
147
|
+
const generation = lifecycleGeneration
|
|
148
|
+
const commandSnapshotEpoch = snapshotEpoch
|
|
149
|
+
return new Promise<void>((resolve) => {
|
|
150
|
+
pending.add(resolve)
|
|
151
|
+
updateLoading()
|
|
152
|
+
let result: Promise<unknown> | undefined
|
|
153
|
+
try {
|
|
154
|
+
result = command()
|
|
155
|
+
} catch (error) {
|
|
156
|
+
result = Promise.reject(error)
|
|
157
|
+
}
|
|
158
|
+
void Promise.resolve(result)
|
|
159
|
+
.then(
|
|
160
|
+
() => {
|
|
161
|
+
if (!isActive() || generation !== lifecycleGeneration) return
|
|
162
|
+
if (transportError !== null) setTransportError(null)
|
|
163
|
+
// A later daemon snapshot is authoritative over command optimism.
|
|
164
|
+
if (snapshotEpoch === commandSnapshotEpoch) afterSuccess?.()
|
|
165
|
+
},
|
|
166
|
+
(error) => {
|
|
167
|
+
if (!isActive() || generation !== lifecycleGeneration) return
|
|
168
|
+
const message = errMsg(error)
|
|
169
|
+
setTransportError(message)
|
|
170
|
+
context.ui.toast.show({ title: "Music", message, variant: "error" })
|
|
171
|
+
},
|
|
172
|
+
)
|
|
173
|
+
.finally(() => settle(resolve))
|
|
174
|
+
})
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const runSeek = (intent: SeekIntent) => {
|
|
178
|
+
activeSeek = intent
|
|
179
|
+
const operation = runCommand(
|
|
180
|
+
() => media.seek(intent.positionMs),
|
|
181
|
+
() => {
|
|
182
|
+
setSession((draft) => {
|
|
183
|
+
draft.player = optimisticSeekPlayerState(
|
|
184
|
+
draft.player,
|
|
185
|
+
intent.positionMs,
|
|
186
|
+
)
|
|
187
|
+
})
|
|
188
|
+
},
|
|
189
|
+
)
|
|
190
|
+
void operation.then(() => {
|
|
191
|
+
if (activeSeek !== intent) return
|
|
192
|
+
activeSeek = null
|
|
193
|
+
for (const resolve of intent.resolves) resolve()
|
|
194
|
+
intent.resolves = []
|
|
195
|
+
const next = latestSeek
|
|
196
|
+
latestSeek = null
|
|
197
|
+
if (isActive() && next) runSeek(next)
|
|
198
|
+
else next?.resolves.splice(0).forEach((resolve) => resolve())
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const refreshAll = async () => {
|
|
203
|
+
if (!isActive()) return
|
|
204
|
+
const generation = lifecycleGeneration
|
|
205
|
+
try {
|
|
206
|
+
const player = await media.player()
|
|
207
|
+
if (!isActive() || generation !== lifecycleGeneration || receivedSnapshot)
|
|
208
|
+
return
|
|
209
|
+
setSession((draft) => {
|
|
210
|
+
draft.player = player
|
|
211
|
+
})
|
|
212
|
+
} catch (error) {
|
|
213
|
+
if (isActive() && generation === lifecycleGeneration)
|
|
214
|
+
setTransportError(errMsg(error))
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const playPause = () => {
|
|
219
|
+
const nextPlaying = !(playbackTarget ?? !!session.player?.is_playing)
|
|
220
|
+
playbackTarget = nextPlaying
|
|
221
|
+
playbackOperations++
|
|
222
|
+
return runCommand(
|
|
223
|
+
() => (nextPlaying ? media.play() : media.pause()),
|
|
224
|
+
() => {
|
|
225
|
+
setSession((draft) => {
|
|
226
|
+
draft.player = optimisticPlayerState(draft.player, nextPlaying)
|
|
227
|
+
})
|
|
228
|
+
},
|
|
229
|
+
).finally(() => {
|
|
230
|
+
playbackOperations--
|
|
231
|
+
if (playbackOperations === 0) playbackTarget = undefined
|
|
232
|
+
})
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const seek = (positionMs: number) => {
|
|
236
|
+
const target = seekTarget(positionMs, session.player?.track?.duration_ms)
|
|
237
|
+
if (!session.player?.track || target === null || !isActive())
|
|
238
|
+
return Promise.resolve()
|
|
239
|
+
return new Promise<void>((resolve) => {
|
|
240
|
+
if (activeSeek) {
|
|
241
|
+
if (latestSeek) {
|
|
242
|
+
latestSeek.positionMs = target
|
|
243
|
+
latestSeek.resolves.push(resolve)
|
|
244
|
+
} else {
|
|
245
|
+
latestSeek = { positionMs: target, resolves: [resolve] }
|
|
246
|
+
}
|
|
247
|
+
return
|
|
248
|
+
}
|
|
249
|
+
runSeek({ positionMs: target, resolves: [resolve] })
|
|
250
|
+
})
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
eventDisposer = media.subscribe((event) => {
|
|
254
|
+
if (!isActive()) return
|
|
255
|
+
if (event?.type === "snapshot") {
|
|
256
|
+
receivedSnapshot = true
|
|
257
|
+
snapshotEpoch++
|
|
258
|
+
setSession((draft) => {
|
|
259
|
+
draft.player = event.state
|
|
260
|
+
})
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
if (event?.type === "lifecycle") {
|
|
264
|
+
// Lifecycle and transport feedback are retained independently. A
|
|
265
|
+
// connection loss takes precedence, a transport failure temporarily
|
|
266
|
+
// takes precedence over provider status, and clearing it restores the
|
|
267
|
+
// latest daemon lifecycle state without requiring a repeated event.
|
|
268
|
+
setLifecycleError(event.message, event.source)
|
|
269
|
+
if (event.message !== null && latestSeek) {
|
|
270
|
+
latestSeek.resolves.splice(0).forEach((resolve) => resolve())
|
|
271
|
+
latestSeek = null
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
})
|
|
275
|
+
presentationDisposer = media.subscribePresentation((event) => {
|
|
276
|
+
if (!isActive()) return
|
|
277
|
+
setSession((draft) => {
|
|
278
|
+
draft.player = mergeArtworkCompletion(draft.player, event)
|
|
279
|
+
})
|
|
280
|
+
})
|
|
281
|
+
void refreshAll()
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
session,
|
|
285
|
+
refreshAll,
|
|
286
|
+
async openApp() {
|
|
287
|
+
if (!isActive()) return
|
|
288
|
+
try {
|
|
289
|
+
openNowPlayingApp()
|
|
290
|
+
context.ui.toast.show({
|
|
291
|
+
title: "Music",
|
|
292
|
+
message: "Play in any app — the sidebar uses system media",
|
|
293
|
+
variant: "info",
|
|
294
|
+
})
|
|
295
|
+
} catch (error) {
|
|
296
|
+
setTransportError(errMsg(error))
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
playPause,
|
|
300
|
+
seek,
|
|
301
|
+
next: () => runCommand(() => media.next()),
|
|
302
|
+
prev: () => runCommand(() => media.previous()),
|
|
303
|
+
dispose() {
|
|
304
|
+
if (disposed) return
|
|
305
|
+
disposed = true
|
|
306
|
+
lifecycleGeneration++
|
|
307
|
+
eventDisposer?.()
|
|
308
|
+
eventDisposer = null
|
|
309
|
+
presentationDisposer?.()
|
|
310
|
+
presentationDisposer = null
|
|
311
|
+
latestSeek?.resolves.splice(0).forEach((resolve) => resolve())
|
|
312
|
+
latestSeek = null
|
|
313
|
+
activeSeek?.resolves.splice(0).forEach((resolve) => resolve())
|
|
314
|
+
activeSeek = null
|
|
315
|
+
for (const resolve of [...pending]) settle(resolve)
|
|
316
|
+
void media.dispose().catch(() => {})
|
|
317
|
+
if (session.loading) {
|
|
318
|
+
setSession((draft) => {
|
|
319
|
+
draft.loading = false
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function AppHost(props: { context: Context; ctrl: Controller }) {
|
|
327
|
+
const { context, ctrl } = props
|
|
328
|
+
context.keymap.layer(() => ({
|
|
329
|
+
mode: "global",
|
|
330
|
+
priority: 200,
|
|
331
|
+
commands: [
|
|
332
|
+
{
|
|
333
|
+
id: "music.playpause",
|
|
334
|
+
title: "Play / pause",
|
|
335
|
+
group: "Music",
|
|
336
|
+
bind: "ctrl+shift+p",
|
|
337
|
+
palette: true,
|
|
338
|
+
run: () => void ctrl.playPause(),
|
|
339
|
+
},
|
|
340
|
+
{
|
|
341
|
+
id: "music.next",
|
|
342
|
+
title: "Next track",
|
|
343
|
+
group: "Music",
|
|
344
|
+
bind: "ctrl+shift+right",
|
|
345
|
+
palette: true,
|
|
346
|
+
run: () => void ctrl.next(),
|
|
347
|
+
},
|
|
348
|
+
{
|
|
349
|
+
id: "music.prev",
|
|
350
|
+
title: "Previous track",
|
|
351
|
+
group: "Music",
|
|
352
|
+
bind: "ctrl+shift+left",
|
|
353
|
+
palette: true,
|
|
354
|
+
run: () => void ctrl.prev(),
|
|
355
|
+
},
|
|
356
|
+
{
|
|
357
|
+
id: "music.open-app",
|
|
358
|
+
title: "Open music site",
|
|
359
|
+
group: "Music",
|
|
360
|
+
palette: true,
|
|
361
|
+
slash: { name: "music-app" },
|
|
362
|
+
run: () => void ctrl.openApp(),
|
|
363
|
+
},
|
|
364
|
+
],
|
|
365
|
+
}))
|
|
366
|
+
return (
|
|
367
|
+
<CompactPlayer
|
|
368
|
+
context={context}
|
|
369
|
+
state={ctrl.session}
|
|
370
|
+
onPlayPause={() => void ctrl.playPause()}
|
|
371
|
+
onSeek={(positionMs) => void ctrl.seek(positionMs)}
|
|
372
|
+
/>
|
|
373
|
+
)
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function createMusicPlayerPlugin(options?: {
|
|
377
|
+
createController?: (context: Context) => Controller
|
|
378
|
+
/** Test-only factory override; production always uses the session adapter. */
|
|
379
|
+
createSessionMedia?: () => SessionMedia
|
|
380
|
+
}) {
|
|
381
|
+
return Plugin.define({
|
|
382
|
+
id: "music-player",
|
|
383
|
+
setup(context: Context) {
|
|
384
|
+
if (!isMac()) {
|
|
385
|
+
context.ui.toast.show({
|
|
386
|
+
title: "Music",
|
|
387
|
+
message: "System media control is macOS-only",
|
|
388
|
+
variant: "warning",
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
const ctrl =
|
|
392
|
+
options?.createController?.(context) ??
|
|
393
|
+
createController(
|
|
394
|
+
context,
|
|
395
|
+
options?.createSessionMedia
|
|
396
|
+
? {
|
|
397
|
+
...controllerDependencies,
|
|
398
|
+
createSessionMedia: options.createSessionMedia,
|
|
399
|
+
}
|
|
400
|
+
: controllerDependencies,
|
|
401
|
+
)
|
|
402
|
+
const unsubApp = context.ui.slot({
|
|
403
|
+
append: "app",
|
|
404
|
+
render: () => <AppHost context={context} ctrl={ctrl} />,
|
|
405
|
+
})
|
|
406
|
+
const unsubSidebar = context.ui.slot({
|
|
407
|
+
append: "sidebar.content",
|
|
408
|
+
render: () => (
|
|
409
|
+
<SidebarPlayer
|
|
410
|
+
context={context}
|
|
411
|
+
state={ctrl.session}
|
|
412
|
+
onPlayPause={() => void ctrl.playPause()}
|
|
413
|
+
onNext={() => void ctrl.next()}
|
|
414
|
+
onPrev={() => void ctrl.prev()}
|
|
415
|
+
onSeek={(positionMs) => void ctrl.seek(positionMs)}
|
|
416
|
+
/>
|
|
417
|
+
),
|
|
418
|
+
})
|
|
419
|
+
return () => {
|
|
420
|
+
unsubApp()
|
|
421
|
+
unsubSidebar()
|
|
422
|
+
ctrl.dispose()
|
|
423
|
+
}
|
|
424
|
+
},
|
|
425
|
+
})
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export default createMusicPlayerPlugin()
|
|
@@ -0,0 +1,90 @@
|
|
|
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(renderer: CliRenderer, data: string): boolean {
|
|
79
|
+
const target = renderer as unknown as RendererWriter
|
|
80
|
+
if (!target.stdout || typeof target.realStdoutWrite !== "function")
|
|
81
|
+
return false
|
|
82
|
+
try {
|
|
83
|
+
const output =
|
|
84
|
+
process.env.TMUX && !process.env.HERDR_ENV ? tmuxPassthrough(data) : data
|
|
85
|
+
target.realStdoutWrite.call(target.stdout, output)
|
|
86
|
+
return true
|
|
87
|
+
} catch {
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@naxodev/opencode-music-player",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "macOS system Now Playing controls for the OpenCode 2 TUI",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": {
|
|
7
|
+
"name": "Nacho Vazquez",
|
|
8
|
+
"url": "https://github.com/NachoVazquez"
|
|
9
|
+
},
|
|
10
|
+
"funding": "https://github.com/sponsors/NachoVazquez",
|
|
11
|
+
"homepage": "https://github.com/naxodev/ai/tree/main/packages/opencode-music-player#readme",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/naxodev/ai.git",
|
|
15
|
+
"directory": "packages/opencode-music-player"
|
|
16
|
+
},
|
|
17
|
+
"bugs": {
|
|
18
|
+
"url": "https://github.com/naxodev/ai/issues"
|
|
19
|
+
},
|
|
20
|
+
"type": "module",
|
|
21
|
+
"keywords": [
|
|
22
|
+
"opencode",
|
|
23
|
+
"opencode-plugin",
|
|
24
|
+
"tui",
|
|
25
|
+
"macos",
|
|
26
|
+
"now-playing",
|
|
27
|
+
"media-control"
|
|
28
|
+
],
|
|
29
|
+
"exports": "./index.tsx",
|
|
30
|
+
"files": [
|
|
31
|
+
"CHANGELOG.md",
|
|
32
|
+
"artwork-placement.ts",
|
|
33
|
+
"artwork.ts",
|
|
34
|
+
"artwork.tsx",
|
|
35
|
+
"index.tsx",
|
|
36
|
+
"kitty-graphics.ts",
|
|
37
|
+
"ui.tsx",
|
|
38
|
+
"system-media.ts",
|
|
39
|
+
"tmux-offset.ts",
|
|
40
|
+
"types.ts",
|
|
41
|
+
"waveform.tsx",
|
|
42
|
+
"SUPPORT.md",
|
|
43
|
+
"LICENSE",
|
|
44
|
+
"README.md"
|
|
45
|
+
],
|
|
46
|
+
"publishConfig": {
|
|
47
|
+
"access": "public",
|
|
48
|
+
"provenance": true
|
|
49
|
+
},
|
|
50
|
+
"engines": {
|
|
51
|
+
"bun": ">=1.3.0"
|
|
52
|
+
},
|
|
53
|
+
"os": [
|
|
54
|
+
"darwin"
|
|
55
|
+
],
|
|
56
|
+
"scripts": {
|
|
57
|
+
"format": "prettier --write .",
|
|
58
|
+
"format:check": "prettier --check .",
|
|
59
|
+
"check": "tsc --noEmit",
|
|
60
|
+
"test": "bun test --preload @opentui/solid/preload",
|
|
61
|
+
"pack:check": "bun run scripts/verify-pack.ts",
|
|
62
|
+
"smoke:package": "bun run scripts/package-smoke.ts",
|
|
63
|
+
"validate": "bun run format:check && bun run check && bun run test && bun run pack:check"
|
|
64
|
+
},
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"@naxodev/music-core": "^0.1.0",
|
|
67
|
+
"@opencode-ai/plugin": "0.0.0-next-17395",
|
|
68
|
+
"@opentui/core": "0.5.2",
|
|
69
|
+
"@opentui/solid": "0.5.2",
|
|
70
|
+
"pngjs": "^7.0.0",
|
|
71
|
+
"solid-js": "1.9.12"
|
|
72
|
+
},
|
|
73
|
+
"devDependencies": {
|
|
74
|
+
"@opencode-ai/theme": "0.0.0-next-17395",
|
|
75
|
+
"@types/bun": "^1.3.14",
|
|
76
|
+
"@types/pngjs": "^6.0.5",
|
|
77
|
+
"prettier": "3.9.6",
|
|
78
|
+
"typescript": "7.0.2"
|
|
79
|
+
}
|
|
80
|
+
}
|