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