@doitian/dsh-music 0.0.0-stage → 0.1.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/LICENSE +28 -0
- package/README.md +482 -2
- package/cordis.patch.yml +22 -0
- package/icon.svg +8 -0
- package/lib/client.js +466 -0
- package/lib/dj.js +543 -0
- package/lib/index.js +618 -0
- package/lib/netease.js +609 -0
- package/lib/panel.html +1105 -0
- package/lib/router.js +569 -0
- package/lib/session.js +346 -0
- package/lib/state.js +389 -0
- package/lib/vendor/qrcode.js +2297 -0
- package/package.json +67 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser half of `@doitian/dsh-music`.
|
|
3
|
+
*
|
|
4
|
+
* Hand-written against the client module contract (`window.__ModuleLoader__`),
|
|
5
|
+
* so the package needs no bundler step:
|
|
6
|
+
*
|
|
7
|
+
* - the shell's frozen `PLATFORM_MODULES` table supplies `react`;
|
|
8
|
+
* - `apply(ctx)` starts a **persistent audio engine** and registers the
|
|
9
|
+
* sidebar entry (`sidebar.panellist`, a list slot) plus the page it opens
|
|
10
|
+
* (the layout's keyed `main` slot, same id);
|
|
11
|
+
* - `ctx.slots.inject(slot, …)` is declaration-aware, so registration waits
|
|
12
|
+
* for the sidebar and layout packages that own those slots.
|
|
13
|
+
*
|
|
14
|
+
* ## Why the audio element is not in the page
|
|
15
|
+
*
|
|
16
|
+
* The layout renders only the **active** `main` slot entry:
|
|
17
|
+
*
|
|
18
|
+
* renderSlot('main', {}, { entryKey: activePanelId ?? 'conversation' })
|
|
19
|
+
*
|
|
20
|
+
* so opening a Session unmounts the Music page and discards its document. An
|
|
21
|
+
* `<audio>` element owned by that page therefore stops the music the moment the
|
|
22
|
+
* user goes back to chatting. Playback instead belongs to this engine, created
|
|
23
|
+
* on the plugin's own lifetime in the shell document, which keeps running no
|
|
24
|
+
* matter which panel is on screen. The page becomes a remote control over the
|
|
25
|
+
* host's shared state, and `window.__dshMusicEngine.sync()` lets it apply a
|
|
26
|
+
* command without waiting for the next poll.
|
|
27
|
+
*
|
|
28
|
+
* @module @doitian/dsh-music/client
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
window.__ModuleLoader__.load({
|
|
32
|
+
id: '@doitian/dsh-music',
|
|
33
|
+
factory: (require) => {
|
|
34
|
+
var module = { exports: {} };
|
|
35
|
+
var exports = module.exports;
|
|
36
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
|
|
37
|
+
|
|
38
|
+
const React = require('react');
|
|
39
|
+
const h = React.createElement;
|
|
40
|
+
|
|
41
|
+
/** Must match the host plugin's `apiPrefix` (see lib/index.js). */
|
|
42
|
+
const BASE = '/music';
|
|
43
|
+
const API = `${BASE}/api`;
|
|
44
|
+
/** Shared by the sidebar entry and the main panel it opens. */
|
|
45
|
+
const PANEL_ID = 'music';
|
|
46
|
+
/** Visible label and collapsed-rail tooltip. */
|
|
47
|
+
const LABEL = '音乐 Music';
|
|
48
|
+
|
|
49
|
+
/** How often the engine re-reads the host state; bounds control latency. */
|
|
50
|
+
const POLL_MS = 500;
|
|
51
|
+
/** Position report cadence while playing, and while idle. */
|
|
52
|
+
const REPORT_MS = 1000;
|
|
53
|
+
const IDLE_REPORT_MS = 5000;
|
|
54
|
+
|
|
55
|
+
/** Services required before the slot registrations can be made. */
|
|
56
|
+
const inject = ['slots'];
|
|
57
|
+
|
|
58
|
+
/** Absolute URL helper; the shell page and the panel share one origin. */
|
|
59
|
+
function origin() {
|
|
60
|
+
return window.location?.origin && window.location.origin !== 'null'
|
|
61
|
+
? window.location.origin
|
|
62
|
+
: '';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Player page URL, shown in errors and used as the iframe source. */
|
|
66
|
+
function panelUrl() {
|
|
67
|
+
return `${origin()}${BASE}/panel`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ------------------------------------------------------------------ engine
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The page/engine boundary this bundle expects, mirrored from the host's
|
|
74
|
+
* `/health`. See {@link createEngine} for why it is checked at all.
|
|
75
|
+
*/
|
|
76
|
+
const PANEL_CONTRACT = 2;
|
|
77
|
+
|
|
78
|
+
/** An engine that does nothing, for a host that predates the contract. */
|
|
79
|
+
function inertEngine() {
|
|
80
|
+
// `playback: false` is the page's cue to own the audio element itself.
|
|
81
|
+
return { playback: false, inert: true, sync() {}, playNow() {}, state: () => null, dispose() {} };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Does the page this host serves expect an engine to own the audio?
|
|
86
|
+
*
|
|
87
|
+
* Asked in two steps, because two independently versioned artifacts are
|
|
88
|
+
* involved. The host module and the page are *not* refreshed together: a
|
|
89
|
+
* plugin mount re-reads `panel.html` from disk while Node keeps serving the
|
|
90
|
+
* cached module. The page therefore wins when the two disagree — it is the
|
|
91
|
+
* component that actually decides whether to defer, so it is the authority
|
|
92
|
+
* on the question being asked.
|
|
93
|
+
*
|
|
94
|
+
* @returns {Promise<boolean>}
|
|
95
|
+
*/
|
|
96
|
+
async function pageAcceptsEngine() {
|
|
97
|
+
try {
|
|
98
|
+
const health = await (await fetch(`${BASE}/health`)).json();
|
|
99
|
+
// A host that advertises the boundary answers definitively, either way.
|
|
100
|
+
if (health?.panelContract !== undefined) return health.panelContract === PANEL_CONTRACT;
|
|
101
|
+
} catch { /* fall through to the page itself */ }
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const html = await (await fetch(`${BASE}/panel`)).text();
|
|
105
|
+
const declared = /name="dsh-music-panel-contract"\s+content="(\d+)"/.exec(html);
|
|
106
|
+
return declared !== null && Number(declared[1]) === PANEL_CONTRACT;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Start the persistent audio engine, after a contract handshake.
|
|
114
|
+
*
|
|
115
|
+
* Playback lives here rather than in the page, so the two halves have to
|
|
116
|
+
* agree. When they do not, the page owns its own `<audio>` and starting
|
|
117
|
+
* this engine as well would play every track twice — so the engine stays
|
|
118
|
+
* inert and the old behaviour applies until the two are refreshed together.
|
|
119
|
+
*
|
|
120
|
+
* @returns {Promise<{sync: () => void, state: () => object | null, dispose: () => void}>}
|
|
121
|
+
*/
|
|
122
|
+
async function createEngine() {
|
|
123
|
+
if (!(await pageAcceptsEngine())) {
|
|
124
|
+
console.warn(
|
|
125
|
+
`[music] the served page does not implement panel contract ${PANEL_CONTRACT}; ` +
|
|
126
|
+
'leaving playback to the page until the harness restarts.',
|
|
127
|
+
);
|
|
128
|
+
return inertEngine();
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A hot re-materialization must not leave a second element playing.
|
|
132
|
+
try {
|
|
133
|
+
window.__dshMusicEngine?.dispose?.();
|
|
134
|
+
} catch { /* ignore a stale engine */ }
|
|
135
|
+
|
|
136
|
+
const audio = document.createElement('audio');
|
|
137
|
+
audio.preload = 'auto';
|
|
138
|
+
audio.setAttribute('aria-hidden', 'true');
|
|
139
|
+
audio.style.display = 'none';
|
|
140
|
+
(document.body ?? document.documentElement).appendChild(audio);
|
|
141
|
+
|
|
142
|
+
let state = null;
|
|
143
|
+
let appliedTransport = -1;
|
|
144
|
+
// Start the clock now so the first tick reconciles without also posting a
|
|
145
|
+
// redundant position report.
|
|
146
|
+
let lastReportAt = Date.now();
|
|
147
|
+
let disposed = false;
|
|
148
|
+
let inflight = false;
|
|
149
|
+
/** Guards against stacking overlapping `play()` attempts. */
|
|
150
|
+
let playing = false;
|
|
151
|
+
|
|
152
|
+
/** One JSON round-trip to the host. */
|
|
153
|
+
async function call(path, init) {
|
|
154
|
+
const response = await fetch(`${API}${path}`, {
|
|
155
|
+
headers: { 'Content-Type': 'application/json' },
|
|
156
|
+
...init,
|
|
157
|
+
...(init?.body ? { body: JSON.stringify(init.body) } : {}),
|
|
158
|
+
});
|
|
159
|
+
const text = await response.text();
|
|
160
|
+
return text ? JSON.parse(text) : {};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Push the audio element back in line with the host's desired state. */
|
|
164
|
+
function reconcile(next) {
|
|
165
|
+
if (next.transportRev === appliedTransport) return;
|
|
166
|
+
appliedTransport = next.transportRev;
|
|
167
|
+
|
|
168
|
+
if (next.pendingSeek !== null && next.pendingSeek !== undefined) {
|
|
169
|
+
try {
|
|
170
|
+
audio.currentTime = next.pendingSeek / 1000;
|
|
171
|
+
} catch { /* not seekable until metadata arrives */ }
|
|
172
|
+
// One-shot: drop it so the next poll cannot re-apply the seek.
|
|
173
|
+
void call('/seek-consumed', { method: 'POST', body: {} }).catch(() => {});
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Decide which resource the element should be playing.
|
|
179
|
+
*
|
|
180
|
+
* Runs on every tick rather than behind the `transportRev` gate, because
|
|
181
|
+
* the resource identity includes the streaming quality: quality is not a
|
|
182
|
+
* one-shot transport change, and gating it meant a switch did nothing
|
|
183
|
+
* until something else moved the revision.
|
|
184
|
+
*
|
|
185
|
+
* The level is part of the URL, not implicit host state: the same track at
|
|
186
|
+
* two levels is two different files, so a change must load a new one
|
|
187
|
+
* instead of range-requesting offsets into the other encoding. The track
|
|
188
|
+
* id stays in the key so a switch resumes in place.
|
|
189
|
+
*/
|
|
190
|
+
function applySource(next) {
|
|
191
|
+
const track = next.current;
|
|
192
|
+
if (!track) {
|
|
193
|
+
if (audio.dataset.trackKey !== undefined) {
|
|
194
|
+
delete audio.dataset.trackKey;
|
|
195
|
+
delete audio.dataset.trackId;
|
|
196
|
+
audio.pause();
|
|
197
|
+
audio.removeAttribute('src');
|
|
198
|
+
audio.load();
|
|
199
|
+
}
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const level = next.audio?.preferred ?? 'exhigh';
|
|
204
|
+
const key = `${track.id}:${level}`;
|
|
205
|
+
if (audio.dataset.trackKey === key) return;
|
|
206
|
+
|
|
207
|
+
const sameTrack = audio.dataset.trackId === String(track.id);
|
|
208
|
+
const resumeAt = sameTrack ? audio.currentTime : 0;
|
|
209
|
+
audio.dataset.trackKey = key;
|
|
210
|
+
audio.dataset.trackId = String(track.id);
|
|
211
|
+
if (resumeAt > 0.5) {
|
|
212
|
+
audio.addEventListener(
|
|
213
|
+
'loadedmetadata',
|
|
214
|
+
() => {
|
|
215
|
+
try {
|
|
216
|
+
audio.currentTime = resumeAt;
|
|
217
|
+
} catch { /* not seekable */ }
|
|
218
|
+
},
|
|
219
|
+
{ once: true },
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
audio.src = `${origin()}${BASE}/stream/${track.id}?level=${encodeURIComponent(level)}`;
|
|
223
|
+
audio.load();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Start playback, tolerating a refusal.
|
|
228
|
+
*
|
|
229
|
+
* Chromium blocks audio until the page has been activated by a gesture,
|
|
230
|
+
* so an attempt can legitimately fail and has to be repeatable. Both
|
|
231
|
+
* outcomes are reported at once, so the host — and the panel's hint —
|
|
232
|
+
* learns the real state instead of waiting for the next scheduled report.
|
|
233
|
+
*/
|
|
234
|
+
function attemptPlay() {
|
|
235
|
+
if (playing || disposed) return;
|
|
236
|
+
playing = true;
|
|
237
|
+
const done = () => {
|
|
238
|
+
playing = false;
|
|
239
|
+
void report({});
|
|
240
|
+
};
|
|
241
|
+
audio.play().then(done, done);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Keep the element in line with the desired transport, on every tick.
|
|
246
|
+
*
|
|
247
|
+
* Deliberately outside the `transportRev` gate: a refused `play()` does
|
|
248
|
+
* not change the revision, so gating this would leave the music stopped
|
|
249
|
+
* for good after the first refusal — the panel would show the autoplay
|
|
250
|
+
* hint and pressing it would fix nothing.
|
|
251
|
+
*/
|
|
252
|
+
function enforce(next) {
|
|
253
|
+
audio.volume = next.muted ? 0 : next.volume;
|
|
254
|
+
if (!next.current) return;
|
|
255
|
+
if (next.playing && audio.paused) {
|
|
256
|
+
attemptPlay();
|
|
257
|
+
} else if (!next.playing && !audio.paused) {
|
|
258
|
+
audio.pause();
|
|
259
|
+
void report({});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Tell the host where playback actually is. */
|
|
264
|
+
async function report(extra) {
|
|
265
|
+
if (!state?.current) return;
|
|
266
|
+
lastReportAt = Date.now();
|
|
267
|
+
try {
|
|
268
|
+
const next = await call('/report', {
|
|
269
|
+
method: 'POST',
|
|
270
|
+
body: {
|
|
271
|
+
trackId: state.current.id,
|
|
272
|
+
position: Math.round((audio.currentTime || 0) * 1000),
|
|
273
|
+
duration: Number.isFinite(audio.duration) ? Math.round(audio.duration * 1000) : undefined,
|
|
274
|
+
playing: !audio.paused,
|
|
275
|
+
...extra,
|
|
276
|
+
},
|
|
277
|
+
});
|
|
278
|
+
if (next?.rev !== undefined) {
|
|
279
|
+
state = next;
|
|
280
|
+
reconcile(next);
|
|
281
|
+
}
|
|
282
|
+
} catch { /* the host will be re-read on the next tick */ }
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
audio.addEventListener('ended', () => void report({ ended: true }));
|
|
286
|
+
audio.addEventListener('error', () => {
|
|
287
|
+
// Fires for an unavailable track and for a dropped connection alike;
|
|
288
|
+
// the host skips to the next track on either.
|
|
289
|
+
if (audio.dataset.trackId) void report({ error: 'audio element error (track unavailable or network)' });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
/** One poll: read state, reconcile, and report when due. */
|
|
293
|
+
async function tick() {
|
|
294
|
+
if (disposed || inflight) return;
|
|
295
|
+
inflight = true;
|
|
296
|
+
try {
|
|
297
|
+
const next = await call('/state');
|
|
298
|
+
state = next;
|
|
299
|
+
reconcile(next);
|
|
300
|
+
applySource(next);
|
|
301
|
+
enforce(next);
|
|
302
|
+
const due = next.playing && !audio.paused ? REPORT_MS : IDLE_REPORT_MS;
|
|
303
|
+
if (audio.dataset.trackId && Date.now() - lastReportAt >= due) void report({});
|
|
304
|
+
} catch { /* host not reachable yet; keep polling */ } finally {
|
|
305
|
+
inflight = false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const timer = setInterval(() => void tick(), POLL_MS);
|
|
310
|
+
void tick();
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
/**
|
|
314
|
+
* Declares that this engine really owns and drives the audio element.
|
|
315
|
+
* The page checks it before deferring, so a host that cannot run the
|
|
316
|
+
* engine never leaves playback unowned.
|
|
317
|
+
*/
|
|
318
|
+
playback: true,
|
|
319
|
+
/** Apply a pending command immediately instead of waiting for a poll. */
|
|
320
|
+
sync() {
|
|
321
|
+
if (!disposed) void tick();
|
|
322
|
+
},
|
|
323
|
+
/**
|
|
324
|
+
* Retry playback synchronously, for a click on the autoplay hint.
|
|
325
|
+
*
|
|
326
|
+
* Called from inside the gesture so the attempt lands while the page is
|
|
327
|
+
* being activated, rather than on the next 500 ms poll.
|
|
328
|
+
*/
|
|
329
|
+
playNow() {
|
|
330
|
+
attemptPlay();
|
|
331
|
+
},
|
|
332
|
+
state() {
|
|
333
|
+
return state;
|
|
334
|
+
},
|
|
335
|
+
dispose() {
|
|
336
|
+
disposed = true;
|
|
337
|
+
clearInterval(timer);
|
|
338
|
+
try {
|
|
339
|
+
audio.pause();
|
|
340
|
+
audio.removeAttribute('src');
|
|
341
|
+
audio.remove();
|
|
342
|
+
} catch { /* the document may already be gone */ }
|
|
343
|
+
},
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// --------------------------------------------------------------- components
|
|
348
|
+
|
|
349
|
+
/** Sidebar destination inside the iframe, shown when the host is down. */
|
|
350
|
+
function PanelError() {
|
|
351
|
+
return h(
|
|
352
|
+
'div',
|
|
353
|
+
{
|
|
354
|
+
style: {
|
|
355
|
+
padding: '28px',
|
|
356
|
+
font: '13px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", sans-serif',
|
|
357
|
+
opacity: 0.75,
|
|
358
|
+
},
|
|
359
|
+
},
|
|
360
|
+
h('div', { style: { fontWeight: 650, marginBottom: 6 } }, '音乐 Music is not reachable'),
|
|
361
|
+
h(
|
|
362
|
+
'div',
|
|
363
|
+
null,
|
|
364
|
+
'The music plugin route did not answer. Check that the plugin is enabled in this profile, then ',
|
|
365
|
+
h('a', { href: panelUrl(), target: '_blank', rel: 'noreferrer' }, 'open the player directly'),
|
|
366
|
+
'.',
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** The page component registered into the layout's keyed `main` slot. */
|
|
372
|
+
function MusicPanel() {
|
|
373
|
+
const [status, setStatus] = React.useState('loading');
|
|
374
|
+
|
|
375
|
+
React.useEffect(() => {
|
|
376
|
+
let cancelled = false;
|
|
377
|
+
fetch(`${BASE}/health`)
|
|
378
|
+
.then((response) => {
|
|
379
|
+
if (!cancelled) setStatus(response.ok ? 'ready' : 'error');
|
|
380
|
+
})
|
|
381
|
+
.catch(() => {
|
|
382
|
+
if (!cancelled) setStatus('error');
|
|
383
|
+
});
|
|
384
|
+
return () => {
|
|
385
|
+
cancelled = true;
|
|
386
|
+
};
|
|
387
|
+
}, []);
|
|
388
|
+
|
|
389
|
+
if (status === 'error') return h(PanelError);
|
|
390
|
+
|
|
391
|
+
return h('iframe', {
|
|
392
|
+
src: panelUrl(),
|
|
393
|
+
title: 'NetEase Cloud Music player',
|
|
394
|
+
allow: 'autoplay; clipboard-write; encrypted-media',
|
|
395
|
+
style: {
|
|
396
|
+
width: '100%',
|
|
397
|
+
height: '100%',
|
|
398
|
+
minHeight: '520px',
|
|
399
|
+
border: 0,
|
|
400
|
+
display: 'block',
|
|
401
|
+
background: 'transparent',
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** The sidebar row icon; the shell renders it in both states of the rail. */
|
|
407
|
+
function MusicIcon() {
|
|
408
|
+
return h(
|
|
409
|
+
'svg',
|
|
410
|
+
{
|
|
411
|
+
width: 16,
|
|
412
|
+
height: 16,
|
|
413
|
+
viewBox: '0 0 24 24',
|
|
414
|
+
fill: 'none',
|
|
415
|
+
stroke: 'currentColor',
|
|
416
|
+
strokeWidth: 1.7,
|
|
417
|
+
strokeLinecap: 'round',
|
|
418
|
+
strokeLinejoin: 'round',
|
|
419
|
+
'aria-hidden': true,
|
|
420
|
+
focusable: false,
|
|
421
|
+
},
|
|
422
|
+
h('path', { d: 'M9 18V5l10-2v13' }),
|
|
423
|
+
h('circle', { cx: 6, cy: 18, r: 3 }),
|
|
424
|
+
h('circle', { cx: 16, cy: 16, r: 3 }),
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Register the sidebar entry and the page it opens. */
|
|
429
|
+
function registerUi(ctx) {
|
|
430
|
+
ctx.slots.inject('main', () =>
|
|
431
|
+
ctx.slots.register({ name: 'main', key: PANEL_ID }, MusicPanel),
|
|
432
|
+
);
|
|
433
|
+
ctx.slots.inject('sidebar.panellist', () =>
|
|
434
|
+
ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 20, label: LABEL }, MusicIcon),
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Activate the music contribution.
|
|
440
|
+
*
|
|
441
|
+
* The UI registers first, then the engine starts behind its contract
|
|
442
|
+
* handshake. The engine is deliberately not tied to any slot: the layout
|
|
443
|
+
* unmounts the Music page whenever another panel is active, and playback
|
|
444
|
+
* must outlive that.
|
|
445
|
+
*
|
|
446
|
+
* @param ctx client runtime.
|
|
447
|
+
* @returns disposer stopping playback and withdrawing both registrations.
|
|
448
|
+
*/
|
|
449
|
+
async function apply(ctx) {
|
|
450
|
+
const ui = ctx.inject(['slots'], () => registerUi(ctx));
|
|
451
|
+
const engine = await createEngine();
|
|
452
|
+
// The panel is same-origin, so it can nudge the engine after a command.
|
|
453
|
+
window.__dshMusicEngine = engine;
|
|
454
|
+
return () => {
|
|
455
|
+
ui?.dispose?.();
|
|
456
|
+
engine.dispose();
|
|
457
|
+
if (window.__dshMusicEngine === engine) delete window.__dshMusicEngine;
|
|
458
|
+
};
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
exports.PANEL_ID = PANEL_ID;
|
|
462
|
+
exports.apply = apply;
|
|
463
|
+
exports.inject = inject;
|
|
464
|
+
return module.exports;
|
|
465
|
+
},
|
|
466
|
+
});
|