@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/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
+ }