@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.
Files changed (4) hide show
  1. package/index.tsx +74 -42
  2. package/package.json +1 -1
  3. package/types.ts +32 -0
  4. 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 { isMac, mergeArtworkCompletion, type SessionMedia } from "./types.ts"
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: SessionStore = {
87
- loading: false,
88
- error: null,
89
- player: null,
90
- }
91
- const sessionListeners = new Set<(session: SessionStore) => void>()
92
- const setSession = (update: (draft: SessionStore) => void) => {
93
- update(session)
94
- for (const listener of sessionListeners) listener(session)
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 updateLoading = () => {
138
- if (!isActive()) return
156
+ const setLoadingOwner = (active: boolean) => {
139
157
  setSession((draft) => {
140
- draft.loading = pending.size > 0
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
- if (session.loading) {
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: () => <AppHost context={context} ctrl={ctrl} />,
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
- <SidebarPlayer
424
- context={context}
425
- state={ctrl.session}
426
- subscribe={ctrl.subscribe}
427
- onPlayPause={() => void ctrl.playPause()}
428
- onNext={() => void ctrl.next()}
429
- onPrev={() => void ctrl.prev()}
430
- onSeek={(positionMs) => void ctrl.seek(positionMs)}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naxodev/opencode-music-player",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "macOS system Now Playing controls for the OpenCode 2 TUI",
5
5
  "license": "MIT",
6
6
  "author": {
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 + (Date.now() - player.fetched_at),
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 state = reactiveUiState(props.state, props.subscribe)
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 [tick, setTick] = createSignal(0)
489
- onMount(() => {
490
- let id: ReturnType<typeof setInterval> | null = null
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
  >