@naxodev/opencode-music-player 0.2.3 → 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 +1 -1
- package/index.tsx +71 -69
- package/kitty-graphics.ts +19 -4
- package/package.json +1 -1
- package/system-media.ts +18 -33
- package/types.ts +0 -4
- package/ui.tsx +17 -7
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
|
|
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 {
|
|
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
|
-
/**
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
128
|
-
|
|
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
|
-
|
|
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(() =>
|
|
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
|
-
|
|
201
|
+
void runCommand(
|
|
211
202
|
() => media.seek(intent.positionMs),
|
|
212
203
|
() => {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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 = !(
|
|
251
|
-
|
|
252
|
-
|
|
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
|
-
|
|
257
|
-
|
|
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
|
-
)
|
|
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(
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
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
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
|
|
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)
|
|
408
|
-
|
|
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
|
|
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
|
|
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 =
|
|
180
|
-
const cleanArtist =
|
|
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) =>
|
|
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}>
|
|
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}>
|
|
635
|
+
<text fg={theme().text.subdued}>
|
|
636
|
+
{sanitizeTerminalText(t().album)}
|
|
637
|
+
</text>
|
|
628
638
|
</Show>
|
|
629
639
|
</box>
|
|
630
640
|
)}
|