@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/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/SUPPORT.md +7 -0
- package/artwork-placement.ts +169 -0
- package/artwork.ts +424 -0
- package/artwork.tsx +221 -0
- package/index.tsx +428 -0
- package/kitty-graphics.ts +90 -0
- package/package.json +80 -0
- package/system-media.ts +568 -0
- package/tmux-offset.ts +120 -0
- package/types.ts +115 -0
- package/ui.tsx +627 -0
- package/waveform.tsx +205 -0
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
|
+
}
|