dsh-music-player 0.6.0 → 0.6.1
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 +8 -2
- package/lib/client.js +292 -81
- package/lib/index.js +84 -0
- package/lib/qq.js +0 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -85,7 +85,13 @@ dsh plugin --profile <profile> add ./dsh-music-player-0.1.0.tgz
|
|
|
85
85
|
name: 'dsh-music-player'
|
|
86
86
|
```
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
播放模式与音量等播放偏好都保存在 **Host 端**(见下),刷新后当前曲目与进度也会恢复(浏览器的自动播放可能被拦截,点一次 ▶ 即可解锁)。
|
|
89
|
+
|
|
90
|
+
> **状态持久化(重要)**:**所有**播放状态都持久化在 Host 端文件 `~/.dsh/music-player-prefs.json`,包括:
|
|
91
|
+
> 音量、播放顺序、AI 讲书声音、播放范围、面板位置、上次播放的曲目/进度、每本小说的进度、QQ 搜索历史、QQ 面板所在层、QQ「我喜欢」收藏兜底。
|
|
92
|
+
> 因此即使在 **dsh-desktop 桌面版**(每次启动随机端口、浏览器存储按源隔离)下,重启后这些状态照样能找回。
|
|
93
|
+
> **升级兼容**:旧版本(<0.7)把同样的键存在浏览器 `localStorage` 里。升级后客户端**优先读 Host**,Host 没有的记录会**自动回退读取旧 `localStorage` 副本并迁移进 Host**,升级不丢用户数据。
|
|
94
|
+
> 音乐/小说目录、自建歌单、QQ 登录态也持久化在 Host 端(见下文),同样不受影响。
|
|
89
95
|
|
|
90
96
|
## 自建歌单(收藏)
|
|
91
97
|
|
|
@@ -119,7 +125,7 @@ dsh plugin --profile <profile> add ./dsh-music-player-0.1.0.tgz
|
|
|
119
125
|
- 分类歌单:60+ 分类(默认折叠显示 8 个,可展开),每个分类的歌单支持「加载更多」。
|
|
120
126
|
- 排行榜:巅峰榜/地区榜/特色榜等分组,点榜单看歌曲(带榜单封面卡片),榜单详情底部「加载更多」可分页续载全部歌曲。
|
|
121
127
|
- 新歌:新歌速递(最新/内地/港台/欧美/韩国等)。
|
|
122
|
-
- 搜索:搜歌曲与歌单,带搜索历史(
|
|
128
|
+
- 搜索:搜歌曲与歌单,带搜索历史(Host 持久化,最近 10 条)。
|
|
123
129
|
- **播放**:点击任意歌曲即可播放(同一播放条 + 频谱);VIP 标识显示在歌名后,行尾显示歌手名。进入歌单/播放列表时自动定位到正在播放的那一首,且正在播放的条目以高亮选中态显示。
|
|
124
130
|
- **收藏**:播放条爱心按钮把当前在线曲目收藏到 QQ 音乐「我喜欢」,已收藏歌曲爱心实时点亮。
|
|
125
131
|
- **续播**:在线播放进度(当前曲目 + 队列)刷新后自动恢复,点 ▶ 续播。
|
package/lib/client.js
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
* Audio is a native <audio> element. A real FFT spectrum (8 log-spaced bands)
|
|
8
8
|
* is computed offline from the decoded track (per 50ms window) and drawn on a
|
|
9
9
|
* canvas rAF loop as a smooth equalizer that follows the current playback
|
|
10
|
-
* position. Play mode and volume persist across reloads via
|
|
11
|
-
*
|
|
10
|
+
* position. Play mode and volume persist across reloads via the Host's prefs
|
|
11
|
+
* endpoint (/dsh-music/prefs — no browser storage); the current track +
|
|
12
|
+
* position are restored without autoplay (a tap on ▶ resumes).
|
|
12
13
|
* Host communication is plain HTTP to the /dsh-music/(manifest|intent|set-root|id)
|
|
13
14
|
* routes.
|
|
14
15
|
*/
|
|
@@ -80,6 +81,21 @@ window.__ModuleLoader__.load({
|
|
|
80
81
|
maxHeight: Math.round(Math.min(60 * vh / 100, 480, avail)),
|
|
81
82
|
};
|
|
82
83
|
};
|
|
84
|
+
// 搜索历史下拉专用:锚定在搜索框正下方(fixed,左边缘与搜索框对齐、宽度一致),
|
|
85
|
+
// portal 到 body 以避开 .dsh-music-panel(overflow:hidden) / .dsh-music-qq-body
|
|
86
|
+
// (overflow-y:auto) 的裁剪(否则下拉在真实浏览器里不可见)。无锚点时回退到
|
|
87
|
+
// 视口顶部中央。
|
|
88
|
+
const anchorBelow = (el, maxW = 420) => {
|
|
89
|
+
const vw = window.innerWidth;
|
|
90
|
+
const r = (el && typeof el.getBoundingClientRect === 'function') ? el.getBoundingClientRect() : null;
|
|
91
|
+
const margin = 8;
|
|
92
|
+
const width = Math.min((r && r.width > 0) ? r.width : 380, maxW, vw - margin * 2);
|
|
93
|
+
const left = (r && r.width > 0)
|
|
94
|
+
? Math.max(margin, Math.min(vw - margin - width, r.left))
|
|
95
|
+
: Math.max(margin, Math.round((vw - width) / 2));
|
|
96
|
+
const top = (r && r.height > 0) ? Math.round(r.bottom + 4) : margin;
|
|
97
|
+
return { position: 'fixed', left: Math.round(left), top, width: Math.round(width), maxHeight: 240 };
|
|
98
|
+
};
|
|
83
99
|
|
|
84
100
|
// 以播放面板中心为基准的固定定位样式(面板可拖拽,弹窗随其居中)。
|
|
85
101
|
// halfW 为目标弹窗的近似半宽;maxH 为弹窗最大高度(px):垂直 clamp 用
|
|
@@ -130,24 +146,196 @@ window.__ModuleLoader__.load({
|
|
|
130
146
|
}
|
|
131
147
|
})();
|
|
132
148
|
|
|
133
|
-
// ---- persisted prefs
|
|
149
|
+
// ---- persisted prefs (Host-backed, with legacy browser-storage fallback) ----
|
|
150
|
+
// Every pref lives in the Host's music-player-prefs.json, served via
|
|
151
|
+
// GET/POST /dsh-music/prefs and held in the in-memory `serverPrefs`
|
|
152
|
+
// snapshot here. dsh-desktop starts the Harness web server on a RANDOM port
|
|
153
|
+
// each launch, so the page origin changes every time — browser localStorage
|
|
154
|
+
// (keyed by origin) is therefore NOT authoritative and is never written.
|
|
155
|
+
// localStorage is kept only as a READ-ONLY upgrade source: old builds (<0.7)
|
|
156
|
+
// persisted these same key names there, so on upgrade we read Host first and
|
|
157
|
+
// fall back to the old browser copy, migrate it into the Host, then delete
|
|
158
|
+
// the browser copy. No user data is lost and no new data touches the browser.
|
|
134
159
|
const PREF_MODE = 'dsh-music-mode';
|
|
135
160
|
const PREF_VOL = 'dsh-music-volume';
|
|
136
161
|
const PREF_PLAYBACK = 'dsh-music-playback';
|
|
137
162
|
const PREF_BOOKS_PLAYBACK = 'dsh-music-books-playback';
|
|
138
|
-
const PREF_ROOT = 'dsh-music-root';
|
|
139
163
|
const PREF_PANEL_POS = 'dsh-music-panel-pos';
|
|
140
164
|
const PREF_VOICE = 'dsh-music-voice';
|
|
141
165
|
const PREF_SCOPE = 'dsh-music-scope';
|
|
142
|
-
const PREF_QQ_FAV = 'dsh-music-qq-fav'; // 「我喜欢」收藏 songid/songmid
|
|
143
|
-
const
|
|
144
|
-
const
|
|
145
|
-
|
|
166
|
+
const PREF_QQ_FAV = 'dsh-music-qq-fav'; // QQ「我喜欢」收藏 songid/songmid(Host 兜底)
|
|
167
|
+
const PREF_QQ_HISTORY = 'dsh-music-qq-history'; // QQ 搜索历史(最近在前,最多 10 条)
|
|
168
|
+
const PREF_QQ_UI = 'dsh-music-qq-ui'; // QQ 面板所在层/歌单 UI 状态
|
|
169
|
+
// Legacy single-book progress key (pre-0.2.1); migrated into the per-book
|
|
170
|
+
// map on upgrade so very old browser copies are not silently dropped.
|
|
171
|
+
const PREF_LEGACY_BOOK = 'dsh-music-book-playback';
|
|
172
|
+
// Every persisted key: written via savePref, read via loadPref, mirrored to
|
|
173
|
+
// the Host on a debounced flush.
|
|
174
|
+
const PREF_KEYS = new Set([
|
|
175
|
+
PREF_MODE, PREF_VOL, PREF_VOICE, PREF_SCOPE, PREF_PANEL_POS,
|
|
176
|
+
PREF_PLAYBACK, PREF_BOOKS_PLAYBACK, PREF_QQ_FAV, PREF_QQ_HISTORY, PREF_QQ_UI,
|
|
177
|
+
]);
|
|
178
|
+
let serverPrefs = null; // null = Host snapshot not fetched yet
|
|
179
|
+
let serverPrefsFetched = false; // distinguishes "not fetched" from an early savePref
|
|
180
|
+
const serverDirtyKeys = new Set(); // keys changed since the last flush
|
|
181
|
+
const serverRemoveKeys = new Set(); // keys cleared since the last flush
|
|
182
|
+
let serverFlushTimer = null;
|
|
183
|
+
let serverFlushSeq = 0;
|
|
184
|
+
// Legacy browser-storage accessors (safe; quota/security errors are ignored).
|
|
185
|
+
// localStorage is READ-ONLY here: it is only a source for upgrading old
|
|
186
|
+
// (<0.7) records into the Host. The client never writes new data to it.
|
|
187
|
+
const legacyGet = (k) => { try { return localStorage.getItem(k); } catch (e) { return null; } };
|
|
188
|
+
const legacyRemove = (k) => { try { localStorage.removeItem(k); } catch (e) {} };
|
|
189
|
+
function loadPref(k) {
|
|
190
|
+
if (!PREF_KEYS.has(k)) return null;
|
|
191
|
+
// Host is authoritative — it survives origin changes (random port).
|
|
192
|
+
if (serverPrefs !== null && Object.prototype.hasOwnProperty.call(serverPrefs, k)) {
|
|
193
|
+
return serverPrefs[k];
|
|
194
|
+
}
|
|
195
|
+
// Upgrade compatibility: if the Host has no record yet, fall back to the
|
|
196
|
+
// old browser localStorage copy (same key names). loadServerPrefs migrates
|
|
197
|
+
// it into the Host after the snapshot is fetched.
|
|
198
|
+
return legacyGet(k);
|
|
199
|
+
}
|
|
200
|
+
function savePref(k, v) {
|
|
201
|
+
if (!PREF_KEYS.has(k)) return;
|
|
202
|
+
serverPrefs = serverPrefs || {};
|
|
203
|
+
serverPrefs[k] = v;
|
|
204
|
+
serverRemoveKeys.delete(k);
|
|
205
|
+
serverDirtyKeys.add(k);
|
|
206
|
+
// Host-only: no mirror write to localStorage. All data lives in the Host.
|
|
207
|
+
scheduleServerPrefsFlush();
|
|
208
|
+
}
|
|
209
|
+
function clearPref(k) {
|
|
210
|
+
if (!PREF_KEYS.has(k)) return;
|
|
211
|
+
serverPrefs = serverPrefs || {};
|
|
212
|
+
delete serverPrefs[k];
|
|
213
|
+
serverDirtyKeys.delete(k);
|
|
214
|
+
serverRemoveKeys.add(k);
|
|
215
|
+
// Also drop the legacy browser copy — otherwise loadPref would resurrect
|
|
216
|
+
// a cleared value from localStorage on the next read.
|
|
217
|
+
legacyRemove(k);
|
|
218
|
+
scheduleServerPrefsFlush();
|
|
219
|
+
}
|
|
220
|
+
function scheduleServerPrefsFlush() {
|
|
221
|
+
if (serverFlushTimer !== null) return;
|
|
222
|
+
serverFlushTimer = setTimeout(() => { serverFlushTimer = null; void flushServerPrefs(); }, 800);
|
|
223
|
+
}
|
|
224
|
+
async function flushServerPrefs() {
|
|
225
|
+
const seq = ++serverFlushSeq;
|
|
226
|
+
const patch = {};
|
|
227
|
+
for (const k of serverDirtyKeys) {
|
|
228
|
+
if (serverRemoveKeys.has(k)) continue;
|
|
229
|
+
if (serverPrefs && Object.prototype.hasOwnProperty.call(serverPrefs, k)) patch[k] = serverPrefs[k];
|
|
230
|
+
}
|
|
231
|
+
const remove = [...serverRemoveKeys];
|
|
232
|
+
serverDirtyKeys.clear();
|
|
233
|
+
serverRemoveKeys.clear();
|
|
234
|
+
if (Object.keys(patch).length === 0 && remove.length === 0) return;
|
|
235
|
+
const payload = JSON.stringify({ prefs: patch, remove });
|
|
236
|
+
// keepalive survives page teardown (pagehide flush), but browsers cap a
|
|
237
|
+
// keepalive body at 64KiB and THROW on anything larger — a long QQ queue
|
|
238
|
+
// (the playback save embeds the whole queue) easily exceeds that, which
|
|
239
|
+
// silently dropped playback POSTs. Use keepalive only for small payloads;
|
|
240
|
+
// larger ones go out as a plain fetch (the periodic ~5s saves keep the
|
|
241
|
+
// state current, so a pagehide cutoff is not a real loss).
|
|
242
|
+
const useKeepalive = payload.length <= 60 * 1024;
|
|
243
|
+
try {
|
|
244
|
+
const r = await fetch('/dsh-music/prefs', {
|
|
245
|
+
method: 'POST', cache: 'no-store', keepalive: useKeepalive,
|
|
246
|
+
headers: { 'content-type': 'application/json' },
|
|
247
|
+
body: payload,
|
|
248
|
+
});
|
|
249
|
+
if (r.ok && seq === serverFlushSeq) {
|
|
250
|
+
const d = await r.json();
|
|
251
|
+
if (d && d.prefs && typeof d.prefs === 'object') {
|
|
252
|
+
// The server confirms our merge. Keep any value that changed locally
|
|
253
|
+
// while this request was in flight (dirty keys / pending removals win
|
|
254
|
+
// over the response), so a concurrent save is never overwritten.
|
|
255
|
+
serverPrefs = { ...d.prefs, ...serverPrefs };
|
|
256
|
+
for (const k of serverRemoveKeys) delete serverPrefs[k];
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
} catch { /* best-effort: the change stays in the in-memory snapshot */ }
|
|
260
|
+
}
|
|
261
|
+
// Fetch the Host snapshot once; merge any pre-fetch local writes on top so
|
|
262
|
+
// an early savePref (before the fetch resolves) is never lost. On a cold
|
|
263
|
+
// start the page can load before the plugin's routes are registered, so a
|
|
264
|
+
// failed/empty first read is retried briefly instead of silently skipping
|
|
265
|
+
// the whole restore (that was why "restart shows nothing" could happen).
|
|
266
|
+
async function loadServerPrefs() {
|
|
267
|
+
if (serverPrefsFetched) return serverPrefs;
|
|
268
|
+
const local = serverPrefs; // possibly written by an early savePref
|
|
269
|
+
let got = false; // route responded with a valid prefs shape
|
|
270
|
+
for (let attempt = 0; attempt < 4 && !got; attempt++) {
|
|
271
|
+
let fetched = {};
|
|
272
|
+
try {
|
|
273
|
+
const d = await jsonGet('/dsh-music/prefs');
|
|
274
|
+
if (d && d.prefs && typeof d.prefs === 'object') { fetched = d.prefs; got = true; }
|
|
275
|
+
} catch { /* transient: route not ready yet */ }
|
|
276
|
+
serverPrefs = { ...fetched, ...(local || {}) };
|
|
277
|
+
if (!got && attempt < 3) await new Promise((r) => setTimeout(r, 250));
|
|
278
|
+
}
|
|
279
|
+
serverPrefsFetched = true;
|
|
280
|
+
migrateLegacyBrowserPrefs();
|
|
281
|
+
return serverPrefs;
|
|
282
|
+
}
|
|
283
|
+
// Upgrade path: adopt any old localStorage record the Host does not yet have
|
|
284
|
+
// (so a <0.7 browser copy survives the move to Host storage), and fold the
|
|
285
|
+
// legacy single-book key into the per-book map. Runs once, after the Host
|
|
286
|
+
// snapshot is fetched, so Host values always win. Once a record is adopted
|
|
287
|
+
// its browser copy is removed — localStorage is a read-only migration source
|
|
288
|
+
// and never holds data after it has been claimed by the Host.
|
|
289
|
+
function migrateLegacyBrowserPrefs() {
|
|
290
|
+
// Legacy single-book progress (pre-0.2.1) → per-book map.
|
|
291
|
+
try {
|
|
292
|
+
const legacyRaw = legacyGet(PREF_LEGACY_BOOK);
|
|
293
|
+
if (legacyRaw) {
|
|
294
|
+
const p = JSON.parse(legacyRaw);
|
|
295
|
+
if (p && typeof p.id === 'string' && typeof p.name === 'string' && p.name !== '') {
|
|
296
|
+
const map = readBooksPlayback();
|
|
297
|
+
if (!Object.prototype.hasOwnProperty.call(map, p.name)) {
|
|
298
|
+
map[p.name] = { from: p.from, base: p.base, pos: p.pos, total: p.total, ts: p.ts || Date.now() };
|
|
299
|
+
writeBooksPlayback(map);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
legacyRemove(PREF_LEGACY_BOOK);
|
|
303
|
+
}
|
|
304
|
+
} catch (e) { /* malformed legacy copy: ignore */ }
|
|
305
|
+
// Any other legacy key: if the Host lacks it, adopt the browser copy (Host
|
|
306
|
+
// becomes authoritative) and remove the browser copy; if the Host already
|
|
307
|
+
// has it, just drop the stale browser duplicate. Either way localStorage
|
|
308
|
+
// ends up holding none of the managed prefs — it is only a one-way upgrade
|
|
309
|
+
// source and never keeps data once the Host snapshot has claimed it.
|
|
310
|
+
for (const k of PREF_KEYS) {
|
|
311
|
+
if (Object.prototype.hasOwnProperty.call(serverPrefs, k)) {
|
|
312
|
+
legacyRemove(k);
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const v = legacyGet(k);
|
|
316
|
+
if (v !== null) {
|
|
317
|
+
savePref(k, v);
|
|
318
|
+
legacyRemove(k);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
// Apply persisted mode / volume / voice to the store + audio element.
|
|
323
|
+
// Mutates directly (no set()) so a pre-startup call does not re-trigger
|
|
324
|
+
// savePref; a later set() from loadTracks re-renders the UI.
|
|
325
|
+
function applyStoredPrefs() {
|
|
326
|
+
try {
|
|
327
|
+
const m = loadPref(PREF_MODE);
|
|
328
|
+
if (m === 'single' || m === 'order' || m === 'shuffle') store.mode = m;
|
|
329
|
+
const v = parseFloat(loadPref(PREF_VOL));
|
|
330
|
+
if (Number.isFinite(v)) { store.volume = Math.min(1, Math.max(0, v)); audio.volume = store.volume; }
|
|
331
|
+
const voice = loadPref(PREF_VOICE);
|
|
332
|
+
if (typeof voice === 'string' && voice !== '') store.voice = voice;
|
|
333
|
+
} catch (e) {}
|
|
334
|
+
}
|
|
146
335
|
// ---- per-book novel progress (independent from music) ----
|
|
147
336
|
// Every novel remembers its own position, keyed by its filename, so switching
|
|
148
337
|
// between books — or to music — never loses another book's place. Music
|
|
149
338
|
// progress lives in PREF_PLAYBACK and this subsystem never touches it.
|
|
150
|
-
const PREF_BOOK_PLAYBACK = 'dsh-music-book-playback'; // legacy single-book key
|
|
151
339
|
function readBooksPlayback() {
|
|
152
340
|
try {
|
|
153
341
|
const raw = loadPref(PREF_BOOKS_PLAYBACK);
|
|
@@ -191,6 +379,12 @@ window.__ModuleLoader__.load({
|
|
|
191
379
|
const PANEL_MAX_W = 720;
|
|
192
380
|
const PANEL_MIN_H = 200;
|
|
193
381
|
const PANEL_MAX_H_VH = 0.8;
|
|
382
|
+
// Auto-size (never-dragged) default height. The panel's height is content-
|
|
383
|
+
// driven (CSS max-height:72vh), but a fresh install has an empty track list
|
|
384
|
+
// whose min-height is only ~60px, so the panel would open absurdly short.
|
|
385
|
+
// Give the auto-size panel a comfortable default minimum so first-use looks
|
|
386
|
+
// right; this only applies while pos === null (never dragged/resized).
|
|
387
|
+
const PANEL_AUTO_MIN_H = '45vh';
|
|
194
388
|
// Restore the playback-panel position ({x,y,w,h}) previously saved by
|
|
195
389
|
// dragging/resizing, if any. Old saves ({x,y,h}) migrate with a default width.
|
|
196
390
|
function loadPanelPos() {
|
|
@@ -261,6 +455,9 @@ window.__ModuleLoader__.load({
|
|
|
261
455
|
playing: false, position: 0, duration: 0, volume: 0.8,
|
|
262
456
|
panelOpen: false, loading: false, error: null, pendingId: null, pendingName: null,
|
|
263
457
|
mode: 'order', vizState: 'ok', tab: 'music',
|
|
458
|
+
// true once the Host prefs snapshot is loaded (volume/mode/playback from
|
|
459
|
+
// /dsh-music/prefs are authoritative); panel position re-applies on it.
|
|
460
|
+
prefsReady: false,
|
|
264
461
|
bookBuffering: false, bookError: '', bookBufferingSince: 0, bookBufferingSilent: false,
|
|
265
462
|
// chapter table of contents (book reader): section list of the current book,
|
|
266
463
|
// whether the toc popup is open, and the heading of the section now playing.
|
|
@@ -271,8 +468,8 @@ window.__ModuleLoader__.load({
|
|
|
271
468
|
// 播放模式弹层是否打开(portal 到 body 时让播放条按钮保持展开、不因移出而收起)。
|
|
272
469
|
modeMenuOpen: false,
|
|
273
470
|
// AI 讲书 TTS voice: available voices come from /manifest, the selection
|
|
274
|
-
// persists in
|
|
275
|
-
// voiceSwitching = a new voice is being synthesized
|
|
471
|
+
// persists in the Host prefs and rides the chunk URL so the host
|
|
472
|
+
// re-synthesizes. voiceSwitching = a new voice is being synthesized.
|
|
276
473
|
voices: [], voice: '白桦', voiceSwitching: false,
|
|
277
474
|
// 自建歌单:manifest.playlists 即数据源;scope 为当前播放范围(曲库/歌单),
|
|
278
475
|
// subTab 为音乐页内的子标签('library' 或歌单 id)。
|
|
@@ -579,31 +776,9 @@ window.__ModuleLoader__.load({
|
|
|
579
776
|
apiPlaylistReorder(pl.id, next);
|
|
580
777
|
}
|
|
581
778
|
|
|
582
|
-
// restore persisted prefs
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
if (m === 'single' || m === 'order' || m === 'shuffle') store.mode = m;
|
|
586
|
-
const v = parseFloat(loadPref(PREF_VOL));
|
|
587
|
-
if (Number.isFinite(v)) { store.volume = Math.min(1, Math.max(0, v)); audio.volume = store.volume; }
|
|
588
|
-
const voice = loadPref(PREF_VOICE);
|
|
589
|
-
if (typeof voice === 'string' && voice !== '') store.voice = voice;
|
|
590
|
-
} catch (e) {}
|
|
591
|
-
|
|
592
|
-
// Migrate legacy single-book progress (pre-0.2.1) into the per-book map once.
|
|
593
|
-
try {
|
|
594
|
-
const legacy = loadPref(PREF_BOOK_PLAYBACK);
|
|
595
|
-
if (legacy) {
|
|
596
|
-
const p = JSON.parse(legacy);
|
|
597
|
-
if (p && typeof p.id === 'string' && typeof p.name === 'string' && p.name !== '') {
|
|
598
|
-
const map = readBooksPlayback();
|
|
599
|
-
if (!Object.prototype.hasOwnProperty.call(map, p.name)) {
|
|
600
|
-
map[p.name] = { from: p.from, base: p.base, pos: p.pos, total: p.total, ts: p.ts || Date.now() };
|
|
601
|
-
writeBooksPlayback(map);
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
clearPref(PREF_BOOK_PLAYBACK);
|
|
605
|
-
}
|
|
606
|
-
} catch (e) {}
|
|
779
|
+
// restore persisted prefs (mode/volume/voice). Runs again after the Host
|
|
780
|
+
// snapshot is fetched (loadTracks), where the Host value is authoritative.
|
|
781
|
+
applyStoredPrefs();
|
|
607
782
|
|
|
608
783
|
// Persist the current playback position. Music and novels are fully separate:
|
|
609
784
|
// music writes PREF_PLAYBACK, novels write into the per-book map — neither
|
|
@@ -1254,8 +1429,8 @@ window.__ModuleLoader__.load({
|
|
|
1254
1429
|
const b = bookById(id);
|
|
1255
1430
|
if (b === null) return null;
|
|
1256
1431
|
// The chosen TTS voice rides the URL so the host re-synthesizes with it.
|
|
1257
|
-
// Only send voices we know (stale
|
|
1258
|
-
// then falls back to its default 白桦).
|
|
1432
|
+
// Only send voices we know (stale persisted voice selections are dropped;
|
|
1433
|
+
// the host then falls back to its default 白桦).
|
|
1259
1434
|
const known = (store.voices && store.voices.length > 0) ? store.voices : FALLBACK_VOICES;
|
|
1260
1435
|
const v = store.voice && known.some((x) => x.id === store.voice) ? '&voice=' + encodeURIComponent(store.voice) : '';
|
|
1261
1436
|
return b.url + '?from=' + from + v;
|
|
@@ -1959,15 +2134,14 @@ window.__ModuleLoader__.load({
|
|
|
1959
2134
|
async function loadTracks() {
|
|
1960
2135
|
set({ loading: true });
|
|
1961
2136
|
try {
|
|
2137
|
+
// Load the Host prefs snapshot first so every restore below reads the
|
|
2138
|
+
// authoritative values (they survive dsh-desktop's random-port origin
|
|
2139
|
+
// changes). Re-apply mode/volume/voice on top of whatever the
|
|
2140
|
+
// synchronous startup restore already applied.
|
|
2141
|
+
await loadServerPrefs();
|
|
2142
|
+
applyStoredPrefs();
|
|
2143
|
+
set({ prefsReady: true });
|
|
1962
2144
|
const result = await jsonGet('/dsh-music/manifest');
|
|
1963
|
-
const rememberedRoot = loadPref(PREF_ROOT);
|
|
1964
|
-
// If the host came up with the default root but this browser remembers
|
|
1965
|
-
// a different one (e.g. the host state file was not yet written on an
|
|
1966
|
-
// older restart), re-apply it so the chosen directory is restored.
|
|
1967
|
-
if (rememberedRoot !== null && rememberedRoot !== '' && result.root !== rememberedRoot) {
|
|
1968
|
-
saveRoot(rememberedRoot);
|
|
1969
|
-
return;
|
|
1970
|
-
}
|
|
1971
2145
|
set({
|
|
1972
2146
|
root: result.root || null, bookRoot: result.bookRoot || null,
|
|
1973
2147
|
tracks: result.tracks || [], books: result.books || [],
|
|
@@ -1994,7 +2168,6 @@ window.__ModuleLoader__.load({
|
|
|
1994
2168
|
body: JSON.stringify({ path }),
|
|
1995
2169
|
}).then((r) => r.json()).then((result) => {
|
|
1996
2170
|
if (result && result.ok) {
|
|
1997
|
-
if (result.root) savePref(PREF_ROOT, result.root);
|
|
1998
2171
|
set({
|
|
1999
2172
|
root: result.root || null, bookRoot: result.bookRoot || null,
|
|
2000
2173
|
tracks: result.tracks || [], books: result.books || [],
|
|
@@ -2531,8 +2704,8 @@ window.__ModuleLoader__.load({
|
|
|
2531
2704
|
}
|
|
2532
2705
|
}
|
|
2533
2706
|
|
|
2534
|
-
// 在线 QQ
|
|
2535
|
-
// (QQ 音乐 ↔ 本地音乐/AI
|
|
2707
|
+
// 在线 QQ 面板的「上次所在层」只在本次会话首次恢复一次,避免切 tab
|
|
2708
|
+
// (QQ 音乐 ↔ 本地音乐/AI讲书)重新挂载时又被持久化的旧层拉回播放列表页。
|
|
2536
2709
|
let qqUiRestored = false;
|
|
2537
2710
|
function QQOnlinePanel({ panelRef }) {
|
|
2538
2711
|
const s = useStore();
|
|
@@ -2561,10 +2734,11 @@ window.__ModuleLoader__.load({
|
|
|
2561
2734
|
const [plSearchPage, setPlSearchPage] = useState(1);
|
|
2562
2735
|
const [plSearchLastLen, setPlSearchLastLen] = useState(0);
|
|
2563
2736
|
const [plSearchingMore, setPlSearchingMore] = useState(false);
|
|
2564
|
-
// 搜索历史(
|
|
2737
|
+
// 搜索历史(Host 持久化,最近在前)。
|
|
2565
2738
|
const [hist, setHist] = useState([]);
|
|
2566
2739
|
const [histOpen, setHistOpen] = useState(false);
|
|
2567
2740
|
const histRef = useRef(null);
|
|
2741
|
+
const histPopRef = useRef(null); // portal 到 body 的历史下拉(点击内部不关闭)
|
|
2568
2742
|
// 浏览。
|
|
2569
2743
|
const [minePlays, setMinePlays] = useState([]);
|
|
2570
2744
|
const [mineLoaded, setMineLoaded] = useState(false);
|
|
@@ -2629,22 +2803,20 @@ window.__ModuleLoader__.load({
|
|
|
2629
2803
|
const qqPlRef = useRef(null);
|
|
2630
2804
|
|
|
2631
2805
|
const json = (url) => jsonGet(url).catch(() => ({ ok: false, error: '网络错误' }));
|
|
2632
|
-
// 搜索历史(
|
|
2633
|
-
const
|
|
2634
|
-
const loadHist = () => { try { const a = JSON.parse(localStorage.getItem(HIST_KEY)); return Array.isArray(a) ? a.filter((x) => typeof x === 'string' && x.trim()) : []; } catch { return []; } };
|
|
2806
|
+
// 搜索历史(Host 持久化,最近在前,最多 10 条)。
|
|
2807
|
+
const loadHist = () => { try { const a = JSON.parse(loadPref(PREF_QQ_HISTORY)); return Array.isArray(a) ? a.filter((x) => typeof x === 'string' && x.trim()) : []; } catch { return []; } };
|
|
2635
2808
|
const saveHist = (kw) => {
|
|
2636
2809
|
kw = (kw || '').trim();
|
|
2637
2810
|
if (!kw) return;
|
|
2638
2811
|
const next = [kw, ...loadHist().filter((x) => x !== kw)].slice(0, 10);
|
|
2639
|
-
|
|
2812
|
+
savePref(PREF_QQ_HISTORY, JSON.stringify(next));
|
|
2640
2813
|
setHist(next);
|
|
2641
2814
|
};
|
|
2642
|
-
const clearHist = () => {
|
|
2815
|
+
const clearHist = () => { clearPref(PREF_QQ_HISTORY); setHist([]); };
|
|
2643
2816
|
const clearPoll = () => { if (pollRef.current !== null) { try { clearInterval(pollRef.current); } catch {} try { clearTimeout(pollRef.current); } catch {} pollRef.current = null; } };
|
|
2644
2817
|
// 记住当前操作所在层(主UI / 播放列表UI),下次弹窗恢复。
|
|
2645
|
-
const
|
|
2646
|
-
const
|
|
2647
|
-
const loadUi = () => { try { return JSON.parse(localStorage.getItem(UI_KEY)); } catch { return null } };
|
|
2818
|
+
const saveUi = (layer2, plId, plName) => { savePref(PREF_QQ_UI, JSON.stringify({ layer: layer2, plId: plId || '', plName: plName || '' })); };
|
|
2819
|
+
const loadUi = () => { try { return JSON.parse(loadPref(PREF_QQ_UI)); } catch { return null } };
|
|
2648
2820
|
const restoreUi = (ui) => {
|
|
2649
2821
|
if (!ui || ui.layer !== 'playlist') return;
|
|
2650
2822
|
setLayer('playlist');
|
|
@@ -2654,18 +2826,30 @@ window.__ModuleLoader__.load({
|
|
|
2654
2826
|
setActivePl({ name: s.qqSource || '在线播放列表', songs: s.qqQueue || [], source: s.qqSource || '在线' });
|
|
2655
2827
|
}
|
|
2656
2828
|
};
|
|
2657
|
-
// ① 挂载时只做本地初始化:读搜索历史(
|
|
2829
|
+
// ① 挂载时只做本地初始化:读搜索历史(Host 持久化)+ 查登录态(status 由
|
|
2658
2830
|
// host 读本地 cookie 文件,不发任何外部网络请求)。外部数据(分类/推荐/我的
|
|
2659
2831
|
// 歌单)一律延后到登录后(见下方 [loggedIn] effect),未登录零外部请求——
|
|
2660
2832
|
// 本插件 QQ 在线功能以「登录」为门槛。
|
|
2661
2833
|
useEffect(() => {
|
|
2662
2834
|
setHist(loadHist());
|
|
2663
2835
|
jsonGet('/dsh-music/qq/status').then((d) => { if (d) { setLoggedIn(!!d.loggedIn); setUin(d.uin || ''); setNickname(d.nickname || ''); } }).catch(() => {});
|
|
2664
|
-
//
|
|
2665
|
-
const onDocClick = (e) => {
|
|
2836
|
+
// 点击搜索框外时关闭历史下拉(含 portal 到 body 的下拉本身)。
|
|
2837
|
+
const onDocClick = (e) => {
|
|
2838
|
+
if (histRef.current !== null && histRef.current.contains(e.target)) return;
|
|
2839
|
+
if (histPopRef.current !== null && histPopRef.current.contains(e.target)) return;
|
|
2840
|
+
setHistOpen(false);
|
|
2841
|
+
};
|
|
2666
2842
|
document.addEventListener('mousedown', onDocClick);
|
|
2667
2843
|
return () => { clearPoll(); document.removeEventListener('mousedown', onDocClick); };
|
|
2668
2844
|
}, []);
|
|
2845
|
+
// Host 预置快照异步到达:一旦就绪,重读搜索历史并恢复上次所在层(面板可能
|
|
2846
|
+
// 先于 Host 快照挂载,挂载时读到的历史/层可能还是空的)。
|
|
2847
|
+
useEffect(() => {
|
|
2848
|
+
if (!s.prefsReady) return;
|
|
2849
|
+
setHist(loadHist());
|
|
2850
|
+
if (!qqUiRestored && loggedIn) { qqUiRestored = true; restoreUi(loadUi()); }
|
|
2851
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2852
|
+
}, [s.prefsReady, loggedIn]);
|
|
2669
2853
|
// ② 登录态变化时才加载在线数据:未登录(loggedIn=false)一个外部请求都不发;
|
|
2670
2854
|
// 登录成功(setLoggedIn(true))或已登录刷新页面(status 返回 true)时自动加载
|
|
2671
2855
|
// 分类 / 我的歌单 / 推荐,并恢复上次所在层。登出后不再加载。
|
|
@@ -2675,8 +2859,10 @@ window.__ModuleLoader__.load({
|
|
|
2675
2859
|
// 默认落在「我的歌单」tab:登录态下加载我的歌单;未登录时主 UI 不显示。
|
|
2676
2860
|
loadMine();
|
|
2677
2861
|
loadRecommended();
|
|
2678
|
-
//
|
|
2679
|
-
|
|
2862
|
+
// 仅当 Host 预置就绪且本会话尚未恢复时,恢复上次所在层;之后切 tab 重挂
|
|
2863
|
+
// 不再拉回旧层。若此刻预置还没到(面板先挂载),留给上方 [prefsReady]
|
|
2864
|
+
// effect 在快照到达后恢复。
|
|
2865
|
+
if (!qqUiRestored && s.prefsReady) { qqUiRestored = true; restoreUi(loadUi()); }
|
|
2680
2866
|
}, [loggedIn]);
|
|
2681
2867
|
// ③ 收藏/取消收藏「我喜欢」后刷新「我的歌单」,让「我喜欢」歌单数目随之更新。
|
|
2682
2868
|
useEffect(() => {
|
|
@@ -3083,17 +3269,20 @@ window.__ModuleLoader__.load({
|
|
|
3083
3269
|
onKeyDown: (e) => { if (e.key === 'Enter') doSearch(); },
|
|
3084
3270
|
onFocus: () => { if (hist.length > 0) setHistOpen(true); },
|
|
3085
3271
|
}),
|
|
3086
|
-
React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => doSearch() }, searching ? '搜索中…' : '搜索')
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3272
|
+
React.createElement('button', { className: 'dsh-music-settings-btn', onClick: () => doSearch() }, searching ? '搜索中…' : '搜索'));
|
|
3273
|
+
// 历史下拉 portal 到 body:搜索框在 .dsh-music-panel(overflow:hidden)/.dsh-music-qq-body
|
|
3274
|
+
// (overflow-y:auto) 内,绝对定位子元素会被裁剪不可见;portaled + fixed 锚定
|
|
3275
|
+
// 在搜索框正下方(与音量/模式/章节目录弹层同款逃逸方案)。
|
|
3276
|
+
const histPop = (histOpen && hist.length > 0)
|
|
3277
|
+
? portalToBody(React.createElement('div', { className: 'dsh-music-qq-hist', style: anchorBelow(histRef.current, 420), ref: histPopRef },
|
|
3278
|
+
React.createElement('div', { className: 'dsh-music-qq-hist-head' },
|
|
3279
|
+
React.createElement('span', { className: 'dsh-music-hint' }, '搜索历史'),
|
|
3280
|
+
React.createElement('button', { className: 'dsh-music-qq-hist-clear', title: '清空历史', onClick: clearHist }, '清空')),
|
|
3281
|
+
hist.map((kw, idx) => React.createElement('button', {
|
|
3282
|
+
key: idx, className: 'dsh-music-qq-hist-item',
|
|
3283
|
+
onClick: () => { setQ(kw); doSearch(kw); },
|
|
3284
|
+
}, kw))))
|
|
3285
|
+
: null;
|
|
3097
3286
|
|
|
3098
3287
|
// 扫码框以面板中心为基准居中(面板可拖拽),贴边时 clamp 不被裁掉。
|
|
3099
3288
|
const qqLoginStyle = panelCenterStyle(panelRef, loginMode !== null, 170, 460);
|
|
@@ -3192,7 +3381,7 @@ window.__ModuleLoader__.load({
|
|
|
3192
3381
|
} else {
|
|
3193
3382
|
resultContent = React.createElement('div', null, plResults.map((p) => playRow(p)), plMoreBtn);
|
|
3194
3383
|
}
|
|
3195
|
-
body = React.createElement('div', null, searchBox,
|
|
3384
|
+
body = React.createElement('div', null, searchBox, histPop,
|
|
3196
3385
|
searched ? React.createElement('div', null, resultContent) : null);
|
|
3197
3386
|
} else {
|
|
3198
3387
|
let content;
|
|
@@ -3330,6 +3519,15 @@ window.__ModuleLoader__.load({
|
|
|
3330
3519
|
// Draggable panel position + size ({x, y, w, h} left/top/width/height once
|
|
3331
3520
|
// dragged or resized; null = CSS default: centered, 380px, auto height).
|
|
3332
3521
|
const [pos, setPos] = useState(loadPanelPos);
|
|
3522
|
+
// The Host prefs snapshot arrives async (loadTracks -> loadServerPrefs).
|
|
3523
|
+
// Once it is ready, re-apply the persisted panel geometry — all prefs are
|
|
3524
|
+
// Host-backed now, so the mount-time value above comes from the snapshot.
|
|
3525
|
+
useEffect(() => {
|
|
3526
|
+
if (!s.prefsReady) return;
|
|
3527
|
+
const next = loadPanelPos();
|
|
3528
|
+
if (next !== null) setPos(next);
|
|
3529
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3530
|
+
}, [s.prefsReady]);
|
|
3333
3531
|
const dragRef = useRef(null); // head-drag state
|
|
3334
3532
|
const resizeRef = useRef(null); // corner-resize state
|
|
3335
3533
|
// 曲库每行「+」打开的「加入歌单」菜单:{track, x, y}(锚点=按钮右上角视口坐标)。
|
|
@@ -3348,7 +3546,7 @@ window.__ModuleLoader__.load({
|
|
|
3348
3546
|
// 面板常驻不卸载:关闭时仅用 display:none 隐藏(子树、QQ 面板状态全保留),
|
|
3349
3547
|
// 重新打开时按播放类别重设 tab(见 togglePanel)并恢复显示。因此组件不会
|
|
3350
3548
|
// 在关闭时 unmount,切 tab / 关面板重开都不会丢内部 useState 状态。
|
|
3351
|
-
const rootStyle = { ...(pos === null ?
|
|
3549
|
+
const rootStyle = { ...(pos === null ? { minHeight: PANEL_AUTO_MIN_H } : { left: pos.x, top: pos.y, width: pos.w, height: pos.h, maxHeight: 'none', transform: 'none' }), display: s.panelOpen ? '' : 'none' };
|
|
3352
3550
|
|
|
3353
3551
|
const onHeadDown = (e) => {
|
|
3354
3552
|
if (e.button !== undefined && e.button !== 0) return;
|
|
@@ -3434,13 +3632,22 @@ window.__ModuleLoader__.load({
|
|
|
3434
3632
|
if (!s.panelOpen) return;
|
|
3435
3633
|
// Close the playlist panel when the user clicks outside it
|
|
3436
3634
|
// (mousedown precedes the toggle's click, so both stay consistent).
|
|
3437
|
-
//
|
|
3438
|
-
//
|
|
3439
|
-
//
|
|
3440
|
-
//
|
|
3635
|
+
// Several popups are PORTALED to <body> (to escape the panel's
|
|
3636
|
+
// overflow:hidden clipping): directory/file pickers, the「加入歌单」menu,
|
|
3637
|
+
// the QQ search-history dropdown, the chapter TOC, and the bar's
|
|
3638
|
+
// mode/volume popups. A click inside any of them is technically outside
|
|
3639
|
+
// the panel's DOM, but they are part of the panel/bar UI — treat them as
|
|
3640
|
+
// "inside" so interacting never closes the panel underneath.
|
|
3441
3641
|
const onDown = (e) => {
|
|
3442
3642
|
if (panelRef.current !== null && !panelRef.current.contains(e.target)
|
|
3443
|
-
&& !(e.target.closest && (
|
|
3643
|
+
&& !(e.target.closest && (
|
|
3644
|
+
e.target.closest('.dsh-music-picker-overlay')
|
|
3645
|
+
|| e.target.closest('.dsh-music-add-pop')
|
|
3646
|
+
|| e.target.closest('.dsh-music-qq-hist')
|
|
3647
|
+
|| e.target.closest('.dsh-music-toc')
|
|
3648
|
+
|| e.target.closest('.dsh-music-mode-pop')
|
|
3649
|
+
|| e.target.closest('.dsh-music-bar-vol-pop')
|
|
3650
|
+
))) {
|
|
3444
3651
|
set({ panelOpen: false });
|
|
3445
3652
|
}
|
|
3446
3653
|
};
|
|
@@ -3870,6 +4077,10 @@ window.__ModuleLoader__.load({
|
|
|
3870
4077
|
const onPageHide = () => {
|
|
3871
4078
|
try { audio.pause(); } catch (e) {}
|
|
3872
4079
|
try { preAudio.pause(); } catch (e) {}
|
|
4080
|
+
// Best-effort flush of pending prefs to the Host before teardown
|
|
4081
|
+
// (fetch uses keepalive; if the request is cut off, the periodic and
|
|
4082
|
+
// debounced flushes have normally already persisted the state).
|
|
4083
|
+
void flushServerPrefs();
|
|
3873
4084
|
};
|
|
3874
4085
|
document.addEventListener('visibilitychange', onVis);
|
|
3875
4086
|
window.addEventListener('pagehide', onPageHide);
|
package/lib/index.js
CHANGED
|
@@ -1163,6 +1163,69 @@ export function apply(ctx) {
|
|
|
1163
1163
|
}
|
|
1164
1164
|
const findPlaylist = (id) => playlists.find((p) => p.id === id) || null
|
|
1165
1165
|
|
|
1166
|
+
// ---- 播放偏好持久化(volume / mode / playback / 小说进度 / QQ UI)----
|
|
1167
|
+
// 这些状态原本存在浏览器 localStorage,而 localStorage 按「源(origin)」隔离。
|
|
1168
|
+
// dsh-desktop 每次启动给 Harness Web 服务分配一个随机端口,导致源每次变化、
|
|
1169
|
+
// localStorage 每次都读不到——重启后音量/播放顺序/上次播放内容全丢。现在
|
|
1170
|
+
// 所有持久化状态统一存 Host 端文件(DSH_HOME 固定不变,DSH 进程重启不丢),
|
|
1171
|
+
// 客户端启动时 GET /prefs 以快照为准恢复,改动经 POST /prefs 合并写回。
|
|
1172
|
+
// 值一律以字符串存储,与客户端 loadPref 对齐。
|
|
1173
|
+
let serverPrefs = {} // { 'dsh-music-volume': '0.8', 'dsh-music-mode': 'order', ... }
|
|
1174
|
+
const PREF_ALLOW = new Set([
|
|
1175
|
+
'dsh-music-mode', 'dsh-music-volume', 'dsh-music-voice', 'dsh-music-scope',
|
|
1176
|
+
'dsh-music-panel-pos', 'dsh-music-playback', 'dsh-music-books-playback',
|
|
1177
|
+
'dsh-music-qq-fav', 'dsh-music-qq-history', 'dsh-music-qq-ui',
|
|
1178
|
+
])
|
|
1179
|
+
const PREF_VALUE_MAX = 256 * 1024 // 单键上限(books 进度 map / QQ 队列可能较大)
|
|
1180
|
+
const sanitizePrefs = (input) => {
|
|
1181
|
+
const out = {}
|
|
1182
|
+
if (!input || typeof input !== 'object') return out
|
|
1183
|
+
for (const k of Object.keys(input)) {
|
|
1184
|
+
if (!PREF_ALLOW.has(k)) continue
|
|
1185
|
+
const v = input[k]
|
|
1186
|
+
if (typeof v !== 'string' || v === '' || v.length > PREF_VALUE_MAX) continue
|
|
1187
|
+
if (k === 'dsh-music-volume') {
|
|
1188
|
+
const n = Number(v)
|
|
1189
|
+
if (!Number.isFinite(n)) continue
|
|
1190
|
+
out[k] = String(Math.min(1, Math.max(0, n)))
|
|
1191
|
+
continue
|
|
1192
|
+
}
|
|
1193
|
+
if (k === 'dsh-music-mode') {
|
|
1194
|
+
if (v !== 'single' && v !== 'order' && v !== 'shuffle') continue
|
|
1195
|
+
out[k] = v
|
|
1196
|
+
continue
|
|
1197
|
+
}
|
|
1198
|
+
out[k] = v
|
|
1199
|
+
}
|
|
1200
|
+
return out
|
|
1201
|
+
}
|
|
1202
|
+
const prefsFile = async () => {
|
|
1203
|
+
const h = await getHome()
|
|
1204
|
+
const base = (process.env.DSH_HOME) || (h === null ? null : h + '/.dsh')
|
|
1205
|
+
return base === null ? null : base + '/music-player-prefs.json'
|
|
1206
|
+
}
|
|
1207
|
+
const loadPrefs = async () => {
|
|
1208
|
+
const file = await prefsFile()
|
|
1209
|
+
serverPrefs = {}
|
|
1210
|
+
if (file === null) return serverPrefs
|
|
1211
|
+
try {
|
|
1212
|
+
const text = readFileSync(file, 'utf8')
|
|
1213
|
+
const data = JSON.parse(text)
|
|
1214
|
+
if (data && typeof data === 'object' && data.prefs && typeof data.prefs === 'object') {
|
|
1215
|
+
serverPrefs = sanitizePrefs(data.prefs)
|
|
1216
|
+
}
|
|
1217
|
+
} catch { /* 不可读 -> 空 */ }
|
|
1218
|
+
return serverPrefs
|
|
1219
|
+
}
|
|
1220
|
+
const savePrefs = async () => {
|
|
1221
|
+
const file = await prefsFile()
|
|
1222
|
+
if (file === null) return
|
|
1223
|
+
try {
|
|
1224
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
1225
|
+
writeFileSync(file, JSON.stringify({ version: 2, prefs: serverPrefs }, null, 2) + '\n', 'utf8')
|
|
1226
|
+
} catch { /* best-effort */ }
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1166
1229
|
// ---- 在线 QQ 音乐:cookie 持久化 + 登录会话(扫码登录后取高音质直链) ----
|
|
1167
1230
|
// cookie 仅存 Host 端(~/.dsh/music-player-qq-cookie.json,0600),不进入浏览器。
|
|
1168
1231
|
const qq = { cookie: '', isVip: null, nickname: '' }
|
|
@@ -1910,6 +1973,27 @@ export function apply(ctx) {
|
|
|
1910
1973
|
})
|
|
1911
1974
|
return
|
|
1912
1975
|
}
|
|
1976
|
+
// 播放偏好读写(volume/mode/voice/scope/panel-pos/playback/books-playback)。
|
|
1977
|
+
// GET 返回完整快照;POST 为合并语义:{ prefs: {k:v,...} } 写入,
|
|
1978
|
+
// { remove: [k,...] } 删除指定键。见 lib/index.js 的 serverPrefs 说明。
|
|
1979
|
+
if (pathname === '/dsh-music/prefs' && req.method === 'GET') {
|
|
1980
|
+
await loadPrefs()
|
|
1981
|
+
writeJson(res, { ok: true, prefs: serverPrefs })
|
|
1982
|
+
return
|
|
1983
|
+
}
|
|
1984
|
+
if (pathname === '/dsh-music/prefs' && req.method === 'POST') {
|
|
1985
|
+
const body = await readBody(req)
|
|
1986
|
+
await loadPrefs()
|
|
1987
|
+
const patch = sanitizePrefs(body && body.prefs ? body.prefs : body)
|
|
1988
|
+
serverPrefs = { ...serverPrefs, ...patch }
|
|
1989
|
+
const remove = Array.isArray(body && body.remove)
|
|
1990
|
+
? body.remove.filter((k) => typeof k === 'string' && PREF_ALLOW.has(k))
|
|
1991
|
+
: []
|
|
1992
|
+
for (const k of remove) delete serverPrefs[k]
|
|
1993
|
+
await savePrefs()
|
|
1994
|
+
writeJson(res, { ok: true, prefs: serverPrefs })
|
|
1995
|
+
return
|
|
1996
|
+
}
|
|
1913
1997
|
if (pathname === '/dsh-music/set-root' && req.method === 'POST') {
|
|
1914
1998
|
const body = await readBody(req)
|
|
1915
1999
|
const rawPath = body && typeof body.path === 'string' ? body.path.trim() : ''
|
package/lib/qq.js
CHANGED
|
@@ -541,10 +541,6 @@ export async function getMyPlaylists(cookie = '') {
|
|
|
541
541
|
const j = await postMusicu(body, cookie)
|
|
542
542
|
const res = j.req_0 || j['music.musicasset.PlaylistBaseRead']
|
|
543
543
|
const rawSnippet = (x) => { try { const s = JSON.stringify(x); return s ? s.slice(0, 300) : String(x) } catch { return String(x) } }
|
|
544
|
-
// 诊断日志:便于排查鉴权 / uin 不符导致的空歌单。
|
|
545
|
-
if (typeof console !== 'undefined' && typeof console.log === 'function') {
|
|
546
|
-
try { console.log('[dsh-music] my-playlists uin=' + uin + ' code=' + (res && res.code) + ' msg=' + ((res && (res.message || res.msg)) || '') + ' top=' + j.code + ' count=' + ((res && res.data && res.data.v_playlist || []).length) + ' raw=' + rawSnippet(j)) } catch { /* ignore */ }
|
|
547
|
-
}
|
|
548
544
|
if (!res) throw new Error('我的歌单接口无响应(顶层 code=' + j.code + ',raw=' + rawSnippet(j) + ')')
|
|
549
545
|
if (Number(res.code) !== 0) throw new Error('获取我的歌单失败:' + ((res.message || res.msg) || ('code ' + res.code)) + '|raw=' + rawSnippet(res))
|
|
550
546
|
const list = res.data?.v_playlist || []
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-music-player",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "DeepSeek Harness 本地音乐 + AI 讲书插件:Host 扫描音乐目录并以 HTTP 流式提供音频、解析 .txt 小说结构并经 MiMo TTS 合成朗读,浏览器侧提供播放条/播放面板/章节目录跳转/多声音选择/实时频谱,并注册 music_play 模型工具",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|