@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 +2 -2
- package/artwork.tsx +42 -17
- package/index.tsx +20 -21
- package/package.json +5 -5
- package/ui.tsx +114 -36
- package/waveform.tsx +39 -39
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-
|
|
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-
|
|
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
|
-
|
|
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(
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
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 (
|
|
136
|
-
|
|
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
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
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,
|
|
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.
|
|
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-
|
|
72
|
-
"@opentui/core": "0.5.
|
|
73
|
-
"@opentui/solid": "0.5.
|
|
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-
|
|
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
|
-
|
|
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 =
|
|
334
|
-
const
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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:
|
|
173
|
-
clear: () =>
|
|
181
|
+
render: paint,
|
|
182
|
+
clear: () => {
|
|
183
|
+
if (text) text.content = " ".repeat(barCount())
|
|
184
|
+
},
|
|
174
185
|
})
|
|
175
186
|
|
|
176
|
-
|
|
177
|
-
coordinator.setInput(
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
}
|