@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/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
- }