softPhone.js 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789
  1. // softPhone.js - WebPhone核心逻辑与配置管理(使用 localStorage 存储,密码混淆)
  2. // 本模块负责 SIP 注册、音频处理、DTMF、保持/接回等,不包含外呼逻辑(由 ccPhoneBarSocket 实现)
  3. import * as JsSIP from 'jssip';
  4. // ========== 回铃音 URL(使用本地音频文件) ==========
  5. export const RINGBACK_AUDIO_URL = '/assets/voice/ringback.wav';
  6. /**
  7. * 简单的密码混淆(非加密,仅避免明文暴露)
  8. * 注意:生产环境应使用服务端认证token或OAuth
  9. */
  10. const encodePassword = (pwd) => {
  11. if (!pwd) return '';
  12. try {
  13. const timestamp = Date.now().toString(36);
  14. const pwdStr = String(pwd);
  15. const encoded = btoa(unescape(encodeURIComponent(pwdStr)));
  16. return `${timestamp}:${encoded}`;
  17. } catch (e) {
  18. console.error('[密码] 编码失败');
  19. try {
  20. return btoa(String(pwd));
  21. } catch (fallbackError) {
  22. console.error('[密码] 备选编码失败');
  23. return '';
  24. }
  25. }
  26. };
  27. const decodePassword = (encoded) => {
  28. if (!encoded) return '';
  29. try {
  30. let base64Part = encoded;
  31. if (encoded.includes(':')) {
  32. base64Part = encoded.split(':')[1];
  33. }
  34. return decodeURIComponent(escape(atob(base64Part)));
  35. } catch (e) {
  36. console.warn('[密码] 解码失败,可能是旧格式');
  37. return '';
  38. }
  39. };
  40. // 检查麦克风权限
  41. export async function checkMicrophonePermission() {
  42. try {
  43. const permissionStatus = await navigator.permissions.query({ name: 'microphone' });
  44. if (permissionStatus.state === 'prompt') {
  45. await navigator.mediaDevices.getUserMedia({ audio: true });
  46. return true;
  47. } else if (permissionStatus.state === 'denied') {
  48. return false;
  49. } else if (permissionStatus.state === 'granted') {
  50. return true;
  51. }
  52. } catch (err) {
  53. return false;
  54. }
  55. }
  56. // ---------- ProfileManager(localStorage 安全存储)----------
  57. export class ProfileManager {
  58. constructor() {
  59. this.profile = null;
  60. this.load();
  61. }
  62. load() {
  63. try {
  64. const json = localStorage.getItem('WebPhoneProfile');
  65. if (!json) {
  66. this.reset();
  67. } else {
  68. this.profile = JSON.parse(json);
  69. if (this.profile.users) {
  70. Object.keys(this.profile.users).forEach(uid => {
  71. const user = this.profile.users[uid];
  72. if (user.password && typeof user.password === 'string') {
  73. user.password = decodePassword(user.password);
  74. }
  75. });
  76. }
  77. }
  78. } catch (error) {
  79. console.error('[配置] 加载失败,重置为默认值');
  80. this.reset();
  81. }
  82. }
  83. save() {
  84. const toStore = JSON.parse(JSON.stringify(this.profile));
  85. if (toStore.users) {
  86. Object.keys(toStore.users).forEach(uid => {
  87. const user = toStore.users[uid];
  88. if (user.password && typeof user.password === 'string') {
  89. user.password = encodePassword(user.password);
  90. }
  91. });
  92. }
  93. localStorage.setItem('WebPhoneProfile', JSON.stringify(toStore));
  94. }
  95. reset() {
  96. this.profile = {
  97. users: {},
  98. user: '',
  99. reconnect: true,
  100. reconnect_interval: 15,
  101. user_agent: 'JsSIP',
  102. session_expires: 180,
  103. min_session_expires: 120,
  104. speaker_volume: 0.8,
  105. mic_volume: 0.8,
  106. speaker_paused: false,
  107. mic_paused: false,
  108. auto_answer: false,
  109. stun: false,
  110. ice_server: ''
  111. };
  112. this.save();
  113. }
  114. getProfile() {
  115. return this.profile;
  116. }
  117. getSettings() {
  118. let reconnectInterval = this.profile.reconnect_interval || 15;
  119. if (reconnectInterval > 1000) {
  120. reconnectInterval = Math.floor(reconnectInterval / 1000);
  121. this.profile.reconnect_interval = reconnectInterval;
  122. this.save();
  123. }
  124. const settings = {
  125. user_agent: this.profile.user_agent,
  126. session_expires: this.profile.session_expires,
  127. min_session_expires: this.profile.min_session_expires,
  128. stun: this.profile.stun,
  129. ice_server: this.profile.ice_server,
  130. auto_answer: this.profile.auto_answer,
  131. reconnect: this.profile.reconnect,
  132. reconnect_interval: reconnectInterval
  133. };
  134. return settings;
  135. }
  136. updateSettings(settings) {
  137. Object.assign(this.profile, settings);
  138. this.save();
  139. }
  140. resetSettings() {
  141. this.profile.reconnect = true;
  142. this.profile.reconnect_interval = 15;
  143. this.profile.user_agent = 'JsSIP';
  144. this.profile.session_expires = 180;
  145. this.profile.min_session_expires = 120;
  146. this.profile.auto_answer = false;
  147. this.profile.stun = false;
  148. this.profile.ice_server = '';
  149. this.save();
  150. }
  151. getCurrentUserProfile() {
  152. if (!this.profile.users || !this.profile.user) return null;
  153. return this.profile.users[this.profile.user];
  154. }
  155. addUser(profile) {
  156. if (!profile.user || !profile.domain || !profile.password) {
  157. throw new Error('登录名、域名和密码为必填项');
  158. }
  159. const userId = `${profile.user}@${profile.domain}`;
  160. if (this.profile.users[userId]) {
  161. this.profile.users[userId] = {
  162. ...this.profile.users[userId],
  163. ...profile,
  164. user: profile.user,
  165. domain: profile.domain
  166. };
  167. this.profile.user = userId;
  168. this.save();
  169. return;
  170. }
  171. this.profile.user = userId;
  172. this.profile.users[userId] = profile;
  173. this.save();
  174. }
  175. updateUser(userId, updatedProfile) {
  176. if (this.profile.users[userId]) {
  177. const existing = this.profile.users[userId];
  178. const merged = {
  179. ...existing,
  180. ...updatedProfile,
  181. user: existing.user,
  182. domain: existing.domain
  183. };
  184. this.profile.users[userId] = merged;
  185. this.save();
  186. }
  187. }
  188. deleteCurrentUser() {
  189. if (!this.profile.user) return;
  190. delete this.profile.users[this.profile.user];
  191. const keys = Object.keys(this.profile.users);
  192. this.profile.user = keys.length > 0 ? keys[0] : '';
  193. this.save();
  194. }
  195. switchUser(userId) {
  196. if (this.profile.users[userId]) {
  197. this.profile.user = userId;
  198. this.save();
  199. }
  200. }
  201. }
  202. // ---------- WebPhone 核心类(SIP注册、媒体处理、保持/接回、DTMF、回铃音)----------
  203. // 外呼功能已移至 Vue 组件中的 ccPhoneBarSocket 实现
  204. export class WebPhone {
  205. constructor(profile, settings) {
  206. this.profile = profile;
  207. this.settings = settings;
  208. this.session = null;
  209. this.ua = null;
  210. this.call_id = this.randomUUID();
  211. this.events = {};
  212. this.sessionCloseTimerId = null;
  213. this.callTimerId = null;
  214. this.dtfmTimerId = null;
  215. this.audioCtx = null;
  216. this.oscillatorLow = null;
  217. this.oscillatorHigh = null;
  218. this.gainNode = null;
  219. this.reconnectEnabled = settings.reconnect;
  220. this.reconnectAttempts = 0;
  221. this.reconnectStartTime = null;
  222. this.reconnectTotalDuration = 1 * 60 * 1000; // 1分钟
  223. this.isReconnecting = false;
  224. this.reconnectTimerId = null;
  225. this._isHandlingDisconnect = false;
  226. // 使用 Base64 回铃音
  227. this.ringbackMedia = new Audio(RINGBACK_AUDIO_URL);
  228. this.ringbackMedia.loop = true;
  229. this.remoteMedia = new Audio();
  230. this.localMedia = new Audio();
  231. // this.applyWebSocketPatch();
  232. this.initUA();
  233. }
  234. randomUUID() {
  235. if (crypto && typeof crypto.randomUUID === 'function') {
  236. return crypto.randomUUID().replace(/-/g, '');
  237. }
  238. return 'xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
  239. const r = Math.random() * 16 | 0;
  240. const v = c === 'x' ? r : (r & 0x3 | 0x8);
  241. return v.toString(16);
  242. });
  243. }
  244. applyWebSocketPatch() {
  245. if (window._webSocketPatchedForSip) return;
  246. const targetServer = this.profile.server;
  247. if (!targetServer || !(targetServer.startsWith('ws://') || targetServer.startsWith('wss://'))) {
  248. console.warn('[WebPhone] WebSocket地址无效:', targetServer);
  249. return;
  250. }
  251. try {
  252. new URL(targetServer);
  253. } catch (e) {
  254. console.error('[WebPhone] WebSocket URL格式错误:', targetServer);
  255. return;
  256. }
  257. const originalWebSocket = window.WebSocket;
  258. const patchedWebSocket = function(url, protocols) {
  259. if (url === targetServer && protocols && protocols.length > 0) {
  260. return new originalWebSocket(url);
  261. }
  262. return new originalWebSocket(url, protocols);
  263. };
  264. for (let key in originalWebSocket) {
  265. if (originalWebSocket.hasOwnProperty(key)) {
  266. patchedWebSocket[key] = originalWebSocket[key];
  267. }
  268. }
  269. window.WebSocket = patchedWebSocket;
  270. window._webSocketPatchedForSip = true;
  271. console.log('[WebPhone] WebSocket Patch已应用');
  272. }
  273. initUA() {
  274. if (!this.profile.server || !this.profile.user || !this.profile.domain) {
  275. console.error('[SIP] 配置不完整');
  276. this.emit('OnStatusMessage', { type: 'error', text: '配置不完整' });
  277. return;
  278. }
  279. const socket = new JsSIP.WebSocketInterface(this.profile.server, { protocols: [] });
  280. socket.via_transport = this.profile.transport || 'wss';
  281. const user = String(this.profile.user || '');
  282. const domain = String(this.profile.domain || '');
  283. const displayName = this.profile.display_name ? String(this.profile.display_name) : '';
  284. const password = this.profile.password ? String(this.profile.password) : '';
  285. const server = String(this.profile.server || '');
  286. const transport = String(this.profile.transport || 'wss');
  287. if (!user || !domain || !password) {
  288. console.error('[SIP] 账号配置缺失:', { user, domain, hasPassword: !!password });
  289. this.emit('OnStatusMessage', { type: 'error', text: '账号配置不完整,请检查登录名、域名和密码' });
  290. return;
  291. }
  292. const uri = new JsSIP.URI('sip', user, domain);
  293. const contactUriStr = `sip:${user}@${domain};transport=ws`;
  294. this.configuration = {
  295. sockets: [socket],
  296. authorization_user: user,
  297. hack_ip_in_contact: true,
  298. user_agent: this.settings.user_agent || 'JsSIP',
  299. display_name: displayName || undefined,
  300. session_timers: true,
  301. no_answer_timeout: 60,
  302. uri: uri.toAor(),
  303. contact_uri: contactUriStr,
  304. };
  305. if (password && password.length === 32) {
  306. this.configuration.ha1 = password;
  307. this.configuration.realm = domain;
  308. } else {
  309. this.configuration.password = password;
  310. }
  311. console.log(`[SIP] UA配置完成: ${user}@${domain}`);
  312. }
  313. createUA() {
  314. this.ua = new JsSIP.UA(this.configuration);
  315. this.ua.set('display_name', this.profile.display_name);
  316. this.ua.on('connecting', this.connecting.bind(this));
  317. this.ua.on('connected', this.connected.bind(this));
  318. this.ua.on('disconnected', this.disconnected.bind(this));
  319. this.ua.on('registered', this.registered.bind(this));
  320. this.ua.on('unregistered', this.unregistered.bind(this));
  321. this.ua.on('registrationFailed', this.registrationFailed.bind(this));
  322. this.ua.on('registrationExpiring', this.registrationExpiring.bind(this));
  323. this.ua.on('newRTCSession', this.newRTCSession.bind(this));
  324. this.ua.on('newMessage', this.newMessage.bind(this));
  325. }
  326. On(event, callback) {
  327. this.events[event] = callback;
  328. }
  329. emit(event, ...args) {
  330. if (this.events[event]) {
  331. try {
  332. this.events[event](...args);
  333. } catch (e) {
  334. console.error(e);
  335. }
  336. }
  337. }
  338. resetReconnectState() {
  339. if (this.reconnectTimerId) {
  340. clearTimeout(this.reconnectTimerId);
  341. this.reconnectTimerId = null;
  342. }
  343. this.isReconnecting = false;
  344. this.reconnectAttempts = 0;
  345. this.reconnectStartTime = null;
  346. this.emit('OnReconnectStatus', { isReconnecting: false, failed: false });
  347. }
  348. Start(reconnect, isReconnect = false) {
  349. console.log(`[SIP] 启动 ${reconnect ? '启用重连' : '禁用重连'} ${isReconnect ? '(重连模式)' : ''}`);
  350. this.reconnectEnabled = reconnect;
  351. if (this.ua) {
  352. try {
  353. if (this.ua.isRegistered()) this.ua.unregister();
  354. this.ua.stop();
  355. } catch (e) {}
  356. this.ua = null;
  357. }
  358. if (!isReconnect) this.resetReconnectState();
  359. setTimeout(() => {
  360. this.createUA();
  361. if (this.ua.isRegistered()) {
  362. this.SetQueueIn();
  363. return;
  364. }
  365. if (this.ua.isConnected()) {
  366. this.Register();
  367. return;
  368. }
  369. try {
  370. this.ua.start();
  371. } catch (error) {
  372. console.error('[SIP] UA启动失败:', error.message);
  373. this.emit('OnStatusMessage', { type: 'error', text: '启动失败: ' + error.message });
  374. this.scheduleReconnect();
  375. }
  376. }, 50);
  377. }
  378. Register() {
  379. if (this.ua) this.ua.register();
  380. }
  381. UnRegister() {
  382. this.reconnectEnabled = false;
  383. this.resetReconnectState();
  384. if (this.ua) {
  385. try {
  386. if (this.ua.isRegistered()) this.ua.unregister();
  387. this.ua.stop();
  388. } catch (e) {}
  389. this.ua = null;
  390. }
  391. if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
  392. if (this.callTimerId) clearInterval(this.callTimerId);
  393. if (this.sessionCloseTimerId) clearTimeout(this.sessionCloseTimerId);
  394. if (this.dtfmTimerId) clearTimeout(this.dtfmTimerId);
  395. }
  396. scheduleReconnect() {
  397. if (!this.reconnectEnabled) return;
  398. if (this.reconnectTimerId) {
  399. clearTimeout(this.reconnectTimerId);
  400. this.reconnectTimerId = null;
  401. }
  402. if (this.isReconnecting) return;
  403. const now = Date.now();
  404. if (this.reconnectStartTime === null) this.reconnectStartTime = now;
  405. const elapsed = now - this.reconnectStartTime;
  406. if (elapsed >= this.reconnectTotalDuration) {
  407. console.error('[SIP] 重连超时(超过1分钟)');
  408. this.isReconnecting = false;
  409. this.emit('OnReconnectStatus', { isReconnecting: false, failed: true });
  410. this.emit('OnStatusMessage', { type: 'error', text: '重连超时' });
  411. return;
  412. }
  413. this.reconnectAttempts++;
  414. let interval = (this.settings.reconnect_interval || 15) * 1000;
  415. if (this.reconnectAttempts > 5) interval = 10000;
  416. if (this.reconnectAttempts > 10) interval = 20000;
  417. if (elapsed + interval > this.reconnectTotalDuration) {
  418. const remainingTime = this.reconnectTotalDuration - elapsed;
  419. if (remainingTime < 1000) {
  420. this.emit('OnReconnectStatus', { isReconnecting: false, failed: true });
  421. this.emit('OnStatusMessage', { type: 'error', text: '重连超时' });
  422. return;
  423. }
  424. interval = Math.min(interval, remainingTime);
  425. }
  426. console.log(`[SIP] ${Math.ceil(interval/1000)}秒后重连 (第${this.reconnectAttempts}次)`);
  427. this.isReconnecting = true;
  428. this.emit('OnReconnectStatus', { isReconnecting: true, failed: false });
  429. this.reconnectTimerId = setTimeout(() => {
  430. this.isReconnecting = false;
  431. this.reconnectTimerId = null;
  432. this.Start(true, true);
  433. }, interval);
  434. }
  435. // ---- JsSIP 事件处理 ----
  436. connecting() {
  437. console.log('[SIP] 连接中...');
  438. this.emit('OnStatusMessage', { type: 'info', text: '连接中...' });
  439. }
  440. connected() {
  441. console.log('[SIP] 已连接,开始注册');
  442. this.Register();
  443. this.emit('OnStatusMessage', { type: 'success', text: '已连接' });
  444. }
  445. disconnected(e) {
  446. if (this._isHandlingDisconnect) return;
  447. this._isHandlingDisconnect = true;
  448. console.log('[SIP] 连接断开');
  449. this.emit('OnRegister', { registered: false });
  450. this.emit('OnStatusMessage', { type: 'error', text: '连接断开' });
  451. if (this.ua) {
  452. try { this.ua.stop(); } catch (err) {}
  453. this.ua = null;
  454. }
  455. if (!this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
  456. setTimeout(() => { this._isHandlingDisconnect = false; }, 1000);
  457. }
  458. registered() {
  459. console.log('[SIP] 连接成功');
  460. this.resetReconnectState();
  461. this.emit('OnRegister', { registered: true });
  462. this.emit('OnStatusMessage', { type: 'success', text: '连接成功' });
  463. this.SetQueueIn();
  464. }
  465. unregistered() {
  466. console.log('[SIP] 已注销');
  467. this.emit('OnRegister', { registered: false });
  468. this.emit('OnStatusMessage', { type: 'info', text: '已注销' });
  469. }
  470. registrationFailed(e) {
  471. console.error('[SIP] 注册失败:', e.cause || '未知原因');
  472. this.emit('OnRegister', { registered: false });
  473. this.emit('OnStatusMessage', { type: 'error', text: '注册失败: ' + (e.cause || '未知原因') });
  474. if (!this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
  475. }
  476. registrationExpiring() {
  477. console.log('[SIP] 注册即将过期,重新注册');
  478. this.Register();
  479. }
  480. // 外呼功能由 ccPhoneBarSocket 实现,WebPhone 不再实现 Call 方法
  481. // 但保留 Answer、Terminate、ToggleHold、ToggleMicPhone 等方法供来电和通话控制
  482. Answer() {
  483. if (this.session) this.session.answer({ mediaConstraints: { audio: true, video: false } });
  484. }
  485. Terminate(code) {
  486. if (this.session) {
  487. if (code) this.session.terminate({ status_code: code });
  488. else this.session.terminate();
  489. }
  490. if (this.sessionCloseTimerId) clearTimeout(this.sessionCloseTimerId);
  491. this.sessionCloseTimerId = setTimeout(() => this.sessionClosed(true, ''), 1000);
  492. }
  493. ToggleHold() {
  494. if (this.session && this.session.isEstablished()) {
  495. if (this.session.isOnHold().local) this.session.unhold();
  496. else this.session.hold();
  497. }
  498. }
  499. ToggleMicPhone() {
  500. if (this.session) {
  501. if (this.session.isMuted().audio) this.session.unmute();
  502. else this.session.mute();
  503. }
  504. }
  505. SetSpeaker(paused, volume) {
  506. this.remoteMedia.volume = paused ? 0 : volume;
  507. this.ringbackMedia.volume = paused ? 0 : volume;
  508. }
  509. SetMicPhone(paused, volume) {
  510. this.localMedia.volume = paused ? 0 : volume;
  511. }
  512. SetQueueIn() {
  513. if (this.ua && this.ua.isRegistered()) {
  514. this.ua.sendMessage('execute_available', `${this.profile.user}@${this.profile.domain}`, {});
  515. }
  516. }
  517. SetQueueOut() {
  518. if (this.ua && this.ua.isRegistered()) {
  519. this.ua.sendMessage('execute_logout', `${this.profile.user}@${this.profile.domain}`, {});
  520. }
  521. }
  522. SendDTMF(tone) {
  523. if (this.session && this.session.isEstablished()) this.session.sendDTMF(tone);
  524. }
  525. PlayDtmfTone(key) {
  526. const DTMF_MAP = {
  527. '0': [697, 1633], '1': [697, 1209], '2': [697, 1336], '3': [697, 1477],
  528. '4': [770, 1209], '5': [770, 1336], '6': [770, 1477], '7': [852, 1209],
  529. '8': [852, 1336], '9': [852, 1477], '*': [697, 1633], '#': [770, 1633]
  530. };
  531. const [lowFreq, highFreq] = DTMF_MAP[key] || [697, 1209];
  532. this.generateDtmfTone(lowFreq, highFreq, 0.2, this.remoteMedia.volume);
  533. }
  534. generateDtmfTone(lowFreq, highFreq, duration, volume) {
  535. if (this.dtfmTimerId) {
  536. clearTimeout(this.dtfmTimerId);
  537. if (this.oscillatorLow) {
  538. try { this.oscillatorLow.stop(); } catch (e) {}
  539. }
  540. if (this.oscillatorHigh) {
  541. try { this.oscillatorHigh.stop(); } catch (e) {}
  542. }
  543. }
  544. if (!this.audioCtx || this.audioCtx.state === 'closed') {
  545. this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
  546. }
  547. this.gainNode = this.audioCtx.createGain();
  548. this.oscillatorLow = this.audioCtx.createOscillator();
  549. this.oscillatorHigh = this.audioCtx.createOscillator();
  550. this.oscillatorLow.type = 'sine';
  551. this.oscillatorLow.frequency.value = lowFreq;
  552. this.oscillatorHigh.type = 'sine';
  553. this.oscillatorHigh.frequency.value = highFreq;
  554. this.oscillatorLow.connect(this.gainNode);
  555. this.oscillatorHigh.connect(this.gainNode);
  556. this.gainNode.connect(this.audioCtx.destination);
  557. this.gainNode.gain.setValueAtTime(volume, this.audioCtx.currentTime);
  558. this.gainNode.gain.linearRampToValueAtTime(0, this.audioCtx.currentTime + duration);
  559. this.oscillatorLow.start();
  560. this.oscillatorHigh.start();
  561. this.dtfmTimerId = setTimeout(() => {
  562. if (this.oscillatorLow) {
  563. try { this.oscillatorLow.stop(); } catch (e) {}
  564. }
  565. if (this.oscillatorHigh) {
  566. try { this.oscillatorHigh.stop(); } catch (e) {}
  567. }
  568. this.dtfmTimerId = null;
  569. }, duration * 1000);
  570. }
  571. IsOnHold() {
  572. return this.session ? this.session.isOnHold().local : false;
  573. }
  574. pauseRingback() {
  575. if (this.ringbackMedia && !this.ringbackMedia.paused) {
  576. this.ringbackMedia.pause();
  577. console.log('[音频] 暂停回铃音');
  578. }
  579. }
  580. playRingback() {
  581. if (this.ringbackMedia && this.ringbackMedia.paused) {
  582. this.ringbackMedia.currentTime = 0;
  583. this.ringbackMedia.play().catch(e => console.warn('[音频] 回铃音播放失败'));
  584. console.log('[音频] 播放回铃音');
  585. }
  586. }
  587. newRTCSession(event) {
  588. console.log('[通话] 新会话创建');
  589. if (this.session) { event.session.terminate({ status_code: 486 }); return; }
  590. this.session = event.session;
  591. this.session.on('progress', (e) => {
  592. this.emit('OnRing', {
  593. outgoing: this.session.direction === 'outgoing',
  594. province: e.response?.getHeader('X-Province') || '',
  595. city: e.response?.getHeader('X-City') || ''
  596. });
  597. });
  598. this.session.on('confirmed', () => {
  599. this.pauseRingback();
  600. this.emit('OnAnswered', this.session.direction === 'outgoing');
  601. this.startCallTimer();
  602. });
  603. this.session.on('ended', () => this.sessionClosed(true, ''));
  604. this.session.on('failed', (e) => this.sessionClosed(false, e.cause));
  605. this.session.on('peerconnection', (pc) => {
  606. if (pc && typeof pc.addTrack === 'function') {
  607. this.registerRemoteMedia(pc);
  608. } else if (this.session && this.session.connection && typeof this.session.connection.addTrack === 'function') {
  609. console.log('[通话] 使用session.connection');
  610. this.registerRemoteMedia(this.session.connection);
  611. } else {
  612. console.error('[通话] 未找到有效的RTCPeerConnection');
  613. }
  614. });
  615. const outgoing = this.session.direction === 'outgoing';
  616. this.emit('OnSessionCreated', {
  617. outgoing,
  618. callee: this.session.remote_identity?.uri?.user || '',
  619. province: event.request?.getHeader('X-Province') || '',
  620. city: event.request?.getHeader('X-City') || ''
  621. });
  622. if (!outgoing && this.settings.auto_answer) this.Answer();
  623. if (outgoing) this.playRingback();
  624. }
  625. newMessage(event) {
  626. if (event.message.direction === 'incoming') {
  627. this.emit('OnMessage', { type: 'request', message: event.request.body });
  628. }
  629. }
  630. registerRemoteMedia(connection) {
  631. if (!connection || typeof connection.addTrack !== 'function') {
  632. console.error('[通话] 无效的连接对象');
  633. return;
  634. }
  635. // 监听远程流
  636. connection.ontrack = (e) => {
  637. if (!this.remoteMedia.srcObject) {
  638. const stream = new MediaStream();
  639. e.streams[0].getTracks().forEach(track => stream.addTrack(track));
  640. this.remoteMedia.srcObject = stream;
  641. this.remoteMedia.play().catch(err => console.warn('[音频] 远程媒体播放失败'));
  642. this.pauseRingback();
  643. }
  644. };
  645. // 获取并添加本地流
  646. navigator.mediaDevices.getUserMedia({ audio: true })
  647. .then(stream => {
  648. this.localMedia.srcObject = stream;
  649. stream.getTracks().forEach(track => {
  650. try {
  651. connection.addTrack(track, stream);
  652. console.log('[音频] 本地轨道添加成功');
  653. } catch (err) {
  654. console.error('[音频] 添加本地轨道失败:', err);
  655. }
  656. });
  657. })
  658. .catch(err => {
  659. console.error('[音频] 获取麦克风失败:', err);
  660. this.emit('OnStatusMessage', { type: 'error', text: '无法获取麦克风权限' });
  661. });
  662. }
  663. sessionClosed(succeed, reason) {
  664. if (!this.session) return;
  665. this.session = null;
  666. if (this.callTimerId) clearInterval(this.callTimerId);
  667. this.pauseRingback();
  668. if (this.remoteMedia.srcObject) this.remoteMedia.srcObject.getTracks().forEach(t => t.stop());
  669. if (this.localMedia.srcObject) this.localMedia.srcObject.getTracks().forEach(t => t.stop());
  670. this.emit('OnSessionClosed', { succeeded: succeed, reason });
  671. }
  672. startCallTimer() {
  673. if (this.callTimerId) clearInterval(this.callTimerId);
  674. let seconds = 0;
  675. this.callTimerId = setInterval(() => {
  676. seconds++;
  677. const mins = Math.floor(seconds / 60);
  678. const secs = seconds % 60;
  679. this.emit('OnCallTimer', `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`);
  680. }, 1000);
  681. }
  682. destroy() {
  683. console.log('[销毁] WebPhone实例');
  684. this.reconnectEnabled = false;
  685. [this.reconnectTimerId, this.callTimerId, this.sessionCloseTimerId, this.dtfmTimerId].forEach(timerId => {
  686. if (timerId) clearTimeout(timerId);
  687. });
  688. this.UnRegister();
  689. const cleanupAudio = (audio) => {
  690. if (!audio) return;
  691. try {
  692. audio.pause();
  693. audio.src = '';
  694. if (audio.srcObject) {
  695. audio.srcObject.getTracks().forEach(t => t.stop());
  696. audio.srcObject = null;
  697. }
  698. audio.load();
  699. } catch (e) {}
  700. };
  701. cleanupAudio(this.ringbackMedia);
  702. cleanupAudio(this.remoteMedia);
  703. cleanupAudio(this.localMedia);
  704. this.ringbackMedia = null;
  705. this.remoteMedia = null;
  706. this.localMedia = null;
  707. if (this.audioCtx) {
  708. try { if (this.audioCtx.state !== 'closed') this.audioCtx.close(); } catch (e) {}
  709. this.audioCtx = null;
  710. }
  711. this.oscillatorLow = null;
  712. this.oscillatorHigh = null;
  713. this.gainNode = null;
  714. this.events = {};
  715. this.configuration = null;
  716. this.profile = null;
  717. this.settings = null;
  718. this.session = null;
  719. this.ua = null;
  720. console.log('[销毁] WebPhone清理完成');
  721. }
  722. }
  723. export default { WebPhone, ProfileManager, checkMicrophonePermission, RINGBACK_AUDIO_URL };