@naxodev/opencode-music-player 0.1.4 → 0.1.6
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 +74 -42
- package/package.json +1 -1
- package/types.ts +32 -0
- package/ui.tsx +13 -51
package/index.tsx
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/** @jsxImportSource @opentui/solid */
|
|
2
2
|
import { Plugin } from "@opencode-ai/plugin/tui"
|
|
3
3
|
import { createSessionSystemMedia, openNowPlayingApp } from "./system-media.ts"
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
isMac,
|
|
6
|
+
mergeArtworkCompletion,
|
|
7
|
+
mergePlayerSnapshot,
|
|
8
|
+
type SessionMedia,
|
|
9
|
+
} from "./types.ts"
|
|
5
10
|
import { CompactPlayer, SidebarPlayer, type UiState } from "./ui.tsx"
|
|
6
11
|
|
|
7
12
|
type Context = Plugin.Context
|
|
8
|
-
type SessionStore = UiState
|
|
13
|
+
type SessionStore = UiState & { loadingOwners?: string[] }
|
|
9
14
|
type SeekIntent = {
|
|
10
15
|
positionMs: number
|
|
11
16
|
resolves: Array<() => void>
|
|
@@ -13,7 +18,6 @@ type SeekIntent = {
|
|
|
13
18
|
|
|
14
19
|
export type Controller = {
|
|
15
20
|
session: SessionStore
|
|
16
|
-
subscribe: (listener: (session: SessionStore) => void) => () => void
|
|
17
21
|
openApp: () => Promise<void>
|
|
18
22
|
refreshAll: () => Promise<void>
|
|
19
23
|
playPause: () => Promise<void>
|
|
@@ -83,16 +87,19 @@ export function createController(
|
|
|
83
87
|
context: Context,
|
|
84
88
|
dependencies: ControllerDependencies = controllerDependencies,
|
|
85
89
|
): Controller {
|
|
86
|
-
const session
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
90
|
+
const [session, setSession] = context.storage.memory<SessionStore>(
|
|
91
|
+
"music-player.session.v6",
|
|
92
|
+
{
|
|
93
|
+
initial: {
|
|
94
|
+
loading: false,
|
|
95
|
+
loadingOwners: [],
|
|
96
|
+
error: null,
|
|
97
|
+
player: null,
|
|
98
|
+
clock_ms: Date.now(),
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
)
|
|
102
|
+
const loadingOwner = crypto.randomUUID()
|
|
96
103
|
const media = dependencies.createSessionMedia()
|
|
97
104
|
let disposed = false
|
|
98
105
|
let lifecycleGeneration = 0
|
|
@@ -111,8 +118,20 @@ export function createController(
|
|
|
111
118
|
let activeSeek: SeekIntent | null = null
|
|
112
119
|
let latestSeek: SeekIntent | null = null
|
|
113
120
|
const pending = new Set<() => void>()
|
|
114
|
-
|
|
115
121
|
const isActive = () => !disposed
|
|
122
|
+
let clockTimer: ReturnType<typeof setInterval> | null = null
|
|
123
|
+
const syncClock = () => {
|
|
124
|
+
if (clockTimer) clearInterval(clockTimer)
|
|
125
|
+
clockTimer = null
|
|
126
|
+
if (!isActive() || !session.player?.is_playing) return
|
|
127
|
+
clockTimer = setInterval(() => {
|
|
128
|
+
if (!isActive()) return
|
|
129
|
+
setSession((draft) => {
|
|
130
|
+
draft.clock_ms = Date.now()
|
|
131
|
+
})
|
|
132
|
+
}, 1_000)
|
|
133
|
+
}
|
|
134
|
+
|
|
116
135
|
const publishError = () => {
|
|
117
136
|
if (!isActive()) return
|
|
118
137
|
const message =
|
|
@@ -134,12 +153,18 @@ export function createController(
|
|
|
134
153
|
transportError = message
|
|
135
154
|
publishError()
|
|
136
155
|
}
|
|
137
|
-
const
|
|
138
|
-
if (!isActive()) return
|
|
156
|
+
const setLoadingOwner = (active: boolean) => {
|
|
139
157
|
setSession((draft) => {
|
|
140
|
-
|
|
158
|
+
const owners = new Set(draft.loadingOwners ?? [])
|
|
159
|
+
if (active) owners.add(loadingOwner)
|
|
160
|
+
else owners.delete(loadingOwner)
|
|
161
|
+
draft.loadingOwners = [...owners]
|
|
162
|
+
draft.loading = owners.size > 0
|
|
141
163
|
})
|
|
142
164
|
}
|
|
165
|
+
const updateLoading = () => {
|
|
166
|
+
if (isActive()) setLoadingOwner(pending.size > 0)
|
|
167
|
+
}
|
|
143
168
|
const settle = (resolve: () => void) => {
|
|
144
169
|
if (!pending.delete(resolve)) return
|
|
145
170
|
resolve()
|
|
@@ -215,7 +240,9 @@ export function createController(
|
|
|
215
240
|
return
|
|
216
241
|
setSession((draft) => {
|
|
217
242
|
draft.player = player
|
|
243
|
+
draft.clock_ms = Date.now()
|
|
218
244
|
})
|
|
245
|
+
syncClock()
|
|
219
246
|
} catch (error) {
|
|
220
247
|
if (isActive() && generation === lifecycleGeneration)
|
|
221
248
|
setTransportError(errMsg(error))
|
|
@@ -232,6 +259,7 @@ export function createController(
|
|
|
232
259
|
setSession((draft) => {
|
|
233
260
|
draft.player = optimisticPlayerState(draft.player, nextPlaying)
|
|
234
261
|
})
|
|
262
|
+
syncClock()
|
|
235
263
|
},
|
|
236
264
|
).finally(() => {
|
|
237
265
|
playbackOperations--
|
|
@@ -263,8 +291,10 @@ export function createController(
|
|
|
263
291
|
receivedSnapshot = true
|
|
264
292
|
snapshotEpoch++
|
|
265
293
|
setSession((draft) => {
|
|
266
|
-
draft.player = event.state
|
|
294
|
+
draft.player = mergePlayerSnapshot(draft.player, event.state)
|
|
295
|
+
draft.clock_ms = Date.now()
|
|
267
296
|
})
|
|
297
|
+
syncClock()
|
|
268
298
|
return
|
|
269
299
|
}
|
|
270
300
|
if (event?.type === "lifecycle") {
|
|
@@ -289,11 +319,6 @@ export function createController(
|
|
|
289
319
|
|
|
290
320
|
return {
|
|
291
321
|
session,
|
|
292
|
-
subscribe(listener) {
|
|
293
|
-
sessionListeners.add(listener)
|
|
294
|
-
listener(session)
|
|
295
|
-
return () => sessionListeners.delete(listener)
|
|
296
|
-
},
|
|
297
322
|
refreshAll,
|
|
298
323
|
async openApp() {
|
|
299
324
|
if (!isActive()) return
|
|
@@ -325,13 +350,9 @@ export function createController(
|
|
|
325
350
|
activeSeek?.resolves.splice(0).forEach((resolve) => resolve())
|
|
326
351
|
activeSeek = null
|
|
327
352
|
for (const resolve of [...pending]) settle(resolve)
|
|
353
|
+
if (clockTimer) clearInterval(clockTimer)
|
|
328
354
|
void media.dispose().catch(() => {})
|
|
329
|
-
|
|
330
|
-
setSession((draft) => {
|
|
331
|
-
draft.loading = false
|
|
332
|
-
})
|
|
333
|
-
}
|
|
334
|
-
sessionListeners.clear()
|
|
355
|
+
setLoadingOwner(false)
|
|
335
356
|
},
|
|
336
357
|
}
|
|
337
358
|
}
|
|
@@ -380,7 +401,6 @@ function AppHost(props: { context: Context; ctrl: Controller }) {
|
|
|
380
401
|
<CompactPlayer
|
|
381
402
|
context={context}
|
|
382
403
|
state={ctrl.session}
|
|
383
|
-
subscribe={ctrl.subscribe}
|
|
384
404
|
onPlayPause={() => void ctrl.playPause()}
|
|
385
405
|
onSeek={(positionMs) => void ctrl.seek(positionMs)}
|
|
386
406
|
/>
|
|
@@ -413,23 +433,35 @@ export function createMusicPlayerPlugin(options?: {
|
|
|
413
433
|
}
|
|
414
434
|
: controllerDependencies,
|
|
415
435
|
)
|
|
436
|
+
// Slot renderers must observe host-owned state before package components run.
|
|
437
|
+
const observeSession = () => {
|
|
438
|
+
void ctrl.session.loading
|
|
439
|
+
void ctrl.session.error
|
|
440
|
+
void ctrl.session.player
|
|
441
|
+
void ctrl.session.clock_ms
|
|
442
|
+
}
|
|
416
443
|
const unsubApp = context.ui.slot({
|
|
417
444
|
append: "session.composer.top",
|
|
418
|
-
render: () =>
|
|
445
|
+
render: () => {
|
|
446
|
+
observeSession()
|
|
447
|
+
return <AppHost context={context} ctrl={ctrl} />
|
|
448
|
+
},
|
|
419
449
|
})
|
|
420
450
|
const unsubSidebar = context.ui.slot({
|
|
421
451
|
append: "sidebar.content",
|
|
422
|
-
render: () =>
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
452
|
+
render: () => {
|
|
453
|
+
observeSession()
|
|
454
|
+
return (
|
|
455
|
+
<SidebarPlayer
|
|
456
|
+
context={context}
|
|
457
|
+
state={ctrl.session}
|
|
458
|
+
onPlayPause={() => void ctrl.playPause()}
|
|
459
|
+
onNext={() => void ctrl.next()}
|
|
460
|
+
onPrev={() => void ctrl.prev()}
|
|
461
|
+
onSeek={(positionMs) => void ctrl.seek(positionMs)}
|
|
462
|
+
/>
|
|
463
|
+
)
|
|
464
|
+
},
|
|
433
465
|
})
|
|
434
466
|
return () => {
|
|
435
467
|
unsubApp()
|
package/package.json
CHANGED
package/types.ts
CHANGED
|
@@ -113,3 +113,35 @@ export function mergeArtworkCompletion(
|
|
|
113
113
|
},
|
|
114
114
|
}
|
|
115
115
|
}
|
|
116
|
+
|
|
117
|
+
/** Keep host-local artwork presentation across playback-only snapshots. */
|
|
118
|
+
export function mergePlayerSnapshot(
|
|
119
|
+
player: PlayerState | null,
|
|
120
|
+
snapshot: PlayerState,
|
|
121
|
+
): PlayerState {
|
|
122
|
+
const current = player?.track
|
|
123
|
+
const next = snapshot.track
|
|
124
|
+
if (
|
|
125
|
+
!current ||
|
|
126
|
+
!next ||
|
|
127
|
+
current.name !== next.name ||
|
|
128
|
+
current.artists !== next.artists ||
|
|
129
|
+
current.album !== next.album ||
|
|
130
|
+
(current.duration_ms > 0 &&
|
|
131
|
+
next.duration_ms > 0 &&
|
|
132
|
+
current.duration_ms !== next.duration_ms) ||
|
|
133
|
+
(current.artwork === null && current.artwork_loading !== false)
|
|
134
|
+
) {
|
|
135
|
+
return snapshot
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
...snapshot,
|
|
139
|
+
track: {
|
|
140
|
+
...next,
|
|
141
|
+
artwork: next.artwork ?? current.artwork,
|
|
142
|
+
artwork_loading: false,
|
|
143
|
+
duration_ms:
|
|
144
|
+
next.duration_ms > 0 ? next.duration_ms : current.duration_ms,
|
|
145
|
+
},
|
|
146
|
+
}
|
|
147
|
+
}
|
package/ui.tsx
CHANGED
|
@@ -5,9 +5,7 @@ import {
|
|
|
5
5
|
createMemo,
|
|
6
6
|
createSignal,
|
|
7
7
|
onCleanup,
|
|
8
|
-
onMount,
|
|
9
8
|
} from "solid-js"
|
|
10
|
-
import { createStore, reconcile } from "solid-js/store"
|
|
11
9
|
import type { Plugin } from "@opencode-ai/plugin/tui"
|
|
12
10
|
import {
|
|
13
11
|
MouseButton,
|
|
@@ -23,19 +21,7 @@ export type UiState = {
|
|
|
23
21
|
loading: boolean
|
|
24
22
|
error: string | null
|
|
25
23
|
player: import("./types.ts").PlayerState | null
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
type UiStateSubscribe = (listener: (state: UiState) => void) => () => void
|
|
29
|
-
|
|
30
|
-
function reactiveUiState(
|
|
31
|
-
state: UiState,
|
|
32
|
-
subscribe: UiStateSubscribe | undefined,
|
|
33
|
-
): UiState {
|
|
34
|
-
if (!subscribe) return state
|
|
35
|
-
const [local, setLocal] = createStore<UiState>({ ...state })
|
|
36
|
-
const unsubscribe = subscribe((next) => setLocal(reconcile({ ...next })))
|
|
37
|
-
onCleanup(unsubscribe)
|
|
38
|
-
return local
|
|
24
|
+
clock_ms?: number
|
|
39
25
|
}
|
|
40
26
|
|
|
41
27
|
type Theme = Context["theme"]
|
|
@@ -224,16 +210,14 @@ export function compactPresentation(
|
|
|
224
210
|
export function CompactPlayer(props: {
|
|
225
211
|
context: Context
|
|
226
212
|
state: UiState
|
|
227
|
-
subscribe?: UiStateSubscribe
|
|
228
213
|
onPlayPause: () => void
|
|
229
214
|
onSeek: (positionMs: number) => void
|
|
230
215
|
}) {
|
|
231
216
|
const theme = () => props.context.theme
|
|
232
|
-
const state = reactiveUiState(props.state, props.subscribe)
|
|
233
217
|
let row: BoxRenderable | undefined
|
|
234
218
|
let content: TextRenderable | undefined
|
|
235
219
|
let allocatedWidth = Number.MAX_SAFE_INTEGER
|
|
236
|
-
const track = createMemo(() => state.player?.track)
|
|
220
|
+
const track = createMemo(() => props.state.player?.track)
|
|
237
221
|
const renderLine = () => {
|
|
238
222
|
const current = track()
|
|
239
223
|
if (!current) return ""
|
|
@@ -241,7 +225,7 @@ export function CompactPlayer(props: {
|
|
|
241
225
|
allocatedWidth,
|
|
242
226
|
current.name,
|
|
243
227
|
current.artists,
|
|
244
|
-
!!state.player?.is_playing,
|
|
228
|
+
!!props.state.player?.is_playing,
|
|
245
229
|
)
|
|
246
230
|
const padding = " ".repeat(display.padding)
|
|
247
231
|
return `${padding}${display.marker}${
|
|
@@ -279,7 +263,7 @@ export function CompactPlayer(props: {
|
|
|
279
263
|
overflow="hidden"
|
|
280
264
|
onMouseDown={(event) => {
|
|
281
265
|
if (event.button !== MouseButton.LEFT) return
|
|
282
|
-
const current = state.player?.track
|
|
266
|
+
const current = props.state.player?.track
|
|
283
267
|
if (!current || !row) return
|
|
284
268
|
|
|
285
269
|
const seekWidth = compactSeekRegionWidth(row.width)
|
|
@@ -288,7 +272,7 @@ export function CompactPlayer(props: {
|
|
|
288
272
|
row.width,
|
|
289
273
|
current.name,
|
|
290
274
|
current.artists,
|
|
291
|
-
!!state.player?.is_playing,
|
|
275
|
+
!!props.state.player?.is_playing,
|
|
292
276
|
).padding
|
|
293
277
|
if (cell === markerCell) {
|
|
294
278
|
event.preventDefault()
|
|
@@ -327,12 +311,12 @@ export function CompactPlayer(props: {
|
|
|
327
311
|
)
|
|
328
312
|
}
|
|
329
313
|
|
|
330
|
-
function liveProgress(player: UiState["player"]): number {
|
|
314
|
+
function liveProgress(player: UiState["player"], now = Date.now()): number {
|
|
331
315
|
if (!player?.track) return 0
|
|
332
316
|
if (!player.is_playing) return player.progress_ms
|
|
333
317
|
return Math.min(
|
|
334
318
|
player.track.duration_ms,
|
|
335
|
-
player.progress_ms + (
|
|
319
|
+
player.progress_ms + (Math.max(now, player.fetched_at) - player.fetched_at),
|
|
336
320
|
)
|
|
337
321
|
}
|
|
338
322
|
|
|
@@ -473,41 +457,19 @@ function IconBtn(props: {
|
|
|
473
457
|
export function SidebarPlayer(props: {
|
|
474
458
|
context: Context
|
|
475
459
|
state: UiState
|
|
476
|
-
subscribe?: UiStateSubscribe
|
|
477
460
|
onPlayPause: () => void
|
|
478
461
|
onNext: () => void
|
|
479
462
|
onPrev: () => void
|
|
480
463
|
onSeek: (positionMs: number) => void
|
|
481
464
|
}) {
|
|
482
465
|
const theme = () => props.context.theme
|
|
483
|
-
const
|
|
484
|
-
const player = createMemo(() => state.player)
|
|
466
|
+
const player = createMemo(() => props.state.player)
|
|
485
467
|
const track = createMemo(() => player()?.track)
|
|
486
468
|
const playing = createMemo(() => !!player()?.is_playing)
|
|
487
469
|
|
|
488
|
-
const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const sync = () => {
|
|
492
|
-
const on = !!state.player?.is_playing
|
|
493
|
-
if (on && !id) id = setInterval(() => setTick((t) => t + 1), 1000)
|
|
494
|
-
else if (!on && id) {
|
|
495
|
-
clearInterval(id)
|
|
496
|
-
id = null
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
sync()
|
|
500
|
-
const watch = setInterval(sync, 400)
|
|
501
|
-
onCleanup(() => {
|
|
502
|
-
if (id) clearInterval(id)
|
|
503
|
-
clearInterval(watch)
|
|
504
|
-
})
|
|
505
|
-
})
|
|
506
|
-
|
|
507
|
-
const progress = createMemo(() => {
|
|
508
|
-
tick()
|
|
509
|
-
return liveProgress(player())
|
|
510
|
-
})
|
|
470
|
+
const progress = createMemo(() =>
|
|
471
|
+
liveProgress(player(), props.state.clock_ms),
|
|
472
|
+
)
|
|
511
473
|
const duration = createMemo(() => track()?.duration_ms ?? 0)
|
|
512
474
|
|
|
513
475
|
return (
|
|
@@ -526,7 +488,7 @@ export function SidebarPlayer(props: {
|
|
|
526
488
|
gap={1}
|
|
527
489
|
flexShrink={0}
|
|
528
490
|
>
|
|
529
|
-
<Show when={state.error}>
|
|
491
|
+
<Show when={props.state.error}>
|
|
530
492
|
{(err) => <text fg={theme().text.feedback.error.default}>{err()}</text>}
|
|
531
493
|
</Show>
|
|
532
494
|
|
|
@@ -562,7 +524,7 @@ export function SidebarPlayer(props: {
|
|
|
562
524
|
when={track()}
|
|
563
525
|
fallback={
|
|
564
526
|
<text fg={theme().text.subdued}>
|
|
565
|
-
{state.loading ? "Syncing…" : "Nothing playing"}
|
|
527
|
+
{props.state.loading ? "Syncing…" : "Nothing playing"}
|
|
566
528
|
</text>
|
|
567
529
|
}
|
|
568
530
|
>
|