@naxodev/opencode-music-player 0.2.3 → 0.2.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.
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.3",
3
+ "version": "0.2.5",
4
4
  "description": "macOS system Now Playing controls for the OpenCode 2 TUI",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -62,20 +62,21 @@
62
62
  "format:check": "prettier --check .",
63
63
  "check": "tsc --noEmit",
64
64
  "test": "bun test --preload @opentui/solid/preload",
65
- "pack:check": "bun run scripts/verify-pack.ts",
65
+ "pack:check": "bun run ../../scripts/music-release.ts opencode-music-player --staged-only && bun run scripts/verify-pack.ts",
66
+ "prepublish:core": "bun run ../../scripts/music-release.ts opencode-music-player",
66
67
  "smoke:package": "bun run scripts/package-smoke.ts",
67
68
  "validate": "bun run format:check && bun run check && bun run test && bun run pack:check"
68
69
  },
69
70
  "dependencies": {
70
71
  "@naxodev/music-core": "^0.1.1",
71
72
  "@opencode-ai/plugin": "0.0.0-next-17444",
72
- "@opentui/core": "0.5.3",
73
- "@opentui/solid": "0.5.3",
73
+ "@opentui/core": "0.5.4",
74
+ "@opentui/solid": "0.5.4",
74
75
  "pngjs": "^7.0.0",
75
- "solid-js": "1.9.12"
76
+ "solid-js": "1.9.15"
76
77
  },
