@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/README.md +17 -14
- package/SUPPORT.md +1 -1
- package/dist/index.js +2272 -0
- package/dist/tui.js +1 -0
- package/package.json +29 -21
- package/artwork-placement.ts +0 -169
- package/artwork.ts +0 -424
- package/artwork.tsx +0 -246
- package/index.tsx +0 -476
- package/kitty-graphics.ts +0 -105
- package/system-media.ts +0 -571
- package/tmux-offset.ts +0 -120
- package/tui.tsx +0 -1
- package/types.ts +0 -143
- package/ui.tsx +0 -695
- package/waveform.tsx +0 -205
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()
|