@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 +1 -1
- package/index.tsx +71 -69
- package/kitty-graphics.ts +19 -4
- package/package.json +7 -6
- package/system-media.ts +55 -52
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@naxodev/opencode-music-player",
|
|
3
|
-
"version": "0.2.
|
|
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.
|
|
73
|
-
"@opentui/solid": "0.5.
|
|
73
|
+
"@opentui/core": "0.5.4",
|
|
74
|
+
"@opentui/solid": "0.5.4",
|
|
74
75
|
"pngjs": "^7.0.0",
|
|
75
|
-
"solid-js": "1.9.
|
|
76
|
+
"solid-js": "1.9.15"
|
|
76
77
|
},
|
|
77
78
|
"devDependencies": {
|
|
78
|
-
"@opencode-ai/theme": "0.0.0
|
|
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 = (
|
|
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
|
|
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
|
|
322
|
+
const releaseClient = async (next: ReconnectingMusicSessionClient) => {
|
|
323
|
+
if (clientReleased) return
|
|
342
324
|
clientReleased = true
|
|
343
|
-
|
|
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)
|
|
408
|
-
|
|
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
|
|
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
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
(
|
|
556
|
-
()
|
|
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
|
|
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
|
)}
|