@naxodev/opencode-music-player 0.1.4 → 0.1.5

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 (3) hide show
  1. package/index.tsx +46 -39
  2. package/package.json +1 -1
  3. package/ui.tsx +8 -26
package/index.tsx CHANGED
@@ -5,7 +5,7 @@ import { isMac, mergeArtworkCompletion, type SessionMedia } from "./types.ts"
5
5
  import { CompactPlayer, SidebarPlayer, type UiState } from "./ui.tsx"
6
6
 
7
7
  type Context = Plugin.Context
8
- type SessionStore = UiState
8
+ type SessionStore = UiState & { loadingOwners?: string[] }
9
9
  type SeekIntent = {
10
10
  positionMs: number
11
11
  resolves: Array<() => void>
@@ -13,7 +13,6 @@ type SeekIntent = {
13
13
 
14
14
  export type Controller = {
15
15
  session: SessionStore
16
- subscribe: (listener: (session: SessionStore) => void) => () => void
17
16
  openApp: () => Promise<void>
18
17
  refreshAll: () => Promise<void>
19
18
  playPause: () => Promise<void>
@@ -83,16 +82,18 @@ export function createController(
83
82
  context: Context,
84
83
  dependencies: ControllerDependencies = controllerDependencies,
85
84
  ): 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
- }
85
+ const [session, setSession] = context.storage.memory<SessionStore>(
86
+ "music-player.session.v6",
87
+ {
88
+ initial: {
89
+ loading: false,
90
+ loadingOwners: [],
91
+ error: null,
92
+ player: null,
93
+ },
94
+ },
95
+ )
96
+ const loadingOwner = crypto.randomUUID()
96
97
  const media = dependencies.createSessionMedia()
97
98
  let disposed = false
98
99
  let lifecycleGeneration = 0
@@ -134,12 +135,18 @@ export function createController(
134
135
  transportError = message
135
136
  publishError()
136
137
  }
137
- const updateLoading = () => {
138
- if (!isActive()) return
138
+ const setLoadingOwner = (active: boolean) => {
139
139
  setSession((draft) => {
140
- draft.loading = pending.size > 0
140
+ const owners = new Set(draft.loadingOwners ?? [])
141
+ if (active) owners.add(loadingOwner)
142
+ else owners.delete(loadingOwner)
143
+ draft.loadingOwners = [...owners]
144
+ draft.loading = owners.size > 0
141
145
  })
142
146
  }
147
+ const updateLoading = () => {
148
+ if (isActive()) setLoadingOwner(pending.size > 0)
149
+ }
143
150
  const settle = (resolve: () => void) => {
144
151
  if (!pending.delete(resolve)) return
145
152
  resolve()
@@ -289,11 +296,6 @@ export function createController(
289
296
 
290
297
  return {
291
298
  session,
292
- subscribe(listener) {
293
- sessionListeners.add(listener)
294
- listener(session)
295
- return () => sessionListeners.delete(listener)
296
- },
297
299
  refreshAll,
298
300
  async openApp() {
299
301
  if (!isActive()) return
@@ -326,12 +328,7 @@ export function createController(
326
328
  activeSeek = null
327
329
  for (const resolve of [...pending]) settle(resolve)
328
330
  void media.dispose().catch(() => {})
329
- if (session.loading) {
330
- setSession((draft) => {
331
- draft.loading = false
332
- })
333
- }
334
- sessionListeners.clear()
331
+ setLoadingOwner(false)
335
332
  },
336
333
  }
337
334
  }
@@ -380,7 +377,6 @@ function AppHost(props: { context: Context; ctrl: Controller }) {
380
377
  <CompactPlayer
381
378
  context={context}
382
379
  state={ctrl.session}
383
- subscribe={ctrl.subscribe}
384
380
  onPlayPause={() => void ctrl.playPause()}
385
381
  onSeek={(positionMs) => void ctrl.seek(positionMs)}
386
382
  />
@@ -413,23 +409,34 @@ export function createMusicPlayerPlugin(options?: {
413
409
  }
414
410
  : controllerDependencies,
415
411
  )
412
+ // Slot renderers must observe host-owned state before package components run.
413
+ const observeSession = () => {
414
+ void ctrl.session.loading
415
+ void ctrl.session.error
416
+ void ctrl.session.player
417
+ }
416
418
  const unsubApp = context.ui.slot({
417
419
  append: "session.composer.top",
418
- render: () => <AppHost context={context} ctrl={ctrl} />,
420
+ render: () => {
421
+ observeSession()
422
+ return <AppHost context={context} ctrl={ctrl} />
423
+ },
419
424
  })
420
425
  const unsubSidebar = context.ui.slot({
421
426
  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
- ),
427
+ render: () => {
428
+ observeSession()
429
+ return (
430
+ <SidebarPlayer
431
+ context={context}
432
+ state={ctrl.session}
433
+ onPlayPause={() => void ctrl.playPause()}
434
+ onNext={() => void ctrl.next()}
435
+ onPrev={() => void ctrl.prev()}
436
+ onSeek={(positionMs) => void ctrl.seek(positionMs)}
437
+ />
438
+ )
439
+ },
433
440
  })
434
441
  return () => {
435
442
  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.5",
4
4
  "description": "macOS system Now Playing controls for the OpenCode 2 TUI",
5
5
  "license": "MIT",
6
6
  "author": {
package/ui.tsx CHANGED
@@ -7,7 +7,6 @@ import {
7
7
  onCleanup,
8
8
  onMount,
9
9
  } from "solid-js"
10
- import { createStore, reconcile } from "solid-js/store"
11
10
  import type { Plugin } from "@opencode-ai/plugin/tui"
12
11
  import {
13
12
  MouseButton,
@@ -25,19 +24,6 @@ export type UiState = {
25
24
  player: import("./types.ts").PlayerState | null
26
25
  }
27
26
 
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
39
- }
40
-
41
27
  type Theme = Context["theme"]
42
28
  type Context = Plugin.Context
43
29
 
@@ -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()
@@ -473,15 +457,13 @@ 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
 
@@ -489,7 +471,7 @@ export function SidebarPlayer(props: {
489
471
  onMount(() => {
490
472
  let id: ReturnType<typeof setInterval> | null = null
491
473
  const sync = () => {
492
- const on = !!state.player?.is_playing
474
+ const on = !!props.state.player?.is_playing
493
475
  if (on && !id) id = setInterval(() => setTick((t) => t + 1), 1000)
494
476
  else if (!on && id) {
495
477
  clearInterval(id)
@@ -526,7 +508,7 @@ export function SidebarPlayer(props: {
526
508
  gap={1}
527
509
  flexShrink={0}
528
510
  >
529
- <Show when={state.error}>
511
+ <Show when={props.state.error}>
530
512
  {(err) => <text fg={theme().text.feedback.error.default}>{err()}</text>}
531
513
  </Show>
532
514
 
@@ -562,7 +544,7 @@ export function SidebarPlayer(props: {
562
544
  when={track()}
563
545
  fallback={
564
546
  <text fg={theme().text.subdued}>
565
- {state.loading ? "Syncing…" : "Nothing playing"}
547
+ {props.state.loading ? "Syncing…" : "Nothing playing"}
566
548
  </text>
567
549
  }
568
550
  >