softPhone.js 43 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001
  1. // softPhone.js - WebPhone核心逻辑与配置管理
  2. import * as JsSIP from 'jssip';
  3. export const RINGBACK_AUDIO_URL = '/assets/voice/ringback.wav';
  4. export const IPCC_DEFAULTS = {
  5. SERVER_PROD: 'sip.ylrzcloud.com',
  6. CONNECT_TIMEOUT: 8000,
  7. RECONNECT_CONNECT_TIMEOUT: 3000,
  8. RECONNECT_DELAY_MS: 100,
  9. RECONNECT_DELAY_IN_CALL_MS: 150,
  10. HEARTBEAT_INTERVAL: 16,
  11. TOKEN_TTL_MS: 60 * 60 * 1000,
  12. GATEWAY_RELEASE_DELAY_MS: 200,
  13. SERVER_RELEASE_DELAY_MS: 300,
  14. DISCONNECT_WAIT_MS: 800,
  15. AGENT_READY_TIMEOUT_MS: 3000,
  16. SIP_REGISTER_TIMEOUT_MS: 8000,
  17. READY_RESTORE_INITIAL_MS: 50,
  18. READY_RESTORE_INTERVAL_MS: 120,
  19. READY_RESTORE_MAX_ATTEMPTS: 12,
  20. STATUS_BUSY_DELAY_MS: 150,
  21. POST_CALL_READY_DEADLINE_MS: 3000
  22. };
  23. const SESSION_CONFIRMED_STATUS = 9;
  24. /** JsSIP RTCSession.STATUS_ANSWERED — 已发送 200,不可再次 answer */
  25. const SESSION_ANSWERED_STATUS = 5;
  26. /** JsSIP RTCSession.STATUS_WAITING_FOR_ACK */
  27. const SESSION_WAITING_ACK_STATUS = 6;
  28. /** JsSIP RTCSession.STATUS_1XX_RECEIVED — 可 answer 的状态含 2/3/4 */
  29. const SESSION_ANSWERABLE_STATUSES = [2, 3, 4];
  30. export const JS_SIP_DEFAULTS = {
  31. SERVER: 'wss://sip.ylrzcloud.com:8443',
  32. DOMAIN: 'sip.ylrzcloud.com',
  33. TRANSPORT: 'wss',
  34. USER_AGENT: 'JsSIP',
  35. SESSION_EXPIRES: 180,
  36. MIN_SESSION_EXPIRES: 90,
  37. SPEAKER_VOLUME: 0.8,
  38. MIC_VOLUME: 0.8,
  39. RECONNECT_INTERVAL: 3,
  40. RECONNECT_TOTAL_DURATION: 60000
  41. };
  42. const toBase64 = (str) => {
  43. const bytes = new TextEncoder().encode(str);
  44. return btoa(String.fromCharCode(...bytes));
  45. };
  46. const fromBase64 = (b64) => {
  47. const binary = atob(b64);
  48. const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
  49. return new TextDecoder().decode(bytes);
  50. };
  51. const encodePassword = (pwd) => {
  52. if (!pwd) return '';
  53. try {
  54. const timestamp = Date.now().toString(36);
  55. const pwdStr = String(pwd);
  56. const encoded = toBase64(pwdStr);
  57. return `${timestamp}:${encoded}`;
  58. } catch (e) {
  59. try { return btoa(String(pwd)); } catch (fallbackError) { return ''; }
  60. }
  61. };
  62. const decodePassword = (encoded) => {
  63. if (!encoded) return '';
  64. try {
  65. let base64Part = encoded;
  66. if (encoded.includes(':')) base64Part = encoded.split(':')[1];
  67. return fromBase64(base64Part);
  68. } catch (e) { return ''; }
  69. };
  70. /** 优先约束:开启回声消除等增强;失败后自动降级 */
  71. const MIC_AUDIO_CONSTRAINTS_ENHANCED = {
  72. audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
  73. video: false
  74. };
  75. /** 兼容约束:部分浏览器/驱动对增强项会报 Requested device not found */
  76. const MIC_AUDIO_CONSTRAINTS_BASIC = {
  77. audio: true,
  78. video: false
  79. };
  80. /** JsSIP answer/call 用的媒体约束(与 BASIC 对齐,避免二次失败) */
  81. export const MIC_MEDIA_CONSTRAINTS = {
  82. audio: true,
  83. video: false
  84. };
  85. /** 兼容旧浏览器:补齐 mediaDevices.getUserMedia */
  86. export function ensureMediaDevices() {
  87. if (typeof navigator === 'undefined') return false;
  88. if (!navigator.mediaDevices) {
  89. navigator.mediaDevices = {};
  90. }
  91. if (!navigator.mediaDevices.getUserMedia) {
  92. const legacy = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
  93. if (!legacy) return false;
  94. navigator.mediaDevices.getUserMedia = (constraints) => new Promise((resolve, reject) => {
  95. legacy.call(navigator, constraints, resolve, reject);
  96. });
  97. }
  98. return typeof navigator.mediaDevices.getUserMedia === 'function';
  99. }
  100. function formatMicError(err) {
  101. const name = err?.name || '';
  102. const msg = err?.message || String(err || '');
  103. if (name === 'NotFoundError' || /Requested device not found/i.test(msg)) {
  104. return '未检测到可用麦克风,请检查设备连接后重试';
  105. }
  106. if (name === 'NotAllowedError' || name === 'PermissionDeniedError' || /Permission|Denied|Access/i.test(msg)) {
  107. return '麦克风权限被拒绝,请在浏览器地址栏允许麦克风后刷新重试';
  108. }
  109. if (name === 'NotReadableError' || name === 'TrackStartError') {
  110. return '麦克风被其他程序占用,请关闭后重试';
  111. }
  112. if (name === 'OverconstrainedError' || name === 'ConstraintNotSatisfiedError') {
  113. return '当前麦克风不支持该音频参数,已尝试兼容模式';
  114. }
  115. if (/secure|HTTPS|getUserMedia/i.test(msg)) {
  116. return '当前环境无法访问麦克风(需 HTTPS 或 localhost)';
  117. }
  118. return msg || '麦克风初始化失败';
  119. }
  120. export function releaseMediaStream(stream) {
  121. if (!stream) return;
  122. stream.getTracks().forEach((track) => {
  123. try { track.stop(); } catch (e) {}
  124. });
  125. }
  126. /**
  127. * 申请麦克风:增强约束失败时自动降级为 audio:true,提升浏览器兼容性
  128. */
  129. export async function requestMicrophoneAccess() {
  130. if (!ensureMediaDevices()) {
  131. throw new Error('浏览器不支持麦克风');
  132. }
  133. const attempts = [MIC_AUDIO_CONSTRAINTS_ENHANCED, MIC_AUDIO_CONSTRAINTS_BASIC];
  134. let lastError = null;
  135. for (let i = 0; i < attempts.length; i++) {
  136. try {
  137. const stream = await navigator.mediaDevices.getUserMedia(attempts[i]);
  138. if (stream?.getAudioTracks?.().length) {
  139. return stream;
  140. }
  141. releaseMediaStream(stream);
  142. lastError = new Error('未获取到麦克风音轨');
  143. } catch (err) {
  144. lastError = err;
  145. // 权限明确拒绝时不再降级重试
  146. const name = err?.name || '';
  147. if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
  148. break;
  149. }
  150. }
  151. }
  152. throw new Error(formatMicError(lastError));
  153. }
  154. export async function checkMicrophonePermission() {
  155. if (!ensureMediaDevices()) return false;
  156. try {
  157. if (navigator.permissions?.query) {
  158. const status = await navigator.permissions.query({ name: 'microphone' });
  159. return status.state !== 'denied';
  160. }
  161. return true;
  162. } catch (err) {
  163. return true;
  164. }
  165. }
  166. export class ProfileManager {
  167. constructor() { this.profile = null; this.load(); }
  168. load() {
  169. try {
  170. const json = localStorage.getItem('WebPhoneProfile');
  171. if (!json) this.reset();
  172. else {
  173. this.profile = JSON.parse(json);
  174. if (this.profile.users) {
  175. Object.keys(this.profile.users).forEach(uid => {
  176. const user = this.profile.users[uid];
  177. if (user.password && typeof user.password === 'string') user.password = decodePassword(user.password);
  178. });
  179. }
  180. }
  181. } catch (error) { this.reset(); }
  182. }
  183. save() {
  184. const toStore = JSON.parse(JSON.stringify(this.profile));
  185. if (toStore.users) {
  186. Object.keys(toStore.users).forEach(uid => {
  187. const user = toStore.users[uid];
  188. if (user.password && typeof user.password === 'string') user.password = encodePassword(user.password);
  189. });
  190. }
  191. localStorage.setItem('WebPhoneProfile', JSON.stringify(toStore));
  192. }
  193. reset() {
  194. this.profile = {
  195. users: {}, user: '', reconnect: true, reconnect_interval: JS_SIP_DEFAULTS.RECONNECT_INTERVAL,
  196. user_agent: JS_SIP_DEFAULTS.USER_AGENT, session_expires: JS_SIP_DEFAULTS.SESSION_EXPIRES,
  197. min_session_expires: JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES, speaker_volume: JS_SIP_DEFAULTS.SPEAKER_VOLUME,
  198. mic_volume: JS_SIP_DEFAULTS.MIC_VOLUME, speaker_paused: false, mic_paused: false,
  199. auto_answer: true, stun: false, ice_server: ''
  200. };
  201. this.save();
  202. }
  203. getProfile() { return this.profile; }
  204. getSettings() {
  205. let reconnectInterval = this.profile.reconnect_interval || 15;
  206. if (reconnectInterval > 1000) {
  207. reconnectInterval = Math.floor(reconnectInterval / 1000);
  208. this.profile.reconnect_interval = reconnectInterval;
  209. this.save();
  210. }
  211. const currentUser = this.getCurrentUserProfile() || {};
  212. return {
  213. user_agent: this.profile.user_agent, session_expires: this.profile.session_expires,
  214. min_session_expires: this.profile.min_session_expires, stun: this.profile.stun,
  215. ice_server: this.profile.ice_server,
  216. auto_answer: currentUser.auto_answer !== undefined ? currentUser.auto_answer : this.profile.auto_answer,
  217. reconnect: this.profile.reconnect, reconnect_interval: reconnectInterval
  218. };
  219. }
  220. updateSettings(settings) { Object.assign(this.profile, settings); this.save(); }
  221. resetSettings() {
  222. this.profile.reconnect = true;
  223. this.profile.reconnect_interval = JS_SIP_DEFAULTS.RECONNECT_INTERVAL;
  224. this.profile.user_agent = JS_SIP_DEFAULTS.USER_AGENT;
  225. this.profile.session_expires = JS_SIP_DEFAULTS.SESSION_EXPIRES;
  226. this.profile.min_session_expires = JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES;
  227. this.profile.auto_answer = false;
  228. this.profile.stun = false;
  229. this.profile.ice_server = '';
  230. this.save();
  231. }
  232. getCurrentUserProfile() { return this.profile.users?.[this.profile.user] || null; }
  233. addUser(profile) {
  234. if (!profile.user || !profile.domain || !profile.password) throw new Error('登录名、域名和密码为必填项');
  235. const userId = `${profile.user}@${profile.domain}`;
  236. if (this.profile.users[userId]) this.profile.users[userId] = { ...this.profile.users[userId], ...profile, user: profile.user, domain: profile.domain };
  237. else this.profile.users[userId] = profile;
  238. this.profile.user = userId;
  239. this.save();
  240. }
  241. updateUser(userId, updatedProfile) {
  242. if (this.profile.users[userId]) {
  243. const newUser = updatedProfile.user, newDomain = updatedProfile.domain;
  244. const newUserId = `${newUser}@${newDomain}`;
  245. if (newUserId !== userId) {
  246. const merged = { ...this.profile.users[userId], ...updatedProfile, user: newUser, domain: newDomain };
  247. delete this.profile.users[userId];
  248. this.profile.users[newUserId] = merged;
  249. if (this.profile.user === userId) this.profile.user = newUserId;
  250. } else this.profile.users[userId] = { ...this.profile.users[userId], ...updatedProfile, user: newUser, domain: newDomain };
  251. this.save();
  252. }
  253. }
  254. deleteCurrentUser() { if (this.profile.user) delete this.profile.users[this.profile.user]; const keys = Object.keys(this.profile.users); this.profile.user = keys.length > 0 ? keys[0] : ''; this.save(); }
  255. switchUser(userId) { if (this.profile.users[userId]) { this.profile.user = userId; this.save(); } }
  256. }
  257. export class WebPhone {
  258. constructor(profile, settings) {
  259. this.profile = profile; this.settings = settings; this.session = null; this.ua = null;
  260. this.call_id = this.randomUUID(); this.events = {}; this.callTimerId = null; this.dtfmTimerId = null; this.audioCtx = null; this.oscillatorLow = null;
  261. this.oscillatorHigh = null; this.gainNode = null; this.reconnectEnabled = settings.reconnect;
  262. this.reconnectAttempts = 0; this.reconnectStartTime = null;
  263. this.reconnectTotalDuration = JS_SIP_DEFAULTS.RECONNECT_TOTAL_DURATION;
  264. this.isReconnecting = false; this.reconnectTimerId = null; this._isHandlingDisconnect = false;
  265. this._deferredReconnectAfterCall = false;
  266. this._intentionalTerminate = false;
  267. this._intentionalTerminateUntil = 0;
  268. this.ringbackMedia = new Audio(RINGBACK_AUDIO_URL); this.ringbackMedia.loop = true;
  269. this.remoteMedia = new Audio(); this.remoteMedia.autoplay = true;
  270. this.remoteMedia.setAttribute('playsinline', 'true');
  271. this.localMedia = new Audio(); this.peerConnection = null;
  272. this._mountPlaybackElement(this.ringbackMedia, 'sip-ringback-audio');
  273. this._mountPlaybackElement(this.remoteMedia, 'sip-remote-audio');
  274. this.localStream = null;
  275. this._speakerPaused = false;
  276. this._speakerVolume = this.profile?.speaker_volume ?? JS_SIP_DEFAULTS.SPEAKER_VOLUME;
  277. this._answering = false;
  278. this._iceIssueTimer = null;
  279. this._remoteAudioSyncTimers = [];
  280. this._remotePlayRetryTimer = null;
  281. this.initUA();
  282. }
  283. _unmountPlaybackElement(id) {
  284. if (typeof document === 'undefined' || !id) return;
  285. const existing = document.getElementById(id);
  286. if (existing) {
  287. try {
  288. existing.pause();
  289. if (existing.srcObject) {
  290. existing.srcObject.getTracks().forEach((t) => { try { t.stop(); } catch (e) {} });
  291. existing.srcObject = null;
  292. }
  293. } catch (e) {}
  294. existing.remove();
  295. }
  296. }
  297. _mountPlaybackElement(el, id) {
  298. if (!el || typeof document === 'undefined') return;
  299. this._unmountPlaybackElement(id);
  300. el.id = id;
  301. el.setAttribute('playsinline', 'true');
  302. el.setAttribute('webkit-playsinline', 'true');
  303. el.preload = 'auto';
  304. if (!el.isConnected) {
  305. el.style.cssText = 'position:fixed;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none;';
  306. document.body.appendChild(el);
  307. }
  308. }
  309. _clearPeerConnection() {
  310. if (!this.peerConnection) return;
  311. this.peerConnection.ontrack = null;
  312. this.peerConnection.onaddstream = null;
  313. this.peerConnection.oniceconnectionstatechange = null;
  314. try { this.peerConnection.close(); } catch (err) {}
  315. this.peerConnection = null;
  316. }
  317. _clearRemotePlayback({ stopTracks = true } = {}) {
  318. this._clearRemoteAudioSyncTimers();
  319. if (this._remotePlayRetryTimer) {
  320. clearTimeout(this._remotePlayRetryTimer);
  321. this._remotePlayRetryTimer = null;
  322. }
  323. this.pauseRingback();
  324. if (this.remoteMedia) {
  325. try { this.remoteMedia.pause(); } catch (e) {}
  326. this.remoteMedia.muted = false;
  327. if (this.remoteMedia.srcObject) {
  328. if (stopTracks) {
  329. this.remoteMedia.srcObject.getTracks().forEach((t) => { try { t.stop(); } catch (e) {} });
  330. }
  331. this.remoteMedia.srcObject = null;
  332. }
  333. try { this.remoteMedia.load(); } catch (e) {}
  334. }
  335. this._clearPeerConnection();
  336. }
  337. /** 通话中远端无声时尝试恢复,不终止当前 SIP 会话 */
  338. async recoverRemotePlayback() {
  339. if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
  340. if (this.remoteMedia?.srcObject) {
  341. this._pruneEndedRemoteTracks(this.remoteMedia.srcObject);
  342. }
  343. return this.resumeRemoteAudio();
  344. }
  345. /** 新外呼/来电前清理上一轮媒体,避免频繁通话后远端无声 */
  346. async prepareForNewCall() {
  347. this._answering = false;
  348. this.clearIceIssueTimer();
  349. this._clearRemotePlayback();
  350. if (this.session) {
  351. const status = this.session.status;
  352. if (status !== 7 && status !== 8) {
  353. try { this.session.terminate({ status_code: 486 }); } catch (e) {}
  354. } else {
  355. this.session = null;
  356. }
  357. }
  358. try {
  359. await this.ensureLocalStream();
  360. } catch (e) {
  361. console.warn('[音频] 新通话前麦克风初始化失败:', e.message || e);
  362. // 向上抛出,便于界面提示;兼容降级已在 requestMicrophoneAccess 内完成
  363. throw e;
  364. }
  365. }
  366. _pruneEndedRemoteTracks(stream) {
  367. if (!stream?.getAudioTracks) return stream;
  368. stream.getAudioTracks().forEach((track) => {
  369. if (track.readyState === 'ended') {
  370. try { stream.removeTrack(track); } catch (e) {}
  371. }
  372. });
  373. return stream;
  374. }
  375. _attachRemoteTrack(track) {
  376. if (!track || track.kind !== 'audio' || track.readyState === 'ended' || !this.remoteMedia) return false;
  377. let stream = this.remoteMedia.srcObject;
  378. if (!stream) {
  379. stream = new MediaStream();
  380. this.remoteMedia.srcObject = stream;
  381. } else {
  382. this._pruneEndedRemoteTracks(stream);
  383. }
  384. if (!stream.getTracks().some((t) => t.id === track.id)) {
  385. stream.addTrack(track);
  386. }
  387. track.enabled = true;
  388. this.remoteMedia.autoplay = true;
  389. this.remoteMedia.setAttribute('playsinline', 'true');
  390. this.remoteMedia.volume = this._getEffectiveSpeakerVolume();
  391. this.remoteMedia.muted = false;
  392. this.pauseRingback();
  393. this._scheduleRemotePlayRetry(0);
  394. return true;
  395. }
  396. _scheduleRemotePlayRetry(attempt = 0) {
  397. if (this._remotePlayRetryTimer) {
  398. clearTimeout(this._remotePlayRetryTimer);
  399. this._remotePlayRetryTimer = null;
  400. }
  401. const delays = [0, 120, 300, 600, 1200, 2500];
  402. const delay = delays[Math.min(attempt, delays.length - 1)];
  403. this._remotePlayRetryTimer = setTimeout(async () => {
  404. this._remotePlayRetryTimer = null;
  405. if (!this.hasActiveCall() && !this.hasSipSession()) return;
  406. const played = await this.resumeRemoteAudio();
  407. if (!played && attempt < delays.length - 1 && (this.hasActiveCall() || this.hasSipSession())) {
  408. if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
  409. this._scheduleRemotePlayRetry(attempt + 1);
  410. }
  411. }, delay);
  412. }
  413. isRemotePlaybackPaused() {
  414. if (!this.remoteMedia) return true;
  415. if (this._speakerPaused) return false;
  416. if (this.remoteMedia.paused) return true;
  417. // MediaStream 播放时 readyState 可能长期为 0,不能作为暂停依据
  418. if (this.remoteMedia.srcObject) return false;
  419. return this.remoteMedia.readyState < 2;
  420. }
  421. isRemotePlaybackActive() {
  422. if (!this.remoteMedia || !this.hasRemoteAudioTrack()) return false;
  423. if (this._speakerPaused) return true;
  424. return !this.remoteMedia.paused;
  425. }
  426. _clearRemoteAudioSyncTimers() {
  427. this._remoteAudioSyncTimers.forEach((t) => clearTimeout(t));
  428. this._remoteAudioSyncTimers = [];
  429. }
  430. _scheduleRemoteAudioSync() {
  431. this._clearRemoteAudioSyncTimers();
  432. [300, 800, 1500, 3000, 5000, 8000, 12000].forEach((delay) => {
  433. const timerId = setTimeout(() => {
  434. if (this.hasActiveCall() || this.hasSipSession()) {
  435. if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
  436. if (!this.isRemotePlaybackActive()) {
  437. this.refreshRemoteAudio();
  438. }
  439. }
  440. }, delay);
  441. this._remoteAudioSyncTimers.push(timerId);
  442. });
  443. }
  444. randomUUID() {
  445. if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID().replace(/-/g, '');
  446. const arr = new Uint8Array(16);
  447. if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') crypto.getRandomValues(arr);
  448. else for (let i = 0; i < 16; i++) arr[i] = Math.floor(Math.random() * 256);
  449. arr[6] = (arr[6] & 0x0f) | 0x40; arr[8] = (arr[8] & 0x3f) | 0x80;
  450. return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
  451. }
  452. initUA() {
  453. if (!this.profile.server || !this.profile.user || !this.profile.domain) {
  454. this.emit('OnStatusMessage', { type: 'error', text: '配置不完整' });
  455. return;
  456. }
  457. const socket = new JsSIP.WebSocketInterface(this.profile.server);
  458. if (String(this.profile.server || '').startsWith('wss://')) socket.via_transport = 'WS';
  459. const user = String(this.profile.user || '');
  460. const displayName = this.profile.display_name ? String(this.profile.display_name) : '';
  461. const password = this.profile.password ? String(this.profile.password) : '';
  462. const server = String(this.profile.server || '');
  463. const transport = 'ws';
  464. const domain = String(this.profile.domain || '');
  465. if (!user || !domain || !password) {
  466. this.emit('OnStatusMessage', { type: 'error', text: '账号配置不完整,请检查登录名、域名和密码' });
  467. return;
  468. }
  469. if (server.startsWith('wss://') && window.location.protocol === 'http:') {
  470. console.warn('[jsSip] 警告: 页面通过 HTTP 加载,但 SIP 服务器使用 WSS 协议。浏览器会阻止混合内容');
  471. this.emit('OnStatusMessage', { type: 'warn', text: 'HTTP页面使用WSS会被浏览器阻止' });
  472. }
  473. const uri = new JsSIP.URI('sip', user, domain);
  474. const contactUriStr = `sip:${user}@${domain};transport=${transport}`;
  475. this.configuration = {
  476. sockets: [socket], authorization_user: user, user_agent: this.settings.user_agent || JS_SIP_DEFAULTS.USER_AGENT,
  477. display_name: displayName || undefined, session_timers: false,
  478. session_timers_expires: this.settings.session_expires || JS_SIP_DEFAULTS.SESSION_EXPIRES,
  479. session_timers_min_se: this.settings.min_session_expires || JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES,
  480. session_timers_force_refresher: false,
  481. no_answer_timeout: 60, register: true, uri: uri.toAor(), contact_uri: contactUriStr, password: password,
  482. log_level: 'error'
  483. };
  484. }
  485. getPcConfig() {
  486. if (!this.settings.stun) return undefined;
  487. const iceUrl = (this.settings.ice_server || 'stun:stun.l.google.com:19302').trim();
  488. if (!iceUrl) return undefined;
  489. return { iceServers: [{ urls: iceUrl }] };
  490. }
  491. hasActiveCall() {
  492. if (!this.session) return false;
  493. const status = this.session.status;
  494. return status === SESSION_CONFIRMED_STATUS || status === SESSION_WAITING_ACK_STATUS || status === SESSION_ANSWERED_STATUS;
  495. }
  496. hasSipSession() {
  497. if (!this.session) return false;
  498. const status = this.session.status;
  499. return status !== 7 && status !== 8 && status !== 0;
  500. }
  501. createUA() {
  502. this.ua = new JsSIP.UA(this.configuration);
  503. this.ua.set('display_name', this.profile.display_name);
  504. this.ua.on('connecting', this.connecting.bind(this));
  505. this.ua.on('connected', this.connected.bind(this));
  506. this.ua.on('disconnected', this.disconnected.bind(this));
  507. this.ua.on('registered', this.registered.bind(this));
  508. this.ua.on('unregistered', this.unregistered.bind(this));
  509. this.ua.on('registrationFailed', this.registrationFailed.bind(this));
  510. this.ua.on('registrationExpiring', this.registrationExpiring.bind(this));
  511. this.ua.on('newRTCSession', this.newRTCSession.bind(this));
  512. this.ua.on('transportError', this.transportError.bind(this));
  513. }
  514. On(event, callback) { this.events[event] = callback; }
  515. Off(event) { if (this.events[event]) delete this.events[event]; }
  516. emit(event, ...args) { if (this.events[event]) try { this.events[event](...args); } catch (e) { console.error(e); } }
  517. resetReconnectState() {
  518. if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
  519. this.reconnectTimerId = null; this.isReconnecting = false; this.reconnectAttempts = 0; this.reconnectStartTime = null;
  520. this.emit('OnReconnectStatus', { isReconnecting: false, failed: false });
  521. }
  522. Start(reconnect, isReconnect = false) {
  523. console.log(`[jsSip] 启动 ${reconnect ? '正常连接' : '禁用重连'} ${isReconnect ? '(重连模式)' : ''}`);
  524. this.reconnectEnabled = reconnect;
  525. if (this.ua) { try { if (this.ua.isRegistered()) this.ua.unregister(); this.ua.stop(); } catch (e) {} this.ua = null; }
  526. if (!isReconnect) this.resetReconnectState();
  527. setTimeout(() => {
  528. this.createUA();
  529. if (this.ua.isRegistered()) { this.SetQueueIn(); return; }
  530. if (this.ua.isConnected()) { this.Register(); return; }
  531. try { this.ua.start(); } catch (error) {
  532. this.emit('OnStatusMessage', { type: 'error', text: '启动失败: ' + error.message });
  533. this.scheduleReconnect();
  534. }
  535. }, 50);
  536. }
  537. Register() { if (this.ua) this.ua.register(); }
  538. UnRegister() {
  539. this.reconnectEnabled = false; this.resetReconnectState();
  540. if (this.ua) { try { if (this.ua.isRegistered()) this.ua.unregister(); this.ua.stop(); } catch (e) {} this.ua = null; }
  541. if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
  542. if (this.callTimerId) clearInterval(this.callTimerId);
  543. if (this.dtfmTimerId) clearTimeout(this.dtfmTimerId);
  544. }
  545. scheduleReconnect() {
  546. if (!this.reconnectEnabled || this.hasActiveCall() || this._isIntentionalHangupPhase()) return;
  547. if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
  548. if (this.isReconnecting) return;
  549. const now = Date.now();
  550. if (this.reconnectStartTime === null) this.reconnectStartTime = now;
  551. const elapsed = now - this.reconnectStartTime;
  552. if (elapsed >= this.reconnectTotalDuration) {
  553. console.error('[jsSip] 重连超时(超过1分钟)');
  554. this.isReconnecting = false;
  555. this.emit('OnReconnectStatus', { isReconnecting: false, failed: true });
  556. this.emit('OnStatusMessage', { type: 'error', text: '重连超时' });
  557. return;
  558. }
  559. this.reconnectAttempts++;
  560. const configuredSec = this.settings.reconnect_interval ?? JS_SIP_DEFAULTS.RECONNECT_INTERVAL;
  561. let interval = this.reconnectAttempts <= 1 ? 400 : Math.min(configuredSec * 1000, 3000);
  562. if (this.reconnectAttempts > 5) interval = 2000;
  563. if (elapsed + interval > this.reconnectTotalDuration) {
  564. const remainingTime = this.reconnectTotalDuration - elapsed;
  565. if (remainingTime < 500) {
  566. this.emit('OnReconnectStatus', { isReconnecting: false, failed: true });
  567. this.emit('OnStatusMessage', { type: 'error', text: '重连超时' });
  568. return;
  569. }
  570. interval = Math.min(interval, remainingTime);
  571. }
  572. console.log(`[jsSip] ${Math.ceil(interval / 1000)}秒后重连 (第${this.reconnectAttempts}次)`);
  573. this.isReconnecting = true;
  574. this.emit('OnReconnectStatus', { isReconnecting: true, failed: false });
  575. this.reconnectTimerId = setTimeout(() => {
  576. this.isReconnecting = false;
  577. this.reconnectTimerId = null;
  578. this.Start(true, true);
  579. }, interval);
  580. }
  581. transportError(err) {
  582. const errorMessage = err?.message || err?.reason || '传输错误';
  583. if (this.hasActiveCall() || this.hasSipSession()) {
  584. console.debug('[SIP] 传输错误(通话中,保持会话):', errorMessage);
  585. return;
  586. }
  587. console.error('[SIP] WebSocket传输错误:', errorMessage);
  588. let errorText = 'WSS连接失败';
  589. if (errorMessage.includes('SecurityError') || errorMessage.includes('mixed content')) errorText = 'WSS连接被浏览器阻止';
  590. else if (err?.code === 1006) errorText = 'WSS连接异常关闭';
  591. else if (err?.code === 1005) errorText = 'WSS连接被拒绝';
  592. this.emit('OnStatusMessage', { type: 'error', text: errorText });
  593. if (!this.hasActiveCall() && !this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
  594. }
  595. connecting() { this.emit('OnStatusMessage', { type: 'info', text: 'jsSip连接中...' }); }
  596. connected() { this.Register(); this.emit('OnStatusMessage', { type: 'success', text: 'jsSip开始注册' }); }
  597. disconnected(e) {
  598. if (this._isHandlingDisconnect) return;
  599. this._isHandlingDisconnect = true;
  600. const reason = e?.cause || e?.message || '未知原因';
  601. const inCall = this.hasActiveCall() || this.hasSipSession();
  602. if (this._isIntentionalHangupPhase()) {
  603. console.debug(`[SIP] 主动挂机后信令变化,保持注册不重连: ${reason}`);
  604. setTimeout(() => { this._isHandlingDisconnect = false; }, 400);
  605. return;
  606. }
  607. if (inCall) {
  608. console.debug(`[SIP] 信令连接断开(通话中,保持会话): ${reason}`);
  609. this._deferredReconnectAfterCall = true;
  610. } else {
  611. console.warn(`[SIP] 连接断开: ${reason}`);
  612. this.emit('OnRegister', { registered: false });
  613. if (!this.isReconnecting && this.reconnectEnabled) {
  614. this.scheduleReconnect();
  615. } else if (!this.reconnectEnabled && this.ua) {
  616. try { this.ua.stop(); } catch (err) {}
  617. this.ua = null;
  618. }
  619. }
  620. setTimeout(() => { this._isHandlingDisconnect = false; }, 400);
  621. }
  622. registered() {
  623. this._clearIntentionalHangupPhase();
  624. this.resetReconnectState();
  625. this.emit('OnRegister', { registered: true });
  626. this.emit('OnStatusMessage', { type: 'success', text: '已连接' });
  627. this.SetQueueIn();
  628. }
  629. unregistered() { this.emit('OnRegister', { registered: false }); this.emit('OnStatusMessage', { type: 'info', text: 'jsSip已注销' }); }
  630. registrationFailed(e) {
  631. const cause = e?.cause || e?.message || '未知原因';
  632. const statusCode = e?.response?.status_code || '';
  633. console.error('[jsSip] 注册失败:', cause);
  634. let errorText = '注册失败';
  635. if (cause === 'Connection Error') errorText = '注册失败: 无法连接服务器';
  636. else if (cause.includes('403') || cause.includes('Forbidden') || cause.includes('401')) errorText = '注册失败: 认证失败';
  637. else if (cause.includes('404')) errorText = '注册失败: 用户不存在';
  638. else if (cause.includes('408') || cause.includes('Timeout')) errorText = '注册失败: 请求超时';
  639. this.emit('OnRegister', { registered: false });
  640. this.emit('OnStatusMessage', { type: 'error', text: errorText });
  641. if (!this.hasActiveCall() && !this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
  642. }
  643. registrationExpiring() { this.Register(); }
  644. releaseLocalStream() {
  645. if (this.localStream) {
  646. releaseMediaStream(this.localStream);
  647. this.localStream = null;
  648. }
  649. if (this.localMedia) this.localMedia.srcObject = null;
  650. }
  651. async ensureLocalStream() {
  652. if (this.localStream?.getAudioTracks().some(t => t.readyState === 'live')) {
  653. return this.localStream;
  654. }
  655. this.releaseLocalStream();
  656. const stream = await requestMicrophoneAccess();
  657. this.localStream = stream;
  658. this.localMedia.srcObject = stream;
  659. this.localMedia.muted = true;
  660. this.localMedia.volume = 0;
  661. return stream;
  662. }
  663. _getSpeakerVolume() {
  664. return this.profile?.speaker_volume ?? JS_SIP_DEFAULTS.SPEAKER_VOLUME;
  665. }
  666. _getEffectiveSpeakerVolume() {
  667. return this._speakerPaused ? 0 : (this._speakerVolume ?? this._getSpeakerVolume());
  668. }
  669. async Answer() {
  670. if (!this.session || this._answering) return;
  671. const status = this.session.status;
  672. if (status === SESSION_CONFIRMED_STATUS || status === SESSION_ANSWERED_STATUS || status === SESSION_WAITING_ACK_STATUS) return;
  673. if (!SESSION_ANSWERABLE_STATUSES.includes(status)) return;
  674. try {
  675. await this.ensureLocalStream();
  676. } catch (e) {
  677. console.warn('[SIP] 麦克风初始化失败:', e.message || e);
  678. // 本地流失败时仍尝试用兼容约束接听,避免 JsSIP 再次用增强约束失败
  679. }
  680. const options = {
  681. // 使用兼容约束,减少 Overconstrained / Requested device not found
  682. mediaConstraints: { ...MIC_MEDIA_CONSTRAINTS }
  683. };
  684. if (this.localStream) options.mediaStream = this.localStream;
  685. const pcConfig = this.getPcConfig();
  686. if (pcConfig) options.pcConfig = pcConfig;
  687. this._answering = true;
  688. try {
  689. this.session.answer(options);
  690. } catch (err) {
  691. console.warn('[SIP] answer 失败:', err.message || err);
  692. } finally {
  693. this._answering = false;
  694. }
  695. }
  696. _scheduleAutoAnswer(attempt = 0) {
  697. if (!this.session || attempt > 30) return;
  698. const status = this.session.status;
  699. if (status === SESSION_CONFIRMED_STATUS || status === SESSION_ANSWERED_STATUS || status === SESSION_WAITING_ACK_STATUS) return;
  700. if (SESSION_ANSWERABLE_STATUSES.includes(status)) {
  701. this.Answer();
  702. return;
  703. }
  704. setTimeout(() => this._scheduleAutoAnswer(attempt + 1), 100);
  705. }
  706. markIntentionalHangup() {
  707. this._intentionalTerminate = true;
  708. this._intentionalTerminateUntil = Date.now() + 8000;
  709. this._deferredReconnectAfterCall = false;
  710. if (this.reconnectTimerId) {
  711. clearTimeout(this.reconnectTimerId);
  712. this.reconnectTimerId = null;
  713. }
  714. this.isReconnecting = false;
  715. this.emit('OnReconnectStatus', { isReconnecting: false, failed: false });
  716. }
  717. _isIntentionalHangupPhase() {
  718. return this._intentionalTerminate || Date.now() < this._intentionalTerminateUntil;
  719. }
  720. _clearIntentionalHangupPhase() {
  721. this._intentionalTerminate = false;
  722. this._intentionalTerminateUntil = 0;
  723. }
  724. Terminate(code) {
  725. this.markIntentionalHangup();
  726. if (!this.session) return;
  727. if (code) this.session.terminate({ status_code: code });
  728. else this.session.terminate();
  729. }
  730. ToggleHold() { if (this.session && this.session.isEstablished()) { if (this.session.isOnHold().local) this.session.unhold(); else this.session.hold(); } }
  731. ToggleMicPhone() {
  732. if (!this.session) return;
  733. const muted = this.session.isMuted().audio;
  734. if (muted) this.session.unmute({ audio: true });
  735. else this.session.mute({ audio: true });
  736. }
  737. isMicMuted() { return this.session ? this.session.isMuted().audio : false; }
  738. SetSpeaker(paused, volume) {
  739. if (typeof paused === 'boolean') this._speakerPaused = paused;
  740. if (typeof volume === 'number' && !Number.isNaN(volume)) {
  741. this._speakerVolume = Math.min(1, Math.max(0, volume));
  742. }
  743. const vol = this._getEffectiveSpeakerVolume();
  744. if (this.remoteMedia) this.remoteMedia.volume = vol;
  745. if (this.ringbackMedia) this.ringbackMedia.volume = vol;
  746. }
  747. SetMicPhone(paused, volume) { this.localMedia.volume = paused ? 0 : volume; if (this.localStream) this.localStream.getAudioTracks().forEach((track) => { track.enabled = !paused; }); }
  748. SetQueueIn() { if (this.ua && this.ua.isRegistered()) this.ua.sendMessage('execute_available', `${this.profile.user}@${this.profile.domain}`, {}); }
  749. SendDTMF(tone) { if (this.session && this.session.isEstablished()) this.session.sendDTMF(tone); }
  750. PlayDtmfTone(key) {
  751. const DTMF_MAP = { '0': [697,1633],'1':[697,1209],'2':[697,1336],'3':[697,1477],'4':[770,1209],'5':[770,1336],'6':[770,1477],'7':[852,1209],'8':[852,1336],'9':[852,1477],'*':[697,1633],'#':[770,1633] };
  752. const [lowFreq, highFreq] = DTMF_MAP[key] || [697,1209];
  753. this.generateDtmfTone(lowFreq, highFreq, 0.2, this.remoteMedia.volume);
  754. }
  755. generateDtmfTone(lowFreq, highFreq, duration, volume) {
  756. if (this.dtfmTimerId) { clearTimeout(this.dtfmTimerId); if (this.oscillatorLow) try { this.oscillatorLow.stop(); } catch(e){} if (this.oscillatorHigh) try { this.oscillatorHigh.stop(); } catch(e){} }
  757. if (!this.audioCtx || this.audioCtx.state === 'closed') this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  758. this.gainNode = this.audioCtx.createGain();
  759. this.oscillatorLow = this.audioCtx.createOscillator(); this.oscillatorLow.type = 'sine'; this.oscillatorLow.frequency.value = lowFreq;
  760. this.oscillatorHigh = this.audioCtx.createOscillator(); this.oscillatorHigh.type = 'sine'; this.oscillatorHigh.frequency.value = highFreq;
  761. this.oscillatorLow.connect(this.gainNode); this.oscillatorHigh.connect(this.gainNode);
  762. this.gainNode.connect(this.audioCtx.destination);
  763. this.gainNode.gain.setValueAtTime(volume, this.audioCtx.currentTime);
  764. this.gainNode.gain.linearRampToValueAtTime(0, this.audioCtx.currentTime + duration);
  765. this.oscillatorLow.start(); this.oscillatorHigh.start();
  766. this.dtfmTimerId = setTimeout(() => { if (this.oscillatorLow) try { this.oscillatorLow.stop(); } catch(e){} if (this.oscillatorHigh) try { this.oscillatorHigh.stop(); } catch(e){} this.dtfmTimerId = null; }, duration * 1000);
  767. }
  768. IsOnHold() { return this.session ? this.session.isOnHold().local : false; }
  769. pauseRingback() { if (this.ringbackMedia && !this.ringbackMedia.paused) this.ringbackMedia.pause(); }
  770. playRingback() { if (this.ringbackMedia && this.ringbackMedia.paused) { this.ringbackMedia.currentTime = 0; this.ringbackMedia.play().catch(e => console.warn('[音频] 回铃音播放失败')); } }
  771. newRTCSession(event) {
  772. const incoming = event.session;
  773. const outgoing = incoming.direction === 'outgoing';
  774. if (this.session && this.session !== incoming && this.hasActiveCall() && !outgoing) {
  775. try { incoming.terminate({ status_code: 486 }); } catch (e) {}
  776. return;
  777. }
  778. const stale = this.session && this.session !== incoming ? this.session : null;
  779. this._clearRemotePlayback();
  780. this._answering = false;
  781. this.session = incoming;
  782. this._bindSessionEvents(incoming);
  783. if (stale && stale.status !== 7 && stale.status !== 8) {
  784. try { stale.terminate({ status_code: 486 }); } catch (e) {}
  785. }
  786. this.emit('OnSessionCreated', { outgoing, callee: incoming.remote_identity?.uri?.user || '', province: event.request?.getHeader('X-Province') || '', city: event.request?.getHeader('X-City') || '' });
  787. if (!outgoing && this.settings.auto_answer !== false) {
  788. this._scheduleAutoAnswer();
  789. }
  790. if (outgoing) this.playRingback();
  791. }
  792. _bindSessionEvents(session) {
  793. session.on('progress', (e) => {
  794. this.emit('OnRing', {
  795. outgoing: session.direction === 'outgoing',
  796. province: e.response?.getHeader('X-Province') || '',
  797. city: e.response?.getHeader('X-City') || ''
  798. });
  799. });
  800. session.on('confirmed', () => {
  801. this.pauseRingback();
  802. this.emit('OnAnswered', session.direction === 'outgoing');
  803. // 外呼计时以客户真正接通为准,由 IPCC CALLEE_ANSWERED / 来电由 SIP confirmed 启动
  804. if (session.direction !== 'outgoing') {
  805. this.startCallTimer();
  806. }
  807. if (this.remoteMedia) {
  808. this.remoteMedia.volume = this._getEffectiveSpeakerVolume();
  809. }
  810. this.refreshRemoteAudio();
  811. this._scheduleRemoteAudioSync();
  812. });
  813. session.on('ended', () => {
  814. console.log('[SIP] session ended', session.direction);
  815. this.sessionClosed(true, '', session);
  816. });
  817. session.on('failed', (e) => {
  818. const cause = e?.cause || '';
  819. console.warn('[SIP] session failed:', cause, session.direction);
  820. this.sessionClosed(false, cause, session);
  821. });
  822. session.on('peerconnection', (data) => {
  823. const pc = data?.peerconnection || data;
  824. if (pc) this.registerRemoteMedia(pc);
  825. });
  826. }
  827. registerRemoteMedia(connection) {
  828. if (!connection) return;
  829. this.peerConnection = connection;
  830. const onRemoteTrack = (track) => this._attachRemoteTrack(track);
  831. connection.ontrack = (e) => {
  832. if (e.track) onRemoteTrack(e.track);
  833. else if (e.streams?.[0]) e.streams[0].getAudioTracks().forEach(onRemoteTrack);
  834. };
  835. if (typeof connection.addEventListener === 'function') {
  836. connection.addEventListener('track', (e) => {
  837. if (e.track) onRemoteTrack(e.track);
  838. else if (e.streams?.[0]) e.streams[0].getAudioTracks().forEach(onRemoteTrack);
  839. });
  840. }
  841. connection.onaddstream = (e) => {
  842. if (e.stream) e.stream.getAudioTracks().forEach(onRemoteTrack);
  843. };
  844. this.syncRemoteReceivers(connection);
  845. this.registerRemoteMediaIceHandlers(connection);
  846. }
  847. syncRemoteReceivers(connection) {
  848. if (!connection || !this.remoteMedia) return false;
  849. let attached = false;
  850. const attach = (track) => {
  851. if (this._attachRemoteTrack(track)) attached = true;
  852. };
  853. if (typeof connection.getTransceivers === 'function') {
  854. connection.getTransceivers().forEach((tr) => {
  855. if (tr.receiver?.track) attach(tr.receiver.track);
  856. });
  857. }
  858. if (typeof connection.getReceivers === 'function') {
  859. connection.getReceivers().forEach((receiver) => {
  860. if (receiver.track) attach(receiver.track);
  861. });
  862. }
  863. return attached;
  864. }
  865. hasRemoteAudioTrack() {
  866. const stream = this.remoteMedia?.srcObject;
  867. if (stream?.getAudioTracks?.().some((t) => t.readyState === 'live' && t.enabled)) return true;
  868. if (this.peerConnection) {
  869. this.syncRemoteReceivers(this.peerConnection);
  870. const refreshed = this.remoteMedia?.srcObject;
  871. return !!refreshed?.getAudioTracks?.().some((t) => t.readyState === 'live' && t.enabled);
  872. }
  873. return false;
  874. }
  875. registerRemoteMediaIceHandlers(connection) {
  876. if (!connection) return;
  877. connection.oniceconnectionstatechange = () => {
  878. const state = connection.iceConnectionState;
  879. if (state === 'failed') {
  880. console.warn('[SIP] ICE 状态: failed');
  881. this.emit('OnIceMediaIssue', { state, timedOut: true });
  882. } else if (state === 'disconnected') {
  883. console.warn('[SIP] ICE 状态: disconnected');
  884. if (this._iceIssueTimer) clearTimeout(this._iceIssueTimer);
  885. this.emit('OnIceMediaIssue', { state, timedOut: false });
  886. this._iceIssueTimer = setTimeout(() => {
  887. this._iceIssueTimer = null;
  888. const cur = connection.iceConnectionState;
  889. if (cur === 'disconnected' || cur === 'failed') {
  890. this.emit('OnIceMediaIssue', { state: cur, timedOut: true });
  891. }
  892. }, 12000);
  893. } else if (state === 'connected' || state === 'completed') {
  894. if (this._iceIssueTimer) {
  895. clearTimeout(this._iceIssueTimer);
  896. this._iceIssueTimer = null;
  897. }
  898. this.resumeRemoteAudio();
  899. this._scheduleRemotePlayRetry(0);
  900. }
  901. };
  902. }
  903. stopCallTimer() {
  904. if (this.callTimerId) {
  905. clearInterval(this.callTimerId);
  906. this.callTimerId = null;
  907. }
  908. }
  909. clearIceIssueTimer() {
  910. if (this._iceIssueTimer) {
  911. clearTimeout(this._iceIssueTimer);
  912. this._iceIssueTimer = null;
  913. }
  914. }
  915. sessionClosed(succeed, reason, closedSession) {
  916. if (!closedSession || this.session !== closedSession) return;
  917. const intentional = this._isIntentionalHangupPhase();
  918. const shouldReconnect = !intentional && this._deferredReconnectAfterCall;
  919. this._deferredReconnectAfterCall = false;
  920. this.clearIceIssueTimer();
  921. this.stopCallTimer();
  922. this._answering = false;
  923. this._clearRemotePlayback();
  924. this.session = null;
  925. if (!intentional) {
  926. this.releaseLocalStream();
  927. }
  928. this.emit('OnSessionClosed', { succeeded: succeed, reason, intentional });
  929. if (shouldReconnect && this.reconnectEnabled && !this.isReconnecting) {
  930. this.scheduleReconnect();
  931. }
  932. }
  933. refreshRemoteAudio() {
  934. if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
  935. return this.resumeRemoteAudio();
  936. }
  937. async resumeRemoteAudio() {
  938. if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
  939. if (this.audioCtx && this.audioCtx.state === 'suspended') {
  940. try { await this.audioCtx.resume(); } catch (e) {}
  941. }
  942. if (!this.remoteMedia || !this.hasRemoteAudioTrack()) return false;
  943. this.remoteMedia.volume = this._getEffectiveSpeakerVolume();
  944. this.remoteMedia.muted = false;
  945. try {
  946. await this.remoteMedia.play();
  947. return !this.remoteMedia.paused;
  948. } catch (e) {
  949. console.warn('[音频] 远端播放 resume 失败:', e.message || e);
  950. return false;
  951. }
  952. }
  953. startCallTimer() {
  954. if (this.callTimerId) clearInterval(this.callTimerId);
  955. let seconds = 0;
  956. this.callTimerId = setInterval(() => { seconds++; const mins = Math.floor(seconds / 60); const secs = seconds % 60; this.emit('OnCallTimer', `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`); }, 1000);
  957. }
  958. destroy() {
  959. this.reconnectEnabled = false;
  960. this.clearIceIssueTimer();
  961. this._clearRemotePlayback();
  962. if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
  963. if (this.callTimerId) clearInterval(this.callTimerId);
  964. if (this.dtfmTimerId) clearTimeout(this.dtfmTimerId);
  965. if (this.session) {
  966. try { this.session.terminate(); } catch (e) {}
  967. this.session = null;
  968. }
  969. this.UnRegister();
  970. const cleanupAudio = (audio) => { if (!audio) return; try { audio.pause(); audio.src = ''; if (audio.srcObject) { audio.srcObject.getTracks().forEach(t => t.stop()); audio.srcObject = null; } audio.load(); } catch(e) {} };
  971. cleanupAudio(this.ringbackMedia); cleanupAudio(this.remoteMedia); cleanupAudio(this.localMedia);
  972. this._unmountPlaybackElement('sip-ringback-audio');
  973. this._unmountPlaybackElement('sip-remote-audio');
  974. this.releaseLocalStream();
  975. this.ringbackMedia = null; this.remoteMedia = null; this.localMedia = null;
  976. if (this.audioCtx) { try { if (this.audioCtx.state !== 'closed') this.audioCtx.close(); } catch(e) {} this.audioCtx = null; }
  977. this.oscillatorLow = null; this.oscillatorHigh = null; this.gainNode = null;
  978. this.events = {}; this.configuration = null; this.profile = null; this.settings = null; this.session = null; this.ua = null;
  979. }
  980. }
  981. export default {
  982. WebPhone, ProfileManager, checkMicrophonePermission, releaseMediaStream,
  983. RINGBACK_AUDIO_URL, IPCC_DEFAULTS, JS_SIP_DEFAULTS
  984. };