@naxodev/opencode-music-player 0.2.6 → 0.3.1

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/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"