dsh-mpkg-wallpaper 3.12.0 → 3.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/audio-bus.js +457 -0
- package/lib/client.js +351 -97
- package/lib/now-playing-math.js +14 -5
- package/lib/now-playing.js +11 -3
- package/package.json +1 -1
package/lib/audio-bus.js
ADDED
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// audio-bus.js —— 「跨出口的总线级静音」内核(宿主侧,纯逻辑,可在桩环境单测)
|
|
2
|
+
//
|
|
3
|
+
// 为什么要它(事故链):用户反复报「设置里静音了,但还是会偶发卡顿地响一声」。根因不是"静音按钮没生效",
|
|
4
|
+
// 而是**声音出口不止一个**:我们自己的音乐控件走 `<audio>`(muted 够用),但壁纸/角色语音/可视化可能走
|
|
5
|
+
// Web Audio——`new AudioContext()` 是**别人**建的、`source.connect(ctx.destination)` 也是**别人**连的,
|
|
6
|
+
// 我们连"谁在响"都看不见。上几轮我们只做到了**记录**(`mpwInstallAudioAudit` 的钩子),
|
|
7
|
+
// 记录不等于接管:只要有一个节点直连 `destination`,它就绕过了我们的静音。
|
|
8
|
+
//
|
|
9
|
+
// 本模块做**接管**,而且是**总线级**的(逐条对应审阅意见里点出的坑):
|
|
10
|
+
// ① `AudioNode.prototype.connect` 原型级拦截:任何节点连 `ctx.destination` ⇒ 改接到该 ctx 的
|
|
11
|
+
// **懒创建 masterGain**。原型级 ⇒ "比我们更早创建的 AudioContext"也能被收进来(只要它**还没**连);
|
|
12
|
+
// ② masterGain 的创建与它到 destination 的连接**走原始引用**(否则懒创建那一下会自己拦自己 ⇒ 递归);
|
|
13
|
+
// ③ `connect` 的**返回值原样透传**(`a.connect(b).connect(c)` 链式依赖它);
|
|
14
|
+
// ④ 判定用 `dest === ctx.destination`(**引用比较**;`instanceof` 跨 realm 会失败);
|
|
15
|
+
// ⑤ `OfflineAudioContext` **放行**(它也有 destination;误挂 master 会改掉离线渲染结果);
|
|
16
|
+
// ⑥ 静音 = `master.gain.cancelScheduledValues(now) + setValueAtTime(0, now)`(**不用 `ctx.suspend()`**:
|
|
17
|
+
// 冻结 `currentTime` 会破坏第三方的调度与可视化);analyser 若接在 master **之前**,频谱仍有数据
|
|
18
|
+
// ⇒ 这同时修掉我们"元素被 muted ⇒ 音条恒 0"的老限制;
|
|
19
|
+
// ⑦ 归因补齐:`new Audio()`、`createMediaElementSource`(元素 muted 只影响输入源,输出在 Web Audio)、
|
|
20
|
+
// `createMediaStreamSource`(不吃 `track.enabled`)、`src`/`srcObject` setter(autoplay 绕过 `play()`)、
|
|
21
|
+
// `start(when)` 的迟到分布(**负很多** = bfcache/节流恢复;**负一点点** = 正常抖动,不误杀)、
|
|
22
|
+
// Shadow DOM 里的媒体元素、`speechSynthesis`、`RTCPeerConnection` 的 `track.enabled`;
|
|
23
|
+
// ⑧ **跨 realm 各装一遍**(同源 iframe 的 `AudioNode.prototype` 与父页不是同一个)。
|
|
24
|
+
//
|
|
25
|
+
// 档位(`?mpwhardmute=` 或 `bus.setMode()`)—— 二分定位用,不是"总开关":
|
|
26
|
+
// off 只归因,不动声音(缺省)
|
|
27
|
+
// redirect **只重定向不静音**:还能听见 ⇒ 有出口绕过 connect(历史遗留直连 / 跨 realm);
|
|
28
|
+
// 声音变闷变小 ⇒ 有节点被**重复挂** master(多次 connect destination)
|
|
29
|
+
// 1 重定向 + master 归零 + 媒体压制 + speechSynthesis.cancel + WebRTC track.enabled=false
|
|
30
|
+
// all 1 + 对跨域帧 postMessage 请求静音 + 连未归因的 ctx 也压
|
|
31
|
+
// report 只汇报不压(产出"不可控清单")
|
|
32
|
+
//
|
|
33
|
+
// 诚实边界:hook 之前**已经**连上 destination 的节点无法追溯(没有任何 API 能枚举已有连接)⇒
|
|
34
|
+
// report() 会把"能观测到出声但不在我们 bus 上"的 ctx 单列为 `bypassSuspected`,不假装收全了。
|
|
35
|
+
|
|
36
|
+
export const BUS_MODES = ['off', 'redirect', '1', 'all', 'report'];
|
|
37
|
+
export const BUS_DEFAULT_MODE = 'off';
|
|
38
|
+
export const BUS_ATTR = 'data-mpw-audio-bus';
|
|
39
|
+
export const BUS_MSG = 'mpw:audio-bus';
|
|
40
|
+
/** 迟到补播的判定阈值:负得比这个多 ⇒ 当"恢复后补播"(bfcache/节流),否则算正常抖动,不误杀。 */
|
|
41
|
+
export const LATE_START_SEC = 1.0;
|
|
42
|
+
export const JITTER_START_SEC = 0.05;
|
|
43
|
+
|
|
44
|
+
const now = (win) => { try { return win.performance && win.performance.now ? win.performance.now() : Date.now() } catch (e) { return Date.now() } };
|
|
45
|
+
|
|
46
|
+
/** 规范化档位(未知值 ⇒ 缺省;不抛)。 */
|
|
47
|
+
export function normalizeMode(m) {
|
|
48
|
+
const s = String(m === undefined || m === null ? '' : m).trim().toLowerCase();
|
|
49
|
+
return BUS_MODES.indexOf(s) >= 0 ? s : BUS_DEFAULT_MODE;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 该档是否真的**压**声音(report/off/redirect 都不压)。 */
|
|
53
|
+
export function modeMutes(m) {
|
|
54
|
+
const s = normalizeMode(m);
|
|
55
|
+
return s === '1' || s === 'all';
|
|
56
|
+
}
|
|
57
|
+
/** 该档是否要连"未归因的 ctx"一起压。 */
|
|
58
|
+
export function modeMutesAll(m) { return normalizeMode(m) === 'all'; }
|
|
59
|
+
/** 该档是否只重定向(诊断档:验证 bus 收没收全)。 */
|
|
60
|
+
export function modeRedirectOnly(m) { return normalizeMode(m) === 'redirect'; }
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 在一个 window 上装总线。**幂等**(同一 win 多次调用只装一遍)。
|
|
64
|
+
* @param {object} win 目标 window(可传 iframe 的 contentWindow ⇒ 跨 realm 各装一遍)
|
|
65
|
+
* @param {object} opts { mode, onEvent(name, detail), mediaRoots: ()=>Element[] }
|
|
66
|
+
* @returns {object|null} api
|
|
67
|
+
*/
|
|
68
|
+
export function installAudioBus(win, opts = {}) {
|
|
69
|
+
if (!win || typeof win !== 'object') return null;
|
|
70
|
+
if (win.__mpwAudioBus && win.__mpwAudioBus.__installed) { // 幂等:同一 realm 只装一遍
|
|
71
|
+
if (opts.mode !== undefined) win.__mpwAudioBus.setMode(opts.mode);
|
|
72
|
+
return win.__mpwAudioBus;
|
|
73
|
+
}
|
|
74
|
+
const log = [];
|
|
75
|
+
const emit = (name, detail) => {
|
|
76
|
+
const rec = { t: now(win), name, detail: detail || null };
|
|
77
|
+
log.push(rec);
|
|
78
|
+
if (log.length > 300) log.shift();
|
|
79
|
+
try { if (typeof opts.onEvent === 'function') opts.onEvent(name, rec.detail) } catch (e) { /* 上报失败不许影响音频 */ }
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const state = {
|
|
83
|
+
mode: normalizeMode(opts.mode),
|
|
84
|
+
muted: false,
|
|
85
|
+
wasMuted: false, // 上一次是否处于"真的压着"的状态(决定恢复时要不要写回 1)
|
|
86
|
+
contexts: [], // { id, ctx, adopted, offline, createAt, createStack, frames }
|
|
87
|
+
masters: new WeakMap(), // ctx -> masterGain
|
|
88
|
+
adopted: new Set(), // 被我们接管过的 ctx(**含**安装前就存在的老 ctx)——静音要遍历这一份
|
|
89
|
+
media: new Set(), // 媒体元素(含 new Audio() 与 shadow DOM 里的)
|
|
90
|
+
tappedMedia: new WeakSet(), // 被 createMediaElementSource 接管的元素(不能 pause:analyser 要数据)
|
|
91
|
+
redirects: 0,
|
|
92
|
+
bypassSuspected: [], // 能观测到出声但不在 bus 上的 ctx
|
|
93
|
+
uncontrollable: [], // 跨域帧(拿不到 contentDocument)
|
|
94
|
+
frames: [],
|
|
95
|
+
};
|
|
96
|
+
let ctxSeq = 0;
|
|
97
|
+
let timer = 0;
|
|
98
|
+
/* 本 realm 的**原始 connect**。必须是 `AudioNode.prototype.connect` —— 真实浏览器里
|
|
99
|
+
`AudioContext.prototype` 上**没有** connect(我第一版写错过,那样一条都拦不到)。 */
|
|
100
|
+
let realmOrigConnect = null;
|
|
101
|
+
let realmNodeProto = null;
|
|
102
|
+
|
|
103
|
+
const protoOf = (Ctor) => { try { return Ctor && Ctor.prototype ? Ctor.prototype : null } catch (e) { return null } };
|
|
104
|
+
const stackOf = () => { try { return String(new win.Error().stack || '').split('\n').slice(2, 5).join(' | ').slice(0, 400) } catch (e) { return '' } };
|
|
105
|
+
|
|
106
|
+
/* ── ① 原型级 connect 拦截(含"懒创建 masterGain") ───────────────────────────── */
|
|
107
|
+
const patched = []; // [{ proto, origConnect }],卸载时还原
|
|
108
|
+
const patchConnect = (proto) => {
|
|
109
|
+
if (!proto || typeof proto.connect !== 'function' || proto.__mpwBusPatched) return false;
|
|
110
|
+
const origConnect = proto.connect;
|
|
111
|
+
proto.connect = function mpwBusConnect(dest) {
|
|
112
|
+
const rest = Array.prototype.slice.call(arguments, 1);
|
|
113
|
+
let ctx = null;
|
|
114
|
+
try { ctx = this && this.context } catch (e) { /* 某些桩没有 context */ }
|
|
115
|
+
// 只拦"直连 destination";用**引用比较**(instanceof 跨 realm 会失败)
|
|
116
|
+
if (ctx && dest && dest === ctx.destination && !isOfflineCtx(ctx)) {
|
|
117
|
+
let master = state.masters.get(ctx);
|
|
118
|
+
if (!master) {
|
|
119
|
+
master = createMaster(ctx); // 内部全走原始引用
|
|
120
|
+
state.masters.set(ctx, master);
|
|
121
|
+
if (master) {
|
|
122
|
+
state.adopted.add(ctx);
|
|
123
|
+
/* 比我们更早创建、我们没登记过的 ctx(历史遗留):**采用时补登记**,
|
|
124
|
+
否则 applyMute 遍历的名单里没有它 ⇒ 它照样出声(这正是"假绿"的来源)。 */
|
|
125
|
+
if (ctxId(ctx) < 0) state.contexts.push({ id: 'ctx' + (++ctxSeq), ctx, adopted: true, legacy: true, createAt: now(win), createStack: stackOf(), frames: 0 });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (this !== master) state.redirects++;
|
|
129
|
+
emit('reroute', { ctxId: ctxId(ctx), kind: nodeKind(this), stack: stackOf() });
|
|
130
|
+
origConnect.call(this, master, ...rest);
|
|
131
|
+
/* ③ 真 API 语义:`connect(dest)` 返回**调用方传进来的那个目标节点**(链式 `a.connect(b).connect(c)` 依赖它)。
|
|
132
|
+
若把 master 返回出去,第三方的 `a.connect(ctx.destination).connect(y)` 会变成 `master→y`(语义被改)。 */
|
|
133
|
+
return dest;
|
|
134
|
+
}
|
|
135
|
+
return origConnect.call(this, dest, ...rest);
|
|
136
|
+
};
|
|
137
|
+
proto.__mpwBusPatched = true;
|
|
138
|
+
patched.push({ proto, origConnect });
|
|
139
|
+
if (!realmOrigConnect) { realmOrigConnect = origConnect; realmNodeProto = proto }
|
|
140
|
+
return true;
|
|
141
|
+
};
|
|
142
|
+
/** ② masterGain 的创建与连接**必须走原始引用**(否则懒创建那一下会自己拦自己 ⇒ 递归)。 */
|
|
143
|
+
const createMaster = (ctx) => {
|
|
144
|
+
const origCreate = ctx.__origCreateGain || (ctx.createGain && ctx.createGain.bind(ctx));
|
|
145
|
+
const master = origCreate ? origCreate() : null;
|
|
146
|
+
if (!master) return null;
|
|
147
|
+
try {
|
|
148
|
+
if (master.gain) { master.gain.value = state.muted ? 0 : 1 }
|
|
149
|
+
const origConnect = realmOrigConnect;
|
|
150
|
+
if (origConnect) origConnect.call(master, ctx.destination); // 原始引用,绕开 hook
|
|
151
|
+
else if (master.connect) master.connect(ctx.destination);
|
|
152
|
+
} catch (e) { emit('master-connect-fail', { err: String(e && e.message || e) }) }
|
|
153
|
+
return master;
|
|
154
|
+
};
|
|
155
|
+
const isOfflineCtx = (ctx) => {
|
|
156
|
+
try {
|
|
157
|
+
const OAC = win.OfflineAudioContext || win.webkitOfflineAudioContext;
|
|
158
|
+
return !!(OAC && ctx instanceof OAC); // ⑤ 离线渲染放行
|
|
159
|
+
} catch (e) { return false }
|
|
160
|
+
};
|
|
161
|
+
const ctxId = (ctx) => { const f = state.contexts.find((c) => c.ctx === ctx); return f ? f.id : -1 };
|
|
162
|
+
const nodeKind = (n) => { try { return String((n && n.constructor && n.constructor.name) || 'AudioNode') } catch (e) { return 'AudioNode' } };
|
|
163
|
+
|
|
164
|
+
/* ── ② AudioContext 构造拦截:登记 + 归因(接管本身靠 connect,构造只登记) ───── */
|
|
165
|
+
const patchCtor = (winObj, key) => {
|
|
166
|
+
try {
|
|
167
|
+
const Orig = winObj[key];
|
|
168
|
+
if (typeof Orig !== 'function' || Orig.__mpwBusWrapped) return;
|
|
169
|
+
const Wrapped = function (...args) {
|
|
170
|
+
const ctx = new Orig(...args);
|
|
171
|
+
try {
|
|
172
|
+
state.contexts.push({ id: 'ctx' + (++ctxSeq), ctx, adopted: false, offline: false, createAt: now(win), createStack: stackOf(), frames: 0 });
|
|
173
|
+
emit('ctx-created', { ctor: key, state: ctx && ctx.state });
|
|
174
|
+
} catch (e) {}
|
|
175
|
+
return ctx;
|
|
176
|
+
};
|
|
177
|
+
try { Wrapped.prototype = Orig.prototype } catch (e) {}
|
|
178
|
+
Wrapped.__mpwBusWrapped = true;
|
|
179
|
+
winObj[key] = Wrapped; // 构造只负责**登记/归因**;接管靠下面的 AudioNode.prototype
|
|
180
|
+
} catch (e) {}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
/* ── ③ 归因钩子:MediaElement / MediaStream / src / srcObject / start(when) / speech / WebRTC ── */
|
|
184
|
+
const patchAttribution = () => {
|
|
185
|
+
const ACproto = protoOf(win.AudioContext || win.webkitAudioContext);
|
|
186
|
+
if (ACproto && !ACproto.__mpwBusAttr) {
|
|
187
|
+
ACproto.__mpwBusAttr = true;
|
|
188
|
+
for (const [fn, kind] of [['createMediaElementSource', 'media-element'], ['createMediaStreamSource', 'media-stream'], ['createMediaStreamDestination', 'media-stream-dest']]) {
|
|
189
|
+
try {
|
|
190
|
+
const orig = ACproto[fn];
|
|
191
|
+
if (typeof orig !== 'function') continue;
|
|
192
|
+
ACproto[fn] = function (...a) {
|
|
193
|
+
const node = orig.apply(this, a);
|
|
194
|
+
try {
|
|
195
|
+
if (kind === 'media-element' && a[0]) { state.tappedMedia.add(a[0]); registerMedia(a[0]); }
|
|
196
|
+
emit('source-node', { kind, ctxId: -1, stack: stackOf() });
|
|
197
|
+
} catch (e) {}
|
|
198
|
+
return node;
|
|
199
|
+
};
|
|
200
|
+
} catch (e) {}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
// start(when):迟到分布(负很多 = bfcache/节流恢复;负一点点 = 抖动)
|
|
204
|
+
for (const [key, kind] of [['AudioBufferSourceNode', 'buffer'], ['OscillatorNode', 'oscillator'], ['ConstantSourceNode', 'constant'], ['AudioWorkletNode', 'worklet']]) {
|
|
205
|
+
try {
|
|
206
|
+
const Ctor = win[key]; const np = protoOf(Ctor);
|
|
207
|
+
if (!np || typeof np.start !== 'function' || np.__mpwBusStart) continue;
|
|
208
|
+
const origStart = np.start;
|
|
209
|
+
np.start = function (when) {
|
|
210
|
+
const rest = Array.prototype.slice.call(arguments, 1);
|
|
211
|
+
try {
|
|
212
|
+
const ctx = this.context;
|
|
213
|
+
const cur = ctx && ctx.currentTime;
|
|
214
|
+
const delta = (typeof when === 'number' && typeof cur === 'number') ? (when - cur) : null;
|
|
215
|
+
/* 只**记录**、绝不阻止/丢弃 start("误杀会掉音符"):负得多 ⇒ burst(bfcache/节流恢复后的补播);
|
|
216
|
+
负一点点 ⇒ jitter(正常调度抖动,进分布供人判读)。未来时刻不记。 */
|
|
217
|
+
if (delta !== null && delta < 0) {
|
|
218
|
+
emit(delta < -LATE_START_SEC ? 'start-late-burst' : 'start-late-jitter', { kind, delta: Number(delta.toFixed(3)), mode: state.mode, muted: state.muted, stack: stackOf() });
|
|
219
|
+
}
|
|
220
|
+
} catch (e) {}
|
|
221
|
+
return origStart.apply(this, arguments);
|
|
222
|
+
};
|
|
223
|
+
np.__mpwBusStart = true;
|
|
224
|
+
} catch (e) {}
|
|
225
|
+
}
|
|
226
|
+
// new Audio() / src / srcObject(autoplay + src 赋值绕过 play())
|
|
227
|
+
try {
|
|
228
|
+
const OrigAudio = win.Audio;
|
|
229
|
+
if (typeof OrigAudio === 'function' && !OrigAudio.__mpwBusAudio) {
|
|
230
|
+
const Wrapped = function (...a) { const el = new OrigAudio(...a); try { registerMedia(el); emit('new-audio', { src: shortSrc(el && el.src) }) } catch (e) {} return el };
|
|
231
|
+
try { Wrapped.prototype = OrigAudio.prototype } catch (e) {}
|
|
232
|
+
Wrapped.__mpwBusAudio = true;
|
|
233
|
+
win.Audio = Wrapped;
|
|
234
|
+
}
|
|
235
|
+
} catch (e) {}
|
|
236
|
+
const HME = win.HTMLMediaElement && win.HTMLMediaElement.prototype;
|
|
237
|
+
if (HME && !HME.__mpwBusMedia) {
|
|
238
|
+
HME.__mpwBusMedia = true;
|
|
239
|
+
for (const prop of ['src', 'srcObject']) {
|
|
240
|
+
try {
|
|
241
|
+
const d = Object.getOwnPropertyDescriptor(HME, prop);
|
|
242
|
+
if (!d || !d.set) continue;
|
|
243
|
+
const origSet = d.set, origGet = d.get;
|
|
244
|
+
Object.defineProperty(HME, prop, {
|
|
245
|
+
configurable: true, enumerable: d.enumerable,
|
|
246
|
+
get: origGet,
|
|
247
|
+
set(v) {
|
|
248
|
+
try { if (state.muted && modeMutes(state.mode)) suppress(this) } catch (e) {}
|
|
249
|
+
emit(prop === 'src' ? 'src-set' : 'srcobject-set', { src: prop === 'src' ? shortSrc(v) : '[stream]', muted: !!(this && this.muted) });
|
|
250
|
+
return origSet.call(this, v);
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
} catch (e) {}
|
|
254
|
+
}
|
|
255
|
+
// play():静音期间**压制**且返回 resolved Promise(不排队——排队会在取消静音瞬间多声齐发)
|
|
256
|
+
try {
|
|
257
|
+
const origPlay = HME.play;
|
|
258
|
+
if (typeof origPlay === 'function' && !HME.__mpwBusPlay) {
|
|
259
|
+
HME.__mpwBusPlay = true;
|
|
260
|
+
HME.play = function () {
|
|
261
|
+
try {
|
|
262
|
+
if (state.muted && modeMutes(state.mode)) {
|
|
263
|
+
suppress(this);
|
|
264
|
+
emit('play-suppressed', { src: shortSrc(this && this.src), stack: stackOf() });
|
|
265
|
+
return win.Promise ? win.Promise.resolve() : undefined; // 必须 resolved,否则调用方 await 卡住
|
|
266
|
+
}
|
|
267
|
+
} catch (e) {}
|
|
268
|
+
return origPlay.apply(this, arguments);
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
} catch (e) {}
|
|
272
|
+
// volumechange:静音下有人抬 volume/muted ⇒ 立刻归零 + 记栈
|
|
273
|
+
try {
|
|
274
|
+
if (!HME.__mpwBusVol) {
|
|
275
|
+
HME.__mpwBusVol = true;
|
|
276
|
+
const origAdd = HME.addEventListener;
|
|
277
|
+
if (typeof origAdd === 'function') {
|
|
278
|
+
const self = win;
|
|
279
|
+
origAdd.call(self.document || self, 'volumechange', (e) => {
|
|
280
|
+
const el = e && e.target;
|
|
281
|
+
if (!el || !state.muted || !modeMutes(state.mode)) return;
|
|
282
|
+
if (!el.muted || Number(el.volume) > 0) {
|
|
283
|
+
emit('volume-raised-while-muted', { muted: !!el.muted, volume: Number(el.volume), stack: stackOf() });
|
|
284
|
+
suppress(el);
|
|
285
|
+
}
|
|
286
|
+
}, true);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch (e) {}
|
|
290
|
+
}
|
|
291
|
+
// speechSynthesis(角色语音可能走它,完全不受 <audio>/Web Audio 控制)
|
|
292
|
+
try {
|
|
293
|
+
const ss = win.speechSynthesis;
|
|
294
|
+
if (ss && typeof ss.speak === 'function' && !ss.__mpwBusSpeech) {
|
|
295
|
+
const origSpeak = ss.speak.bind(ss);
|
|
296
|
+
ss.__mpwBusSpeech = true;
|
|
297
|
+
ss.speak = function (u) {
|
|
298
|
+
if (state.muted && modeMutes(state.mode)) { try { ss.cancel() } catch (e) {} ; emit('speech-suppressed', { text: String(u && u.text || '').slice(0, 60) }); return }
|
|
299
|
+
emit('speech', { text: String(u && u.text || '').slice(0, 60) });
|
|
300
|
+
return origSpeak(u);
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
} catch (e) {}
|
|
304
|
+
// WebRTC:outbound 的 track.enabled 是唯一能压它的开关
|
|
305
|
+
try {
|
|
306
|
+
const RTC = win.RTCPeerConnection;
|
|
307
|
+
if (typeof RTC === 'function' && !RTC.__mpwBusRtc) {
|
|
308
|
+
const Wrapped = function (...a) {
|
|
309
|
+
const pc = new RTC(...a);
|
|
310
|
+
try {
|
|
311
|
+
pc.addEventListener('track', (ev) => {
|
|
312
|
+
const tracks = (ev && ev.streams && ev.streams[0] && ev.streams[0].getTracks) ? ev.streams[0].getTracks() : [];
|
|
313
|
+
for (const t of tracks) { try { if (t.kind === 'audio' && state.muted && modeMutes(state.mode)) t.enabled = false } catch (e) {} }
|
|
314
|
+
emit('rtc-track', { n: tracks.length });
|
|
315
|
+
});
|
|
316
|
+
} catch (e) {}
|
|
317
|
+
return pc;
|
|
318
|
+
};
|
|
319
|
+
try { Wrapped.prototype = RTC.prototype } catch (e) {}
|
|
320
|
+
Wrapped.__mpwBusRtc = true;
|
|
321
|
+
win.RTCPeerConnection = Wrapped;
|
|
322
|
+
}
|
|
323
|
+
} catch (e) {}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const shortSrc = (s) => String(s || '').replace(/token=[^&]*/, 'token=…').slice(0, 120);
|
|
327
|
+
|
|
328
|
+
const registerMedia = (el) => {
|
|
329
|
+
if (!el || typeof el !== 'object') return false;
|
|
330
|
+
if (state.media.has(el)) return false;
|
|
331
|
+
state.media.add(el);
|
|
332
|
+
return true;
|
|
333
|
+
};
|
|
334
|
+
/** 收集媒体元素:document + **Shadow DOM**(querySelectorAll 抓不到 shadow root 里的)+ 同源 iframe。 */
|
|
335
|
+
const collectMedia = (doc, out) => {
|
|
336
|
+
try {
|
|
337
|
+
for (const el of doc.querySelectorAll('audio,video')) out.push(el);
|
|
338
|
+
for (const host of doc.querySelectorAll('*')) { if (host.shadowRoot) collectMedia(host.shadowRoot, out) }
|
|
339
|
+
} catch (e) {}
|
|
340
|
+
return out;
|
|
341
|
+
};
|
|
342
|
+
const allMedia = () => {
|
|
343
|
+
const out = [];
|
|
344
|
+
try { collectMedia(win.document, out) } catch (e) {}
|
|
345
|
+
for (const el of state.media) out.push(el);
|
|
346
|
+
if (typeof opts.mediaRoots === 'function') { try { for (const el of opts.mediaRoots() || []) out.push(el) } catch (e) {} }
|
|
347
|
+
return out;
|
|
348
|
+
};
|
|
349
|
+
/** 压一个媒体元素:**被 createMediaElementSource 接管的不要 pause**(analyser 要数据,暂停 = 频谱恒 0)。 */
|
|
350
|
+
const suppress = (el) => {
|
|
351
|
+
try { el.muted = true } catch (e) {}
|
|
352
|
+
try {
|
|
353
|
+
if (state.tappedMedia.has(el)) { /* 只总线归零,保留元素输出给 analyser */ }
|
|
354
|
+
else { el.volume = 0; if (typeof el.pause === 'function' && !el.paused) el.pause() }
|
|
355
|
+
} catch (e) {}
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
/* ── ④ 总线静音:只动我们自己的 masterGain(不 suspend:冻结 currentTime 会破坏调度/可视化) ── */
|
|
359
|
+
const applyMute = () => {
|
|
360
|
+
const mute = state.muted && modeMutes(state.mode);
|
|
361
|
+
/* 非静音档(off/report/redirect)**不碰 gain**:我们的 master 初值就是 1,没人会去改它;
|
|
362
|
+
反过来"每次都写 1"会盖掉第三方自己对 master 的调整,也会让 redirect 诊断档失去意义
|
|
363
|
+
(它要能回答"bus 到底收没收全",而不是顺手改了音量)。只有**从静音恢复**那一次必须写回 1。 */
|
|
364
|
+
if (!mute && !state.wasMuted) return;
|
|
365
|
+
state.wasMuted = mute;
|
|
366
|
+
for (const ctx of state.adopted) {
|
|
367
|
+
const master = state.masters.get(ctx);
|
|
368
|
+
if (!master || !master.gain) continue;
|
|
369
|
+
try {
|
|
370
|
+
const t = ctx.currentTime;
|
|
371
|
+
if (master.gain.cancelScheduledValues) master.gain.cancelScheduledValues(t);
|
|
372
|
+
if (master.gain.setValueAtTime) master.gain.setValueAtTime(mute ? 0 : 1, t);
|
|
373
|
+
else master.gain.value = mute ? 0 : 1;
|
|
374
|
+
} catch (e) { try { master.gain.value = mute ? 0 : 1 } catch (e2) {} }
|
|
375
|
+
}
|
|
376
|
+
for (const el of allMedia()) { try { if (mute) suppress(el); else { el.muted = false } } catch (e) {} }
|
|
377
|
+
if (mute) { try { win.speechSynthesis && win.speechSynthesis.cancel && win.speechSynthesis.cancel() } catch (e) {} }
|
|
378
|
+
emit('apply-mute', { muted: state.muted, mode: state.mode, contexts: state.contexts.length, media: state.media.size });
|
|
379
|
+
};
|
|
380
|
+
|
|
381
|
+
/* ── ⑤ 跨域帧:压不动就**如实报不可控**;同源帧各装一遍(跨 realm) ── */
|
|
382
|
+
const syncFrames = () => {
|
|
383
|
+
state.uncontrollable = [];
|
|
384
|
+
let frames = [];
|
|
385
|
+
try { frames = Array.prototype.slice.call(win.document.querySelectorAll('iframe')) } catch (e) {}
|
|
386
|
+
state.frames = frames.map((f, i) => {
|
|
387
|
+
let sameOrigin = false, cw = null;
|
|
388
|
+
try { cw = f.contentWindow; sameOrigin = !!(cw && cw.document) } catch (e) { sameOrigin = false }
|
|
389
|
+
if (sameOrigin && cw) { try { installAudioBus(cw, { mode: state.mode, onEvent: opts.onEvent, mediaRoots: opts.mediaRoots }) } catch (e) {} }
|
|
390
|
+
else state.uncontrollable.push({ i, src: shortSrc(f.src || f.getAttribute && f.getAttribute('src')) });
|
|
391
|
+
return { i, sameOrigin, src: shortSrc(f.src || '') };
|
|
392
|
+
});
|
|
393
|
+
if (modeMutesAll(state.mode)) {
|
|
394
|
+
for (const f of frames) { try { f.contentWindow && f.contentWindow.postMessage({ type: BUS_MSG, muted: state.muted, mode: state.mode }, '*') } catch (e) {} }
|
|
395
|
+
}
|
|
396
|
+
return state.frames;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const api = {
|
|
400
|
+
__installed: true,
|
|
401
|
+
get mode() { return state.mode },
|
|
402
|
+
get muted() { return state.muted },
|
|
403
|
+
setMode(m) { state.mode = normalizeMode(m); applyMute(); emit('mode', { mode: state.mode }); return state.mode },
|
|
404
|
+
setMuted(v) {
|
|
405
|
+
state.muted = !!v;
|
|
406
|
+
syncFrames();
|
|
407
|
+
applyMute();
|
|
408
|
+
if (state.muted && modeMutes(state.mode)) { // "偶发" ⇒ 周期性重压(新节点/自动化都要压住)
|
|
409
|
+
if (!timer) timer = win.setInterval(() => { try { applyMute(); syncFrames() } catch (e) {} }, 750);
|
|
410
|
+
} else if (timer) { try { win.clearInterval(timer) } catch (e) {} ; timer = 0 }
|
|
411
|
+
return state.muted;
|
|
412
|
+
},
|
|
413
|
+
installFrame(cw, o) { return installAudioBus(cw, Object.assign({ mode: state.mode, onEvent: opts.onEvent }, o || {})) },
|
|
414
|
+
syncFrames,
|
|
415
|
+
/** 自上报快照(探针/diag 直接读它,**不需要用户操作**)。 */
|
|
416
|
+
report() {
|
|
417
|
+
return {
|
|
418
|
+
mode: state.mode, muted: state.muted, redirects: state.redirects,
|
|
419
|
+
contexts: state.contexts.map((c) => Object.assign({}, c, { ctx: undefined, adopted: state.masters.has(c.ctx), state: safeState(c.ctx) })),
|
|
420
|
+
masters: state.contexts.filter((c) => state.masters.has(c.ctx)).length,
|
|
421
|
+
media: allMedia().length, registered: state.media.size, tapped: countTapped(),
|
|
422
|
+
bypassSuspected: state.bypassSuspected.slice(0, 12),
|
|
423
|
+
uncontrollableFrames: state.uncontrollable.slice(0, 12),
|
|
424
|
+
frames: state.frames.slice(0, 12),
|
|
425
|
+
log: log.slice(-40),
|
|
426
|
+
};
|
|
427
|
+
},
|
|
428
|
+
log: () => log.slice(-100),
|
|
429
|
+
dispose() {
|
|
430
|
+
if (timer) { try { win.clearInterval(timer) } catch (e) {} ; timer = 0 }
|
|
431
|
+
for (const p of patched) { try { p.proto.connect = p.origConnect; p.proto.__mpwBusPatched = false } catch (e) {} }
|
|
432
|
+
try { delete win.__mpwAudioBus } catch (e) { win.__mpwAudioBus = null }
|
|
433
|
+
},
|
|
434
|
+
};
|
|
435
|
+
const safeState = (ctx) => { try { return ctx.state } catch (e) { return null } };
|
|
436
|
+
const countTapped = () => { let n = 0; for (const el of state.media) if (state.tappedMedia.has(el)) n++; return n };
|
|
437
|
+
|
|
438
|
+
patchCtor(win, 'AudioContext'); patchCtor(win, 'webkitAudioContext');
|
|
439
|
+
/* ④ `AudioNode.prototype` 每 realm 各装一遍(同源 iframe 的 prototype 与父页不是同一个);
|
|
440
|
+
用 `AudioNode` 而不是 `AudioContext` —— 后者身上没有 connect。 */
|
|
441
|
+
if (!patchConnect(protoOf(win.AudioNode))) patchConnect(protoOf(win.AudioContext));
|
|
442
|
+
patchAttribution();
|
|
443
|
+
try { win.__mpwAudioBus = api } catch (e) {}
|
|
444
|
+
try {
|
|
445
|
+
const doc = win.document;
|
|
446
|
+
if (doc && doc.addEventListener) {
|
|
447
|
+
const onVis = () => { emit('visibility', { state: doc.visibilityState }); if (state.muted && modeMutes(state.mode)) applyMute() };
|
|
448
|
+
doc.addEventListener('visibilitychange', onVis, true);
|
|
449
|
+
win.addEventListener && win.addEventListener('pageshow', (e) => { emit('pageshow', { persisted: !!(e && e.persisted) }); if (state.muted && modeMutes(state.mode)) applyMute() }, true);
|
|
450
|
+
win.addEventListener && win.addEventListener('message', (e) => { // 跨域帧配合时的入口
|
|
451
|
+
try { if (e && e.data && e.data.type === BUS_MSG) api.setMuted(!!e.data.muted) } catch (err) {}
|
|
452
|
+
}, false);
|
|
453
|
+
}
|
|
454
|
+
} catch (e) {}
|
|
455
|
+
emit('installed', { mode: state.mode });
|
|
456
|
+
return api;
|
|
457
|
+
}
|
package/lib/client.js
CHANGED
|
@@ -1044,6 +1044,33 @@ function sectionSigNow() {
|
|
|
1044
1044
|
let mpwThumbDirSeen = null; // 上一次算缓存键时的目录身份
|
|
1045
1045
|
let mpwThumbEpoch = 0; // 缓存纪元(毫秒时间戳;换目录/显式失效即前进)
|
|
1046
1046
|
let mpwThumbInvalidations = 0; // 纪元前进次数(诊断/门禁读数)
|
|
1047
|
+
let mpwThumbContentSig = ""; // 当前目录的**内容身份**(扫描响应算出来的稳定串;同一个目录同一份清单恒等)
|
|
1048
|
+
/** 目录内容签名(纯函数):目录身份 + 文件清单(名字/类型/子路径/预览名/大小/时间戳,排序后拼接)。
|
|
1049
|
+
* 为什么要有它:缓存纪元过去**每次扫描都前进**,而纪元进了 React 的 key ⇒ 11 个缩略图全部
|
|
1050
|
+
* 重挂载、全部重发请求(真机读数:宿主串行解析容器,`/custom-mpkg-preview` 逐个 5.6s→11.8s
|
|
1051
|
+
* 递增 ⇒ 预览框白十几秒、偶发落到类型占位)。判据"扫描 N 次结果一致"要求:同一份内容 ⇒ 同一个键。 */
|
|
1052
|
+
function mpwThumbContentSignature(dirId, files) {
|
|
1053
|
+
try {
|
|
1054
|
+
const rows = (Array.isArray(files) ? files : []).map((f) => [
|
|
1055
|
+
String(f && f.name || ""), String(f && f.type || ""), String(f && f.media || ""),
|
|
1056
|
+
String(f && f.preview || ""), String(f && f.size || ""), String(f && f.mtime || ""),
|
|
1057
|
+
].join(":"));
|
|
1058
|
+
rows.sort(); // 宿主给的文件顺序不参与身份(顺序变了不算内容变了)
|
|
1059
|
+
return String(dirId || "") + "|" + rows.join(",");
|
|
1060
|
+
} catch (e) { return String(dirId || "") + "|err" }
|
|
1061
|
+
}
|
|
1062
|
+
/** 扫描响应到达:内容**变了**才推进缓存纪元(换了 URL 键 ⇒ React 换新节点 + 浏览器重发请求)。
|
|
1063
|
+
* 返回 {changed, epoch} 供诊断/探针读。诚实边界:宿主清单里没有内容指纹(同名文件被替换)
|
|
1064
|
+
* 时这里判不出来 —— 那种情况用面板的「刷新壁纸」显式重来一次。 */
|
|
1065
|
+
function mpwThumbNoteContent(dirId, files, why) {
|
|
1066
|
+
try {
|
|
1067
|
+
const sig = mpwThumbContentSignature(dirId, files);
|
|
1068
|
+
if (sig === mpwThumbContentSig) return { changed: false, epoch: mpwThumbEpoch, sig: sig };
|
|
1069
|
+
mpwThumbContentSig = sig;
|
|
1070
|
+
const ep = mpwThumbInvalidate(why || "content-changed");
|
|
1071
|
+
return { changed: true, epoch: ep, sig: sig };
|
|
1072
|
+
} catch (e) { return { changed: false, epoch: mpwThumbEpoch, err: String(e && e.message || e) } }
|
|
1073
|
+
}
|
|
1047
1074
|
/** 缩略图所属的**目录身份**:盘上目录(customDirPath)> 来源目录名 > mpkgKey 里的目录 > ltoken。 */
|
|
1048
1075
|
function mpwThumbDirIdentity(s) {
|
|
1049
1076
|
try {
|
|
@@ -1105,62 +1132,146 @@ function sectionSigNow() {
|
|
|
1105
1132
|
try { ph = box.querySelector ? box.querySelector("[data-mpw-thumb-ph]") : null } catch (e) { ph = null }
|
|
1106
1133
|
return { box: box, media: media, ph: ph };
|
|
1107
1134
|
}
|
|
1108
|
-
/**
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1135
|
+
/** 媒体"真的画出来了"没有(唯一判据;`onError` 之后 naturalWidth 会归零,天然自洽)。
|
|
1136
|
+
* video 要求 `readyState>=2`(HAVE_CURRENT_DATA):只有元数据、还没解出帧的元素是**空白**的,
|
|
1137
|
+
* 让它可见就会得到用户说的"白白一片"。 */
|
|
1138
|
+
function mpwThumbPainted(el) {
|
|
1139
|
+
try {
|
|
1140
|
+
if (!el) return false;
|
|
1141
|
+
const tag = String(el.tagName || "").toUpperCase();
|
|
1142
|
+
if (tag === "IMG") return !!(el.complete && (el.naturalWidth || 0) > 0);
|
|
1143
|
+
if (tag === "VIDEO") return !!((el.readyState || 0) >= 2 && (el.videoWidth || 0) > 0);
|
|
1144
|
+
return false;
|
|
1145
|
+
} catch (e) { return false }
|
|
1146
|
+
}
|
|
1147
|
+
/** 候选优先级 = DOM 顺序(`<img>` 在前、视频首帧在后)。**前面的还没判死 ⇒ 后面的不许抢镜头**。
|
|
1148
|
+
* 真机 bug:`<video>` 的 onLoadedMetadata 里无条件调 mpwThumbOk ⇒ 与 `<img>` 的 onLoad 抢,
|
|
1149
|
+
* 同一档"多数时候是图、偶而是视频帧",点一次「扫描该目录」就翻一次面(用户第 1 条)。 */
|
|
1150
|
+
function mpwThumbEarlierFailed(p, el) {
|
|
1151
|
+
try {
|
|
1152
|
+
for (const m of p.media) { if (m === el) return true; if (m.getAttribute("data-mpw-thumb-failed") !== "1") return false }
|
|
1153
|
+
return true;
|
|
1154
|
+
} catch (e) { return false }
|
|
1155
|
+
}
|
|
1156
|
+
/** 把"框底色"补上:媒体以 `contain` 呈现(**不裁切**,既有契约),留白过去露的是框底色
|
|
1157
|
+
* —— 浅色主题下就是白条(真机读数:容器自带的 192×192 预览放进 74×42 的框 ⇒ 左右各留
|
|
1158
|
+
* 21.6% ≈ 用户说的"左右两边各约 1/5 是白块,只剩中间能预览")。这里用**同一个媒体**做一层
|
|
1159
|
+
* 模糊铺满的底(CSS `--mpw-thumb-cover` + `::before`),于是"不裁切"与"没有白边"同时成立,
|
|
1160
|
+
* 而且底色与画面的来源是**同一个元素**(唯一事实源,不会出现两者不一致)。 */
|
|
1161
|
+
function mpwThumbCover(el, box) {
|
|
1162
|
+
try {
|
|
1163
|
+
if (!box || !box.style || !box.style.setProperty || !el) return false;
|
|
1164
|
+
const tag = String(el.tagName || "").toUpperCase();
|
|
1165
|
+
if (tag === "VIDEO") return mpwThumbCoverFromVideo(el, box);
|
|
1166
|
+
const u = String(el.currentSrc || el.getAttribute("src") || "");
|
|
1167
|
+
if (!u) return false;
|
|
1168
|
+
box.style.setProperty("--mpw-thumb-cover", 'url("' + u.replace(/"/g, "%22") + '")');
|
|
1169
|
+
return true;
|
|
1170
|
+
} catch (e) { return false }
|
|
1171
|
+
}
|
|
1172
|
+
/** 视频的底色:`background-image: url(mp4)` 画不出画面 ⇒ 只在**画幅与框不一致**(contain 会留边)
|
|
1173
|
+
* 时把当前帧画进一张 32×18 的小画布当底色。画不出来(跨源/未解码/被拒)就什么都不做 ——
|
|
1174
|
+
* 诚实退化成"有留边",绝不伪造一张假底色。同一张只画一次(`data-mpw-thumb-cover`)。 */
|
|
1175
|
+
function mpwThumbCoverFromVideo(video, box) {
|
|
1176
|
+
try {
|
|
1177
|
+
const vw = video.videoWidth || 0, vh = video.videoHeight || 0;
|
|
1178
|
+
const bw = box.clientWidth || 0, bh = box.clientHeight || 0;
|
|
1179
|
+
if (!(vw > 0) || !(vh > 0) || !(bw > 0) || !(bh > 0)) return false;
|
|
1180
|
+
if (Math.abs((vw / vh) - (bw / bh)) <= 0.06) { // 画幅与框基本一致 ⇒ contain 已经铺满,不必画
|
|
1181
|
+
try { box.style.removeProperty("--mpw-thumb-cover") } catch (e) {}
|
|
1182
|
+
return true;
|
|
1130
1183
|
}
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
} catch (e) {
|
|
1184
|
+
if (box.getAttribute("data-mpw-thumb-cover") === "1") return true;
|
|
1185
|
+
const c = document.createElement("canvas"); c.width = 32; c.height = 18;
|
|
1186
|
+
const ctx = c.getContext && c.getContext("2d");
|
|
1187
|
+
if (!ctx) return false;
|
|
1188
|
+
ctx.drawImage(video, 0, 0, 32, 18);
|
|
1189
|
+
const url = c.toDataURL("image/jpeg", 0.5);
|
|
1190
|
+
if (!url || url.length < 32) return false;
|
|
1191
|
+
box.style.setProperty("--mpw-thumb-cover", 'url("' + url + '")');
|
|
1192
|
+
box.setAttribute("data-mpw-thumb-cover", "1");
|
|
1193
|
+
return true;
|
|
1194
|
+
} catch (e) { return false }
|
|
1195
|
+
}
|
|
1196
|
+
/** **唯一落点**:把整个框的显隐按"候选优先级 + 真的画出来了"重新推导一遍。
|
|
1197
|
+
* 三个不变量(判据见 tools/thumb-chain-test.mjs B 组与 tools/scan-switch-live-probe.mjs T1/T2):
|
|
1198
|
+
* ① 同框**至多一个可见**:主人 = 第一个"已画出来 + 前面的候选都判死"的媒体;其余一律 display:none;
|
|
1199
|
+
* ② **可见即有画面**:显示只由这里写,而这里只挑"画出来了"的元素 ⇒ 不存在"可见但空白"的中间态
|
|
1200
|
+
* (旧写法在换候选时先把还没加载完的 `<img>` 显示出来 ⇒ 真机"一块白底");
|
|
1201
|
+
* ③ 占位只在**一个都没画出来、且没有还没试过的候选**时整块露出(状态机之外的任何地方都不许写它)。
|
|
1202
|
+
* 事件(onLoad/onError/onLoadedMetadata/onLoadedData)只负责**触发**它,自己不写显隐。 */
|
|
1203
|
+
function mpwThumbSync(p, why) {
|
|
1204
|
+
try {
|
|
1205
|
+
if (!p) return false;
|
|
1206
|
+
let owner = null;
|
|
1207
|
+
for (const m of p.media) {
|
|
1208
|
+
if (m.getAttribute("data-mpw-thumb-failed") === "1") continue;
|
|
1209
|
+
if (!mpwThumbEarlierFailed(p, m)) continue;
|
|
1210
|
+
if (!mpwThumbPainted(m)) continue;
|
|
1211
|
+
owner = m; break;
|
|
1212
|
+
}
|
|
1213
|
+
for (const m of p.media) {
|
|
1214
|
+
const mine = m === owner;
|
|
1215
|
+
try { m.style.display = mine ? "" : "none" } catch (e) {}
|
|
1216
|
+
const st = mine ? "media" : (m.getAttribute("data-mpw-thumb-failed") === "1" ? "failed" : "trying");
|
|
1217
|
+
try { m.setAttribute("data-mpw-thumb-state", st) } catch (e) {}
|
|
1218
|
+
if (mine) { try { m.setAttribute("data-mpw-thumb-tried", "1") } catch (e) {} }
|
|
1219
|
+
}
|
|
1220
|
+
if (owner) {
|
|
1221
|
+
if (p.ph) { try { p.ph.removeAttribute("data-mpw-thumb-shown") } catch (e) {}; try { p.ph.style.display = "" } catch (e) {} }
|
|
1222
|
+
try { p.box.setAttribute("data-mpw-thumb-state", "media") } catch (e) {}
|
|
1223
|
+
try { p.box.removeAttribute("data-mpw-thumb-why") } catch (e) {}
|
|
1224
|
+
mpwThumbCover(owner, p.box);
|
|
1225
|
+
return true;
|
|
1226
|
+
}
|
|
1227
|
+
/* 没有画面:还有"没试过且带 src"的候选就继续等(框交给 CSS 的加载态,不露类型文字);
|
|
1228
|
+
一个都不剩才露占位 —— 占位的两个属性仍然只在这一个函数里写。 */
|
|
1229
|
+
const untried = p.media.some((m) => m.getAttribute("data-mpw-thumb-tried") !== "1" && String(m.getAttribute("src") || ""));
|
|
1230
|
+
if (untried) {
|
|
1231
|
+
if (p.ph) { try { p.ph.removeAttribute("data-mpw-thumb-shown") } catch (e) {}; try { p.ph.style.display = "" } catch (e) {} }
|
|
1232
|
+
try { p.box.setAttribute("data-mpw-thumb-state", "trying") } catch (e) {}
|
|
1233
|
+
if (why) { try { p.box.setAttribute("data-mpw-thumb-why", String(why).slice(0, 80)) } catch (e) {} }
|
|
1234
|
+
return false;
|
|
1235
|
+
}
|
|
1236
|
+
if (p.ph) {
|
|
1237
|
+
try { p.ph.setAttribute("data-mpw-thumb-shown", "") } catch (e) {}
|
|
1238
|
+
try { p.ph.style.display = "" } catch (e) {}
|
|
1239
|
+
try { p.ph.setAttribute("data-mpw-thumb-state", "placeholder") } catch (e) {}
|
|
1163
1240
|
}
|
|
1241
|
+
try { p.box.setAttribute("data-mpw-thumb-state", "placeholder") } catch (e) {}
|
|
1242
|
+
if (why) { try { p.box.setAttribute("data-mpw-thumb-why", String(why).slice(0, 80)) } catch (e) {} }
|
|
1243
|
+
return false;
|
|
1244
|
+
} catch (e) { return false }
|
|
1245
|
+
}
|
|
1246
|
+
/** 媒体可用(`onLoad` / `onLoadedMetadata` / `onLoadedData` / `onSeeked`):只是**触发一次重推导** ——
|
|
1247
|
+
* 真正的裁决在 mpwThumbSync 里("画出来了没有 + 优先级")。事件本身不再写显隐,这就是
|
|
1248
|
+
* "谁最后跑谁说了算"那个竞态的根除点。
|
|
1249
|
+
* ①(2026-09-21 真机) **入参既认元素也认事件**:挂成 `onLoad: mpwThumbOk` 时 React 交下来的是
|
|
1250
|
+
* **事件对象**,旧实现直接拿它当元素用 ⇒ `parentElement` 为 undefined ⇒ 永远返回 false ——
|
|
1251
|
+
* 也就是说 `<img>` 的 onLoad **从来没有生效过**(真机读数:图的 naturalWidth 早已是 192,
|
|
1252
|
+
* 框里却是视频首帧/空白,只能靠视频那条 `onLoadedMetadata: ev => mpwThumbOk(ev.target)` 救场)。
|
|
1253
|
+
* 这个坑正是"多数时候正常、点一下扫描就白一块"的另一半根因:唯一能画的是慢半拍的视频。 */
|
|
1254
|
+
function mpwThumbOk(el) {
|
|
1255
|
+
try {
|
|
1256
|
+
const target = (el && el.target) ? el.target : el;
|
|
1257
|
+
const p = mpwThumbParts(target);
|
|
1258
|
+
if (!p) return false;
|
|
1259
|
+
return mpwThumbSync(p, "");
|
|
1260
|
+
} catch (e) { return false }
|
|
1261
|
+
}
|
|
1262
|
+
/** 某个媒体元素**这一条用不了**(候选耗尽 / 加载失败 / 请求被取消):判死它,然后重推导 ——
|
|
1263
|
+
* 下一个候选(视频首帧)只有在"前面的候选都判死"之后才可能被选中,占位则要等到一个都不剩。 */
|
|
1264
|
+
function mpwThumbFail(el, why) {
|
|
1265
|
+
try {
|
|
1266
|
+
const p = mpwThumbParts(el);
|
|
1267
|
+
if (!p) return false;
|
|
1268
|
+
try { el.style.display = "none" } catch (e) {}
|
|
1269
|
+
try { el.setAttribute("data-mpw-thumb-failed", "1") } catch (e) {}
|
|
1270
|
+
try { el.setAttribute("data-mpw-thumb-tried", "1") } catch (e) {}
|
|
1271
|
+
try { el.setAttribute("data-mpw-thumb-state", "failed") } catch (e) {}
|
|
1272
|
+
return mpwThumbSync(p, why || "chain-exhausted");
|
|
1273
|
+
} catch (e) { return false }
|
|
1274
|
+
}
|
|
1164
1275
|
/** 候选链载荷:一次 JSON 写清 `{urls,i}`(**一个属性**,换目录时 URL 与下标一起重置,
|
|
1165
1276
|
不会出现"URL 换了、下标还停在上一次的 3"这种跳过候选的中间态)。
|
|
1166
1277
|
兼容旧的 `data-mpw-thumb-list` 数组 + `data-mpw-thumb-idx` 两属性写法(旧产物/夹具)。 */
|
|
@@ -1178,6 +1289,14 @@ function sectionSigNow() {
|
|
|
1178
1289
|
function mpwThumbPayloadSet(el, urls, i) {
|
|
1179
1290
|
try { el.setAttribute("data-mpw-thumb-list", JSON.stringify({ urls: urls, i: i })) } catch (e) {}
|
|
1180
1291
|
}
|
|
1292
|
+
/** 这个候选源是不是**容器文件**(`.mpkg`/`.pkg`):URL 或文件名里出现容器扩展名即为真。
|
|
1293
|
+
* 纯函数,门禁可直接断言(tools/thumb-chain-test.mjs B7)。 */
|
|
1294
|
+
function mpwThumbIsContainerUrl(u, name) {
|
|
1295
|
+
try {
|
|
1296
|
+
const all = String(u || "") + " " + String(name || "");
|
|
1297
|
+
return /\.(mpkg|pkg)([?#"'\s]|$)/i.test(all);
|
|
1298
|
+
} catch (e) { return false }
|
|
1299
|
+
}
|
|
1181
1300
|
/** 预览候选链(**顺序即优先级**,2026-09-21 定案):
|
|
1182
1301
|
① 扫描器**明确**找到的目录内预览图(`section.preview`,如 video 档旁边的 `preview.gif`);
|
|
1183
1302
|
② 容器内 preview.*(mpkg 容器档,服务端解容器;既有行为,容器档第一位);
|
|
@@ -1230,34 +1349,47 @@ function sectionSigNow() {
|
|
|
1230
1349
|
push("img", "dir-preview-convention", host + dirRef.base + encodeURIComponent(dirRef.id) + "/" + name);
|
|
1231
1350
|
}
|
|
1232
1351
|
}
|
|
1233
|
-
|
|
1234
|
-
|
|
1352
|
+
// ④ 视频首帧(浏览器原生抽帧,零依赖)/ ⑤ 图片
|
|
1353
|
+
/* ①(2026-09-21 真机)"候选的 kind 跟着**源文件类型**走":`.mpkg/.pkg` 是**容器**,
|
|
1354
|
+
不是媒体流。把它交给 `<video>` 只会让浏览器去拉几百 MB 字节然后报
|
|
1355
|
+
"Content-Type not supported"(真机读数:`/custom-media` 206 ×20,文件 130MB~830MB;
|
|
1356
|
+
这些无谓请求还会**占满宿主的连接与解析**,把真正要用的容器预览挤到 5.6s→11.8s)。
|
|
1357
|
+
容器档的"首帧"本来就有正道:② 的容器内预览图。 */
|
|
1358
|
+
const containerSrc = mpwThumbIsContainerUrl(img, srcName);
|
|
1359
|
+
if (img && !containerSrc) {
|
|
1235
1360
|
if (converted === "mp4" || /\.(mp4|webm|mkv|mov|m4v)$/i.test(srcName)) push("video", "video-first-frame", resolveHostUrl(img));
|
|
1236
1361
|
else push("img", "image", resolveHostUrl(img));
|
|
1237
1362
|
}
|
|
1238
1363
|
} catch (e) {}
|
|
1239
1364
|
return out;
|
|
1240
1365
|
}
|
|
1241
|
-
/** 预览候选链的推进器(挂在 <img>/<video> 的 onError 上):下一个候选 /
|
|
1242
|
-
* 状态存在**元素自己身上**(`data-mpw-thumb-list` 的 `{urls,i}`),与 React 重渲染无冲突。
|
|
1366
|
+
/** 预览候选链的推进器(挂在 <img>/<video> 的 onError 上):下一个候选 / 耗尽则落占位。
|
|
1367
|
+
* 状态存在**元素自己身上**(`data-mpw-thumb-list` 的 `{urls,i}`),与 React 重渲染无冲突。
|
|
1368
|
+
* ①(2026-09-21 真机) 换源那一瞬间元素**必然空掉**(浏览器换了 src 就不再有画面)⇒ 先把它收起来
|
|
1369
|
+
* 再继续试下一个候选;框的显隐一律交给 mpwThumbSync 推导(旧写法在这里调 mpwThumbOk 把
|
|
1370
|
+
* 还没加载完的元素显示出来 ⇒ 真机"一块白底"就是这么来的)。 */
|
|
1243
1371
|
function mpwThumbNext(ev) {
|
|
1244
1372
|
try {
|
|
1245
|
-
const el = ev && ev.target;
|
|
1373
|
+
const el = (ev && ev.target) ? ev.target : ev; // 事件或元素都认(与 mpwThumbOk 同一口径)
|
|
1246
1374
|
if (!el || !el.getAttribute) return;
|
|
1247
|
-
const p =
|
|
1248
|
-
const
|
|
1249
|
-
|
|
1375
|
+
const p = mpwThumbParts(el);
|
|
1376
|
+
const payload = mpwThumbPayloadGet(el);
|
|
1377
|
+
const list = payload.urls;
|
|
1378
|
+
let i = payload.i + 1;
|
|
1379
|
+
try { el.style.display = "none" } catch (e) {}
|
|
1380
|
+
try { el.removeAttribute("data-mpw-thumb-failed") } catch (e) {}
|
|
1381
|
+
try { el.setAttribute("data-mpw-thumb-state", "trying") } catch (e) {}
|
|
1250
1382
|
while (i < list.length) {
|
|
1251
1383
|
if (list[i]) {
|
|
1252
1384
|
mpwThumbPayloadSet(el, list, i);
|
|
1253
1385
|
try { el.setAttribute("src", list[i]) } catch (e) {}
|
|
1254
|
-
|
|
1386
|
+
mpwThumbSync(p, "trying:next-candidate");
|
|
1255
1387
|
return;
|
|
1256
1388
|
}
|
|
1257
1389
|
i++;
|
|
1258
1390
|
}
|
|
1259
1391
|
try { el.setAttribute("data-mpw-thumb-failed", "1") } catch (e) {}
|
|
1260
|
-
mpwThumbFail(el, "chain-exhausted");
|
|
1392
|
+
mpwThumbFail(el, "chain-exhausted");
|
|
1261
1393
|
} catch (e) {}
|
|
1262
1394
|
}
|
|
1263
1395
|
/** 预览框的子元素**唯一构造点**(设置里"当前壁纸"、自定义目录列表、本地库列表共用一套):
|
|
@@ -1275,20 +1407,32 @@ function sectionSigNow() {
|
|
|
1275
1407
|
const imgs = cands.filter((c) => c.kind !== "video").map((c) => c.src);
|
|
1276
1408
|
const vids = cands.filter((c) => c.kind === "video").map((c) => c.src);
|
|
1277
1409
|
const out = [];
|
|
1410
|
+
/* ①(2026-09-21 真机) **媒体一律"先藏着"**:显示只由 mpwThumbSync 在"真的画出来了"那一刻写。
|
|
1411
|
+
旧写法把 `<img>` 的 display 留空 ⇒ 元素一挂上就可见、而画面还没到 ⇒ 用户看到"一块白底"。
|
|
1412
|
+
`data-mpw-thumb-standby` 保留为**可读标记**("这是还没轮到它的备胎"),裁决仍看优先级。 */
|
|
1278
1413
|
if (imgs.length) {
|
|
1279
|
-
|
|
1414
|
+
/* ①(2026-09-21 真机)**不许用 `loading="lazy"`**:真机读数 —— 元素被我们"先藏着"
|
|
1415
|
+
(display:none)时,Firefox 的惰性加载**永远不发出请求**(`/custom-mpkg-preview`
|
|
1416
|
+
在整轮里 0 次),于是图既不出画也不报错,而候选优先级又挡着视频首帧
|
|
1417
|
+
⇒ 框永远停在加载态。改为即时加载(预览图都很小;真正的大请求是容器字节那条,
|
|
1418
|
+
已被"容器不给 video 候选"堵掉)。 */
|
|
1419
|
+
out.push(h("img", { key: keyBase + "|img", className: "mpw_thumbImg", src: imgs[0], alt: "", decoding: "async",
|
|
1420
|
+
style: { display: "none" },
|
|
1280
1421
|
"data-mpw-thumb-list": JSON.stringify({ urls: imgs, i: 0 }),
|
|
1281
1422
|
onLoad: mpwThumbOk, onError: mpwThumbNext }));
|
|
1282
1423
|
}
|
|
1283
1424
|
if (vids.length) {
|
|
1284
1425
|
out.push(h("video", { key: keyBase + "|video", className: "mpw_thumbImg", src: vids[0], muted: true, playsInline: true, preload: "metadata",
|
|
1285
|
-
style:
|
|
1426
|
+
style: { display: "none" },
|
|
1286
1427
|
"data-mpw-thumb-list": JSON.stringify({ urls: vids, i: 0 }),
|
|
1287
1428
|
"data-mpw-thumb-standby": imgs.length ? "1" : void 0,
|
|
1429
|
+
/* 元数据到 ⇒ 定到 0.05s 抽首帧;**帧真的到了**(loadeddata/seeked,readyState≥2)才可能被
|
|
1430
|
+
选中 —— 只有元数据时元素是空白的,让它可见就又是一块白底(判据 T2)。 */
|
|
1288
1431
|
onLoadedMetadata: (ev) => {
|
|
1289
1432
|
try { const v = ev.target; if (v.duration && v.duration > 0.1) v.currentTime = 0.05 } catch (e) {}
|
|
1290
1433
|
mpwThumbOk(ev.target);
|
|
1291
1434
|
},
|
|
1435
|
+
onLoadedData: mpwThumbOk, onSeeked: mpwThumbOk,
|
|
1292
1436
|
onError: mpwThumbNext }));
|
|
1293
1437
|
}
|
|
1294
1438
|
out.push(h("span", { key: keyBase + "|ph", className: "mpw_hint", style: { margin: 0 }, "data-mpw-thumb-ph": "",
|
|
@@ -5292,9 +5436,10 @@ function sectionSigNow() {
|
|
|
5292
5436
|
这里再加一道有界兜底:apply 后 1.2s 复核一次,若"有源但没挂上"→ 补挂一次 + 一行 console.warn
|
|
5293
5437
|
+ 计数 `window.__mpwBgSrcHeal`(同一内容签名只补一次,绝不循环)。
|
|
5294
5438
|
`?bgwrapfix=legacy` 时整段跳过(回退到改动前:不校验、只靠调用方)。 */
|
|
5295
|
-
let __mpwBgHeal = { sig: "", tries: 0 };
|
|
5439
|
+
let __mpwBgHeal = { sig: "", tries: 0, slowTries: 0 };
|
|
5296
5440
|
let __mpwBgHealTimer = 0;
|
|
5297
|
-
|
|
5441
|
+
let __mpwBgHealSlowTimer = 0;
|
|
5442
|
+
/** 当前壁纸源对应的媒体**有没有挂上 src**(按 converted/webUrl 分派,与 show*El 的落点一致)。 */
|
|
5298
5443
|
function mpwBgArmedNow(section) {
|
|
5299
5444
|
try {
|
|
5300
5445
|
const { img, video, frame, canvas, wrap } = bgElements();
|
|
@@ -5307,37 +5452,95 @@ function sectionSigNow() {
|
|
|
5307
5452
|
return !!(img && (img.getAttribute("src") || img.currentSrc));
|
|
5308
5453
|
} catch { return true } // 判定本身出错时**不补挂**(宁可不动,也不制造抖动)
|
|
5309
5454
|
}
|
|
5455
|
+
/* ①(2026-09-21 真机)"有 src" ≠ "画出来了"(用户原话"壁纸是白色的,什么都没有"):
|
|
5456
|
+
宿主被容器解析占满时(真机读数:10 个缩略图的 `/custom-mpkg-preview` 串行放大到
|
|
5457
|
+
5.6s→11.8s),壁纸自己的 `<video>` 会长时间停在 readyState 0 / videoWidth 0 —— 层是可见的、
|
|
5458
|
+
里面却没有画面;而之后**任何**一次 re-apply(设置同步 / 卡片交互 / 重新选一次)都会把它
|
|
5459
|
+
重新武装 ⇒ 用户以为"点一下 Now Playing 就好了"。所以补挂校验分两档:
|
|
5460
|
+
· 快判(1.2s):有源但**没有 src** ⇒ 立刻补挂(既有行为);
|
|
5461
|
+
· 慢判(12s):有 src 但**一直没有画面** ⇒ 强制重挂一次(同签名只做一次)。
|
|
5462
|
+
诚实边界:转码档(host 正在转)不判死 —— 它有自己的超时回退(见 showVideoEl 后面的
|
|
5463
|
+
`__mpwTransLoadingTimeout`),在后面推它只会把转码重来一遍。 */
|
|
5464
|
+
function mpwBgPaintedNow(section) {
|
|
5465
|
+
try {
|
|
5466
|
+
const { img, video, canvas, wrap } = bgElements();
|
|
5467
|
+
if (!wrap) return true;
|
|
5468
|
+
if (section.webUrl) return true; // 帧内画面看不见,不越权判死
|
|
5469
|
+
if (section.converted === "scene" && section.sceneKey) return !!(canvas && (canvas.width || 0) > 0);
|
|
5470
|
+
if (section.converted === "mp4") {
|
|
5471
|
+
if (!video) return true;
|
|
5472
|
+
let st = "";
|
|
5473
|
+
try { st = String(video.getAttribute("data-mpw-wp-state") || (window.__mpwWallpaperState && window.__mpwWallpaperState.state) || "") } catch {}
|
|
5474
|
+
if (st === "transcode") return true; // 转码中:交给它自己的超时回退
|
|
5475
|
+
/* 用户自己按了暂停 / 省电暂停 / 页面在后台 ⇒ 元素**本来就不该出画面**,
|
|
5476
|
+
这时"没画面"是正常的:不许补(补了会打断用户的暂停,也会白丢已缓冲的数据)。 */
|
|
5477
|
+
try { if (wallUserPaused || powPaused) return true } catch (e) {}
|
|
5478
|
+
try { if (typeof document !== "undefined" && document && document.hidden) return true } catch (e) {}
|
|
5479
|
+
const rs = Number(video.readyState || 0);
|
|
5480
|
+
const vw = Number(video.videoWidth || 0);
|
|
5481
|
+
if (!(video.getAttribute("src") || video.currentSrc)) return true; // 没 src 属于"快判"那一档
|
|
5482
|
+
return rs >= 2 && vw > 0;
|
|
5483
|
+
}
|
|
5484
|
+
if (!img) return true;
|
|
5485
|
+
if (!(img.getAttribute("src") || img.currentSrc)) return true;
|
|
5486
|
+
return !!(img.complete && (img.naturalWidth || 0) > 0);
|
|
5487
|
+
} catch { return true }
|
|
5488
|
+
}
|
|
5310
5489
|
function mpwBgSrcHealSchedule() {
|
|
5311
5490
|
if (!mpwBgWrapFixOn()) return;
|
|
5312
|
-
|
|
5491
|
+
// 已有待复核(apply 很频繁,只留一个)—— 两档各留一个
|
|
5313
5492
|
try {
|
|
5314
|
-
|
|
5315
|
-
__mpwBgHealTimer =
|
|
5316
|
-
|
|
5317
|
-
|
|
5493
|
+
if (!__mpwBgHealTimer) {
|
|
5494
|
+
__mpwBgHealTimer = setTimeout(() => {
|
|
5495
|
+
__mpwBgHealTimer = 0;
|
|
5496
|
+
try { mpwBgSrcHealCheck("fast") } catch (e) { mpwErr("壁纸源补挂校验", e) }
|
|
5497
|
+
}, 1200);
|
|
5498
|
+
}
|
|
5499
|
+
} catch {}
|
|
5500
|
+
try {
|
|
5501
|
+
if (!__mpwBgHealSlowTimer) {
|
|
5502
|
+
__mpwBgHealSlowTimer = setTimeout(() => {
|
|
5503
|
+
__mpwBgHealSlowTimer = 0;
|
|
5504
|
+
try { mpwBgSrcHealCheck("slow") } catch (e) { mpwErr("壁纸源补画校验", e) }
|
|
5505
|
+
}, 12000);
|
|
5506
|
+
}
|
|
5318
5507
|
} catch {}
|
|
5319
5508
|
}
|
|
5320
|
-
function mpwBgSrcHealCheck() {
|
|
5509
|
+
function mpwBgSrcHealCheck(phase) {
|
|
5510
|
+
const fast = phase !== "slow";
|
|
5321
5511
|
const s = normalizeSection(readSection());
|
|
5322
5512
|
if (!(s.enabled !== void 0 ? !!s.enabled : DEFAULT_ENABLED)) return; // 总开关关:不挂是对的
|
|
5323
5513
|
if (!(s.image || s.webUrl)) return; // 无源:不挂是对的(正常语义)
|
|
5324
5514
|
const sig = sectionSigNow();
|
|
5325
|
-
if (__mpwBgHeal.sig !== sig) __mpwBgHeal = { sig, tries: 0 };
|
|
5326
|
-
|
|
5327
|
-
if (
|
|
5328
|
-
|
|
5515
|
+
if (__mpwBgHeal.sig !== sig) __mpwBgHeal = { sig, tries: 0, slowTries: 0 };
|
|
5516
|
+
const bad = fast ? !mpwBgArmedNow(s) : !mpwBgPaintedNow(s);
|
|
5517
|
+
if (!bad) return;
|
|
5518
|
+
const used = fast ? __mpwBgHeal.tries : __mpwBgHeal.slowTries;
|
|
5519
|
+
if (used >= 1) {
|
|
5520
|
+
// 补过一次仍没到位 → 只报一次,不再重试(避免抖动/循环)
|
|
5329
5521
|
if (__mpwBgHeal.warned !== true) {
|
|
5330
5522
|
__mpwBgHeal.warned = true;
|
|
5331
|
-
console.warn("[dsh-mpkg-wallpaper]
|
|
5523
|
+
console.warn("[dsh-mpkg-wallpaper] 有壁纸源但补" + (fast ? "挂" : "画") + "仍未生效:",
|
|
5524
|
+
fast ? "(媒体元素没有 src)" : "(有 src 但一直没有画面)", sig.slice(0, 80));
|
|
5332
5525
|
}
|
|
5333
5526
|
return;
|
|
5334
5527
|
}
|
|
5335
|
-
__mpwBgHeal.tries++;
|
|
5528
|
+
if (fast) __mpwBgHeal.tries++; else __mpwBgHeal.slowTries++;
|
|
5336
5529
|
try { window.__mpwBgSrcHeal = ((window.__mpwBgSrcHeal || 0) + 1) } catch {}
|
|
5337
|
-
console.warn("[dsh-mpkg-wallpaper]
|
|
5530
|
+
console.warn("[dsh-mpkg-wallpaper] 检测到有壁纸源但" + (fast ? "媒体未挂上" : "一直没有画面") + " → 补一次:", sig.slice(0, 80));
|
|
5531
|
+
if (!fast) {
|
|
5532
|
+
/* 慢判要**强制重挂**:showVideoEl 有"src 相同就不重设"的守卫(那是有意的,防止
|
|
5533
|
+
无关设置把播放打断),所以只调 applyFromStorageInner 对"卡住的那一次加载"没用 ——
|
|
5534
|
+
必须显式把同一个 URL 重新 load 一遍。有界:同签名只做一次。 */
|
|
5535
|
+
try {
|
|
5536
|
+
const v = bgElements().video;
|
|
5537
|
+
const cur = v ? String(v.getAttribute("src") || v.currentSrc || "") : "";
|
|
5538
|
+
if (v && cur) { try { v.pause() } catch (e) {}; v.removeAttribute("src"); v.src = cur; if (v.load) v.load(); }
|
|
5539
|
+
} catch (e) {}
|
|
5540
|
+
}
|
|
5338
5541
|
try { applyFromStorageInner() } catch (e) { mpwErr("壁纸源补挂", e) }
|
|
5339
5542
|
}
|
|
5340
|
-
try { window.__mpwBgWrapState = () => ({ fixOn: mpwBgWrapFixOn(), heal: (() => { try { return window.__mpwBgSrcHeal || 0 } catch { return -1 } })(), last: __mpwBgHeal }) } catch {}
|
|
5543
|
+
try { window.__mpwBgWrapState = () => ({ fixOn: mpwBgWrapFixOn(), heal: (() => { try { return window.__mpwBgSrcHeal || 0 } catch { return -1 } })(), last: __mpwBgHeal, paintedNow: (() => { try { return mpwBgPaintedNow(normalizeSection(readSection())) } catch { return null } })() }) } catch {}
|
|
5341
5544
|
/* ═══ ①(2026-09-16 根治) 设置归一化:**必须是模块级函数** ═══
|
|
5342
5545
|
历史 bug("标题栏磨砂一直没有效果"的真根因,真机 diag 实锤):
|
|
5343
5546
|
`__asStr` / `normalizeSection` 原本定义在 `applyFromStorageInner()` **函数体内**
|
|
@@ -9615,7 +9818,15 @@ function sectionSigNow() {
|
|
|
9615
9818
|
thumbParts: (el) => { try { const p = mpwThumbParts(el); return p ? { box: p.box, media: p.media, ph: p.ph } : null } catch (e) { return null } },
|
|
9616
9819
|
thumbIdentity: (s) => { try { return mpwThumbDirIdentity(s || readSection()) } catch (e) { return "" } },
|
|
9617
9820
|
thumbBust: (url, s) => { try { return mpwThumbBust(url, s || readSection()) } catch (e) { return String(e && e.message || e) } },
|
|
9618
|
-
thumbEpoch: () => { try { return { epoch: mpwThumbEpoch, dir: mpwThumbDirSeen, n: mpwThumbInvalidations } } catch (e) { return null } },
|
|
9821
|
+
thumbEpoch: () => { try { return { epoch: mpwThumbEpoch, dir: mpwThumbDirSeen, n: mpwThumbInvalidations, sig: mpwThumbContentSig } } catch (e) { return null } },
|
|
9822
|
+
thumbNoteContent: (dirId, files, why) => { try { return mpwThumbNoteContent(dirId, files, why) } catch (e) { return { err: String(e && e.message || e) } } },
|
|
9823
|
+
thumbContentSig: (dirId, files) => { try { return mpwThumbContentSignature(dirId, files) } catch (e) { return "" } },
|
|
9824
|
+
thumbSync: (el, why) => { try { const p = mpwThumbParts(el); return p ? mpwThumbSync(p, why || "test") : false } catch (e) { return String(e && e.message || e) } },
|
|
9825
|
+
thumbPainted: (el) => { try { return mpwThumbPainted(el) } catch (e) { return null } },
|
|
9826
|
+
thumbContainerUrl: (u, name) => { try { return mpwThumbIsContainerUrl(u, name) } catch (e) { return null } },
|
|
9827
|
+
bgPaintedNow: (sec) => { try { return mpwBgPaintedNow(normalizeSection(sec || readSection())) } catch (e) { return null } },
|
|
9828
|
+
bgArmedNow: (sec) => { try { return mpwBgArmedNow(normalizeSection(sec || readSection())) } catch (e) { return null } },
|
|
9829
|
+
bgHealCheck: (phase) => { try { mpwBgSrcHealCheck(phase || "slow"); return true } catch (e) { return String(e && e.message || e) } },
|
|
9619
9830
|
thumbInvalidate: (why) => { try { return mpwThumbInvalidate(why || "test") } catch (e) { return -1 } },
|
|
9620
9831
|
// ⑥ 弹层类污染
|
|
9621
9832
|
syncLayerHosts: () => { try { return mpwSyncLayerHosts() } catch (e) { return { error: String(e && e.message || e) } } },
|
|
@@ -10512,10 +10723,19 @@ const barTop = (p) => mix(SHUT - PAD - RAIL_H, RAIL_Y, p);
|
|
|
10512
10723
|
/** 轨道的左右端点(一个 margin,所以两端永不移动)。 */
|
|
10513
10724
|
const barLeft = () => PAD;
|
|
10514
10725
|
const barWidth = () => W - PAD * 2;
|
|
10515
|
-
/**
|
|
10516
|
-
|
|
10517
|
-
|
|
10518
|
-
|
|
10726
|
+
/** 时间轴(**唯一事实源**):当前媒体的时长。缺省/非法值才退回上游演示常量 `TOTAL`。
|
|
10727
|
+
*
|
|
10728
|
+
* 为什么必须带参(2026-09-21 真机):卡片的"−剩余"与进度条填充过去无条件用 `TOTAL`(214 = 上游
|
|
10729
|
+
* 演示里那首曲子的秒数)。真实媒体 100.05s(1:40)时:卡片写 `−3:29`、条子播到结尾也只画到
|
|
10730
|
+
* 47%(100/214)⇒ 用户看到的正是「1 分 40 秒读成 3 分 30 多秒」+「点一下只跳到整条的一半」。
|
|
10731
|
+
* 3.11.0 修的是**数据目标**(npMediaTarget:位置/时长/seek 落到同一个元素),
|
|
10732
|
+
* 但**显示轴**还是这个常量 —— 于是"数据对、显示错"能长期共存。这条口径与数据面同源:
|
|
10733
|
+
* 显示用的时长 == `npResolveMedia()` 送进控制器的 `media.total` == 媒体元素自己的 duration。 */
|
|
10734
|
+
const axisOf = (total) => (typeof total === "number" && isFinite(total) && total > 0 ? total : TOTAL);
|
|
10735
|
+
/** 播放进度百分比(0..`total` 的时钟 → 0..100)。`total` 省略 = 上游演示常量(演示/纯几何测试用)。 */
|
|
10736
|
+
const runPct = (at, total) => (clamp(at, 0, axisOf(total)) / axisOf(total)) * 100;
|
|
10737
|
+
/** 剩余时间(负号由组件加,保证两段数字等宽)。同样以 `total` 为准(唯一事实源)。 */
|
|
10738
|
+
const remain = (at, total) => axisOf(total) - clamp(at, 0, axisOf(total));
|
|
10519
10739
|
|
|
10520
10740
|
/* ── the one thing you press to change shape ───────
|
|
10521
10741
|
Invisible, and it is the WHOLE bar when shut — the
|
|
@@ -10561,7 +10781,7 @@ module.exports = {
|
|
|
10561
10781
|
/* 两个八点四边形 */
|
|
10562
10782
|
PAUSE_L, PLAY_L, PAUSE_R, PLAY_R, quad,
|
|
10563
10783
|
/* 时间 */
|
|
10564
|
-
clock, tickClock,
|
|
10784
|
+
clock, tickClock, axisOf,
|
|
10565
10785
|
/* 端点/派生形状 */
|
|
10566
10786
|
durationOf, artRadius, cornerOffset, boxRadius,
|
|
10567
10787
|
swellOf, zoomOf, gooOf, gooPull, gooTip, gooScale,
|
|
@@ -11324,7 +11544,12 @@ function createNowPlaying(deps) {
|
|
|
11324
11544
|
const going = !!q.playing;
|
|
11325
11545
|
const media = q.media || {};
|
|
11326
11546
|
const known = typeof media.total === "number" && media.total > 0;
|
|
11327
|
-
|
|
11547
|
+
/* 时间轴 = **当前媒体的时长**(唯一事实源;`known` 为假才退回上游演示常量)。
|
|
11548
|
+
夹取、进度条填充、"−剩余"三处**共用这一个值** —— 任何一处各自用常量,就会出现
|
|
11549
|
+
"数据对、显示错"(真机:100.05s 的媒体被画成 214s 的轴)。判据见
|
|
11550
|
+
tools/np-axis-live-probe.mjs(真机)与 tools/np-control-test.mjs 的 X 组(无浏览器)。 */
|
|
11551
|
+
const axis = M.axisOf(known ? media.total : null);
|
|
11552
|
+
const at = Math.max(0, Math.min(axis, Number(q.at) || 0));
|
|
11328
11553
|
|
|
11329
11554
|
const art = M.artSize(p);
|
|
11330
11555
|
const artR = M.artRadius(M.CORNER);
|
|
@@ -11372,7 +11597,7 @@ function createNowPlaying(deps) {
|
|
|
11372
11597
|
h("span", { className: "mpw_np_rail" },
|
|
11373
11598
|
h("span", {
|
|
11374
11599
|
className: "mpw_np_run",
|
|
11375
|
-
style: { width: (known ? M.runPct(at) : 0) + "%" },
|
|
11600
|
+
style: { width: (known ? M.runPct(at, axis) : 0) + "%" },
|
|
11376
11601
|
})),
|
|
11377
11602
|
/* ①(NP-4)③ 进度条可拖动:命中带绝对定位在 bar 上(**不进**那一列的 flex 流),
|
|
11378
11603
|
所以时钟与轨道的布局一个像素都不动。轨道只有 3px,命中带上下各补 NP_SCRUB_PAD。
|
|
@@ -11429,7 +11654,7 @@ function createNowPlaying(deps) {
|
|
|
11429
11654
|
h("span", { className: "mpw_np_volRun", style: { width: (vol * 100) + "%" } }),
|
|
11430
11655
|
h("span", { className: "mpw_np_volKnob", style: { left: (vol * 100) + "%" } }))) : null,
|
|
11431
11656
|
/* 数据源没有时长时**不假装**:显示 --:--(见 docs/NOW-PLAYING-DSH.md §4 情形②) */
|
|
11432
|
-
h("span", null, known ? ("−" + M.clock(M.remain(at))) : "--:--"))),
|
|
11657
|
+
h("span", null, known ? ("−" + M.clock(M.remain(at, axis))) : "--:--"))),
|
|
11433
11658
|
h("button", {
|
|
11434
11659
|
className: "mpw_np_tap", type: "button",
|
|
11435
11660
|
onClick: () => { if (q.onToggle) q.onToggle(); },
|
|
@@ -12256,6 +12481,9 @@ function createNowPlaying(deps) {
|
|
|
12256
12481
|
}
|
|
12257
12482
|
|
|
12258
12483
|
return {
|
|
12484
|
+
/* ①(2026-09-21) 纯渲染函数也对外:**显示面**(进度条填充宽度、"−剩余"文本)要能在
|
|
12485
|
+
无浏览器门禁里对着产出的元素树断言 —— 只测控制器状态测不到"数据对、显示错"
|
|
12486
|
+
(真机 bug:媒体 100.05s,卡片按上游常量 214 画)。判据:tools/np-control-test.mjs 的 X 组。 */
|
|
12259
12487
|
NowPlaying, PlayMark, createController, createSlotAction, shouldHide, resolveAnchor, sidebarRoot, sidebarCol, occupantOf,
|
|
12260
12488
|
};
|
|
12261
12489
|
}
|
|
@@ -14244,8 +14472,28 @@ body[data-ds-dark-theme] .mpw_tabBar {
|
|
|
14244
14472
|
媒体脱离文档流铺满整框 + object-fit:contain(宁可留边,绝不切图);占位同层居中。
|
|
14245
14473
|
旧写法是 flex 行 + object-fit:cover:占位一露出来就把 100% 宽的图压到 3/4,
|
|
14246
14474
|
右侧 1/4 变成白块(真机读数)——现在两者重叠而非并排,且由状态机保证只露一个。 */
|
|
14247
|
-
.mpw_wallThumb .mpw_thumbImg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; display: block; }
|
|
14248
|
-
.mpw_thumbImg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; display: block; }
|
|
14475
|
+
.mpw_wallThumb .mpw_thumbImg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; display: block; z-index: 1; }
|
|
14476
|
+
.mpw_thumbImg { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; display: block; z-index: 1; }
|
|
14477
|
+
/* ①(2026-09-21 真机)"留白露框底色"的修法:媒体以 contain 呈现(**不裁切**,既有契约),
|
|
14478
|
+
留白由**同一张图**的模糊铺满层兜底 —— --mpw-thumb-cover 由状态机在"媒体真的画出来"那一刻
|
|
14479
|
+
写成(唯一事实源,与画面同一个元素)。真机读数:容器自带 192×192 预览放进 74×42 的框,
|
|
14480
|
+
contain 左右各留 21.6% ≈ 用户说的"左右两边各约 1/5 是白块,只剩中间能预览"。 */
|
|
14481
|
+
.mpw_thumb::before, .mpw_wallThumb::before {
|
|
14482
|
+
content: ""; position: absolute; inset: 0; z-index: 0; pointer-events: none;
|
|
14483
|
+
background-image: var(--mpw-thumb-cover, none); background-size: cover; background-position: center;
|
|
14484
|
+
filter: blur(6px); transform: scale(1.08);
|
|
14485
|
+
}
|
|
14486
|
+
.mpw_thumb [data-mpw-thumb-ph], .mpw_wallThumb [data-mpw-thumb-ph] { z-index: 2; }
|
|
14487
|
+
/* ①(2026-09-21 真机)"框里还没有画面"的加载态:给一层会走的骨架,而不是纯底色
|
|
14488
|
+
(用户原话"变成一块白色、中间写着 mp4"—— 分不清在加载还是坏了)。
|
|
14489
|
+
只在 trying 出现;一旦有媒体画出来 box 状态就变 media,这一层随之消失。 */
|
|
14490
|
+
.mpw_thumb[data-mpw-thumb-state="trying"], .mpw_wallThumb[data-mpw-thumb-state="trying"] {
|
|
14491
|
+
background-image: linear-gradient(100deg, transparent 25%, rgba(255,255,255,.30) 50%, transparent 75%);
|
|
14492
|
+
background-size: 200% 100%; background-repeat: no-repeat;
|
|
14493
|
+
animation: mpwThumbSkeleton 1.4s linear infinite;
|
|
14494
|
+
}
|
|
14495
|
+
@keyframes mpwThumbSkeleton { from { background-position: 120% 0; } to { background-position: -120% 0; } }
|
|
14496
|
+
@media (prefers-reduced-motion: reduce) { .mpw_thumb[data-mpw-thumb-state="trying"], .mpw_wallThumb[data-mpw-thumb-state="trying"] { animation: none; } }
|
|
14249
14497
|
/* 占位:整块(铺满 + 居中),不是一条被挤出来的白边。
|
|
14250
14498
|
显隐由**我们自己的**属性 data-mpw-thumb-shown 控制(不用标准 [hidden]:样式作用域护栏要求
|
|
14251
14499
|
选择器里的原子都在登记表里,而我们自己的 data-mpw-* 天生在册;状态机也只有这一个开关)。 */
|
|
@@ -15835,10 +16083,16 @@ body[data-mpw-modal] [role="dialog"] {
|
|
|
15835
16083
|
setCustomFiles([]); setWallList([]);
|
|
15836
16084
|
}
|
|
15837
16085
|
else {
|
|
15838
|
-
/* ①(2026-09-21
|
|
15839
|
-
|
|
15840
|
-
|
|
15841
|
-
|
|
16086
|
+
/* ①(2026-09-21 真机) 扫描成功 ⇒ **只有内容真的变了**才推进缩略图缓存纪元。
|
|
16087
|
+
旧写法无条件 invalidate ⇒ 每次扫描都把全部缩略图重挂载 + 重发请求(真机读数:
|
|
16088
|
+
宿主串行解析容器,`/custom-mpkg-preview` 逐个 5.6s→11.8s 递增 ⇒ 预览框白十几秒、
|
|
16089
|
+
偶发落到类型占位)—— 而用户看到的只是"同一批壁纸"。
|
|
16090
|
+
现在:同一个目录、同一份清单 ⇒ 纪元不动、URL 不动、React 节点不动 ⇒
|
|
16091
|
+
"扫描 N 次结果一致"是**构造出来的**,不是碰巧。判据 T3(真机探针)。 */
|
|
16092
|
+
try {
|
|
16093
|
+
const dirId = mpwThumbDirIdentity(readSection());
|
|
16094
|
+
mpwThumbNoteContent(dirId, d.files || [], "scan-custom-dir");
|
|
16095
|
+
} catch (e) {}
|
|
15842
16096
|
setCustomFiles(d.files || []);
|
|
15843
16097
|
// ①(新) 合并进统一列表:自定义目录文件(图片+视频)+ workshop 原始子文件夹
|
|
15844
16098
|
// (video → mp4 播放;web → iframe;scene → 预览图/后续静态帧)
|
package/lib/now-playing-math.js
CHANGED
|
@@ -605,10 +605,19 @@ const barTop = (p) => mix(SHUT - PAD - RAIL_H, RAIL_Y, p);
|
|
|
605
605
|
/** 轨道的左右端点(一个 margin,所以两端永不移动)。 */
|
|
606
606
|
const barLeft = () => PAD;
|
|
607
607
|
const barWidth = () => W - PAD * 2;
|
|
608
|
-
/**
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
608
|
+
/** 时间轴(**唯一事实源**):当前媒体的时长。缺省/非法值才退回上游演示常量 `TOTAL`。
|
|
609
|
+
*
|
|
610
|
+
* 为什么必须带参(2026-09-21 真机):卡片的"−剩余"与进度条填充过去无条件用 `TOTAL`(214 = 上游
|
|
611
|
+
* 演示里那首曲子的秒数)。真实媒体 100.05s(1:40)时:卡片写 `−3:29`、条子播到结尾也只画到
|
|
612
|
+
* 47%(100/214)⇒ 用户看到的正是「1 分 40 秒读成 3 分 30 多秒」+「点一下只跳到整条的一半」。
|
|
613
|
+
* 3.11.0 修的是**数据目标**(npMediaTarget:位置/时长/seek 落到同一个元素),
|
|
614
|
+
* 但**显示轴**还是这个常量 —— 于是"数据对、显示错"能长期共存。这条口径与数据面同源:
|
|
615
|
+
* 显示用的时长 == `npResolveMedia()` 送进控制器的 `media.total` == 媒体元素自己的 duration。 */
|
|
616
|
+
const axisOf = (total) => (typeof total === "number" && isFinite(total) && total > 0 ? total : TOTAL);
|
|
617
|
+
/** 播放进度百分比(0..`total` 的时钟 → 0..100)。`total` 省略 = 上游演示常量(演示/纯几何测试用)。 */
|
|
618
|
+
const runPct = (at, total) => (clamp(at, 0, axisOf(total)) / axisOf(total)) * 100;
|
|
619
|
+
/** 剩余时间(负号由组件加,保证两段数字等宽)。同样以 `total` 为准(唯一事实源)。 */
|
|
620
|
+
const remain = (at, total) => axisOf(total) - clamp(at, 0, axisOf(total));
|
|
612
621
|
|
|
613
622
|
/* ── the one thing you press to change shape ───────
|
|
614
623
|
Invisible, and it is the WHOLE bar when shut — the
|
|
@@ -654,7 +663,7 @@ module.exports = {
|
|
|
654
663
|
/* 两个八点四边形 */
|
|
655
664
|
PAUSE_L, PLAY_L, PAUSE_R, PLAY_R, quad,
|
|
656
665
|
/* 时间 */
|
|
657
|
-
clock, tickClock,
|
|
666
|
+
clock, tickClock, axisOf,
|
|
658
667
|
/* 端点/派生形状 */
|
|
659
668
|
durationOf, artRadius, cornerOffset, boxRadius,
|
|
660
669
|
swellOf, zoomOf, gooOf, gooPull, gooTip, gooScale,
|
package/lib/now-playing.js
CHANGED
|
@@ -745,7 +745,12 @@ function createNowPlaying(deps) {
|
|
|
745
745
|
const going = !!q.playing;
|
|
746
746
|
const media = q.media || {};
|
|
747
747
|
const known = typeof media.total === "number" && media.total > 0;
|
|
748
|
-
|
|
748
|
+
/* 时间轴 = **当前媒体的时长**(唯一事实源;`known` 为假才退回上游演示常量)。
|
|
749
|
+
夹取、进度条填充、"−剩余"三处**共用这一个值** —— 任何一处各自用常量,就会出现
|
|
750
|
+
"数据对、显示错"(真机:100.05s 的媒体被画成 214s 的轴)。判据见
|
|
751
|
+
tools/np-axis-live-probe.mjs(真机)与 tools/np-control-test.mjs 的 X 组(无浏览器)。 */
|
|
752
|
+
const axis = M.axisOf(known ? media.total : null);
|
|
753
|
+
const at = Math.max(0, Math.min(axis, Number(q.at) || 0));
|
|
749
754
|
|
|
750
755
|
const art = M.artSize(p);
|
|
751
756
|
const artR = M.artRadius(M.CORNER);
|
|
@@ -793,7 +798,7 @@ function createNowPlaying(deps) {
|
|
|
793
798
|
h("span", { className: "mpw_np_rail" },
|
|
794
799
|
h("span", {
|
|
795
800
|
className: "mpw_np_run",
|
|
796
|
-
style: { width: (known ? M.runPct(at) : 0) + "%" },
|
|
801
|
+
style: { width: (known ? M.runPct(at, axis) : 0) + "%" },
|
|
797
802
|
})),
|
|
798
803
|
/* ①(NP-4)③ 进度条可拖动:命中带绝对定位在 bar 上(**不进**那一列的 flex 流),
|
|
799
804
|
所以时钟与轨道的布局一个像素都不动。轨道只有 3px,命中带上下各补 NP_SCRUB_PAD。
|
|
@@ -850,7 +855,7 @@ function createNowPlaying(deps) {
|
|
|
850
855
|
h("span", { className: "mpw_np_volRun", style: { width: (vol * 100) + "%" } }),
|
|
851
856
|
h("span", { className: "mpw_np_volKnob", style: { left: (vol * 100) + "%" } }))) : null,
|
|
852
857
|
/* 数据源没有时长时**不假装**:显示 --:--(见 docs/NOW-PLAYING-DSH.md §4 情形②) */
|
|
853
|
-
h("span", null, known ? ("−" + M.clock(M.remain(at))) : "--:--"))),
|
|
858
|
+
h("span", null, known ? ("−" + M.clock(M.remain(at, axis))) : "--:--"))),
|
|
854
859
|
h("button", {
|
|
855
860
|
className: "mpw_np_tap", type: "button",
|
|
856
861
|
onClick: () => { if (q.onToggle) q.onToggle(); },
|
|
@@ -1677,6 +1682,9 @@ function createNowPlaying(deps) {
|
|
|
1677
1682
|
}
|
|
1678
1683
|
|
|
1679
1684
|
return {
|
|
1685
|
+
/* ①(2026-09-21) 纯渲染函数也对外:**显示面**(进度条填充宽度、"−剩余"文本)要能在
|
|
1686
|
+
无浏览器门禁里对着产出的元素树断言 —— 只测控制器状态测不到"数据对、显示错"
|
|
1687
|
+
(真机 bug:媒体 100.05s,卡片按上游常量 214 画)。判据:tools/np-control-test.mjs 的 X 组。 */
|
|
1680
1688
|
NowPlaying, PlayMark, createController, createSlotAction, shouldHide, resolveAnchor, sidebarRoot, sidebarCol, occupantOf,
|
|
1681
1689
|
};
|
|
1682
1690
|
}
|
package/package.json
CHANGED