@naxodev/opencode-music-player 0.1.5 → 0.2.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.
Files changed (4) hide show
  1. package/index.tsx +28 -3
  2. package/package.json +1 -1
  3. package/types.ts +32 -0
  4. package/ui.tsx +6 -26
package/index.tsx CHANGED
@@ -1,7 +1,12 @@
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
@@ -90,6 +95,7 @@ export function createController(
90
95
  loadingOwners: [],
91
96
  error: null,
92
97
  player: null,
98
+ clock_ms: Date.now(),
93
99
  },
94
100
  },
95
101
  )
@@ -112,8 +118,20 @@ export function createController(
112
118
  let activeSeek: SeekIntent | null = null
113
119
  let latestSeek: SeekIntent | null = null
114
120
  const pending = new Set<() => void>()
115
-
116
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
+
117
135
  const publishError = () => {
118
136
  if (!isActive()) return
119
137
  const message =
@@ -222,7 +240,9 @@ export function createController(
222
240
  return
223
241
  setSession((draft) => {
224
242
  draft.player = player
243
+ draft.clock_ms = Date.now()
225
244
  })
245
+ syncClock()
226
246
  } catch (error) {
227
247
  if (isActive() && generation === lifecycleGeneration)
228
248
  setTransportError(errMsg(error))
@@ -239,6 +259,7 @@ export function createController(
239
259
  setSession((draft) => {
240
260
  draft.player = optimisticPlayerState(draft.player, nextPlaying)
241
261
  })
262
+ syncClock()
242
263
  },
243
264
  ).finally(() => {
244
265
  playbackOperations--
@@ -270,8 +291,10 @@ export function createController(
270
291
  receivedSnapshot = true
271
292
  snapshotEpoch++
272
293
  setSession((draft) => {
273
- draft.player = event.state
294
+ draft.player = mergePlayerSnapshot(draft.player, event.state)
295
+ draft.clock_ms = Date.now()
274
296
  })
297
+ syncClock()
275
298
  return
276
299
  }
277
300
  if (event?.type === "lifecycle") {
@@ -327,6 +350,7 @@ export function createController(
327
350
  activeSeek?.resolves.splice(0).forEach((resolve) => resolve())
328
351
  activeSeek = null
329
352
  for (const resolve of [...pending]) settle(resolve)
353
+ if (clockTimer) clearInterval(clockTimer)
330
354
  void media.dispose().catch(() => {})
331
355
  setLoadingOwner(false)
332
356
  },
@@ -414,6 +438,7 @@ export function createMusicPlayerPlugin(options?: {
414
438
  void ctrl.session.loading
415
439
  void ctrl.session.error
416
440
  void ctrl.session.player
441
+ void ctrl.session.clock_ms
417
442
  }
418
443
  const unsubApp = context.ui.slot({
419
444
  append: "session.composer.top",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naxodev/opencode-music-player",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
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,7 +5,6 @@ import {
5
5
  createMemo,
6
6
  createSignal,
7
7
  onCleanup,
8
- onMount,
9
8
  } from "solid-js"
10
9
  import type { Plugin } from "@opencode-ai/plugin/tui"
11
10
  import {
@@ -22,6 +21,7 @@ export type UiState = {
22
21
  loading: boolean
23
22
  error: string | null
24
23
  player: import("./types.ts").PlayerState | null
24
+ clock_ms?: number
25
25
  }
26
26
 
27
27
  type Theme = Context["theme"]
@@ -311,12 +311,12 @@ export function CompactPlayer(props: {
311
311
  )
312
312
  }
313
313
 
314
- function liveProgress(player: UiState["player"]): number {
314
+ function liveProgress(player: UiState["player"], now = Date.now()): number {
315
315
  if (!player?.track) return 0
316
316
  if (!player.is_playing) return player.progress_ms
317
317
  return Math.min(
318
318
  player.track.duration_ms,
319
- player.progress_ms + (Date.now() - player.fetched_at),
319
+ player.progress_ms + (Math.max(now, player.fetched_at) - player.fetched_at),
320
320
  )
321
321
  }
322
322
 
@@ -467,29 +467,9 @@ export function SidebarPlayer(props: {
467
467
  const track = createMemo(() => player()?.track)
468
468
  const playing = createMemo(() => !!player()?.is_playing)
469
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
- })
470
+ const progress = createMemo(() =>
471
+ liveProgress(player(), props.state.clock_ms),
472
+ )
493
473
  const duration = createMemo(() => track()?.duration_ms ?? 0)
494
474
 
495
475
  return (