@naxodev/opencode-music-player 0.2.0 → 0.2.2

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
@@ -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: {
@@ -95,10 +96,20 @@ export function createController(
95
96
  loadingOwners: [],
96
97
  error: null,
97
98
  player: null,
98
- clock_ms: Date.now(),
99
99
  },
100
100
  },
101
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
+ }
102
113
  const loadingOwner = crypto.randomUUID()
103
114
  const media = dependencies.createSessionMedia()
104
115
  let disposed = false
@@ -119,18 +130,6 @@ export function createController(
119
130
  let latestSeek: SeekIntent | null = null
120
131
  const pending = new Set<() => void>()
121
132
  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
133
 
135
134
  const publishError = () => {
136
135
  if (!isActive()) return
@@ -240,9 +239,7 @@ export function createController(
240
239
  return
241
240
  setSession((draft) => {
242
241
  draft.player = player
243
- draft.clock_ms = Date.now()
244
242
  })
245
- syncClock()
246
243
  } catch (error) {
247
244
  if (isActive() && generation === lifecycleGeneration)
248
245
  setTransportError(errMsg(error))
@@ -259,7 +256,6 @@ export function createController(
259
256
  setSession((draft) => {
260
257
  draft.player = optimisticPlayerState(draft.player, nextPlaying)
261
258
  })
262
- syncClock()
263
259
  },
264
260
  ).finally(() => {
265
261
  playbackOperations--
@@ -292,9 +288,7 @@ export function createController(
292
288
  snapshotEpoch++
293
289
  setSession((draft) => {
294
290
  draft.player = mergePlayerSnapshot(draft.player, event.state)
295
- draft.clock_ms = Date.now()
296
291
  })
297
- syncClock()
298
292
  return
299
293
  }
300
294
  if (event?.type === "lifecycle") {
@@ -319,6 +313,11 @@ export function createController(
319
313
 
320
314
  return {
321
315
  session,
316
+ subscribe(listener) {
317
+ sessionListeners.add(listener)
318
+ listener(session)
319
+ return () => sessionListeners.delete(listener)
320
+ },
322
321
  refreshAll,
323
322
  async openApp() {
324
323
  if (!isActive()) return
@@ -350,9 +349,9 @@ export function createController(
350
349
  activeSeek?.resolves.splice(0).forEach((resolve) => resolve())
351
350
  activeSeek = null
352
351
  for (const resolve of [...pending]) settle(resolve)
353
- if (clockTimer) clearInterval(clockTimer)
354
352
  void media.dispose().catch(() => {})
355
353
  setLoadingOwner(false)
354
+ sessionListeners.clear()
356
355
  },
357
356
  }
358
357
  }
@@ -438,7 +437,6 @@ export function createMusicPlayerPlugin(options?: {
438
437
  void ctrl.session.loading
439
438
  void ctrl.session.error
440
439
  void ctrl.session.player
441
- void ctrl.session.clock_ms
442
440
  }
443
441
  const unsubApp = context.ui.slot({
444
442
  append: "session.composer.top",
@@ -455,6 +453,7 @@ export function createMusicPlayerPlugin(options?: {
455
453
  <SidebarPlayer
456
454
  context={context}
457
455
  state={ctrl.session}
456
+ subscribe={ctrl.subscribe}
458
457
  onPlayPause={() => void ctrl.playPause()}
459
458
  onNext={() => void ctrl.next()}
460
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.0",
3
+ "version": "0.2.2",
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,27 +5,28 @@ 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
- import { waveformSeedKey } from "@naxodev/music-core"
16
17
  import { formatMs } from "./types.ts"
17
18
  import { AlbumArtwork } from "./artwork.tsx"
18
- import { Waveform } from "./waveform.tsx"
19
+ import { Waveform, type PlayerPresentationSource } from "./waveform.tsx"
19
20
 
20
21
  export type UiState = {
21
22
  loading: boolean
22
23
  error: string | null
23
24
  player: import("./types.ts").PlayerState | null
24
- clock_ms?: number
25
25
  }
26
26
 
27
27
  type Theme = Context["theme"]
28
28
  type Context = Plugin.Context
29
+ type UiStateSubscribe = (listener: (state: UiState) => void) => () => void
29
30
 
30
31
  /** Text presentation keeps media symbols single-cell on emoji fonts. */
31
32
  const t = (s: string) => s + "\uFE0E"
@@ -320,30 +321,54 @@ 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
326
- duration: number
338
+ source: PlayerPresentationSource
327
339
  width?: number
328
340
  accent?: string | undefined
329
341
  onSeek: (positionMs: number) => void
330
342
  }) {
331
343
  let bar: BoxRenderable | undefined
344
+ let leftNode: TextNodeRenderable | undefined
345
+ let thumbNode: TextNodeRenderable | undefined
346
+ let rightNode: TextNodeRenderable | undefined
332
347
  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
- }
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
+ }
356
+ const paint = () => {
357
+ const next = rendered()
358
+ if (leftNode) leftNode.children = [next.left]
359
+ if (thumbNode) thumbNode.children = [next.thumb]
360
+ if (rightNode) rightNode.children = [next.right]
361
+ }
362
+
363
+ onMount(() => {
364
+ const unsubscribe = props.source.subscribe(() => paint())
365
+ const timer = setInterval(() => {
366
+ if (props.source.current()?.is_playing) paint()
367
+ }, 1_000)
368
+ onCleanup(() => {
369
+ unsubscribe()
370
+ clearInterval(timer)
371
+ })
347
372
  })
348
373
 
349
374
  return (
@@ -357,7 +382,7 @@ function ProgressBar(props: {
357
382
  const position = seekPositionForCell(
358
383
  event.x - bar.x,
359
384
  bar.width,
360
- props.duration,
385
+ props.source.current()?.track?.duration_ms ?? 0,
361
386
  )
362
387
  if (position === null) return
363
388
  event.preventDefault()
@@ -367,6 +392,7 @@ function ProgressBar(props: {
367
392
  >
368
393
  <text>
369
394
  <span
395
+ ref={(element) => (leftNode = element)}
370
396
  style={{
371
397
  fg: props.accent ?? props.theme.text.action.primary.default,
372
398
  }}
@@ -374,18 +400,49 @@ function ProgressBar(props: {
374
400
  {rendered().left}
375
401
  </span>
376
402
  <span
403
+ ref={(element) => (thumbNode = element)}
377
404
  style={{
378
405
  fg: props.accent ?? props.theme.text.action.primary.default,
379
406
  }}
380
407
  >
381
408
  {rendered().thumb}
382
409
  </span>
383
- <span style={{ fg: props.theme.text.subdued }}>{rendered().right}</span>
410
+ <span
411
+ ref={(element) => (rightNode = element)}
412
+ style={{ fg: props.theme.text.subdued }}
413
+ >
414
+ {rendered().right}
415
+ </span>
384
416
  </text>
385
417
  </box>
386
418
  )
387
419
  }
388
420
 
421
+ function LiveElapsed(props: {
422
+ theme: Theme
423
+ source: PlayerPresentationSource
424
+ }) {
425
+ let text: TextRenderable | undefined
426
+ const paint = () => {
427
+ if (text) text.content = formatMs(liveProgress(props.source.current()))
428
+ }
429
+ onMount(() => {
430
+ const unsubscribe = props.source.subscribe(() => paint())
431
+ const timer = setInterval(() => {
432
+ if (props.source.current()?.is_playing) paint()
433
+ }, 1_000)
434
+ onCleanup(() => {
435
+ unsubscribe()
436
+ clearInterval(timer)
437
+ })
438
+ })
439
+ return (
440
+ <text ref={(element) => (text = element)} fg={props.theme.text.subdued}>
441
+ {formatMs(liveProgress(props.source.current()))}
442
+ </text>
443
+ )
444
+ }
445
+
389
446
  /** Hover/press icon button — props read in JSX (Solid setup is once). */
390
447
  function IconBtn(props: {
391
448
  theme: Theme
@@ -457,19 +514,49 @@ function IconBtn(props: {
457
514
  export function SidebarPlayer(props: {
458
515
  context: Context
459
516
  state: UiState
517
+ subscribe?: UiStateSubscribe
460
518
  onPlayPause: () => void
461
519
  onNext: () => void
462
520
  onPrev: () => void
463
521
  onSeek: (positionMs: number) => void
464
522
  }) {
465
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
+ })
466
556
  const player = createMemo(() => props.state.player)
467
557
  const track = createMemo(() => player()?.track)
468
558
  const playing = createMemo(() => !!player()?.is_playing)
469
559
 
470
- const progress = createMemo(() =>
471
- liveProgress(player(), props.state.clock_ms),
472
- )
473
560
  const duration = createMemo(() => track()?.duration_ms ?? 0)
474
561
 
475
562
  return (
@@ -544,29 +631,20 @@ export function SidebarPlayer(props: {
544
631
  </Show>
545
632
 
546
633
  <box flexDirection="row" justifyContent="center" overflow="hidden">
547
- <Waveform
548
- theme={theme()}
549
- player={player()}
550
- trackIdentity={
551
- track() ? waveformSeedKey(track()!.name, track()!.id) : ""
552
- }
553
- bars={24}
554
- variant="hero"
555
- />
634
+ <Waveform theme={theme()} source={source} bars={24} variant="hero" />
556
635
  </box>
557
636
 
558
637
  <Show when={track() && duration() > 0}>
559
638
  <box flexDirection="column" gap={0} overflow="hidden">
560
639
  <ProgressBar
561
640
  theme={theme()}
562
- progress={progress()}
563
- duration={duration()}
641
+ source={source}
564
642
  width={24}
565
643
  accent={track()?.artwork?.accent}
566
644
  onSeek={props.onSeek}
567
645
  />
568
646
  <box flexDirection="row" justifyContent="space-between">
569
- <text fg={theme().text.subdued}>{formatMs(progress())}</text>
647
+ <LiveElapsed theme={theme()} source={source} />
570
648
  <text fg={theme().text.subdued}>{formatMs(duration())}</text>
571
649
  </box>
572
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
  }