@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/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
|
+
}
|