@naxodev/opencode-music-player 0.2.5 → 0.3.0
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 +17 -14
- package/SUPPORT.md +1 -1
- package/dist/index.js +2272 -0
- package/dist/tui.js +1 -0
- package/package.json +29 -21
- package/artwork-placement.ts +0 -169
- package/artwork.ts +0 -424
- package/artwork.tsx +0 -246
- package/index.tsx +0 -476
- package/kitty-graphics.ts +0 -105
- package/system-media.ts +0 -571
- package/tmux-offset.ts +0 -120
- package/tui.tsx +0 -1
- package/types.ts +0 -143
- package/ui.tsx +0 -695
- package/waveform.tsx +0 -205
package/kitty-graphics.ts
DELETED
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import type { CliRenderer } from "@opentui/core"
|
|
2
|
-
|
|
3
|
-
const APC = "\x1b_G"
|
|
4
|
-
const ST = "\x1b\\"
|
|
5
|
-
const CHUNK_SIZE = 4096
|
|
6
|
-
|
|
7
|
-
export function tmuxPassthrough(data: string): string {
|
|
8
|
-
return `\x1bPtmux;${data.replaceAll("\x1b", "\x1b\x1b")}\x1b\\`
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
export function kittyTransmitPng(pngBase64: string, imageId: number): string[] {
|
|
12
|
-
const chunks: string[] = []
|
|
13
|
-
for (let offset = 0; offset < pngBase64.length; offset += CHUNK_SIZE) {
|
|
14
|
-
const chunk = pngBase64.slice(offset, offset + CHUNK_SIZE)
|
|
15
|
-
const more = offset + CHUNK_SIZE < pngBase64.length ? 1 : 0
|
|
16
|
-
const control =
|
|
17
|
-
offset === 0 ? `a=t,f=100,i=${imageId},q=2,m=${more}` : `m=${more}`
|
|
18
|
-
chunks.push(`${APC}${control};${chunk}${ST}`)
|
|
19
|
-
}
|
|
20
|
-
return chunks
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function kittyDisplayPng(
|
|
24
|
-
pngBase64: string,
|
|
25
|
-
imageId: number,
|
|
26
|
-
x: number,
|
|
27
|
-
y: number,
|
|
28
|
-
width: number,
|
|
29
|
-
height: number,
|
|
30
|
-
): string[] {
|
|
31
|
-
const chunks = kittyTransmitPng(pngBase64, imageId)
|
|
32
|
-
return chunks.map((command, index) => {
|
|
33
|
-
let next = command
|
|
34
|
-
if (index === 0) {
|
|
35
|
-
next = next.replace(
|
|
36
|
-
`${APC}a=t,f=100,i=${imageId},q=2,`,
|
|
37
|
-
`\x1b7\x1b[${y + 1};${x + 1}H${APC}a=T,f=100,i=${imageId},p=${imageId},q=2,C=1,c=${width},r=${height},z=1,`,
|
|
38
|
-
)
|
|
39
|
-
}
|
|
40
|
-
return index === chunks.length - 1 ? `${next}\x1b8` : next
|
|
41
|
-
})
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function kittyPlace(
|
|
45
|
-
imageId: number,
|
|
46
|
-
placementId: number,
|
|
47
|
-
x: number,
|
|
48
|
-
y: number,
|
|
49
|
-
width: number,
|
|
50
|
-
height: number,
|
|
51
|
-
): string {
|
|
52
|
-
return `\x1b7\x1b[${y + 1};${x + 1}H${APC}a=p,i=${imageId},p=${placementId},q=2,c=${width},r=${height},z=1;${ST}\x1b8`
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function kittyDelete(imageId: number): string {
|
|
56
|
-
return `${APC}a=d,d=I,i=${imageId},q=2;${ST}`
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function kittyDeletePlacement(imageId: number): string {
|
|
60
|
-
return `${APC}a=d,d=i,i=${imageId},q=2;${ST}`
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
export function kittyImageId(key: string): number {
|
|
64
|
-
let hash = 2166136261
|
|
65
|
-
for (let i = 0; i < key.length; i++) {
|
|
66
|
-
hash ^= key.charCodeAt(i)
|
|
67
|
-
hash = Math.imul(hash, 16777619)
|
|
68
|
-
}
|
|
69
|
-
return hash >>> 0 || 1
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
type RendererWriter = {
|
|
73
|
-
stdout?: NodeJS.WriteStream
|
|
74
|
-
realStdoutWrite?: NodeJS.WriteStream["write"]
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** OpenTUI detects Kitty graphics but does not yet expose a raw output API. */
|
|
78
|
-
export function writeGraphics(
|
|
79
|
-
renderer: CliRenderer,
|
|
80
|
-
data: string | readonly string[],
|
|
81
|
-
): boolean {
|
|
82
|
-
const target = renderer as unknown as RendererWriter
|
|
83
|
-
if (!target.stdout || typeof target.realStdoutWrite !== "function")
|
|
84
|
-
return false
|
|
85
|
-
const output = (value: string) =>
|
|
86
|
-
process.env.TMUX && !process.env.HERDR_ENV ? tmuxPassthrough(value) : value
|
|
87
|
-
let cursorSaved = false
|
|
88
|
-
try {
|
|
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
|
-
}
|
|
94
|
-
return true
|
|
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
|
-
}
|
|
103
|
-
return false
|
|
104
|
-
}
|
|
105
|
-
}
|
package/system-media.ts
DELETED
|
@@ -1,571 +0,0 @@
|
|
|
1
|
-
/** OpenCode session facade plus host-local artwork presentation. */
|
|
2
|
-
import {
|
|
3
|
-
baselineCapabilities,
|
|
4
|
-
createReconnectingMusicSessionClient,
|
|
5
|
-
type ArtworkIdentity as SessionArtworkIdentity,
|
|
6
|
-
type ArtworkResult as SessionArtworkResult,
|
|
7
|
-
type CommandResult,
|
|
8
|
-
type MusicSessionConnectionLifecycle,
|
|
9
|
-
type ProviderStatus,
|
|
10
|
-
type ReconnectingMusicSessionClient,
|
|
11
|
-
type RevisionedState,
|
|
12
|
-
} from "@naxodev/music-core"
|
|
13
|
-
import { resolveArtworkDetails } from "./artwork.ts"
|
|
14
|
-
import type {
|
|
15
|
-
Artwork,
|
|
16
|
-
ArtworkCompletionEvent,
|
|
17
|
-
ArtworkIdentity,
|
|
18
|
-
ArtworkPresentationListener,
|
|
19
|
-
SessionMedia,
|
|
20
|
-
SessionMediaEvent,
|
|
21
|
-
SessionMediaLifecycleEvent,
|
|
22
|
-
PlayerState,
|
|
23
|
-
} from "./types.ts"
|
|
24
|
-
|
|
25
|
-
type ArtworkCacheEntry = {
|
|
26
|
-
value: Artwork | null
|
|
27
|
-
duration_ms: number
|
|
28
|
-
resolved: boolean
|
|
29
|
-
pending: boolean
|
|
30
|
-
attempts: number
|
|
31
|
-
retry_at: number
|
|
32
|
-
interests: Map<PresentationHost, ArtworkIdentity>
|
|
33
|
-
}
|
|
34
|
-
const MAX_ARTWORK_ENTRIES = 32
|
|
35
|
-
const artworkCache = new Map<string, ArtworkCacheEntry>()
|
|
36
|
-
const artworkJobs = new Map<string, ArtworkCacheEntry>()
|
|
37
|
-
type DeferredArtworkInterest = {
|
|
38
|
-
legacyKey: string
|
|
39
|
-
target: { title: string; artist: string; album: string; duration_ms: number }
|
|
40
|
-
native: () => Promise<string | null>
|
|
41
|
-
resolver: ArtworkResolver
|
|
42
|
-
host: PresentationHost
|
|
43
|
-
identity: ArtworkIdentity
|
|
44
|
-
now: () => number
|
|
45
|
-
}
|
|
46
|
-
type DeferredArtwork = {
|
|
47
|
-
interests: Map<PresentationHost, DeferredArtworkInterest>
|
|
48
|
-
}
|
|
49
|
-
// Deferred admissions are keyed by work, not host. This shares one eventual
|
|
50
|
-
// native/catalog request between views and bounds retained deferred work.
|
|
51
|
-
const waitingArtwork = new Map<string, DeferredArtwork>()
|
|
52
|
-
type ArtworkResolver = typeof resolveArtworkDetails
|
|
53
|
-
|
|
54
|
-
/** Public-contract-only seam for the reconnecting session adapter. */
|
|
55
|
-
export type SessionClientFactory = (
|
|
56
|
-
signal?: AbortSignal,
|
|
57
|
-
) => Promise<ReconnectingMusicSessionClient>
|
|
58
|
-
export type SessionSystemMediaOverrides = {
|
|
59
|
-
readonly createClient?: SessionClientFactory
|
|
60
|
-
readonly resolveArtworkDetails?: ArtworkResolver
|
|
61
|
-
readonly now?: () => number
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
let sessionClientSequence = 0
|
|
65
|
-
const createOpenCodeSessionClient: SessionClientFactory = (signal) =>
|
|
66
|
-
createReconnectingMusicSessionClient({
|
|
67
|
-
clientId: `opencode-music-player-${++sessionClientSequence}`,
|
|
68
|
-
hostKind: "opencode",
|
|
69
|
-
capabilities: [...baselineCapabilities],
|
|
70
|
-
...(signal ? { signal } : {}),
|
|
71
|
-
})
|
|
72
|
-
|
|
73
|
-
type PresentationHost = {
|
|
74
|
-
publish: (event: ArtworkCompletionEvent) => void
|
|
75
|
-
isActive: () => boolean
|
|
76
|
-
accepts: (identity: ArtworkIdentity) => boolean
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export type { CommandResult }
|
|
80
|
-
|
|
81
|
-
export function artworkIdentityKey(identity: ArtworkIdentity): string {
|
|
82
|
-
return JSON.stringify([
|
|
83
|
-
identity.uid,
|
|
84
|
-
identity.title,
|
|
85
|
-
identity.artist,
|
|
86
|
-
identity.album,
|
|
87
|
-
identity.duration_ms,
|
|
88
|
-
])
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** Cache covers by recording metadata; volatile provider IDs remain valid. */
|
|
92
|
-
export function artworkCacheKey(identity: ArtworkIdentity): string {
|
|
93
|
-
return JSON.stringify([
|
|
94
|
-
identity.title,
|
|
95
|
-
identity.artist,
|
|
96
|
-
identity.album,
|
|
97
|
-
identity.duration_ms,
|
|
98
|
-
])
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
function identityFromTrack(track: {
|
|
102
|
-
id: string
|
|
103
|
-
name: string
|
|
104
|
-
artists: string
|
|
105
|
-
album: string
|
|
106
|
-
duration_ms: number
|
|
107
|
-
}): ArtworkIdentity {
|
|
108
|
-
return {
|
|
109
|
-
uid: track.id,
|
|
110
|
-
title: track.name,
|
|
111
|
-
artist: track.artists,
|
|
112
|
-
album: track.album,
|
|
113
|
-
duration_ms: track.duration_ms,
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
function removeWaitingInterest(host: PresentationHost) {
|
|
118
|
-
for (const [key, deferred] of waitingArtwork) {
|
|
119
|
-
deferred.interests.delete(host)
|
|
120
|
-
if (deferred.interests.size === 0) waitingArtwork.delete(key)
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function admitDeferredArtwork() {
|
|
125
|
-
const next = waitingArtwork.entries().next().value as
|
|
126
|
-
[string, DeferredArtwork] | undefined
|
|
127
|
-
if (!next) return
|
|
128
|
-
const [key, deferred] = next
|
|
129
|
-
waitingArtwork.delete(key)
|
|
130
|
-
for (const host of deferred.interests.keys()) {
|
|
131
|
-
if (!host.isActive()) deferred.interests.delete(host)
|
|
132
|
-
}
|
|
133
|
-
const leader = deferred.interests.values().next().value as
|
|
134
|
-
DeferredArtworkInterest | undefined
|
|
135
|
-
if (!leader) {
|
|
136
|
-
admitDeferredArtwork()
|
|
137
|
-
return
|
|
138
|
-
}
|
|
139
|
-
artworkForTrack(
|
|
140
|
-
key,
|
|
141
|
-
leader.legacyKey,
|
|
142
|
-
leader.target,
|
|
143
|
-
leader.native,
|
|
144
|
-
leader.resolver,
|
|
145
|
-
leader.host,
|
|
146
|
-
leader.identity,
|
|
147
|
-
leader.now,
|
|
148
|
-
)
|
|
149
|
-
const admitted = artworkJobs.get(key)
|
|
150
|
-
if (!admitted) return
|
|
151
|
-
for (const interest of deferred.interests.values()) {
|
|
152
|
-
if (interest.host.isActive())
|
|
153
|
-
admitted.interests.set(interest.host, interest.identity)
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function releaseArtworkSlot(key: string, entry?: ArtworkCacheEntry) {
|
|
158
|
-
artworkJobs.delete(key)
|
|
159
|
-
if (entry) {
|
|
160
|
-
artworkCache.set(key, entry)
|
|
161
|
-
if (artworkCache.size > MAX_ARTWORK_ENTRIES) {
|
|
162
|
-
const oldest = artworkCache.keys().next().value
|
|
163
|
-
if (oldest) artworkCache.delete(oldest)
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
admitDeferredArtwork()
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
function settleArtworkEntry(key: string, entry: ArtworkCacheEntry) {
|
|
170
|
-
releaseArtworkSlot(key, entry)
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
function publishArtworkCompletion(
|
|
174
|
-
entry: ArtworkCacheEntry,
|
|
175
|
-
artwork: Artwork | null,
|
|
176
|
-
) {
|
|
177
|
-
for (const [host, identity] of entry.interests) {
|
|
178
|
-
if (!host.isActive() || !host.accepts(identity)) continue
|
|
179
|
-
host.publish({
|
|
180
|
-
type: "artwork-completion",
|
|
181
|
-
identity,
|
|
182
|
-
artwork,
|
|
183
|
-
duration_ms: entry.duration_ms,
|
|
184
|
-
})
|
|
185
|
-
}
|
|
186
|
-
entry.interests.clear()
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
function removeArtworkInterests(host: PresentationHost) {
|
|
190
|
-
removeWaitingInterest(host)
|
|
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()) {
|
|
194
|
-
entry.interests.delete(host)
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
function artworkForTrack(
|
|
199
|
-
key: string,
|
|
200
|
-
legacyKey: string,
|
|
201
|
-
target: { title: string; artist: string; album: string; duration_ms: number },
|
|
202
|
-
native: () => Promise<string | null>,
|
|
203
|
-
resolver: ArtworkResolver,
|
|
204
|
-
host: PresentationHost,
|
|
205
|
-
identity: ArtworkIdentity,
|
|
206
|
-
now: () => number,
|
|
207
|
-
): { artwork: Artwork | null; duration_ms: number; loading: boolean } {
|
|
208
|
-
let entry = artworkCache.get(key) ?? artworkJobs.get(key)
|
|
209
|
-
if (!entry) {
|
|
210
|
-
if (artworkJobs.size >= MAX_ARTWORK_ENTRIES) {
|
|
211
|
-
removeWaitingInterest(host)
|
|
212
|
-
let deferred = waitingArtwork.get(key)
|
|
213
|
-
if (!deferred && waitingArtwork.size < MAX_ARTWORK_ENTRIES) {
|
|
214
|
-
deferred = { interests: new Map() }
|
|
215
|
-
waitingArtwork.set(key, deferred)
|
|
216
|
-
}
|
|
217
|
-
if (deferred && deferred.interests.size < MAX_ARTWORK_ENTRIES)
|
|
218
|
-
deferred.interests.set(host, {
|
|
219
|
-
legacyKey,
|
|
220
|
-
target,
|
|
221
|
-
native,
|
|
222
|
-
resolver,
|
|
223
|
-
host,
|
|
224
|
-
identity,
|
|
225
|
-
now,
|
|
226
|
-
})
|
|
227
|
-
// Overflow is deliberately stable: no additional host/job is retained
|
|
228
|
-
// and the unchanged track renders as a settled no-artwork result rather
|
|
229
|
-
// than a permanent loading indicator in the no-poll session model.
|
|
230
|
-
return {
|
|
231
|
-
artwork: null,
|
|
232
|
-
duration_ms: target.duration_ms,
|
|
233
|
-
loading: !!deferred && deferred.interests.has(host),
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
entry = {
|
|
237
|
-
value: null,
|
|
238
|
-
duration_ms: target.duration_ms,
|
|
239
|
-
resolved: false,
|
|
240
|
-
pending: false,
|
|
241
|
-
attempts: 0,
|
|
242
|
-
retry_at: 0,
|
|
243
|
-
interests: new Map(),
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
// A state change that reached admission supersedes any older deferred
|
|
247
|
-
// identity owned by this adapter generation.
|
|
248
|
-
removeWaitingInterest(host)
|
|
249
|
-
if (
|
|
250
|
-
!entry.pending &&
|
|
251
|
-
entry.attempts < 3 &&
|
|
252
|
-
now() >= entry.retry_at &&
|
|
253
|
-
(!entry.resolved || entry.value === null)
|
|
254
|
-
) {
|
|
255
|
-
entry.pending = true
|
|
256
|
-
entry.attempts++
|
|
257
|
-
artworkJobs.set(key, entry)
|
|
258
|
-
const activeEntry = entry
|
|
259
|
-
void (async () => {
|
|
260
|
-
let data: string | null = null
|
|
261
|
-
try {
|
|
262
|
-
data = await native()
|
|
263
|
-
} catch {
|
|
264
|
-
// Session artwork failure is transient; host catalog fallback remains.
|
|
265
|
-
}
|
|
266
|
-
return resolver(key, target, data, legacyKey)
|
|
267
|
-
})().then(
|
|
268
|
-
(resolution) => {
|
|
269
|
-
activeEntry.value = resolution.artwork
|
|
270
|
-
activeEntry.duration_ms = resolution.duration_ms
|
|
271
|
-
activeEntry.resolved = true
|
|
272
|
-
activeEntry.pending = false
|
|
273
|
-
if (!resolution.artwork)
|
|
274
|
-
activeEntry.retry_at = now() + 2_000 * 2 ** (activeEntry.attempts - 1)
|
|
275
|
-
settleArtworkEntry(key, activeEntry)
|
|
276
|
-
publishArtworkCompletion(activeEntry, resolution.artwork)
|
|
277
|
-
},
|
|
278
|
-
() => {
|
|
279
|
-
activeEntry.value = null
|
|
280
|
-
activeEntry.resolved = true
|
|
281
|
-
activeEntry.pending = false
|
|
282
|
-
activeEntry.retry_at = now() + 2_000 * 2 ** (activeEntry.attempts - 1)
|
|
283
|
-
settleArtworkEntry(key, activeEntry)
|
|
284
|
-
publishArtworkCompletion(activeEntry, null)
|
|
285
|
-
},
|
|
286
|
-
)
|
|
287
|
-
}
|
|
288
|
-
if (entry.pending) entry.interests.set(host, identity)
|
|
289
|
-
return {
|
|
290
|
-
artwork: entry.value,
|
|
291
|
-
duration_ms: entry.duration_ms,
|
|
292
|
-
loading: entry.pending,
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/**
|
|
297
|
-
* OpenCode projection over one reconnecting core client. It owns no provider
|
|
298
|
-
* probing, native process execution, polling, playback clock, or command queue.
|
|
299
|
-
*/
|
|
300
|
-
export function createSessionSystemMedia(
|
|
301
|
-
overrides: SessionSystemMediaOverrides = {},
|
|
302
|
-
): SessionMedia {
|
|
303
|
-
const factory = overrides.createClient ?? createOpenCodeSessionClient
|
|
304
|
-
const resolver = overrides.resolveArtworkDetails ?? resolveArtworkDetails
|
|
305
|
-
const now = overrides.now ?? Date.now
|
|
306
|
-
const listeners = new Set<(event: SessionMediaEvent) => void>()
|
|
307
|
-
const presentationListeners = new Set<ArtworkPresentationListener>()
|
|
308
|
-
const acquisitionAbort = new AbortController()
|
|
309
|
-
let disposed = false
|
|
310
|
-
let currentArtworkIdentity: string | null = null
|
|
311
|
-
let client: ReconnectingMusicSessionClient | undefined
|
|
312
|
-
let installed = false
|
|
313
|
-
let latest: RevisionedState | undefined
|
|
314
|
-
let latestStatus: ProviderStatus | undefined
|
|
315
|
-
let latestConnection: MusicSessionConnectionLifecycle | undefined
|
|
316
|
-
let acquisitionError: string | undefined
|
|
317
|
-
let publishedLifecycle: string | undefined
|
|
318
|
-
let unsubscribers: Array<() => void> = []
|
|
319
|
-
let clientReleased = false
|
|
320
|
-
let disposal: Promise<void> | undefined
|
|
321
|
-
|
|
322
|
-
const releaseClient = async (next: ReconnectingMusicSessionClient) => {
|
|
323
|
-
if (clientReleased) return
|
|
324
|
-
clientReleased = true
|
|
325
|
-
await next.dispose()
|
|
326
|
-
}
|
|
327
|
-
const emit = (event: SessionMediaEvent) => {
|
|
328
|
-
if (disposed) return
|
|
329
|
-
for (const listener of [...listeners]) {
|
|
330
|
-
try {
|
|
331
|
-
listener(event)
|
|
332
|
-
} catch {
|
|
333
|
-
// One host observer cannot block another.
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
const host: PresentationHost = {
|
|
338
|
-
publish(event) {
|
|
339
|
-
if (disposed) return
|
|
340
|
-
for (const listener of [...presentationListeners]) {
|
|
341
|
-
try {
|
|
342
|
-
listener(event)
|
|
343
|
-
} catch {
|
|
344
|
-
// One presentation observer cannot block another.
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
},
|
|
348
|
-
isActive: () => !disposed,
|
|
349
|
-
accepts: (identity) =>
|
|
350
|
-
currentArtworkIdentity === artworkIdentityKey(identity),
|
|
351
|
-
}
|
|
352
|
-
const lifecycle = (): SessionMediaLifecycleEvent => {
|
|
353
|
-
if (
|
|
354
|
-
latestConnection?.type === "reconnecting" ||
|
|
355
|
-
latestConnection?.type === "terminal"
|
|
356
|
-
)
|
|
357
|
-
return {
|
|
358
|
-
type: "lifecycle",
|
|
359
|
-
message: latestConnection.error.message,
|
|
360
|
-
source: "connection",
|
|
361
|
-
}
|
|
362
|
-
if (latestConnection?.type === "disposed")
|
|
363
|
-
return {
|
|
364
|
-
type: "lifecycle",
|
|
365
|
-
message: "music session is disposed",
|
|
366
|
-
source: "connection",
|
|
367
|
-
}
|
|
368
|
-
if (acquisitionError)
|
|
369
|
-
return {
|
|
370
|
-
type: "lifecycle",
|
|
371
|
-
message: acquisitionError,
|
|
372
|
-
source: "acquisition",
|
|
373
|
-
}
|
|
374
|
-
return {
|
|
375
|
-
type: "lifecycle",
|
|
376
|
-
message:
|
|
377
|
-
latestStatus && latestStatus.kind !== "ready"
|
|
378
|
-
? latestStatus.message
|
|
379
|
-
: null,
|
|
380
|
-
source: "provider",
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
const publishLifecycle = () => {
|
|
384
|
-
const event = lifecycle()
|
|
385
|
-
const key = `${event.source}:${event.message}`
|
|
386
|
-
if (publishedLifecycle === key) return
|
|
387
|
-
publishedLifecycle = key
|
|
388
|
-
emit(event)
|
|
389
|
-
}
|
|
390
|
-
const project = (state: RevisionedState | undefined): PlayerState | null => {
|
|
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
|
-
}
|
|
399
|
-
const track = state.state.track
|
|
400
|
-
const identity = identityFromTrack(track)
|
|
401
|
-
currentArtworkIdentity = artworkIdentityKey(identity)
|
|
402
|
-
const artworkState = artworkForTrack(
|
|
403
|
-
artworkCacheKey(identity),
|
|
404
|
-
artworkIdentityKey(identity),
|
|
405
|
-
{
|
|
406
|
-
title: track.name,
|
|
407
|
-
artist: track.artists,
|
|
408
|
-
album: track.album,
|
|
409
|
-
duration_ms: track.duration_ms,
|
|
410
|
-
},
|
|
411
|
-
async () => {
|
|
412
|
-
const active = await clientPromise
|
|
413
|
-
if (disposed || active !== client) return null
|
|
414
|
-
const result: SessionArtworkResult = await active.artwork({
|
|
415
|
-
id: track.id,
|
|
416
|
-
name: track.name,
|
|
417
|
-
artists: track.artists,
|
|
418
|
-
album: track.album,
|
|
419
|
-
duration_ms: track.duration_ms,
|
|
420
|
-
} satisfies SessionArtworkIdentity)
|
|
421
|
-
return active === client && result.type === "available"
|
|
422
|
-
? result.base64
|
|
423
|
-
: null
|
|
424
|
-
},
|
|
425
|
-
resolver,
|
|
426
|
-
host,
|
|
427
|
-
identity,
|
|
428
|
-
now,
|
|
429
|
-
)
|
|
430
|
-
return {
|
|
431
|
-
...state.state,
|
|
432
|
-
track: {
|
|
433
|
-
...track,
|
|
434
|
-
artwork: artworkState.artwork,
|
|
435
|
-
artwork_loading: artworkState.loading,
|
|
436
|
-
},
|
|
437
|
-
}
|
|
438
|
-
}
|
|
439
|
-
const install = (next: ReconnectingMusicSessionClient) => {
|
|
440
|
-
if (installed) return
|
|
441
|
-
installed = true
|
|
442
|
-
client = next
|
|
443
|
-
latest = next.state
|
|
444
|
-
latestStatus = next.status
|
|
445
|
-
latestConnection = next.connection
|
|
446
|
-
unsubscribers = [
|
|
447
|
-
next.subscribeState((state) => {
|
|
448
|
-
if (disposed) return
|
|
449
|
-
latest = state
|
|
450
|
-
emit({ type: "snapshot", state: project(state)! })
|
|
451
|
-
}),
|
|
452
|
-
next.subscribeStatus((status) => {
|
|
453
|
-
if (disposed) return
|
|
454
|
-
latestStatus = status
|
|
455
|
-
publishLifecycle()
|
|
456
|
-
}),
|
|
457
|
-
next.subscribeConnection((connection) => {
|
|
458
|
-
if (disposed) return
|
|
459
|
-
latestConnection = connection
|
|
460
|
-
publishLifecycle()
|
|
461
|
-
}),
|
|
462
|
-
]
|
|
463
|
-
}
|
|
464
|
-
const clientPromise = Promise.resolve()
|
|
465
|
-
.then(() => factory(acquisitionAbort.signal))
|
|
466
|
-
.then((next) => {
|
|
467
|
-
if (disposed) {
|
|
468
|
-
void releaseClient(next).catch((error) => {
|
|
469
|
-
console.error("Failed to dispose late music session client", error)
|
|
470
|
-
})
|
|
471
|
-
return next
|
|
472
|
-
}
|
|
473
|
-
install(next)
|
|
474
|
-
return next
|
|
475
|
-
})
|
|
476
|
-
void clientPromise.catch((error) => {
|
|
477
|
-
if (disposed) return
|
|
478
|
-
acquisitionError = error instanceof Error ? error.message : String(error)
|
|
479
|
-
publishLifecycle()
|
|
480
|
-
})
|
|
481
|
-
const activeClient = async () => {
|
|
482
|
-
const next = await clientPromise
|
|
483
|
-
if (disposed || next !== client)
|
|
484
|
-
throw new Error("music session is disposed")
|
|
485
|
-
return next
|
|
486
|
-
}
|
|
487
|
-
|
|
488
|
-
return {
|
|
489
|
-
async player() {
|
|
490
|
-
await activeClient()
|
|
491
|
-
return project(latest)
|
|
492
|
-
},
|
|
493
|
-
async play() {
|
|
494
|
-
await (await activeClient()).play()
|
|
495
|
-
},
|
|
496
|
-
async pause() {
|
|
497
|
-
await (await activeClient()).pause()
|
|
498
|
-
},
|
|
499
|
-
async next() {
|
|
500
|
-
await (await activeClient()).next()
|
|
501
|
-
},
|
|
502
|
-
async previous() {
|
|
503
|
-
await (await activeClient()).previous()
|
|
504
|
-
},
|
|
505
|
-
async seek(positionMs) {
|
|
506
|
-
await (await activeClient()).seek(positionMs)
|
|
507
|
-
},
|
|
508
|
-
subscribe(listener) {
|
|
509
|
-
if (disposed) return () => {}
|
|
510
|
-
let closed = false
|
|
511
|
-
listeners.add(listener)
|
|
512
|
-
try {
|
|
513
|
-
if (latest) listener({ type: "snapshot", state: project(latest)! })
|
|
514
|
-
if (installed || acquisitionError) listener(lifecycle())
|
|
515
|
-
} catch {
|
|
516
|
-
// Replay is observer-isolated too.
|
|
517
|
-
}
|
|
518
|
-
return () => {
|
|
519
|
-
if (closed) return
|
|
520
|
-
closed = true
|
|
521
|
-
listeners.delete(listener)
|
|
522
|
-
}
|
|
523
|
-
},
|
|
524
|
-
subscribePresentation(listener) {
|
|
525
|
-
if (disposed) return () => {}
|
|
526
|
-
let closed = false
|
|
527
|
-
presentationListeners.add(listener)
|
|
528
|
-
return () => {
|
|
529
|
-
if (closed) return
|
|
530
|
-
closed = true
|
|
531
|
-
presentationListeners.delete(listener)
|
|
532
|
-
}
|
|
533
|
-
},
|
|
534
|
-
dispose() {
|
|
535
|
-
if (disposal) return disposal
|
|
536
|
-
disposed = true
|
|
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()
|
|
561
|
-
return disposal
|
|
562
|
-
},
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
export function openNowPlayingApp() {
|
|
567
|
-
Bun.spawn(["open", "https://music.youtube.com"], {
|
|
568
|
-
stdout: "ignore",
|
|
569
|
-
stderr: "ignore",
|
|
570
|
-
})
|
|
571
|
-
}
|