@xmov/avatar 2.0.0-alpha.52 → 2.0.0-alpha.54

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.
@@ -14,6 +14,7 @@ export declare function XMLRequest(options: {
14
14
  cache?: {
15
15
  enable: boolean;
16
16
  };
17
+ timeout?: number;
17
18
  onProgress: (progress: number, e: ProgressEvent) => void;
18
19
  }): Promise<[boolean, ArrayBuffer | string]>;
19
20
  export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmov/avatar",
3
- "version": "2.0.0-alpha.52",
3
+ "version": "2.0.0-alpha.54",
4
4
  "description": "魔珐数字人 JS-SDK",
5
5
  "type": "module",
6
6
  "source": "src/index.ts",
@@ -10,12 +10,19 @@
10
10
  "default": "./dist/index.modern.js"
11
11
  },
12
12
  "./agent": {
13
- "types": "./src/agent/index.ts",
13
+ "types": "./dist/agent/index.d.ts",
14
14
  "require": "./dist/agent/index.cjs",
15
15
  "default": "./dist/agent/avatar.modern.js"
16
16
  },
17
17
  "./agent/umd": "./dist/agent/index.umd.js"
18
18
  },
19
+ "typesVersions": {
20
+ "*": {
21
+ "agent": [
22
+ "dist/agent/index.d.ts"
23
+ ]
24
+ }
25
+ },
19
26
  "amdName": "XmovAvatar",
20
27
  "main": "./dist/index.cjs",
21
28
  "types": "./dist/index.d.ts",
@@ -2153,10 +2153,17 @@ test("XingyunAvatarAgent rejects ambiguous non-empty config sources", () => {
2153
2153
  );
2154
2154
  });
2155
2155
 
