dsh-boot-suite 2.13.11 → 2.13.19

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +3 -1
  3. package/lib/assets/voices/f/line-1.mp3 +0 -0
  4. package/lib/assets/voices/f/line-2.mp3 +0 -0
  5. package/lib/assets/voices/f/line-3.mp3 +0 -0
  6. package/lib/assets/voices/f/line-4.mp3 +0 -0
  7. package/lib/assets/voices/f/line-5.mp3 +0 -0
  8. package/lib/assets/voices/f/line-6.mp3 +0 -0
  9. package/lib/assets/voices/g/line-1.mp3 +0 -0
  10. package/lib/assets/voices/g/line-2.mp3 +0 -0
  11. package/lib/assets/voices/g/line-3.mp3 +0 -0
  12. package/lib/assets/voices/g/line-4.mp3 +0 -0
  13. package/lib/assets/voices/g/line-5.mp3 +0 -0
  14. package/lib/assets/voices/g/line-6.mp3 +0 -0
  15. package/lib/assets/voices/h/line-1.mp3 +0 -0
  16. package/lib/assets/voices/h/line-2.mp3 +0 -0
  17. package/lib/assets/voices/h/line-3.mp3 +0 -0
  18. package/lib/assets/voices/h/line-4.mp3 +0 -0
  19. package/lib/assets/voices/h/line-5.mp3 +0 -0
  20. package/lib/assets/voices/h/line-6.mp3 +0 -0
  21. package/lib/assets/voices/i/line-1.mp3 +0 -0
  22. package/lib/assets/voices/i/line-2.mp3 +0 -0
  23. package/lib/assets/voices/i/line-3.mp3 +0 -0
  24. package/lib/assets/voices/i/line-4.mp3 +0 -0
  25. package/lib/assets/voices/i/line-5.mp3 +0 -0
  26. package/lib/assets/voices/i/line-6.mp3 +0 -0
  27. package/lib/assets/voices/j/line-1.mp3 +0 -0
  28. package/lib/assets/voices/j/line-2.mp3 +0 -0
  29. package/lib/assets/voices/j/line-3.mp3 +0 -0
  30. package/lib/assets/voices/j/line-4.mp3 +0 -0
  31. package/lib/assets/voices/j/line-5.mp3 +0 -0
  32. package/lib/assets/voices/j/line-6.mp3 +0 -0
  33. package/lib/assets/voices/k/line-1.mp3 +0 -0
  34. package/lib/assets/voices/k/line-2.mp3 +0 -0
  35. package/lib/assets/voices/k/line-3.mp3 +0 -0
  36. package/lib/assets/voices/k/line-4.mp3 +0 -0
  37. package/lib/assets/voices/k/line-5.mp3 +0 -0
  38. package/lib/assets/voices/k/line-6.mp3 +0 -0
  39. package/lib/assets/voices/manifest.json +32 -0
  40. package/lib/client.js +145 -64
  41. package/lib/host-startup.js +81 -4
  42. package/lib/host-wallpaper.js +8 -2
  43. package/lib/splash.js +73 -23
  44. package/package.json +2 -2
  45. package/test/smoke-client.mjs +27 -4
  46. package/test/smoke-host.mjs +29 -4
  47. package/test/smoke-package.mjs +4 -1
  48. package/test/smoke-splash.mjs +8 -1
  49. package/test/smoke-wallpaper-client.mjs +1 -1
  50. package/test/smoke-wallpaper-host.mjs +7 -2
  51. package/lib/assets/line-1.mp3 +0 -0
  52. package/lib/assets/line-2.mp3 +0 -0
  53. package/lib/assets/line-3.mp3 +0 -0
  54. package/lib/assets/line-4.mp3 +0 -0
  55. package/lib/assets/line-5.mp3 +0 -0
  56. package/lib/assets/line-6.mp3 +0 -0
