@doitian/dsh-music 0.1.1 → 0.1.2

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 CHANGED
@@ -2,17 +2,19 @@
2
2
 
3
3
  NetEase Cloud Music ([music.163.com](https://music.163.com)) playback and an AI DJ, inside DeepSeek Harness.
4
4
 
5
- A **Music** entry appears in the DSH sidebar. It opens a full player — search, charts, your queue, synced lyrics, an `<audio>` element that streams through the host, QR sign-in, and a continuous AI DJ. The same player is also exposed to the agent through seven tools, so it can search, queue, and curate from a conversation.
5
+ A **Music** entry appears in the DSH sidebar. It opens a player — your queue, synced lyrics, an `<audio>` element that streams through the host, QR sign-in, and a continuous AI DJ. The same player is also exposed to the agent through seven tools, so it can search, queue, and curate from a conversation.
6
6
 
7
7
  ```
8
- ┌ 音乐 music.163.com ───────────── [AI DJ] [mood input] [account] ┐
9
- │ 搜索 | 榜单 | 推荐 │ ♪ 海屿你 — 马也_Crabbit │
10
- │ │ ⏮ ▶ ⏭ 列表 ♡ ✕ 🔊 ──────── │
11
- │ 1. 海屿你 │ │
12
- │ 马也_Crabbit │ 歌词 Lyrics │
13
- │ 2. 明知故犯 │ [00:12.40] ... │
14
- │ Max李玄 │ 播放队列 Queue (12) │
15
- └───────────────────────────┴────────────────────────────────────────┘
8
+ ┌──────────────────────────────────────────────────────────────────┐
9
+ │ 音乐 music.163.com [AI DJ] [mood] [account] │
10
+ │ │
11
+ │ ♪ 海屿你 — 马也_Crabbit │
12
+ │ 上一首 · 播放 · 下一首 · 列表 · 喜欢 · 静音 · 音量 │
13
+ │ │
14
+ │ 歌词 Lyrics │
15
+ │ [00:12.40] ... │
16
+ │ 播放队列 Queue (12) │
17
+ └──────────────────────────────────────────────────────────────────┘
16
18
  ```
17
19
 
18
20
  ## Install
@@ -61,6 +63,15 @@ need no reinstall. Note that this is what makes a plugin *remount* re-read the
61
63
  served page while the module stays cached — see
62
64
  [Reloading a change](#reloading-a-change).
63
65
 
66
+ **Language.** The UI follows the Harness language (Settings → Language): the
67
+ sidebar label and error page register a zh/en dictionary with the host's
68
+ `locale` service, and the player page follows the shell's `<html lang>` live.
69
+ Switching language re-renders both in place — no restart.
70
+
71
+ While a profile is linked, the Desktop app's **Add plugin** flow is the wrong
72
+ tool for it: that flow installs the published tarball, which replaces the
73
+ `link:` dependency. Re-add the link if it happens.
74
+
64
75
  ### Either way
65
76
 
66
77
  The bundle's own `cordis.patch.yml` inserts the plugin entry, so no
@@ -115,7 +126,7 @@ rich request could not be honoured. It never silently pretends, and it never
115
126
  leaves you with silence because you asked for more than the track has.
116
127
 
117
128
  The choice is saved in `session.json` and beats `config.audioLevel` in the
118
- profile patch, the same precedence the curator model uses. An unknown value in
129
+ profile patch, the same precedence the DJ's model route uses. An unknown value in
119
130
  either place falls back to `exhigh` rather than failing a stream.
120
131
 
121
132
  Two implementation details worth knowing:
@@ -154,6 +165,28 @@ Then one of two tiers chooses:
154
165
  affinity, novelty against play history, and explicit like/dislike feedback,
155
166
  then interleaved so consecutive tracks do not share an artist.
156
167
 
168
+ **Every model call carries a session identity.** A leaf call cannot set headers
169
+ — `GenerateOptions` has no `headers` field — so `sessionId` is the only identity
170
+ a plugin can give a provider, and each adapter maps it onto whatever that
171
+ provider calls a per-conversation header: pi-ai emits `x-opencode-session` for
172
+ the `opencode-go` route. Without it, a provider that keys on the conversation
173
+ sees an anonymous client ignoring its conventions.
174
+
175
+ The identity is minted once per install and persisted in `session.json`, so
176
+ every plan and every restart stays inside one conversation; `dj.sessionId` pins
177
+ it explicitly instead. A request with no identity is refused rather than sent
178
+ bare, so the failure is a recorded `dj.modelError` rather than a silent one.
179
+
180
+ Two other properties are worth knowing:
181
+
182
+ - **The DJ is not an agent, and does not start one.** It is plugin code that
183
+ gathers candidates, makes one model call, and stocks the queue. Routing that
184
+ call through a DSH agent would buy conversation memory and an audit trail at
185
+ the cost of a session lifecycle (rotation, tool masking, disposal).
186
+ - **The model tier is stateless.** Each plan sends its own pool and gets its
187
+ picks back; there is no accumulated transcript, no context growth and no drift
188
+ from a previous mood — which is the property, not a limitation, for this job.
189
+
157
190
  The model tier is optional and silent on failure; the DJ never blocks playback.
158
191
  It only ever **stocks the queue** — it never starts or stops playback, so a
159
192
  top-up while paused stays paused, and one that refills an empty queue after the
@@ -162,7 +195,7 @@ last track ended resumes on its own because the desired state was already
162
195
 
163
196
  ### Choosing the model
164
197
 
165
- **In the player.** The right column carries a **AI DJ 模型 / curator model**
198
+ **In the player.** The right column carries a **AI DJ 模型 / DJ model**
166
199
  panel: a provider picker, a model picker, and three buttons.
167
200
 
168
201
  | Control | What it does |
@@ -201,21 +234,35 @@ control back to the patch.
201
234
  Both names must be ones the host actually serves. `provider` is a registered
202
235
  adapter route — read them from the `llm-pi-ai` entry's `config.providers` (or
203
236
  ask the agent for `ctx.llm.listProviders()`); `model` is any id that route
204
- accepts. The simplest safe choice is to **mirror the session's own model**:
205
- whatever `agent-default-model` uses is known to work.
237
+ accepts.
238
+
239
+ **Pin it, or follow the session.** With both fields unset the DJ inherits
240
+ `agent-default-model` — the same selection the composer's model picker writes,
241
+ so it is the model the agent loop itself runs on — which is why "use my session
242
+ model" needs no configuration. Pinning `dj.provider`/`dj.model` curates on
243
+ something else instead, and pinning is what makes the two choices independent:
244
+
245
+ ```yaml
246
+ - id: agent-default-model # the agent loop / session model
247
+ config: { provider: opencode-go, model: deepseek-v4.1-flash }
248
+ - id: music
249
+ config:
250
+ dj: # the DJ's own model, when you want a different one
251
+ provider: opencode-go
252
+ model: minimax-m3
253
+ ```
206
254
 
207
255
  Three things worth knowing:
208
256
 
209
257
  - **`config` is replaced, not merged.** The loader assigns the whole object
210
258
  (`entry.ts`: `this.options.config = value`), so if you also want, say,
211
259
  `audioLevel`, keep every option in one block. Schema defaults fill the rest.
212
- - **Discovery runs when you pin nothing, and its choice is arbitrary.** With no
213
- config the DJ asks the mounted LLM service for its routes
214
- (`listProviders()`), takes the first, then takes that route's first catalogue
215
- entry. On this machine `opencode-go`'s catalogue is
260
+ - **Discovery is the last resort, and its choice is arbitrary.** Only when
261
+ neither a pin nor `agent-default-model` exists does the DJ ask the mounted LLM
262
+ service for its routes (`listProviders()`), take the first, then take that
263
+ route's first catalogue entry. On this machine `opencode-go`'s catalogue is
216
264
  `minimax-m3, deepseek-v4-flash, gpt-5.6-luna`, so discovery curates with
217
- **MiniMax-M3** — not a recommendation, just the first row. Pinning both fields
218
- is the only way to know which model is curating.
265
+ **MiniMax-M3** — not a recommendation, just the first row.
219
266
  - **Partial pins are honoured.** A `provider` alone keeps that route and picks
220
267
  its first catalogue model; a `model` alone keeps that model on the first
221
268
  route.
@@ -224,7 +271,7 @@ Confirm it took effect:
224
271
 
225
272
  | Where | What to look for |
226
273
  |---|---|
227
- | `/music/health` | `dj.model` is your pinned pair; `dj.modelSource` is `panel`, `config` or `discovered`; `dj.lastRoute` is the route that actually curated the last batch; `dj.modelError` is `null` once the tier works |
274
+ | `/music/health` | `dj.model` is your pinned pair; `dj.modelSource` is `panel`, `config`, `agent-default` or `discovered`; `dj.resolvedFrom` is what the last plan actually used (`pin`, `agent-default`, `discovered`); `dj.sessionModel` is the deployment's own model; `dj.lastRoute` is the route that curated the last batch; `dj.sessionId` is the identity every call carries; `dj.modelError` is `null` once the tier works |
228
275
  | Player, DJ status line | **AI DJ** plus the route (heuristic runs read **AI DJ (heuristic)**, and a declined tier prints the reason) |
229
276
  | **测试 Test** in the player | Answers "does this route work?" in one click, without touching the queue |
230
277
  | `music_dj` tool result | `AI DJ is on (model via opencode-go/deepseek-v4.1-flash)` |
@@ -237,6 +284,29 @@ the provider's own failure (a `LlmError` code such as `NO_ADAPTER`,
237
284
  `MISSING_CREDENTIAL`, `AUTH`, `RATE_LIMIT`). The heuristic tier covers that batch
238
285
  either way.
239
286
 
287
+ **If a provider says your usage does not follow its conventions**, the first
288
+ thing to check is the route's identity, not the request: pi-ai adds
289
+ `x-opencode-session` only for its own catalog routes `opencode-go` and
290
+ `opencode`. A hand-declared route id that merely points at
291
+ `https://opencode.ai/zen/go/v1` gets no wrapper and therefore no header, with
292
+ nothing in the logs to say so — keep the catalog route name. For the same
293
+ reason, do not set `x-opencode-session` in a profile's `headers`: the adapter
294
+ lets a configured value win over the generated one, which would freeze a single
295
+ id across every conversation. What remains after that is entitlement rather
296
+ than convention — a plan that only permits its own client's traffic cannot be
297
+ satisfied by a header, only by delegating transport to that client.
298
+
299
+ To see the bytes rather than trust the reasoning, run the bundled probe, point a
300
+ route's `baseURL` at it for one plan, and read what actually leaves the process:
301
+
302
+ ```powershell
303
+ npm run probe:headers # http://127.0.0.1:8787 -> https://opencode.ai
304
+ ```
305
+
306
+ You should see `x-opencode-session` carrying the DJ's id, and
307
+ `user-agent: deepseek-harness/<version> (+…)` — attribution that must never be
308
+ replaced by a provider-shaped one. Take the `baseURL` back out afterwards.
309
+
240
310
  Editing `config` recomposes the running host, but **the plugin module itself is
241
311
  cached** — restart the harness for the new route to take effect. If the model
242
312
  tier fails for any reason (no adapter, bad credentials, unparseable reply,
@@ -263,6 +333,7 @@ add `config` to the inserted entry:
263
333
  dj:
264
334
  provider: opencode-go # enables the model tier
265
335
  model: deepseek-v4.1-flash
336
+ sessionId: music-dj-mine # optional: pin the provider-visible identity
266
337
  ```
267
338
 
268
339
  | Field | Default | Meaning |
@@ -271,7 +342,8 @@ add `config` to the inserted entry:
271
342
  | `dataDir` | `$DSH_HOME/music` | Where `session.json` (cookie, history, feedback, settings) lives. |
272
343
  | `audioLevel` | `exhigh` | Initial streaming quality, until the player's picker records a choice. One of the levels above; an unknown value falls back to `exhigh`. |
273
344
  | `requestTimeoutMs` | `15000` | Per-request deadline for NetEase calls. |
274
- | `dj.provider` / `dj.model` | unset | Model route for the DJ's model tier — see [Choosing the model](#choosing-the-model). Unset, the tier auto-discovers and falls back to heuristics on any failure. |
345
+ | `dj.provider` / `dj.model` | unset | Model route for the DJ's model tier — see [Choosing the model](#choosing-the-model). Unset, the tier follows `agent-default-model` and only then falls back to discovery; any failure falls back to heuristics. |
346
+ | `dj.sessionId` | minted, persisted | The identity every model call carries. Adapters map it onto the provider's per-conversation header. Pin it to control what a provider sees, or leave it unset and let the DJ mint one per install. |
275
347
 
276
348
  Player preferences (quality, DJ on/off, mood brief, batch size, extend
277
349
  threshold) are persisted in `session.json` and edited from the panel.
@@ -397,7 +469,7 @@ down first. A restart brings both halves back into agreement.
397
469
 
398
470
  ```powershell
399
471
  npm run check # node --check on every module
400
- npm test # 65 deterministic tests: pure, DJ, browser half
472
+ npm test # 76 deterministic tests: pure, DJ, browser half
401
473
  npm run test:live # 23 integration tests against the live NetEase API
402
474
  npm run test:all # both
403
475
  ```
@@ -414,16 +486,17 @@ network cases inside it.
414
486
  Or run one file directly:
415
487
 
416
488
  ```powershell
417
- node test/netease.test.mjs # 16 pure: normalisation, quality ladder, cookies, player state
418
- node test/dj.test.mjs # 31 AI DJ: model tier, failure reporting, picker listing, queue invariants
419
- node test/client.test.mjs # 18 browser half, executed against a fake DOM
489
+ node test/netease.test.mjs # 18 pure: normalisation, quality ladder, cookies, player state
490
+ node test/dj.test.mjs # 37 AI DJ: model call identity, route resolution, failure reporting, queue invariants
491
+ node test/client.test.mjs # 21 browser half: the engine against a fake DOM, and the page it pairs with
420
492
  node test/host.test.mjs # 23 integration: routes, streaming, curation, quality
421
493
  ```
422
494
 
423
- The DJ tests drive the model tier with a stub `ctx.llm.stream()` that emits the
424
- documented chunks, so the pinned-route happy path, both chunk spellings
425
- (`type`/`kind`), index filtering, prose-wrapped JSON, discovery, and every
426
- fallback are covered without a provider.
495
+ The DJ tests drive `ctx.llm.stream()` with a stub that emits the documented
496
+ chunks, and stub `ctx.agentDefaultModel` for route inheritance. So the
497
+ pinned-route happy path, both chunk spellings (`type`/`kind`), index filtering,
498
+ prose-wrapped JSON, the session identity every call must carry, inheritance from
499
+ the session model, discovery, and every fallback are covered without a provider.
427
500
 
428
501
  The integration tests mount the plugin against stand-in `tools`/`webServer`
429
502
  services and drive the captured route over a real `node:http` server, so they
@@ -439,6 +512,16 @@ component ever rendered**, so playback cannot depend on the Music page being
439
512
  mounted. They also cover the seek handshake, failure reporting, disposal, and
440
513
  the contract handshake.
441
514
 
515
+ The same fake DOM boots `lib/panel.html` itself, with an engine that reports
516
+ `playback: true` — the shell document owns the audio element. That is the case
517
+ where the page must fetch everything it renders on its own: lyrics used to be
518
+ requested only from the local fallback transport's `applySource`, so with the
519
+ engine playing the pane stayed empty for every track. Three tests hold the
520
+ line: the rendered track is asked for, one fetch per track rather than one per
521
+ poll, and a track change replaces the pane. Two more cover the pane's shape: it
522
+ is capped to a few lines, collapses to its header on demand, and remembers that
523
+ choice across loads — while still fetching the lines, so expanding is instant.
524
+
442
525
  `npm test` runs the three deterministic files in sequence (`npm run test:all`
443
526
  adds the live one), deliberately **not** `node --test <dir>`: the directory form forks one child process per file, which
444
527
  is blocked in sandboxed environments.
@@ -462,6 +545,35 @@ is blocked in sandboxed environments.
462
545
  [qrcode-generator](https://github.com/kazuhikoarase/qrcode-generator) by
463
546
  Kazuhiko Arase, vendored so QR sign-in needs no network call or build step.
464
547
 
548
+ ### Releasing
549
+
550
+ Releases are cut from GitHub and published by the `publish` workflow with **npm
551
+ trusted publishing (OIDC)**. There is no `NPM_TOKEN` secret and no
552
+ `NODE_AUTH_TOKEN` in the repository, and adding one would disable the OIDC
553
+ exchange and break publishing.
554
+
555
+ 1. Bump `version` in `package.json`, commit, and push.
556
+ 2. Create a GitHub release whose tag is `v<version>` — `v0.1.2` for `0.1.2` —
557
+ pointing at that commit.
558
+
559
+ The workflow runs `npm run check`, the deterministic suites, asserts the tag
560
+ matches `package.json`, and publishes. A tag push alone publishes nothing: the
561
+ trigger is `release: published`.
562
+
563
+ Two timings look like failures and are not:
564
+
565
+ - **A green job is not yet a live version.** The registry records a
566
+ `0.0.0-stage` placeholder first and reports the package as *being processed*;
567
+ the version becomes installable roughly a minute later. Inside that window the
568
+ packument still answers `404`, and publishing the same version from a second
569
+ place is refused with `409 Cannot publish over previously staged version`.
570
+ - **Registry reads can lag the publish**, so an `npm view` run immediately
571
+ afterwards may still say `404`. Wait a minute, or read it from CI instead.
572
+
573
+ Versions published this way carry a SLSA provenance attestation naming the
574
+ workflow, tag, and commit — `npm view @doitian/dsh-music@<version> dist.attestations`.
575
+ `0.1.0` predates the trusted publisher: it was published by hand and has none.
576
+
465
577
  ## Known limitations
466
578
 
467
579
  - **Anonymous sessions cannot play VIP tracks**; NetEase returns `url: null`.
@@ -474,10 +586,16 @@ Kazuhiko Arase, vendored so QR sign-in needs no network call or build step.
474
586
  fed to the DJ instead.
475
587
  - **`apiPrefix` must stay `music`** unless `BASE` in `lib/client.js` is changed
476
588
  to match.
477
- - **The DJ's model tier is covered against the stub stream contract, not a live
478
- provider** — no adapter was configured where this was built, so request
479
- building, the pinned route, both chunk spellings, index filtering and every
480
- fallback are tested, but a real end-to-end model call has not been observed
481
- here. The heuristic tier is what the live runs exercised.
589
+ - **The DJ's model tier is covered against stub contracts, not a live
590
+ provider** — request building, the session identity, route resolution, both
591
+ chunk spellings, index filtering and every fallback are tested, but a real
592
+ end-to-end model call has not been observed here. The heuristic tier is what
593
+ the live runs exercised.
594
+ - **The identity only becomes a header on the catalog routes that define one.**
595
+ pi-ai adds `x-opencode-session` for its own `opencode-go`/`opencode` routes. A
596
+ hand-declared route id pointing at the same endpoint gets no wrapper, so the
597
+ header is simply absent — keep the catalog route name, and never pin
598
+ `x-opencode-session` in a profile's `headers` (a configured value wins over
599
+ the generated one and freezes a single id for every conversation).
482
600
  - **Chromium autoplay policy** may block playback the agent starts before the
483
601
  user has interacted with the page; the panel then shows a *click to play* hint.
package/cordis.patch.yml CHANGED
@@ -13,6 +13,20 @@
13
13
  # requestTimeoutMs NetEase request deadline (default 15000).
14
14
  # dj.provider model route for the AI DJ's model tier, e.g. opencode-go.
15
15
  # dj.model exact model id, e.g. deepseek-v4.1-flash.
16
+ # dj.sessionId the identity every model call carries. A leaf call cannot
17
+ # set headers, so this string is the only identity the DJ
18
+ # can give a provider — and each adapter maps it onto that
19
+ # provider's own per-conversation header (pi-ai emits
20
+ # `x-opencode-session` for the `opencode-go` route). Left
21
+ # unset, one is minted on first use and persisted in
22
+ # session.json, so every plan and restart stays inside one
23
+ # conversation.
24
+ #
25
+ # Route resolution, highest first: the player's picker -> `dj.provider`/`dj.model`
26
+ # -> the deployment's own `agent-default-model` (the model the agent loop runs
27
+ # on, which is why "use my session model" needs no configuration) -> discovery,
28
+ # whose first row is arbitrary. The DJ is not an agent and starts none: each
29
+ # plan is one stateless leaf call, so there is no session to rotate or dispose.
16
30
  #
17
31
  # Without `dj.provider`/`dj.model` the DJ still works: it falls back to scoring
18
32
  # similar songs, the daily recommendations and the charts by the listener's
package/lib/client.js CHANGED
@@ -43,8 +43,34 @@ window.__ModuleLoader__.load({
43
43
  const API = `${BASE}/api`;
44
44
  /** Shared by the sidebar entry and the main panel it opens. */
45
45
  const PANEL_ID = 'music';
46
- /** Visible label and collapsed-rail tooltip. */
47
- const LABEL = '音乐 Music';
46
+
47
+ /**
48
+ * The locale namespace and its dictionary, registered with the host's
49
+ * `locale` service so the UI follows the Settings language. The service
50
+ * requires both shipped locales up front.
51
+ */
52
+ const NS = 'music';
53
+ const DICTIONARY = {
54
+ zh: {
55
+ panel: '音乐',
56
+ iframeTitle: '网易云音乐播放器',
57
+ unreachableTitle: '无法连接到音乐插件',
58
+ unreachableBody: '音乐插件路由没有响应。请确认插件已在当前 profile 中启用,然后',
59
+ unreachableLink: '直接打开播放器',
60
+ unreachableSuffix: '。',
61
+ },
62
+ en: {
63
+ panel: 'Music',
64
+ iframeTitle: 'NetEase Cloud Music player',
65
+ unreachableTitle: 'Music is not reachable',
66
+ unreachableBody:
67
+ 'The music plugin route did not answer. Check that the plugin is enabled in this profile, then ',
68
+ unreachableLink: 'open the player directly',
69
+ unreachableSuffix: '.',
70
+ },
71
+ };
72
+ /** Bound in apply() once the dictionary is registered; English until then. */
73
+ let t = (key) => DICTIONARY.en[key] ?? key;
48
74
 
49
75
  /** How often the engine re-reads the host state; bounds control latency. */
50
76
  const POLL_MS = 500;
@@ -53,7 +79,7 @@ window.__ModuleLoader__.load({
53
79
  const IDLE_REPORT_MS = 5000;
54
80
 
55
81
  /** Services required before the slot registrations can be made. */
56
- const inject = ['slots'];
82
+ const inject = ['slots', 'locale'];
57
83
 
58
84
  /** Absolute URL helper; the shell page and the panel share one origin. */
59
85
  function origin() {
@@ -357,13 +383,13 @@ window.__ModuleLoader__.load({
357
383
  opacity: 0.75,
358
384
  },
359
385
  },
360
- h('div', { style: { fontWeight: 650, marginBottom: 6 } }, '音乐 Music is not reachable'),
386
+ h('div', { style: { fontWeight: 650, marginBottom: 6 } }, t('unreachableTitle')),
361
387
  h(
362
388
  'div',
363
389
  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
- '.',
390
+ t('unreachableBody'),
391
+ h('a', { href: panelUrl(), target: '_blank', rel: 'noreferrer' }, t('unreachableLink')),
392
+ t('unreachableSuffix'),
367
393
  ),
368
394
  );
369
395
  }
@@ -390,7 +416,7 @@ window.__ModuleLoader__.load({
390
416
 
391
417
  return h('iframe', {
392
418
  src: panelUrl(),
393
- title: 'NetEase Cloud Music player',
419
+ title: t('iframeTitle'),
394
420
  allow: 'autoplay; clipboard-write; encrypted-media',
395
421
  style: {
396
422
  width: '100%',
@@ -425,13 +451,19 @@ window.__ModuleLoader__.load({
425
451
  );
426
452
  }
427
453
 
428
- /** Register the sidebar entry and the page it opens. */
454
+ /**
455
+ * Register the sidebar entry and the page it opens.
456
+ *
457
+ * Both registrations carry `locale: NS` and resolve their text through
458
+ * `t` at render time, so the slot system re-renders them in place when
459
+ * the user switches the Harness language — no remount, no refresh.
460
+ */
429
461
  function registerUi(ctx) {
430
462
  ctx.slots.inject('main', () =>
431
- ctx.slots.register({ name: 'main', key: PANEL_ID }, MusicPanel),
463
+ ctx.slots.register({ name: 'main', key: PANEL_ID, locale: NS }, MusicPanel),
432
464
  );
433
465
  ctx.slots.inject('sidebar.panellist', () =>
434
- ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 20, label: LABEL }, MusicIcon),
466
+ ctx.slots.register({ name: 'sidebar.panellist', id: PANEL_ID, order: 20, locale: NS, label: () => t('panel') }, MusicIcon),
435
467
  );
436
468
  }
437
469
 
@@ -447,6 +479,10 @@ window.__ModuleLoader__.load({
447
479
  * @returns disposer stopping playback and withdrawing both registrations.
448
480
  */
449
481
  async function apply(ctx) {
482
+ // The dictionary goes in before anything renders, and bound `t` reads
483
+ // the live snapshot, so a later language switch needs no rewiring.
484
+ const disposeLocale = ctx.locale.register(NS, DICTIONARY);
485
+ t = ctx.locale.bind(NS);
450
486
  const ui = ctx.inject(['slots'], () => registerUi(ctx));
451
487
  const engine = await createEngine();
452
488
  // The panel is same-origin, so it can nudge the engine after a command.
@@ -454,6 +490,7 @@ window.__ModuleLoader__.load({
454
490
  return () => {
455
491
  ui?.dispose?.();
456
492
  engine.dispose();
493
+ disposeLocale?.();
457
494
  if (window.__dshMusicEngine === engine) delete window.__dshMusicEngine;
458
495
  };
459
496
  }