2156
- test("XingyunAvatarAgent extends XmovAvatar and preserves base speak", async () => {
2156
+ test("XingyunAvatarAgent extends XmovAvatar while keeping the base class behavior unchanged", async () => {
2157
2157
  MockWebSocket.instances = [];
2158
2158
  const { agent } = createAgent();
2159
2159
  const XmovAvatar = require("../../index");
2160
+ const avatar = new XmovAvatar({});
2161
+
2162
+ avatar.speak("legacy TTSA speak");
2163
+ const interruptedSpeechId = avatar.interrupt("speak");
2164
+ assert.deepEqual(avatar.speeches, [["legacy TTSA speak"]]);
2165
+ assert.deepEqual(avatar.interrupts, ["speak"]);
2166
+ assert.equal(interruptedSpeechId, 1);
2160
2167
 
2161
2168
  await agent.init();
2162
2169
  const ws = MockWebSocket.instances[0];
@@ -2167,11 +2174,6 @@ test("XingyunAvatarAgent extends XmovAvatar and preserves base speak", async ()
2167
2174
 
2168
2175
  agent.start();
2169
2176
  assert.equal(agent.startCalls, 1);
2170
- agent.speak("legacy TTSA speak");
2171
- assert.deepEqual(agent.speeches, [["legacy TTSA speak"]]);
2172
- assert.deepEqual(ws.sent, []);
2173
- agent.avatarOptions.onVoiceStateChange("end");
2174
- assert.deepEqual(ws.sent, []);
2175
2177
  });
2176
2178
 
2177
2179
  test("XingyunAvatarAgent restarts TTSA after same-session ready without leaving stopped state", async () => {
@@ -2200,7 +2202,7 @@ test("XingyunAvatarAgent ignores an explicit start while init is pending", () =>
2200
2202
  assert.equal(agent.started, false);
2201
2203
  });
2202
2204
 
2203
- test("XingyunAvatarAgent sends frozen ask and speak payloads without client IDs or providers", async () => {
2205
+ test("XingyunAvatarAgent sends frozen ask and canonical speak payloads without client IDs or providers", async () => {
2204
2206
  MockWebSocket.instances = [];
2205
2207
  const conversations = [];
2206
2208
  const { agent } = createAgent({
@@ -2215,15 +2217,34 @@ test("XingyunAvatarAgent sends frozen ask and speak payloads without client IDs
2215
2217
  assert.deepEqual(askPayload, { type: "ask", message: { text: "hello" } });
2216
2218
  assert.deepEqual(conversations.at(-1), { state: "asking", text: "hello" });
2217
2219
 
2218
- await agent.speakByE2E("welcome");
2220
+ const speakResult = agent.speak("welcome");
2221
+ assert.equal(speakResult, undefined);
2219
2222
  const speakPayload = JSON.parse(ws.sent.at(-1));
2220
2223
  assert.deepEqual(speakPayload, {
2221
2224
  type: "speak",
2222
2225
  message: { text: "welcome", is_start: true, is_end: true },
2223
2226
  });
2227
+ assert.deepEqual(conversations.at(-1), { state: "speaking-directly", text: "welcome" });
2228
+
2229
+ const e2eSentCount = ws.sent.length;
2230
+ const replayExtra = { client_speak_id: "replay", client_frame: 7 };
2231
+ const replayResult = agent.speak("<speak>replay</speak>", false, true, replayExtra);
2232
+ assert.equal(replayResult, undefined);
2233
+ assert.deepEqual(agent.speeches.at(-1), ["<speak>replay</speak>", false, true, replayExtra]);
2234
+ assert.equal(ws.sent.length, e2eSentCount);
2235
+ agent.avatarOptions.onVoiceStateChange("end", 1200);
2236
+ assert.equal(ws.sent.length, e2eSentCount);
2237
+
2238
+ const sentCount = ws.sent.length;
2239
+ await agent.speakByE2E("deprecated welcome");
2240
+ assert.equal(ws.sent.length, sentCount + 1);
2241
+ assert.deepEqual(JSON.parse(ws.sent.at(-1)), {
2242
+ type: "speak",
2243
+ message: { text: "deprecated welcome", is_start: true, is_end: true },
2244
+ });
2224
2245
  });
2225
2246
 
2226
- test("XingyunAvatarAgent interrupts E2E and local playback without client IDs", async () => {
2247
+ test("XingyunAvatarAgent interrupts E2E and local playback through canonical and deprecated methods", async () => {
2227
2248
  MockWebSocket.instances = [];
2228
2249
  const { agent } = createAgent();
2229
2250
 
@@ -2231,9 +2252,16 @@ test("XingyunAvatarAgent interrupts E2E and local playback without client IDs",
2231
2252
  const ws = MockWebSocket.instances[0];
2232
2253
  await agent.ask("cancel me");
2233
2254
 
2234
- await agent.interruptConversation("user");
2255
+ const interruptResult = agent.interrupt();
2256
+ assert.equal(interruptResult, 1);
2235
2257
  assert.deepEqual(agent.interrupts, ["speak"]);
2236
2258
  assert.deepEqual(JSON.parse(ws.sent.at(-1)), { type: "interrupt" });
2259
+
2260
+ const sentCount = ws.sent.length;
2261
+ await agent.interruptConversation("user");
2262
+ assert.equal(ws.sent.length, sentCount + 1);
2263
+ assert.deepEqual(agent.interrupts, ["speak", "speak"]);
2264
+ assert.deepEqual(JSON.parse(ws.sent.at(-1)), { type: "interrupt" });
2237
2265
  });
2238
2266
 
2239
2267
  test("XingyunAvatarAgent keeps ASR listening when interrupting a conversation", async () => {
@@ -2248,7 +2276,7 @@ test("XingyunAvatarAgent keeps ASR listening when interrupting a conversation",
2248
2276
  const recorder = browser.recorders[0];
2249
2277
  ws.sent = [];
2250
2278
 
2251
- await agent.interruptConversation("user");
2279
+ agent.interrupt();
2252
2280
  const audioAfterInterrupt = new Blob(["audio-after-interrupt"], {
2253
2281
  type: "audio/webm;codecs=opus",
2254
2282
  });
@@ -262,7 +262,9 @@ export default class XingyunAvatarAgent extends XmovAvatar {
262
262
  return this.agentState;
263
263
  }
264
264
 
265
- // Kept for callers that adopted the first Agent preview.
265
+ /**
266
+ * @deprecated Use getAgentState() instead.
267
+ */
266
268
  getState() {
267
269
  return this.getAgentState();
268
270
  }
@@ -460,13 +462,6 @@ export default class XingyunAvatarAgent extends XmovAvatar {
460
462
  }
461
463
  }
462
464
 
463
- override speak(...args: Parameters<XmovAvatar["speak"]>) {
464
- if (args[2] ?? true) {
465
- this.suppressNextVoiceEnd = true;
466
- }
467
- return super.speak(...args);
468
- }
469
-
470
465
  override async destroy(reason = "user") {
471
466
  if (this.agentDestroyed) {
472
467
  if (this.agentState !== "destroyed") {
@@ -510,7 +505,16 @@ export default class XingyunAvatarAgent extends XmovAvatar {
510
505
  });
511
506
  }
512
507
 
513
- async speakByE2E(text: string): Promise<void> {
508
+ override speak(text: string): string | undefined {
509
+ // XmovAvatar Replay dispatches recorded TTSA events through this.speak(...).
510
+ // Preserve that inherited multi-argument call path without exposing it as
511
+ // the Agent's public E2E API.
512
+ if (arguments.length > 1) {
513
+ if (arguments[2] ?? true) {
514
+ this.suppressNextVoiceEnd = true;
515
+ }
516
+ return super.speak(text, arguments[1], arguments[2], arguments[3]);
517
+ }
514
518
  this.assertRunning();
515
519
  this.suppressNextVoiceEnd = false;
516
520
  this.emitConversation({ state: "speaking-directly", text });
@@ -522,6 +526,14 @@ export default class XingyunAvatarAgent extends XmovAvatar {
522
526
  is_end: true,
523
527
  },
524
528
  });
529
+ return undefined;
530
+ }
531
+
532
+ /**
533
+ * @deprecated Use speak(text) instead.
534
+ */
535
+ async speakByE2E(text: string): Promise<void> {
536
+ this.speak(text);
525
537
  }
526
538
 
527
539
  async startASR(options: AgentASRStartOptions = {}): Promise<void> {
@@ -670,18 +682,25 @@ export default class XingyunAvatarAgent extends XmovAvatar {
670
682
  }
671
683
  }
672
684
 
673
- async interruptConversation(reason = "user") {
685
+ override interrupt(): number {
674
686
  if (this.agentDestroyed) {
675
- return;
687
+ return 0;
676
688
  }
677
689
 
678
- void reason;
679
-
680
690
  this.sendControl({
681
691
  type: "interrupt",
682
692
  });
683
- super.interrupt("speak");
693
+ const interruptedSpeechId = super.interrupt("speak");
684
694
  this.emitConversation({ state: "interrupted" });
695
+ return interruptedSpeechId;
696
+ }
697
+
698
+ /**
699
+ * @deprecated Use interrupt() instead. The reason parameter is ignored.
700
+ */
701
+ async interruptConversation(reason = "user"): Promise<void> {
702
+ void reason;
703
+ this.interrupt();
685
704
  }
686
705
 
687
706
  protected override async onSessionReloaded(sessionInfo: ISessionResponse): Promise<void> {
@@ -717,10 +736,6 @@ export default class XingyunAvatarAgent extends XmovAvatar {
717
736
  }
718
737
  }
719
738
 
720
- override reloadSuccess() {
721
- super.reloadSuccess();
722
- }
723
-
724
739
  private async replaceE2EConnection(sessionInfo?: AgentSessionInfo) {
725
740
  const { wsUrl, token } = this.resolveE2EConnection(sessionInfo);
726
741
  const previousClient = this.e2eClient;
@@ -840,6 +840,21 @@ export default class RenderScheduler {
840
840
  }])
841
841
  }
842
842
 
843
+ /**
844
+ * 重连成功恢复(含窗口内 socket.io 自动重连恢复、窗口外重开房间):
845
+ * 数据缓存在断网进入离线时已清空(_offlineMode/_offlineRun),此处仅恢复播放状态:
846
+ * - 标记帧号对齐:首个 BODY/AUDIO 数据到达时按 sf 重设帧时钟
847
+ * - 恢复音频播放
848
+ * - 解码器退出离线模式(重置 _offlineLastFrame、清空离线队列)
849
+ * 避免离线 idle 播放推进的帧号领先服务端续推数据导致人物卡死、音画不同步
850
+ */
851
+ markReconnectRecover() {
852
+ // 恢复音频播放
853
+ this.audioRenderer.resume();
854
+ // 解码器退出离线模式(重置 _offlineLastFrame、清空离线队列)
855
+ this.decoder._reload();
856
+ }
857
+
843
858
  _offlineMode() {
844
859
  this.dataCacheQueue._clearEvent();
845
860
  const currentFrame = this.getCurrentFrameIndex() ?? 0;
@@ -55,6 +55,7 @@ interface ITtsaOptions {
55
55
  enterOfflineMode: () => void;
56
56
  reStartSDK: () => void;
57
57
  reconnect_client_timeout: number;
58
+ reconnect_timeout: number;
58
59
  sendVoiceEnd: () => void;
59
60
  getCurrentFrameIndex: () => number;
60
61
  }
@@ -77,7 +78,9 @@ export default class Ttsa {
77
78
  private enterOfflineMode: () => void;
78
79
  private reStartSDK: () => void;
79
80
  private reconnect_client_timeout: number;
80
- private reconnectTimer = -1;
81
+ private reconnect_timeout: number;
82
+ private reconnectTimer = -1; // 重开房间定时器(reconnect_client_timeout 后 stopSession + reStartSDK)
83
+ private socketReconnectTimer = -1; // 停止 socket.io 自动重连定时器(reconnect_timeout 后旧房间已失效)
81
84
  private wsConnectTimer = 0;
82
85
  private enterOfflineFlag = false;
83
86
  private sendVoiceEnd: () => void;
@@ -94,13 +97,26 @@ export default class Ttsa {
94
97
  private readonly HEARTBEAT_INTERVAL = 2000;
95
98
 
96
99
  /**
97
- * 清除WebSocket重连定时器
100
+ * 清除WebSocket重连定时器(含停止 socket.io 自动重连的定时器)
98
101
  */
99
102
  public clearReconnectTimer(): void {
100
103
  if (this.reconnectTimer !== -1) {
101
104
  clearTimeout(this.reconnectTimer);
102
105
  this.reconnectTimer = -1;
103
106
  }
107
+ // 一并清理停止 socket.io 自动重连的定时器:网络恢复后交由 socket.io 自行重连,
108
+ // 不再强制断开(下次 disconnect 会重新按窗口设置)
109
+ this.clearSocketReconnectTimer();
110
+ }
111
+
112
+ /**
113
+ * 清除停止 socket.io 自动重连的定时器
114
+ */
115
+ private clearSocketReconnectTimer(): void {
116
+ if (this.socketReconnectTimer !== -1) {
117
+ clearTimeout(this.socketReconnectTimer);
118
+ this.socketReconnectTimer = -1;
119
+ }
104
120
  }
105
121
 
106
122
  /**
@@ -146,6 +162,7 @@ export default class Ttsa {
146
162
  this.enterOfflineMode = options.enterOfflineMode;
147
163
  this.reStartSDK = options.reStartSDK;
148
164
  this.reconnect_client_timeout = options.reconnect_client_timeout;
165
+ this.reconnect_timeout = options.reconnect_timeout;
149
166
  this.sendVoiceEnd = options.sendVoiceEnd;
150
167
  this.getCurrentFrameIndex = options.getCurrentFrameIndex;
151
168
 
@@ -154,6 +171,9 @@ export default class Ttsa {
154
171
  token: this.token,
155
172
  },
156
173
  transports: ['websocket'],
174
+ // 自动重连不限制次数:在 reconnect_timeout(服务端断开旧房间的窗口)内持续尝试,
175
+ // 失效房间必然重连失败;超过窗口由 disconnect 处理器中的停止定时器断开,
176
+ // 超过 reconnect_client_timeout 由重开房间定时器兜底(stopSession + reStartSDK)
157
177
  reconnection: true,
158
178
  reconnectionAttempts: Infinity,
159
179
  reconnectionDelay: 1000,
@@ -167,17 +187,21 @@ export default class Ttsa {
167
187
  this.initWsTimeoutTimer();
168
188
  // 启动客户端心跳
169
189
  this.startHeartbeat();
190
+ // 重置离线处理标志:重连成功后允许下次断网正常进入离线/重连流程
191
+ this.enterOfflineFlag = false;
170
192
  (window as any).performanceTracker.markEnd(performanceConstant.ttsa_connect);
171
193
  const payload = {
172
194
  room: this.room,
173
195
  client_type: "web",
174
196
  invisible_mode: this.sdk.getPendingInvisibleMode(),
175
197
  };
176
- // TODO: 临时处理,待后端修复
177
- if(this.sdk.getStatus()=== AvatarStatus.offline) {
178
- this.sdk.onStatusChange(AvatarStatus.online);
179
- }
180
198
  this.send("enter_room", payload);
199
+ // 窗口内自动重连恢复:服务端可能不重发 first_start_timestamp,
200
+ // 若仅依赖 reloadSuccess 补发 online/恢复播放,状态会卡在离线导致重连后卡死。
201
+ // 因此重连成功即恢复(仅初始化完成且此前已离线时;初次启动 status 为 -1 不受影响)
202
+ if (this.sdk.getStatus() === AvatarStatus.offline && this.sdk.getIsInitialized()) {
203
+ this.reloadSuccess();
204
+ }
181
205
  });
182
206
  ws.on("first_start_timestamp", (e: any) => {
183
207
  (window as any).avatarSDKLogger.log(this.TAG, "first_start_timestamp", e);
@@ -339,11 +363,23 @@ export default class Ttsa {
339
363
  // 无网断开则进入离线模式
340
364
  this.enterOfflineMode();
341
365
  }
342
- // 发起定时器,getReconnectClientTimeout时间内重连socket,超过时间则发起reStartSession
343
- // 只有在网络断开时才设置重连定时器,网络恢复时由NetworkMonitor处理
344
- if (!navigator.onLine) {
345
- // 清除可能存在的旧定时器
346
- this.clearReconnectTimer();
366
+ // 重连窗口策略(不再依赖 navigator.onLine,socket 断开即启动):
367
+ // ① socketReconnectTimer(reconnect_timeout):窗口内 socket.io 自动重连;
368
+ // 服务端会在 reconnect_timeout 后断开旧房间,因此窗口结束即停止无谓的自动重连
369
+ // ② reconnectTimer(reconnect_client_timeout):仍未能重连则停止旧连接并重开房间
370
+ // 均仅首次设置,避免 socket.io 重连失败反复触发 disconnect 重置定时器导致永不执行;
371
+ // 重连成功时 connect 处理器、网络恢复时 NetworkMonitor -> clearReconnectTimer 都会取消
372
+ if (this.socketReconnectTimer === -1 && this.reconnect_timeout > 0) {
373
+ this.socketReconnectTimer = window.setTimeout(() => {
374
+ try {
375
+ ws.disconnect();
376
+ } catch (error) {
377
+ (window as any).avatarSDKLogger.warn(this.TAG, "disconnect error", error);
378
+ }
379
+ this.socketReconnectTimer = -1;
380
+ }, this.reconnect_timeout * 1000);
381
+ }
382
+ if (this.reconnectTimer === -1) {
347
383
  this.reconnectTimer = window.setTimeout(async () => {
348
384
  try {
349
385
  ws.disconnect();
package/src/index.ts CHANGED
@@ -104,7 +104,6 @@ export default class XmovAvatar {
104
104
  private pcmCaptureMode: 'webcodec' | 'mediarecorder' | 'none' = 'none';
105
105
  private pcmEncodeId: number = 0; // 用于 reset 竞态检测,递增标识当前编码会话
106
106
 
107
- private reconnectDebounceTimer = -1; // 重连防抖定时器
108
107
  private networkInfo: any = {
109
108
  rtt: 0,
110
109
  downlink: 0.0,
@@ -200,6 +199,14 @@ export default class XmovAvatar {
200
199
  return this.status
201
200
  }
202
201
 
202
+ /**
203
+ * 初始化是否完成(progress === 100)
204
+ * 供 TTSA 重连恢复时判断是否可提前恢复在线状态
205
+ */
206
+ public getIsInitialized() {
207
+ return this.isInitialized
208
+ }
209
+
203
210
  public get businessENV() {
204
211
  return this._env
205
212
  }
@@ -294,6 +301,10 @@ export default class XmovAvatar {
294
301
  // 标记初始化完成
295
302
  this.isInitialized = progress === 100;
296
303
  if(progress === 100) {
304
+ // 初始化完成:若 start 时因未初始化完成而未上线(状态守卫),此处补发 online
305
+ if (!this.pendingInvisibleMode && this.status !== AvatarStatus.online && this.status !== AvatarStatus.invisible) {
306
+ this.onStatusChange(AvatarStatus.online);
307
+ }
297
308
  this.resourceManager._setOfflineCache();
298
309
  this.ttsa?.sendSdkPoint('loadvideo_decode_time', {
299
310
  timestamp: Date.now(),
@@ -1103,6 +1114,7 @@ export default class XmovAvatar {
1103
1114
  token: sessionInfo.token,
1104
1115
  framedata_proto_version: this.resourceManager.getConfig().framedata_proto_version,
1105
1116
  reconnect_client_timeout: sessionInfo?.reconnect_client_timeout || 0,
1117
+ reconnect_timeout: sessionInfo?.reconnect_timeout || 0,
1106
1118
  appInfo: {
1107
1119
  ...this.resourceManager.getAppInfo(),
1108
1120
  env: this._env
@@ -1111,7 +1123,7 @@ export default class XmovAvatar {
1111
1123
  this.ttsa?.sendSdkPoint('connect_sdk', {
1112
1124
  timestamp: this.startConnectTime,
1113
1125
  });
1114
- this.onDownloadProgress?.(85);
1126
+ // 不再强行上报 85:进度以真实资源下载为准,避免重连时 UI 进度被错误拉低
1115
1127
  this.ttsa?.sendSdkPoint('connect_sdk_success', {
1116
1128
  timestamp: this.connectSuccessTime,
1117
1129
  });
@@ -1165,16 +1177,18 @@ export default class XmovAvatar {
1165
1177
  }
1166
1178
 
1167
1179
  protected start() {
1168
- this.renderScheduler.render();
1169
- this.ttsa?.start();
1170
-
1171
- // 如果初始化时设置了隐身模式,则立即进入隐身状态
1180
+ // 先发布在线状态,再启动渲染:
1181
+ // 避免先 render() 触发 rendering -> visible(5) 后再发 online(0),造成 0/5 状态横跳
1172
1182
  if (this.pendingInvisibleMode) {
1173
1183
  this.pendingInvisibleMode = false;
1174
1184
  this.setInvisibleMode()
1175
- } else {
1185
+ } else if (this.isInitialized) {
1186
+ // 状态守卫:仅在初始化完成(progress === 100)时上线,
1187
+ // 避免重连重建房间过程中(资源未下载完)反复触发 online -> visible 状态横跳
1176
1188
  this.onStatusChange(AvatarStatus.online);
1177
1189
  }
1190
+ this.renderScheduler.render();
1191
+ this.ttsa?.start();
1178
1192
 
1179
1193
  }
1180
1194
 
@@ -1221,7 +1235,14 @@ export default class XmovAvatar {
1221
1235
  }
1222
1236
 
1223
1237
  reloadSuccess() {
1224
- this.onStatusChange(AvatarStatus.online);
1238
+ // 重连成功恢复:恢复音频、重置解码器离线状态、清空旧数据缓存,并按首个数据 sf 对齐帧号,
1239
+ // 解决窗口内自动重连恢复后人物卡死(帧号错位数据被过滤)、无声音/音画不同步的问题
1240
+ this.renderScheduler?.markReconnectRecover();
1241
+ // 状态守卫:仅初始化完成时上线;未完成时由 onDownloadProgress(100) 补发,
1242
+ // 避免重连重建房间过程中初始化未完成却提前进入 online
1243
+ if (this.isInitialized) {
1244
+ this.onStatusChange(AvatarStatus.online);
1245
+ }
1225
1246
  setTimeout(() => {
1226
1247
  window.clearInterval(this._offlineTimer)
1227
1248
  }, 1000)
@@ -1636,28 +1657,13 @@ export default class XmovAvatar {
1636
1657
  }
1637
1658
 
1638
1659
  private triggerReconnect() {
1639
- // 如果已经在重连中,直接返回,避免重复触发
1640
- if (this.isRetrying) {
1641
- (window as any).avatarSDKLogger.warn(this.TAG, "正在重连中,跳过本次触发");
1642
- return;
1643
- }
1644
-
1645
- if (this.reconnectDebounceTimer !== -1) {
1646
- clearTimeout(this.reconnectDebounceTimer);
1647
- this.reconnectDebounceTimer = -1;
1648
- }
1649
- if (this.retryTimer !== -1) {
1650
- clearTimeout(this.retryTimer);
1651
- this.retryTimer = -1;
1652
- }
1653
-
1654
- this.reconnectDebounceTimer = window.setTimeout(() => {
1655
- this.isRetrying = false;
1656
- this.retryCount = 1;
1657
- this.retryRound = 1;
1658
- this.reStartSession();
1659
- this.reconnectDebounceTimer = -1;
1660
- }, 100);
1660
+ // 网络恢复触发:无论是否正在退避重试,都取消挂起的重试定时器并立即重连(重开房间),
1661
+ // 避免网络恢复后仍需等待当前退避窗口(最长 32s)才重连
1662
+ this.clearAllRetryTimers();
1663
+ this.isRetrying = false;
1664
+ this.retryCount = 1;
1665
+ this.retryRound = 1;
1666
+ this.reStartSession();
1661
1667
  }
1662
1668
 
1663
1669
  reStartSession() {
@@ -1683,8 +1689,9 @@ export default class XmovAvatar {
1683
1689
  delay = Math.pow(2, this.retryCount) * 1000;
1684
1690
  }
1685
1691
 
1686
- // 使用setTimeout代替递归,避免调用栈溢出
1687
- setTimeout(async () => {
1692
+ // 使用setTimeout代替递归,避免调用栈溢出;记录句柄以便网络恢复时取消挂起的退避重试
1693
+ this.retryTimer = window.setTimeout(async () => {
1694
+ this.retryTimer = -1;
1688
1695
  if (!this.isRetrying || this.destroyed) {
1689
1696
  return;
1690
1697
  }
@@ -1759,12 +1766,7 @@ export default class XmovAvatar {
1759
1766
  }, delay);
1760
1767
  }
1761
1768
  private clearAllRetryTimers() {
1762
- // 清理重连防抖定时器
1763
- if (this.reconnectDebounceTimer !== -1) {
1764
- clearTimeout(this.reconnectDebounceTimer);
1765
- this.reconnectDebounceTimer = -1;
1766
- }
1767
- // 清理重试定时器
1769
+ // 清理重试定时器(含退避重试挂起的定时器)
1768
1770
  if (this.retryTimer !== -1) {
1769
1771
  clearTimeout(this.retryTimer);
1770
1772
  this.retryTimer = -1;
@@ -70,20 +70,32 @@ export function XMLRequest(options: {
70
70
  cache?: {
71
71
  enable: boolean
72
72
  }
73
+ timeout?: number
73
74
  onProgress: (progress: number, e: ProgressEvent) => void
74
75
  }): Promise<[boolean, ArrayBuffer | string]> {
75
76
  return new Promise((resolve, reject) => {
76
77
  const xhr = new XMLHttpRequest();
77
78
  xhr.open('GET', options.url);
78
79
  xhr.responseType = 'arraybuffer';
80
+ // 超时兜底:断网/请求挂起时不再永久等待(默认 60s,资源下载可能超时失败)
81
+ xhr.timeout = options.timeout ?? 60000;
79
82
  xhr.onload = function() {
80
83
  if (xhr.readyState === 4 && xhr.status === 200) {
81
84
  const arrayBuffer = xhr.response;
82
85
  resolve([false, arrayBuffer]);
83
- } else if (xhr.status !== 200) {
84
- reject([true, xhr.statusText]);
86
+ } else {
87
+ reject([true, `HTTP ${xhr.status} ${xhr.statusText}`]);
85
88
  }
86
89
  };
90
+ xhr.onerror = function() {
91
+ reject([true, 'network error']);
92
+ };
93
+ xhr.ontimeout = function() {
94
+ reject([true, `timeout after ${xhr.timeout}ms`]);
95
+ };
96
+ xhr.onabort = function() {
97
+ reject([true, 'aborted']);
98
+ };
87
99
  xhr.onprogress = (e) => {
88
100
  if (e.lengthComputable) {
89
101
  const percent = Math.round((e.loaded / e.total) * 100);