@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/index.tsx DELETED
@@ -1,493 +0,0 @@
1
- /** @jsxImportSource @opentui/solid */
2
- import { Plugin } from "@opencode-ai/plugin/tui"
3
- import { createSessionSystemMedia, openNowPlayingApp } from "./system-media.ts"
4
- import {
5
- isMac,
6
- mergeArtworkCompletion,
7
- mergePlayerSnapshot,
8
- type SessionMedia,
9
- } from "./types.ts"
10
- import {
11
- CompactPlayer,
12
- sanitizeTerminalText,
13
- SidebarPlayer,
14
- type UiState,
15
- } from "./ui.tsx"
16
-
17
- type Context = Plugin.Context
18
- type SessionStore = UiState & { loadingOwners?: string[] }
19
- type SeekIntent = {
20
- positionMs: number
21
- resolves: Array<() => void>
22
- }
23
-
24
- export type Controller = {
25
- session: SessionStore
26
- subscribe: (listener: (session: SessionStore) => void) => () => void
27
- openApp: () => Promise<void>
28
- refreshAll: () => Promise<void>
29
- refreshArtwork: () => Promise<void>
30
- playPause: () => Promise<void>
31
- seek: (positionMs: number) => Promise<void>
32
- next: () => Promise<void>
33
- prev: () => Promise<void>
34
- dispose: () => void
35
- }
36
-
37
- export type ControllerDependencies = {
38
- createSessionMedia: () => SessionMedia
39
- }
40
-
41
- /**
42
- * Seeking to the exact duration finishes the track immediately, so a click
43
- * near the end of a seek bar would skip songs instead of seeking. Clamp to
44
- * just short of the end; skipping is what the next button is for.
45
- */
46
- export const SEEK_END_MARGIN_MS = 1_000
47
-
48
- function seekTarget(positionMs: number, durationMs?: number): number | null {
49
- if (
50
- !Number.isFinite(positionMs) ||
51
- typeof durationMs !== "number" ||
52
- !Number.isFinite(durationMs) ||
53
- durationMs <= 0
54
- )
55
- return null
56
- const ceiling = Math.max(0, durationMs - SEEK_END_MARGIN_MS)
57
- return Math.max(0, Math.min(ceiling, Math.round(positionMs)))
58
- }
59
-
60
- const controllerDependencies: ControllerDependencies = {
61
- createSessionMedia: createSessionSystemMedia,
62
- }
63
-
64
- function errMsg(error: unknown): string {
65
- return error && typeof error === "object" && "message" in error
66
- ? String((error as { message: unknown }).message)
67
- : String(error)
68
- }
69
-
70
- export function createController(
71
- context: Context,
72
- dependencies: ControllerDependencies = controllerDependencies,
73
- ): Controller {
74
- const [session, setSessionStore] = context.storage.memory<SessionStore>(
75
- "music-player.session.v6",
76
- {
77
- initial: {
78
- loading: false,
79
- loadingOwners: [],
80
- error: null,
81
- player: null,
82
- },
83
- },
84
- )
85
- const sessionListeners = new Set<(session: SessionStore) => void>()
86
- const setSession = (update: (draft: SessionStore) => void) => {
87
- setSessionStore(update)
88
- for (const listener of [...sessionListeners]) {
89
- try {
90
- listener(session)
91
- } catch {
92
- // One mounted presentation cannot block controller state.
93
- }
94
- }
95
- }
96
- const loadingOwner = crypto.randomUUID()
97
- const media = dependencies.createSessionMedia()
98
- let disposed = false
99
- let lifecycleGeneration = 0
100
- let eventDisposer: (() => void) | null = null
101
- let presentationDisposer: (() => void) | null = null
102
- let lifecycleError: {
103
- message: string | null
104
- source: "connection" | "provider" | "acquisition" | undefined
105
- } = { message: null, source: undefined }
106
- let transportError: string | null = null
107
- let receivedSnapshot = false
108
- let snapshotEpoch = 0
109
- let playbackIntent:
110
- | {
111
- readonly target: boolean
112
- readonly startedAtEpoch: number
113
- settled: boolean
114
- }
115
- | undefined
116
- let activeSeek: SeekIntent | null = null
117
- let latestSeek: SeekIntent | null = null
118
- const pending = new Set<() => void>()
119
- const isActive = () => !disposed
120
-
121
- const publishError = () => {
122
- if (!isActive()) return
123
- const message =
124
- lifecycleError.source === "connection" && lifecycleError.message
125
- ? lifecycleError.message
126
- : (transportError ?? lifecycleError.message)
127
- setSession((draft) => {
128
- draft.error = message
129
- })
130
- }
131
- const setLifecycleError = (
132
- message: string | null,
133
- source: "connection" | "provider" | "acquisition" | undefined,
134
- ) => {
135
- lifecycleError = { message, source }
136
- publishError()
137
- }
138
- const setTransportError = (message: string | null) => {
139
- transportError = message
140
- publishError()
141
- }
142
- const setLoadingOwner = (active: boolean) => {
143
- setSession((draft) => {
144
- const owners = new Set(draft.loadingOwners ?? [])
145
- if (active) owners.add(loadingOwner)
146
- else owners.delete(loadingOwner)
147
- draft.loadingOwners = [...owners]
148
- draft.loading = owners.size > 0
149
- })
150
- }
151
- const updateLoading = () => {
152
- if (isActive()) setLoadingOwner(pending.size > 0)
153
- }
154
- const settle = (resolve: () => void) => {
155
- if (!pending.delete(resolve)) return
156
- resolve()
157
- updateLoading()
158
- }
159
-
160
- const runCommand = (
161
- command: () => Promise<unknown>,
162
- onSettled?: (succeeded: boolean) => void,
163
- ) => {
164
- if (!isActive()) return Promise.resolve()
165
- const generation = lifecycleGeneration
166
- return new Promise<void>((resolve) => {
167
- pending.add(resolve)
168
- updateLoading()
169
- let result: Promise<unknown> | undefined
170
- try {
171
- result = command()
172
- } catch (error) {
173
- result = Promise.reject(error)
174
- }
175
- let succeeded = false
176
- void Promise.resolve(result)
177
- .then(
178
- () => {
179
- succeeded = true
180
- if (!isActive() || generation !== lifecycleGeneration) return
181
- if (transportError !== null) setTransportError(null)
182
- },
183
- (error) => {
184
- if (!isActive() || generation !== lifecycleGeneration) return
185
- const message = sanitizeTerminalText(errMsg(error))
186
- setTransportError(message)
187
- context.ui.toast.show({ title: "Music", message, variant: "error" })
188
- },
189
- )
190
- .finally(() => {
191
- try {
192
- onSettled?.(succeeded)
193
- } finally {
194
- settle(resolve)
195
- }
196
- })
197
- })
198
- }
199
-
200
- const runSeek = (intent: SeekIntent) => {
201
- activeSeek = intent
202
- void runCommand(
203
- () => media.seek(intent.positionMs),
204
- () => {
205
- if (activeSeek !== intent) return
206
- activeSeek = null
207
- for (const resolve of intent.resolves) resolve()
208
- intent.resolves = []
209
- const next = latestSeek
210
- latestSeek = null
211
- if (isActive() && next) runSeek(next)
212
- else next?.resolves.splice(0).forEach((resolve) => resolve())
213
- },
214
- )
215
- }
216
-
217
- const refreshAll = async () => {
218
- if (!isActive()) return
219
- const generation = lifecycleGeneration
220
- try {
221
- const player = await media.player()
222
- if (!isActive() || generation !== lifecycleGeneration || receivedSnapshot)
223
- return
224
- setSession((draft) => {
225
- draft.player = player
226
- })
227
- } catch (error) {
228
- if (isActive() && generation === lifecycleGeneration)
229
- setTransportError(errMsg(error))
230
- }
231
- }
232
-
233
- const playPause = () => {
234
- const nextPlaying = !(
235
- playbackIntent?.target ?? !!session.player?.is_playing
236
- )
237
- const intent = {
238
- target: nextPlaying,
239
- startedAtEpoch: snapshotEpoch,
240
- settled: false,
241
- }
242
- playbackIntent = intent
243
- return runCommand(
244
- () => (nextPlaying ? media.play() : media.pause()),
245
- (succeeded) => {
246
- if (playbackIntent !== intent) return
247
- if (!succeeded) {
248
- playbackIntent = undefined
249
- return
250
- }
251
- intent.settled = true
252
- if (
253
- snapshotEpoch > intent.startedAtEpoch &&
254
- session.player?.is_playing === intent.target
255
- )
256
- playbackIntent = undefined
257
- },
258
- )
259
- }
260
-
261
- const seek = (positionMs: number) => {
262
- const target = seekTarget(positionMs, session.player?.track?.duration_ms)
263
- if (!session.player?.track || target === null || !isActive())
264
- return Promise.resolve()
265
- return new Promise<void>((resolve) => {
266
- if (activeSeek) {
267
- if (latestSeek) {
268
- latestSeek.positionMs = target
269
- latestSeek.resolves.push(resolve)
270
- } else {
271
- latestSeek = { positionMs: target, resolves: [resolve] }
272
- }
273
- return
274
- }
275
- runSeek({ positionMs: target, resolves: [resolve] })
276
- })
277
- }
278
-
279
- eventDisposer = media.subscribe((event) => {
280
- if (!isActive()) return
281
- if (event?.type === "snapshot") {
282
- receivedSnapshot = true
283
- snapshotEpoch++
284
- setSession((draft) => {
285
- draft.player = mergePlayerSnapshot(draft.player, event.state)
286
- })
287
- if (
288
- playbackIntent?.settled &&
289
- snapshotEpoch > playbackIntent.startedAtEpoch &&
290
- event.state.is_playing === playbackIntent.target
291
- )
292
- playbackIntent = undefined
293
- return
294
- }
295
- if (event?.type === "lifecycle") {
296
- // Lifecycle and transport feedback are retained independently. A
297
- // connection loss takes precedence, a transport failure temporarily
298
- // takes precedence over provider status, and clearing it restores the
299
- // latest daemon lifecycle state without requiring a repeated event.
300
- setLifecycleError(event.message, event.source)
301
- if (event.source === "connection" && event.message !== null)
302
- playbackIntent = undefined
303
- if (event.message !== null && latestSeek) {
304
- latestSeek.resolves.splice(0).forEach((resolve) => resolve())
305
- latestSeek = null
306
- }
307
- }
308
- })
309
- presentationDisposer = media.subscribePresentation((event) => {
310
- if (!isActive()) return
311
- setSession((draft) => {
312
- draft.player = mergeArtworkCompletion(draft.player, event)
313
- })
314
- })
315
- void refreshAll()
316
-
317
- return {
318
- session,
319
- subscribe(listener) {
320
- sessionListeners.add(listener)
321
- listener(session)
322
- return () => sessionListeners.delete(listener)
323
- },
324
- refreshAll,
325
- refreshArtwork: async () => {
326
- if (!isActive()) return
327
- try {
328
- await media.refreshArtwork()
329
- } catch (error) {
330
- if (isActive()) setTransportError(errMsg(error))
331
- }
332
- },
333
- async openApp() {
334
- if (!isActive()) return
335
- try {
336
- openNowPlayingApp()
337
- context.ui.toast.show({
338
- title: "Music",
339
- message: "Play in any app — the sidebar uses system media",
340
- variant: "info",
341
- })
342
- } catch (error) {
343
- setTransportError(errMsg(error))
344
- }
345
- },
346
- playPause,
347
- seek,
348
- next: () => runCommand(() => media.next()),
349
- prev: () => runCommand(() => media.previous()),
350
- dispose() {
351
- if (disposed) return
352
- disposed = true
353
- lifecycleGeneration++
354
- eventDisposer?.()
355
- eventDisposer = null
356
- presentationDisposer?.()
357
- presentationDisposer = null
358
- latestSeek?.resolves.splice(0).forEach((resolve) => resolve())
359
- latestSeek = null
360
- activeSeek?.resolves.splice(0).forEach((resolve) => resolve())
361
- activeSeek = null
362
- for (const resolve of [...pending]) settle(resolve)
363
- void media.dispose().catch(() => {})
364
- setLoadingOwner(false)
365
- sessionListeners.clear()
366
- },
367
- }
368
- }
369
-
370
- function AppHost(props: { context: Context; ctrl: Controller }) {
371
- const { context, ctrl } = props
372
- context.keymap.layer(() => ({
373
- mode: "global",
374
- priority: 200,
375
- commands: [
376
- {
377
- id: "music.playpause",
378
- title: "Play / pause",
379
- group: "Music",
380
- bind: "ctrl+shift+p",
381
- palette: true,
382
- run: () => void ctrl.playPause(),
383
- },
384
- {
385
- id: "music.next",
386
- title: "Next track",
387
- group: "Music",
388
- bind: "ctrl+shift+right",
389
- palette: true,
390
- run: () => void ctrl.next(),
391
- },
392
- {
393
- id: "music.prev",
394
- title: "Previous track",
395
- group: "Music",
396
- bind: "ctrl+shift+left",
397
- palette: true,
398
- run: () => void ctrl.prev(),
399
- },
400
- {
401
- id: "music.open-app",
402
- title: "Open music site",
403
- group: "Music",
404
- palette: true,
405
- slash: { name: "music-app" },
406
- run: () => void ctrl.openApp(),
407
- },
408
- {
409
- id: "music.refresh-artwork",
410
- title: "Refresh artwork",
411
- group: "Music",
412
- palette: true,
413
- slash: { name: "music-artwork" },
414
- run: () => void ctrl.refreshArtwork(),
415
- },
416
- ],
417
- }))
418
- return (
419
- <CompactPlayer
420
- context={context}
421
- state={ctrl.session}
422
- onPlayPause={() => void ctrl.playPause()}
423
- onSeek={(positionMs) => void ctrl.seek(positionMs)}
424
- />
425
- )
426
- }
427
-
428
- export function createMusicPlayerPlugin(options?: {
429
- createController?: (context: Context) => Controller
430
- /** Test-only factory override; production always uses the session adapter. */
431
- createSessionMedia?: () => SessionMedia
432
- }) {
433
- return Plugin.define({
434
- id: "music-player",
435
- setup(context: Context) {
436
- if (!isMac()) {
437
- context.ui.toast.show({
438
- title: "Music",
439
- message: "System media control is macOS-only",
440
- variant: "warning",
441
- })
442
- }
443
- const ctrl =
444
- options?.createController?.(context) ??
445
- createController(
446
- context,
447
- options?.createSessionMedia
448
- ? {
449
- ...controllerDependencies,
450
- createSessionMedia: options.createSessionMedia,
451
- }
452
- : controllerDependencies,
453
- )
454
- // Slot renderers must observe host-owned state before package components run.
455
- const observeSession = () => {
456
- void ctrl.session.loading
457
- void ctrl.session.error
458
- void ctrl.session.player
459
- }
460
- const unsubApp = context.ui.slot({
461
- append: "session.composer.top",
462
- render: () => {
463
- observeSession()
464
- return <AppHost context={context} ctrl={ctrl} />
465
- },
466
- })
467
- const unsubSidebar = context.ui.slot({
468
- append: "sidebar.content",
469
- render: () => {
470
- observeSession()
471
- return (
472
- <SidebarPlayer
473
- context={context}
474
- state={ctrl.session}
475
- subscribe={ctrl.subscribe}
476
- onPlayPause={() => void ctrl.playPause()}
477
- onNext={() => void ctrl.next()}
478
- onPrev={() => void ctrl.prev()}
479
- onSeek={(positionMs) => void ctrl.seek(positionMs)}
480
- />
481
- )
482
- },
483
- })
484
- return () => {
485
- unsubApp()
486
- unsubSidebar()
487
- ctrl.dispose()
488
- }
489
- },
490
- })
491
- }
492
-
493
- export default createMusicPlayerPlugin()
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
- }