@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/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
+ });