@naxodev/opencode-music-player 0.1.6 → 0.2.1
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 +2 -2
- package/artwork.tsx +42 -17
- package/index.tsx +0 -20
- package/package.json +5 -5
- package/ui.tsx +72 -22
package/README.md
CHANGED
|
@@ -34,12 +34,12 @@ set -g allow-passthrough on
|
|
|
34
34
|
```
|
|
35
35
|
|
|
36
36
|
> [!IMPORTANT]
|
|
37
|
-
> This package targets the beta OpenCode 2 TUI plugin API in `opencode2 v0.0.0-next-
|
|
37
|
+
> This package targets the beta OpenCode 2 TUI plugin API in `opencode2 v0.0.0-next-17444`. OpenCode may change this API before its stable release.
|
|
38
38
|
|
|
39
39
|
## Requirements
|
|
40
40
|
|
|
41
41
|
- macOS
|
|
42
|
-
- OpenCode 2 `v0.0.0-next-
|
|
42
|
+
- OpenCode 2 `v0.0.0-next-17444`
|
|
43
43
|
- Bun, which OpenCode uses to load TypeScript plugin packages
|
|
44
44
|
- [`media-control`](https://github.com/ungive/media-control), recommended:
|
|
45
45
|
|
package/artwork.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
|
-
import type { BoxRenderable } from "@opentui/core"
|
|
2
|
+
import type { BoxRenderable, CliRenderer } from "@opentui/core"
|
|
3
3
|
import { For, onCleanup, onMount } from "solid-js"
|
|
4
4
|
import type { Plugin } from "@opencode-ai/plugin/tui"
|
|
5
5
|
import type { Artwork } from "./types.ts"
|
|
@@ -26,13 +26,33 @@ import {
|
|
|
26
26
|
type Context = Plugin.Context
|
|
27
27
|
const tmuxOffsetCache = createTmuxOffsetCache()
|
|
28
28
|
const nativeArtworkImageId = kittyImageId("opencode-music-player:artwork")
|
|
29
|
-
|
|
29
|
+
type NativeArtworkRuntime = {
|
|
30
|
+
latestOwner: number
|
|
31
|
+
state: NativeArtworkState
|
|
32
|
+
identity: string
|
|
33
|
+
}
|
|
34
|
+
const defaultOwnershipScope = { latestOwner: 0 }
|
|
35
|
+
const nativeArtworkRuntimes = new WeakMap<CliRenderer, NativeArtworkRuntime>()
|
|
36
|
+
|
|
37
|
+
function nativeArtworkRuntime(renderer: CliRenderer): NativeArtworkRuntime {
|
|
38
|
+
const existing = nativeArtworkRuntimes.get(renderer)
|
|
39
|
+
if (existing) return existing
|
|
40
|
+
const created = {
|
|
41
|
+
latestOwner: 0,
|
|
42
|
+
state: { transmitted: 0, placement: null },
|
|
43
|
+
identity: "",
|
|
44
|
+
}
|
|
45
|
+
nativeArtworkRuntimes.set(renderer, created)
|
|
46
|
+
return created
|
|
47
|
+
}
|
|
30
48
|
|
|
31
49
|
export type NativeArtworkOwnership = { isCurrent: () => boolean }
|
|
32
50
|
|
|
33
|
-
export function claimNativeArtworkOwnership(
|
|
34
|
-
|
|
35
|
-
|
|
51
|
+
export function claimNativeArtworkOwnership(
|
|
52
|
+
scope: { latestOwner: number } = defaultOwnershipScope,
|
|
53
|
+
): NativeArtworkOwnership {
|
|
54
|
+
const owner = ++scope.latestOwner
|
|
55
|
+
return { isCurrent: () => owner === scope.latestOwner }
|
|
36
56
|
}
|
|
37
57
|
|
|
38
58
|
export function cleanupNativeArtwork(
|
|
@@ -78,10 +98,10 @@ function copyState(next: NativeArtworkState): NativeArtworkState {
|
|
|
78
98
|
}
|
|
79
99
|
|
|
80
100
|
export function AlbumArtwork(props: { context: Context; artwork: Artwork }) {
|
|
81
|
-
const
|
|
101
|
+
const renderer = props.context.renderer
|
|
102
|
+
const runtime = nativeArtworkRuntime(renderer)
|
|
103
|
+
const ownership = claimNativeArtworkOwnership(runtime)
|
|
82
104
|
let container: BoxRenderable | undefined
|
|
83
|
-
let state: NativeArtworkState = { transmitted: 0, placement: null }
|
|
84
|
-
let artworkIdentity = ""
|
|
85
105
|
let paintPending = false
|
|
86
106
|
let disposed = false
|
|
87
107
|
|
|
@@ -127,18 +147,18 @@ export function AlbumArtwork(props: { context: Context; artwork: Artwork }) {
|
|
|
127
147
|
}
|
|
128
148
|
|
|
129
149
|
const commitPlan = (plan: NativeArtworkPlacementPlan) => {
|
|
130
|
-
state = copyState(plan.nextState)
|
|
150
|
+
runtime.state = copyState(plan.nextState)
|
|
131
151
|
}
|
|
132
152
|
|
|
133
153
|
const paintNativeImage = () => {
|
|
134
154
|
if (!ownership.isCurrent()) return
|
|
135
|
-
if (
|
|
136
|
-
|
|
155
|
+
if (runtime.identity !== props.artwork.id) {
|
|
156
|
+
runtime.identity = props.artwork.id
|
|
137
157
|
writeGraphics(
|
|
138
158
|
props.context.renderer,
|
|
139
159
|
kittyDelete(legacyImageIdForResolvedArtwork(props.artwork)),
|
|
140
160
|
)
|
|
141
|
-
state = { transmitted: 0, placement: null }
|
|
161
|
+
runtime.state = { transmitted: 0, placement: null }
|
|
142
162
|
}
|
|
143
163
|
const kittySupported = supportsKittyGraphics(props.context)
|
|
144
164
|
const slotValid =
|
|
@@ -163,7 +183,7 @@ export function AlbumArtwork(props: { context: Context; artwork: Artwork }) {
|
|
|
163
183
|
const height = slot?.height ?? 0
|
|
164
184
|
|
|
165
185
|
const plan = planNativeArtworkPlacement({
|
|
166
|
-
state,
|
|
186
|
+
state: runtime.state,
|
|
167
187
|
imageId,
|
|
168
188
|
x,
|
|
169
189
|
y,
|
|
@@ -191,10 +211,15 @@ export function AlbumArtwork(props: { context: Context; artwork: Artwork }) {
|
|
|
191
211
|
onCleanup(() => {
|
|
192
212
|
disposed = true
|
|
193
213
|
props.context.renderer.off("frame", scheduleNativeImage)
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
214
|
+
// A replacement mount claims ownership before the next task runs.
|
|
215
|
+
setTimeout(() => {
|
|
216
|
+
cleanupNativeArtwork(ownership, () => {
|
|
217
|
+
if (!renderer.isDestroyed)
|
|
218
|
+
writeGraphics(renderer, kittyDelete(nativeArtworkImageId))
|
|
219
|
+
runtime.state = { transmitted: 0, placement: null }
|
|
220
|
+
runtime.identity = ""
|
|
221
|
+
})
|
|
222
|
+
}, 0)
|
|
198
223
|
})
|
|
199
224
|
|
|
200
225
|
return (
|
package/index.tsx
CHANGED
|
@@ -95,7 +95,6 @@ export function createController(
|
|
|
95
95
|
loadingOwners: [],
|
|
96
96
|
error: null,
|
|
97
97
|
player: null,
|
|
98
|
-
clock_ms: Date.now(),
|
|
99
98
|
},
|
|
100
99
|
},
|
|
101
100
|
)
|
|
@@ -119,18 +118,6 @@ export function createController(
|
|
|
119
118
|
let latestSeek: SeekIntent | null = null
|
|
120
119
|
const pending = new Set<() => void>()
|
|
121
120
|
const isActive = () => !disposed
|
|
122
|
-
let clockTimer: ReturnType<typeof setInterval> | null = null
|
|
123
|
-
const syncClock = () => {
|
|
124
|
-
if (clockTimer) clearInterval(clockTimer)
|
|
125
|
-
clockTimer = null
|
|
126
|
-
if (!isActive() || !session.player?.is_playing) return
|
|
127
|
-
clockTimer = setInterval(() => {
|
|
128
|
-
if (!isActive()) return
|
|
129
|
-
setSession((draft) => {
|
|
130
|
-
draft.clock_ms = Date.now()
|
|
131
|
-
})
|
|
132
|
-
}, 1_000)
|
|
133
|
-
}
|
|
134
121
|
|
|
135
122
|
const publishError = () => {
|
|
136
123
|
if (!isActive()) return
|
|
@@ -240,9 +227,7 @@ export function createController(
|
|
|
240
227
|
return
|
|
241
228
|
setSession((draft) => {
|
|
242
229
|
draft.player = player
|
|
243
|
-
draft.clock_ms = Date.now()
|
|
244
230
|
})
|
|
245
|
-
syncClock()
|
|
246
231
|
} catch (error) {
|
|
247
232
|
if (isActive() && generation === lifecycleGeneration)
|
|
248
233
|
setTransportError(errMsg(error))
|
|
@@ -259,7 +244,6 @@ export function createController(
|
|
|
259
244
|
setSession((draft) => {
|
|
260
245
|
draft.player = optimisticPlayerState(draft.player, nextPlaying)
|
|
261
246
|
})
|
|
262
|
-
syncClock()
|
|
263
247
|
},
|
|
264
248
|
).finally(() => {
|
|
265
249
|
playbackOperations--
|
|
@@ -292,9 +276,7 @@ export function createController(
|
|
|
292
276
|
snapshotEpoch++
|
|
293
277
|
setSession((draft) => {
|
|
294
278
|
draft.player = mergePlayerSnapshot(draft.player, event.state)
|
|
295
|
-
draft.clock_ms = Date.now()
|
|
296
279
|
})
|
|
297
|
-
syncClock()
|
|
298
280
|
return
|
|
299
281
|
}
|
|
300
282
|
if (event?.type === "lifecycle") {
|
|
@@ -350,7 +332,6 @@ export function createController(
|
|
|
350
332
|
activeSeek?.resolves.splice(0).forEach((resolve) => resolve())
|
|
351
333
|
activeSeek = null
|
|
352
334
|
for (const resolve of [...pending]) settle(resolve)
|
|
353
|
-
if (clockTimer) clearInterval(clockTimer)
|
|
354
335
|
void media.dispose().catch(() => {})
|
|
355
336
|
setLoadingOwner(false)
|
|
356
337
|
},
|
|
@@ -438,7 +419,6 @@ export function createMusicPlayerPlugin(options?: {
|
|
|
438
419
|
void ctrl.session.loading
|
|
439
420
|
void ctrl.session.error
|
|
440
421
|
void ctrl.session.player
|
|
441
|
-
void ctrl.session.clock_ms
|
|
442
422
|
}
|
|
443
423
|
const unsubApp = context.ui.slot({
|
|
444
424
|
append: "session.composer.top",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naxodev/opencode-music-player",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "macOS system Now Playing controls for the OpenCode 2 TUI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -68,14 +68,14 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@naxodev/music-core": "^0.1.0",
|
|
71
|
-
"@opencode-ai/plugin": "0.0.0-next-
|
|
72
|
-
"@opentui/core": "0.5.
|
|
73
|
-
"@opentui/solid": "0.5.
|
|
71
|
+
"@opencode-ai/plugin": "0.0.0-next-17444",
|
|
72
|
+
"@opentui/core": "0.5.3",
|
|
73
|
+
"@opentui/solid": "0.5.3",
|
|
74
74
|
"pngjs": "^7.0.0",
|
|
75
75
|
"solid-js": "1.9.12"
|
|
76
76
|
},
|
|
77
77
|
"devDependencies": {
|
|
78
|
-
"@opencode-ai/theme": "0.0.0-next-
|
|
78
|
+
"@opencode-ai/theme": "0.0.0-next-17444",
|
|
79
79
|
"@types/bun": "^1.3.14",
|
|
80
80
|
"@types/pngjs": "^6.0.5",
|
|
81
81
|
"prettier": "3.9.6",
|
package/ui.tsx
CHANGED
|
@@ -5,11 +5,13 @@ import {
|
|
|
5
5
|
createMemo,
|
|
6
6
|
createSignal,
|
|
7
7
|
onCleanup,
|
|
8
|
+
onMount,
|
|
8
9
|
} from "solid-js"
|
|
9
10
|
import type { Plugin } from "@opencode-ai/plugin/tui"
|
|
10
11
|
import {
|
|
11
12
|
MouseButton,
|
|
12
13
|
type BoxRenderable,
|
|
14
|
+
type TextNodeRenderable,
|
|
13
15
|
type TextRenderable,
|
|
14
16
|
} from "@opentui/core"
|
|
15
17
|
import { waveformSeedKey } from "@naxodev/music-core"
|
|
@@ -21,7 +23,6 @@ export type UiState = {
|
|
|
21
23
|
loading: boolean
|
|
22
24
|
error: string | null
|
|
23
25
|
player: import("./types.ts").PlayerState | null
|
|
24
|
-
clock_ms?: number
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
type Theme = Context["theme"]
|
|
@@ -320,30 +321,52 @@ function liveProgress(player: UiState["player"], now = Date.now()): number {
|
|
|
320
321
|
)
|
|
321
322
|
}
|
|
322
323
|
|
|
324
|
+
function progressSegments(progress: number, duration: number, width: number) {
|
|
325
|
+
if (duration <= 0) return { left: "", thumb: "", right: "─".repeat(width) }
|
|
326
|
+
const ratio = Math.max(0, Math.min(1, progress / duration))
|
|
327
|
+
const track = width - 1
|
|
328
|
+
const filled = Math.max(0, Math.min(track, Math.round(ratio * track)))
|
|
329
|
+
return {
|
|
330
|
+
left: "━".repeat(filled),
|
|
331
|
+
thumb: Icon.scrub,
|
|
332
|
+
right: "─".repeat(Math.max(0, track - filled)),
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
323
336
|
function ProgressBar(props: {
|
|
324
337
|
theme: Theme
|
|
325
|
-
|
|
338
|
+
player: UiState["player"]
|
|
326
339
|
duration: number
|
|
327
340
|
width?: number
|
|
328
341
|
accent?: string | undefined
|
|
329
342
|
onSeek: (positionMs: number) => void
|
|
330
343
|
}) {
|
|
331
344
|
let bar: BoxRenderable | undefined
|
|
345
|
+
let leftNode: TextNodeRenderable | undefined
|
|
346
|
+
let thumbNode: TextNodeRenderable | undefined
|
|
347
|
+
let rightNode: TextNodeRenderable | undefined
|
|
332
348
|
const width = () => Math.max(8, props.width ?? 28)
|
|
333
|
-
const rendered =
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
349
|
+
const rendered = () =>
|
|
350
|
+
progressSegments(liveProgress(props.player), props.duration, width())
|
|
351
|
+
const paint = () => {
|
|
352
|
+
const next = rendered()
|
|
353
|
+
if (leftNode) leftNode.children = [next.left]
|
|
354
|
+
if (thumbNode) thumbNode.children = [next.thumb]
|
|
355
|
+
if (rightNode) rightNode.children = [next.right]
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
createEffect(() => {
|
|
359
|
+
void props.player
|
|
360
|
+
void props.duration
|
|
361
|
+
void props.width
|
|
362
|
+
paint()
|
|
363
|
+
})
|
|
364
|
+
onMount(() => {
|
|
365
|
+
paint()
|
|
366
|
+
const timer = setInterval(() => {
|
|
367
|
+
if (props.player?.is_playing) paint()
|
|
368
|
+
}, 1_000)
|
|
369
|
+
onCleanup(() => clearInterval(timer))
|
|
347
370
|
})
|
|
348
371
|
|
|
349
372
|
return (
|
|
@@ -367,6 +390,7 @@ function ProgressBar(props: {
|
|
|
367
390
|
>
|
|
368
391
|
<text>
|
|
369
392
|
<span
|
|
393
|
+
ref={(element) => (leftNode = element)}
|
|
370
394
|
style={{
|
|
371
395
|
fg: props.accent ?? props.theme.text.action.primary.default,
|
|
372
396
|
}}
|
|
@@ -374,18 +398,47 @@ function ProgressBar(props: {
|
|
|
374
398
|
{rendered().left}
|
|
375
399
|
</span>
|
|
376
400
|
<span
|
|
401
|
+
ref={(element) => (thumbNode = element)}
|
|
377
402
|
style={{
|
|
378
403
|
fg: props.accent ?? props.theme.text.action.primary.default,
|
|
379
404
|
}}
|
|
380
405
|
>
|
|
381
406
|
{rendered().thumb}
|
|
382
407
|
</span>
|
|
383
|
-
<span
|
|
408
|
+
<span
|
|
409
|
+
ref={(element) => (rightNode = element)}
|
|
410
|
+
style={{ fg: props.theme.text.subdued }}
|
|
411
|
+
>
|
|
412
|
+
{rendered().right}
|
|
413
|
+
</span>
|
|
384
414
|
</text>
|
|
385
415
|
</box>
|
|
386
416
|
)
|
|
387
417
|
}
|
|
388
418
|
|
|
419
|
+
function LiveElapsed(props: { theme: Theme; player: UiState["player"] }) {
|
|
420
|
+
let text: TextRenderable | undefined
|
|
421
|
+
const paint = () => {
|
|
422
|
+
if (text) text.content = formatMs(liveProgress(props.player))
|
|
423
|
+
}
|
|
424
|
+
createEffect(() => {
|
|
425
|
+
void props.player
|
|
426
|
+
paint()
|
|
427
|
+
})
|
|
428
|
+
onMount(() => {
|
|
429
|
+
paint()
|
|
430
|
+
const timer = setInterval(() => {
|
|
431
|
+
if (props.player?.is_playing) paint()
|
|
432
|
+
}, 1_000)
|
|
433
|
+
onCleanup(() => clearInterval(timer))
|
|
434
|
+
})
|
|
435
|
+
return (
|
|
436
|
+
<text ref={(element) => (text = element)} fg={props.theme.text.subdued}>
|
|
437
|
+
{formatMs(liveProgress(props.player))}
|
|
438
|
+
</text>
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
|
|
389
442
|
/** Hover/press icon button — props read in JSX (Solid setup is once). */
|
|
390
443
|
function IconBtn(props: {
|
|
391
444
|
theme: Theme
|
|
@@ -467,9 +520,6 @@ export function SidebarPlayer(props: {
|
|
|
467
520
|
const track = createMemo(() => player()?.track)
|
|
468
521
|
const playing = createMemo(() => !!player()?.is_playing)
|
|
469
522
|
|
|
470
|
-
const progress = createMemo(() =>
|
|
471
|
-
liveProgress(player(), props.state.clock_ms),
|
|
472
|
-
)
|
|
473
523
|
const duration = createMemo(() => track()?.duration_ms ?? 0)
|
|
474
524
|
|
|
475
525
|
return (
|
|
@@ -559,14 +609,14 @@ export function SidebarPlayer(props: {
|
|
|
559
609
|
<box flexDirection="column" gap={0} overflow="hidden">
|
|
560
610
|
<ProgressBar
|
|
561
611
|
theme={theme()}
|
|
562
|
-
|
|
612
|
+
player={player()}
|
|
563
613
|
duration={duration()}
|
|
564
614
|
width={24}
|
|
565
615
|
accent={track()?.artwork?.accent}
|
|
566
616
|
onSeek={props.onSeek}
|
|
567
617
|
/>
|
|
568
618
|
<box flexDirection="row" justifyContent="space-between">
|
|
569
|
-
<
|
|
619
|
+
<LiveElapsed theme={theme()} player={player()} />
|
|
570
620
|
<text fg={theme().text.subdued}>{formatMs(duration())}</text>
|
|
571
621
|
</box>
|
|
572
622
|
</box>
|