@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/README.md +15 -15
- package/SUPPORT.md +1 -1
- package/dist/index.js +2257 -0
- package/dist/tui.js +1 -0
- package/package.json +28 -20
- package/artwork-placement.ts +0 -169
- package/artwork.ts +0 -240
- package/artwork.tsx +0 -246
- package/index.tsx +0 -493
- package/kitty-graphics.ts +0 -105
- package/system-media.ts +0 -593
- package/tmux-offset.ts +0 -120
- package/tui.tsx +0 -1
- package/types.ts +0 -144
- package/ui.tsx +0 -695
- package/waveform.tsx +0 -205
package/dist/index.js
ADDED
|
@@ -0,0 +1,2257 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// index.tsx
|
|
3
|
+
import { createComponent as _$createComponent3 } from "@opentui/solid";
|
|
4
|
+
import { Plugin } from "@opencode/plugin/tui";
|
|
5
|
+
|
|
6
|
+
// system-media.ts
|
|
7
|
+
import {
|
|
8
|
+
baselineCapabilities,
|
|
9
|
+
createReconnectingMusicSessionClient
|
|
10
|
+
} from "@naxodev/music-core";
|
|
11
|
+
|
|
12
|
+
// artwork.ts
|
|
13
|
+
import { mkdtemp, rm } from "fs/promises";
|
|
14
|
+
import { tmpdir } from "os";
|
|
15
|
+
import { join } from "path";
|
|
16
|
+
import { PNG } from "pngjs";
|
|
17
|
+
import {
|
|
18
|
+
acquireCatalogArtwork,
|
|
19
|
+
MAX_ARTWORK_BYTES,
|
|
20
|
+
selectCatalogResolution
|
|
21
|
+
} from "@naxodev/music-core";
|
|
22
|
+
import {
|
|
23
|
+
selectCatalogResolution as selectCatalogResolution2,
|
|
24
|
+
selectCatalogTrack,
|
|
25
|
+
readLimitedResponse,
|
|
26
|
+
downloadCatalogImage
|
|
27
|
+
} from "@naxodev/music-core";
|
|
28
|
+
var MAX_CONVERTED_PNG_BYTES = 1e6;
|
|
29
|
+
var MAX_IMAGE_DIMENSION = 4096;
|
|
30
|
+
var MAX_IMAGE_PIXELS = 12000000;
|
|
31
|
+
var CONVERSION_TIMEOUT_MS = 3000;
|
|
32
|
+
function imageDimensionsAreSafe(width, height) {
|
|
33
|
+
return Number.isInteger(width) && Number.isInteger(height) && width > 0 && height > 0 && width <= MAX_IMAGE_DIMENSION && height <= MAX_IMAGE_DIMENSION && width * height <= MAX_IMAGE_PIXELS;
|
|
34
|
+
}
|
|
35
|
+
async function runCommandWithTimeout(command, timeoutMs) {
|
|
36
|
+
const proc = Bun.spawn(command, { stdout: "pipe", stderr: "ignore" });
|
|
37
|
+
let timedOut = false;
|
|
38
|
+
const timer = setTimeout(() => {
|
|
39
|
+
timedOut = true;
|
|
40
|
+
proc.kill(9);
|
|
41
|
+
}, timeoutMs);
|
|
42
|
+
try {
|
|
43
|
+
const [out, code] = await Promise.all([
|
|
44
|
+
new Response(proc.stdout).text(),
|
|
45
|
+
proc.exited
|
|
46
|
+
]);
|
|
47
|
+
return { code, out, timed_out: timedOut };
|
|
48
|
+
} finally {
|
|
49
|
+
clearTimeout(timer);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
function dimensionsFromSips(output) {
|
|
53
|
+
const width = Number(output.match(/pixelWidth:\s*(\d+)/)?.[1]);
|
|
54
|
+
const height = Number(output.match(/pixelHeight:\s*(\d+)/)?.[1]);
|
|
55
|
+
return imageDimensionsAreSafe(width, height) ? { width, height } : null;
|
|
56
|
+
}
|
|
57
|
+
async function squarePng(bytes, size) {
|
|
58
|
+
if (bytes.byteLength === 0 || bytes.byteLength > MAX_ARTWORK_BYTES)
|
|
59
|
+
return null;
|
|
60
|
+
const dir = await mkdtemp(join(tmpdir(), "opencode-music-art-"));
|
|
61
|
+
const input = join(dir, "input");
|
|
62
|
+
const output = join(dir, "art.png");
|
|
63
|
+
try {
|
|
64
|
+
await Bun.write(input, bytes);
|
|
65
|
+
const inspected = await runCommandWithTimeout(["sips", "-g", "pixelWidth", "-g", "pixelHeight", input], CONVERSION_TIMEOUT_MS);
|
|
66
|
+
if (inspected.timed_out || inspected.code !== 0 || !dimensionsFromSips(inspected.out)) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
const converted = await runCommandWithTimeout([
|
|
70
|
+
"sips",
|
|
71
|
+
"-z",
|
|
72
|
+
String(size),
|
|
73
|
+
String(size),
|
|
74
|
+
"-s",
|
|
75
|
+
"format",
|
|
76
|
+
"png",
|
|
77
|
+
input,
|
|
78
|
+
"--out",
|
|
79
|
+
output
|
|
80
|
+
], CONVERSION_TIMEOUT_MS);
|
|
81
|
+
if (converted.timed_out || converted.code !== 0)
|
|
82
|
+
return null;
|
|
83
|
+
const file = Bun.file(output);
|
|
84
|
+
if (file.size === 0 || file.size > MAX_CONVERTED_PNG_BYTES)
|
|
85
|
+
return null;
|
|
86
|
+
return new Uint8Array(await file.arrayBuffer());
|
|
87
|
+
} finally {
|
|
88
|
+
await rm(dir, { recursive: true, force: true });
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
var hex = (r, g, b) => `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
|
|
92
|
+
function presentation(pngBytes) {
|
|
93
|
+
const image = PNG.sync.read(Buffer.from(pngBytes));
|
|
94
|
+
const cells = [];
|
|
95
|
+
let accent = "#7aa2f7";
|
|
96
|
+
let accentScore = -1;
|
|
97
|
+
for (let y = 0;y < image.height; y += 2) {
|
|
98
|
+
const row = [];
|
|
99
|
+
for (let x = 0;x < image.width; x++) {
|
|
100
|
+
const colors = [y, Math.min(y + 1, image.height - 1)].map((py) => {
|
|
101
|
+
const offset = (py * image.width + x) * 4;
|
|
102
|
+
const r = image.data[offset] ?? 0;
|
|
103
|
+
const g = image.data[offset + 1] ?? 0;
|
|
104
|
+
const b = image.data[offset + 2] ?? 0;
|
|
105
|
+
const max = Math.max(r, g, b) / 255;
|
|
106
|
+
const min = Math.min(r, g, b) / 255;
|
|
107
|
+
const light = (max + min) / 2;
|
|
108
|
+
const score = (max - min) * (1 - Math.abs(light - 0.5) * 2);
|
|
109
|
+
if (score > accentScore) {
|
|
110
|
+
accentScore = score;
|
|
111
|
+
accent = hex(r, g, b);
|
|
112
|
+
}
|
|
113
|
+
return hex(r, g, b);
|
|
114
|
+
});
|
|
115
|
+
row.push({ upper: colors[0], lower: colors[1] });
|
|
116
|
+
}
|
|
117
|
+
cells.push(row);
|
|
118
|
+
}
|
|
119
|
+
return { cells, accent };
|
|
120
|
+
}
|
|
121
|
+
async function resolveArtworkDetails(id, target, nativeBase64, legacyId = id, fetcher = fetch, signal) {
|
|
122
|
+
if (signal?.aborted)
|
|
123
|
+
return { artwork: null, duration_ms: target.duration_ms };
|
|
124
|
+
const candidates = [];
|
|
125
|
+
if (nativeBase64) {
|
|
126
|
+
try {
|
|
127
|
+
const maxBase64Length = Math.ceil(MAX_ARTWORK_BYTES / 3) * 4;
|
|
128
|
+
if (nativeBase64.length <= maxBase64Length) {
|
|
129
|
+
const bytes = new Uint8Array(Buffer.from(nativeBase64, "base64"));
|
|
130
|
+
if (bytes.byteLength <= MAX_ARTWORK_BYTES)
|
|
131
|
+
candidates.push(bytes);
|
|
132
|
+
}
|
|
133
|
+
} catch {}
|
|
134
|
+
}
|
|
135
|
+
for (const bytes of candidates) {
|
|
136
|
+
try {
|
|
137
|
+
const nativePng = await squarePng(bytes, 300);
|
|
138
|
+
const thumbnail = nativePng ? await squarePng(nativePng, 24) : null;
|
|
139
|
+
if (nativePng && thumbnail) {
|
|
140
|
+
return {
|
|
141
|
+
artwork: {
|
|
142
|
+
id,
|
|
143
|
+
legacy_id: legacyId,
|
|
144
|
+
png_base64: Buffer.from(nativePng).toString("base64"),
|
|
145
|
+
...presentation(thumbnail)
|
|
146
|
+
},
|
|
147
|
+
duration_ms: target.duration_ms
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
const catalog = await acquireCatalogArtwork(target, {
|
|
153
|
+
fetch: fetcher,
|
|
154
|
+
signal
|
|
155
|
+
});
|
|
156
|
+
if (catalog.kind !== "available" || signal?.aborted)
|
|
157
|
+
return {
|
|
158
|
+
artwork: null,
|
|
159
|
+
duration_ms: catalog.duration_ms ?? target.duration_ms
|
|
160
|
+
};
|
|
161
|
+
if (!catalog.bytes) {
|
|
162
|
+
return { artwork: null, duration_ms: catalog.duration_ms };
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const nativePng = await squarePng(catalog.bytes, 300);
|
|
166
|
+
const thumbnail = nativePng ? await squarePng(nativePng, 24) : null;
|
|
167
|
+
if (!nativePng || !thumbnail) {
|
|
168
|
+
return { artwork: null, duration_ms: catalog.duration_ms };
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
artwork: {
|
|
172
|
+
id,
|
|
173
|
+
legacy_id: legacyId,
|
|
174
|
+
png_base64: Buffer.from(nativePng).toString("base64"),
|
|
175
|
+
...presentation(thumbnail)
|
|
176
|
+
},
|
|
177
|
+
duration_ms: catalog.duration_ms
|
|
178
|
+
};
|
|
179
|
+
} catch {
|
|
180
|
+
return { artwork: null, duration_ms: catalog.duration_ms };
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// system-media.ts
|
|
185
|
+
var MAX_ARTWORK_ENTRIES = 32;
|
|
186
|
+
var artworkCache = new Map;
|
|
187
|
+
var artworkJobs = new Map;
|
|
188
|
+
var waitingArtwork = new Map;
|
|
189
|
+
var sessionClientSequence = 0;
|
|
190
|
+
var createOpenCodeSessionClient = (signal) => createReconnectingMusicSessionClient({
|
|
191
|
+
clientId: `opencode-music-player-${++sessionClientSequence}`,
|
|
192
|
+
hostKind: "opencode",
|
|
193
|
+
capabilities: [...baselineCapabilities],
|
|
194
|
+
...signal ? { signal } : {}
|
|
195
|
+
});
|
|
196
|
+
function artworkIdentityKey(identity) {
|
|
197
|
+
return JSON.stringify([
|
|
198
|
+
identity.uid,
|
|
199
|
+
identity.title,
|
|
200
|
+
identity.artist,
|
|
201
|
+
identity.album,
|
|
202
|
+
identity.duration_ms
|
|
203
|
+
]);
|
|
204
|
+
}
|
|
205
|
+
function artworkCacheKey(identity) {
|
|
206
|
+
return JSON.stringify([
|
|
207
|
+
identity.title,
|
|
208
|
+
identity.artist,
|
|
209
|
+
identity.album,
|
|
210
|
+
identity.duration_ms
|
|
211
|
+
]);
|
|
212
|
+
}
|
|
213
|
+
function identityFromTrack(track) {
|
|
214
|
+
return {
|
|
215
|
+
uid: track.id,
|
|
216
|
+
title: track.name,
|
|
217
|
+
artist: track.artists,
|
|
218
|
+
album: track.album,
|
|
219
|
+
duration_ms: track.duration_ms
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function removeWaitingInterest(host) {
|
|
223
|
+
for (const [key, deferred] of waitingArtwork) {
|
|
224
|
+
deferred.interests.delete(host);
|
|
225
|
+
if (deferred.interests.size === 0)
|
|
226
|
+
waitingArtwork.delete(key);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function admitDeferredArtwork() {
|
|
230
|
+
const next = waitingArtwork.entries().next().value;
|
|
231
|
+
if (!next)
|
|
232
|
+
return;
|
|
233
|
+
const [key, deferred] = next;
|
|
234
|
+
waitingArtwork.delete(key);
|
|
235
|
+
for (const host of deferred.interests.keys()) {
|
|
236
|
+
if (!host.isActive())
|
|
237
|
+
deferred.interests.delete(host);
|
|
238
|
+
}
|
|
239
|
+
const leader = deferred.interests.values().next().value;
|
|
240
|
+
if (!leader) {
|
|
241
|
+
admitDeferredArtwork();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
artworkForTrack(key, leader.legacyKey, leader.target, leader.native, leader.resolver, leader.host, leader.identity, leader.now);
|
|
245
|
+
const admitted = artworkJobs.get(key);
|
|
246
|
+
if (!admitted)
|
|
247
|
+
return;
|
|
248
|
+
for (const interest of deferred.interests.values()) {
|
|
249
|
+
if (interest.host.isActive())
|
|
250
|
+
admitted.interests.set(interest.host, interest.identity);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
function releaseArtworkSlot(key, entry) {
|
|
254
|
+
artworkJobs.delete(key);
|
|
255
|
+
if (entry) {
|
|
256
|
+
artworkCache.set(key, entry);
|
|
257
|
+
if (artworkCache.size > MAX_ARTWORK_ENTRIES) {
|
|
258
|
+
const oldest = artworkCache.keys().next().value;
|
|
259
|
+
if (oldest)
|
|
260
|
+
artworkCache.delete(oldest);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
admitDeferredArtwork();
|
|
264
|
+
}
|
|
265
|
+
function settleArtworkEntry(key, entry) {
|
|
266
|
+
releaseArtworkSlot(key, entry);
|
|
267
|
+
}
|
|
268
|
+
function publishArtworkCompletion(entry, artwork) {
|
|
269
|
+
for (const [host, identity] of entry.interests) {
|
|
270
|
+
if (!host.isActive() || !host.accepts(identity))
|
|
271
|
+
continue;
|
|
272
|
+
host.publish({
|
|
273
|
+
type: "artwork-completion",
|
|
274
|
+
identity,
|
|
275
|
+
artwork,
|
|
276
|
+
duration_ms: entry.duration_ms
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
entry.interests.clear();
|
|
280
|
+
}
|
|
281
|
+
function removeArtworkInterests(host) {
|
|
282
|
+
removeWaitingInterest(host);
|
|
283
|
+
for (const entry of artworkCache.values())
|
|
284
|
+
entry.interests.delete(host);
|
|
285
|
+
for (const entry of artworkJobs.values()) {
|
|
286
|
+
entry.interests.delete(host);
|
|
287
|
+
if (!entry.interests.size)
|
|
288
|
+
entry.abort.abort();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
function artworkForTrack(key, legacyKey, target, native, resolver, host, identity, now) {
|
|
292
|
+
let entry = artworkJobs.get(key) ?? artworkCache.get(key);
|
|
293
|
+
if (entry?.abort.signal.aborted)
|
|
294
|
+
entry = undefined;
|
|
295
|
+
if (!entry) {
|
|
296
|
+
if (artworkJobs.has(key) || artworkJobs.size >= MAX_ARTWORK_ENTRIES) {
|
|
297
|
+
removeWaitingInterest(host);
|
|
298
|
+
let deferred = waitingArtwork.get(key);
|
|
299
|
+
if (!deferred && waitingArtwork.size < MAX_ARTWORK_ENTRIES) {
|
|
300
|
+
deferred = { interests: new Map };
|
|
301
|
+
waitingArtwork.set(key, deferred);
|
|
302
|
+
}
|
|
303
|
+
if (deferred && deferred.interests.size < MAX_ARTWORK_ENTRIES)
|
|
304
|
+
deferred.interests.set(host, {
|
|
305
|
+
legacyKey,
|
|
306
|
+
target,
|
|
307
|
+
native,
|
|
308
|
+
resolver,
|
|
309
|
+
host,
|
|
310
|
+
identity,
|
|
311
|
+
now
|
|
312
|
+
});
|
|
313
|
+
return {
|
|
314
|
+
artwork: null,
|
|
315
|
+
duration_ms: target.duration_ms,
|
|
316
|
+
loading: !!deferred && deferred.interests.has(host)
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
entry = {
|
|
320
|
+
value: null,
|
|
321
|
+
duration_ms: target.duration_ms,
|
|
322
|
+
resolved: false,
|
|
323
|
+
pending: false,
|
|
324
|
+
abort: new AbortController,
|
|
325
|
+
interests: new Map
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
removeWaitingInterest(host);
|
|
329
|
+
if (!entry.pending && !entry.resolved) {
|
|
330
|
+
entry.pending = true;
|
|
331
|
+
artworkJobs.set(key, entry);
|
|
332
|
+
const activeEntry = entry;
|
|
333
|
+
(async () => {
|
|
334
|
+
let data = null;
|
|
335
|
+
try {
|
|
336
|
+
data = await native();
|
|
337
|
+
} catch {}
|
|
338
|
+
return resolver(key, target, data, legacyKey, undefined, activeEntry.abort.signal);
|
|
339
|
+
})().then((resolution) => {
|
|
340
|
+
activeEntry.value = resolution.artwork;
|
|
341
|
+
activeEntry.duration_ms = resolution.duration_ms;
|
|
342
|
+
activeEntry.resolved = true;
|
|
343
|
+
activeEntry.pending = false;
|
|
344
|
+
if (activeEntry.abort.signal.aborted) {
|
|
345
|
+
releaseArtworkSlot(key);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
settleArtworkEntry(key, activeEntry);
|
|
349
|
+
publishArtworkCompletion(activeEntry, resolution.artwork);
|
|
350
|
+
}, () => {
|
|
351
|
+
activeEntry.value = null;
|
|
352
|
+
activeEntry.resolved = true;
|
|
353
|
+
activeEntry.pending = false;
|
|
354
|
+
if (activeEntry.abort.signal.aborted) {
|
|
355
|
+
releaseArtworkSlot(key);
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
settleArtworkEntry(key, activeEntry);
|
|
359
|
+
publishArtworkCompletion(activeEntry, null);
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
if (entry.pending)
|
|
363
|
+
entry.interests.set(host, identity);
|
|
364
|
+
return {
|
|
365
|
+
artwork: entry.value,
|
|
366
|
+
duration_ms: entry.duration_ms,
|
|
367
|
+
loading: entry.pending
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
function createSessionSystemMedia(overrides = {}) {
|
|
371
|
+
const factory = overrides.createClient ?? createOpenCodeSessionClient;
|
|
372
|
+
const resolver = overrides.resolveArtworkDetails ?? resolveArtworkDetails;
|
|
373
|
+
const now = overrides.now ?? Date.now;
|
|
374
|
+
const listeners = new Set;
|
|
375
|
+
const presentationListeners = new Set;
|
|
376
|
+
const acquisitionAbort = new AbortController;
|
|
377
|
+
let disposed = false;
|
|
378
|
+
let currentArtworkIdentity = null;
|
|
379
|
+
let currentArtworkKey = null;
|
|
380
|
+
let client;
|
|
381
|
+
let installed = false;
|
|
382
|
+
let latest;
|
|
383
|
+
let latestStatus;
|
|
384
|
+
let latestConnection;
|
|
385
|
+
let acquisitionError;
|
|
386
|
+
let publishedLifecycle;
|
|
387
|
+
let unsubscribers = [];
|
|
388
|
+
let clientReleased = false;
|
|
389
|
+
let disposal;
|
|
390
|
+
const releaseClient = async (next) => {
|
|
391
|
+
if (clientReleased)
|
|
392
|
+
return;
|
|
393
|
+
clientReleased = true;
|
|
394
|
+
await next.dispose();
|
|
395
|
+
};
|
|
396
|
+
const emit = (event) => {
|
|
397
|
+
if (disposed)
|
|
398
|
+
return;
|
|
399
|
+
for (const listener of [...listeners]) {
|
|
400
|
+
try {
|
|
401
|
+
listener(event);
|
|
402
|
+
} catch {}
|
|
403
|
+
}
|
|
404
|
+
};
|
|
405
|
+
const host = {
|
|
406
|
+
publish(event) {
|
|
407
|
+
if (disposed)
|
|
408
|
+
return;
|
|
409
|
+
for (const listener of [...presentationListeners]) {
|
|
410
|
+
try {
|
|
411
|
+
listener(event);
|
|
412
|
+
} catch {}
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
isActive: () => !disposed,
|
|
416
|
+
accepts: (identity) => currentArtworkIdentity === artworkIdentityKey(identity)
|
|
417
|
+
};
|
|
418
|
+
const lifecycle = () => {
|
|
419
|
+
if (latestConnection?.type === "reconnecting" || latestConnection?.type === "terminal")
|
|
420
|
+
return {
|
|
421
|
+
type: "lifecycle",
|
|
422
|
+
message: latestConnection.error.message,
|
|
423
|
+
source: "connection"
|
|
424
|
+
};
|
|
425
|
+
if (latestConnection?.type === "disposed")
|
|
426
|
+
return {
|
|
427
|
+
type: "lifecycle",
|
|
428
|
+
message: "music session is disposed",
|
|
429
|
+
source: "connection"
|
|
430
|
+
};
|
|
431
|
+
if (acquisitionError)
|
|
432
|
+
return {
|
|
433
|
+
type: "lifecycle",
|
|
434
|
+
message: acquisitionError,
|
|
435
|
+
source: "acquisition"
|
|
436
|
+
};
|
|
437
|
+
return {
|
|
438
|
+
type: "lifecycle",
|
|
439
|
+
message: latestStatus && latestStatus.kind !== "ready" ? latestStatus.message : null,
|
|
440
|
+
source: "provider"
|
|
441
|
+
};
|
|
442
|
+
};
|
|
443
|
+
const publishLifecycle = () => {
|
|
444
|
+
const event = lifecycle();
|
|
445
|
+
const key = `${event.source}:${event.message}`;
|
|
446
|
+
if (publishedLifecycle === key)
|
|
447
|
+
return;
|
|
448
|
+
publishedLifecycle = key;
|
|
449
|
+
emit(event);
|
|
450
|
+
};
|
|
451
|
+
const project = (state) => {
|
|
452
|
+
const nextKey = state?.state.track ? artworkCacheKey(identityFromTrack(state.state.track)) : null;
|
|
453
|
+
if (nextKey !== currentArtworkKey)
|
|
454
|
+
removeArtworkInterests(host);
|
|
455
|
+
currentArtworkKey = nextKey;
|
|
456
|
+
if (!state) {
|
|
457
|
+
currentArtworkIdentity = null;
|
|
458
|
+
return null;
|
|
459
|
+
}
|
|
460
|
+
if (!state.state.track) {
|
|
461
|
+
currentArtworkIdentity = null;
|
|
462
|
+
return state.state;
|
|
463
|
+
}
|
|
464
|
+
const track = state.state.track;
|
|
465
|
+
const identity = identityFromTrack(track);
|
|
466
|
+
currentArtworkIdentity = artworkIdentityKey(identity);
|
|
467
|
+
const artworkState = artworkForTrack(artworkCacheKey(identity), artworkIdentityKey(identity), {
|
|
468
|
+
title: track.name,
|
|
469
|
+
artist: track.artists,
|
|
470
|
+
album: track.album,
|
|
471
|
+
duration_ms: track.duration_ms
|
|
472
|
+
}, async () => {
|
|
473
|
+
const active = await clientPromise;
|
|
474
|
+
if (disposed || active !== client)
|
|
475
|
+
return null;
|
|
476
|
+
const result = await active.artwork({
|
|
477
|
+
id: track.id,
|
|
478
|
+
name: track.name,
|
|
479
|
+
artists: track.artists,
|
|
480
|
+
album: track.album,
|
|
481
|
+
duration_ms: track.duration_ms
|
|
482
|
+
});
|
|
483
|
+
return active === client && result.type === "available" ? result.base64 : null;
|
|
484
|
+
}, resolver, host, identity, now);
|
|
485
|
+
return {
|
|
486
|
+
...state.state,
|
|
487
|
+
track: {
|
|
488
|
+
...track,
|
|
489
|
+
artwork: artworkState.artwork,
|
|
490
|
+
artwork_loading: artworkState.loading
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
};
|
|
494
|
+
const install = (next) => {
|
|
495
|
+
if (installed)
|
|
496
|
+
return;
|
|
497
|
+
installed = true;
|
|
498
|
+
client = next;
|
|
499
|
+
latest = next.state;
|
|
500
|
+
latestStatus = next.status;
|
|
501
|
+
latestConnection = next.connection;
|
|
502
|
+
unsubscribers = [
|
|
503
|
+
next.subscribeState((state) => {
|
|
504
|
+
if (disposed)
|
|
505
|
+
return;
|
|
506
|
+
latest = state;
|
|
507
|
+
emit({ type: "snapshot", state: project(state) });
|
|
508
|
+
}),
|
|
509
|
+
next.subscribeStatus((status) => {
|
|
510
|
+
if (disposed)
|
|
511
|
+
return;
|
|
512
|
+
latestStatus = status;
|
|
513
|
+
publishLifecycle();
|
|
514
|
+
}),
|
|
515
|
+
next.subscribeConnection((connection) => {
|
|
516
|
+
if (disposed)
|
|
517
|
+
return;
|
|
518
|
+
latestConnection = connection;
|
|
519
|
+
publishLifecycle();
|
|
520
|
+
})
|
|
521
|
+
];
|
|
522
|
+
};
|
|
523
|
+
const clientPromise = Promise.resolve().then(() => factory(acquisitionAbort.signal)).then((next) => {
|
|
524
|
+
if (disposed) {
|
|
525
|
+
releaseClient(next).catch((error) => {
|
|
526
|
+
console.error("Failed to dispose late music session client", error);
|
|
527
|
+
});
|
|
528
|
+
return next;
|
|
529
|
+
}
|
|
530
|
+
install(next);
|
|
531
|
+
return next;
|
|
532
|
+
});
|
|
533
|
+
clientPromise.catch((error) => {
|
|
534
|
+
if (disposed)
|
|
535
|
+
return;
|
|
536
|
+
acquisitionError = error instanceof Error ? error.message : String(error);
|
|
537
|
+
publishLifecycle();
|
|
538
|
+
});
|
|
539
|
+
const activeClient = async () => {
|
|
540
|
+
const next = await clientPromise;
|
|
541
|
+
if (disposed || next !== client)
|
|
542
|
+
throw new Error("music session is disposed");
|
|
543
|
+
return next;
|
|
544
|
+
};
|
|
545
|
+
return {
|
|
546
|
+
async player() {
|
|
547
|
+
await activeClient();
|
|
548
|
+
return project(latest);
|
|
549
|
+
},
|
|
550
|
+
async refreshArtwork() {
|
|
551
|
+
await activeClient();
|
|
552
|
+
const track = latest?.state.track;
|
|
553
|
+
if (!track)
|
|
554
|
+
return;
|
|
555
|
+
const key = artworkCacheKey(identityFromTrack(track));
|
|
556
|
+
if (artworkJobs.has(key))
|
|
557
|
+
return;
|
|
558
|
+
artworkCache.delete(key);
|
|
559
|
+
emit({ type: "snapshot", state: project(latest) });
|
|
560
|
+
},
|
|
561
|
+
async play() {
|
|
562
|
+
await (await activeClient()).play();
|
|
563
|
+
},
|
|
564
|
+
async toggle() {
|
|
565
|
+
await (await activeClient()).toggle();
|
|
566
|
+
},
|
|
567
|
+
async pause() {
|
|
568
|
+
await (await activeClient()).pause();
|
|
569
|
+
},
|
|
570
|
+
async next() {
|
|
571
|
+
await (await activeClient()).next();
|
|
572
|
+
},
|
|
573
|
+
async previous() {
|
|
574
|
+
await (await activeClient()).previous();
|
|
575
|
+
},
|
|
576
|
+
async seek(positionMs) {
|
|
577
|
+
await (await activeClient()).seek(positionMs);
|
|
578
|
+
},
|
|
579
|
+
subscribe(listener) {
|
|
580
|
+
if (disposed)
|
|
581
|
+
return () => {};
|
|
582
|
+
let closed = false;
|
|
583
|
+
listeners.add(listener);
|
|
584
|
+
try {
|
|
585
|
+
if (latest)
|
|
586
|
+
listener({ type: "snapshot", state: project(latest) });
|
|
587
|
+
if (installed || acquisitionError)
|
|
588
|
+
listener(lifecycle());
|
|
589
|
+
} catch {}
|
|
590
|
+
return () => {
|
|
591
|
+
if (closed)
|
|
592
|
+
return;
|
|
593
|
+
closed = true;
|
|
594
|
+
listeners.delete(listener);
|
|
595
|
+
};
|
|
596
|
+
},
|
|
597
|
+
subscribePresentation(listener) {
|
|
598
|
+
if (disposed)
|
|
599
|
+
return () => {};
|
|
600
|
+
let closed = false;
|
|
601
|
+
presentationListeners.add(listener);
|
|
602
|
+
return () => {
|
|
603
|
+
if (closed)
|
|
604
|
+
return;
|
|
605
|
+
closed = true;
|
|
606
|
+
presentationListeners.delete(listener);
|
|
607
|
+
};
|
|
608
|
+
},
|
|
609
|
+
dispose() {
|
|
610
|
+
if (disposal)
|
|
611
|
+
return disposal;
|
|
612
|
+
disposed = true;
|
|
613
|
+
disposal = Promise.resolve().then(async () => {
|
|
614
|
+
const errors = [];
|
|
615
|
+
for (const unsubscribe of unsubscribers.splice(0)) {
|
|
616
|
+
try {
|
|
617
|
+
unsubscribe();
|
|
618
|
+
} catch (error) {
|
|
619
|
+
errors.push(error);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
listeners.clear();
|
|
623
|
+
presentationListeners.clear();
|
|
624
|
+
removeArtworkInterests(host);
|
|
625
|
+
if (client) {
|
|
626
|
+
try {
|
|
627
|
+
await releaseClient(client);
|
|
628
|
+
} catch (error) {
|
|
629
|
+
errors.push(error);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
if (errors.length)
|
|
633
|
+
throw new AggregateError(errors, "music session cleanup failed");
|
|
634
|
+
});
|
|
635
|
+
acquisitionAbort.abort();
|
|
636
|
+
return disposal;
|
|
637
|
+
}
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
function openNowPlayingApp() {
|
|
641
|
+
Bun.spawn(["open", "https://music.youtube.com"], {
|
|
642
|
+
stdout: "ignore",
|
|
643
|
+
stderr: "ignore"
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// types.ts
|
|
648
|
+
import {
|
|
649
|
+
emptyPlayer as emptyCorePlayer,
|
|
650
|
+
formatMs,
|
|
651
|
+
isMac
|
|
652
|
+
} from "@naxodev/music-core";
|
|
653
|
+
function mergeArtworkCompletion(player, event) {
|
|
654
|
+
const track = player?.track;
|
|
655
|
+
if (!track || track.name !== event.identity.title || track.artists !== event.identity.artist || track.album !== event.identity.album || track.duration_ms > 0 && track.duration_ms !== event.identity.duration_ms) {
|
|
656
|
+
return player;
|
|
657
|
+
}
|
|
658
|
+
return {
|
|
659
|
+
...player,
|
|
660
|
+
track: {
|
|
661
|
+
...track,
|
|
662
|
+
artwork: event.artwork,
|
|
663
|
+
artwork_loading: false
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
function mergePlayerSnapshot(player, snapshot) {
|
|
668
|
+
const current = player?.track;
|
|
669
|
+
const next = snapshot.track;
|
|
670
|
+
if (!current || !next || current.name !== next.name || current.artists !== next.artists || current.album !== next.album || current.duration_ms > 0 && next.duration_ms > 0 && current.duration_ms !== next.duration_ms || current.artwork === null && current.artwork_loading !== false) {
|
|
671
|
+
return snapshot;
|
|
672
|
+
}
|
|
673
|
+
return {
|
|
674
|
+
...snapshot,
|
|
675
|
+
track: {
|
|
676
|
+
...next,
|
|
677
|
+
artwork: next.artwork ?? current.artwork,
|
|
678
|
+
artwork_loading: false
|
|
679
|
+
}
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ui.tsx
|
|
684
|
+
import { memo as _$memo } from "@opentui/solid";
|
|
685
|
+
import { effect as _$effect2 } from "@opentui/solid";
|
|
686
|
+
import { insertNode as _$insertNode2 } from "@opentui/solid";
|
|
687
|
+
import { insert as _$insert3 } from "@opentui/solid";
|
|
688
|
+
import { use as _$use3 } from "@opentui/solid";
|
|
689
|
+
import { setProp as _$setProp2 } from "@opentui/solid";
|
|
690
|
+
import { createElement as _$createElement3 } from "@opentui/solid";
|
|
691
|
+
import { createComponent as _$createComponent2 } from "@opentui/solid";
|
|
692
|
+
import { Show, createEffect, createMemo, createSignal, onCleanup as onCleanup3, onMount as onMount3 } from "solid-js";
|
|
693
|
+
import { MouseButton } from "@opentui/core";
|
|
694
|
+
|
|
695
|
+
// artwork.tsx
|
|
696
|
+
import { effect as _$effect } from "@opentui/solid";
|
|
697
|
+
import { createTextNode as _$createTextNode } from "@opentui/solid";
|
|
698
|
+
import { insertNode as _$insertNode } from "@opentui/solid";
|
|
699
|
+
import { insert as _$insert } from "@opentui/solid";
|
|
700
|
+
import { createComponent as _$createComponent } from "@opentui/solid";
|
|
701
|
+
import { setProp as _$setProp } from "@opentui/solid";
|
|
702
|
+
import { use as _$use } from "@opentui/solid";
|
|
703
|
+
import { createElement as _$createElement } from "@opentui/solid";
|
|
704
|
+
import { For, onCleanup, onMount } from "solid-js";
|
|
705
|
+
|
|
706
|
+
// artwork-placement.ts
|
|
707
|
+
var CLEARED_STATE = {
|
|
708
|
+
transmitted: 0,
|
|
709
|
+
placement: null
|
|
710
|
+
};
|
|
711
|
+
function placementKey(placement) {
|
|
712
|
+
return [
|
|
713
|
+
placement.imageId,
|
|
714
|
+
placement.x,
|
|
715
|
+
placement.y,
|
|
716
|
+
placement.width,
|
|
717
|
+
placement.height
|
|
718
|
+
].join(":");
|
|
719
|
+
}
|
|
720
|
+
function samePlacement(a, b) {
|
|
721
|
+
return a !== null && placementKey(a) === placementKey(b);
|
|
722
|
+
}
|
|
723
|
+
function planNativeArtworkPlacement(input) {
|
|
724
|
+
const { state, imageId, x, y, width, height } = input;
|
|
725
|
+
const desired = { imageId, x, y, width, height };
|
|
726
|
+
if (input.disposed || !input.kittySupported || !input.slotValid) {
|
|
727
|
+
if (state.transmitted !== 0) {
|
|
728
|
+
return {
|
|
729
|
+
actions: [{ type: "delete-image", imageId: state.transmitted }],
|
|
730
|
+
nextState: CLEARED_STATE
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
return { actions: [], nextState: state };
|
|
734
|
+
}
|
|
735
|
+
if (state.transmitted !== imageId) {
|
|
736
|
+
const actions2 = [];
|
|
737
|
+
if (state.transmitted !== 0) {
|
|
738
|
+
actions2.push({ type: "delete-image", imageId: state.transmitted });
|
|
739
|
+
} else {
|
|
740
|
+
actions2.push({ type: "delete-image", imageId });
|
|
741
|
+
}
|
|
742
|
+
actions2.push({
|
|
743
|
+
type: "transmit-and-display",
|
|
744
|
+
imageId,
|
|
745
|
+
x,
|
|
746
|
+
y,
|
|
747
|
+
width,
|
|
748
|
+
height
|
|
749
|
+
});
|
|
750
|
+
return {
|
|
751
|
+
actions: actions2,
|
|
752
|
+
nextState: { transmitted: imageId, placement: desired }
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
if (samePlacement(state.placement, desired)) {
|
|
756
|
+
return { actions: [], nextState: state };
|
|
757
|
+
}
|
|
758
|
+
const actions = [];
|
|
759
|
+
if (state.placement !== null || state.transmitted !== 0) {
|
|
760
|
+
actions.push({ type: "delete-placement", imageId });
|
|
761
|
+
}
|
|
762
|
+
actions.push({
|
|
763
|
+
type: "place",
|
|
764
|
+
imageId,
|
|
765
|
+
placementId: imageId,
|
|
766
|
+
x,
|
|
767
|
+
y,
|
|
768
|
+
width,
|
|
769
|
+
height
|
|
770
|
+
});
|
|
771
|
+
return {
|
|
772
|
+
actions,
|
|
773
|
+
nextState: { transmitted: imageId, placement: desired }
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
// kitty-graphics.ts
|
|
778
|
+
var APC = "\x1B_G";
|
|
779
|
+
var ST = "\x1B\\";
|
|
780
|
+
var CHUNK_SIZE = 4096;
|
|
781
|
+
function tmuxPassthrough(data) {
|
|
782
|
+
return `\x1BPtmux;${data.replaceAll("\x1B", "\x1B\x1B")}\x1B\\`;
|
|
783
|
+
}
|
|
784
|
+
function kittyTransmitPng(pngBase64, imageId) {
|
|
785
|
+
const chunks = [];
|
|
786
|
+
for (let offset = 0;offset < pngBase64.length; offset += CHUNK_SIZE) {
|
|
787
|
+
const chunk = pngBase64.slice(offset, offset + CHUNK_SIZE);
|
|
788
|
+
const more = offset + CHUNK_SIZE < pngBase64.length ? 1 : 0;
|
|
789
|
+
const control = offset === 0 ? `a=t,f=100,i=${imageId},q=2,m=${more}` : `m=${more}`;
|
|
790
|
+
chunks.push(`${APC}${control};${chunk}${ST}`);
|
|
791
|
+
}
|
|
792
|
+
return chunks;
|
|
793
|
+
}
|
|
794
|
+
function kittyDisplayPng(pngBase64, imageId, x, y, width, height) {
|
|
795
|
+
const chunks = kittyTransmitPng(pngBase64, imageId);
|
|
796
|
+
return chunks.map((command, index) => {
|
|
797
|
+
let next = command;
|
|
798
|
+
if (index === 0) {
|
|
799
|
+
next = next.replace(`${APC}a=t,f=100,i=${imageId},q=2,`, `\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,`);
|
|
800
|
+
}
|
|
801
|
+
return index === chunks.length - 1 ? `${next}\x1B8` : next;
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
function kittyPlace(imageId, placementId, x, y, width, height) {
|
|
805
|
+
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`;
|
|
806
|
+
}
|
|
807
|
+
function kittyDelete(imageId) {
|
|
808
|
+
return `${APC}a=d,d=I,i=${imageId},q=2;${ST}`;
|
|
809
|
+
}
|
|
810
|
+
function kittyDeletePlacement(imageId) {
|
|
811
|
+
return `${APC}a=d,d=i,i=${imageId},q=2;${ST}`;
|
|
812
|
+
}
|
|
813
|
+
function kittyImageId(key) {
|
|
814
|
+
let hash = 2166136261;
|
|
815
|
+
for (let i = 0;i < key.length; i++) {
|
|
816
|
+
hash ^= key.charCodeAt(i);
|
|
817
|
+
hash = Math.imul(hash, 16777619);
|
|
818
|
+
}
|
|
819
|
+
return hash >>> 0 || 1;
|
|
820
|
+
}
|
|
821
|
+
function writeGraphics(renderer, data) {
|
|
822
|
+
const target = renderer;
|
|
823
|
+
if (!target.stdout || typeof target.realStdoutWrite !== "function")
|
|
824
|
+
return false;
|
|
825
|
+
const output = (value) => process.env.TMUX && !process.env.HERDR_ENV ? tmuxPassthrough(value) : value;
|
|
826
|
+
let cursorSaved = false;
|
|
827
|
+
try {
|
|
828
|
+
for (const command of typeof data === "string" ? [data] : data) {
|
|
829
|
+
if (command.includes("\x1B7"))
|
|
830
|
+
cursorSaved = true;
|
|
831
|
+
target.realStdoutWrite.call(target.stdout, output(command));
|
|
832
|
+
if (command.includes("\x1B8"))
|
|
833
|
+
cursorSaved = false;
|
|
834
|
+
}
|
|
835
|
+
return true;
|
|
836
|
+
} catch {
|
|
837
|
+
if (cursorSaved) {
|
|
838
|
+
try {
|
|
839
|
+
target.realStdoutWrite.call(target.stdout, output("\x1B8"));
|
|
840
|
+
} catch {}
|
|
841
|
+
}
|
|
842
|
+
return false;
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// tmux-offset.ts
|
|
847
|
+
var ZERO = { x: 0, y: 0 };
|
|
848
|
+
function createTmuxOffsetCache() {
|
|
849
|
+
return { offset: null, slot: null };
|
|
850
|
+
}
|
|
851
|
+
function shouldUseTmuxOffset(env = process.env) {
|
|
852
|
+
return Boolean(env.TMUX) && !env.HERDR_ENV;
|
|
853
|
+
}
|
|
854
|
+
function slotKey(slot) {
|
|
855
|
+
return [slot.screenX, slot.screenY, slot.width, slot.height].join(":");
|
|
856
|
+
}
|
|
857
|
+
function sameSlotGeometry(a, b) {
|
|
858
|
+
if (a === null || b === null)
|
|
859
|
+
return false;
|
|
860
|
+
return slotKey(a) === slotKey(b);
|
|
861
|
+
}
|
|
862
|
+
function parseTmuxOffsetMessage(stdout) {
|
|
863
|
+
const [left, top, statusPosition, status] = stdout.trim().split("\t");
|
|
864
|
+
return {
|
|
865
|
+
x: Number.parseInt(left ?? "0", 10) || 0,
|
|
866
|
+
y: (Number.parseInt(top ?? "0", 10) || 0) + (statusPosition === "top" && status !== "off" ? 1 : 0)
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
function queryTmuxOffset() {
|
|
870
|
+
const result = Bun.spawnSync([
|
|
871
|
+
"tmux",
|
|
872
|
+
"display-message",
|
|
873
|
+
"-p",
|
|
874
|
+
"#{pane_left}\t#{pane_top}\t#{status-position}\t#{status}"
|
|
875
|
+
]);
|
|
876
|
+
return {
|
|
877
|
+
ok: result.exitCode === 0,
|
|
878
|
+
stdout: result.stdout.toString()
|
|
879
|
+
};
|
|
880
|
+
}
|
|
881
|
+
function resolveTerminalOffset(options) {
|
|
882
|
+
const env = options.env ?? process.env;
|
|
883
|
+
if (!shouldUseTmuxOffset(env)) {
|
|
884
|
+
options.cache.offset = null;
|
|
885
|
+
options.cache.slot = null;
|
|
886
|
+
return ZERO;
|
|
887
|
+
}
|
|
888
|
+
if (options.cache.offset !== null && options.slot !== null && sameSlotGeometry(options.cache.slot, options.slot)) {
|
|
889
|
+
return options.cache.offset;
|
|
890
|
+
}
|
|
891
|
+
const query = options.query ?? queryTmuxOffset;
|
|
892
|
+
const result = query();
|
|
893
|
+
if (!result.ok) {
|
|
894
|
+
options.cache.offset = null;
|
|
895
|
+
options.cache.slot = null;
|
|
896
|
+
return ZERO;
|
|
897
|
+
}
|
|
898
|
+
const offset = parseTmuxOffsetMessage(result.stdout);
|
|
899
|
+
options.cache.offset = offset;
|
|
900
|
+
options.cache.slot = options.slot ? { ...options.slot } : null;
|
|
901
|
+
return offset;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// artwork.tsx
|
|
905
|
+
var tmuxOffsetCache = createTmuxOffsetCache();
|
|
906
|
+
var nativeArtworkImageId = kittyImageId("opencode-music-player:artwork");
|
|
907
|
+
var defaultOwnershipScope = {
|
|
908
|
+
latestOwner: 0
|
|
909
|
+
};
|
|
910
|
+
var nativeArtworkRuntimes = new WeakMap;
|
|
911
|
+
function nativeArtworkRuntime(renderer) {
|
|
912
|
+
const existing = nativeArtworkRuntimes.get(renderer);
|
|
913
|
+
if (existing)
|
|
914
|
+
return existing;
|
|
915
|
+
const created = {
|
|
916
|
+
latestOwner: 0,
|
|
917
|
+
state: {
|
|
918
|
+
transmitted: 0,
|
|
919
|
+
placement: null
|
|
920
|
+
},
|
|
921
|
+
identity: ""
|
|
922
|
+
};
|
|
923
|
+
nativeArtworkRuntimes.set(renderer, created);
|
|
924
|
+
return created;
|
|
925
|
+
}
|
|
926
|
+
function claimNativeArtworkOwnership(scope = defaultOwnershipScope) {
|
|
927
|
+
const owner = ++scope.latestOwner;
|
|
928
|
+
return {
|
|
929
|
+
isCurrent: () => owner === scope.latestOwner
|
|
930
|
+
};
|
|
931
|
+
}
|
|
932
|
+
function cleanupNativeArtwork(ownership, remove) {
|
|
933
|
+
if (ownership.isCurrent())
|
|
934
|
+
remove();
|
|
935
|
+
}
|
|
936
|
+
function imageIdForArtwork(_artworkId) {
|
|
937
|
+
return nativeArtworkImageId;
|
|
938
|
+
}
|
|
939
|
+
function legacyImageIdForArtwork(artworkId) {
|
|
940
|
+
return kittyImageId(artworkId);
|
|
941
|
+
}
|
|
942
|
+
function legacyImageIdForResolvedArtwork(artwork) {
|
|
943
|
+
return legacyImageIdForArtwork(artwork.legacy_id ?? artwork.id);
|
|
944
|
+
}
|
|
945
|
+
function terminalOffset(slot) {
|
|
946
|
+
return resolveTerminalOffset({
|
|
947
|
+
slot,
|
|
948
|
+
cache: tmuxOffsetCache
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
function supportsKittyGraphics(context) {
|
|
952
|
+
return !!context.renderer.capabilities?.kitty_graphics || process.env.TERM_PROGRAM?.toLowerCase() === "ghostty";
|
|
953
|
+
}
|
|
954
|
+
function copyState(next) {
|
|
955
|
+
return {
|
|
956
|
+
transmitted: next.transmitted,
|
|
957
|
+
placement: next.placement ? {
|
|
958
|
+
...next.placement
|
|
959
|
+
} : null
|
|
960
|
+
};
|
|
961
|
+
}
|
|
962
|
+
function AlbumArtwork(props) {
|
|
963
|
+
const renderer = props.context.renderer;
|
|
964
|
+
const runtime = nativeArtworkRuntime(renderer);
|
|
965
|
+
const ownership = claimNativeArtworkOwnership(runtime);
|
|
966
|
+
let container;
|
|
967
|
+
let paintPending = false;
|
|
968
|
+
let disposed = false;
|
|
969
|
+
const applyAction = (action) => {
|
|
970
|
+
const renderer2 = props.context.renderer;
|
|
971
|
+
switch (action.type) {
|
|
972
|
+
case "delete-image":
|
|
973
|
+
return writeGraphics(renderer2, kittyDelete(action.imageId));
|
|
974
|
+
case "delete-placement":
|
|
975
|
+
return writeGraphics(renderer2, kittyDeletePlacement(action.imageId));
|
|
976
|
+
case "transmit-and-display": {
|
|
977
|
+
const commands = kittyDisplayPng(props.artwork.png_base64, action.imageId, action.x, action.y, action.width, action.height);
|
|
978
|
+
return writeGraphics(renderer2, commands);
|
|
979
|
+
}
|
|
980
|
+
case "place":
|
|
981
|
+
return writeGraphics(renderer2, kittyPlace(action.imageId, action.placementId, action.x, action.y, action.width, action.height));
|
|
982
|
+
}
|
|
983
|
+
};
|
|
984
|
+
const applyPlan = (plan) => {
|
|
985
|
+
for (const action of plan.actions) {
|
|
986
|
+
if (!applyAction(action))
|
|
987
|
+
return false;
|
|
988
|
+
}
|
|
989
|
+
return true;
|
|
990
|
+
};
|
|
991
|
+
const commitPlan = (plan) => {
|
|
992
|
+
runtime.state = copyState(plan.nextState);
|
|
993
|
+
};
|
|
994
|
+
const paintNativeImage = () => {
|
|
995
|
+
if (!ownership.isCurrent())
|
|
996
|
+
return;
|
|
997
|
+
if (runtime.identity !== props.artwork.id) {
|
|
998
|
+
runtime.identity = props.artwork.id;
|
|
999
|
+
writeGraphics(props.context.renderer, kittyDelete(legacyImageIdForResolvedArtwork(props.artwork)));
|
|
1000
|
+
runtime.state = {
|
|
1001
|
+
transmitted: 0,
|
|
1002
|
+
placement: null
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
const kittySupported = supportsKittyGraphics(props.context);
|
|
1006
|
+
const slotValid = !!container && !container.isDestroyed && container.width >= 1 && container.height >= 1;
|
|
1007
|
+
const slot = container ? {
|
|
1008
|
+
screenX: container.screenX,
|
|
1009
|
+
screenY: container.screenY,
|
|
1010
|
+
width: container.width,
|
|
1011
|
+
height: container.height
|
|
1012
|
+
} : null;
|
|
1013
|
+
const offset = terminalOffset(slot);
|
|
1014
|
+
const imageId = imageIdForArtwork(props.artwork.id);
|
|
1015
|
+
const x = slot ? slot.screenX + offset.x : 0;
|
|
1016
|
+
const y = slot ? slot.screenY + offset.y : 0;
|
|
1017
|
+
const width = slot?.width ?? 0;
|
|
1018
|
+
const height = slot?.height ?? 0;
|
|
1019
|
+
const plan = planNativeArtworkPlacement({
|
|
1020
|
+
state: runtime.state,
|
|
1021
|
+
imageId,
|
|
1022
|
+
x,
|
|
1023
|
+
y,
|
|
1024
|
+
width,
|
|
1025
|
+
height,
|
|
1026
|
+
kittySupported,
|
|
1027
|
+
slotValid,
|
|
1028
|
+
disposed
|
|
1029
|
+
});
|
|
1030
|
+
if (plan.actions.length === 0)
|
|
1031
|
+
return;
|
|
1032
|
+
if (applyPlan(plan))
|
|
1033
|
+
commitPlan(plan);
|
|
1034
|
+
};
|
|
1035
|
+
const scheduleNativeImage = () => {
|
|
1036
|
+
if (paintPending || disposed)
|
|
1037
|
+
return;
|
|
1038
|
+
paintPending = true;
|
|
1039
|
+
props.context.renderer.idle().then(() => {
|
|
1040
|
+
paintPending = false;
|
|
1041
|
+
if (!disposed && ownership.isCurrent())
|
|
1042
|
+
paintNativeImage();
|
|
1043
|
+
}).catch((error) => {
|
|
1044
|
+
paintPending = false;
|
|
1045
|
+
if (!disposed && ownership.isCurrent())
|
|
1046
|
+
console.error("Failed to paint music artwork", error);
|
|
1047
|
+
});
|
|
1048
|
+
};
|
|
1049
|
+
onMount(() => props.context.renderer.on("frame", scheduleNativeImage));
|
|
1050
|
+
onCleanup(() => {
|
|
1051
|
+
disposed = true;
|
|
1052
|
+
props.context.renderer.off("frame", scheduleNativeImage);
|
|
1053
|
+
setTimeout(() => {
|
|
1054
|
+
cleanupNativeArtwork(ownership, () => {
|
|
1055
|
+
if (!renderer.isDestroyed)
|
|
1056
|
+
writeGraphics(renderer, kittyDelete(nativeArtworkImageId));
|
|
1057
|
+
runtime.state = {
|
|
1058
|
+
transmitted: 0,
|
|
1059
|
+
placement: null
|
|
1060
|
+
};
|
|
1061
|
+
runtime.identity = "";
|
|
1062
|
+
});
|
|
1063
|
+
}, 0);
|
|
1064
|
+
});
|
|
1065
|
+
return (() => {
|
|
1066
|
+
var _el$ = _$createElement("box");
|
|
1067
|
+
_$use((value) => container = value, _el$);
|
|
1068
|
+
_$setProp(_el$, "width", 24);
|
|
1069
|
+
_$setProp(_el$, "height", 12);
|
|
1070
|
+
_$setProp(_el$, "flexDirection", "column");
|
|
1071
|
+
_$setProp(_el$, "overflow", "hidden");
|
|
1072
|
+
_$insert(_el$, _$createComponent(For, {
|
|
1073
|
+
get each() {
|
|
1074
|
+
return props.artwork.cells;
|
|
1075
|
+
},
|
|
1076
|
+
children: (row) => (() => {
|
|
1077
|
+
var _el$2 = _$createElement("text");
|
|
1078
|
+
_$insert(_el$2, _$createComponent(For, {
|
|
1079
|
+
each: row,
|
|
1080
|
+
children: (cell) => (() => {
|
|
1081
|
+
var _el$3 = _$createElement("span");
|
|
1082
|
+
_$insertNode(_el$3, _$createTextNode(`\u2580`));
|
|
1083
|
+
_$effect((_$p) => _$setProp(_el$3, "style", {
|
|
1084
|
+
fg: cell.upper,
|
|
1085
|
+
bg: cell.lower
|
|
1086
|
+
}, _$p));
|
|
1087
|
+
return _el$3;
|
|
1088
|
+
})()
|
|
1089
|
+
}));
|
|
1090
|
+
return _el$2;
|
|
1091
|
+
})()
|
|
1092
|
+
}));
|
|
1093
|
+
return _el$;
|
|
1094
|
+
})();
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
// waveform.tsx
|
|
1098
|
+
import { insert as _$insert2 } from "@opentui/solid";
|
|
1099
|
+
import { use as _$use2 } from "@opentui/solid";
|
|
1100
|
+
import { createElement as _$createElement2 } from "@opentui/solid";
|
|
1101
|
+
import { createEngine, displayLevel, isFlat, sameTrackIdentity, stepEngine, waveformSeedKey } from "@naxodev/music-core";
|
|
1102
|
+
import { onCleanup as onCleanup2, onMount as onMount2 } from "solid-js";
|
|
1103
|
+
import { StyledText, fg } from "@opentui/core";
|
|
1104
|
+
function createWaveformCoordinator(options) {
|
|
1105
|
+
let player = null;
|
|
1106
|
+
let bars = 0;
|
|
1107
|
+
let requestedKey = "";
|
|
1108
|
+
let engineKey = "";
|
|
1109
|
+
let engine = null;
|
|
1110
|
+
let timer = null;
|
|
1111
|
+
const stop = () => {
|
|
1112
|
+
if (timer === null)
|
|
1113
|
+
return;
|
|
1114
|
+
options.scheduler.clearInterval(timer);
|
|
1115
|
+
timer = null;
|
|
1116
|
+
};
|
|
1117
|
+
const start = () => {
|
|
1118
|
+
if (timer !== null || !player?.track)
|
|
1119
|
+
return;
|
|
1120
|
+
timer = options.scheduler.setInterval(() => frame(), options.intervalMs);
|
|
1121
|
+
};
|
|
1122
|
+
const frame = (seek = false) => {
|
|
1123
|
+
const track = player?.track;
|
|
1124
|
+
if (!player || !track) {
|
|
1125
|
+
stop();
|
|
1126
|
+
return null;
|
|
1127
|
+
}
|
|
1128
|
+
const key = engineKey || requestedKey;
|
|
1129
|
+
if (!engine || engine.n !== bars || engineKey !== key) {
|
|
1130
|
+
engine = createEngine(bars, key);
|
|
1131
|
+
engineKey = key;
|
|
1132
|
+
}
|
|
1133
|
+
stepEngine(engine, {
|
|
1134
|
+
track_key: key,
|
|
1135
|
+
bars,
|
|
1136
|
+
progress_ms: player.progress_ms,
|
|
1137
|
+
fetched_at: player.fetched_at,
|
|
1138
|
+
is_playing: player.is_playing,
|
|
1139
|
+
duration_ms: track.duration_ms,
|
|
1140
|
+
now_ms: options.now(),
|
|
1141
|
+
seek
|
|
1142
|
+
});
|
|
1143
|
+
options.render(player, engine);
|
|
1144
|
+
if (player.is_playing || !isFlat(engine))
|
|
1145
|
+
start();
|
|
1146
|
+
else
|
|
1147
|
+
stop();
|
|
1148
|
+
return engine;
|
|
1149
|
+
};
|
|
1150
|
+
return {
|
|
1151
|
+
setInput: (next, trackIdentity, nextBars) => {
|
|
1152
|
+
if (!next?.track) {
|
|
1153
|
+
player = null;
|
|
1154
|
+
engine = null;
|
|
1155
|
+
engineKey = "";
|
|
1156
|
+
requestedKey = "";
|
|
1157
|
+
bars = nextBars;
|
|
1158
|
+
stop();
|
|
1159
|
+
options.clear();
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
if (bars !== nextBars || player?.track && !sameTrackIdentity(player.track, next.track)) {
|
|
1163
|
+
engine = null;
|
|
1164
|
+
engineKey = "";
|
|
1165
|
+
}
|
|
1166
|
+
player = next;
|
|
1167
|
+
requestedKey = trackIdentity;
|
|
1168
|
+
bars = nextBars;
|
|
1169
|
+
},
|
|
1170
|
+
frame,
|
|
1171
|
+
start,
|
|
1172
|
+
stop,
|
|
1173
|
+
dispose: () => {
|
|
1174
|
+
stop();
|
|
1175
|
+
player = null;
|
|
1176
|
+
engine = null;
|
|
1177
|
+
engineKey = "";
|
|
1178
|
+
requestedKey = "";
|
|
1179
|
+
}
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
var BLOCK = [" ", "\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
|
1183
|
+
var BLUE = [
|
|
1184
|
+
"#1a1b26",
|
|
1185
|
+
"#24283b",
|
|
1186
|
+
"#3d59a1",
|
|
1187
|
+
"#414868",
|
|
1188
|
+
"#565f89",
|
|
1189
|
+
"#7aa2f7",
|
|
1190
|
+
"#89b4fa",
|
|
1191
|
+
"#b4c0f7",
|
|
1192
|
+
"#c0caf5"
|
|
1193
|
+
];
|
|
1194
|
+
function blueFor(level, playing, theme) {
|
|
1195
|
+
if (!playing && level < 0.05)
|
|
1196
|
+
return theme.text.subdued;
|
|
1197
|
+
if (level <= 0.02)
|
|
1198
|
+
return BLUE[1];
|
|
1199
|
+
const idx = Math.min(BLUE.length - 1, Math.floor(level * (BLUE.length - 1)));
|
|
1200
|
+
return BLUE[Math.max(2, idx)];
|
|
1201
|
+
}
|
|
1202
|
+
function blockChar(level) {
|
|
1203
|
+
return BLOCK[Math.max(0, Math.min(8, Math.round(level * 8)))];
|
|
1204
|
+
}
|
|
1205
|
+
function Waveform(props) {
|
|
1206
|
+
const barCount = () => props.bars ?? (props.variant === "hero" ? 48 : 16);
|
|
1207
|
+
let text;
|
|
1208
|
+
const paint = (player, engine) => {
|
|
1209
|
+
if (!text)
|
|
1210
|
+
return;
|
|
1211
|
+
const playing = player.is_playing;
|
|
1212
|
+
text.content = new StyledText(Array.from({
|
|
1213
|
+
length: barCount()
|
|
1214
|
+
}, (_, index) => {
|
|
1215
|
+
const level = displayLevel(engine.levels[index] ?? 0, index, playing);
|
|
1216
|
+
return fg(blueFor(level, playing || level > 0.05, props.theme))(blockChar(level));
|
|
1217
|
+
}));
|
|
1218
|
+
};
|
|
1219
|
+
const coordinator = createWaveformCoordinator({
|
|
1220
|
+
now: () => Date.now(),
|
|
1221
|
+
scheduler: {
|
|
1222
|
+
setInterval,
|
|
1223
|
+
clearInterval: (timer) => clearInterval(timer)
|
|
1224
|
+
},
|
|
1225
|
+
intervalMs: props.variant === "hero" ? 48 : 64,
|
|
1226
|
+
render: paint,
|
|
1227
|
+
clear: () => {
|
|
1228
|
+
if (text)
|
|
1229
|
+
text.content = " ".repeat(barCount());
|
|
1230
|
+
}
|
|
1231
|
+
});
|
|
1232
|
+
const update = (player) => {
|
|
1233
|
+
coordinator.setInput(player, player?.track ? waveformSeedKey(player.track.name, player.track.id) : "", barCount());
|
|
1234
|
+
coordinator.frame();
|
|
1235
|
+
};
|
|
1236
|
+
const unsubscribe = props.source.subscribe(update);
|
|
1237
|
+
onMount2(() => coordinator.frame());
|
|
1238
|
+
onCleanup2(() => {
|
|
1239
|
+
unsubscribe();
|
|
1240
|
+
coordinator.dispose();
|
|
1241
|
+
});
|
|
1242
|
+
return (() => {
|
|
1243
|
+
var _el$ = _$createElement2("text");
|
|
1244
|
+
_$use2((element) => text = element, _el$);
|
|
1245
|
+
_$insert2(_el$, () => " ".repeat(barCount()));
|
|
1246
|
+
return _el$;
|
|
1247
|
+
})();
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
// ui.tsx
|
|
1251
|
+
var t = (s) => s + "\uFE0E";
|
|
1252
|
+
var Icon = {
|
|
1253
|
+
prev: t("\u23EE"),
|
|
1254
|
+
next: t("\u23ED"),
|
|
1255
|
+
play: t("\u25B6"),
|
|
1256
|
+
pause: t("\u23F8"),
|
|
1257
|
+
scrub: "\u25CF"
|
|
1258
|
+
};
|
|
1259
|
+
var TRANSPORT_LAYOUT = {
|
|
1260
|
+
prevWidth: 6,
|
|
1261
|
+
playWidth: 10,
|
|
1262
|
+
nextWidth: 6,
|
|
1263
|
+
height: 2,
|
|
1264
|
+
gap: 1
|
|
1265
|
+
};
|
|
1266
|
+
var COMPACT_MARKER_WIDTH = 1;
|
|
1267
|
+
var COMPACT_TITLE_SEPARATOR = " ";
|
|
1268
|
+
var COMPACT_SEPARATOR = " - ";
|
|
1269
|
+
var COMPACT_SEEK_FRACTION = 0.8;
|
|
1270
|
+
function compactSeekRegionWidth(width) {
|
|
1271
|
+
if (!Number.isFinite(width) || width <= 0)
|
|
1272
|
+
return 0;
|
|
1273
|
+
const available = Math.floor(width);
|
|
1274
|
+
return Math.max(0, Math.min(available, Math.round(available * COMPACT_SEEK_FRACTION)));
|
|
1275
|
+
}
|
|
1276
|
+
function seekPositionForCell(cell, width, durationMs) {
|
|
1277
|
+
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(durationMs) || durationMs <= 0) {
|
|
1278
|
+
return null;
|
|
1279
|
+
}
|
|
1280
|
+
const lastCell = Math.max(0, Math.floor(width) - 1);
|
|
1281
|
+
const clampedCell = Math.max(0, Math.min(lastCell, Math.floor(cell)));
|
|
1282
|
+
const ratio = lastCell === 0 ? 0 : clampedCell / lastCell;
|
|
1283
|
+
return Math.round(ratio * durationMs);
|
|
1284
|
+
}
|
|
1285
|
+
var COMPACT_BUDGETS = {
|
|
1286
|
+
wide: {
|
|
1287
|
+
minWidth: 55,
|
|
1288
|
+
padding: 1,
|
|
1289
|
+
titleWidth: 28,
|
|
1290
|
+
artistWidth: 20
|
|
1291
|
+
},
|
|
1292
|
+
medium: {
|
|
1293
|
+
minWidth: 32,
|
|
1294
|
+
padding: 1,
|
|
1295
|
+
titleWidth: 28,
|
|
1296
|
+
artistWidth: 0
|
|
1297
|
+
},
|
|
1298
|
+
narrow: {
|
|
1299
|
+
minWidth: 6,
|
|
1300
|
+
padding: 1,
|
|
1301
|
+
titleWidth: 2,
|
|
1302
|
+
artistWidth: 0
|
|
1303
|
+
},
|
|
1304
|
+
markerOnly: {
|
|
1305
|
+
minWidth: 1,
|
|
1306
|
+
padding: 0,
|
|
1307
|
+
titleWidth: 0,
|
|
1308
|
+
artistWidth: 0
|
|
1309
|
+
}
|
|
1310
|
+
};
|
|
1311
|
+
function sanitizeTerminalText(value, fallback = "") {
|
|
1312
|
+
const clean = value.normalize("NFC").replace(/(?:\x1b\]|\x9d)[\s\S]*?(?:\x07|\x1b\\|\x9c|$)/g, "").replace(/(?:\x1b\[|\x9b)[0-?]*[ -/]*[@-~]/g, "").replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, " ").replace(/\s+/g, " ").trim();
|
|
1313
|
+
return clean || fallback;
|
|
1314
|
+
}
|
|
1315
|
+
function truncate(value, width) {
|
|
1316
|
+
if (Bun.stringWidth(value) <= width)
|
|
1317
|
+
return value;
|
|
1318
|
+
if (width <= 1)
|
|
1319
|
+
return "\u2026";
|
|
1320
|
+
let result = "";
|
|
1321
|
+
for (const character of value) {
|
|
1322
|
+
if (Bun.stringWidth(result + character) > width - 1)
|
|
1323
|
+
break;
|
|
1324
|
+
result += character;
|
|
1325
|
+
}
|
|
1326
|
+
return `${result}\u2026`;
|
|
1327
|
+
}
|
|
1328
|
+
function compactPresentation(width, title, artist, playing) {
|
|
1329
|
+
const available = Math.max(1, Math.floor(width));
|
|
1330
|
+
const marker = playing ? Icon.pause : Icon.play;
|
|
1331
|
+
const cleanTitle = sanitizeTerminalText(title, "Unknown track");
|
|
1332
|
+
const cleanArtist = sanitizeTerminalText(artist);
|
|
1333
|
+
const tier = available >= COMPACT_BUDGETS.wide.minWidth ? "wide" : available >= COMPACT_BUDGETS.medium.minWidth ? "medium" : available >= COMPACT_BUDGETS.narrow.minWidth ? "narrow" : "markerOnly";
|
|
1334
|
+
const budget = COMPACT_BUDGETS[tier];
|
|
1335
|
+
if (tier === "markerOnly") {
|
|
1336
|
+
return {
|
|
1337
|
+
tier,
|
|
1338
|
+
marker,
|
|
1339
|
+
padding: budget.padding,
|
|
1340
|
+
title: null,
|
|
1341
|
+
artist: null
|
|
1342
|
+
};
|
|
1343
|
+
}
|
|
1344
|
+
const fixedWidth = COMPACT_MARKER_WIDTH + COMPACT_TITLE_SEPARATOR.length + budget.padding * 2;
|
|
1345
|
+
const titleWidth = tier === "narrow" ? available - fixedWidth : budget.titleWidth;
|
|
1346
|
+
return {
|
|
1347
|
+
tier,
|
|
1348
|
+
marker,
|
|
1349
|
+
padding: budget.padding,
|
|
1350
|
+
title: truncate(cleanTitle, titleWidth),
|
|
1351
|
+
artist: tier === "wide" && cleanArtist ? truncate(cleanArtist, budget.artistWidth) : null
|
|
1352
|
+
};
|
|
1353
|
+
}
|
|
1354
|
+
function CompactPlayer(props) {
|
|
1355
|
+
const theme = () => props.context.theme;
|
|
1356
|
+
let row;
|
|
1357
|
+
let content;
|
|
1358
|
+
let allocatedWidth = Number.MAX_SAFE_INTEGER;
|
|
1359
|
+
const track = createMemo(() => props.state.player?.track);
|
|
1360
|
+
const renderLine = () => {
|
|
1361
|
+
const current = track();
|
|
1362
|
+
if (!current)
|
|
1363
|
+
return "";
|
|
1364
|
+
const display = compactPresentation(allocatedWidth, current.name, current.artists, !!props.state.player?.is_playing);
|
|
1365
|
+
const padding = " ".repeat(display.padding);
|
|
1366
|
+
return `${padding}${display.marker}${display.title ? `${COMPACT_TITLE_SEPARATOR}${display.title}` : ""}${display.artist ? `${COMPACT_SEPARATOR}${display.artist}` : ""}${padding}`;
|
|
1367
|
+
};
|
|
1368
|
+
const updateContent = () => {
|
|
1369
|
+
const line = renderLine();
|
|
1370
|
+
if (content)
|
|
1371
|
+
content.content = line;
|
|
1372
|
+
};
|
|
1373
|
+
const updateWidth = function() {
|
|
1374
|
+
allocatedWidth = Math.max(1, this.width);
|
|
1375
|
+
updateContent();
|
|
1376
|
+
};
|
|
1377
|
+
createEffect(updateContent);
|
|
1378
|
+
onCleanup3(() => {
|
|
1379
|
+
if (row?.onSizeChange === updateWidth)
|
|
1380
|
+
row.onSizeChange = undefined;
|
|
1381
|
+
});
|
|
1382
|
+
return _$createComponent2(Show, {
|
|
1383
|
+
get when() {
|
|
1384
|
+
return track();
|
|
1385
|
+
},
|
|
1386
|
+
children: (_track) => (() => {
|
|
1387
|
+
var _el$ = _$createElement3("box"), _el$2 = _$createElement3("text");
|
|
1388
|
+
_$insertNode2(_el$, _el$2);
|
|
1389
|
+
_$use3((element) => {
|
|
1390
|
+
row = element;
|
|
1391
|
+
element.onSizeChange = updateWidth;
|
|
1392
|
+
}, _el$);
|
|
1393
|
+
_$setProp2(_el$, "id", "music-compact-player");
|
|
1394
|
+
_$setProp2(_el$, "width", "100%");
|
|
1395
|
+
_$setProp2(_el$, "alignSelf", "stretch");
|
|
1396
|
+
_$setProp2(_el$, "minWidth", 0);
|
|
1397
|
+
_$setProp2(_el$, "height", 1);
|
|
1398
|
+
_$setProp2(_el$, "flexDirection", "row");
|
|
1399
|
+
_$setProp2(_el$, "flexShrink", 0);
|
|
1400
|
+
_$setProp2(_el$, "overflow", "hidden");
|
|
1401
|
+
_$setProp2(_el$, "onMouseDown", (event) => {
|
|
1402
|
+
if (event.button !== MouseButton.LEFT)
|
|
1403
|
+
return;
|
|
1404
|
+
const current = props.state.player?.track;
|
|
1405
|
+
if (!current || !row)
|
|
1406
|
+
return;
|
|
1407
|
+
const seekWidth = compactSeekRegionWidth(row.width);
|
|
1408
|
+
const cell = Math.floor(event.x - row.x);
|
|
1409
|
+
const markerCell = compactPresentation(row.width, current.name, current.artists, !!props.state.player?.is_playing).padding;
|
|
1410
|
+
if (cell === markerCell) {
|
|
1411
|
+
event.preventDefault();
|
|
1412
|
+
event.stopPropagation();
|
|
1413
|
+
props.onPlayPause();
|
|
1414
|
+
return;
|
|
1415
|
+
}
|
|
1416
|
+
if (cell < 0 || cell >= seekWidth)
|
|
1417
|
+
return;
|
|
1418
|
+
const position = seekPositionForCell(cell, seekWidth, current.duration_ms);
|
|
1419
|
+
if (position === null)
|
|
1420
|
+
return;
|
|
1421
|
+
event.preventDefault();
|
|
1422
|
+
event.stopPropagation();
|
|
1423
|
+
props.onSeek(position);
|
|
1424
|
+
});
|
|
1425
|
+
_$use3((element) => {
|
|
1426
|
+
content = element;
|
|
1427
|
+
updateContent();
|
|
1428
|
+
}, _el$2);
|
|
1429
|
+
_$setProp2(_el$2, "wrapMode", "none");
|
|
1430
|
+
_$setProp2(_el$2, "truncate", true);
|
|
1431
|
+
_$insert3(_el$2, renderLine);
|
|
1432
|
+
_$effect2((_$p) => _$setProp2(_el$2, "fg", theme().text.action.primary.default, _$p));
|
|
1433
|
+
return _el$;
|
|
1434
|
+
})()
|
|
1435
|
+
});
|
|
1436
|
+
}
|
|
1437
|
+
function liveProgress(player, now = Date.now()) {
|
|
1438
|
+
if (!player?.track)
|
|
1439
|
+
return 0;
|
|
1440
|
+
if (!player.is_playing)
|
|
1441
|
+
return player.progress_ms;
|
|
1442
|
+
return Math.min(player.track.duration_ms, player.progress_ms + (Math.max(now, player.fetched_at) - player.fetched_at));
|
|
1443
|
+
}
|
|
1444
|
+
function progressSegments(progress, duration, width) {
|
|
1445
|
+
if (duration <= 0)
|
|
1446
|
+
return {
|
|
1447
|
+
left: "",
|
|
1448
|
+
thumb: "",
|
|
1449
|
+
right: "\u2500".repeat(width)
|
|
1450
|
+
};
|
|
1451
|
+
const ratio = Math.max(0, Math.min(1, progress / duration));
|
|
1452
|
+
const track = width - 1;
|
|
1453
|
+
const filled = Math.max(0, Math.min(track, Math.round(ratio * track)));
|
|
1454
|
+
return {
|
|
1455
|
+
left: "\u2501".repeat(filled),
|
|
1456
|
+
thumb: Icon.scrub,
|
|
1457
|
+
right: "\u2500".repeat(Math.max(0, track - filled))
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
function ProgressBar(props) {
|
|
1461
|
+
let bar;
|
|
1462
|
+
let leftNode;
|
|
1463
|
+
let thumbNode;
|
|
1464
|
+
let rightNode;
|
|
1465
|
+
const width = () => Math.max(8, props.width ?? 28);
|
|
1466
|
+
const rendered = () => {
|
|
1467
|
+
const player = props.source.current();
|
|
1468
|
+
return progressSegments(liveProgress(player), player?.track?.duration_ms ?? 0, width());
|
|
1469
|
+
};
|
|
1470
|
+
const paint = () => {
|
|
1471
|
+
const next = rendered();
|
|
1472
|
+
if (leftNode)
|
|
1473
|
+
leftNode.children = [next.left];
|
|
1474
|
+
if (thumbNode)
|
|
1475
|
+
thumbNode.children = [next.thumb];
|
|
1476
|
+
if (rightNode)
|
|
1477
|
+
rightNode.children = [next.right];
|
|
1478
|
+
};
|
|
1479
|
+
onMount3(() => {
|
|
1480
|
+
const unsubscribe = props.source.subscribe(() => paint());
|
|
1481
|
+
const timer = setInterval(() => {
|
|
1482
|
+
if (props.source.current()?.is_playing)
|
|
1483
|
+
paint();
|
|
1484
|
+
}, 1000);
|
|
1485
|
+
onCleanup3(() => {
|
|
1486
|
+
unsubscribe();
|
|
1487
|
+
clearInterval(timer);
|
|
1488
|
+
});
|
|
1489
|
+
});
|
|
1490
|
+
return (() => {
|
|
1491
|
+
var _el$3 = _$createElement3("box"), _el$4 = _$createElement3("text"), _el$5 = _$createElement3("span"), _el$6 = _$createElement3("span"), _el$7 = _$createElement3("span");
|
|
1492
|
+
_$insertNode2(_el$3, _el$4);
|
|
1493
|
+
_$use3((element) => bar = element, _el$3);
|
|
1494
|
+
_$setProp2(_el$3, "id", "music-sidebar-seek");
|
|
1495
|
+
_$setProp2(_el$3, "height", 1);
|
|
1496
|
+
_$setProp2(_el$3, "onMouseDown", (event) => {
|
|
1497
|
+
if (event.button !== MouseButton.LEFT || !bar)
|
|
1498
|
+
return;
|
|
1499
|
+
const position = seekPositionForCell(event.x - bar.x, bar.width, props.source.current()?.track?.duration_ms ?? 0);
|
|
1500
|
+
if (position === null)
|
|
1501
|
+
return;
|
|
1502
|
+
event.preventDefault();
|
|
1503
|
+
event.stopPropagation();
|
|
1504
|
+
props.onSeek(position);
|
|
1505
|
+
});
|
|
1506
|
+
_$insertNode2(_el$4, _el$5);
|
|
1507
|
+
_$insertNode2(_el$4, _el$6);
|
|
1508
|
+
_$insertNode2(_el$4, _el$7);
|
|
1509
|
+
_$use3((element) => leftNode = element, _el$5);
|
|
1510
|
+
_$insert3(_el$5, () => rendered().left);
|
|
1511
|
+
_$use3((element) => thumbNode = element, _el$6);
|
|
1512
|
+
_$insert3(_el$6, () => rendered().thumb);
|
|
1513
|
+
_$use3((element) => rightNode = element, _el$7);
|
|
1514
|
+
_$insert3(_el$7, () => rendered().right);
|
|
1515
|
+
_$effect2((_p$) => {
|
|
1516
|
+
var _v$ = width(), _v$2 = {
|
|
1517
|
+
fg: props.accent ?? props.theme.text.action.primary.default
|
|
1518
|
+
}, _v$3 = {
|
|
1519
|
+
fg: props.accent ?? props.theme.text.action.primary.default
|
|
1520
|
+
}, _v$4 = {
|
|
1521
|
+
fg: props.theme.text.subdued
|
|
1522
|
+
};
|
|
1523
|
+
_v$ !== _p$.e && (_p$.e = _$setProp2(_el$3, "width", _v$, _p$.e));
|
|
1524
|
+
_v$2 !== _p$.t && (_p$.t = _$setProp2(_el$5, "style", _v$2, _p$.t));
|
|
1525
|
+
_v$3 !== _p$.a && (_p$.a = _$setProp2(_el$6, "style", _v$3, _p$.a));
|
|
1526
|
+
_v$4 !== _p$.o && (_p$.o = _$setProp2(_el$7, "style", _v$4, _p$.o));
|
|
1527
|
+
return _p$;
|
|
1528
|
+
}, {
|
|
1529
|
+
e: undefined,
|
|
1530
|
+
t: undefined,
|
|
1531
|
+
a: undefined,
|
|
1532
|
+
o: undefined
|
|
1533
|
+
});
|
|
1534
|
+
return _el$3;
|
|
1535
|
+
})();
|
|
1536
|
+
}
|
|
1537
|
+
function LiveElapsed(props) {
|
|
1538
|
+
let text;
|
|
1539
|
+
const paint = () => {
|
|
1540
|
+
if (text)
|
|
1541
|
+
text.content = formatMs(liveProgress(props.source.current()));
|
|
1542
|
+
};
|
|
1543
|
+
onMount3(() => {
|
|
1544
|
+
const unsubscribe = props.source.subscribe(() => paint());
|
|
1545
|
+
const timer = setInterval(() => {
|
|
1546
|
+
if (props.source.current()?.is_playing)
|
|
1547
|
+
paint();
|
|
1548
|
+
}, 1000);
|
|
1549
|
+
onCleanup3(() => {
|
|
1550
|
+
unsubscribe();
|
|
1551
|
+
clearInterval(timer);
|
|
1552
|
+
});
|
|
1553
|
+
});
|
|
1554
|
+
return (() => {
|
|
1555
|
+
var _el$8 = _$createElement3("text");
|
|
1556
|
+
_$use3((element) => text = element, _el$8);
|
|
1557
|
+
_$insert3(_el$8, () => formatMs(liveProgress(props.source.current())));
|
|
1558
|
+
_$effect2((_$p) => _$setProp2(_el$8, "fg", props.theme.text.subdued, _$p));
|
|
1559
|
+
return _el$8;
|
|
1560
|
+
})();
|
|
1561
|
+
}
|
|
1562
|
+
function IconBtn(props) {
|
|
1563
|
+
const [hover, setHover] = createSignal(false);
|
|
1564
|
+
const [press, setPress] = createSignal(false);
|
|
1565
|
+
const bg = () => {
|
|
1566
|
+
if (press() || props.active) {
|
|
1567
|
+
return props.theme.background.action.primary.default;
|
|
1568
|
+
}
|
|
1569
|
+
return props.theme.background.surface.offset;
|
|
1570
|
+
};
|
|
1571
|
+
const fg2 = () => {
|
|
1572
|
+
if (press() || props.active)
|
|
1573
|
+
return props.accent ?? props.theme.text.action.primary.default;
|
|
1574
|
+
if (props.primary) {
|
|
1575
|
+
return hover() ? props.theme.text.action.primary.default : props.theme.text.default;
|
|
1576
|
+
}
|
|
1577
|
+
return hover() ? props.theme.text.action.primary.default : props.theme.text.default;
|
|
1578
|
+
};
|
|
1579
|
+
const w = () => props.width ?? 3;
|
|
1580
|
+
const h = () => props.height ?? 1;
|
|
1581
|
+
return (() => {
|
|
1582
|
+
var _el$9 = _$createElement3("box"), _el$0 = _$createElement3("text");
|
|
1583
|
+
_$insertNode2(_el$9, _el$0);
|
|
1584
|
+
_$setProp2(_el$9, "onMouseDown", () => {
|
|
1585
|
+
setPress(true);
|
|
1586
|
+
props.onClick();
|
|
1587
|
+
});
|
|
1588
|
+
_$setProp2(_el$9, "onMouseUp", () => setPress(false));
|
|
1589
|
+
_$setProp2(_el$9, "onMouseOver", () => setHover(true));
|
|
1590
|
+
_$setProp2(_el$9, "onMouseOut", () => {
|
|
1591
|
+
setHover(false);
|
|
1592
|
+
setPress(false);
|
|
1593
|
+
});
|
|
1594
|
+
_$setProp2(_el$9, "flexShrink", 0);
|
|
1595
|
+
_$setProp2(_el$9, "alignItems", "center");
|
|
1596
|
+
_$setProp2(_el$9, "justifyContent", "center");
|
|
1597
|
+
_$insert3(_el$0, () => props.icon);
|
|
1598
|
+
_$effect2((_p$) => {
|
|
1599
|
+
var _v$5 = w(), _v$6 = h(), _v$7 = bg(), _v$8 = fg2();
|
|
1600
|
+
_v$5 !== _p$.e && (_p$.e = _$setProp2(_el$9, "width", _v$5, _p$.e));
|
|
1601
|
+
_v$6 !== _p$.t && (_p$.t = _$setProp2(_el$9, "height", _v$6, _p$.t));
|
|
1602
|
+
_v$7 !== _p$.a && (_p$.a = _$setProp2(_el$9, "backgroundColor", _v$7, _p$.a));
|
|
1603
|
+
_v$8 !== _p$.o && (_p$.o = _$setProp2(_el$0, "fg", _v$8, _p$.o));
|
|
1604
|
+
return _p$;
|
|
1605
|
+
}, {
|
|
1606
|
+
e: undefined,
|
|
1607
|
+
t: undefined,
|
|
1608
|
+
a: undefined,
|
|
1609
|
+
o: undefined
|
|
1610
|
+
});
|
|
1611
|
+
return _el$9;
|
|
1612
|
+
})();
|
|
1613
|
+
}
|
|
1614
|
+
function SidebarPlayer(props) {
|
|
1615
|
+
const theme = () => props.context.theme;
|
|
1616
|
+
let latestPlayer = props.state.player;
|
|
1617
|
+
const presentationListeners = new Set;
|
|
1618
|
+
const publishPlayer = (player2) => {
|
|
1619
|
+
latestPlayer = player2;
|
|
1620
|
+
for (const listener of [...presentationListeners]) {
|
|
1621
|
+
try {
|
|
1622
|
+
listener(player2);
|
|
1623
|
+
} catch {}
|
|
1624
|
+
}
|
|
1625
|
+
};
|
|
1626
|
+
const source = {
|
|
1627
|
+
current: () => latestPlayer,
|
|
1628
|
+
subscribe(listener) {
|
|
1629
|
+
presentationListeners.add(listener);
|
|
1630
|
+
try {
|
|
1631
|
+
listener(latestPlayer);
|
|
1632
|
+
} catch {}
|
|
1633
|
+
return () => presentationListeners.delete(listener);
|
|
1634
|
+
}
|
|
1635
|
+
};
|
|
1636
|
+
createEffect(() => publishPlayer(props.state.player));
|
|
1637
|
+
const unsubscribeSession = props.subscribe?.((state) => publishPlayer(state.player));
|
|
1638
|
+
onCleanup3(() => {
|
|
1639
|
+
unsubscribeSession?.();
|
|
1640
|
+
presentationListeners.clear();
|
|
1641
|
+
});
|
|
1642
|
+
const player = createMemo(() => props.state.player);
|
|
1643
|
+
const track = createMemo(() => player()?.track);
|
|
1644
|
+
const playing = createMemo(() => !!player()?.is_playing);
|
|
1645
|
+
const duration = createMemo(() => track()?.duration_ms ?? 0);
|
|
1646
|
+
return (() => {
|
|
1647
|
+
var _el$1 = _$createElement3("box"), _el$10 = _$createElement3("box"), _el$11 = _$createElement3("box"), _el$12 = _$createElement3("box"), _el$16 = _$createElement3("box");
|
|
1648
|
+
_$insertNode2(_el$1, _el$10);
|
|
1649
|
+
_$insertNode2(_el$1, _el$12);
|
|
1650
|
+
_$insertNode2(_el$1, _el$16);
|
|
1651
|
+
_$setProp2(_el$1, "flexDirection", "column");
|
|
1652
|
+
_$setProp2(_el$1, "border", ["top"]);
|
|
1653
|
+
_$setProp2(_el$1, "paddingLeft", 1);
|
|
1654
|
+
_$setProp2(_el$1, "paddingRight", 1);
|
|
1655
|
+
_$setProp2(_el$1, "paddingTop", 1);
|
|
1656
|
+
_$setProp2(_el$1, "paddingBottom", 1);
|
|
1657
|
+
_$setProp2(_el$1, "gap", 1);
|
|
1658
|
+
_$setProp2(_el$1, "flexShrink", 0);
|
|
1659
|
+
_$insert3(_el$1, _$createComponent2(Show, {
|
|
1660
|
+
get when() {
|
|
1661
|
+
return props.state.error;
|
|
1662
|
+
},
|
|
1663
|
+
children: (err) => (() => {
|
|
1664
|
+
var _el$17 = _$createElement3("text");
|
|
1665
|
+
_$insert3(_el$17, () => sanitizeTerminalText(err()));
|
|
1666
|
+
_$effect2((_$p) => _$setProp2(_el$17, "fg", theme().text.feedback.error.default, _$p));
|
|
1667
|
+
return _el$17;
|
|
1668
|
+
})()
|
|
1669
|
+
}), _el$10);
|
|
1670
|
+
_$insertNode2(_el$10, _el$11);
|
|
1671
|
+
_$setProp2(_el$10, "flexDirection", "row");
|
|
1672
|
+
_$setProp2(_el$10, "justifyContent", "center");
|
|
1673
|
+
_$setProp2(_el$11, "width", 24);
|
|
1674
|
+
_$setProp2(_el$11, "height", 12);
|
|
1675
|
+
_$setProp2(_el$11, "overflow", "hidden");
|
|
1676
|
+
_$insert3(_el$11, _$createComponent2(Show, {
|
|
1677
|
+
get when() {
|
|
1678
|
+
return track()?.artwork;
|
|
1679
|
+
},
|
|
1680
|
+
get fallback() {
|
|
1681
|
+
return (() => {
|
|
1682
|
+
var _el$18 = _$createElement3("box"), _el$19 = _$createElement3("text");
|
|
1683
|
+
_$insertNode2(_el$18, _el$19);
|
|
1684
|
+
_$setProp2(_el$18, "width", 24);
|
|
1685
|
+
_$setProp2(_el$18, "height", 12);
|
|
1686
|
+
_$setProp2(_el$18, "alignItems", "center");
|
|
1687
|
+
_$setProp2(_el$18, "justifyContent", "center");
|
|
1688
|
+
_$insert3(_el$19, (() => {
|
|
1689
|
+
var _c$ = _$memo(() => !!track());
|
|
1690
|
+
return () => _c$() ? track()?.artwork_loading ? "Loading artwork\u2026" : "Artwork unavailable" : "";
|
|
1691
|
+
})());
|
|
1692
|
+
_$effect2((_$p) => _$setProp2(_el$19, "fg", theme().text.subdued, _$p));
|
|
1693
|
+
return _el$18;
|
|
1694
|
+
})();
|
|
1695
|
+
},
|
|
1696
|
+
children: (artwork) => _$createComponent2(AlbumArtwork, {
|
|
1697
|
+
get context() {
|
|
1698
|
+
return props.context;
|
|
1699
|
+
},
|
|
1700
|
+
get artwork() {
|
|
1701
|
+
return artwork();
|
|
1702
|
+
}
|
|
1703
|
+
})
|
|
1704
|
+
}));
|
|
1705
|
+
_$insert3(_el$1, _$createComponent2(Show, {
|
|
1706
|
+
get when() {
|
|
1707
|
+
return track();
|
|
1708
|
+
},
|
|
1709
|
+
get fallback() {
|
|
1710
|
+
return (() => {
|
|
1711
|
+
var _el$20 = _$createElement3("text");
|
|
1712
|
+
_$insert3(_el$20, () => props.state.loading ? "Syncing\u2026" : "Nothing playing");
|
|
1713
|
+
_$effect2((_$p) => _$setProp2(_el$20, "fg", theme().text.subdued, _$p));
|
|
1714
|
+
return _el$20;
|
|
1715
|
+
})();
|
|
1716
|
+
},
|
|
1717
|
+
children: (t2) => (() => {
|
|
1718
|
+
var _el$21 = _$createElement3("box"), _el$22 = _$createElement3("text"), _el$23 = _$createElement3("b");
|
|
1719
|
+
_$insertNode2(_el$21, _el$22);
|
|
1720
|
+
_$setProp2(_el$21, "flexDirection", "column");
|
|
1721
|
+
_$setProp2(_el$21, "gap", 0);
|
|
1722
|
+
_$insertNode2(_el$22, _el$23);
|
|
1723
|
+
_$insert3(_el$23, () => sanitizeTerminalText(t2().name, "Unknown track"));
|
|
1724
|
+
_$insert3(_el$21, _$createComponent2(Show, {
|
|
1725
|
+
get when() {
|
|
1726
|
+
return t2().artists;
|
|
1727
|
+
},
|
|
1728
|
+
get children() {
|
|
1729
|
+
var _el$24 = _$createElement3("text");
|
|
1730
|
+
_$insert3(_el$24, () => sanitizeTerminalText(t2().artists));
|
|
1731
|
+
_$effect2((_$p) => _$setProp2(_el$24, "fg", theme().text.subdued, _$p));
|
|
1732
|
+
return _el$24;
|
|
1733
|
+
}
|
|
1734
|
+
}), null);
|
|
1735
|
+
_$insert3(_el$21, _$createComponent2(Show, {
|
|
1736
|
+
get when() {
|
|
1737
|
+
return t2().album;
|
|
1738
|
+
},
|
|
1739
|
+
get children() {
|
|
1740
|
+
var _el$25 = _$createElement3("text");
|
|
1741
|
+
_$insert3(_el$25, () => sanitizeTerminalText(t2().album));
|
|
1742
|
+
_$effect2((_$p) => _$setProp2(_el$25, "fg", theme().text.subdued, _$p));
|
|
1743
|
+
return _el$25;
|
|
1744
|
+
}
|
|
1745
|
+
}), null);
|
|
1746
|
+
_$effect2((_$p) => _$setProp2(_el$22, "fg", theme().text.default, _$p));
|
|
1747
|
+
return _el$21;
|
|
1748
|
+
})()
|
|
1749
|
+
}), _el$12);
|
|
1750
|
+
_$setProp2(_el$12, "flexDirection", "row");
|
|
1751
|
+
_$setProp2(_el$12, "justifyContent", "center");
|
|
1752
|
+
_$setProp2(_el$12, "overflow", "hidden");
|
|
1753
|
+
_$insert3(_el$12, _$createComponent2(Waveform, {
|
|
1754
|
+
get theme() {
|
|
1755
|
+
return theme();
|
|
1756
|
+
},
|
|
1757
|
+
source,
|
|
1758
|
+
bars: 24,
|
|
1759
|
+
variant: "hero"
|
|
1760
|
+
}));
|
|
1761
|
+
_$insert3(_el$1, _$createComponent2(Show, {
|
|
1762
|
+
get when() {
|
|
1763
|
+
return _$memo(() => !!track())() && duration() > 0;
|
|
1764
|
+
},
|
|
1765
|
+
get children() {
|
|
1766
|
+
var _el$13 = _$createElement3("box"), _el$14 = _$createElement3("box"), _el$15 = _$createElement3("text");
|
|
1767
|
+
_$insertNode2(_el$13, _el$14);
|
|
1768
|
+
_$setProp2(_el$13, "flexDirection", "column");
|
|
1769
|
+
_$setProp2(_el$13, "gap", 0);
|
|
1770
|
+
_$setProp2(_el$13, "overflow", "hidden");
|
|
1771
|
+
_$insert3(_el$13, _$createComponent2(ProgressBar, {
|
|
1772
|
+
get theme() {
|
|
1773
|
+
return theme();
|
|
1774
|
+
},
|
|
1775
|
+
source,
|
|
1776
|
+
width: 24,
|
|
1777
|
+
get accent() {
|
|
1778
|
+
return track()?.artwork?.accent;
|
|
1779
|
+
},
|
|
1780
|
+
get onSeek() {
|
|
1781
|
+
return props.onSeek;
|
|
1782
|
+
}
|
|
1783
|
+
}), _el$14);
|
|
1784
|
+
_$insertNode2(_el$14, _el$15);
|
|
1785
|
+
_$setProp2(_el$14, "flexDirection", "row");
|
|
1786
|
+
_$setProp2(_el$14, "justifyContent", "space-between");
|
|
1787
|
+
_$insert3(_el$14, _$createComponent2(LiveElapsed, {
|
|
1788
|
+
get theme() {
|
|
1789
|
+
return theme();
|
|
1790
|
+
},
|
|
1791
|
+
source
|
|
1792
|
+
}), _el$15);
|
|
1793
|
+
_$insert3(_el$15, () => formatMs(duration()));
|
|
1794
|
+
_$effect2((_$p) => _$setProp2(_el$15, "fg", theme().text.subdued, _$p));
|
|
1795
|
+
return _el$13;
|
|
1796
|
+
}
|
|
1797
|
+
}), _el$16);
|
|
1798
|
+
_$setProp2(_el$16, "flexDirection", "row");
|
|
1799
|
+
_$setProp2(_el$16, "justifyContent", "center");
|
|
1800
|
+
_$insert3(_el$16, _$createComponent2(IconBtn, {
|
|
1801
|
+
get theme() {
|
|
1802
|
+
return theme();
|
|
1803
|
+
},
|
|
1804
|
+
get icon() {
|
|
1805
|
+
return Icon.prev;
|
|
1806
|
+
},
|
|
1807
|
+
get width() {
|
|
1808
|
+
return TRANSPORT_LAYOUT.prevWidth;
|
|
1809
|
+
},
|
|
1810
|
+
get height() {
|
|
1811
|
+
return TRANSPORT_LAYOUT.height;
|
|
1812
|
+
},
|
|
1813
|
+
onClick: () => props.onPrev()
|
|
1814
|
+
}), null);
|
|
1815
|
+
_$insert3(_el$16, _$createComponent2(IconBtn, {
|
|
1816
|
+
get theme() {
|
|
1817
|
+
return theme();
|
|
1818
|
+
},
|
|
1819
|
+
get icon() {
|
|
1820
|
+
return _$memo(() => !!playing())() ? Icon.pause : Icon.play;
|
|
1821
|
+
},
|
|
1822
|
+
primary: true,
|
|
1823
|
+
get active() {
|
|
1824
|
+
return playing();
|
|
1825
|
+
},
|
|
1826
|
+
get accent() {
|
|
1827
|
+
return track()?.artwork?.accent;
|
|
1828
|
+
},
|
|
1829
|
+
get width() {
|
|
1830
|
+
return TRANSPORT_LAYOUT.playWidth;
|
|
1831
|
+
},
|
|
1832
|
+
get height() {
|
|
1833
|
+
return TRANSPORT_LAYOUT.height;
|
|
1834
|
+
},
|
|
1835
|
+
onClick: () => props.onPlayPause()
|
|
1836
|
+
}), null);
|
|
1837
|
+
_$insert3(_el$16, _$createComponent2(IconBtn, {
|
|
1838
|
+
get theme() {
|
|
1839
|
+
return theme();
|
|
1840
|
+
},
|
|
1841
|
+
get icon() {
|
|
1842
|
+
return Icon.next;
|
|
1843
|
+
},
|
|
1844
|
+
get width() {
|
|
1845
|
+
return TRANSPORT_LAYOUT.nextWidth;
|
|
1846
|
+
},
|
|
1847
|
+
get height() {
|
|
1848
|
+
return TRANSPORT_LAYOUT.height;
|
|
1849
|
+
},
|
|
1850
|
+
onClick: () => props.onNext()
|
|
1851
|
+
}), null);
|
|
1852
|
+
_$effect2((_p$) => {
|
|
1853
|
+
var _v$9 = playing() ? track()?.artwork?.accent ?? theme().text.action.primary.default : theme().border.default, _v$0 = TRANSPORT_LAYOUT.gap;
|
|
1854
|
+
_v$9 !== _p$.e && (_p$.e = _$setProp2(_el$1, "borderColor", _v$9, _p$.e));
|
|
1855
|
+
_v$0 !== _p$.t && (_p$.t = _$setProp2(_el$16, "gap", _v$0, _p$.t));
|
|
1856
|
+
return _p$;
|
|
1857
|
+
}, {
|
|
1858
|
+
e: undefined,
|
|
1859
|
+
t: undefined
|
|
1860
|
+
});
|
|
1861
|
+
return _el$1;
|
|
1862
|
+
})();
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// index.tsx
|
|
1866
|
+
var SEEK_END_MARGIN_MS = 1000;
|
|
1867
|
+
function seekTarget(positionMs, durationMs) {
|
|
1868
|
+
if (!Number.isFinite(positionMs) || typeof durationMs !== "number" || !Number.isFinite(durationMs) || durationMs <= 0)
|
|
1869
|
+
return null;
|
|
1870
|
+
const ceiling = Math.max(0, durationMs - SEEK_END_MARGIN_MS);
|
|
1871
|
+
return Math.max(0, Math.min(ceiling, Math.round(positionMs)));
|
|
1872
|
+
}
|
|
1873
|
+
var controllerDependencies = {
|
|
1874
|
+
createSessionMedia: createSessionSystemMedia
|
|
1875
|
+
};
|
|
1876
|
+
function errMsg(error) {
|
|
1877
|
+
return error && typeof error === "object" && "message" in error ? String(error.message) : String(error);
|
|
1878
|
+
}
|
|
1879
|
+
function createController(context, dependencies = controllerDependencies) {
|
|
1880
|
+
const [session, setSessionStore] = context.storage.memory("music-player.session.v6", {
|
|
1881
|
+
initial: {
|
|
1882
|
+
loading: false,
|
|
1883
|
+
loadingOwners: [],
|
|
1884
|
+
error: null,
|
|
1885
|
+
player: null
|
|
1886
|
+
}
|
|
1887
|
+
});
|
|
1888
|
+
const sessionListeners = new Set;
|
|
1889
|
+
const setSession = (update) => {
|
|
1890
|
+
setSessionStore(update);
|
|
1891
|
+
for (const listener of [...sessionListeners]) {
|
|
1892
|
+
try {
|
|
1893
|
+
listener(session);
|
|
1894
|
+
} catch {}
|
|
1895
|
+
}
|
|
1896
|
+
};
|
|
1897
|
+
const loadingOwner = crypto.randomUUID();
|
|
1898
|
+
const media = dependencies.createSessionMedia();
|
|
1899
|
+
let disposed = false;
|
|
1900
|
+
let lifecycleGeneration = 0;
|
|
1901
|
+
let eventDisposer = null;
|
|
1902
|
+
let presentationDisposer = null;
|
|
1903
|
+
let lifecycleError = {
|
|
1904
|
+
message: null,
|
|
1905
|
+
source: undefined
|
|
1906
|
+
};
|
|
1907
|
+
let transportError = null;
|
|
1908
|
+
let receivedSnapshot = false;
|
|
1909
|
+
let activeSeek = null;
|
|
1910
|
+
let latestSeek = null;
|
|
1911
|
+
const pending = new Set;
|
|
1912
|
+
const isActive = () => !disposed;
|
|
1913
|
+
const publishError = () => {
|
|
1914
|
+
if (!isActive())
|
|
1915
|
+
return;
|
|
1916
|
+
const message = lifecycleError.source === "connection" && lifecycleError.message ? lifecycleError.message : transportError ?? lifecycleError.message;
|
|
1917
|
+
setSession((draft) => {
|
|
1918
|
+
draft.error = message;
|
|
1919
|
+
});
|
|
1920
|
+
};
|
|
1921
|
+
const setLifecycleError = (message, source) => {
|
|
1922
|
+
lifecycleError = {
|
|
1923
|
+
message,
|
|
1924
|
+
source
|
|
1925
|
+
};
|
|
1926
|
+
publishError();
|
|
1927
|
+
};
|
|
1928
|
+
const setTransportError = (message) => {
|
|
1929
|
+
transportError = message;
|
|
1930
|
+
publishError();
|
|
1931
|
+
};
|
|
1932
|
+
const setLoadingOwner = (active) => {
|
|
1933
|
+
setSession((draft) => {
|
|
1934
|
+
const owners = new Set(draft.loadingOwners ?? []);
|
|
1935
|
+
if (active)
|
|
1936
|
+
owners.add(loadingOwner);
|
|
1937
|
+
else
|
|
1938
|
+
owners.delete(loadingOwner);
|
|
1939
|
+
draft.loadingOwners = [...owners];
|
|
1940
|
+
draft.loading = owners.size > 0;
|
|
1941
|
+
});
|
|
1942
|
+
};
|
|
1943
|
+
const updateLoading = () => {
|
|
1944
|
+
if (isActive())
|
|
1945
|
+
setLoadingOwner(pending.size > 0);
|
|
1946
|
+
};
|
|
1947
|
+
const settle = (resolve) => {
|
|
1948
|
+
if (!pending.delete(resolve))
|
|
1949
|
+
return;
|
|
1950
|
+
resolve();
|
|
1951
|
+
updateLoading();
|
|
1952
|
+
};
|
|
1953
|
+
const runCommand = (command, onSettled) => {
|
|
1954
|
+
if (!isActive())
|
|
1955
|
+
return Promise.resolve();
|
|
1956
|
+
const generation = lifecycleGeneration;
|
|
1957
|
+
return new Promise((resolve) => {
|
|
1958
|
+
pending.add(resolve);
|
|
1959
|
+
updateLoading();
|
|
1960
|
+
let result;
|
|
1961
|
+
try {
|
|
1962
|
+
result = command();
|
|
1963
|
+
} catch (error) {
|
|
1964
|
+
result = Promise.reject(error);
|
|
1965
|
+
}
|
|
1966
|
+
Promise.resolve(result).then(() => {
|
|
1967
|
+
if (!isActive() || generation !== lifecycleGeneration)
|
|
1968
|
+
return;
|
|
1969
|
+
if (transportError !== null)
|
|
1970
|
+
setTransportError(null);
|
|
1971
|
+
}, (error) => {
|
|
1972
|
+
if (!isActive() || generation !== lifecycleGeneration)
|
|
1973
|
+
return;
|
|
1974
|
+
const message = sanitizeTerminalText(errMsg(error));
|
|
1975
|
+
setTransportError(message);
|
|
1976
|
+
context.ui.toast.show({
|
|
1977
|
+
title: "Music",
|
|
1978
|
+
message,
|
|
1979
|
+
variant: "error"
|
|
1980
|
+
});
|
|
1981
|
+
}).finally(() => {
|
|
1982
|
+
try {
|
|
1983
|
+
onSettled?.();
|
|
1984
|
+
} finally {
|
|
1985
|
+
settle(resolve);
|
|
1986
|
+
}
|
|
1987
|
+
}).catch((error) => {
|
|
1988
|
+
console.error("Failed to settle music command", error);
|
|
1989
|
+
});
|
|
1990
|
+
});
|
|
1991
|
+
};
|
|
1992
|
+
const runSeek = (intent) => {
|
|
1993
|
+
activeSeek = intent;
|
|
1994
|
+
runCommand(() => media.seek(intent.positionMs), () => {
|
|
1995
|
+
if (activeSeek !== intent)
|
|
1996
|
+
return;
|
|
1997
|
+
activeSeek = null;
|
|
1998
|
+
for (const resolve of intent.resolves)
|
|
1999
|
+
resolve();
|
|
2000
|
+
intent.resolves = [];
|
|
2001
|
+
const next = latestSeek;
|
|
2002
|
+
latestSeek = null;
|
|
2003
|
+
if (isActive() && next)
|
|
2004
|
+
runSeek(next);
|
|
2005
|
+
else
|
|
2006
|
+
next?.resolves.splice(0).forEach((resolve) => resolve());
|
|
2007
|
+
}).catch((error) => {
|
|
2008
|
+
console.error("Failed to start music seek", error);
|
|
2009
|
+
});
|
|
2010
|
+
};
|
|
2011
|
+
const refreshAll = async () => {
|
|
2012
|
+
if (!isActive())
|
|
2013
|
+
return;
|
|
2014
|
+
const generation = lifecycleGeneration;
|
|
2015
|
+
try {
|
|
2016
|
+
const player = await media.player();
|
|
2017
|
+
if (!isActive() || generation !== lifecycleGeneration || receivedSnapshot)
|
|
2018
|
+
return;
|
|
2019
|
+
setSession((draft) => {
|
|
2020
|
+
draft.player = player;
|
|
2021
|
+
});
|
|
2022
|
+
} catch (error) {
|
|
2023
|
+
if (isActive() && generation === lifecycleGeneration)
|
|
2024
|
+
setTransportError(errMsg(error));
|
|
2025
|
+
}
|
|
2026
|
+
};
|
|
2027
|
+
const playPause = () => runCommand(() => media.toggle());
|
|
2028
|
+
const seek = (positionMs) => {
|
|
2029
|
+
const target = seekTarget(positionMs, session.player?.track?.duration_ms);
|
|
2030
|
+
if (!session.player?.track || target === null || !isActive())
|
|
2031
|
+
return Promise.resolve();
|
|
2032
|
+
return new Promise((resolve) => {
|
|
2033
|
+
if (activeSeek) {
|
|
2034
|
+
if (latestSeek) {
|
|
2035
|
+
latestSeek.positionMs = target;
|
|
2036
|
+
latestSeek.resolves.push(resolve);
|
|
2037
|
+
} else {
|
|
2038
|
+
latestSeek = {
|
|
2039
|
+
positionMs: target,
|
|
2040
|
+
resolves: [resolve]
|
|
2041
|
+
};
|
|
2042
|
+
}
|
|
2043
|
+
return;
|
|
2044
|
+
}
|
|
2045
|
+
runSeek({
|
|
2046
|
+
positionMs: target,
|
|
2047
|
+
resolves: [resolve]
|
|
2048
|
+
});
|
|
2049
|
+
});
|
|
2050
|
+
};
|
|
2051
|
+
eventDisposer = media.subscribe((event) => {
|
|
2052
|
+
if (!isActive())
|
|
2053
|
+
return;
|
|
2054
|
+
if (event?.type === "snapshot") {
|
|
2055
|
+
receivedSnapshot = true;
|
|
2056
|
+
setSession((draft) => {
|
|
2057
|
+
draft.player = mergePlayerSnapshot(draft.player, event.state);
|
|
2058
|
+
});
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
if (event?.type === "lifecycle") {
|
|
2062
|
+
setLifecycleError(event.message, event.source);
|
|
2063
|
+
if (event.message !== null && latestSeek) {
|
|
2064
|
+
latestSeek.resolves.splice(0).forEach((resolve) => resolve());
|
|
2065
|
+
latestSeek = null;
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
});
|
|
2069
|
+
presentationDisposer = media.subscribePresentation((event) => {
|
|
2070
|
+
if (!isActive())
|
|
2071
|
+
return;
|
|
2072
|
+
setSession((draft) => {
|
|
2073
|
+
draft.player = mergeArtworkCompletion(draft.player, event);
|
|
2074
|
+
});
|
|
2075
|
+
});
|
|
2076
|
+
refreshAll().catch((error) => {
|
|
2077
|
+
console.error("Failed to publish initial music state", error);
|
|
2078
|
+
});
|
|
2079
|
+
return {
|
|
2080
|
+
session,
|
|
2081
|
+
subscribe(listener) {
|
|
2082
|
+
sessionListeners.add(listener);
|
|
2083
|
+
listener(session);
|
|
2084
|
+
return () => sessionListeners.delete(listener);
|
|
2085
|
+
},
|
|
2086
|
+
refreshAll,
|
|
2087
|
+
refreshArtwork: async () => {
|
|
2088
|
+
if (!isActive())
|
|
2089
|
+
return;
|
|
2090
|
+
try {
|
|
2091
|
+
await media.refreshArtwork();
|
|
2092
|
+
} catch (error) {
|
|
2093
|
+
if (isActive())
|
|
2094
|
+
setTransportError(errMsg(error));
|
|
2095
|
+
}
|
|
2096
|
+
},
|
|
2097
|
+
async openApp() {
|
|
2098
|
+
if (!isActive())
|
|
2099
|
+
return;
|
|
2100
|
+
try {
|
|
2101
|
+
openNowPlayingApp();
|
|
2102
|
+
context.ui.toast.show({
|
|
2103
|
+
title: "Music",
|
|
2104
|
+
message: "Play in any app \u2014 the sidebar uses system media",
|
|
2105
|
+
variant: "info"
|
|
2106
|
+
});
|
|
2107
|
+
} catch (error) {
|
|
2108
|
+
setTransportError(errMsg(error));
|
|
2109
|
+
}
|
|
2110
|
+
},
|
|
2111
|
+
playPause,
|
|
2112
|
+
seek,
|
|
2113
|
+
next: () => runCommand(() => media.next()),
|
|
2114
|
+
prev: () => runCommand(() => media.previous()),
|
|
2115
|
+
dispose() {
|
|
2116
|
+
if (disposed)
|
|
2117
|
+
return;
|
|
2118
|
+
disposed = true;
|
|
2119
|
+
lifecycleGeneration++;
|
|
2120
|
+
eventDisposer?.();
|
|
2121
|
+
eventDisposer = null;
|
|
2122
|
+
presentationDisposer?.();
|
|
2123
|
+
presentationDisposer = null;
|
|
2124
|
+
latestSeek?.resolves.splice(0).forEach((resolve) => resolve());
|
|
2125
|
+
latestSeek = null;
|
|
2126
|
+
activeSeek?.resolves.splice(0).forEach((resolve) => resolve());
|
|
2127
|
+
activeSeek = null;
|
|
2128
|
+
for (const resolve of [...pending])
|
|
2129
|
+
settle(resolve);
|
|
2130
|
+
media.dispose().catch(() => {});
|
|
2131
|
+
setLoadingOwner(false);
|
|
2132
|
+
sessionListeners.clear();
|
|
2133
|
+
}
|
|
2134
|
+
};
|
|
2135
|
+
}
|
|
2136
|
+
function AppHost(props) {
|
|
2137
|
+
const {
|
|
2138
|
+
context,
|
|
2139
|
+
ctrl
|
|
2140
|
+
} = props;
|
|
2141
|
+
context.keymap.layer(() => ({
|
|
2142
|
+
mode: "global",
|
|
2143
|
+
priority: 200,
|
|
2144
|
+
commands: [{
|
|
2145
|
+
id: "music.playpause",
|
|
2146
|
+
title: "Play / pause",
|
|
2147
|
+
group: "Music",
|
|
2148
|
+
bind: "ctrl+shift+p",
|
|
2149
|
+
palette: true,
|
|
2150
|
+
run: () => void ctrl.playPause()
|
|
2151
|
+
}, {
|
|
2152
|
+
id: "music.next",
|
|
2153
|
+
title: "Next track",
|
|
2154
|
+
group: "Music",
|
|
2155
|
+
bind: "ctrl+shift+right",
|
|
2156
|
+
palette: true,
|
|
2157
|
+
run: () => void ctrl.next()
|
|
2158
|
+
}, {
|
|
2159
|
+
id: "music.prev",
|
|
2160
|
+
title: "Previous track",
|
|
2161
|
+
group: "Music",
|
|
2162
|
+
bind: "ctrl+shift+left",
|
|
2163
|
+
palette: true,
|
|
2164
|
+
run: () => void ctrl.prev()
|
|
2165
|
+
}, {
|
|
2166
|
+
id: "music.open-app",
|
|
2167
|
+
title: "Open music site",
|
|
2168
|
+
group: "Music",
|
|
2169
|
+
palette: true,
|
|
2170
|
+
slash: {
|
|
2171
|
+
name: "music-app"
|
|
2172
|
+
},
|
|
2173
|
+
run: () => void ctrl.openApp()
|
|
2174
|
+
}, {
|
|
2175
|
+
id: "music.refresh-artwork",
|
|
2176
|
+
title: "Refresh artwork",
|
|
2177
|
+
group: "Music",
|
|
2178
|
+
palette: true,
|
|
2179
|
+
slash: {
|
|
2180
|
+
name: "music-artwork"
|
|
2181
|
+
},
|
|
2182
|
+
run: () => void ctrl.refreshArtwork()
|
|
2183
|
+
}]
|
|
2184
|
+
}));
|
|
2185
|
+
return _$createComponent3(CompactPlayer, {
|
|
2186
|
+
context,
|
|
2187
|
+
get state() {
|
|
2188
|
+
return ctrl.session;
|
|
2189
|
+
},
|
|
2190
|
+
onPlayPause: () => void ctrl.playPause(),
|
|
2191
|
+
onSeek: (positionMs) => void ctrl.seek(positionMs)
|
|
2192
|
+
});
|
|
2193
|
+
}
|
|
2194
|
+
function createMusicPlayerPlugin(options) {
|
|
2195
|
+
return Plugin.define({
|
|
2196
|
+
id: "music-player",
|
|
2197
|
+
setup(context) {
|
|
2198
|
+
if (!isMac()) {
|
|
2199
|
+
context.ui.toast.show({
|
|
2200
|
+
title: "Music",
|
|
2201
|
+
message: "System media control is macOS-only",
|
|
2202
|
+
variant: "warning"
|
|
2203
|
+
});
|
|
2204
|
+
}
|
|
2205
|
+
const ctrl = options?.createController?.(context) ?? createController(context, options?.createSessionMedia ? {
|
|
2206
|
+
...controllerDependencies,
|
|
2207
|
+
createSessionMedia: options.createSessionMedia
|
|
2208
|
+
} : controllerDependencies);
|
|
2209
|
+
const observeSession = () => {
|
|
2210
|
+
ctrl.session.loading;
|
|
2211
|
+
ctrl.session.error;
|
|
2212
|
+
ctrl.session.player;
|
|
2213
|
+
};
|
|
2214
|
+
const unsubApp = context.ui.slot({
|
|
2215
|
+
append: "session.composer.top",
|
|
2216
|
+
render: () => {
|
|
2217
|
+
observeSession();
|
|
2218
|
+
return _$createComponent3(AppHost, {
|
|
2219
|
+
context,
|
|
2220
|
+
ctrl
|
|
2221
|
+
});
|
|
2222
|
+
}
|
|
2223
|
+
});
|
|
2224
|
+
const unsubSidebar = context.ui.slot({
|
|
2225
|
+
append: "sidebar.content",
|
|
2226
|
+
render: () => {
|
|
2227
|
+
observeSession();
|
|
2228
|
+
return _$createComponent3(SidebarPlayer, {
|
|
2229
|
+
context,
|
|
2230
|
+
get state() {
|
|
2231
|
+
return ctrl.session;
|
|
2232
|
+
},
|
|
2233
|
+
get subscribe() {
|
|
2234
|
+
return ctrl.subscribe;
|
|
2235
|
+
},
|
|
2236
|
+
onPlayPause: () => void ctrl.playPause(),
|
|
2237
|
+
onNext: () => void ctrl.next(),
|
|
2238
|
+
onPrev: () => void ctrl.prev(),
|
|
2239
|
+
onSeek: (positionMs) => void ctrl.seek(positionMs)
|
|
2240
|
+
});
|
|
2241
|
+
}
|
|
2242
|
+
});
|
|
2243
|
+
return () => {
|
|
2244
|
+
unsubApp();
|
|
2245
|
+
unsubSidebar();
|
|
2246
|
+
ctrl.dispose();
|
|
2247
|
+
};
|
|
2248
|
+
}
|
|
2249
|
+
});
|
|
2250
|
+
}
|
|
2251
|
+
var opencode_music_player_default = createMusicPlayerPlugin();
|
|
2252
|
+
export {
|
|
2253
|
+
opencode_music_player_default as default,
|
|
2254
|
+
createMusicPlayerPlugin,
|
|
2255
|
+
createController,
|
|
2256
|
+
SEEK_END_MARGIN_MS
|
|
2257
|
+
};
|