@naxodev/opencode-music-player 0.2.5 → 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 +2 -2
- package/system-media.ts +39 -17
- 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": {
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"validate": "bun run format:check && bun run check && bun run test && bun run pack:check"
|
|
69
69
|
},
|
|
70
70
|
"dependencies": {
|
|
71
|
-
"@naxodev/music-core": "^0.1.
|
|
71
|
+
"@naxodev/music-core": "^0.1.4",
|
|
72
72
|
"@opencode-ai/plugin": "0.0.0-next-17444",
|
|
73
73
|
"@opentui/core": "0.5.4",
|
|
74
74
|
"@opentui/solid": "0.5.4",
|
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
|
|
@@ -192,6 +191,7 @@ function removeArtworkInterests(host: PresentationHost) {
|
|
|
192
191
|
// A host owns only its interest. The physical job keeps its slot until it settles.
|
|
193
192
|
for (const entry of artworkJobs.values()) {
|
|
194
193
|
entry.interests.delete(host)
|
|
194
|
+
if (!entry.interests.size) entry.abort.abort()
|
|
195
195
|
}
|
|
196
196
|
}
|
|
197
197
|
|
|
@@ -205,9 +205,10 @@ function artworkForTrack(
|
|
|
205
205
|
identity: ArtworkIdentity,
|
|
206
206
|
now: () => number,
|
|
207
207
|
): { artwork: Artwork | null; duration_ms: number; loading: boolean } {
|
|
208
|
-
let entry =
|
|
208
|
+
let entry = artworkJobs.get(key) ?? artworkCache.get(key)
|
|
209
|
+
if (entry?.abort.signal.aborted) entry = undefined
|
|
209
210
|
if (!entry) {
|
|
210
|
-
if (artworkJobs.size >= MAX_ARTWORK_ENTRIES) {
|
|
211
|
+
if (artworkJobs.has(key) || artworkJobs.size >= MAX_ARTWORK_ENTRIES) {
|
|
211
212
|
removeWaitingInterest(host)
|
|
212
213
|
let deferred = waitingArtwork.get(key)
|
|
213
214
|
if (!deferred && waitingArtwork.size < MAX_ARTWORK_ENTRIES) {
|
|
@@ -238,22 +239,15 @@ function artworkForTrack(
|
|
|
238
239
|
duration_ms: target.duration_ms,
|
|
239
240
|
resolved: false,
|
|
240
241
|
pending: false,
|
|
241
|
-
|
|
242
|
-
retry_at: 0,
|
|
242
|
+
abort: new AbortController(),
|
|
243
243
|
interests: new Map(),
|
|
244
244
|
}
|
|
245
245
|
}
|
|
246
246
|
// A state change that reached admission supersedes any older deferred
|
|
247
247
|
// identity owned by this adapter generation.
|
|
248
248
|
removeWaitingInterest(host)
|
|
249
|
-
if (
|
|
250
|
-
!entry.pending &&
|
|
251
|
-
entry.attempts < 3 &&
|
|
252
|
-
now() >= entry.retry_at &&
|
|
253
|
-
(!entry.resolved || entry.value === null)
|
|
254
|
-
) {
|
|
249
|
+
if (!entry.pending && !entry.resolved) {
|
|
255
250
|
entry.pending = true
|
|
256
|
-
entry.attempts++
|
|
257
251
|
artworkJobs.set(key, entry)
|
|
258
252
|
const activeEntry = entry
|
|
259
253
|
void (async () => {
|
|
@@ -263,15 +257,24 @@ function artworkForTrack(
|
|
|
263
257
|
} catch {
|
|
264
258
|
// Session artwork failure is transient; host catalog fallback remains.
|
|
265
259
|
}
|
|
266
|
-
return resolver(
|
|
260
|
+
return resolver(
|
|
261
|
+
key,
|
|
262
|
+
target,
|
|
263
|
+
data,
|
|
264
|
+
legacyKey,
|
|
265
|
+
undefined,
|
|
266
|
+
activeEntry.abort.signal,
|
|
267
|
+
)
|
|
267
268
|
})().then(
|
|
268
269
|
(resolution) => {
|
|
269
270
|
activeEntry.value = resolution.artwork
|
|
270
271
|
activeEntry.duration_ms = resolution.duration_ms
|
|
271
272
|
activeEntry.resolved = true
|
|
272
273
|
activeEntry.pending = false
|
|
273
|
-
if (
|
|
274
|
-
|
|
274
|
+
if (activeEntry.abort.signal.aborted) {
|
|
275
|
+
releaseArtworkSlot(key)
|
|
276
|
+
return
|
|
277
|
+
}
|
|
275
278
|
settleArtworkEntry(key, activeEntry)
|
|
276
279
|
publishArtworkCompletion(activeEntry, resolution.artwork)
|
|
277
280
|
},
|
|
@@ -279,7 +282,10 @@ function artworkForTrack(
|
|
|
279
282
|
activeEntry.value = null
|
|
280
283
|
activeEntry.resolved = true
|
|
281
284
|
activeEntry.pending = false
|
|
282
|
-
|
|
285
|
+
if (activeEntry.abort.signal.aborted) {
|
|
286
|
+
releaseArtworkSlot(key)
|
|
287
|
+
return
|
|
288
|
+
}
|
|
283
289
|
settleArtworkEntry(key, activeEntry)
|
|
284
290
|
publishArtworkCompletion(activeEntry, null)
|
|
285
291
|
},
|
|
@@ -308,6 +314,7 @@ export function createSessionSystemMedia(
|
|
|
308
314
|
const acquisitionAbort = new AbortController()
|
|
309
315
|
let disposed = false
|
|
310
316
|
let currentArtworkIdentity: string | null = null
|
|
317
|
+
let currentArtworkKey: string | null = null
|
|
311
318
|
let client: ReconnectingMusicSessionClient | undefined
|
|
312
319
|
let installed = false
|
|
313
320
|
let latest: RevisionedState | undefined
|
|
@@ -388,6 +395,11 @@ export function createSessionSystemMedia(
|
|
|
388
395
|
emit(event)
|
|
389
396
|
}
|
|
390
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
|
|
391
403
|
if (!state) {
|
|
392
404
|
currentArtworkIdentity = null
|
|
393
405
|
return null
|
|
@@ -490,6 +502,16 @@ export function createSessionSystemMedia(
|
|
|
490
502
|
await activeClient()
|
|
491
503
|
return project(latest)
|
|
492
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
|
+
},
|
|
493
515
|
async play() {
|
|
494
516
|
await (await activeClient()).play()
|
|
495
517
|
},
|
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>
|