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