package/lib/client.js CHANGED
@@ -249,10 +249,11 @@ window.__ModuleLoader__.load({
249
249
  面板也要能正常显示这些控件,而不是一片 undefined。 */
250
250
  var FALLBACK = {
251
251
  voice: true, voiceLang: 'en', voiceName: '', voiceRate: 0.88, voicePitch: 0.85,
252
- voiceVolume: 0.9, voiceCue: true, voiceLead: 240, voiceSource: 'asset',
252
+ voiceVolume: 0.9, voiceCue: true, voiceLead: 240, welcomeDelay: 550, voiceSource: 'asset', voicePack: 'f', voiceFx: 'robot',
253
253
  sound: true, soundVolume: 0.5, bedNoise: false,
254
254
  transitionIn: 'dissolve', transitionJoin: 'dissolve', transitionOut: 'dissolve',
255
- transitionMs: 600, transitionDir: 'left'
255
+ transitionMs: 600, transitionDir: 'left',
256
+ colorMode: 'color'
256
257
  }
257
258
  function num(v, d) {
258
259
  var n = Number(v)
@@ -265,7 +266,9 @@ window.__ModuleLoader__.load({
265
266
  —— 桌面端走浏览器半侧自举时没有 1,就靠这一条
266
267
  3) 下面这个常量:两份都没拿到时的兜底,别让面板显示 undefined
267
268
  版本号本身只在 lib/index.js 里读一次,这里不重复维护。 */
268
- var FALLBACK_VERSION = '2.13.11'
269
+ var FALLBACK_VERSION = '2.13.19'
270
+ /* 手动选包成功后置 true:让还在后台跑的“浏览器下载监听”不要再弹第二次更新器 */
271
+ var __manualUpdateApplied = false
269
272
  function readInjectedVersion() {
270
273
  try {
271
274
  var v = window.__DSH_STARTUP_VERSION__
@@ -283,10 +286,13 @@ window.__ModuleLoader__.load({
283
286
  var [busy, setBusy] = react.useState(false)
284
287
  var [templates, setTemplates] = react.useState([])
285
288
  var [hiddenTemplates, setHiddenTemplates] = react.useState([])
289
+ var [voicePacks, setVoicePacks] = react.useState([])
286
290
  var [update, setUpdate] = react.useState(null)
287
291
  var [checking, setChecking] = react.useState(false)
288
292
  var [updating, setUpdating] = react.useState(false)
289
293
  var [updateMsg, setUpdateMsg] = react.useState('')
294
+ var [waitingDownload, setWaitingDownload] = react.useState(false)
295
+ var [diagnosing, setDiagnosing] = react.useState(false)
290
296
 
291
297
  function load() {
292
298
  setError(null)
@@ -317,6 +323,25 @@ window.__ModuleLoader__.load({
317
323
  }
318
324
  react.useEffect(() => { refreshTemplates() }, [])
319
325
 
326
+ /* 音色包列表:来自 lib/assets/voices/manifest.json,由宿主只读下发 */
327
+ function refreshVoices() {
328
+ return fetch('/dsh-startup/voices', { headers: { Accept: 'application/json' }, cache: 'no-store' })
329
+ .then((r) => r.json())
330
+ .then((j) => { if (j && j.ok === true && Array.isArray(j.packs)) setVoicePacks(j.packs) })
331
+ .catch(() => { /* 拉不到就只显示内置音色 */ })
332
+ }
333
+ react.useEffect(() => { refreshVoices() }, [])
334
+
335
+ function voiceOptions() {
336
+ var opts = []
337
+ for (var i = 0; i < voicePacks.length; i++) {
338
+ var v = voicePacks[i]
339
+ if (!v || !v.id) continue
340
+ opts.push([v.id, String(v.name || v.id)])
341
+ }
342
+ return opts
343
+ }
344
+
320
345
  function set(key, value) {
321
346
  setNotice(null)
322
347
  setDraft((d) => Object.assign({}, d, { [key]: value }))
@@ -357,18 +382,23 @@ window.__ModuleLoader__.load({
357
382
  .then(() => setBusy(false))
358
383
  }
359
384
 
360
- /** 一键诊断:让宿主写一份报告到桌面,用户直接发给作者。 */
385
+ /** 一键诊断:让宿主写一份报告到桌面,用户直接发给作者。生成中按钮禁用,防止连点刷屏。 */
361
386
  function runDiagnose() {
362
- setNotice(null)
387
+ if (diagnosing) return
388
+ setDiagnosing(true)
389
+ setNotice('正在生成诊断报告,请稍等…')
390
+ setError(null)
363
391
  fetch(STARTUP_ROUTE + '/diagnose', { method: 'POST' })
364
392
  .then(function (r) { return r.json() })
365
393
  .then(function (j) {
366
394
  if (!j || j.ok !== true) throw new Error((j && j.error) || '生成失败')
367
- setNotice('诊断报告已生成到桌面:' + j.file)
395
+ setNotice('诊断报告已生成到桌面:' + j.file + '(报告里不含任何密钥/密码)')
368
396
  })
369
397
  .catch(function (e) {
370
- setNotice('生成诊断报告失败:' + String((e && e.message) || e))
398
+ setNotice(null)
399
+ setError('生成诊断报告失败:' + String((e && e.message) || e))
371
400
  })
401
+ .then(function () { setDiagnosing(false) })
372
402
  }
373
403
 
374
404
  /** 检查更新:问宿主(宿主去读 GitHub latest release,避开浏览器 CORS)。 */
@@ -409,27 +439,44 @@ window.__ModuleLoader__.load({
409
439
 
410
440
  /** 立即更新:让系统浏览器下载(走浏览器自己的代理),插件监听 Downloads 自动接住 */
411
441
  function runUpdate() {
412
- if (!update || !update.latest) return
442
+ if (!update || !update.latest || updating) return
443
+ __manualUpdateApplied = false
413
444
  setUpdating(true)
445
+ setWaitingDownload(true)
414
446
  setUpdateMsg('正在让浏览器下载 v' + update.latest + '(使用浏览器自己的代理)…')
415
447
  fetch(STARTUP_ROUTE + '/update-browser-download', { method: 'POST' })
416
448
  .then(function (r) { return r.json() })
417
449
  .then(function (j) {
418
450
  if (!j || j.ok !== true) throw new Error((j && j.error) || '无法启动浏览器下载')
419
- setUpdateMsg('已用 ' + (j.browser === 'chrome' ? 'Chrome' : '默认浏览器') + ' 打开下载,完成后会自动继续(最多等 10 分钟)…')
451
+ setUpdateMsg('已用 ' + (j.browser === 'chrome' ? 'Chrome' : '默认浏览器') + ' 打开下载,完成后会自动继续(最多等 30 分钟)。等太久可以点「打开下载文件夹」或「选择安装包」。')
420
452
  return fetch(STARTUP_ROUTE + '/update-watch-download?version=' + encodeURIComponent(update.latest))
421
453
  })
422
454
  .then(function (r) { return r.json() })
423
455
  .then(function (j) {
456
+ setWaitingDownload(false)
457
+ if (__manualUpdateApplied) return
424
458
  if (!j || j.ok !== true) throw new Error((j && j.error) || '没有等到下载文件')
425
459
  setUpdating(false)
426
460
  setUpdateMsg('已获取 v' + j.version + '(' + Math.round(j.size / 1048576) + ' MB)')
427
461
  confirmAndApply(j.version)
428
462
  })
429
463
  .catch(function (e) {
464
+ setWaitingDownload(false)
465
+ if (__manualUpdateApplied) return
430
466
  setUpdating(false)
431
- setUpdateMsg('浏览器下载没接住:' + String((e && e.message) || e) + '。可再点「立即更新」,或点「选择安装包」手动指定。')
467
+ setUpdateMsg('浏览器下载没接住:' + String((e && e.message) || e) + '。可以点「打开下载文件夹」找到 zip 后点「选择安装包」。')
468
+ })
469
+ }
470
+
471
+ /** 打开系统下载文件夹(等下载时的手动出口) */
472
+ function openDownloads() {
473
+ fetch(STARTUP_ROUTE + '/open-downloads', { method: 'POST' })
474
+ .then(function (r) { return r.json() })
475
+ .then(function (j) {
476
+ if (!j || j.ok !== true) throw new Error((j && j.error) || '打不开文件夹')
477
+ setUpdateMsg('已打开下载文件夹:' + j.dir + '。找到 dsh-boot-suite 的 zip 后,点「选择安装包」选它。')
432
478
  })
479
+ .catch(function (e) { setUpdateMsg('打开下载文件夹失败:' + String((e && e.message) || e)) })
433
480
  }
434
481
 
435
482
  /** 浏览器下载好 zip 后,从本地选择文件(完全不依赖宿主联网) */
@@ -454,6 +501,8 @@ window.__ModuleLoader__.load({
454
501
  .then(function (r) { return r.json() })
455
502
  .then(function (j) {
456
503
  if (!j || j.ok !== true) throw new Error((j && j.error) || '本地安装包无效')
504
+ __manualUpdateApplied = true
505
+ setWaitingDownload(false)
457
506
  setUpdating(false)
458
507
  setUpdateMsg('已载入本地安装包 v' + j.version + '(' + Math.round(j.size / 1048576) + ' MB)')
459
508
  confirmAndApply(j.version)
@@ -616,7 +665,10 @@ window.__ModuleLoader__.load({
616
665
  .then((r) => r.json())
617
666
  .then((j) => {
618
667
  if (!j || j.ok !== true) return
619
- if (j.config) window.__DSH_STARTUP__ = Object.assign({}, FALLBACK, j.config)
668
+ /* v2.13.15:预览必须带上「当前还没保存的修改」。
669
+ 原来只灌宿主已保存的配置:用户选完音色/效果直接点「立即预览」,
670
+ 运行时拿到的还是旧配置 —— 表现就是「选了日文音色,预览仍播英文」。 */
671
+ if (j.config) window.__DSH_STARTUP__ = Object.assign({}, FALLBACK, j.config, draft || {})
620
672
  try {
621
673
  if (j.template) window.__DSH_STARTUP_TEMPLATE__ = j.template
622
674
  else delete window.__DSH_STARTUP_TEMPLATE__
@@ -876,56 +928,38 @@ window.__ModuleLoader__.load({
876
928
  h('div', { className: '__ss_groupTitle' }, '语音与音效'),
877
929
  h(Check, {
878
930
  label: '女声播报(念每一行的英文)',
879
- hint: '照参考片里那位女声的节奏:文字出现的同一时刻念出来。'
880
- + '动画会等这句话念完再走下一步,不会把语音掐断;浏览器要求先有一次点击才准出声,'
881
- + '所以第一次点按钮/按 Enter 时会把当前这行补念一次。',
931
+ hint: '每一行的英文都在文字出现的同一时刻念出来,动画等这句话念完再走下一步。',
882
932
  checked: draft.voice,
883
933
  onChange: (v) => set('voice', v)
884
934
  }),
885
- h(Field, {
886
- label: '语音来源',
887
- hint: '素材 = 插件内置的真声(来自你给的音频,5 句);TTS = 用系统语音合成。'
888
- + '素材缺失时无论选哪个都会自动回落到 TTS;终幕那句嵌了可配置的身份名称,始终走 TTS。'
935
+ draft.voice ? h(Field, {
936
+ label: '配音音色',
937
+ hint: '6 套完整干音:英文 1 套(音色F·默认)+ 日文 5 套(日文音色1–5);可再叠声音效果。'
938
+ + '选完音色后,下面的「声音效果」可以再叠一层处理,或选「无」保留原声。'
889
939
  },
890
940
  h(Select, {
891
- value: draft.voiceSource || 'asset',
892
- options: [['asset', '真声素材(推荐)'], ['tts', '系统 TTS 合成']],
893
- onChange: (v) => set('voiceSource', v)
941
+ value: draft.voicePack || 'builtin',
942
+ options: voiceOptions(),
943
+ onChange: (v) => set('voicePack', v)
894
944
  })
895
- ),
896
- h(Field, { label: '播报语言' },
945
+ ) : null,
946
+ draft.voice ? h(Field, {
947
+ label: '声音效果',
948
+ hint: '5 个音效都来自模板编辑器里的同一套参数,实时叠加在所选音色上;选「无(原声)」就是干净人声。'
949
+ + '只影响全局配音素材,模板自带的配音不受影响。'
950
+ },
897
951
  h(Select, {
898
- value: draft.voiceLang,
899
- options: [['en', '英语(照参考片)'], ['zh', '中文'], ['both', '中英各念一遍']],
900
- onChange: (v) => set('voiceLang', v)
952
+ value: draft.voiceFx || 'none',
953
+ options: [
954
+ ['none', '无(原声,默认)'],
955
+ ['ai-mech', 'AI 机械音'],
956
+ ['robot', '机器人'],
957
+ ['radio', '无线电'],
958
+ ['oldtv', '老电视'],
959
+ ['dark', '低沉']
960
+ ],
961
+ onChange: (v) => set('voiceFx', v)
901
962
  })
902
- ),
903
- draft.voice ? h(VoicePicker, {
904
- value: draft.voiceName,
905
- onChange: (v) => set('voiceName', v),
906
- onPreview: () => speakSample(null, draft)
907
- }) : null,
908
- draft.voice ? h('div', { className: '__ss_row' },
909
- h('button', {
910
- className: '__ss_btn',
911
- type: 'button',
912
- onClick: () => {
913
- set('voiceRate', 0.88)
914
- set('voicePitch', 0.85)
915
- set('voiceName', '')
916
- set('voiceCue', true)
917
- set('voiceLead', 240)
918
- }
919
- }, '↺ 套用「电影旁白」档'),
920
- h('span', { className: '__ss_hint' }, '语速 0.88 · 音调 0.85(压抑、深沉)· 自动挑神经网络女声 · 提示音 + 开口留白')
921
- ) : null,
922
- draft.voice ? h('div', { className: '__ss_two' },
923
- h(Field, { label: '语速' },
924
- h(Range, { value: num(draft.voiceRate, 1), min: 0.5, max: 2, display: num(draft.voiceRate, 1).toFixed(2) + '×', onChange: (v) => set('voiceRate', v) })
925
- ),
926
- h(Field, { label: '音调' },
927
- h(Range, { value: num(draft.voicePitch, 1), min: 0, max: 2, display: num(draft.voicePitch, 1).toFixed(2), onChange: (v) => set('voicePitch', v) })
928
- )
929
963
  ) : null,
930
964
  draft.voice ? h(Field, { label: '开口前留白', hint: '提示音响过之后停多久再开口。电影旁白/机场广播都有这一拍,240ms 起最像"系统在播报"。' },
931
965
  h(Range, {
@@ -934,6 +968,13 @@ window.__ModuleLoader__.load({
934
968
  onChange: (v) => set('voiceLead', v)
935
969
  })
936
970
  ) : null,
971
+ draft.voice ? h(Field, { label: '终幕语音延迟', hint: '05→06 的过场和声钟约 1.5 秒;默认等它收尾再进最后一句。觉得欢迎语进得太早/太晚,就调这里。' },
972
+ h(Range, {
973
+ value: num(draft.welcomeDelay, 550), min: 0, max: 3000, step: 50,
974
+ display: Math.round(num(draft.welcomeDelay, 550)) + ' ms',
975
+ onChange: (v) => set('welcomeDelay', v)
976
+ })
977
+ ) : null,
937
978
  draft.voice ? h(Field, { label: '音量' },
938
979
  h(Range, { value: num(draft.voiceVolume, 0.9), min: 0, max: 1, display: Math.round(num(draft.voiceVolume, 0.9) * 100) + '%', onChange: (v) => set('voiceVolume', v) })
939
980
  ) : null,
@@ -1017,7 +1058,7 @@ window.__ModuleLoader__.load({
1017
1058
  /* 版本与更新 */
1018
1059
  h('div', { className: '__ss_row', style: { marginTop: '10px' } },
1019
1060
  h('button', { className: '__ss_btn', disabled: checking, onClick: checkUpdate }, checking ? '检查中…' : '检查更新'),
1020
- h('button', { className: '__ss_btn', onClick: runDiagnose }, '生成诊断报告'),
1061
+ h('button', { className: '__ss_btn', disabled: diagnosing, onClick: runDiagnose }, diagnosing ? '生成中…' : '生成诊断报告'),
1021
1062
  h('span', {
1022
1063
  className: (update && update.ok && update.hasUpdate) ? '__ss_ok' : '__ss_hint'
1023
1064
  }, (function () {
@@ -1034,12 +1075,19 @@ window.__ModuleLoader__.load({
1034
1075
  ? h('button', { className: '__ss_btn __ss_btnPrimary', disabled: updating, onClick: runUpdate }, updating ? '更新中…' : '立即更新')
1035
1076
  : null,
1036
1077
  (update && update.ok && update.hasUpdate)
1037
- ? h('button', { className: '__ss_btn', disabled: updating, onClick: pickLocalPackage }, '选择安装包')
1078
+ ? h('button', { className: '__ss_btn', disabled: updating && !waitingDownload, onClick: pickLocalPackage }, '选择安装包')
1079
+ : null,
1080
+ (update && update.ok && update.hasUpdate && waitingDownload)
1081
+ ? h('button', { className: '__ss_btn', onClick: openDownloads }, '打开下载文件夹')
1038
1082
  : null
1039
1083
  ),
1040
1084
  (update && update.ok && update.hasUpdate)
1041
1085
  ? h('div', { className: '__ss_hint', style: { marginTop: '6px', lineHeight: '1.8' } },
1042
- '下载会优先用 Chrome(Edge + IDM 可能拦下载)。如果没自动继续,点「选择安装包」选 Downloads 里的 zip。')
1086
+ '下载会优先用 Chrome(Edge + IDM 可能拦下载)。如果没自动继续,点「打开下载文件夹」找到 zip,再点「选择安装包」。')
1087
+ : null,
1088
+ waitingDownload
1089
+ ? h('div', { className: '__ss_hint', style: { marginTop: '6px', lineHeight: '1.8', color: '#c98a2b' } },
1090
+ '正在等浏览器下载(最多 30 分钟)。下载完成后会自动继续;如果已经下好,点「打开下载文件夹」看看,或直接点「选择安装包」选那个 zip。')
1043
1091
  : null,
1044
1092
  h('div', { className: '__ss_hint', style: { marginTop: '6px', lineHeight: '1.8' } },
1045
1093
  '回滚保护:安装新版本前,桌面会自动多一个「紧急回滚-V当前版本」按钮。新版装不上、或装完 DSH 打不开时,完全退出 DSH(窗口+托盘)后双击它,就能回到更新前的版本;它只换插件版本,不动你的设置。更新成功后旧按钮会被自动替换,不会越积越多——确认新版正常后也不用手动删。'),
@@ -1552,7 +1600,7 @@ window.__ModuleLoader__.load({
1552
1600
  /* 每次改客户端就把这个号 +1。
1553
1601
  有了它在控制台一眼就能判断"浏览器里跑的是不是最新那份" ——
1554
1602
  排查问题时,分不清是代码问题还是旧包,会白猜一轮。 */
1555
- var BUILD = 'cw-34'
1603
+ var BUILD = 'cw-42'
1556
1604
 
1557
1605
  function log() {
1558
1606
  try {
@@ -2058,6 +2106,8 @@ window.__ModuleLoader__.load({
2058
2106
  L.push(B + ' > ' + V + ' { position: absolute; inset: 0; }')
2059
2107
  L.push('html.dsh-cw-light ' + B + ' > ' + V + ' { background: linear-gradient(' + cfg.veilLightA + ', ' + cfg.veilLightB + '); }')
2060
2108
  L.push('html.dsh-cw-dark ' + B + ' > ' + V + ' { background: linear-gradient(' + cfg.veilDarkA + ', ' + cfg.veilDarkB + '); }')
2109
+ /* v2.13.17:黑白原版素材模式 —— 不染色,直接关掉主题色纱(模糊仍可单独调) */
2110
+ L.push('html.dsh-cw-mono ' + B + ' > ' + V + ' { background: transparent !important; }')
2061
2111
  /* v2.13.11 镜头模糊:只模糊纱后面的壁纸/视频,纱上的聊天文字不受影响。
2062
2112
  0 = 不生成规则(完全不动);100 = 约 24px。 */
2063
2113
  if (Number(cfg.veilBlur) > 0) {
@@ -2353,6 +2403,8 @@ window.__ModuleLoader__.load({
2353
2403
  current = cfg
2354
2404
  var dark = looksDark()
2355
2405
  setThemeClass(dark)
2406
+ /* v2.13.17:黑白(原版素材)模式 → 关掉主题色纱,保留原画面 */
2407
+ try { document.documentElement.classList.toggle('dsh-cw-mono', cfg.colorMode === 'mono') } catch (e) { /* 忽略 */ }
2356
2408
  log('套用壁纸', { image: cfg.__image, theme: dark ? 'dark' : 'light' })
2357
2409
  injectCss(cfg)
2358
2410
  mountBg(cfg) /* 最底:聊天底图(用户自己定的那张,可选)*/
@@ -2386,7 +2438,7 @@ window.__ModuleLoader__.load({
2386
2438
  removeBg()
2387
2439
  removeBanner()
2388
2440
  try {
2389
- document.documentElement.classList.remove('dsh-cw-dark', 'dsh-cw-light')
2441
+ document.documentElement.classList.remove('dsh-cw-dark', 'dsh-cw-light', 'dsh-cw-mono')
2390
2442
  themeClass = null
2391
2443
  } catch (e) { /* 忽略 */ }
2392
2444
  }
@@ -2520,9 +2572,11 @@ window.__ModuleLoader__.load({
2520
2572
  var [busy, setBusy] = cwReact.useState(false)
2521
2573
  var [error, setError] = cwReact.useState(null)
2522
2574
  var [notice, setNotice] = cwReact.useState(null)
2575
+ var [refreshing, setRefreshing] = cwReact.useState(false)
2523
2576
 
2524
- function load() {
2577
+ function load(manual) {
2525
2578
  setError(null)
2579
+ if (manual) { setNotice(null); setRefreshing(true) }
2526
2580
  return Promise.all([api('/images'), api('/config', { cache: 'no-store' }), api('/videos')])
2527
2581
  .then(function (arr) {
2528
2582
  var images = arr[0] || {}
@@ -2543,6 +2597,7 @@ window.__ModuleLoader__.load({
2543
2597
  veilBlur: cfg.veilBlur == null ? 0 : Math.max(0, Math.min(100, Number(cfg.veilBlur) || 0)),
2544
2598
  image: conf.image || null,
2545
2599
  imageFit: cfg.imageFit === 'contain' ? 'contain' : 'cover',
2600
+ colorMode: cfg.colorMode === 'mono' ? 'mono' : 'color',
2546
2601
  video: {
2547
2602
  bgMode: vids.bgMode || 'image',
2548
2603
  videoMode: vids.videoMode || 'single',
@@ -2555,8 +2610,13 @@ window.__ModuleLoader__.load({
2555
2610
  }
2556
2611
  setData(next)
2557
2612
  setDraft({ banner: next.banner, bannerOpacity: next.bannerOpacity, dark: looksDark(), newRoot: '', shuffleMin: next.video.shuffleMinutes })
2613
+ if (manual) {
2614
+ setNotice('已重新读取:图片 ' + next.images.length + ' 张 / 视频 ' + next.video.items.length + ' 个'
2615
+ + (next.dropDirExists ? '' : '(丢图目录还不存在,点「打开图片文件夹」会创建)'))
2616
+ }
2558
2617
  })
2559
2618
  .catch(function (e) { setError('读取失败:' + String((e && e.message) || e)) })
2619
+ .then(function () { if (manual) setRefreshing(false) })
2560
2620
  }
2561
2621
  cwReact.useEffect(function () { load() }, [])
2562
2622
 
@@ -2659,11 +2719,20 @@ window.__ModuleLoader__.load({
2659
2719
  cwH('br'),
2660
2720
  '循环视频:' + (data.video && data.video.items.length ? ('已扫描 ' + data.video.items.length + ' 段' + (data.video.bgMode === 'video' && data.video.currentId ? ',正在播放' : ',未启用')) : '(视频目录里还没有视频)'),
2661
2721
  cwH('br'),
2662
- '丢图目录:' + (data.dropDir || '?')
2722
+ '丢图目录:' + (data.dropDir || '(尚未创建,点「打开图片文件夹」会自动创建)')
2663
2723
  ),
2664
2724
  cwH('div', { className: '__ss_row' },
2665
- cwH('button', { className: '__ss_btn', type: 'button', onClick: function () { api('/open-dir', { method: 'POST' }) } }, '打开图片文件夹'),
2666
- cwH('button', { className: '__ss_btn', type: 'button', disabled: busy, onClick: function () { load() } }, '刷新')
2725
+ cwH('button', {
2726
+ className: '__ss_btn', type: 'button', disabled: busy,
2727
+ onClick: function () {
2728
+ setNotice(null); setError(null)
2729
+ api('/open-dir', { method: 'POST' }).then(function (j) {
2730
+ if (j && j.ok) setNotice('已打开图片文件夹:' + (j.opened || data.dropDir))
2731
+ else setError('打开图片文件夹失败:' + ((j && j.error) || '宿主没有返回'))
2732
+ })
2733
+ }
2734
+ }, '打开图片文件夹'),
2735
+ cwH('button', { className: '__ss_btn', type: 'button', disabled: busy || refreshing, onClick: function () { load(true) } }, refreshing ? '刷新中…' : '刷新')
2667
2736
  )
2668
2737
  ),
2669
2738
 
@@ -2672,7 +2741,7 @@ window.__ModuleLoader__.load({
2672
2741
  cwH('div', { className: '__ss_hint' }, '点一下立刻换。第一张「默认背景(内置)」是插件自带、开箱就有的;把图丢进上面的目录也会出现在这里,按修改时间取最新那张。'),
2673
2742
  data.images.length
2674
2743
  ? cwH('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 8 } }, data.images.map(thumb))
2675
- : cwH('div', { className: '__ss_hint' }, '目录里还没有图。点「打开图片文件夹」,把 jpg / png / webp 丢进去,再点刷新。'),
2744
+ : cwH('div', { className: '__ss_hint' }, '目录里还没有图。点「打开图片文件夹」,把 jpg / png / webp 丢进去,再点刷新。只扫描这一层,子文件夹里的不算(支持 jpg/png/webp/gif/avif/bmp)。'),
2676
2745
  cwH('div', { className: '__ss_row', style: { marginTop: 8 } },
2677
2746
  cwH('span', { className: '__ss_status' }, '图片适配'),
2678
2747
  cwH('select', {
@@ -2714,6 +2783,18 @@ window.__ModuleLoader__.load({
2714
2783
  cwH('option', { value: 'shuffle' }, '随机轮换')
2715
2784
  )
2716
2785
  ),
2786
+ cwH('div', { className: '__ss_row', style: { marginTop: 8 } },
2787
+ cwH('span', { className: '__ss_status' }, '色彩模式'),
2788
+ cwH('select', {
2789
+ className: '__ss_select', disabled: busy,
2790
+ value: data.colorMode,
2791
+ onChange: function (ev) { apply({ colorMode: ev.target.value }, true) }
2792
+ },
2793
+ cwH('option', { value: 'color' }, '彩色(默认)'),
2794
+ cwH('option', { value: 'mono' }, '黑白(原版素材,不染色)')
2795
+ )
2796
+ ),
2797
+ cwH('div', { className: '__ss_hint' }, '黑白模式会关掉主题色纱、保留原画面颜色,适合本身就是黑白的动态壁纸;彩色模式不做任何调色。'),
2717
2798
  data.video.videoMode === 'shuffle'
2718
2799
  ? cwH('div', { className: '__ss_row' },
2719
2800
  cwH('span', { className: '__ss_status' }, '轮换间隔(分钟,0 = 每次启动随机)'),
@@ -2764,7 +2845,7 @@ window.__ModuleLoader__.load({
2764
2845
  : cwH('div', { className: '__ss_hint' }, '还没有视频。把 mp4/webm 或壁纸引擎的项目文件夹放进上面的目录,再点下方「刷新」。'),
2765
2846
  cwH('div', { className: '__ss_row', style: { marginTop: 8 } },
2766
2847
  cwH('button', { className: '__ss_btn', type: 'button', disabled: busy || !(data.video.roots && data.video.roots.length), onClick: function () { if (data.video.roots && data.video.roots.length) rootAction('open', data.video.roots[0]) } }, '打开视频文件夹'),
2767
- cwH('button', { className: '__ss_btn', type: 'button', disabled: busy, onClick: function () { load() } }, '刷新')
2848
+ cwH('button', { className: '__ss_btn', type: 'button', disabled: busy || refreshing, onClick: function () { load(true) } }, refreshing ? '刷新中…' : '刷新')
2768
2849
  )
2769
2850
  ),
2770
2851
 
@@ -19,6 +19,18 @@ import { spawn } from 'node:child_process'
19
19
  const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
20
20
  const DSH_HOME = process.env.DSH_HOME || path.join(os.homedir(), '.dsh')
21
21
  const CONFIG_FILE = path.join(DSH_HOME, 'dsh-startup.json')
22
+ const VOICE_ROOT = path.join(PACKAGE_ROOT, 'lib', 'assets', 'voices')
23
+ /* 音色包清单由 lib/assets/voices/manifest.json 描述(随安装包发布)。
24
+ 读不到就当作没有扩展音色,内置音色不受影响。 */
25
+ const VOICE_PACKS = (() => {
26
+ try {
27
+ const list = JSON.parse(fs.readFileSync(path.join(VOICE_ROOT, 'manifest.json'), 'utf8'))
28
+ if (!Array.isArray(list)) return []
29
+ return list.filter((v) => v && /^[a-z]$/.test(String(v.id)) && (v.lang === 'en' || v.lang === 'ja'))
30
+ .map((v) => ({ id: String(v.id), name: String(v.name || '').slice(0, 12) || ('音色 ' + String(v.id).toUpperCase()), lang: v.lang }))
31
+ } catch (err) { return [] }
32
+ })()
33
+ const VOICE_PACK_IDS = [...VOICE_PACKS.map((v) => v.id)]
22
34
 
23
35
  const NS = 'dsh-startup-screen'
24
36
  const ROUTE_BASE = '/dsh-startup'
@@ -75,7 +87,10 @@ const DEFAULTS = {
75
87
  voiceCue: true, // 念台词前的一声轻提示
76
88
  sfx: 'audio', // 音效来源:audio=lib/assets 里的真素材 / synth=现场合成
77
89
  voiceSource: 'asset', // 语音来源:asset=优先用真声素材(拿不到自动回落 TTS)/ tts=全 TTS
90
+ voicePack: 'f', // 配音音色:f..k=音色包(默认 F 英文);用户在设置里可换
91
+ voiceFx: 'robot', // 声音效果:none | ai-mech | robot | radio | oldtv | dark(默认机器人,贴近旧版听感)
78
92
  voiceLead: 240, // 提示音之后留一小段空白再开口
93
+ welcomeDelay: 550, // 05→06 过场和声钟之后等多久再进终幕欢迎语(0–3000ms;实测 550 最顺)
79
94
  identity: 'JOYCE MOORE', // ← 设置页里可改的「身份名称」
80
95
  identityId: '0087',
81
96
  accessLevel: '3',
@@ -117,6 +132,8 @@ const ENUMS = {
117
132
  voiceLang: ['en', 'zh', 'both'],
118
133
  sfx: ['audio', 'synth'],
119
134
  voiceSource: ['asset', 'tts'],
135
+ voicePack: VOICE_PACK_IDS,
136
+ voiceFx: ['none', 'ai-mech', 'robot', 'radio', 'oldtv', 'dark'],
120
137
  transitionIn: TRANSITION_TYPES,
121
138
  transitionJoin: TRANSITION_TYPES,
122
139
  transitionOut: TRANSITION_TYPES,
@@ -125,6 +142,7 @@ const ENUMS = {
125
142
  const BOOL_KEYS = ['enabled', 'requireInteraction', 'allowSkip', 'sound', 'bedNoise', 'voice', 'voiceCue']
126
143
  /** 数值字段的合法区间(越界即夹取)。 */
127
144
  const RANGES = {
145
+ welcomeDelay: [0, 3000],
128
146
  speed: [0.35, 3],
129
147
  transitionMs: [200, 2000],
130
148
  soundVolume: [0, 1],
@@ -397,6 +415,36 @@ function apply(ctx) {
397
415
  }
398
416
  }))
399
417
 
418
+ /* 音色包:6 套扩展配音(英文 F + 日文 G–K)。设置页只拿 id/名称/语言,音频走只读白名单路由;
419
+ id 与文件名都白名单,且不接受子路径 —— 和 /asset 同样防目录穿越。 */
420
+ disposers.push(registerRoute({
421
+ kind: 'exact',
422
+ path: ROUTE_BASE + '/voices',
423
+ handler: (req, res) => sendJson(res, 200, { ok: true, packs: VOICE_PACKS, default: 'f' })
424
+ }))
425
+ disposers.push(registerRoute({
426
+ kind: 'prefix',
427
+ path: ROUTE_BASE + '/voice',
428
+ handler: (req, res) => {
429
+ const rest = decodeURIComponent(String(req.url || '').split('?')[0])
430
+ .slice((ROUTE_BASE + '/voice').length).replace(/^\/+/, '')
431
+ const parts = rest.split('/')
432
+ const id = String(parts[0] || '')
433
+ const name = String(parts[1] || '')
434
+ if (!/^[a-z]$/.test(id) || !/^line-[1-6]\.mp3$/.test(name) || !VOICE_PACK_IDS.includes(id)) {
435
+ res.writeHead(404); res.end(); return
436
+ }
437
+ let bytes
438
+ try { bytes = fs.readFileSync(path.join(VOICE_ROOT, id, name)) } catch (err) { res.writeHead(404); res.end(); return }
439
+ res.writeHead(200, {
440
+ 'Content-Type': 'audio/mpeg',
441
+ 'Cache-Control': 'no-store',
442
+ 'Content-Length': String(bytes.length)
443
+ })
444
+ res.end(bytes)
445
+ }
446
+ }))
447
+
400
448
  /* 模板自带声音:/dsh-startup/template-asset/<模板id>/<文件名>
401
449
  先找用户目录 ~/.dsh/startup-templates/<id>/assets/,再找插件自带 templates/<id>/assets/。
402
450
  id 与文件名都过白名单,且不接受子路径 —— 模板作者塞不进来任意文件读取。 */
@@ -601,11 +649,16 @@ function apply(ctx) {
601
649
  '<script src="' + ROUTE_BASE + '/splash.js"></script></body></html>'
602
650
  }
603
651
 
604
- /* 一键诊断:把环境信息写到桌面,让不会用 AI 的用户也能一键求助 */
652
+ /* 一键诊断:把环境信息写到桌面,让不会用 AI 的用户也能一键求助。
653
+ 同一时间只允许生成一份:按钮连点时后面的请求直接返回“正在生成中”,
654
+ 避免桌面被一堆同名报告刷屏。 */
655
+ let diagnoseBusy = false
605
656
  disposers.push(registerRoute({
606
657
  kind: 'exact',
607
658
  path: ROUTE_BASE + '/diagnose',
608
659
  handler: async (req, res) => {
660
+ if (diagnoseBusy) { sendJson(res, 429, { ok: false, error: '诊断报告正在生成中,请等几秒,不用重复点击。' }); return }
661
+ diagnoseBusy = true
609
662
  try {
610
663
  const lines = []
611
664
  const add = (s2) => lines.push(s2 == null ? '' : String(s2))
@@ -688,6 +741,8 @@ function apply(ctx) {
688
741
  sendJson(res, 200, { ok: true, file, desktop })
689
742
  } catch (err) {
690
743
  sendJson(res, 500, { ok: false, error: String((err && err.message) || err) })
744
+ } finally {
745
+ diagnoseBusy = false
691
746
  }
692
747
  }
693
748
  }))
@@ -1327,6 +1382,26 @@ function apply(ctx) {
1327
1382
  return out
1328
1383
  }
1329
1384
 
1385
+ /* 打开浏览器默认下载目录:等下载超时/找不到文件时,给用户一个直接可见的出口 */
1386
+ disposers.push(registerRoute({
1387
+ kind: 'exact',
1388
+ path: ROUTE_BASE + '/open-downloads',
1389
+ handler: async (req, res) => {
1390
+ if ((req.method || 'GET').toUpperCase() !== 'POST') { sendJson(res, 405, { ok: false, error: 'method not allowed' }); return }
1391
+ try {
1392
+ const dir = browserDownloadDirs()[0]
1393
+ try { fs.mkdirSync(dir, { recursive: true }) } catch (e) { /* 忽略 */ }
1394
+ if (process.platform === 'win32') spawn('explorer.exe', [dir], { detached: true, stdio: 'ignore' }).unref()
1395
+ else if (process.platform === 'darwin') spawn('open', [dir], { detached: true, stdio: 'ignore' }).unref()
1396
+ else spawn('xdg-open', [dir], { detached: true, stdio: 'ignore' }).unref()
1397
+ diag('open-downloads ' + dir)
1398
+ sendJson(res, 200, { ok: true, dir })
1399
+ } catch (err) {
1400
+ sendJson(res, 200, { ok: false, error: String((err && err.message) || err) })
1401
+ }
1402
+ }
1403
+ }))
1404
+
1330
1405
 
1331
1406
  /* 自动更新(浏览器代理路线):让系统默认浏览器去下载,插件只负责“接住”文件 */
1332
1407
  disposers.push(registerRoute({
@@ -1366,7 +1441,8 @@ function apply(ctx) {
1366
1441
  if (!/^[0-9][0-9A-Za-z.+-]{0,39}$/.test(version)) throw new Error('版本号不合法')
1367
1442
  const dirs = browserDownloadDirs()
1368
1443
  const since = (browserDownloadAt || Date.now()) - 120000
1369
- const deadline = Date.now() + 600000
1444
+ /* 30 分钟:国内网络下 24MB 的包偶尔要十几分钟,10 分钟太容易误判超时 */
1445
+ const deadline = Date.now() + 1800000
1370
1446
  diag('update-watch-download start dirs=' + dirs.join('|') + ' since=' + since)
1371
1447
  let found = null
1372
1448
  let bad = null
@@ -1375,7 +1451,8 @@ function apply(ctx) {
1375
1451
  try {
1376
1452
  if (!fs.existsSync(dir)) continue
1377
1453
  for (const f of fs.readdirSync(dir)) {
1378
- if (!/^dsh-boot-suite-win.*\.zip$/i.test(f)) continue
1454
+ /* 文件名放宽:Chrome 可能存成 “dsh-boot-suite-win-V2.13.12 (1).zip” 甚至带空格的名字 */
1455
+ if (!/dsh[-_ ]?boot[-_ ]?suite.*\.zip$/i.test(f)) continue
1379
1456
  const fp = path.join(dir, f)
1380
1457
  const st = fs.statSync(fp)
1381
1458
  if (!st.isFile() || st.size < 1024 * 1024) continue
@@ -1397,7 +1474,7 @@ function apply(ctx) {
1397
1474
  }
1398
1475
  if (!found) await new Promise((r) => setTimeout(r, 1500))
1399
1476
  }
1400
- if (!found) { diag('update-watch-download timeout'); sendJson(res, 200, { ok: false, error: '等待浏览器下载超时(10 分钟)。建议用 Chrome 重新点「立即更新」,或点「选择安装包」手动指定。' }); return }
1477
+ if (!found) { diag('update-watch-download timeout'); sendJson(res, 200, { ok: false, error: '等待浏览器下载超时(30 分钟)。可以点「打开下载文件夹」看看安装包是不是已经下好,再点「选择安装包」手动指定。' }); return }
1401
1478
  const raw = fs.readFileSync(found.file)
1402
1479
  if (!zipComplete(raw)) throw new Error('浏览器下载的安装包不完整(zip 校验失败),请重新下载')
1403
1480
  const outDir = path.join(DSH_HOME, 'boot-suite-updates')
@@ -63,7 +63,8 @@ const DEFAULTS = {
63
63
  videoMode: 'single', // single 手动选 / shuffle 随机轮换
64
64
  videoId: '', // 选中的视频 id(视频文件的绝对路径规范化)
65
65
  videoRoots: [], // 额外的视频存放目录(默认 loops 始终包含)
66
- videoShuffleMinutes: 0 // 0 = 每次启动随机;>0 = 每 N 分钟换一段
66
+ videoShuffleMinutes: 0, // 0 = 每次启动随机;>0 = 每 N 分钟换一段
67
+ colorMode: 'color' // color 彩色 | mono 黑白(原版素材,不染色)
67
68
  }
68
69
 
69
70
  function readConfig() {
@@ -407,6 +408,8 @@ export default {
407
408
  const cfg = readConfig()
408
409
  const cur = resolveImage(cfg)
409
410
  const dir = wallpaperImageDir()
411
+ /* v2.13.17:目录不存在时直接建出来,面板不再显示「?」,打开按钮也才有东西可开 */
412
+ try { mkdirSync(dir, { recursive: true }) } catch { /* 权限不足就保持原样 */ }
410
413
  json(res, 200, {
411
414
  ok: true,
412
415
  dropDir: dir.replace(/\\/g, '/'),
@@ -464,7 +467,7 @@ export default {
464
467
  * 只接受白名单键,值做长度/类型校验,避免面板把配置写成垃圾。
465
468
  */
466
469
  const ALLOWED_SET = new Set(['image', 'imageFit', 'veilLightA', 'veilLightB', 'veilDarkA', 'veilDarkB', 'veilBlur', 'banner', 'bannerOpacity',
467
- 'bgMode', 'videoMode', 'videoId', 'videoShuffleMinutes'])
470
+ 'bgMode', 'videoMode', 'videoId', 'videoShuffleMinutes', 'colorMode'])
468
471
  const setHandler = (req, res) => {
469
472
  let body = ''
470
473
  req.on('data', (c) => { body += c; if (body.length > 16384) req.destroy() })
@@ -483,6 +486,7 @@ export default {
483
486
  continue
484
487
  }
485
488
  if (k === 'bgMode') { if (v === 'image' || v === 'video') { cfg.bgMode = v; applied.bgMode = v } continue }
489
+ if (k === 'colorMode') { if (v === 'color' || v === 'mono') { cfg.colorMode = v; applied.colorMode = v } continue }
486
490
  if (k === 'videoMode') { if (v === 'single' || v === 'shuffle') { cfg.videoMode = v; applied.videoMode = v } continue }
487
491
  if (k === 'videoShuffleMinutes') {
488
492
  const n = Number(v)
@@ -691,6 +695,8 @@ export default {
691
695
  }
692
696
  }
693
697
 
698
+ /* v2.13.17:开机就把丢图目录建好;老用户/新装用户都不会再看到「?」 */
699
+ try { mkdirSync(wallpaperImageDir(), { recursive: true }) } catch { /* 忽略 */ }
694
700
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/config', handler: configHandler }))
695
701
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/image', handler: imageHandler }))
696
702
  ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: '/chat-wallpaper/images', handler: imagesHandler }))