@naxodev/opencode-music-player 0.2.6 → 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 +13 -14
- package/SUPPORT.md +1 -1
- package/dist/index.js +2272 -0
- package/dist/tui.js +1 -0
- package/package.json +28 -20
- package/artwork-placement.ts +0 -169
- package/artwork.ts +0 -240
- package/artwork.tsx +0 -246
- package/index.tsx +0 -493
- package/kitty-graphics.ts +0 -105
- package/system-media.ts +0 -593
- package/tmux-offset.ts +0 -120
- package/tui.tsx +0 -1
- package/types.ts +0 -144
- package/ui.tsx +0 -695
- package/waveform.tsx +0 -205
package/system-media.ts
DELETED
|
@@ -1,593 +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
|
-
abort: AbortController
|
|
31
|
-
interests: Map<PresentationHost, ArtworkIdentity>
|
|
32
|
-
}
|
|
33
|
-
const MAX_ARTWORK_ENTRIES = 32
|
|
34
|
-
const artworkCache = new Map<string, ArtworkCacheEntry>()
|
|
35
|
-
const artworkJobs = new Map<string, ArtworkCacheEntry>()
|
|
36
|
-
type DeferredArtworkInterest = {
|
|
37
|
-
legacyKey: string
|
|
38
|
-
target: { title: string; artist: string; album: string; duration_ms: number }
|
|
39
|
-
native: () => Promise<string | null>
|
|
40
|
-
resolver: ArtworkResolver
|
|
41
|
-
host: PresentationHost
|
|
42
|
-
identity: ArtworkIdentity
|
|
43
|
-
now: () => number
|
|
44
|
-
}
|
|
45
|
-
type DeferredArtwork = {
|
|
46
|
-
interests: Map<PresentationHost, DeferredArtworkInterest>
|
|
47
|
-
}
|
|
48
|
-
// Deferred admissions are keyed by work, not host. This shares one eventual
|
|
49
|
-
// native/catalog request between views and bounds retained deferred work.
|
|
50
|
-
const waitingArtwork = new Map<string, DeferredArtwork>()
|
|
51
|
-
type ArtworkResolver = typeof resolveArtworkDetails
|
|
52
|
-
|
|
53
|
-
/** Public-contract-only seam for the reconnecting session adapter. */
|
|
54
|
-
export type SessionClientFactory = (
|
|
55
|
-
signal?: AbortSignal,
|
|
56
|
-
) => Promise<ReconnectingMusicSessionClient>
|
|
57
|
-
export type SessionSystemMediaOverrides = {
|
|
58
|
-
readonly createClient?: SessionClientFactory
|
|
59
|
-
readonly resolveArtworkDetails?: ArtworkResolver
|
|
60
|
-
readonly now?: () => number
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
let sessionClientSequence = 0
|
|
64
|
-
const createOpenCodeSessionClient: SessionClientFactory = (signal) =>
|
|
65
|
-
createReconnectingMusicSessionClient({
|
|
66
|
-
clientId: `opencode-music-player-${++sessionClientSequence}`,
|
|
67
|
-
hostKind: "opencode",
|
|
68
|
-
capabilities: [...baselineCapabilities],
|
|
69
|
-
...(signal ? { signal } : {}),
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
type PresentationHost = {
|
|
73
|
-
publish: (event: ArtworkCompletionEvent) => void
|
|
74
|
-
isActive: () => boolean
|
|
75
|
-
accepts: (identity: ArtworkIdentity) => boolean
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export type { CommandResult }
|
|
79
|
-
|
|
80
|
-
export function artworkIdentityKey(identity: ArtworkIdentity): string {
|
|
81
|
-
return JSON.stringify([
|
|
82
|
-
identity.uid,
|
|
83
|
-
identity.title,
|
|
84
|
-
identity.artist,
|
|
85
|
-
identity.album,
|
|
86
|
-
identity.duration_ms,
|
|
87
|
-
])
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/** Cache covers by recording metadata; volatile provider IDs remain valid. */
|
|
91
|
-
export function artworkCacheKey(identity: ArtworkIdentity): string {
|
|
92
|
-
return JSON.stringify([
|
|
93
|
-
identity.title,
|
|
94
|
-
identity.artist,
|
|
95
|
-
identity.album,
|
|
96
|
-
identity.duration_ms,
|
|
97
|
-
])
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function identityFromTrack(track: {
|
|
101
|
-
id: string
|
|
102
|
-
name: string
|
|
103
|
-
artists: string
|
|
104
|
-
album: string
|
|
105
|
-
duration_ms: number
|
|
106
|
-
}): ArtworkIdentity {
|
|
107
|
-
return {
|
|
108
|
-
uid: track.id,
|
|
109
|
-
title: track.name,
|
|
110
|
-
artist: track.artists,
|
|
111
|
-
album: track.album,
|
|
112
|
-
duration_ms: track.duration_ms,
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function removeWaitingInterest(host: PresentationHost) {
|
|
117
|
-
for (const [key, deferred] of waitingArtwork) {
|
|
118
|
-
deferred.interests.delete(host)
|
|
119
|
-
if (deferred.interests.size === 0) waitingArtwork.delete(key)
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
function admitDeferredArtwork() {
|
|
124
|
-
const next = waitingArtwork.entries().next().value as
|
|
125
|
-
[string, DeferredArtwork] | undefined
|
|
126
|
-
if (!next) return
|
|
127
|
-
const [key, deferred] = next
|
|
128
|
-
waitingArtwork.delete(key)
|
|
129
|
-
for (const host of deferred.interests.keys()) {
|
|
130
|
-
if (!host.isActive()) deferred.interests.delete(host)
|
|
131
|
-
}
|
|
132
|
-
const leader = deferred.interests.values().next().value as
|
|
133
|
-
DeferredArtworkInterest | undefined
|
|
134
|
-
if (!leader) {
|
|
135
|
-
admitDeferredArtwork()
|
|
136
|
-
return
|
|
137
|
-
}
|
|
138
|
-
artworkForTrack(
|
|
139
|
-
key,
|
|
140
|
-
leader.legacyKey,
|
|
141
|
-
leader.target,
|
|
142
|
-
leader.native,
|
|
143
|
-
leader.resolver,
|
|
144
|
-
leader.host,
|
|
145
|
-
leader.identity,
|
|
146
|
-
leader.now,
|
|
147
|
-
)
|
|
148
|
-
const admitted = artworkJobs.get(key)
|
|
149
|
-
if (!admitted) return
|
|
150
|
-
for (const interest of deferred.interests.values()) {
|
|
151
|
-
if (interest.host.isActive())
|
|
152
|
-
admitted.interests.set(interest.host, interest.identity)
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function releaseArtworkSlot(key: string, entry?: ArtworkCacheEntry) {
|
|
157
|
-
artworkJobs.delete(key)
|
|
158
|
-
if (entry) {
|
|
159
|
-
artworkCache.set(key, entry)
|
|
160
|
-
if (artworkCache.size > MAX_ARTWORK_ENTRIES) {
|
|
161
|
-
const oldest = artworkCache.keys().next().value
|
|
162
|
-
if (oldest) artworkCache.delete(oldest)
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
admitDeferredArtwork()
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function settleArtworkEntry(key: string, entry: ArtworkCacheEntry) {
|
|
169
|
-
releaseArtworkSlot(key, entry)
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function publishArtworkCompletion(
|
|
173
|
-
entry: ArtworkCacheEntry,
|
|
174
|
-
artwork: Artwork | null,
|
|
175
|
-
) {
|
|
176
|
-
for (const [host, identity] of entry.interests) {
|
|
177
|
-
if (!host.isActive() || !host.accepts(identity)) continue
|
|
178
|
-
host.publish({
|
|
179
|
-
type: "artwork-completion",
|
|
180
|
-
identity,
|
|
181
|
-
artwork,
|
|
182
|
-
duration_ms: entry.duration_ms,
|
|
183
|
-
})
|
|
184
|
-
}
|
|
185
|
-
entry.interests.clear()
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function removeArtworkInterests(host: PresentationHost) {
|
|
189
|
-
removeWaitingInterest(host)
|
|
190
|
-
for (const entry of artworkCache.values()) entry.interests.delete(host)
|
|
191
|
-
// A host owns only its interest. The physical job keeps its slot until it settles.
|
|
192
|
-
for (const entry of artworkJobs.values()) {
|
|
193
|
-
entry.interests.delete(host)
|
|
194
|
-
if (!entry.interests.size) entry.abort.abort()
|
|
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 = artworkJobs.get(key) ?? artworkCache.get(key)
|
|
209
|
-
if (entry?.abort.signal.aborted) entry = undefined
|
|
210
|
-
if (!entry) {
|
|
211
|
-
if (artworkJobs.has(key) || artworkJobs.size >= MAX_ARTWORK_ENTRIES) {
|
|
212
|
-
removeWaitingInterest(host)
|
|
213
|
-
let deferred = waitingArtwork.get(key)
|
|
214
|
-
if (!deferred && waitingArtwork.size < MAX_ARTWORK_ENTRIES) {
|
|
215
|
-
deferred = { interests: new Map() }
|
|
216
|
-
waitingArtwork.set(key, deferred)
|
|
217
|
-
}
|
|
218
|
-
if (deferred && deferred.interests.size < MAX_ARTWORK_ENTRIES)
|
|
219
|
-
deferred.interests.set(host, {
|
|
220
|
-
legacyKey,
|
|
221
|
-
target,
|
|
222
|
-
native,
|
|
223
|
-
resolver,
|
|
224
|
-
host,
|
|
225
|
-
identity,
|
|
226
|
-
now,
|
|
227
|
-
})
|
|
228
|
-
// Overflow is deliberately stable: no additional host/job is retained
|
|
229
|
-
// and the unchanged track renders as a settled no-artwork result rather
|
|
230
|
-
// than a permanent loading indicator in the no-poll session model.
|
|
231
|
-
return {
|
|
232
|
-
artwork: null,
|
|
233
|
-
duration_ms: target.duration_ms,
|
|
234
|
-
loading: !!deferred && deferred.interests.has(host),
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
entry = {
|
|
238
|
-
value: null,
|
|
239
|
-
duration_ms: target.duration_ms,
|
|
240
|
-
resolved: false,
|
|
241
|
-
pending: false,
|
|
242
|
-
abort: new AbortController(),
|
|
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 (!entry.pending && !entry.resolved) {
|
|
250
|
-
entry.pending = true
|
|
251
|
-
artworkJobs.set(key, entry)
|
|
252
|
-
const activeEntry = entry
|
|
253
|
-
void (async () => {
|
|
254
|
-
let data: string | null = null
|
|
255
|
-
try {
|
|
256
|
-
data = await native()
|
|
257
|
-
} catch {
|
|
258
|
-
// Session artwork failure is transient; host catalog fallback remains.
|
|
259
|
-
}
|
|
260
|
-
return resolver(
|
|
261
|
-
key,
|
|
262
|
-
target,
|
|
263
|
-
data,
|
|
264
|
-
legacyKey,
|
|
265
|
-
undefined,
|
|
266
|
-
activeEntry.abort.signal,
|
|
267
|
-
)
|
|
268
|
-
})().then(
|
|
269
|
-
(resolution) => {
|
|
270
|
-
activeEntry.value = resolution.artwork
|
|
271
|
-
activeEntry.duration_ms = resolution.duration_ms
|
|
272
|
-
activeEntry.resolved = true
|
|
273
|
-
activeEntry.pending = false
|
|
274
|
-
if (activeEntry.abort.signal.aborted) {
|
|
275
|
-
releaseArtworkSlot(key)
|
|
276
|
-
return
|
|
277
|
-
}
|
|
278
|
-
settleArtworkEntry(key, activeEntry)
|
|
279
|
-
publishArtworkCompletion(activeEntry, resolution.artwork)
|
|
280
|
-
},
|
|
281
|
-
() => {
|
|
282
|
-
activeEntry.value = null
|
|
283
|
-
activeEntry.resolved = true
|
|
284
|
-
activeEntry.pending = false
|
|
285
|
-
if (activeEntry.abort.signal.aborted) {
|
|
286
|
-
releaseArtworkSlot(key)
|
|
287
|
-
return
|
|
288
|
-
}
|
|
289
|
-
settleArtworkEntry(key, activeEntry)
|
|
290
|
-
publishArtworkCompletion(activeEntry, null)
|
|
291
|
-
},
|
|
292
|
-
)
|
|
293
|
-
}
|
|
294
|
-
if (entry.pending) entry.interests.set(host, identity)
|
|
295
|
-
return {
|
|
296
|
-
artwork: entry.value,
|
|
297
|
-
duration_ms: entry.duration_ms,
|
|
298
|
-
loading: entry.pending,
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/**
|
|
303
|
-
* OpenCode projection over one reconnecting core client. It owns no provider
|
|
304
|
-
* probing, native process execution, polling, playback clock, or command queue.
|
|
305
|
-
*/
|
|
306
|
-
export function createSessionSystemMedia(
|
|
307
|
-
overrides: SessionSystemMediaOverrides = {},
|
|
308
|
-
): SessionMedia {
|
|
309
|
-
const factory = overrides.createClient ?? createOpenCodeSessionClient
|
|
310
|
-
const resolver = overrides.resolveArtworkDetails ?? resolveArtworkDetails
|
|
311
|
-
const now = overrides.now ?? Date.now
|
|
312
|
-
const listeners = new Set<(event: SessionMediaEvent) => void>()
|
|
313
|
-
const presentationListeners = new Set<ArtworkPresentationListener>()
|
|
314
|
-
const acquisitionAbort = new AbortController()
|
|
315
|
-
let disposed = false
|
|
316
|
-
let currentArtworkIdentity: string | null = null
|
|
317
|
-
let currentArtworkKey: string | null = null
|
|
318
|
-
let client: ReconnectingMusicSessionClient | undefined
|
|
319
|
-
let installed = false
|
|
320
|
-
let latest: RevisionedState | undefined
|
|
321
|
-
let latestStatus: ProviderStatus | undefined
|
|
322
|
-
let latestConnection: MusicSessionConnectionLifecycle | undefined
|
|
323
|
-
let acquisitionError: string | undefined
|
|
324
|
-
let publishedLifecycle: string | undefined
|
|
325
|
-
let unsubscribers: Array<() => void> = []
|
|
326
|
-
let clientReleased = false
|
|
327
|
-
let disposal: Promise<void> | undefined
|
|
328
|
-
|
|
329
|
-
const releaseClient = async (next: ReconnectingMusicSessionClient) => {
|
|
330
|
-
if (clientReleased) return
|
|
331
|
-
clientReleased = true
|
|
332
|
-
await next.dispose()
|
|
333
|
-
}
|
|
334
|
-
const emit = (event: SessionMediaEvent) => {
|
|
335
|
-
if (disposed) return
|
|
336
|
-
for (const listener of [...listeners]) {
|
|
337
|
-
try {
|
|
338
|
-
listener(event)
|
|
339
|
-
} catch {
|
|
340
|
-
// One host observer cannot block another.
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
const host: PresentationHost = {
|
|
345
|
-
publish(event) {
|
|
346
|
-
if (disposed) return
|
|
347
|
-
for (const listener of [...presentationListeners]) {
|
|
348
|
-
try {
|
|
349
|
-
listener(event)
|
|
350
|
-
} catch {
|
|
351
|
-
// One presentation observer cannot block another.
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
},
|
|
355
|
-
isActive: () => !disposed,
|
|
356
|
-
accepts: (identity) =>
|
|
357
|
-
currentArtworkIdentity === artworkIdentityKey(identity),
|
|
358
|
-
}
|
|
359
|
-
const lifecycle = (): SessionMediaLifecycleEvent => {
|
|
360
|
-
if (
|
|
361
|
-
latestConnection?.type === "reconnecting" ||
|
|
362
|
-
latestConnection?.type === "terminal"
|
|
363
|
-
)
|
|
364
|
-
return {
|
|
365
|
-
type: "lifecycle",
|
|
366
|
-
message: latestConnection.error.message,
|
|
367
|
-
source: "connection",
|
|
368
|
-
}
|
|
369
|
-
if (latestConnection?.type === "disposed")
|
|
370
|
-
return {
|
|
371
|
-
type: "lifecycle",
|
|
372
|
-
message: "music session is disposed",
|
|
373
|
-
source: "connection",
|
|
374
|
-
}
|
|
375
|
-
if (acquisitionError)
|
|
376
|
-
return {
|
|
377
|
-
type: "lifecycle",
|
|
378
|
-
message: acquisitionError,
|
|
379
|
-
source: "acquisition",
|
|
380
|
-
}
|
|
381
|
-
return {
|
|
382
|
-
type: "lifecycle",
|
|
383
|
-
message:
|
|
384
|
-
latestStatus && latestStatus.kind !== "ready"
|
|
385
|
-
? latestStatus.message
|
|
386
|
-
: null,
|
|
387
|
-
source: "provider",
|
|
388
|
-
}
|
|
389
|
-
}
|
|
390
|
-
const publishLifecycle = () => {
|
|
391
|
-
const event = lifecycle()
|
|
392
|
-
const key = `${event.source}:${event.message}`
|
|
393
|
-
if (publishedLifecycle === key) return
|
|
394
|
-
publishedLifecycle = key
|
|
395
|
-
emit(event)
|
|
396
|
-
}
|
|
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
|
|
403
|
-
if (!state) {
|
|
404
|
-
currentArtworkIdentity = null
|
|
405
|
-
return null
|
|
406
|
-
}
|
|
407
|
-
if (!state.state.track) {
|
|
408
|
-
currentArtworkIdentity = null
|
|
409
|
-
return state.state as PlayerState
|
|
410
|
-
}
|
|
411
|
-
const track = state.state.track
|
|
412
|
-
const identity = identityFromTrack(track)
|
|
413
|
-
currentArtworkIdentity = artworkIdentityKey(identity)
|
|
414
|
-
const artworkState = artworkForTrack(
|
|
415
|
-
artworkCacheKey(identity),
|
|
416
|
-
artworkIdentityKey(identity),
|
|
417
|
-
{
|
|
418
|
-
title: track.name,
|
|
419
|
-
artist: track.artists,
|
|
420
|
-
album: track.album,
|
|
421
|
-
duration_ms: track.duration_ms,
|
|
422
|
-
},
|
|
423
|
-
async () => {
|
|
424
|
-
const active = await clientPromise
|
|
425
|
-
if (disposed || active !== client) return null
|
|
426
|
-
const result: SessionArtworkResult = await active.artwork({
|
|
427
|
-
id: track.id,
|
|
428
|
-
name: track.name,
|
|
429
|
-
artists: track.artists,
|
|
430
|
-
album: track.album,
|
|
431
|
-
duration_ms: track.duration_ms,
|
|
432
|
-
} satisfies SessionArtworkIdentity)
|
|
433
|
-
return active === client && result.type === "available"
|
|
434
|
-
? result.base64
|
|
435
|
-
: null
|
|
436
|
-
},
|
|
437
|
-
resolver,
|
|
438
|
-
host,
|
|
439
|
-
identity,
|
|
440
|
-
now,
|
|
441
|
-
)
|
|
442
|
-
return {
|
|
443
|
-
...state.state,
|
|
444
|
-
track: {
|
|
445
|
-
...track,
|
|
446
|
-
artwork: artworkState.artwork,
|
|
447
|
-
artwork_loading: artworkState.loading,
|
|
448
|
-
},
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
const install = (next: ReconnectingMusicSessionClient) => {
|
|
452
|
-
if (installed) return
|
|
453
|
-
installed = true
|
|
454
|
-
client = next
|
|
455
|
-
latest = next.state
|
|
456
|
-
latestStatus = next.status
|
|
457
|
-
latestConnection = next.connection
|
|
458
|
-
unsubscribers = [
|
|
459
|
-
next.subscribeState((state) => {
|
|
460
|
-
if (disposed) return
|
|
461
|
-
latest = state
|
|
462
|
-
emit({ type: "snapshot", state: project(state)! })
|
|
463
|
-
}),
|
|
464
|
-
next.subscribeStatus((status) => {
|
|
465
|
-
if (disposed) return
|
|
466
|
-
latestStatus = status
|
|
467
|
-
publishLifecycle()
|
|
468
|
-
}),
|
|
469
|
-
next.subscribeConnection((connection) => {
|
|
470
|
-
if (disposed) return
|
|
471
|
-
latestConnection = connection
|
|
472
|
-
publishLifecycle()
|
|
473
|
-
}),
|
|
474
|
-
]
|
|
475
|
-
}
|
|
476
|
-
const clientPromise = Promise.resolve()
|
|
477
|
-
.then(() => factory(acquisitionAbort.signal))
|
|
478
|
-
.then((next) => {
|
|
479
|
-
if (disposed) {
|
|
480
|
-
void releaseClient(next).catch((error) => {
|
|
481
|
-
console.error("Failed to dispose late music session client", error)
|
|
482
|
-
})
|
|
483
|
-
return next
|
|
484
|
-
}
|
|
485
|
-
install(next)
|
|
486
|
-
return next
|
|
487
|
-
})
|
|
488
|
-
void clientPromise.catch((error) => {
|
|
489
|
-
if (disposed) return
|
|
490
|
-
acquisitionError = error instanceof Error ? error.message : String(error)
|
|
491
|
-
publishLifecycle()
|
|
492
|
-
})
|
|
493
|
-
const activeClient = async () => {
|
|
494
|
-
const next = await clientPromise
|
|
495
|
-
if (disposed || next !== client)
|
|
496
|
-
throw new Error("music session is disposed")
|
|
497
|
-
return next
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
return {
|
|
501
|
-
async player() {
|
|
502
|
-
await activeClient()
|
|
503
|
-
return project(latest)
|
|
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
|
-
},
|
|
515
|
-
async play() {
|
|
516
|
-
await (await activeClient()).play()
|
|
517
|
-
},
|
|
518
|
-
async pause() {
|
|
519
|
-
await (await activeClient()).pause()
|
|
520
|
-
},
|
|
521
|
-
async next() {
|
|
522
|
-
await (await activeClient()).next()
|
|
523
|
-
},
|
|
524
|
-
async previous() {
|
|
525
|
-
await (await activeClient()).previous()
|
|
526
|
-
},
|
|
527
|
-
async seek(positionMs) {
|
|
528
|
-
await (await activeClient()).seek(positionMs)
|
|
529
|
-
},
|
|
530
|
-
subscribe(listener) {
|
|
531
|
-
if (disposed) return () => {}
|
|
532
|
-
let closed = false
|
|
533
|
-
listeners.add(listener)
|
|
534
|
-
try {
|
|
535
|
-
if (latest) listener({ type: "snapshot", state: project(latest)! })
|
|
536
|
-
if (installed || acquisitionError) listener(lifecycle())
|
|
537
|
-
} catch {
|
|
538
|
-
// Replay is observer-isolated too.
|
|
539
|
-
}
|
|
540
|
-
return () => {
|
|
541
|
-
if (closed) return
|
|
542
|
-
closed = true
|
|
543
|
-
listeners.delete(listener)
|
|
544
|
-
}
|
|
545
|
-
},
|
|
546
|
-
subscribePresentation(listener) {
|
|
547
|
-
if (disposed) return () => {}
|
|
548
|
-
let closed = false
|
|
549
|
-
presentationListeners.add(listener)
|
|
550
|
-
return () => {
|
|
551
|
-
if (closed) return
|
|
552
|
-
closed = true
|
|
553
|
-
presentationListeners.delete(listener)
|
|
554
|
-
}
|
|
555
|
-
},
|
|
556
|
-
dispose() {
|
|
557
|
-
if (disposal) return disposal
|
|
558
|
-
disposed = true
|
|
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()
|
|
583
|
-
return disposal
|
|
584
|
-
},
|
|
585
|
-
}
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
export function openNowPlayingApp() {
|
|
589
|
-
Bun.spawn(["open", "https://music.youtube.com"], {
|
|
590
|
-
stdout: "ignore",
|
|
591
|
-
stderr: "ignore",
|
|
592
|
-
})
|
|
593
|
-
}
|
package/tmux-offset.ts
DELETED
|
@@ -1,120 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tmux pane-origin offset for absolute Kitty coordinates.
|
|
3
|
-
*
|
|
4
|
-
* Cache is keyed by OpenTUI **pre-offset** slot geometry so resize/layout
|
|
5
|
-
* changes force a re-query even when wall-clock TTL would still allow a hit.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export type SlotGeometry = {
|
|
9
|
-
screenX: number
|
|
10
|
-
screenY: number
|
|
11
|
-
width: number
|
|
12
|
-
height: number
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export type TmuxOffset = { x: number; y: number }
|
|
16
|
-
|
|
17
|
-
export type TmuxOffsetQueryResult = {
|
|
18
|
-
ok: boolean
|
|
19
|
-
stdout: string
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export type TmuxOffsetCache = {
|
|
23
|
-
offset: TmuxOffset | null
|
|
24
|
-
slot: SlotGeometry | null
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
const ZERO: TmuxOffset = { x: 0, y: 0 }
|
|
28
|
-
|
|
29
|
-
export function createTmuxOffsetCache(): TmuxOffsetCache {
|
|
30
|
-
return { offset: null, slot: null }
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** True only when running under tmux without Herdr (Herdr already owns passthrough). */
|
|
34
|
-
export function shouldUseTmuxOffset(
|
|
35
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
36
|
-
): boolean {
|
|
37
|
-
return Boolean(env.TMUX) && !env.HERDR_ENV
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function slotKey(slot: SlotGeometry): string {
|
|
41
|
-
return [slot.screenX, slot.screenY, slot.width, slot.height].join(":")
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
function sameSlotGeometry(
|
|
45
|
-
a: SlotGeometry | null,
|
|
46
|
-
b: SlotGeometry | null,
|
|
47
|
-
): boolean {
|
|
48
|
-
if (a === null || b === null) return false
|
|
49
|
-
return slotKey(a) === slotKey(b)
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Parse `tmux display-message -p '#{pane_left}\t#{pane_top}\t#{status-position}\t#{status}'`.
|
|
54
|
-
* Status bar on top adds one row when status is not off.
|
|
55
|
-
*/
|
|
56
|
-
export function parseTmuxOffsetMessage(stdout: string): TmuxOffset {
|
|
57
|
-
const [left, top, statusPosition, status] = stdout.trim().split("\t")
|
|
58
|
-
return {
|
|
59
|
-
x: Number.parseInt(left ?? "0", 10) || 0,
|
|
60
|
-
y:
|
|
61
|
-
(Number.parseInt(top ?? "0", 10) || 0) +
|
|
62
|
-
(statusPosition === "top" && status !== "off" ? 1 : 0),
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
function queryTmuxOffset(): TmuxOffsetQueryResult {
|
|
67
|
-
const result = Bun.spawnSync([
|
|
68
|
-
"tmux",
|
|
69
|
-
"display-message",
|
|
70
|
-
"-p",
|
|
71
|
-
"#{pane_left}\t#{pane_top}\t#{status-position}\t#{status}",
|
|
72
|
-
])
|
|
73
|
-
return {
|
|
74
|
-
ok: result.exitCode === 0,
|
|
75
|
-
stdout: result.stdout.toString(),
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Resolve pane origin for absolute Kitty placement.
|
|
81
|
-
*
|
|
82
|
-
* - No TMUX / HERDR_ENV → zeros (no spawn).
|
|
83
|
-
* - Slot geometry changed or first paint / empty cache → re-query.
|
|
84
|
-
* - Identical slot + prior successful cache → reuse (no spawn).
|
|
85
|
-
* - Failed query → zeros and clear cache so the next paint retries.
|
|
86
|
-
*/
|
|
87
|
-
export function resolveTerminalOffset(options: {
|
|
88
|
-
env?: NodeJS.ProcessEnv
|
|
89
|
-
slot: SlotGeometry | null
|
|
90
|
-
cache: TmuxOffsetCache
|
|
91
|
-
query?: () => TmuxOffsetQueryResult
|
|
92
|
-
}): TmuxOffset {
|
|
93
|
-
const env = options.env ?? process.env
|
|
94
|
-
if (!shouldUseTmuxOffset(env)) {
|
|
95
|
-
options.cache.offset = null
|
|
96
|
-
options.cache.slot = null
|
|
97
|
-
return ZERO
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
if (
|
|
101
|
-
options.cache.offset !== null &&
|
|
102
|
-
options.slot !== null &&
|
|
103
|
-
sameSlotGeometry(options.cache.slot, options.slot)
|
|
104
|
-
) {
|
|
105
|
-
return options.cache.offset
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
const query = options.query ?? queryTmuxOffset
|
|
109
|
-
const result = query()
|
|
110
|
-
if (!result.ok) {
|
|
111
|
-
options.cache.offset = null
|
|
112
|
-
options.cache.slot = null
|
|
113
|
-
return ZERO
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const offset = parseTmuxOffsetMessage(result.stdout)
|
|
117
|
-
options.cache.offset = offset
|
|
118
|
-
options.cache.slot = options.slot ? { ...options.slot } : null
|
|
119
|
-
return offset
|
|
120
|
-
}
|
package/tui.tsx
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { default } from "./index.tsx"
|