@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 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 catalogArtwork(target, fetcher)
394
- if (!catalog) return { artwork: null, duration_ms: target.duration_ms }
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.4",
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.1",
71
+ "@naxodev/music-core": "^0.1.4",
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
@@ -27,8 +27,7 @@ type ArtworkCacheEntry = {
27
27
  duration_ms: number
28
28
  resolved: boolean
29
29
  pending: boolean
30
- attempts: number
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 = () => Promise<ReconnectingMusicSessionClient>
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 = artworkCache.get(key) ?? artworkJobs.get(key)
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
- attempts: 0,
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(key, target, data, legacyKey)
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 (!resolution.artwork)
271
- activeEntry.retry_at = now() + 2_000 * 2 ** (activeEntry.attempts - 1)
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
- activeEntry.retry_at = now() + 2_000 * 2 ** (activeEntry.attempts - 1)
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 Promise.resolve()
329
+ const releaseClient = async (next: ReconnectingMusicSessionClient) => {
330
+ if (clientReleased) return
320
331
  clientReleased = true
321
- return Promise.resolve(next.dispose()).catch(() => {})
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
- for (const unsubscribe of unsubscribers.splice(0)) {
532
- try {
533
- unsubscribe()
534
- } catch {}
535
- }
536
- listeners.clear()
537
- presentationListeners.clear()
538
- removeArtworkInterests(host)
539
- disposal = clientPromise.then(
540
- (next) => releaseClient(next),
541
- () => undefined,
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>