77
78
  "devDependencies": {
78
- "@opencode-ai/theme": "0.0.0-next-17444",
79
+ "@opencode-ai/theme": "0.0.0",
79
80
  "@types/bun": "^1.3.14",
80
81
  "@types/pngjs": "^6.0.5",
81
82
  "prettier": "3.9.6",
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
@@ -53,7 +52,9 @@ const waitingArtwork = new Map<string, DeferredArtwork>()
53
52
  type ArtworkResolver = typeof resolveArtworkDetails
54
53
 
55
54
  /** Public-contract-only seam for the reconnecting session adapter. */
56
- export type SessionClientFactory = () => Promise<ReconnectingMusicSessionClient>
55
+ export type SessionClientFactory = (
56
+ signal?: AbortSignal,
57
+ ) => Promise<ReconnectingMusicSessionClient>
57
58
  export type SessionSystemMediaOverrides = {
58
59
  readonly createClient?: SessionClientFactory
59
60
  readonly resolveArtworkDetails?: ArtworkResolver
@@ -61,16 +62,18 @@ export type SessionSystemMediaOverrides = {
61
62
  }
62
63
 
63
64
  let sessionClientSequence = 0
64
- const createOpenCodeSessionClient: SessionClientFactory = () =>
65
+ const createOpenCodeSessionClient: SessionClientFactory = (signal) =>
65
66
  createReconnectingMusicSessionClient({
66
67
  clientId: `opencode-music-player-${++sessionClientSequence}`,
67
68
  hostKind: "opencode",
68
69
  capabilities: [...baselineCapabilities],
70
+ ...(signal ? { signal } : {}),
69
71
  })
70
72
 
71
73
  type PresentationHost = {
72
74
  publish: (event: ArtworkCompletionEvent) => void
73
75
  isActive: () => boolean
76
+ accepts: (identity: ArtworkIdentity) => boolean
74
77
  }
75
78
 
76
79
  export type { CommandResult }
@@ -172,6 +175,7 @@ function publishArtworkCompletion(
172
175
  artwork: Artwork | null,
173
176
  ) {
174
177
  for (const [host, identity] of entry.interests) {
178
+ if (!host.isActive() || !host.accepts(identity)) continue
175
179
  host.publish({
176
180
  type: "artwork-completion",
177
181
  identity,
@@ -184,22 +188,10 @@ function publishArtworkCompletion(
184
188
 
185
189
  function removeArtworkInterests(host: PresentationHost) {
186
190
  removeWaitingInterest(host)
187
- for (const [key, entry] of artworkCache) {
191
+ for (const entry of artworkCache.values()) entry.interests.delete(host)
192
+ // A host owns only its interest. The physical job keeps its slot until it settles.
193
+ for (const entry of artworkJobs.values()) {
188
194
  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
195
  }
204
196
  }
205
197
 
@@ -248,18 +240,12 @@ function artworkForTrack(
248
240
  pending: false,
249
241
  attempts: 0,
250
242
  retry_at: 0,
251
- owner: null,
252
243
  interests: new Map(),
253
244
  }
254
245
  }
255
246
  // A state change that reached admission supersedes any older deferred
256
247
  // identity owned by this adapter generation.
257
248
  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
249
  if (
264
250
  !entry.pending &&
265
251
  entry.attempts < 3 &&
@@ -270,8 +256,6 @@ function artworkForTrack(
270
256
  entry.attempts++
271
257
  artworkJobs.set(key, entry)
272
258
  const activeEntry = entry
273
- const activeHost = host
274
- activeEntry.owner = activeHost
275
259
  void (async () => {
276
260
  let data: string | null = null
277
261
  try {
@@ -279,12 +263,9 @@ function artworkForTrack(
279
263
  } catch {
280
264
  // Session artwork failure is transient; host catalog fallback remains.
281
265
  }
282
- if (!activeHost.isActive() || activeEntry.owner !== activeHost)
283
- return { artwork: null, duration_ms: activeEntry.duration_ms }
284
266
  return resolver(key, target, data, legacyKey)
285
267
  })().then(
286
268
  (resolution) => {
287
- if (!activeHost.isActive() || activeEntry.owner !== activeHost) return
288
269
  activeEntry.value = resolution.artwork
289
270
  activeEntry.duration_ms = resolution.duration_ms
290
271
  activeEntry.resolved = true
@@ -295,7 +276,6 @@ function artworkForTrack(
295
276
  publishArtworkCompletion(activeEntry, resolution.artwork)
296
277
  },
297
278
  () => {
298
- if (!activeHost.isActive() || activeEntry.owner !== activeHost) return
299
279
  activeEntry.value = null
300
280
  activeEntry.resolved = true
301
281
  activeEntry.pending = false
@@ -325,7 +305,9 @@ export function createSessionSystemMedia(
325
305
  const now = overrides.now ?? Date.now
326
306
  const listeners = new Set<(event: SessionMediaEvent) => void>()
327
307
  const presentationListeners = new Set<ArtworkPresentationListener>()
308
+ const acquisitionAbort = new AbortController()
328
309
  let disposed = false
310
+ let currentArtworkIdentity: string | null = null
329
311
  let client: ReconnectingMusicSessionClient | undefined
330
312
  let installed = false
331
313
  let latest: RevisionedState | undefined
@@ -337,10 +319,10 @@ export function createSessionSystemMedia(
337
319
  let clientReleased = false
338
320
  let disposal: Promise<void> | undefined
339
321
 
340
- const releaseClient = (next: ReconnectingMusicSessionClient) => {
341
- if (clientReleased) return Promise.resolve()
322
+ const releaseClient = async (next: ReconnectingMusicSessionClient) => {
323
+ if (clientReleased) return
342
324
  clientReleased = true
343
- return Promise.resolve(next.dispose()).catch(() => {})
325
+ await next.dispose()
344
326
  }
345
327
  const emit = (event: SessionMediaEvent) => {
346
328
  if (disposed) return
@@ -364,6 +346,8 @@ export function createSessionSystemMedia(
364
346
  }
365
347
  },
366
348
  isActive: () => !disposed,
349
+ accepts: (identity) =>
350
+ currentArtworkIdentity === artworkIdentityKey(identity),
367
351
  }
368
352
  const lifecycle = (): SessionMediaLifecycleEvent => {
369
353
  if (
@@ -404,10 +388,17 @@ export function createSessionSystemMedia(
404
388
  emit(event)
405
389
  }
406
390
  const project = (state: RevisionedState | undefined): PlayerState | null => {
407
- if (!state) return null
408
- if (!state.state.track) return state.state as PlayerState
391
+ if (!state) {
392
+ currentArtworkIdentity = null
393
+ return null
394
+ }
395
+ if (!state.state.track) {
396
+ currentArtworkIdentity = null
397
+ return state.state as PlayerState
398
+ }
409
399
  const track = state.state.track
410
400
  const identity = identityFromTrack(track)
401
+ currentArtworkIdentity = artworkIdentityKey(identity)
411
402
  const artworkState = artworkForTrack(
412
403
  artworkCacheKey(identity),
413
404
  artworkIdentityKey(identity),
@@ -427,7 +418,7 @@ export function createSessionSystemMedia(
427
418
  album: track.album,
428
419
  duration_ms: track.duration_ms,
429
420
  } satisfies SessionArtworkIdentity)
430
- return !disposed && active === client && result.type === "available"
421
+ return active === client && result.type === "available"
431
422
  ? result.base64
432
423
  : null
433
424
  },
@@ -440,8 +431,6 @@ export function createSessionSystemMedia(
440
431
  ...state.state,
441
432
  track: {
442
433
  ...track,
443
- duration_ms:
444
- track.duration_ms > 0 ? track.duration_ms : artworkState.duration_ms,
445
434
  artwork: artworkState.artwork,
446
435
  artwork_loading: artworkState.loading,
447
436
  },
@@ -473,10 +462,12 @@ export function createSessionSystemMedia(
473
462
  ]
474
463
  }
475
464
  const clientPromise = Promise.resolve()
476
- .then(factory)
465
+ .then(() => factory(acquisitionAbort.signal))
477
466
  .then((next) => {
478
467
  if (disposed) {
479
- void releaseClient(next)
468
+ void releaseClient(next).catch((error) => {
469
+ console.error("Failed to dispose late music session client", error)
470
+ })
480
471
  return next
481
472
  }
482
473
  install(next)
@@ -543,18 +534,30 @@ export function createSessionSystemMedia(
543
534
  dispose() {
544
535
  if (disposal) return disposal
545
536
  disposed = true
546
- for (const unsubscribe of unsubscribers.splice(0)) {
547
- try {
548
- unsubscribe()
549
- } catch {}
550
- }
551
- listeners.clear()
552
- presentationListeners.clear()
553
- removeArtworkInterests(host)
554
- disposal = clientPromise.then(
555
- (next) => releaseClient(next),
556
- () => undefined,
557
- )
537
+ disposal = Promise.resolve().then(async () => {
538
+ const errors: unknown[] = []
539
+ for (const unsubscribe of unsubscribers.splice(0)) {
540
+ try {
541
+ unsubscribe()
542
+ } catch (error) {
543
+ errors.push(error)
544
+ }
545
+ }
546
+ listeners.clear()
547
+ presentationListeners.clear()
548
+ removeArtworkInterests(host)
549
+ // Acquisition is cooperative. A late client is released by its callback.
550
+ if (client) {
551
+ try {
552
+ await releaseClient(client)
553
+ } catch (error) {
554
+ errors.push(error)
555
+ }
556
+ }
557
+ if (errors.length)
558
+ throw new AggregateError(errors, "music session cleanup failed")
559
+ })
560
+ acquisitionAbort.abort()
558
561
  return disposal
559
562
  },
560
563
  }
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
  )}