@naxodev/opencode-music-player 0.2.1 → 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/index.tsx +20 -1
- package/package.json +1 -1
- package/ui.tsx +66 -38
- 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,
|
|
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
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
|
-
|
|
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
|
-
|
|
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.
|
|
366
|
+
if (props.source.current()?.is_playing) paint()
|
|
368
367
|
}, 1_000)
|
|
369
|
-
onCleanup(() =>
|
|
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.
|
|
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: {
|
|
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.
|
|
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.
|
|
432
|
+
if (props.source.current()?.is_playing) paint()
|
|
432
433
|
}, 1_000)
|
|
433
|
-
onCleanup(() =>
|
|
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.
|
|
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
|
-
|
|
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()}
|
|
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
|
-
|
|
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
|
}
|