@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/waveform.tsx ADDED
@@ -0,0 +1,205 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import {
3
+ createEngine,
4
+ displayLevel,
5
+ isFlat,
6
+ sameTrackIdentity,
7
+ stepEngine,
8
+ type WaveEngine,
9
+ } from "@naxodev/music-core"
10
+ import {
11
+ For,
12
+ createEffect,
13
+ createMemo,
14
+ createSignal,
15
+ onCleanup,
16
+ } from "solid-js"
17
+ import type { Plugin } from "@opencode-ai/plugin/tui"
18
+ import type { PlayerState } from "./types.ts"
19
+
20
+ type Context = Plugin.Context
21
+ type Theme = Context["theme"]
22
+
23
+ export type WaveformScheduler = {
24
+ setInterval: (callback: () => void, ms: number) => unknown
25
+ clearInterval: (timer: unknown) => void
26
+ }
27
+
28
+ type WaveformCoordinatorOptions = {
29
+ now: () => number
30
+ scheduler: WaveformScheduler
31
+ render: (player: PlayerState, engine: WaveEngine) => void
32
+ clear: () => void
33
+ intervalMs: number
34
+ }
35
+
36
+ /** OpenCode-owned engine and animation lifecycle, with injectable time and timers. */
37
+ export function createWaveformCoordinator(options: WaveformCoordinatorOptions) {
38
+ let player: PlayerState | null = null
39
+ let bars = 0
40
+ let requestedKey = ""
41
+ let engineKey = ""
42
+ let engine: WaveEngine | null = null
43
+ let timer: unknown = null
44
+
45
+ const stop = () => {
46
+ if (timer === null) return
47
+ options.scheduler.clearInterval(timer)
48
+ timer = null
49
+ }
50
+
51
+ const start = () => {
52
+ if (timer !== null || !player?.track) return
53
+ timer = options.scheduler.setInterval(() => frame(), options.intervalMs)
54
+ }
55
+
56
+ const frame = (seek = false) => {
57
+ const track = player?.track
58
+ if (!player || !track) {
59
+ stop()
60
+ return null
61
+ }
62
+
63
+ const key = engineKey || requestedKey
64
+ if (!engine || engine.n !== bars || engineKey !== key) {
65
+ engine = createEngine(bars, key)
66
+ engineKey = key
67
+ }
68
+ stepEngine(engine, {
69
+ track_key: key,
70
+ bars,
71
+ progress_ms: player.progress_ms,
72
+ fetched_at: player.fetched_at,
73
+ is_playing: player.is_playing,
74
+ duration_ms: track.duration_ms,
75
+ now_ms: options.now(),
76
+ seek,
77
+ })
78
+ options.render(player, engine)
79
+ if (player.is_playing || !isFlat(engine)) start()
80
+ else stop()
81
+ return engine
82
+ }
83
+
84
+ return {
85
+ setInput: (
86
+ next: PlayerState | null,
87
+ trackIdentity: string,
88
+ nextBars: number,
89
+ ) => {
90
+ if (!next?.track) {
91
+ player = null
92
+ engine = null
93
+ engineKey = ""
94
+ requestedKey = ""
95
+ bars = nextBars
96
+ stop()
97
+ options.clear()
98
+ return
99
+ }
100
+ if (
101
+ bars !== nextBars ||
102
+ (player?.track && !sameTrackIdentity(player.track, next.track))
103
+ ) {
104
+ engine = null
105
+ engineKey = ""
106
+ }
107
+ player = next
108
+ requestedKey = trackIdentity
109
+ bars = nextBars
110
+ },
111
+ frame,
112
+ start,
113
+ stop,
114
+ dispose: () => {
115
+ stop()
116
+ player = null
117
+ engine = null
118
+ engineKey = ""
119
+ requestedKey = ""
120
+ },
121
+ }
122
+ }
123
+
124
+ const BLOCK = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const
125
+
126
+ /** Tokyonight blue ramp — low energy → peak (no rainbow). */
127
+ const BLUE = [
128
+ "#1a1b26", // void
129
+ "#24283b", // surface
130
+ "#3d59a1", // deep
131
+ "#414868", // comment
132
+ "#565f89", // dark blue-gray
133
+ "#7aa2f7", // blue
134
+ "#89b4fa", // soft blue
135
+ "#b4c0f7", // pale
136
+ "#c0caf5", // foreground flash
137
+ ] as const
138
+
139
+ function blueFor(level: number, playing: boolean, theme: Theme) {
140
+ if (!playing && level < 0.05) return theme.text.subdued
141
+ if (level <= 0.02) return BLUE[1]!
142
+ const idx = Math.min(BLUE.length - 1, Math.floor(level * (BLUE.length - 1)))
143
+ return BLUE[Math.max(2, idx)]!
144
+ }
145
+
146
+ function blockChar(level: number): string {
147
+ return BLOCK[Math.max(0, Math.min(8, Math.round(level * 8)))]!
148
+ }
149
+
150
+ export function Waveform(props: {
151
+ theme: Theme
152
+ player: PlayerState | null
153
+ trackIdentity: string
154
+ bars?: number
155
+ /** Kept for API compatibility; both variants render one clean row. */
156
+ variant?: "mini" | "hero"
157
+ rows?: number
158
+ }) {
159
+ const barCount = () => props.bars ?? (props.variant === "hero" ? 48 : 16)
160
+ const [rendered, setRendered] = createSignal<{
161
+ player: PlayerState
162
+ engine: WaveEngine
163
+ } | null>(null)
164
+ const coordinator = createWaveformCoordinator({
165
+ now: () => Date.now(),
166
+ scheduler: {
167
+ setInterval,
168
+ clearInterval: (timer) =>
169
+ clearInterval(timer as ReturnType<typeof setInterval>),
170
+ },
171
+ intervalMs: props.variant === "hero" ? 48 : 64,
172
+ render: (player, engine) => setRendered({ player, engine }),
173
+ clear: () => setRendered(null),
174
+ })
175
+
176
+ createEffect(() => {
177
+ coordinator.setInput(props.player, props.trackIdentity, barCount())
178
+ 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
196
+ })
197
+
198
+ return (
199
+ <text>
200
+ <For each={cells()}>
201
+ {(cell) => <span style={{ fg: cell.fg }}>{cell.ch}</span>}
202
+ </For>
203
+ </text>
204
+ )
205
+ }