@naxodev/opencode-music-player 0.2.2 → 0.2.4

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/artwork.tsx CHANGED
@@ -121,7 +121,7 @@ export function AlbumArtwork(props: { context: Context; artwork: Artwork }) {
121
121
  action.width,
122
122
  action.height,
123
123
  )
124
- return commands.every((command) => writeGraphics(renderer, command))
124
+ return writeGraphics(renderer, commands)
125
125
  }
126
126
  case "place":
127
127
  return writeGraphics(
package/index.tsx CHANGED
@@ -7,7 +7,12 @@ import {
7
7
  mergePlayerSnapshot,
8
8
  type SessionMedia,
9
9
  } from "./types.ts"
10
- import { CompactPlayer, SidebarPlayer, type UiState } from "./ui.tsx"
10
+ import {
11
+ CompactPlayer,
12
+ sanitizeTerminalText,
13
+ SidebarPlayer,
14
+ type UiState,
15
+ } from "./ui.tsx"
11
16
 
12
17
  type Context = Plugin.Context
13
18
  type SessionStore = UiState & { loadingOwners?: string[] }
@@ -32,36 +37,12 @@ export type ControllerDependencies = {
32
37
  createSessionMedia: () => SessionMedia
33
38
  }
34
39
 
35
- /** Apply successful daemon transport state until the next authoritative replay. */
36
- export function optimisticPlayerState(
37
- player: SessionStore["player"],
38
- playing: boolean,
39
- now = Date.now(),
40
- ): SessionStore["player"] {
41
- if (!player) return player
42
- const progress = player.is_playing
43
- ? Math.min(
44
- player.track?.duration_ms || Number.POSITIVE_INFINITY,
45
- Math.max(0, player.progress_ms + (now - player.fetched_at)),
46
- )
47
- : player.progress_ms
48
- return {
49
- ...player,
50
- progress_ms: progress,
51
- is_playing: playing,
52
- fetched_at: now,
53
- }
54
- }
55
-
56
- export function optimisticSeekPlayerState(
57
- player: SessionStore["player"],
58
- positionMs: number,
59
- now = Date.now(),
60
- ): SessionStore["player"] {
61
- const target = seekTarget(positionMs, player?.track?.duration_ms)
62
- if (!player?.track || target === null) return player
63
- return { ...player, progress_ms: target, fetched_at: now }
64
- }
40
+ /**
41
+ * Seeking to the exact duration finishes the track immediately, so a click
42
+ * near the end of a seek bar would skip songs instead of seeking. Clamp to
43
+ * just short of the end; skipping is what the next button is for.
44
+ */
45
+ export const SEEK_END_MARGIN_MS = 1_000
65
46
 
66
47
  function seekTarget(positionMs: number, durationMs?: number): number | null {
67
48
  if (
@@ -71,7 +52,8 @@ function seekTarget(positionMs: number, durationMs?: number): number | null {
71
52
  durationMs <= 0
72
53
  )
73
54
  return null
74
- return Math.max(0, Math.min(durationMs, Math.round(positionMs)))
55
+ const ceiling = Math.max(0, durationMs - SEEK_END_MARGIN_MS)
56
+ return Math.max(0, Math.min(ceiling, Math.round(positionMs)))
75
57
  }
76
58
 
77
59
  const controllerDependencies: ControllerDependencies = {
@@ -122,10 +104,14 @@ export function createController(
122
104
  } = { message: null, source: undefined }
123
105
  let transportError: string | null = null
124
106
  let receivedSnapshot = false
125
- // An authority fence for optimistic UI projection, not a host sampling lane.
126
107
  let snapshotEpoch = 0
127
- let playbackTarget: boolean | undefined
128
- let playbackOperations = 0
108
+ let playbackIntent:
109
+ | {
110
+ readonly target: boolean
111
+ readonly startedAtEpoch: number
112
+ settled: boolean
113
+ }
114
+ | undefined
129
115
  let activeSeek: SeekIntent | null = null
130
116
  let latestSeek: SeekIntent | null = null
131
117
  const pending = new Set<() => void>()
@@ -172,11 +158,10 @@ export function createController(
172
158
 
173
159
  const runCommand = (
174
160
  command: () => Promise<unknown>,
175
- afterSuccess?: () => void,
161
+ onSettled?: (succeeded: boolean) => void,
176
162
  ) => {
177
163
  if (!isActive()) return Promise.resolve()
178
164
  const generation = lifecycleGeneration
179
- const commandSnapshotEpoch = snapshotEpoch
180
165
  return new Promise<void>((resolve) => {
181
166
  pending.add(resolve)
182
167
  updateLoading()
@@ -186,48 +171,46 @@ export function createController(
186
171
  } catch (error) {
187
172
  result = Promise.reject(error)
188
173
  }
174
+ let succeeded = false
189
175
  void Promise.resolve(result)
190
176
  .then(
191
177
  () => {
178
+ succeeded = true
192
179
  if (!isActive() || generation !== lifecycleGeneration) return
193
180
  if (transportError !== null) setTransportError(null)
194
- // A later daemon snapshot is authoritative over command optimism.
195
- if (snapshotEpoch === commandSnapshotEpoch) afterSuccess?.()
196
181
  },
197
182
  (error) => {
198
183
  if (!isActive() || generation !== lifecycleGeneration) return
199
- const message = errMsg(error)
184
+ const message = sanitizeTerminalText(errMsg(error))
200
185
  setTransportError(message)
201
186
  context.ui.toast.show({ title: "Music", message, variant: "error" })
202
187
  },
203
188
  )
204
- .finally(() => settle(resolve))
189
+ .finally(() => {
190
+ try {
191
+ onSettled?.(succeeded)
192
+ } finally {
193
+ settle(resolve)
194
+ }
195
+ })
205
196
  })
206
197
  }
207
198
 
208
199
  const runSeek = (intent: SeekIntent) => {
209
200
  activeSeek = intent
210
- const operation = runCommand(
201
+ void runCommand(
211
202
  () => media.seek(intent.positionMs),
212
203
  () => {
213
- setSession((draft) => {
214
- draft.player = optimisticSeekPlayerState(
215
- draft.player,
216
- intent.positionMs,
217
- )
218
- })
204
+ if (activeSeek !== intent) return
205
+ activeSeek = null
206
+ for (const resolve of intent.resolves) resolve()
207
+ intent.resolves = []
208
+ const next = latestSeek
209
+ latestSeek = null
210
+ if (isActive() && next) runSeek(next)
211
+ else next?.resolves.splice(0).forEach((resolve) => resolve())
219
212
  },
220
213
  )
221
- void operation.then(() => {
222
- if (activeSeek !== intent) return
223
- activeSeek = null
224
- for (const resolve of intent.resolves) resolve()
225
- intent.resolves = []
226
- const next = latestSeek
227
- latestSeek = null
228
- if (isActive() && next) runSeek(next)
229
- else next?.resolves.splice(0).forEach((resolve) => resolve())
230
- })
231
214
  }
232
215
 
233
216
  const refreshAll = async () => {
@@ -247,20 +230,31 @@ export function createController(
247
230
  }
248
231
 
249
232
  const playPause = () => {
250
- const nextPlaying = !(playbackTarget ?? !!session.player?.is_playing)
251
- playbackTarget = nextPlaying
252
- playbackOperations++
233
+ const nextPlaying = !(
234
+ playbackIntent?.target ?? !!session.player?.is_playing
235
+ )
236
+ const intent = {
237
+ target: nextPlaying,
238
+ startedAtEpoch: snapshotEpoch,
239
+ settled: false,
240
+ }
241
+ playbackIntent = intent
253
242
  return runCommand(
254
243
  () => (nextPlaying ? media.play() : media.pause()),
255
- () => {
256
- setSession((draft) => {
257
- draft.player = optimisticPlayerState(draft.player, nextPlaying)
258
- })
244
+ (succeeded) => {
245
+ if (playbackIntent !== intent) return
246
+ if (!succeeded) {
247
+ playbackIntent = undefined
248
+ return
249
+ }
250
+ intent.settled = true
251
+ if (
252
+ snapshotEpoch > intent.startedAtEpoch &&
253
+ session.player?.is_playing === intent.target
254
+ )
255
+ playbackIntent = undefined
259
256
  },
260
- ).finally(() => {
261
- playbackOperations--
262
- if (playbackOperations === 0) playbackTarget = undefined
263
- })
257
+ )
264
258
  }
265
259
 
266
260
  const seek = (positionMs: number) => {
@@ -289,6 +283,12 @@ export function createController(
289
283
  setSession((draft) => {
290
284
  draft.player = mergePlayerSnapshot(draft.player, event.state)
291
285
  })
286
+ if (
287
+ playbackIntent?.settled &&
288
+ snapshotEpoch > playbackIntent.startedAtEpoch &&
289
+ event.state.is_playing === playbackIntent.target
290
+ )
291
+ playbackIntent = undefined
292
292
  return
293
293
  }
294
294
  if (event?.type === "lifecycle") {
@@ -297,6 +297,8 @@ export function createController(
297
297
  // takes precedence over provider status, and clearing it restores the
298
298
  // latest daemon lifecycle state without requiring a repeated event.
299
299
  setLifecycleError(event.message, event.source)
300
+ if (event.source === "connection" && event.message !== null)
301
+ playbackIntent = undefined
300
302
  if (event.message !== null && latestSeek) {
301
303
  latestSeek.resolves.splice(0).forEach((resolve) => resolve())
302
304
  latestSeek = null
package/kitty-graphics.ts CHANGED
@@ -75,16 +75,31 @@ type RendererWriter = {
75
75
  }
76
76
 
77
77
  /** OpenTUI detects Kitty graphics but does not yet expose a raw output API. */
78
- export function writeGraphics(renderer: CliRenderer, data: string): boolean {
78
+ export function writeGraphics(
79
+ renderer: CliRenderer,
80
+ data: string | readonly string[],
81
+ ): boolean {
79
82
  const target = renderer as unknown as RendererWriter
80
83
  if (!target.stdout || typeof target.realStdoutWrite !== "function")
81
84
  return false
85
+ const output = (value: string) =>
86
+ process.env.TMUX && !process.env.HERDR_ENV ? tmuxPassthrough(value) : value
87
+ let cursorSaved = false
82
88
  try {
83
- const output =
84
- process.env.TMUX && !process.env.HERDR_ENV ? tmuxPassthrough(data) : data
85
- target.realStdoutWrite.call(target.stdout, output)
89
+ for (const command of typeof data === "string" ? [data] : data) {
90
+ if (command.includes("\x1b7")) cursorSaved = true
91
+ target.realStdoutWrite.call(target.stdout, output(command))
92
+ if (command.includes("\x1b8")) cursorSaved = false
93
+ }
86
94
  return true
87
95
  } catch {
96
+ if (cursorSaved) {
97
+ try {
98
+ target.realStdoutWrite.call(target.stdout, output("\x1b8"))
99
+ } catch {
100
+ // The original transaction still failed; restoration is best effort.
101
+ }
102
+ }
88
103
  return false
89
104
  }
90
105
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@naxodev/opencode-music-player",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "macOS system Now Playing controls for the OpenCode 2 TUI",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -67,7 +67,7 @@
67
67
  "validate": "bun run format:check && bun run check && bun run test && bun run pack:check"
68
68
  },
69
69
  "dependencies": {
70
- "@naxodev/music-core": "^0.1.0",
70
+ "@naxodev/music-core": "^0.1.1",
71
71
  "@opencode-ai/plugin": "0.0.0-next-17444",
72
72
  "@opentui/core": "0.5.3",
73
73
  "@opentui/solid": "0.5.3",
package/system-media.ts CHANGED
@@ -29,7 +29,6 @@ type ArtworkCacheEntry = {
29
29
  pending: boolean
30
30
  attempts: number
31
31
  retry_at: number
32
- owner: PresentationHost | null
33
32
  interests: Map<PresentationHost, ArtworkIdentity>
34
33
  }
35
34
  const MAX_ARTWORK_ENTRIES = 32
@@ -71,6 +70,7 @@ const createOpenCodeSessionClient: SessionClientFactory = () =>
71
70
  type PresentationHost = {
72
71
  publish: (event: ArtworkCompletionEvent) => void
73
72
  isActive: () => boolean
73
+ accepts: (identity: ArtworkIdentity) => boolean
74
74
  }
75
75
 
76
76
  export type { CommandResult }
@@ -172,6 +172,7 @@ function publishArtworkCompletion(
172
172
  artwork: Artwork | null,
173
173
  ) {
174
174
  for (const [host, identity] of entry.interests) {
175
+ if (!host.isActive() || !host.accepts(identity)) continue
175
176
  host.publish({
176
177
  type: "artwork-completion",
177
178
  identity,
@@ -184,22 +185,10 @@ function publishArtworkCompletion(
184
185
 
185
186
  function removeArtworkInterests(host: PresentationHost) {
186
187
  removeWaitingInterest(host)
187
- for (const [key, entry] of artworkCache) {
188
+ for (const entry of artworkCache.values()) entry.interests.delete(host)
189
+ // A host owns only its interest. The physical job keeps its slot until it settles.
190
+ for (const entry of artworkJobs.values()) {
188
191
  entry.interests.delete(host)
189
- if (entry.owner !== host || entry.pending) continue
190
- if (entry.value === null) artworkCache.delete(key)
191
- else entry.owner = null
192
- }
193
- for (const [key, entry] of artworkJobs) {
194
- entry.interests.delete(host)
195
- if (entry.owner !== host) continue
196
- entry.pending = false
197
- entry.owner = null
198
- if (artworkCache.get(key) === entry && entry.value === null)
199
- artworkCache.delete(key)
200
- // Disposal/cancellation frees a slot too. A deferred current identity
201
- // must be admitted even when its predecessor never resolves.
202
- releaseArtworkSlot(key)
203
192
  }
204
193
  }
205
194
 
@@ -248,18 +237,12 @@ function artworkForTrack(
248
237
  pending: false,
249
238
  attempts: 0,
250
239
  retry_at: 0,
251
- owner: null,
252
240
  interests: new Map(),
253
241
  }
254
242
  }
255
243
  // A state change that reached admission supersedes any older deferred
256
244
  // identity owned by this adapter generation.
257
245
  removeWaitingInterest(host)
258
- if (entry.pending && entry.owner && !entry.owner.isActive()) {
259
- entry.pending = false
260
- entry.owner = null
261
- artworkJobs.delete(key)
262
- }
263
246
  if (
264
247
  !entry.pending &&
265
248
  entry.attempts < 3 &&
@@ -270,8 +253,6 @@ function artworkForTrack(
270
253
  entry.attempts++
271
254
  artworkJobs.set(key, entry)
272
255
  const activeEntry = entry
273
- const activeHost = host
274
- activeEntry.owner = activeHost
275
256
  void (async () => {
276
257
  let data: string | null = null
277
258
  try {
@@ -279,12 +260,9 @@ function artworkForTrack(
279
260
  } catch {
280
261
  // Session artwork failure is transient; host catalog fallback remains.
281
262
  }
282
- if (!activeHost.isActive() || activeEntry.owner !== activeHost)
283
- return { artwork: null, duration_ms: activeEntry.duration_ms }
284
263
  return resolver(key, target, data, legacyKey)
285
264
  })().then(
286
265
  (resolution) => {
287
- if (!activeHost.isActive() || activeEntry.owner !== activeHost) return
288
266
  activeEntry.value = resolution.artwork
289
267
  activeEntry.duration_ms = resolution.duration_ms
290
268
  activeEntry.resolved = true
@@ -295,7 +273,6 @@ function artworkForTrack(
295
273
  publishArtworkCompletion(activeEntry, resolution.artwork)
296
274
  },
297
275
  () => {
298
- if (!activeHost.isActive() || activeEntry.owner !== activeHost) return
299
276
  activeEntry.value = null
300
277
  activeEntry.resolved = true
301
278
  activeEntry.pending = false
@@ -326,6 +303,7 @@ export function createSessionSystemMedia(
326
303
  const listeners = new Set<(event: SessionMediaEvent) => void>()
327
304
  const presentationListeners = new Set<ArtworkPresentationListener>()
328
305
  let disposed = false
306
+ let currentArtworkIdentity: string | null = null
329
307
  let client: ReconnectingMusicSessionClient | undefined
330
308
  let installed = false
331
309
  let latest: RevisionedState | undefined
@@ -364,6 +342,8 @@ export function createSessionSystemMedia(
364
342
  }
365
343
  },
366
344
  isActive: () => !disposed,
345
+ accepts: (identity) =>
346
+ currentArtworkIdentity === artworkIdentityKey(identity),
367
347
  }
368
348
  const lifecycle = (): SessionMediaLifecycleEvent => {
369
349
  if (
@@ -404,10 +384,17 @@ export function createSessionSystemMedia(
404
384
  emit(event)
405
385
  }
406
386
  const project = (state: RevisionedState | undefined): PlayerState | null => {
407
- if (!state) return null
408
- if (!state.state.track) return state.state as PlayerState
387
+ if (!state) {
388
+ currentArtworkIdentity = null
389
+ return null
390
+ }
391
+ if (!state.state.track) {
392
+ currentArtworkIdentity = null
393
+ return state.state as PlayerState
394
+ }
409
395
  const track = state.state.track
410
396
  const identity = identityFromTrack(track)
397
+ currentArtworkIdentity = artworkIdentityKey(identity)
411
398
  const artworkState = artworkForTrack(
412
399
  artworkCacheKey(identity),
413
400
  artworkIdentityKey(identity),
@@ -427,7 +414,7 @@ export function createSessionSystemMedia(
427
414
  album: track.album,
428
415
  duration_ms: track.duration_ms,
429
416
  } satisfies SessionArtworkIdentity)
430
- return !disposed && active === client && result.type === "available"
417
+ return active === client && result.type === "available"
431
418
  ? result.base64
432
419
  : null
433
420
  },
@@ -440,8 +427,6 @@ export function createSessionSystemMedia(
440
427
  ...state.state,
441
428
  track: {
442
429
  ...track,
443
- duration_ms:
444
- track.duration_ms > 0 ? track.duration_ms : artworkState.duration_ms,
445
430
  artwork: artworkState.artwork,
446
431
  artwork_loading: artworkState.loading,
447
432
  },
package/types.ts CHANGED
@@ -108,8 +108,6 @@ export function mergeArtworkCompletion(
108
108
  ...track,
109
109
  artwork: event.artwork,
110
110
  artwork_loading: false,
111
- duration_ms:
112
- track.duration_ms > 0 ? track.duration_ms : event.duration_ms,
113
111
  },
114
112
  }
115
113
  }
@@ -140,8 +138,6 @@ export function mergePlayerSnapshot(
140
138
  ...next,
141
139
  artwork: next.artwork ?? current.artwork,
142
140
  artwork_loading: false,
143
- duration_ms:
144
- next.duration_ms > 0 ? next.duration_ms : current.duration_ms,
145
141
  },
146
142
  }
147
143
  }
package/ui.tsx CHANGED
@@ -146,9 +146,11 @@ export type CompactPresentation = {
146
146
  artist: string | null
147
147
  }
148
148
 
149
- function sanitize(value: string, fallback = ""): string {
149
+ export function sanitizeTerminalText(value: string, fallback = ""): string {
150
150
  const clean = value
151
151
  .normalize("NFC")
152
+ .replace(/(?:\x1b\]|\x9d)[\s\S]*?(?:\x07|\x1b\\|\x9c|$)/g, "")
153
+ .replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, "")
152
154
  .replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ")
153
155
  .replace(/\s+/g, " ")
154
156
  .trim()
@@ -176,8 +178,8 @@ export function compactPresentation(
176
178
  ): CompactPresentation {
177
179
  const available = Math.max(1, Math.floor(width))
178
180
  const marker = playing ? Icon.pause : Icon.play
179
- const cleanTitle = sanitize(title, "Unknown track")
180
- const cleanArtist = sanitize(artist)
181
+ const cleanTitle = sanitizeTerminalText(title, "Unknown track")
182
+ const cleanArtist = sanitizeTerminalText(artist)
181
183
 
182
184
  const tier: CompactTier =
183
185
  available >= COMPACT_BUDGETS.wide.minWidth
@@ -576,7 +578,11 @@ export function SidebarPlayer(props: {
576
578
  flexShrink={0}
577
579
  >
578
580
  <Show when={props.state.error}>
579
- {(err) => <text fg={theme().text.feedback.error.default}>{err()}</text>}
581
+ {(err) => (
582
+ <text fg={theme().text.feedback.error.default}>
583
+ {sanitizeTerminalText(err())}
584
+ </text>
585
+ )}
580
586
  </Show>
581
587
 
582
588
  <box flexDirection="row" justifyContent="center">
@@ -618,13 +624,17 @@ export function SidebarPlayer(props: {
618
624
  {(t) => (
619
625
  <box flexDirection="column" gap={0}>
620
626
  <text fg={theme().text.default}>
621
- <b>{t().name}</b>
627
+ <b>{sanitizeTerminalText(t().name, "Unknown track")}</b>
622
628
  </text>
623
629
  <Show when={t().artists}>
624
- <text fg={theme().text.subdued}>{t().artists}</text>
630
+ <text fg={theme().text.subdued}>
631
+ {sanitizeTerminalText(t().artists)}
632
+ </text>
625
633
  </Show>
626
634
  <Show when={t().album}>
627
- <text fg={theme().text.subdued}>{t().album}</text>
635
+ <text fg={theme().text.subdued}>
636
+ {sanitizeTerminalText(t().album)}
637
+ </text>
628
638
  </Show>
629
639
  </box>
630
640
  )}