@naxodev/opencode-music-player 0.2.6 → 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/README.md +13 -14
- package/SUPPORT.md +1 -1
- package/dist/index.js +2272 -0
- package/dist/tui.js +1 -0
- package/package.json +28 -20
- package/artwork-placement.ts +0 -169
- package/artwork.ts +0 -240
- package/artwork.tsx +0 -246
- package/index.tsx +0 -493
- package/kitty-graphics.ts +0 -105
- package/system-media.ts +0 -593
- package/tmux-offset.ts +0 -120
- package/tui.tsx +0 -1
- package/types.ts +0 -144
- package/ui.tsx +0 -695
- package/waveform.tsx +0 -205
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
|
-
}
|