@naxodev/opencode-music-player 0.2.0 → 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 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-17395`. OpenCode may change this API before its stable release.
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-17395`
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
- let latestNativeArtworkOwner = 0
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(): NativeArtworkOwnership {
34
- const owner = ++latestNativeArtworkOwner
35
- return { isCurrent: () => owner === latestNativeArtworkOwner }
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 ownership = claimNativeArtworkOwnership()
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 (artworkIdentity !== props.artwork.id) {
136
- artworkIdentity = props.artwork.id
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
- // Clear even if this owner unmounts before its first native paint.
195
- cleanupNativeArtwork(ownership, () => {
196
- writeGraphics(props.context.renderer, kittyDelete(nativeArtworkImageId))
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.2.0",
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-17395",
72
- "@opentui/core": "0.5.2",
73
- "@opentui/solid": "0.5.2",
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-17395",
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
- progress: number
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 = createMemo(() => {
334
- const w = width()
335
- if (props.duration <= 0) {
336
- return { left: "", thumb: "", right: "─".repeat(w) }
337
- }
338
- const ratio = Math.max(0, Math.min(1, props.progress / props.duration))
339
- // leave 1 cell for thumb
340
- const track = w - 1
341
- const filled = Math.max(0, Math.min(track, Math.round(ratio * track)))
342
- return {
343
- left: "━".repeat(filled),
344
- thumb: Icon.scrub,
345
- right: "─".repeat(Math.max(0, track - filled)),
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 style={{ fg: props.theme.text.subdued }}>{rendered().right}</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
- progress={progress()}
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
- <text fg={theme().text.subdued}>{formatMs(progress())}</text>
619
+ <LiveElapsed theme={theme()} player={player()} />
570
620
  <text fg={theme().text.subdued}>{formatMs(duration())}</text>
571
621
  </box>
572
622
  </box>