@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/ui.tsx ADDED
@@ -0,0 +1,627 @@
1
+ /** @jsxImportSource @opentui/solid */
2
+ import {
3
+ Show,
4
+ createEffect,
5
+ createMemo,
6
+ createSignal,
7
+ onCleanup,
8
+ onMount,
9
+ } from "solid-js"
10
+ import type { Plugin } from "@opencode-ai/plugin/tui"
11
+ import {
12
+ MouseButton,
13
+ type BoxRenderable,
14
+ type TextRenderable,
15
+ } from "@opentui/core"
16
+ import { waveformSeedKey } from "@naxodev/music-core"
17
+ import { formatMs } from "./types.ts"
18
+ import { AlbumArtwork } from "./artwork.tsx"
19
+ import { Waveform } from "./waveform.tsx"
20
+
21
+ export type UiState = {
22
+ loading: boolean
23
+ error: string | null
24
+ player: import("./types.ts").PlayerState | null
25
+ }
26
+
27
+ type Theme = Context["theme"]
28
+ type Context = Plugin.Context
29
+
30
+ /** Text presentation keeps media symbols single-cell on emoji fonts. */
31
+ const t = (s: string) => s + "\uFE0E"
32
+ const Icon = {
33
+ prev: t("⏮"),
34
+ next: t("⏭"),
35
+ play: t("▶"),
36
+ pause: t("⏸"),
37
+ scrub: "●",
38
+ } as const
39
+
40
+ /** Matches artwork / waveform / progress content width. */
41
+ export const TRANSPORT_CONTENT_WIDTH = 24
42
+
43
+ /** Pre-change baseline — documented for tests; do not use as live sizes. */
44
+ export const TRANSPORT_BASELINE = {
45
+ prevWidth: 5,
46
+ playWidth: 7,
47
+ nextWidth: 5,
48
+ height: 1,
49
+ gap: 1,
50
+ } as const
51
+
52
+ export const TRANSPORT_LAYOUT = {
53
+ prevWidth: 6,
54
+ playWidth: 10,
55
+ nextWidth: 6,
56
+ /** Shared enlarged hit height for all three controls. */
57
+ height: 2,
58
+ gap: 1,
59
+ } as const
60
+
61
+ export function transportRowWidth(
62
+ layout: {
63
+ prevWidth: number
64
+ playWidth: number
65
+ nextWidth: number
66
+ gap: number
67
+ } = TRANSPORT_LAYOUT,
68
+ ): number {
69
+ return (
70
+ layout.prevWidth +
71
+ layout.gap +
72
+ layout.playWidth +
73
+ layout.gap +
74
+ layout.nextWidth
75
+ )
76
+ }
77
+
78
+ export const COMPACT_MARKER_WIDTH = 1
79
+ export const COMPACT_TITLE_SEPARATOR = " "
80
+ export const COMPACT_SEPARATOR = " - "
81
+ export const COMPACT_SEEK_FRACTION = 0.8
82
+
83
+ export function compactSeekRegionWidth(width: number): number {
84
+ if (!Number.isFinite(width) || width <= 0) return 0
85
+ const available = Math.floor(width)
86
+ return Math.max(
87
+ 0,
88
+ Math.min(available, Math.round(available * COMPACT_SEEK_FRACTION)),
89
+ )
90
+ }
91
+
92
+ export function seekPositionForCell(
93
+ cell: number,
94
+ width: number,
95
+ durationMs: number,
96
+ ): number | null {
97
+ if (
98
+ !Number.isFinite(width) ||
99
+ width <= 0 ||
100
+ !Number.isFinite(durationMs) ||
101
+ durationMs <= 0
102
+ ) {
103
+ return null
104
+ }
105
+ const lastCell = Math.max(0, Math.floor(width) - 1)
106
+ const clampedCell = Math.max(0, Math.min(lastCell, Math.floor(cell)))
107
+ const ratio = lastCell === 0 ? 0 : clampedCell / lastCell
108
+ return Math.round(ratio * durationMs)
109
+ }
110
+
111
+ export const COMPACT_BUDGETS = {
112
+ wide: {
113
+ minWidth: 55,
114
+ padding: 1,
115
+ titleWidth: 28,
116
+ artistWidth: 20,
117
+ },
118
+ medium: {
119
+ minWidth: 32,
120
+ padding: 1,
121
+ titleWidth: 28,
122
+ artistWidth: 0,
123
+ },
124
+ narrow: {
125
+ minWidth: 6,
126
+ padding: 1,
127
+ titleWidth: 2,
128
+ artistWidth: 0,
129
+ },
130
+ markerOnly: {
131
+ minWidth: 1,
132
+ padding: 0,
133
+ titleWidth: 0,
134
+ artistWidth: 0,
135
+ },
136
+ } as const
137
+
138
+ export type CompactTier = keyof typeof COMPACT_BUDGETS
139
+
140
+ export type CompactPresentation = {
141
+ tier: CompactTier
142
+ marker: string
143
+ padding: number
144
+ title: string | null
145
+ artist: string | null
146
+ }
147
+
148
+ function sanitize(value: string, fallback = ""): string {
149
+ const clean = value
150
+ .normalize("NFC")
151
+ .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
152
+ .replace(/\s+/g, " ")
153
+ .trim()
154
+ return clean || fallback
155
+ }
156
+
157
+ function truncate(value: string, width: number): string {
158
+ if (Bun.stringWidth(value) <= width) return value
159
+ if (width <= 1) return "…"
160
+
161
+ let result = ""
162
+ for (const character of value) {
163
+ if (Bun.stringWidth(result + character) > width - 1) break
164
+ result += character
165
+ }
166
+ return `${result}…`
167
+ }
168
+
169
+ /** Allocate the compact row from its actual parent width, not renderer width. */
170
+ export function compactPresentation(
171
+ width: number,
172
+ title: string,
173
+ artist: string,
174
+ playing: boolean,
175
+ ): CompactPresentation {
176
+ const available = Math.max(1, Math.floor(width))
177
+ const marker = playing ? Icon.pause : Icon.play
178
+ const cleanTitle = sanitize(title, "Unknown track")
179
+ const cleanArtist = sanitize(artist)
180
+
181
+ const tier: CompactTier =
182
+ available >= COMPACT_BUDGETS.wide.minWidth
183
+ ? "wide"
184
+ : available >= COMPACT_BUDGETS.medium.minWidth
185
+ ? "medium"
186
+ : available >= COMPACT_BUDGETS.narrow.minWidth
187
+ ? "narrow"
188
+ : "markerOnly"
189
+ const budget = COMPACT_BUDGETS[tier]
190
+ if (tier === "markerOnly") {
191
+ return { tier, marker, padding: budget.padding, title: null, artist: null }
192
+ }
193
+
194
+ const fixedWidth =
195
+ COMPACT_MARKER_WIDTH + COMPACT_TITLE_SEPARATOR.length + budget.padding * 2
196
+ const titleWidth =
197
+ tier === "narrow" ? available - fixedWidth : budget.titleWidth
198
+ return {
199
+ tier,
200
+ marker,
201
+ padding: budget.padding,
202
+ title: truncate(cleanTitle, titleWidth),
203
+ artist:
204
+ tier === "wide" && cleanArtist
205
+ ? truncate(cleanArtist, budget.artistWidth)
206
+ : null,
207
+ }
208
+ }
209
+
210
+ export function CompactPlayer(props: {
211
+ context: Context
212
+ state: UiState
213
+ onPlayPause: () => void
214
+ onSeek: (positionMs: number) => void
215
+ }) {
216
+ const theme = () => props.context.theme
217
+ let row: BoxRenderable | undefined
218
+ let content: TextRenderable | undefined
219
+ let allocatedWidth = Number.MAX_SAFE_INTEGER
220
+ const track = createMemo(() => props.state.player?.track)
221
+ const renderLine = () => {
222
+ const current = track()
223
+ if (!current) return ""
224
+ const display = compactPresentation(
225
+ allocatedWidth,
226
+ current.name,
227
+ current.artists,
228
+ !!props.state.player?.is_playing,
229
+ )
230
+ const padding = " ".repeat(display.padding)
231
+ return `${padding}${display.marker}${
232
+ display.title ? `${COMPACT_TITLE_SEPARATOR}${display.title}` : ""
233
+ }${display.artist ? `${COMPACT_SEPARATOR}${display.artist}` : ""}${padding}`
234
+ }
235
+ const updateContent = () => {
236
+ const line = renderLine()
237
+ if (content) content.content = line
238
+ }
239
+ const updateWidth = function (this: BoxRenderable) {
240
+ allocatedWidth = Math.max(1, this.width)
241
+ updateContent()
242
+ }
243
+ createEffect(updateContent)
244
+ onCleanup(() => {
245
+ if (row?.onSizeChange === updateWidth) row.onSizeChange = undefined
246
+ })
247
+
248
+ return (
249
+ <Show when={track()}>
250
+ {(_track) => (
251
+ <box
252
+ id="music-compact-player"
253
+ ref={(element) => {
254
+ row = element
255
+ element.onSizeChange = updateWidth
256
+ }}
257
+ width="100%"
258
+ alignSelf="stretch"
259
+ minWidth={0}
260
+ height={1}
261
+ flexDirection="row"
262
+ flexShrink={0}
263
+ overflow="hidden"
264
+ onMouseDown={(event) => {
265
+ if (event.button !== MouseButton.LEFT) return
266
+ const current = props.state.player?.track
267
+ if (!current || !row) return
268
+
269
+ const seekWidth = compactSeekRegionWidth(row.width)
270
+ const cell = Math.floor(event.x - row.x)
271
+ const markerCell = compactPresentation(
272
+ row.width,
273
+ current.name,
274
+ current.artists,
275
+ !!props.state.player?.is_playing,
276
+ ).padding
277
+ if (cell === markerCell) {
278
+ event.preventDefault()
279
+ event.stopPropagation()
280
+ props.onPlayPause()
281
+ return
282
+ }
283
+ if (cell < 0 || cell >= seekWidth) return
284
+
285
+ const position = seekPositionForCell(
286
+ cell,
287
+ seekWidth,
288
+ current.duration_ms,
289
+ )
290
+ if (position === null) return
291
+
292
+ event.preventDefault()
293
+ event.stopPropagation()
294
+ props.onSeek(position)
295
+ }}
296
+ >
297
+ <text
298
+ ref={(element) => {
299
+ content = element
300
+ updateContent()
301
+ }}
302
+ wrapMode="none"
303
+ truncate
304
+ fg={theme().text.action.primary.default}
305
+ >
306
+ {renderLine()}
307
+ </text>
308
+ </box>
309
+ )}
310
+ </Show>
311
+ )
312
+ }
313
+
314
+ function liveProgress(player: UiState["player"]): number {
315
+ if (!player?.track) return 0
316
+ if (!player.is_playing) return player.progress_ms
317
+ return Math.min(
318
+ player.track.duration_ms,
319
+ player.progress_ms + (Date.now() - player.fetched_at),
320
+ )
321
+ }
322
+
323
+ function ProgressBar(props: {
324
+ theme: Theme
325
+ progress: number
326
+ duration: number
327
+ width?: number
328
+ accent?: string | undefined
329
+ onSeek: (positionMs: number) => void
330
+ }) {
331
+ let bar: BoxRenderable | undefined
332
+ 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
+ }
347
+ })
348
+
349
+ return (
350
+ <box
351
+ id="music-sidebar-seek"
352
+ ref={(element) => (bar = element)}
353
+ width={width()}
354
+ height={1}
355
+ onMouseDown={(event) => {
356
+ if (event.button !== MouseButton.LEFT || !bar) return
357
+ const position = seekPositionForCell(
358
+ event.x - bar.x,
359
+ bar.width,
360
+ props.duration,
361
+ )
362
+ if (position === null) return
363
+ event.preventDefault()
364
+ event.stopPropagation()
365
+ props.onSeek(position)
366
+ }}
367
+ >
368
+ <text>
369
+ <span
370
+ style={{
371
+ fg: props.accent ?? props.theme.text.action.primary.default,
372
+ }}
373
+ >
374
+ {rendered().left}
375
+ </span>
376
+ <span
377
+ style={{
378
+ fg: props.accent ?? props.theme.text.action.primary.default,
379
+ }}
380
+ >
381
+ {rendered().thumb}
382
+ </span>
383
+ <span style={{ fg: props.theme.text.subdued }}>{rendered().right}</span>
384
+ </text>
385
+ </box>
386
+ )
387
+ }
388
+
389
+ /** Hover/press icon button — props read in JSX (Solid setup is once). */
390
+ function IconBtn(props: {
391
+ theme: Theme
392
+ icon: string
393
+ /** Accessible / tooltip-ish label shown nowhere — reserved */
394
+ title?: string
395
+ /** Emphasized play control */
396
+ primary?: boolean
397
+ /** Currently “on” (playing) */
398
+ active?: boolean
399
+ accent?: string | undefined
400
+ /** Fixed outer width in cells */
401
+ width?: number
402
+ /** Fixed outer height in cells */
403
+ height?: number
404
+ onClick: () => void
405
+ }) {
406
+ const [hover, setHover] = createSignal(false)
407
+ const [press, setPress] = createSignal(false)
408
+
409
+ const bg = () => {
410
+ if (press() || props.active) {
411
+ return props.theme.background.action.primary.default
412
+ }
413
+ // Idle + hover share offset so the cluster reads as one control strip
414
+ return props.theme.background.surface.offset
415
+ }
416
+
417
+ const fg = () => {
418
+ if (press() || props.active)
419
+ return props.accent ?? props.theme.text.action.primary.default
420
+ if (props.primary) {
421
+ return hover()
422
+ ? props.theme.text.action.primary.default
423
+ : props.theme.text.default
424
+ }
425
+ return hover()
426
+ ? props.theme.text.action.primary.default
427
+ : props.theme.text.default
428
+ }
429
+
430
+ const w = () => props.width ?? 3
431
+ const h = () => props.height ?? 1
432
+
433
+ return (
434
+ <box
435
+ onMouseDown={() => {
436
+ setPress(true)
437
+ props.onClick()
438
+ }}
439
+ onMouseUp={() => setPress(false)}
440
+ onMouseOver={() => setHover(true)}
441
+ onMouseOut={() => {
442
+ setHover(false)
443
+ setPress(false)
444
+ }}
445
+ flexShrink={0}
446
+ width={w()}
447
+ height={h()}
448
+ alignItems="center"
449
+ justifyContent="center"
450
+ backgroundColor={bg()}
451
+ >
452
+ <text fg={fg()}>{props.icon}</text>
453
+ </box>
454
+ )
455
+ }
456
+
457
+ export function SidebarPlayer(props: {
458
+ context: Context
459
+ state: UiState
460
+ onPlayPause: () => void
461
+ onNext: () => void
462
+ onPrev: () => void
463
+ onSeek: (positionMs: number) => void
464
+ }) {
465
+ const theme = () => props.context.theme
466
+ const player = createMemo(() => props.state.player)
467
+ const track = createMemo(() => player()?.track)
468
+ const playing = createMemo(() => !!player()?.is_playing)
469
+
470
+ const [tick, setTick] = createSignal(0)
471
+ onMount(() => {
472
+ let id: ReturnType<typeof setInterval> | null = null
473
+ const sync = () => {
474
+ const on = !!props.state.player?.is_playing
475
+ if (on && !id) id = setInterval(() => setTick((t) => t + 1), 1000)
476
+ else if (!on && id) {
477
+ clearInterval(id)
478
+ id = null
479
+ }
480
+ }
481
+ sync()
482
+ const watch = setInterval(sync, 400)
483
+ onCleanup(() => {
484
+ if (id) clearInterval(id)
485
+ clearInterval(watch)
486
+ })
487
+ })
488
+
489
+ const progress = createMemo(() => {
490
+ tick()
491
+ return liveProgress(player())
492
+ })
493
+ const duration = createMemo(() => track()?.duration_ms ?? 0)
494
+
495
+ return (
496
+ <box
497
+ flexDirection="column"
498
+ border={["top"]}
499
+ borderColor={
500
+ playing()
501
+ ? (track()?.artwork?.accent ?? theme().text.action.primary.default)
502
+ : theme().border.default
503
+ }
504
+ paddingLeft={1}
505
+ paddingRight={1}
506
+ paddingTop={1}
507
+ paddingBottom={1}
508
+ gap={1}
509
+ flexShrink={0}
510
+ >
511
+ <Show when={props.state.error}>
512
+ {(err) => <text fg={theme().text.feedback.error.default}>{err()}</text>}
513
+ </Show>
514
+
515
+ <box flexDirection="row" justifyContent="center">
516
+ <box width={24} height={12} overflow="hidden">
517
+ <Show
518
+ when={track()?.artwork}
519
+ fallback={
520
+ <box
521
+ width={24}
522
+ height={12}
523
+ alignItems="center"
524
+ justifyContent="center"
525
+ >
526
+ <text fg={theme().text.subdued}>
527
+ {track()
528
+ ? track()?.artwork_loading
529
+ ? "Loading artwork…"
530
+ : "Artwork unavailable"
531
+ : ""}
532
+ </text>
533
+ </box>
534
+ }
535
+ >
536
+ {(artwork) => (
537
+ <AlbumArtwork context={props.context} artwork={artwork()} />
538
+ )}
539
+ </Show>
540
+ </box>
541
+ </box>
542
+
543
+ <Show
544
+ when={track()}
545
+ fallback={
546
+ <text fg={theme().text.subdued}>
547
+ {props.state.loading ? "Syncing…" : "Nothing playing"}
548
+ </text>
549
+ }
550
+ >
551
+ {(t) => (
552
+ <box flexDirection="column" gap={0}>
553
+ <text fg={theme().text.default}>
554
+ <b>{t().name}</b>
555
+ </text>
556
+ <Show when={t().artists}>
557
+ <text fg={theme().text.subdued}>{t().artists}</text>
558
+ </Show>
559
+ <Show when={t().album}>
560
+ <text fg={theme().text.subdued}>{t().album}</text>
561
+ </Show>
562
+ </box>
563
+ )}
564
+ </Show>
565
+
566
+ <box flexDirection="row" justifyContent="center" overflow="hidden">
567
+ <Waveform
568
+ theme={theme()}
569
+ player={player()}
570
+ trackIdentity={
571
+ track() ? waveformSeedKey(track()!.name, track()!.id) : ""
572
+ }
573
+ bars={24}
574
+ variant="hero"
575
+ />
576
+ </box>
577
+
578
+ <Show when={track() && duration() > 0}>
579
+ <box flexDirection="column" gap={0} overflow="hidden">
580
+ <ProgressBar
581
+ theme={theme()}
582
+ progress={progress()}
583
+ duration={duration()}
584
+ width={24}
585
+ accent={track()?.artwork?.accent}
586
+ onSeek={props.onSeek}
587
+ />
588
+ <box flexDirection="row" justifyContent="space-between">
589
+ <text fg={theme().text.subdued}>{formatMs(progress())}</text>
590
+ <text fg={theme().text.subdued}>{formatMs(duration())}</text>
591
+ </box>
592
+ </box>
593
+ </Show>
594
+
595
+ <box
596
+ flexDirection="row"
597
+ gap={TRANSPORT_LAYOUT.gap}
598
+ justifyContent="center"
599
+ >
600
+ <IconBtn
601
+ theme={theme()}
602
+ icon={Icon.prev}
603
+ width={TRANSPORT_LAYOUT.prevWidth}
604
+ height={TRANSPORT_LAYOUT.height}
605
+ onClick={() => props.onPrev()}
606
+ />
607
+ <IconBtn
608
+ theme={theme()}
609
+ icon={playing() ? Icon.pause : Icon.play}
610
+ primary
611
+ active={playing()}
612
+ accent={track()?.artwork?.accent}
613
+ width={TRANSPORT_LAYOUT.playWidth}
614
+ height={TRANSPORT_LAYOUT.height}
615
+ onClick={() => props.onPlayPause()}
616
+ />
617
+ <IconBtn
618
+ theme={theme()}
619
+ icon={Icon.next}
620
+ width={TRANSPORT_LAYOUT.nextWidth}
621
+ height={TRANSPORT_LAYOUT.height}
622
+ onClick={() => props.onNext()}
623
+ />
624
+ </box>
625
+ </box>
626
+ )
627
+ }