dsh-plugin-ekko-skin-manager 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Ekko 皮肤管理器 · 旧版兼容入口
2
2
 
3
- **主题管理功能已整合到新版主题中。** 新用户请直接安装 [初音未来主题](https://github.com/cjjchina3088191870-sudo/dsh-plugin-miku-theme),主题内已包含管理界面、手机导航、外观设置和启动视频功能。
3
+ **主题管理功能已整合到新版主题中。** 新用户请直接安装 [初音未来与玫瑰女王主题套装](https://github.com/cjjchina3088191870-sudo/dsh-plugin-miku-theme),主题内已包含管理界面、手机导航、外观设置和启动视频功能。
4
4
 
5
5
  本仓库保留旧版兼容与迁移说明。新版主题可独立使用,安装其他兼容主题后,会自动纳入同一管理界面。
6
6
 
@@ -9,12 +9,12 @@
9
9
  适配 DSH 0.2.2-rc.1:
10
10
 
11
11
  ```sh
12
- dsh plugin --profile web add github:cjjchina3088191870-sudo/dsh-plugin-miku-theme#main
12
+ dsh plugin --profile web add dsh-plugin-miku-theme
13
13
  ```
14
14
 
15
15
  重启 DSH 并刷新网页,进入 **账号菜单 → 设置 → Ekko 皮肤** 即可使用。
16
16
 
17
- 电脑与手机可以分别上传启动视频,支持 MP4 / WebM、预览、声音控制与不同画面比例。完整介绍和主题预览请查看 [初音未来主题仓库](https://github.com/cjjchina3088191870-sudo/dsh-plugin-miku-theme#readme)。
17
+ 电脑与手机可以分别上传启动视频,支持 MP4 / WebM、预览、声音控制与不同画面比例。完整介绍和主题预览请查看 [初音未来与玫瑰女王主题套装仓库](https://github.com/cjjchina3088191870-sudo/dsh-plugin-miku-theme#readme)。
18
18
 
19
19
  ## 已安装独立管理器的用户
20
20
 
@@ -22,12 +22,12 @@ dsh plugin --profile web add github:cjjchina3088191870-sudo/dsh-plugin-miku-them
22
22
 
23
23
  ```sh
24
24
  dsh plugin --profile web remove dsh-plugin-ekko-skin-manager
25
- dsh plugin --profile web add github:cjjchina3088191870-sudo/dsh-plugin-miku-theme#main
25
+ dsh plugin --profile web add dsh-plugin-miku-theme
26
26
  ```
27
27
 
28
28
  完成后重启 DSH 并刷新网页。主题偏好和已保存的视频会保留;使用其他 profile 时,将 `web` 替换为对应名称。
29
29
 
30
- 独立管理器 0.2.x 与新版主题可以共用管理界面,但日常使用只安装主题即可。
30
+ 独立管理器 0.3.x 与新版主题套装可以共用管理界面,但日常使用只安装主题即可。
31
31
 
32
32
  ## 许可
33
33
 
package/lib/client.js CHANGED
@@ -379,7 +379,7 @@ function VideoSettings({ skin }) {
379
379
  };
380
380
  if (!data || !draft) return /* @__PURE__ */ import_react.default.createElement("section", { className: "ekko-skin-options ekko-video-settings", "data-testid": "ekko-video-settings" }, /* @__PURE__ */ import_react.default.createElement("h3", null, "\u542F\u52A8\u89C6\u9891"), /* @__PURE__ */ import_react.default.createElement("p", { role: "status" }, message || "\u6B63\u5728\u8BFB\u53D6\u89C6\u9891\u8BBE\u7F6E\u2026"), /* @__PURE__ */ import_react.default.createElement("button", { type: "button", className: "ekko-skin-button", onClick: load }, "\u91CD\u8BD5"));
381
381
  const update = (patch) => setDraft({ ...draft, ...patch }), fitKey = device === "mobile" ? "mobileFit" : "fit";
382
- return /* @__PURE__ */ import_react.default.createElement("section", { className: "ekko-skin-options ekko-video-settings", "data-testid": "ekko-video-settings", "data-device": device }, /* @__PURE__ */ import_react.default.createElement("div", { className: "ekko-skin-toolbar" }, /* @__PURE__ */ import_react.default.createElement("h3", null, skin === "miku" ? "\u521D\u97F3\u672A\u6765" : "\u6709\u83DC\u73AB\u7470", " \xB7 \u542F\u52A8\u89C6\u9891"), /* @__PURE__ */ import_react.default.createElement("span", { className: "ekko-skin-version" }, "\u6BCF\u6B3E\u4E3B\u9898\u5206\u522B\u4FDD\u5B58")), /* @__PURE__ */ import_react.default.createElement("div", { className: "ekko-video-controls" }, /* @__PURE__ */ import_react.default.createElement("label", { className: "ekko-skin-option" }, /* @__PURE__ */ import_react.default.createElement("span", null, "\u542F\u7528\u542F\u52A8\u89C6\u9891", /* @__PURE__ */ import_react.default.createElement("small", null, "\u9875\u9762\u542F\u52A8\u65F6\u64AD\u653E\uFF0C\u53EF\u968F\u65F6\u8DF3\u8FC7")), /* @__PURE__ */ import_react.default.createElement("input", { type: "checkbox", checked: draft.enabled, disabled: busy, onChange: (e) => update({ enabled: e.target.checked }) })), /* @__PURE__ */ import_react.default.createElement("label", { className: "ekko-skin-option" }, /* @__PURE__ */ import_react.default.createElement("span", null, "\u64AD\u653E\u58F0\u97F3", /* @__PURE__ */ import_react.default.createElement("small", null, "\u6D4F\u89C8\u5668\u9650\u5236\u81EA\u52A8\u64AD\u653E\u65F6\u5148\u9759\u97F3\uFF0C\u70B9\u51FB\u53EF\u5F00\u542F\u58F0\u97F3")), /* @__PURE__ */ import_react.default.createElement("input", { type: "checkbox", checked: draft.sound, disabled: busy, onChange: (e) => update({ sound: e.target.checked }) })), /* @__PURE__ */ import_react.default.createElement("label", { className: "ekko-skin-option" }, /* @__PURE__ */ import_react.default.createElement("span", null, "\u97F3\u91CF", /* @__PURE__ */ import_react.default.createElement("small", null, Math.round(draft.volume * 100), "%")), /* @__PURE__ */ import_react.default.createElement("input", { type: "range", min: "0", max: "100", value: Math.round(draft.volume * 100), disabled: busy, onChange: (e) => update({ volume: Number(e.target.value) / 100 }) })), /* @__PURE__ */ import_react.default.createElement("label", { className: "ekko-skin-option" }, /* @__PURE__ */ import_react.default.createElement("span", null, "\u6700\u957F\u64AD\u653E\u65F6\u95F4", /* @__PURE__ */ import_react.default.createElement("small", null, "5\u201360 \u79D2\uFF0C\u5230\u65F6\u81EA\u52A8\u8FDB\u5165\u754C\u9762")), /* @__PURE__ */ import_react.default.createElement("input", { className: "ekko-video-seconds", type: "number", min: "5", max: "60", value: draft.maxSeconds, disabled: busy, onChange: (e) => update({ maxSeconds: Number(e.target.value) }) }))), /* @__PURE__ */ import_react.default.createElement("div", { className: "ekko-video-device-tabs", role: "tablist", "aria-label": "\u542F\u52A8\u89C6\u9891\u5C4F\u5E55\u7C7B\u578B" }, ["desktop", "mobile"].map((value) => /* @__PURE__ */ import_react.default.createElement("button", { key: value, type: "button", role: "tab", id: "ekko-video-tab-" + skin + "-" + value, "aria-selected": device === value, "aria-controls": "ekko-video-panel-" + skin, disabled: busy, className: "ekko-skin-button", "data-active": device === value, onClick: () => {
382
+ return /* @__PURE__ */ import_react.default.createElement("section", { className: "ekko-skin-options ekko-video-settings", "data-testid": "ekko-video-settings", "data-device": device }, /* @__PURE__ */ import_react.default.createElement("div", { className: "ekko-skin-toolbar" }, /* @__PURE__ */ import_react.default.createElement("h3", null, skin === "miku" ? "\u521D\u97F3\u672A\u6765" : "\u73AB\u7470\u5973\u738B", " \xB7 \u542F\u52A8\u89C6\u9891"), /* @__PURE__ */ import_react.default.createElement("span", { className: "ekko-skin-version" }, "\u6BCF\u6B3E\u4E3B\u9898\u5206\u522B\u4FDD\u5B58")), /* @__PURE__ */ import_react.default.createElement("div", { className: "ekko-video-controls" }, /* @__PURE__ */ import_react.default.createElement("label", { className: "ekko-skin-option" }, /* @__PURE__ */ import_react.default.createElement("span", null, "\u542F\u7528\u542F\u52A8\u89C6\u9891", /* @__PURE__ */ import_react.default.createElement("small", null, "\u9875\u9762\u542F\u52A8\u65F6\u64AD\u653E\uFF0C\u53EF\u968F\u65F6\u8DF3\u8FC7")), /* @__PURE__ */ import_react.default.createElement("input", { type: "checkbox", checked: draft.enabled, disabled: busy, onChange: (e) => update({ enabled: e.target.checked }) })), /* @__PURE__ */ import_react.default.createElement("label", { className: "ekko-skin-option" }, /* @__PURE__ */ import_react.default.createElement("span", null, "\u64AD\u653E\u58F0\u97F3", /* @__PURE__ */ import_react.default.createElement("small", null, "\u6D4F\u89C8\u5668\u9650\u5236\u81EA\u52A8\u64AD\u653E\u65F6\u5148\u9759\u97F3\uFF0C\u70B9\u51FB\u53EF\u5F00\u542F\u58F0\u97F3")), /* @__PURE__ */ import_react.default.createElement("input", { type: "checkbox", checked: draft.sound, disabled: busy, onChange: (e) => update({ sound: e.target.checked }) })), /* @__PURE__ */ import_react.default.createElement("label", { className: "ekko-skin-option" }, /* @__PURE__ */ import_react.default.createElement("span", null, "\u97F3\u91CF", /* @__PURE__ */ import_react.default.createElement("small", null, Math.round(draft.volume * 100), "%")), /* @__PURE__ */ import_react.default.createElement("input", { type: "range", min: "0", max: "100", value: Math.round(draft.volume * 100), disabled: busy, onChange: (e) => update({ volume: Number(e.target.value) / 100 }) })), /* @__PURE__ */ import_react.default.createElement("label", { className: "ekko-skin-option" }, /* @__PURE__ */ import_react.default.createElement("span", null, "\u6700\u957F\u64AD\u653E\u65F6\u95F4", /* @__PURE__ */ import_react.default.createElement("small", null, "5\u201360 \u79D2\uFF0C\u5230\u65F6\u81EA\u52A8\u8FDB\u5165\u754C\u9762")), /* @__PURE__ */ import_react.default.createElement("input", { className: "ekko-video-seconds", type: "number", min: "5", max: "60", value: draft.maxSeconds, disabled: busy, onChange: (e) => update({ maxSeconds: Number(e.target.value) }) }))), /* @__PURE__ */ import_react.default.createElement("div", { className: "ekko-video-device-tabs", role: "tablist", "aria-label": "\u542F\u52A8\u89C6\u9891\u5C4F\u5E55\u7C7B\u578B" }, ["desktop", "mobile"].map((value) => /* @__PURE__ */ import_react.default.createElement("button", { key: value, type: "button", role: "tab", id: "ekko-video-tab-" + skin + "-" + value, "aria-selected": device === value, "aria-controls": "ekko-video-panel-" + skin, disabled: busy, className: "ekko-skin-button", "data-active": device === value, onClick: () => {
383
383
  setDevice(value);
384
384
  setMessage("");
385
385
  } }, value === "mobile" ? "\u624B\u673A\u7AEF\u89C6\u9891" : "\u7535\u8111\u7AEF\u89C6\u9891"))), /* @__PURE__ */ import_react.default.createElement("div", { className: "ekko-video-device-panel", role: "tabpanel", id: "ekko-video-panel-" + skin, "aria-labelledby": "ekko-video-tab-" + skin + "-" + device }, /* @__PURE__ */ import_react.default.createElement("p", { className: "ekko-skin-version" }, device === "mobile" ? "\u624B\u673A\u53CA\u7A84\u5C4F\u81EA\u52A8\u4F7F\u7528\u6B64\u89C6\u9891\uFF0C\u63A8\u8350 9:16 \u7AD6\u5C4F\uFF1B\u672A\u4E0A\u4F20\u65F6\u6CBF\u7528\u7535\u8111\u7AEF\u89C6\u9891\u3002" : "\u7535\u8111\u53CA\u5BBD\u5C4F\u4F7F\u7528\u6B64\u89C6\u9891\uFF0C\u63A8\u8350 16:9 \u6A2A\u5C4F\u3002", "\u652F\u6301\u5176\u4ED6\u5206\u8FA8\u7387\u4E0E\u6BD4\u4F8B\u3002"), /* @__PURE__ */ import_react.default.createElement("label", { className: "ekko-skin-option" }, /* @__PURE__ */ import_react.default.createElement("span", null, device === "mobile" ? "\u624B\u673A\u7AEF" : "\u7535\u8111\u7AEF", "\u753B\u9762\u663E\u793A"), /* @__PURE__ */ import_react.default.createElement("select", { "aria-label": (device === "mobile" ? "\u624B\u673A\u7AEF" : "\u7535\u8111\u7AEF") + "\u753B\u9762\u663E\u793A", value: draft[fitKey], disabled: busy, onChange: (e) => update({ [fitKey]: e.target.value }) }, /* @__PURE__ */ import_react.default.createElement("option", { value: "cover" }, "\u94FA\u6EE1\u5C4F\u5E55"), /* @__PURE__ */ import_react.default.createElement("option", { value: "contain" }, "\u5B8C\u6574\u663E\u793A"))), /* @__PURE__ */ import_react.default.createElement("video", { key: device + source, ref: preview, className: "ekko-video-preview", "data-device": device, muted: !draft.sound, controls: true, playsInline: true, preload: "metadata", onLoadedMetadata: () => setMediaError(""), onError: (e) => {
@@ -475,6 +475,9 @@ function createController({ storage, mount, prepare = async () => {
475
475
  },
476
476
  register(provider) {
477
477
  if (!["rose", "miku"].includes(provider.id) || typeof provider.plugin?.apply !== "function") throw new TypeError("Unknown Ekko skin provider");
478
+ const existing = providers.get(provider.id);
479
+ if (existing && (existing.priority || 0) > (provider.priority || 0)) return () => {
480
+ };
478
481
  providers.set(provider.id, provider);
479
482
  publish({});
480
483
  if (initialized && provider.id === wanted) void reconcile();
@@ -705,7 +708,7 @@ function createMobileNavigation({ layout, document: doc = document }) {
705
708
 
706
709
  // src/client.jsx
707
710
  var inject = ["slots", "theme", "layout"];
708
- var NAMES = { rose: { zh: "\u6709\u83DC\u73AB\u7470", en: "Arina Rose" }, miku: { zh: "\u521D\u97F3\u672A\u6765", en: "Hatsune Miku" } };
711
+ var NAMES = { rose: { zh: "\u73AB\u7470\u5973\u738B", en: "Rose Queen" }, miku: { zh: "\u521D\u97F3\u672A\u6765", en: "Hatsune Miku" } };
709
712
  var COPY = {
710
713
  zh: { nav: "Ekko \u76AE\u80A4", title: "\u73AB\u7470\u4E0E\u521D\u97F3", intro: "\u9009\u62E9\u4F60\u7684\u521B\u4F5C\u7A7A\u95F4\uFF1B\u6BCF\u4E2A\u4E3B\u9898\u7684\u88C5\u9970\u504F\u597D\u5206\u522B\u4FDD\u7559\u3002", use: "\u4F7F\u7528", active: "\u5F53\u524D\u4F7F\u7528", switching: "\u6B63\u5728\u5207\u6362\u2026", native: "\u539F\u751F\u754C\u9762", options: "\u4E3B\u9898\u88C5\u9970", artwork: "\u6B22\u8FCE\u9875\u88C5\u9970", companion: "\u5BF9\u8BDD\u4EBA\u7269\u80CC\u666F", polaroid: "\u53F3\u4E0B\u89D2\u7167\u7247", opacity: "\u5BF9\u8BDD\u80CC\u666F\u6D53\u5EA6", reset: "\u6062\u590D\u6B64\u4E3B\u9898\u9ED8\u8BA4", loading: "\u6B63\u5728\u52A0\u8F7D\u4E3B\u9898\u2026", unavailable: "\u53EF\u9009\u4E3B\u9898\uFF0C\u5C1A\u672A\u5B89\u88C5", retry: "\u91CD\u8BD5", loadFailed: "\u76AE\u80A4\u7BA1\u7406\u5668\u6682\u65F6\u65E0\u6CD5\u52A0\u8F7D\uFF0C\u8BF7\u91CD\u8BD5\u3002", materialFailed: "\u4E3B\u9898\u7D20\u6750\u672A\u80FD\u52A0\u8F7D\uFF0C\u8BF7\u91CD\u8BD5\u3002" },
711
714
  en: { nav: "Ekko skins", title: "Rose & Miku", intro: "Choose your creative space. Each skin keeps its own appearance preferences.", use: "Use", active: "In use", switching: "Switching\u2026", native: "Native appearance", options: "Artwork preferences", artwork: "Welcome artwork", companion: "Chat character background", polaroid: "Corner photo", opacity: "Chat background opacity", reset: "Reset this skin", loading: "Loading skins\u2026", unavailable: "Optional skin, not installed", retry: "Retry", loadFailed: "Unable to load the skin manager. Please retry.", materialFailed: "Unable to load the skin artwork. Please retry." }
package/lib/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/index.mjs
2
2
  import { readFile as readFile2 } from "node:fs/promises";
3
+ import { existsSync as existsSync2 } from "node:fs";
3
4
  import { createRequire } from "node:module";
4
5
  import { createHash as createHash2 } from "node:crypto";
5
6
  import { dirname, join as join2 } from "node:path";
@@ -330,12 +331,20 @@ var BOOT_SCRIPT = '(()=>{var D=`\n.ekko-boot{position:fixed;inset:0;z-index:2147
330
331
  var require2 = createRequire(import.meta.url);
331
332
  var PREFIX = "/ekko-skins";
332
333
  var PACKAGES = { rose: "dsh-plugin-rose-theme", miku: "dsh-plugin-miku-theme" };
334
+ function resolveSkinPackage(resolvePackage, id) {
335
+ if (id === "rose") try {
336
+ const bundled = join2(resolvePackage(PACKAGES.miku), "themes/rose");
337
+ if (existsSync2(join2(bundled, "package.json"))) return bundled;
338
+ } catch {
339
+ }
340
+ return resolvePackage(PACKAGES[id]);
341
+ }
333
342
  function createHandler({ resolvePackage = (name) => dirname(require2.resolve(name + "/package.json")), defaultSkin = "miku" } = {}) {
334
343
  async function catalog() {
335
344
  const skins = [];
336
345
  for (const [id, pkg] of Object.entries(PACKAGES)) {
337
346
  try {
338
- const root = resolvePackage(pkg), meta = JSON.parse(await readFile2(join2(root, "package.json"), "utf8"));
347
+ const root = resolveSkinPackage(resolvePackage, id), meta = JSON.parse(await readFile2(join2(root, "package.json"), "utf8"));
339
348
  skins.push({ id, version: meta.version, available: meta.dsh?.ekkoSkinProvider === 1, preview: { light: "./ekko-skins/preview/" + id + "/light.webp", dark: "./ekko-skins/preview/" + id + "/dark.webp" } });
340
349
  } catch {
341
350
  skins.push({ id, available: false });
@@ -344,7 +353,7 @@ function createHandler({ resolvePackage = (name) => dirname(require2.resolve(nam
344
353
  const installed = skins.filter((s) => s.available).map((s) => s.id);
345
354
  const preferred = ["rose", "miku", "default"].includes(defaultSkin) ? defaultSkin : "miku";
346
355
  const selected = preferred === "default" || installed.includes(preferred) ? preferred : installed[0] || "default";
347
- return { version: "0.2.2", defaultSkin: selected, skins };
356
+ return { version: "0.3.0", defaultSkin: selected, skins };
348
357
  }
349
358
  return async (req, res) => {
350
359
  if (!["GET", "HEAD"].includes(req.method)) {
@@ -366,7 +375,7 @@ function createHandler({ resolvePackage = (name) => dirname(require2.resolve(nam
366
375
  const [, kind, id, file] = match;
367
376
  let bytes;
368
377
  try {
369
- const root = resolvePackage(PACKAGES[id]);
378
+ const root = resolveSkinPackage(resolvePackage, id);
370
379
  if (kind === "assets") {
371
380
  if (!/^[a-f0-9]{64}\.(webp|png|svg|woff2)$/.test(file)) {
372
381
  res.writeHead(404).end();
@@ -405,7 +414,7 @@ function apply(ctx, config = {}) {
405
414
  ctx.inject(["webServer", "profileContext", "connection"], async (child) => {
406
415
  const profile = child.get("profileContext"), resolver = createRequire(pathToFileURL(join2(profile.dir, "package.json")));
407
416
  const resolvePackage = (name) => dirname(resolver.resolve(name + "/package.json"));
408
- const media = await createMediaStore({ directory: join2(profile.dir, "ekko-skins-media"), resolvePackage: (id) => resolvePackage(PACKAGES[id]) });
417
+ const media = await createMediaStore({ directory: join2(profile.dir, "ekko-skins-media"), resolvePackage: (id) => resolveSkinPackage(resolvePackage, id) });
409
418
  child.effect(() => () => media.close(), "ekko-skins: private media store");
410
419
  child.effect(() => child.get("webServer").register({ kind: "prefix", path: PREFIX, handler: createHandler({ defaultSkin: config.defaultSkin, resolvePackage }) }));
411
420
  for (const [path, methods, fetch, requestBody] of [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-plugin-ekko-skin-manager",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "旧版独立皮肤管理器兼容入口,主题管理已整合到新版主题中;新用户请安装 dsh-plugin-miku-theme",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
package/src/client.jsx CHANGED
@@ -4,7 +4,7 @@ import{createController,readSelection,SELECTION_KEY}from'./controller.mjs';
4
4
  import{STYLES}from'./generated.mjs';
5
5
  import{createMobileNavigation}from'./mobile-navigation.mjs';
6
6
  export const inject=['slots','theme','layout'];
7
- const NAMES={rose:{zh:'有菜玫瑰',en:'Arina Rose'},miku:{zh:'初音未来',en:'Hatsune Miku'}};
7
+ const NAMES={rose:{zh:'玫瑰女王',en:'Rose Queen'},miku:{zh:'初音未来',en:'Hatsune Miku'}};
8
8
  const COPY={
9
9
  zh:{nav:'Ekko 皮肤',title:'玫瑰与初音',intro:'选择你的创作空间;每个主题的装饰偏好分别保留。',use:'使用',active:'当前使用',switching:'正在切换…',native:'原生界面',options:'主题装饰',artwork:'欢迎页装饰',companion:'对话人物背景',polaroid:'右下角照片',opacity:'对话背景浓度',reset:'恢复此主题默认',loading:'正在加载主题…',unavailable:'可选主题,尚未安装',retry:'重试',loadFailed:'皮肤管理器暂时无法加载,请重试。',materialFailed:'主题素材未能加载,请重试。'},
10
10
  en:{nav:'Ekko skins',title:'Rose & Miku',intro:'Choose your creative space. Each skin keeps its own appearance preferences.',use:'Use',active:'In use',switching:'Switching…',native:'Native appearance',options:'Artwork preferences',artwork:'Welcome artwork',companion:'Chat character background',polaroid:'Corner photo',opacity:'Chat background opacity',reset:'Reset this skin',loading:'Loading skins…',unavailable:'Optional skin, not installed',retry:'Retry',loadFailed:'Unable to load the skin manager. Please retry.',materialFailed:'Unable to load the skin artwork. Please retry.'}
@@ -48,6 +48,8 @@ export function createController({storage,mount,prepare=async()=>{},fallback='ro
48
48
  subscribe(fn){listeners.add(fn);return()=>listeners.delete(fn);},
49
49
  register(provider){
50
50
  if(!['rose','miku'].includes(provider.id)||typeof provider.plugin?.apply!=='function')throw new TypeError('Unknown Ekko skin provider');
51
+ const existing=providers.get(provider.id);
52
+ if(existing&&(existing.priority||0)>(provider.priority||0))return()=>{};
51
53
  providers.set(provider.id,provider);publish({});
52
54
  if(initialized&&provider.id===wanted)void reconcile();
53
55
  return()=>{if(providers.get(provider.id)!==provider)return;providers.delete(provider.id);publish({});if(provider.id===wanted||runtime?.provider===provider)void reconcile();};
package/src/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import {readFile} from 'node:fs/promises';
2
+ import {existsSync} from 'node:fs';
2
3
  import {createRequire} from 'node:module';
3
4
  import {createHash} from 'node:crypto';
4
5
  import {dirname,join} from 'node:path';
@@ -9,15 +10,22 @@ import {toInlineJson} from './vendor/video-route.mjs';
9
10
  const require=createRequire(import.meta.url);
10
11
  export const PREFIX='/ekko-skins';
11
12
  const PACKAGES={rose:'dsh-plugin-rose-theme',miku:'dsh-plugin-miku-theme'};
13
+ export function resolveSkinPackage(resolvePackage,id){
14
+ if(id==='rose')try{
15
+ const bundled=join(resolvePackage(PACKAGES.miku),'themes/rose');
16
+ if(existsSync(join(bundled,'package.json')))return bundled;
17
+ }catch{}
18
+ return resolvePackage(PACKAGES[id]);
19
+ }
12
20
  export function createHandler({resolvePackage=name=>dirname(require.resolve(name+'/package.json')),defaultSkin='miku'}={}){
13
21
  async function catalog(){
14
22
  const skins=[];for(const[id,pkg]of Object.entries(PACKAGES)){
15
- try{const root=resolvePackage(pkg),meta=JSON.parse(await readFile(join(root,'package.json'),'utf8'));skins.push({id,version:meta.version,available:meta.dsh?.ekkoSkinProvider===1,preview:{light:'./ekko-skins/preview/'+id+'/light.webp',dark:'./ekko-skins/preview/'+id+'/dark.webp'}});}catch{skins.push({id,available:false});}
23
+ try{const root=resolveSkinPackage(resolvePackage,id),meta=JSON.parse(await readFile(join(root,'package.json'),'utf8'));skins.push({id,version:meta.version,available:meta.dsh?.ekkoSkinProvider===1,preview:{light:'./ekko-skins/preview/'+id+'/light.webp',dark:'./ekko-skins/preview/'+id+'/dark.webp'}});}catch{skins.push({id,available:false});}
16
24
  }
17
25
  const installed=skins.filter(s=>s.available).map(s=>s.id);
18
26
  const preferred=['rose','miku','default'].includes(defaultSkin)?defaultSkin:'miku';
19
27
  const selected=preferred==='default'||installed.includes(preferred)?preferred:(installed[0]||'default');
20
- return{version:'0.2.2',defaultSkin:selected,skins};
28
+ return{version:'0.3.0',defaultSkin:selected,skins};
21
29
  }
22
30
  return async(req,res)=>{
23
31
  if(!['GET','HEAD'].includes(req.method)){res.writeHead(405,{Allow:'GET, HEAD'}).end();return;}
@@ -29,7 +37,7 @@ export function createHandler({resolvePackage=name=>dirname(require.resolve(name
29
37
  if(!match){res.writeHead(404).end();return;}
30
38
  const[,kind,id,file]=match;let bytes;
31
39
  try{
32
- const root=resolvePackage(PACKAGES[id]);
40
+ const root=resolveSkinPackage(resolvePackage,id);
33
41
  if(kind==='assets'){
34
42
  if(!/^[a-f0-9]{64}\.(webp|png|svg|woff2)$/.test(file)){res.writeHead(404).end();return;}
35
43
  // Read the current allowlist so updating artwork does not need a host restart.
@@ -55,7 +63,7 @@ export function apply(ctx,config={}){
55
63
  ctx.inject(['webServer','profileContext','connection'],async child=>{
56
64
  const profile=child.get('profileContext'),resolver=createRequire(pathToFileURL(join(profile.dir,'package.json')));
57
65
  const resolvePackage=name=>dirname(resolver.resolve(name+'/package.json'));
58
- const media=await createMediaStore({directory:join(profile.dir,'ekko-skins-media'),resolvePackage:id=>resolvePackage(PACKAGES[id])});
66
+ const media=await createMediaStore({directory:join(profile.dir,'ekko-skins-media'),resolvePackage:id=>resolveSkinPackage(resolvePackage,id)});
59
67
  child.effect(()=>()=>media.close(),'ekko-skins: private media store');
60
68
  child.effect(()=>child.get('webServer').register({kind:'prefix',path:PREFIX,handler:createHandler({defaultSkin:config.defaultSkin,resolvePackage})}));
61
69
  for(const [path,methods,fetch,requestBody]of [
@@ -59,7 +59,7 @@ export function VideoSettings({skin}){
59
59
  if(!data||!draft)return <section className="ekko-skin-options ekko-video-settings" data-testid="ekko-video-settings"><h3>启动视频</h3><p role="status">{message||'正在读取视频设置…'}</p><button type="button" className="ekko-skin-button" onClick={load}>重试</button></section>;
60
60
  const update=patch=>setDraft({...draft,...patch}),fitKey=device==='mobile'?'mobileFit':'fit';
61
61
  return <section className="ekko-skin-options ekko-video-settings" data-testid="ekko-video-settings" data-device={device}>
62
- <div className="ekko-skin-toolbar"><h3>{skin==='miku'?'初音未来':'有菜玫瑰'} · 启动视频</h3><span className="ekko-skin-version">每款主题分别保存</span></div>
62
+ <div className="ekko-skin-toolbar"><h3>{skin==='miku'?'初音未来':'玫瑰女王'} · 启动视频</h3><span className="ekko-skin-version">每款主题分别保存</span></div>
63
63
  <div className="ekko-video-controls">
64
64
  <label className="ekko-skin-option"><span>启用启动视频<small>页面启动时播放,可随时跳过</small></span><input type="checkbox" checked={draft.enabled} disabled={busy} onChange={e=>update({enabled:e.target.checked})}/></label>
65
65
  <label className="ekko-skin-option"><span>播放声音<small>浏览器限制自动播放时先静音,点击可开启声音</small></span><input type="checkbox" checked={draft.sound} disabled={busy} onChange={e=>update({sound:e.target.checked})}/></label>