@naxodev/opencode-music-player 0.2.4 → 0.2.6
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/README.md +4 -0
- package/artwork.ts +26 -210
- package/index.tsx +17 -0
- package/package.json +8 -7
- package/system-media.ts +76 -36
- package/types.ts +1 -0
package/README.md
CHANGED
|
@@ -16,6 +16,10 @@ Read the [music session architecture field guide](../../docs/music-session-archi
|
|
|
16
16
|
|
|
17
17
|
The daemon performs the bounded native `media-control get --now` read and validates the complete recording identity before and after the read. OpenCode uses those bytes when available, then keeps iTunes Search fallback, image downloads, conversion, cache/job ownership, and terminal rendering locally. Artwork failure never blocks playback state.
|
|
18
18
|
|
|
19
|
+
Catalog matching, bounded downloads, cancellation, and transient retries use the [shared host-side acquisition policy](../music-core/README.md#host-side-catalog-artwork). OpenCode keeps image conversion and its shared presentation cache/jobs. Track changes remove only the changing view's interest; another view can keep the same job alive.
|
|
20
|
+
|
|
21
|
+
Run `/music-artwork`, or select **Music → Refresh artwork** in the command palette, to recover artwork for the current track after connectivity returns. Automatic recovery stops after three transient attempts. A settled mismatch does not retry on playback snapshots. Refresh starts a new bounded attempt set; repeated refreshes while a job is active share that job. This action does not change playback.
|
|
22
|
+
|
|
19
23
|
Ghostty and other terminals with Kitty graphics support display the cover as a native image. Other terminals receive a true-color half-block rendering of the same cover.
|
|
20
24
|
|
|
21
25
|
Terminal multiplexers must pass Kitty graphics through to use native images. The player uses the half-block rendering when the host does not expose that support.
|
package/artwork.ts
CHANGED
|
@@ -3,226 +3,33 @@ import { tmpdir } from "node:os"
|
|
|
3
3
|
import { join } from "node:path"
|
|
4
4
|
import { PNG } from "pngjs"
|
|
5
5
|
import type { Artwork } from "./types.ts"
|
|
6
|
+
import {
|
|
7
|
+
acquireCatalogArtwork,
|
|
8
|
+
MAX_ARTWORK_BYTES,
|
|
9
|
+
selectCatalogResolution,
|
|
10
|
+
type CatalogTarget as TrackIdentity,
|
|
11
|
+
type ArtworkFetcher as Fetcher,
|
|
12
|
+
} from "@naxodev/music-core"
|
|
13
|
+
export {
|
|
14
|
+
selectCatalogResolution,
|
|
15
|
+
selectCatalogTrack,
|
|
16
|
+
readLimitedResponse,
|
|
17
|
+
downloadCatalogImage,
|
|
18
|
+
type CatalogTrack,
|
|
19
|
+
} from "@naxodev/music-core"
|
|
6
20
|
|
|
7
|
-
const MAX_ARTWORK_BYTES = 3_000_000
|
|
8
|
-
const MAX_CATALOG_RESPONSE_BYTES = 512_000
|
|
9
21
|
const MAX_CONVERTED_PNG_BYTES = 1_000_000
|
|
10
22
|
const MAX_IMAGE_DIMENSION = 4_096
|
|
11
23
|
const MAX_IMAGE_PIXELS = 12_000_000
|
|
12
|
-
const FETCH_TIMEOUT_MS = 4_000
|
|
13
24
|
const CONVERSION_TIMEOUT_MS = 3_000
|
|
14
|
-
// MediaRemote may expose whole seconds while catalogs retain milliseconds.
|
|
15
|
-
const DURATION_TOLERANCE_MS = 1_000
|
|
16
|
-
const MAX_CATALOG_DURATION_MS = 24 * 60 * 60 * 1_000
|
|
17
|
-
|
|
18
|
-
type TrackIdentity = {
|
|
19
|
-
title: string
|
|
20
|
-
artist: string
|
|
21
|
-
album: string
|
|
22
|
-
duration_ms: number
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export type CatalogTrack = {
|
|
26
|
-
trackName?: string
|
|
27
|
-
artistName?: string
|
|
28
|
-
collectionName?: string
|
|
29
|
-
trackTimeMillis?: number
|
|
30
|
-
artworkUrl100?: string
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
type Fetcher = (
|
|
34
|
-
input: string | URL | Request,
|
|
35
|
-
init?: RequestInit,
|
|
36
|
-
) => Promise<Response>
|
|
37
|
-
|
|
38
|
-
function normalized(value: string | undefined): string {
|
|
39
|
-
return (value ?? "")
|
|
40
|
-
.normalize("NFKC")
|
|
41
|
-
.replace(/’/g, "'")
|
|
42
|
-
.replace(/\s+/g, " ")
|
|
43
|
-
.trim()
|
|
44
|
-
.toLowerCase()
|
|
45
|
-
}
|
|
46
25
|
|
|
47
26
|
export function selectArtworkUrl(
|
|
48
27
|
target: TrackIdentity,
|
|
49
|
-
results: CatalogTrack[],
|
|
28
|
+
results: import("@naxodev/music-core").CatalogTrack[],
|
|
50
29
|
): string | null {
|
|
51
30
|
return selectCatalogResolution(target, results).artworkUrl
|
|
52
31
|
}
|
|
53
32
|
|
|
54
|
-
function matchingCatalogTracks(
|
|
55
|
-
target: TrackIdentity,
|
|
56
|
-
results: CatalogTrack[],
|
|
57
|
-
): CatalogTrack[] {
|
|
58
|
-
const title = normalized(target.title)
|
|
59
|
-
const artist = normalized(target.artist)
|
|
60
|
-
const album = normalized(target.album)
|
|
61
|
-
if (!title || !artist) return []
|
|
62
|
-
|
|
63
|
-
let matches = results.filter(
|
|
64
|
-
(item) =>
|
|
65
|
-
normalized(item.trackName) === title &&
|
|
66
|
-
normalized(item.artistName) === artist,
|
|
67
|
-
)
|
|
68
|
-
if (album) {
|
|
69
|
-
matches = matches.filter(
|
|
70
|
-
(item) => normalized(item.collectionName) === album,
|
|
71
|
-
)
|
|
72
|
-
}
|
|
73
|
-
return matches
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function validCatalogDuration(value: number | undefined): value is number {
|
|
77
|
-
return (
|
|
78
|
-
typeof value === "number" &&
|
|
79
|
-
Number.isFinite(value) &&
|
|
80
|
-
value > 0 &&
|
|
81
|
-
value <= MAX_CATALOG_DURATION_MS
|
|
82
|
-
)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function selectCatalogCandidate(
|
|
86
|
-
target: TrackIdentity,
|
|
87
|
-
results: CatalogTrack[],
|
|
88
|
-
): CatalogTrack | null {
|
|
89
|
-
let matches = matchingCatalogTracks(target, results)
|
|
90
|
-
if (target.duration_ms > 0) {
|
|
91
|
-
matches = matches.filter(
|
|
92
|
-
(item) =>
|
|
93
|
-
validCatalogDuration(item.trackTimeMillis) &&
|
|
94
|
-
Math.abs(item.trackTimeMillis! - target.duration_ms) <=
|
|
95
|
-
DURATION_TOLERANCE_MS,
|
|
96
|
-
)
|
|
97
|
-
} else if (matches.length !== 1) {
|
|
98
|
-
return null
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
return matches[0] ?? null
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
export function selectCatalogTrack(
|
|
105
|
-
target: TrackIdentity,
|
|
106
|
-
results: CatalogTrack[],
|
|
107
|
-
): CatalogTrack | null {
|
|
108
|
-
const match = selectCatalogCandidate(target, results)
|
|
109
|
-
return validCatalogDuration(match?.trackTimeMillis) ? match : null
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
export function selectCatalogResolution(
|
|
113
|
-
target: TrackIdentity,
|
|
114
|
-
results: CatalogTrack[],
|
|
115
|
-
): { artworkUrl: string | null; duration_ms: number } {
|
|
116
|
-
const match = selectCatalogCandidate(target, results)
|
|
117
|
-
return {
|
|
118
|
-
artworkUrl:
|
|
119
|
-
match?.artworkUrl100?.replace(/100x100(?=[a-z]*\.)/, "300x300") ?? null,
|
|
120
|
-
duration_ms: validCatalogDuration(match?.trackTimeMillis)
|
|
121
|
-
? match.trackTimeMillis
|
|
122
|
-
: target.duration_ms,
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function allowedCatalogImageUrl(raw: string | URL): URL | null {
|
|
127
|
-
try {
|
|
128
|
-
const url = new URL(raw)
|
|
129
|
-
if (url.protocol !== "https:" || !url.hostname.endsWith(".mzstatic.com")) {
|
|
130
|
-
return null
|
|
131
|
-
}
|
|
132
|
-
return url
|
|
133
|
-
} catch {
|
|
134
|
-
return null
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
export async function readLimitedResponse(
|
|
139
|
-
response: Response,
|
|
140
|
-
maxBytes: number,
|
|
141
|
-
): Promise<Uint8Array | null> {
|
|
142
|
-
const declared = Number(response.headers.get("content-length"))
|
|
143
|
-
if (Number.isFinite(declared) && declared > maxBytes) return null
|
|
144
|
-
if (!response.body) return new Uint8Array()
|
|
145
|
-
|
|
146
|
-
const bytes = new Uint8Array(maxBytes)
|
|
147
|
-
const reader = response.body.getReader()
|
|
148
|
-
let total = 0
|
|
149
|
-
try {
|
|
150
|
-
while (true) {
|
|
151
|
-
const { done, value } = await reader.read()
|
|
152
|
-
if (done) break
|
|
153
|
-
if (total + value.byteLength > maxBytes) {
|
|
154
|
-
await reader.cancel("artwork exceeds byte limit")
|
|
155
|
-
return null
|
|
156
|
-
}
|
|
157
|
-
bytes.set(value, total)
|
|
158
|
-
total += value.byteLength
|
|
159
|
-
}
|
|
160
|
-
} finally {
|
|
161
|
-
reader.releaseLock()
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return bytes.slice(0, total)
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
export async function downloadCatalogImage(
|
|
168
|
-
rawUrl: string,
|
|
169
|
-
fetcher: Fetcher = fetch,
|
|
170
|
-
): Promise<Uint8Array | null> {
|
|
171
|
-
const url = allowedCatalogImageUrl(rawUrl)
|
|
172
|
-
if (!url) return null
|
|
173
|
-
try {
|
|
174
|
-
const response = await fetcher(url, {
|
|
175
|
-
redirect: "error",
|
|
176
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
177
|
-
})
|
|
178
|
-
if (!response.ok || response.redirected) return null
|
|
179
|
-
if (response.url && !allowedCatalogImageUrl(response.url)) return null
|
|
180
|
-
return readLimitedResponse(response, MAX_ARTWORK_BYTES)
|
|
181
|
-
} catch {
|
|
182
|
-
return null
|
|
183
|
-
}
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
async function catalogArtwork(
|
|
187
|
-
target: TrackIdentity,
|
|
188
|
-
fetcher: Fetcher = fetch,
|
|
189
|
-
): Promise<{ bytes: Uint8Array | null; duration_ms: number } | null> {
|
|
190
|
-
if (!target.title || !target.artist) return null
|
|
191
|
-
const term = [target.artist, target.title, target.album]
|
|
192
|
-
.filter(Boolean)
|
|
193
|
-
.join(" ")
|
|
194
|
-
const url = new URL("https://itunes.apple.com/search")
|
|
195
|
-
url.searchParams.set("term", term)
|
|
196
|
-
url.searchParams.set("entity", "song")
|
|
197
|
-
url.searchParams.set("limit", "10")
|
|
198
|
-
|
|
199
|
-
try {
|
|
200
|
-
const result = await fetcher(url, {
|
|
201
|
-
redirect: "error",
|
|
202
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
203
|
-
})
|
|
204
|
-
if (!result.ok || result.redirected) return null
|
|
205
|
-
const responseBytes = await readLimitedResponse(
|
|
206
|
-
result,
|
|
207
|
-
MAX_CATALOG_RESPONSE_BYTES,
|
|
208
|
-
)
|
|
209
|
-
if (!responseBytes) return null
|
|
210
|
-
const payload = JSON.parse(new TextDecoder().decode(responseBytes)) as {
|
|
211
|
-
results?: CatalogTrack[]
|
|
212
|
-
}
|
|
213
|
-
const results = payload.results ?? []
|
|
214
|
-
const resolution = selectCatalogResolution(target, results)
|
|
215
|
-
const bytes = resolution.artworkUrl
|
|
216
|
-
? await downloadCatalogImage(resolution.artworkUrl, fetcher)
|
|
217
|
-
: null
|
|
218
|
-
if (!resolution.artworkUrl && resolution.duration_ms === target.duration_ms)
|
|
219
|
-
return null
|
|
220
|
-
return { bytes, duration_ms: resolution.duration_ms }
|
|
221
|
-
} catch {
|
|
222
|
-
return null
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
33
|
export function imageDimensionsAreSafe(width: number, height: number): boolean {
|
|
227
34
|
return (
|
|
228
35
|
Number.isInteger(width) &&
|
|
@@ -356,7 +163,9 @@ export async function resolveArtworkDetails(
|
|
|
356
163
|
nativeBase64: string | null,
|
|
357
164
|
legacyId = id,
|
|
358
165
|
fetcher: Fetcher = fetch,
|
|
166
|
+
signal?: AbortSignal,
|
|
359
167
|
): Promise<ArtworkResolution> {
|
|
168
|
+
if (signal?.aborted) return { artwork: null, duration_ms: target.duration_ms }
|
|
360
169
|
const candidates: Uint8Array[] = []
|
|
361
170
|
if (nativeBase64) {
|
|
362
171
|
try {
|
|
@@ -390,8 +199,15 @@ export async function resolveArtworkDetails(
|
|
|
390
199
|
}
|
|
391
200
|
}
|
|
392
201
|
|
|
393
|
-
const catalog = await
|
|
394
|
-
|
|
202
|
+
const catalog = await acquireCatalogArtwork(target, {
|
|
203
|
+
fetch: fetcher,
|
|
204
|
+
signal,
|
|
205
|
+
})
|
|
206
|
+
if (catalog.kind !== "available" || signal?.aborted)
|
|
207
|
+
return {
|
|
208
|
+
artwork: null,
|
|
209
|
+
duration_ms: catalog.duration_ms ?? target.duration_ms,
|
|
210
|
+
}
|
|
395
211
|
if (!catalog.bytes) {
|
|
396
212
|
return { artwork: null, duration_ms: catalog.duration_ms }
|
|
397
213
|
}
|
package/index.tsx
CHANGED
|
@@ -26,6 +26,7 @@ export type Controller = {
|
|
|
26
26
|
subscribe: (listener: (session: SessionStore) => void) => () => void
|
|
27
27
|
openApp: () => Promise<void>
|
|
28
28
|
refreshAll: () => Promise<void>
|
|
29
|
+
refreshArtwork: () => Promise<void>
|
|
29
30
|
playPause: () => Promise<void>
|
|
30
31
|
seek: (positionMs: number) => Promise<void>
|
|
31
32
|
next: () => Promise<void>
|
|
@@ -321,6 +322,14 @@ export function createController(
|
|
|
321
322
|
return () => sessionListeners.delete(listener)
|
|
322
323
|
},
|
|
323
324
|
refreshAll,
|
|
325
|
+
refreshArtwork: async () => {
|
|
326
|
+
if (!isActive()) return
|
|
327
|
+
try {
|
|
328
|
+
await media.refreshArtwork()
|
|
329
|
+
} catch (error) {
|
|
330
|
+
if (isActive()) setTransportError(errMsg(error))
|
|
331
|
+
}
|
|
332
|
+
},
|
|
324
333
|
async openApp() {
|
|
325
334
|
if (!isActive()) return
|
|
326
335
|
try {
|
|
@@ -396,6 +405,14 @@ function AppHost(props: { context: Context; ctrl: Controller }) {
|
|
|
396
405
|
slash: { name: "music-app" },
|
|
397
406
|
run: () => void ctrl.openApp(),
|
|
398
407
|
},
|
|
408
|
+
{
|
|
409
|
+
id: "music.refresh-artwork",
|
|
410
|
+
title: "Refresh artwork",
|
|
411
|
+
group: "Music",
|
|
412
|
+
palette: true,
|
|
413
|
+
slash: { name: "music-artwork" },
|
|
414
|
+
run: () => void ctrl.refreshArtwork(),
|
|
415
|
+
},
|
|
399
416
|
],
|
|
400
417
|
}))
|
|
401
418
|
return (
|
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.6",
|
|
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
|
-
"@naxodev/music-core": "^0.1.
|
|
71
|
+
"@naxodev/music-core": "^0.1.4",
|
|
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
|
@@ -27,8 +27,7 @@ type ArtworkCacheEntry = {
|
|
|
27
27
|
duration_ms: number
|
|
28
28
|
resolved: boolean
|
|
29
29
|
pending: boolean
|
|
30
|
-
|
|
31
|
-
retry_at: number
|
|
30
|
+
abort: AbortController
|
|
32
31
|
interests: Map<PresentationHost, ArtworkIdentity>
|
|
33
32
|
}
|
|
34
33
|
const MAX_ARTWORK_ENTRIES = 32
|
|
@@ -52,7 +51,9 @@ const waitingArtwork = new Map<string, DeferredArtwork>()
|
|
|
52
51
|
type ArtworkResolver = typeof resolveArtworkDetails
|
|
53
52
|
|
|
54
53
|
/** Public-contract-only seam for the reconnecting session adapter. */
|
|
55
|
-
export type SessionClientFactory = (
|
|
54
|
+
export type SessionClientFactory = (
|
|
55
|
+
signal?: AbortSignal,
|
|
56
|
+
) => Promise<ReconnectingMusicSessionClient>
|
|
56
57
|
export type SessionSystemMediaOverrides = {
|
|
57
58
|
readonly createClient?: SessionClientFactory
|
|
58
59
|
readonly resolveArtworkDetails?: ArtworkResolver
|
|
@@ -60,11 +61,12 @@ export type SessionSystemMediaOverrides = {
|
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
let sessionClientSequence = 0
|
|
63
|
-
const createOpenCodeSessionClient: SessionClientFactory = () =>
|
|
64
|
+
const createOpenCodeSessionClient: SessionClientFactory = (signal) =>
|
|
64
65
|
createReconnectingMusicSessionClient({
|
|
65
66
|
clientId: `opencode-music-player-${++sessionClientSequence}`,
|
|
66
67
|
hostKind: "opencode",
|
|
67
68
|
capabilities: [...baselineCapabilities],
|
|
69
|
+
...(signal ? { signal } : {}),
|
|
68
70
|
})
|
|
69
71
|
|
|
70
72
|
type PresentationHost = {
|
|
@@ -189,6 +191,7 @@ function removeArtworkInterests(host: PresentationHost) {
|
|
|
189
191
|
// A host owns only its interest. The physical job keeps its slot until it settles.
|
|
190
192
|
for (const entry of artworkJobs.values()) {
|
|
191
193
|
entry.interests.delete(host)
|
|
194
|
+
if (!entry.interests.size) entry.abort.abort()
|
|
192
195
|
}
|
|
193
196
|
}
|
|
194
197
|
|
|
@@ -202,9 +205,10 @@ function artworkForTrack(
|
|
|
202
205
|
identity: ArtworkIdentity,
|
|
203
206
|
now: () => number,
|
|
204
207
|
): { artwork: Artwork | null; duration_ms: number; loading: boolean } {
|
|
205
|
-
let entry =
|
|
208
|
+
let entry = artworkJobs.get(key) ?? artworkCache.get(key)
|
|
209
|
+
if (entry?.abort.signal.aborted) entry = undefined
|
|
206
210
|
if (!entry) {
|
|
207
|
-
if (artworkJobs.size >= MAX_ARTWORK_ENTRIES) {
|
|
211
|
+
if (artworkJobs.has(key) || artworkJobs.size >= MAX_ARTWORK_ENTRIES) {
|
|
208
212
|
removeWaitingInterest(host)
|
|
209
213
|
let deferred = waitingArtwork.get(key)
|
|
210
214
|
if (!deferred && waitingArtwork.size < MAX_ARTWORK_ENTRIES) {
|
|
@@ -235,22 +239,15 @@ function artworkForTrack(
|
|
|
235
239
|
duration_ms: target.duration_ms,
|
|
236
240
|
resolved: false,
|
|
237
241
|
pending: false,
|
|
238
|
-
|
|
239
|
-
retry_at: 0,
|
|
242
|
+
abort: new AbortController(),
|
|
240
243
|
interests: new Map(),
|
|
241
244
|
}
|
|
242
245
|
}
|
|
243
246
|
// A state change that reached admission supersedes any older deferred
|
|
244
247
|
// identity owned by this adapter generation.
|
|
245
248
|
removeWaitingInterest(host)
|
|
246
|
-
if (
|
|
247
|
-
!entry.pending &&
|
|
248
|
-
entry.attempts < 3 &&
|
|
249
|
-
now() >= entry.retry_at &&
|
|
250
|
-
(!entry.resolved || entry.value === null)
|
|
251
|
-
) {
|
|
249
|
+
if (!entry.pending && !entry.resolved) {
|
|
252
250
|
entry.pending = true
|
|
253
|
-
entry.attempts++
|
|
254
251
|
artworkJobs.set(key, entry)
|
|
255
252
|
const activeEntry = entry
|
|
256
253
|
void (async () => {
|
|
@@ -260,15 +257,24 @@ function artworkForTrack(
|
|
|
260
257
|
} catch {
|
|
261
258
|
// Session artwork failure is transient; host catalog fallback remains.
|
|
262
259
|
}
|
|
263
|
-
return resolver(
|
|
260
|
+
return resolver(
|
|
261
|
+
key,
|
|
262
|
+
target,
|
|
263
|
+
data,
|
|
264
|
+
legacyKey,
|
|
265
|
+
undefined,
|
|
266
|
+
activeEntry.abort.signal,
|
|
267
|
+
)
|
|
264
268
|
})().then(
|
|
265
269
|
(resolution) => {
|
|
266
270
|
activeEntry.value = resolution.artwork
|
|
267
271
|
activeEntry.duration_ms = resolution.duration_ms
|
|
268
272
|
activeEntry.resolved = true
|
|
269
273
|
activeEntry.pending = false
|
|
270
|
-
if (
|
|
271
|
-
|
|
274
|
+
if (activeEntry.abort.signal.aborted) {
|
|
275
|
+
releaseArtworkSlot(key)
|
|
276
|
+
return
|
|
277
|
+
}
|
|
272
278
|
settleArtworkEntry(key, activeEntry)
|
|
273
279
|
publishArtworkCompletion(activeEntry, resolution.artwork)
|
|
274
280
|
},
|
|
@@ -276,7 +282,10 @@ function artworkForTrack(
|
|
|
276
282
|
activeEntry.value = null
|
|
277
283
|
activeEntry.resolved = true
|
|
278
284
|
activeEntry.pending = false
|
|
279
|
-
|
|
285
|
+
if (activeEntry.abort.signal.aborted) {
|
|
286
|
+
releaseArtworkSlot(key)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
280
289
|
settleArtworkEntry(key, activeEntry)
|
|
281
290
|
publishArtworkCompletion(activeEntry, null)
|
|
282
291
|
},
|
|
@@ -302,8 +311,10 @@ export function createSessionSystemMedia(
|
|
|
302
311
|
const now = overrides.now ?? Date.now
|
|
303
312
|
const listeners = new Set<(event: SessionMediaEvent) => void>()
|
|
304
313
|
const presentationListeners = new Set<ArtworkPresentationListener>()
|
|
314
|
+
const acquisitionAbort = new AbortController()
|
|
305
315
|
let disposed = false
|
|
306
316
|
let currentArtworkIdentity: string | null = null
|
|
317
|
+
let currentArtworkKey: string | null = null
|
|
307
318
|
let client: ReconnectingMusicSessionClient | undefined
|
|
308
319
|
let installed = false
|
|
309
320
|
let latest: RevisionedState | undefined
|
|
@@ -315,10 +326,10 @@ export function createSessionSystemMedia(
|
|
|
315
326
|
let clientReleased = false
|
|
316
327
|
let disposal: Promise<void> | undefined
|
|
317
328
|
|
|
318
|
-
const releaseClient = (next: ReconnectingMusicSessionClient) => {
|
|
319
|
-
if (clientReleased) return
|
|
329
|
+
const releaseClient = async (next: ReconnectingMusicSessionClient) => {
|
|
330
|
+
if (clientReleased) return
|
|
320
331
|
clientReleased = true
|
|
321
|
-
|
|
332
|
+
await next.dispose()
|
|
322
333
|
}
|
|
323
334
|
const emit = (event: SessionMediaEvent) => {
|
|
324
335
|
if (disposed) return
|
|
@@ -384,6 +395,11 @@ export function createSessionSystemMedia(
|
|
|
384
395
|
emit(event)
|
|
385
396
|
}
|
|
386
397
|
const project = (state: RevisionedState | undefined): PlayerState | null => {
|
|
398
|
+
const nextKey = state?.state.track
|
|
399
|
+
? artworkCacheKey(identityFromTrack(state.state.track))
|
|
400
|
+
: null
|
|
401
|
+
if (nextKey !== currentArtworkKey) removeArtworkInterests(host)
|
|
402
|
+
currentArtworkKey = nextKey
|
|
387
403
|
if (!state) {
|
|
388
404
|
currentArtworkIdentity = null
|
|
389
405
|
return null
|
|
@@ -458,10 +474,12 @@ export function createSessionSystemMedia(
|
|
|
458
474
|
]
|
|
459
475
|
}
|
|
460
476
|
const clientPromise = Promise.resolve()
|
|
461
|
-
.then(factory)
|
|
477
|
+
.then(() => factory(acquisitionAbort.signal))
|
|
462
478
|
.then((next) => {
|
|
463
479
|
if (disposed) {
|
|
464
|
-
void releaseClient(next)
|
|
480
|
+
void releaseClient(next).catch((error) => {
|
|
481
|
+
console.error("Failed to dispose late music session client", error)
|
|
482
|
+
})
|
|
465
483
|
return next
|
|
466
484
|
}
|
|
467
485
|
install(next)
|
|
@@ -484,6 +502,16 @@ export function createSessionSystemMedia(
|
|
|
484
502
|
await activeClient()
|
|
485
503
|
return project(latest)
|
|
486
504
|
},
|
|
505
|
+
async refreshArtwork() {
|
|
506
|
+
await activeClient()
|
|
507
|
+
const track = latest?.state.track
|
|
508
|
+
if (!track) return
|
|
509
|
+
const key = artworkCacheKey(identityFromTrack(track))
|
|
510
|
+
// Repeated refreshes join active work; no new slot or retry budget.
|
|
511
|
+
if (artworkJobs.has(key)) return
|
|
512
|
+
artworkCache.delete(key)
|
|
513
|
+
emit({ type: "snapshot", state: project(latest)! })
|
|
514
|
+
},
|
|
487
515
|
async play() {
|
|
488
516
|
await (await activeClient()).play()
|
|
489
517
|
},
|
|
@@ -528,18 +556,30 @@ export function createSessionSystemMedia(
|
|
|
528
556
|
dispose() {
|
|
529
557
|
if (disposal) return disposal
|
|
530
558
|
disposed = true
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
(
|
|
541
|
-
()
|
|
542
|
-
|
|
559
|
+
disposal = Promise.resolve().then(async () => {
|
|
560
|
+
const errors: unknown[] = []
|
|
561
|
+
for (const unsubscribe of unsubscribers.splice(0)) {
|
|
562
|
+
try {
|
|
563
|
+
unsubscribe()
|
|
564
|
+
} catch (error) {
|
|
565
|
+
errors.push(error)
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
listeners.clear()
|
|
569
|
+
presentationListeners.clear()
|
|
570
|
+
removeArtworkInterests(host)
|
|
571
|
+
// Acquisition is cooperative. A late client is released by its callback.
|
|
572
|
+
if (client) {
|
|
573
|
+
try {
|
|
574
|
+
await releaseClient(client)
|
|
575
|
+
} catch (error) {
|
|
576
|
+
errors.push(error)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (errors.length)
|
|
580
|
+
throw new AggregateError(errors, "music session cleanup failed")
|
|
581
|
+
})
|
|
582
|
+
acquisitionAbort.abort()
|
|
543
583
|
return disposal
|
|
544
584
|
},
|
|
545
585
|
}
|
package/types.ts
CHANGED
|
@@ -68,6 +68,7 @@ export type SessionMediaDisposer = () => void
|
|
|
68
68
|
/** The OpenCode controller's session-only media contract. */
|
|
69
69
|
export type SessionMedia = {
|
|
70
70
|
player: () => Promise<PlayerState | null>
|
|
71
|
+
refreshArtwork: () => Promise<void>
|
|
71
72
|
play: () => Promise<unknown>
|
|
72
73
|
pause: () => Promise<unknown>
|
|
73
74
|
next: () => Promise<unknown>
|