@naxodev/opencode-music-player 0.2.1 → 0.2.3

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.
Files changed (4) hide show
  1. package/index.tsx +20 -1
  2. package/package.json +2 -2
  3. package/ui.tsx +66 -38
  4. package/waveform.tsx +39 -39
package/index.tsx CHANGED
@@ -18,6 +18,7 @@ type SeekIntent = {
18
18
 
19
19
  export type Controller = {
20
20
  session: SessionStore
21
+ subscribe: (listener: (session: SessionStore) => void) => () => void
21
22
  openApp: () => Promise<void>
22
23
  refreshAll: () => Promise<void>
23
24
  playPause: () => Promise<void>
@@ -87,7 +88,7 @@ export function createController(
87
88
  context: Context,
88
89
  dependencies: ControllerDependencies = controllerDependencies,
89
90
  ): Controller {
90
- const [session, setSession] = context.storage.memory<SessionStore>(
91
+ const [session, setSessionStore] = context.storage.memory<SessionStore>(
91
92
  "music-player.session.v6",
92
93
  {
93
94
  initial: {
@@ -98,6 +99,17 @@ export function createController(
98
99
  },
99
100
  },
100
101
  )
102
+ const sessionListeners = new Set<(session: SessionStore) => void>()
103
+ const setSession = (update: (draft: SessionStore) => void) => {
104
+ setSessionStore(update)
105
+ for (const listener of [...sessionListeners]) {
106
+ try {
107
+ listener(session)
108
+ } catch {
109
+ // One mounted presentation cannot block controller state.
110
+ }
111
+ }
112
+ }
101
113
  const loadingOwner = crypto.randomUUID()
102
114
  const media = dependencies.createSessionMedia()
103
115
  let disposed = false
@@ -301,6 +313,11 @@ export function createController(
301
313
 
302
314
  return {
303
315
  session,
316
+ subscribe(listener) {
317
+ sessionListeners.add(listener)
318
+ listener(session)
319
+ return () => sessionListeners.delete(listener)
320
+ },
304
321
  refreshAll,
305
322
  async openApp() {
306
323
  if (!isActive()) return
@@ -334,6 +351,7 @@ export function createController(
334
351
  for (const resolve of [...pending]) settle(resolve)
335
352
  void media.dispose().catch(() => {})
336
353
  setLoadingOwner(false)
354
+ sessionListeners.clear()
337
355
  },
338
356
  }
339
357
  }
@@ -435,6 +453,7 @@ export function createMusicPlayerPlugin(options?: {
435
453
  <SidebarPlayer
436
454
  context={context}
437
455
  state={ctrl.session}
456
+ subscribe={ctrl.subscribe}
438
457
  onPlayPause={() => void ctrl.playPause()}
439
458
  onNext={() => void ctrl.next()}
440
459
  onPrev={() => void ctrl.prev()}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naxodev/opencode-music-player",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "macOS system Now Playing controls for the OpenCode 2 TUI",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -67,7 +67,7 @@
67
67
  "validate": "bun run format:check && bun run check && bun run test && bun run pack:check"
68
68
  },
69
69
  "dependencies": {
70
- "@naxodev/music-core": "^0.1.0",
70
+ "@naxodev/music-core": "^0.1.1",
71
71
  "@opencode-ai/plugin": "0.0.0-next-17444",
72
72
  "@opentui/core": "0.5.3",
73
73
  "@opentui/solid": "0.5.3",
package/ui.tsx CHANGED
@@ -14,10 +14,9 @@ import {
14
14
  type TextNodeRenderable,
15
15
  type TextRenderable,
16
16
  } from "@opentui/core"
17
- import { waveformSeedKey } from "@naxodev/music-core"
18
17
  import { formatMs } from "./types.ts"
19
18
  import { AlbumArtwork } from "./artwork.tsx"
20
- import { Waveform } from "./waveform.tsx"
19
+ import { Waveform, type PlayerPresentationSource } from "./waveform.tsx"
21
20
 
22
21
  export type UiState = {
23
22
  loading: boolean
@@ -27,6 +26,7 @@ export type UiState = {
27
26
 
28
27
  type Theme = Context["theme"]
29
28
  type Context = Plugin.Context
29
+ type UiStateSubscribe = (listener: (state: UiState) => void) => () => void
30
30
 
31
31
  /** Text presentation keeps media symbols single-cell on emoji fonts. */
32
32
  const t = (s: string) => s + "\uFE0E"
@@ -335,8 +335,7 @@ function progressSegments(progress: number, duration: number, width: number) {
335
335
 
336
336
  function ProgressBar(props: {
337
337
  theme: Theme
338
- player: UiState["player"]
339
- duration: number
338
+ source: PlayerPresentationSource
340
339
  width?: number
341
340
  accent?: string | undefined
342
341
  onSeek: (positionMs: number) => void
@@ -346,8 +345,14 @@ function ProgressBar(props: {
346
345
  let thumbNode: TextNodeRenderable | undefined
347
346
  let rightNode: TextNodeRenderable | undefined
348
347
  const width = () => Math.max(8, props.width ?? 28)
349
- const rendered = () =>
350
- progressSegments(liveProgress(props.player), props.duration, width())
348
+ const rendered = () => {
349
+ const player = props.source.current()
350
+ return progressSegments(
351
+ liveProgress(player),
352
+ player?.track?.duration_ms ?? 0,
353
+ width(),
354
+ )
355
+ }
351
356
  const paint = () => {
352
357
  const next = rendered()
353
358
  if (leftNode) leftNode.children = [next.left]
@@ -355,18 +360,15 @@ function ProgressBar(props: {
355
360
  if (rightNode) rightNode.children = [next.right]
356
361
  }
357
362
 
358
- createEffect(() => {
359
- void props.player
360
- void props.duration
361
- void props.width
362
- paint()
363
- })
364
363
  onMount(() => {
365
- paint()
364
+ const unsubscribe = props.source.subscribe(() => paint())
366
365
  const timer = setInterval(() => {
367
- if (props.player?.is_playing) paint()
366
+ if (props.source.current()?.is_playing) paint()
368
367
  }, 1_000)
369
- onCleanup(() => clearInterval(timer))
368
+ onCleanup(() => {
369
+ unsubscribe()
370
+ clearInterval(timer)
371
+ })
370
372
  })
371
373
 
372
374
  return (
@@ -380,7 +382,7 @@ function ProgressBar(props: {
380
382
  const position = seekPositionForCell(
381
383
  event.x - bar.x,
382
384
  bar.width,
383
- props.duration,
385
+ props.source.current()?.track?.duration_ms ?? 0,
384
386
  )
385
387
  if (position === null) return
386
388
  event.preventDefault()
@@ -416,25 +418,27 @@ function ProgressBar(props: {
416
418
  )
417
419
  }
418
420
 
419
- function LiveElapsed(props: { theme: Theme; player: UiState["player"] }) {
421
+ function LiveElapsed(props: {
422
+ theme: Theme
423
+ source: PlayerPresentationSource
424
+ }) {
420
425
  let text: TextRenderable | undefined
421
426
  const paint = () => {
422
- if (text) text.content = formatMs(liveProgress(props.player))
427
+ if (text) text.content = formatMs(liveProgress(props.source.current()))
423
428
  }
424
- createEffect(() => {
425
- void props.player
426
- paint()
427
- })
428
429
  onMount(() => {
429
- paint()
430
+ const unsubscribe = props.source.subscribe(() => paint())
430
431
  const timer = setInterval(() => {
431
- if (props.player?.is_playing) paint()
432
+ if (props.source.current()?.is_playing) paint()
432
433
  }, 1_000)
433
- onCleanup(() => clearInterval(timer))
434
+ onCleanup(() => {
435
+ unsubscribe()
436
+ clearInterval(timer)
437
+ })
434
438
  })
435
439
  return (
436
440
  <text ref={(element) => (text = element)} fg={props.theme.text.subdued}>
437
- {formatMs(liveProgress(props.player))}
441
+ {formatMs(liveProgress(props.source.current()))}
438
442
  </text>
439
443
  )
440
444
  }
@@ -510,12 +514,45 @@ function IconBtn(props: {
510
514
  export function SidebarPlayer(props: {
511
515
  context: Context
512
516
  state: UiState
517
+ subscribe?: UiStateSubscribe
513
518
  onPlayPause: () => void
514
519
  onNext: () => void
515
520
  onPrev: () => void
516
521
  onSeek: (positionMs: number) => void
517
522
  }) {
518
523
  const theme = () => props.context.theme
524
+ let latestPlayer = props.state.player
525
+ const presentationListeners = new Set<(player: UiState["player"]) => void>()
526
+ const publishPlayer = (player: UiState["player"]) => {
527
+ latestPlayer = player
528
+ for (const listener of [...presentationListeners]) {
529
+ try {
530
+ listener(player)
531
+ } catch {
532
+ // One presentation widget cannot block its siblings.
533
+ }
534
+ }
535
+ }
536
+ const source: PlayerPresentationSource = {
537
+ current: () => latestPlayer,
538
+ subscribe(listener) {
539
+ presentationListeners.add(listener)
540
+ try {
541
+ listener(latestPlayer)
542
+ } catch {
543
+ // Initial presentation failure remains isolated to this widget.
544
+ }
545
+ return () => presentationListeners.delete(listener)
546
+ },
547
+ }
548
+ createEffect(() => publishPlayer(props.state.player))
549
+ const unsubscribeSession = props.subscribe?.((state) =>
550
+ publishPlayer(state.player),
551
+ )
552
+ onCleanup(() => {
553
+ unsubscribeSession?.()
554
+ presentationListeners.clear()
555
+ })
519
556
  const player = createMemo(() => props.state.player)
520
557
  const track = createMemo(() => player()?.track)
521
558
  const playing = createMemo(() => !!player()?.is_playing)
@@ -594,29 +631,20 @@ export function SidebarPlayer(props: {
594
631
  </Show>
595
632
 
596
633
  <box flexDirection="row" justifyContent="center" overflow="hidden">
597
- <Waveform
598
- theme={theme()}
599
- player={player()}
600
- trackIdentity={
601
- track() ? waveformSeedKey(track()!.name, track()!.id) : ""
602
- }
603
- bars={24}
604
- variant="hero"
605
- />
634
+ <Waveform theme={theme()} source={source} bars={24} variant="hero" />
606
635
  </box>
607
636
 
608
637
  <Show when={track() && duration() > 0}>
609
638
  <box flexDirection="column" gap={0} overflow="hidden">
610
639
  <ProgressBar
611
640
  theme={theme()}
612
- player={player()}
613
- duration={duration()}
641
+ source={source}
614
642
  width={24}
615
643
  accent={track()?.artwork?.accent}
616
644
  onSeek={props.onSeek}
617
645
  />
618
646
  <box flexDirection="row" justifyContent="space-between">
619
- <LiveElapsed theme={theme()} player={player()} />
647
+ <LiveElapsed theme={theme()} source={source} />
620
648
  <text fg={theme().text.subdued}>{formatMs(duration())}</text>
621
649
  </box>
622
650
  </box>
package/waveform.tsx CHANGED
@@ -5,21 +5,22 @@ import {
5
5
  isFlat,
6
6
  sameTrackIdentity,
7
7
  stepEngine,
8
+ waveformSeedKey,
8
9
  type WaveEngine,
9
10
  } from "@naxodev/music-core"
10
- import {
11
- For,
12
- createEffect,
13
- createMemo,
14
- createSignal,
15
- onCleanup,
16
- } from "solid-js"
11
+ import { onCleanup, onMount } from "solid-js"
12
+ import { StyledText, fg, type TextRenderable } from "@opentui/core"
17
13
  import type { Plugin } from "@opencode-ai/plugin/tui"
18
14
  import type { PlayerState } from "./types.ts"
19
15
 
20
16
  type Context = Plugin.Context
21
17
  type Theme = Context["theme"]
22
18
 
19
+ export type PlayerPresentationSource = {
20
+ current: () => PlayerState | null
21
+ subscribe: (listener: (player: PlayerState | null) => void) => () => void
22
+ }
23
+
23
24
  export type WaveformScheduler = {
24
25
  setInterval: (callback: () => void, ms: number) => unknown
25
26
  clearInterval: (timer: unknown) => void
@@ -149,18 +150,26 @@ function blockChar(level: number): string {
149
150
 
150
151
  export function Waveform(props: {
151
152
  theme: Theme
152
- player: PlayerState | null
153
- trackIdentity: string
153
+ source: PlayerPresentationSource
154
154
  bars?: number
155
155
  /** Kept for API compatibility; both variants render one clean row. */
156
156
  variant?: "mini" | "hero"
157
157
  rows?: number
158
158
  }) {
159
159
  const barCount = () => props.bars ?? (props.variant === "hero" ? 48 : 16)
160
- const [rendered, setRendered] = createSignal<{
161
- player: PlayerState
162
- engine: WaveEngine
163
- } | null>(null)
160
+ let text: TextRenderable | undefined
161
+ const paint = (player: PlayerState, engine: WaveEngine) => {
162
+ if (!text) return
163
+ const playing = player.is_playing
164
+ text.content = new StyledText(
165
+ Array.from({ length: barCount() }, (_, index) => {
166
+ const level = displayLevel(engine.levels[index] ?? 0, index, playing)
167
+ return fg(blueFor(level, playing || level > 0.05, props.theme))(
168
+ blockChar(level),
169
+ )
170
+ }),
171
+ )
172
+ }
164
173
  const coordinator = createWaveformCoordinator({
165
174
  now: () => Date.now(),
166
175
  scheduler: {
@@ -169,37 +178,28 @@ export function Waveform(props: {
169
178
  clearInterval(timer as ReturnType<typeof setInterval>),
170
179
  },
171
180
  intervalMs: props.variant === "hero" ? 48 : 64,
172
- render: (player, engine) => setRendered({ player, engine }),
173
- clear: () => setRendered(null),
181
+ render: paint,
182
+ clear: () => {
183
+ if (text) text.content = " ".repeat(barCount())
184
+ },
174
185
  })
175
186
 
176
- createEffect(() => {
177
- coordinator.setInput(props.player, props.trackIdentity, barCount())
187
+ const update = (player: PlayerState | null) => {
188
+ coordinator.setInput(
189
+ player,
190
+ player?.track ? waveformSeedKey(player.track.name, player.track.id) : "",
191
+ barCount(),
192
+ )
178
193
  coordinator.frame()
179
- })
180
- onCleanup(() => coordinator.dispose())
181
-
182
- const cells = createMemo(() => {
183
- const current = rendered()
184
- const n = barCount()
185
- const levels = current?.engine.levels ?? new Float64Array(n)
186
- const playing = current?.player.is_playing ?? false
187
- const out: { ch: string; fg: ReturnType<typeof blueFor> }[] = []
188
- for (let i = 0; i < n; i++) {
189
- const level = displayLevel(levels[i] ?? 0, i, playing)
190
- out.push({
191
- ch: blockChar(level),
192
- fg: blueFor(level, playing || level > 0.05, props.theme),
193
- })
194
- }
195
- return out
194
+ }
195
+ const unsubscribe = props.source.subscribe(update)
196
+ onMount(() => coordinator.frame())
197
+ onCleanup(() => {
198
+ unsubscribe()
199
+ coordinator.dispose()
196
200
  })
197
201
 
198
202
  return (
199
- <text>
200
- <For each={cells()}>
201
- {(cell) => <span style={{ fg: cell.fg }}>{cell.ch}</span>}
202
- </For>
203
- </text>
203
+ <text ref={(element) => (text = element)}>{" ".repeat(barCount())}</text>
204
204
  )
205
205
  }