|
|
@@ -0,0 +1,1001 @@
|
|
|
+// softPhone.js - WebPhone核心逻辑与配置管理
|
|
|
+import * as JsSIP from 'jssip';
|
|
|
+
|
|
|
+export const RINGBACK_AUDIO_URL = '/assets/voice/ringback.wav';
|
|
|
+
|
|
|
+export const IPCC_DEFAULTS = {
|
|
|
+ SERVER_PROD: 'sip.ylrzcloud.com',
|
|
|
+ CONNECT_TIMEOUT: 8000,
|
|
|
+ RECONNECT_CONNECT_TIMEOUT: 3000,
|
|
|
+ RECONNECT_DELAY_MS: 100,
|
|
|
+ RECONNECT_DELAY_IN_CALL_MS: 150,
|
|
|
+ HEARTBEAT_INTERVAL: 16,
|
|
|
+ TOKEN_TTL_MS: 60 * 60 * 1000,
|
|
|
+ GATEWAY_RELEASE_DELAY_MS: 200,
|
|
|
+ SERVER_RELEASE_DELAY_MS: 300,
|
|
|
+ DISCONNECT_WAIT_MS: 800,
|
|
|
+ AGENT_READY_TIMEOUT_MS: 3000,
|
|
|
+ SIP_REGISTER_TIMEOUT_MS: 8000,
|
|
|
+ READY_RESTORE_INITIAL_MS: 50,
|
|
|
+ READY_RESTORE_INTERVAL_MS: 120,
|
|
|
+ READY_RESTORE_MAX_ATTEMPTS: 12,
|
|
|
+ STATUS_BUSY_DELAY_MS: 150,
|
|
|
+ POST_CALL_READY_DEADLINE_MS: 3000
|
|
|
+};
|
|
|
+const SESSION_CONFIRMED_STATUS = 9;
|
|
|
+/** JsSIP RTCSession.STATUS_ANSWERED — 已发送 200,不可再次 answer */
|
|
|
+const SESSION_ANSWERED_STATUS = 5;
|
|
|
+/** JsSIP RTCSession.STATUS_WAITING_FOR_ACK */
|
|
|
+const SESSION_WAITING_ACK_STATUS = 6;
|
|
|
+/** JsSIP RTCSession.STATUS_1XX_RECEIVED — 可 answer 的状态含 2/3/4 */
|
|
|
+const SESSION_ANSWERABLE_STATUSES = [2, 3, 4];
|
|
|
+
|
|
|
+export const JS_SIP_DEFAULTS = {
|
|
|
+ SERVER: 'wss://sip.ylrzcloud.com:8443',
|
|
|
+ DOMAIN: 'sip.ylrzcloud.com',
|
|
|
+ TRANSPORT: 'wss',
|
|
|
+ USER_AGENT: 'JsSIP',
|
|
|
+ SESSION_EXPIRES: 180,
|
|
|
+ MIN_SESSION_EXPIRES: 90,
|
|
|
+ SPEAKER_VOLUME: 0.8,
|
|
|
+ MIC_VOLUME: 0.8,
|
|
|
+ RECONNECT_INTERVAL: 3,
|
|
|
+ RECONNECT_TOTAL_DURATION: 60000
|
|
|
+};
|
|
|
+
|
|
|
+const toBase64 = (str) => {
|
|
|
+ const bytes = new TextEncoder().encode(str);
|
|
|
+ return btoa(String.fromCharCode(...bytes));
|
|
|
+};
|
|
|
+const fromBase64 = (b64) => {
|
|
|
+ const binary = atob(b64);
|
|
|
+ const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
|
|
+ return new TextDecoder().decode(bytes);
|
|
|
+};
|
|
|
+const encodePassword = (pwd) => {
|
|
|
+ if (!pwd) return '';
|
|
|
+ try {
|
|
|
+ const timestamp = Date.now().toString(36);
|
|
|
+ const pwdStr = String(pwd);
|
|
|
+ const encoded = toBase64(pwdStr);
|
|
|
+ return `${timestamp}:${encoded}`;
|
|
|
+ } catch (e) {
|
|
|
+ try { return btoa(String(pwd)); } catch (fallbackError) { return ''; }
|
|
|
+ }
|
|
|
+};
|
|
|
+const decodePassword = (encoded) => {
|
|
|
+ if (!encoded) return '';
|
|
|
+ try {
|
|
|
+ let base64Part = encoded;
|
|
|
+ if (encoded.includes(':')) base64Part = encoded.split(':')[1];
|
|
|
+ return fromBase64(base64Part);
|
|
|
+ } catch (e) { return ''; }
|
|
|
+};
|
|
|
+
|
|
|
+/** 优先约束:开启回声消除等增强;失败后自动降级 */
|
|
|
+const MIC_AUDIO_CONSTRAINTS_ENHANCED = {
|
|
|
+ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
|
|
|
+ video: false
|
|
|
+};
|
|
|
+/** 兼容约束:部分浏览器/驱动对增强项会报 Requested device not found */
|
|
|
+const MIC_AUDIO_CONSTRAINTS_BASIC = {
|
|
|
+ audio: true,
|
|
|
+ video: false
|
|
|
+};
|
|
|
+/** JsSIP answer/call 用的媒体约束(与 BASIC 对齐,避免二次失败) */
|
|
|
+export const MIC_MEDIA_CONSTRAINTS = {
|
|
|
+ audio: true,
|
|
|
+ video: false
|
|
|
+};
|
|
|
+
|
|
|
+/** 兼容旧浏览器:补齐 mediaDevices.getUserMedia */
|
|
|
+export function ensureMediaDevices() {
|
|
|
+ if (typeof navigator === 'undefined') return false;
|
|
|
+ if (!navigator.mediaDevices) {
|
|
|
+ navigator.mediaDevices = {};
|
|
|
+ }
|
|
|
+ if (!navigator.mediaDevices.getUserMedia) {
|
|
|
+ const legacy = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;
|
|
|
+ if (!legacy) return false;
|
|
|
+ navigator.mediaDevices.getUserMedia = (constraints) => new Promise((resolve, reject) => {
|
|
|
+ legacy.call(navigator, constraints, resolve, reject);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return typeof navigator.mediaDevices.getUserMedia === 'function';
|
|
|
+}
|
|
|
+
|
|
|
+function formatMicError(err) {
|
|
|
+ const name = err?.name || '';
|
|
|
+ const msg = err?.message || String(err || '');
|
|
|
+ if (name === 'NotFoundError' || /Requested device not found/i.test(msg)) {
|
|
|
+ return '未检测到可用麦克风,请检查设备连接后重试';
|
|
|
+ }
|
|
|
+ if (name === 'NotAllowedError' || name === 'PermissionDeniedError' || /Permission|Denied|Access/i.test(msg)) {
|
|
|
+ return '麦克风权限被拒绝,请在浏览器地址栏允许麦克风后刷新重试';
|
|
|
+ }
|
|
|
+ if (name === 'NotReadableError' || name === 'TrackStartError') {
|
|
|
+ return '麦克风被其他程序占用,请关闭后重试';
|
|
|
+ }
|
|
|
+ if (name === 'OverconstrainedError' || name === 'ConstraintNotSatisfiedError') {
|
|
|
+ return '当前麦克风不支持该音频参数,已尝试兼容模式';
|
|
|
+ }
|
|
|
+ if (/secure|HTTPS|getUserMedia/i.test(msg)) {
|
|
|
+ return '当前环境无法访问麦克风(需 HTTPS 或 localhost)';
|
|
|
+ }
|
|
|
+ return msg || '麦克风初始化失败';
|
|
|
+}
|
|
|
+
|
|
|
+export function releaseMediaStream(stream) {
|
|
|
+ if (!stream) return;
|
|
|
+ stream.getTracks().forEach((track) => {
|
|
|
+ try { track.stop(); } catch (e) {}
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 申请麦克风:增强约束失败时自动降级为 audio:true,提升浏览器兼容性
|
|
|
+ */
|
|
|
+export async function requestMicrophoneAccess() {
|
|
|
+ if (!ensureMediaDevices()) {
|
|
|
+ throw new Error('浏览器不支持麦克风');
|
|
|
+ }
|
|
|
+ const attempts = [MIC_AUDIO_CONSTRAINTS_ENHANCED, MIC_AUDIO_CONSTRAINTS_BASIC];
|
|
|
+ let lastError = null;
|
|
|
+ for (let i = 0; i < attempts.length; i++) {
|
|
|
+ try {
|
|
|
+ const stream = await navigator.mediaDevices.getUserMedia(attempts[i]);
|
|
|
+ if (stream?.getAudioTracks?.().length) {
|
|
|
+ return stream;
|
|
|
+ }
|
|
|
+ releaseMediaStream(stream);
|
|
|
+ lastError = new Error('未获取到麦克风音轨');
|
|
|
+ } catch (err) {
|
|
|
+ lastError = err;
|
|
|
+ // 权限明确拒绝时不再降级重试
|
|
|
+ const name = err?.name || '';
|
|
|
+ if (name === 'NotAllowedError' || name === 'PermissionDeniedError') {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ throw new Error(formatMicError(lastError));
|
|
|
+}
|
|
|
+
|
|
|
+export async function checkMicrophonePermission() {
|
|
|
+ if (!ensureMediaDevices()) return false;
|
|
|
+ try {
|
|
|
+ if (navigator.permissions?.query) {
|
|
|
+ const status = await navigator.permissions.query({ name: 'microphone' });
|
|
|
+ return status.state !== 'denied';
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ } catch (err) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export class ProfileManager {
|
|
|
+ constructor() { this.profile = null; this.load(); }
|
|
|
+ load() {
|
|
|
+ try {
|
|
|
+ const json = localStorage.getItem('WebPhoneProfile');
|
|
|
+ if (!json) this.reset();
|
|
|
+ else {
|
|
|
+ this.profile = JSON.parse(json);
|
|
|
+ if (this.profile.users) {
|
|
|
+ Object.keys(this.profile.users).forEach(uid => {
|
|
|
+ const user = this.profile.users[uid];
|
|
|
+ if (user.password && typeof user.password === 'string') user.password = decodePassword(user.password);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (error) { this.reset(); }
|
|
|
+ }
|
|
|
+ save() {
|
|
|
+ const toStore = JSON.parse(JSON.stringify(this.profile));
|
|
|
+ if (toStore.users) {
|
|
|
+ Object.keys(toStore.users).forEach(uid => {
|
|
|
+ const user = toStore.users[uid];
|
|
|
+ if (user.password && typeof user.password === 'string') user.password = encodePassword(user.password);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ localStorage.setItem('WebPhoneProfile', JSON.stringify(toStore));
|
|
|
+ }
|
|
|
+ reset() {
|
|
|
+ this.profile = {
|
|
|
+ users: {}, user: '', reconnect: true, reconnect_interval: JS_SIP_DEFAULTS.RECONNECT_INTERVAL,
|
|
|
+ user_agent: JS_SIP_DEFAULTS.USER_AGENT, session_expires: JS_SIP_DEFAULTS.SESSION_EXPIRES,
|
|
|
+ min_session_expires: JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES, speaker_volume: JS_SIP_DEFAULTS.SPEAKER_VOLUME,
|
|
|
+ mic_volume: JS_SIP_DEFAULTS.MIC_VOLUME, speaker_paused: false, mic_paused: false,
|
|
|
+ auto_answer: true, stun: false, ice_server: ''
|
|
|
+ };
|
|
|
+ this.save();
|
|
|
+ }
|
|
|
+ getProfile() { return this.profile; }
|
|
|
+ getSettings() {
|
|
|
+ let reconnectInterval = this.profile.reconnect_interval || 15;
|
|
|
+ if (reconnectInterval > 1000) {
|
|
|
+ reconnectInterval = Math.floor(reconnectInterval / 1000);
|
|
|
+ this.profile.reconnect_interval = reconnectInterval;
|
|
|
+ this.save();
|
|
|
+ }
|
|
|
+ const currentUser = this.getCurrentUserProfile() || {};
|
|
|
+ return {
|
|
|
+ user_agent: this.profile.user_agent, session_expires: this.profile.session_expires,
|
|
|
+ min_session_expires: this.profile.min_session_expires, stun: this.profile.stun,
|
|
|
+ ice_server: this.profile.ice_server,
|
|
|
+ auto_answer: currentUser.auto_answer !== undefined ? currentUser.auto_answer : this.profile.auto_answer,
|
|
|
+ reconnect: this.profile.reconnect, reconnect_interval: reconnectInterval
|
|
|
+ };
|
|
|
+ }
|
|
|
+ updateSettings(settings) { Object.assign(this.profile, settings); this.save(); }
|
|
|
+ resetSettings() {
|
|
|
+ this.profile.reconnect = true;
|
|
|
+ this.profile.reconnect_interval = JS_SIP_DEFAULTS.RECONNECT_INTERVAL;
|
|
|
+ this.profile.user_agent = JS_SIP_DEFAULTS.USER_AGENT;
|
|
|
+ this.profile.session_expires = JS_SIP_DEFAULTS.SESSION_EXPIRES;
|
|
|
+ this.profile.min_session_expires = JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES;
|
|
|
+ this.profile.auto_answer = false;
|
|
|
+ this.profile.stun = false;
|
|
|
+ this.profile.ice_server = '';
|
|
|
+ this.save();
|
|
|
+ }
|
|
|
+ getCurrentUserProfile() { return this.profile.users?.[this.profile.user] || null; }
|
|
|
+ addUser(profile) {
|
|
|
+ if (!profile.user || !profile.domain || !profile.password) throw new Error('登录名、域名和密码为必填项');
|
|
|
+ const userId = `${profile.user}@${profile.domain}`;
|
|
|
+ if (this.profile.users[userId]) this.profile.users[userId] = { ...this.profile.users[userId], ...profile, user: profile.user, domain: profile.domain };
|
|
|
+ else this.profile.users[userId] = profile;
|
|
|
+ this.profile.user = userId;
|
|
|
+ this.save();
|
|
|
+ }
|
|
|
+ updateUser(userId, updatedProfile) {
|
|
|
+ if (this.profile.users[userId]) {
|
|
|
+ const newUser = updatedProfile.user, newDomain = updatedProfile.domain;
|
|
|
+ const newUserId = `${newUser}@${newDomain}`;
|
|
|
+ if (newUserId !== userId) {
|
|
|
+ const merged = { ...this.profile.users[userId], ...updatedProfile, user: newUser, domain: newDomain };
|
|
|
+ delete this.profile.users[userId];
|
|
|
+ this.profile.users[newUserId] = merged;
|
|
|
+ if (this.profile.user === userId) this.profile.user = newUserId;
|
|
|
+ } else this.profile.users[userId] = { ...this.profile.users[userId], ...updatedProfile, user: newUser, domain: newDomain };
|
|
|
+ this.save();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ 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(); }
|
|
|
+ switchUser(userId) { if (this.profile.users[userId]) { this.profile.user = userId; this.save(); } }
|
|
|
+}
|
|
|
+
|
|
|
+export class WebPhone {
|
|
|
+ constructor(profile, settings) {
|
|
|
+ this.profile = profile; this.settings = settings; this.session = null; this.ua = null;
|
|
|
+ this.call_id = this.randomUUID(); this.events = {}; this.callTimerId = null; this.dtfmTimerId = null; this.audioCtx = null; this.oscillatorLow = null;
|
|
|
+ this.oscillatorHigh = null; this.gainNode = null; this.reconnectEnabled = settings.reconnect;
|
|
|
+ this.reconnectAttempts = 0; this.reconnectStartTime = null;
|
|
|
+ this.reconnectTotalDuration = JS_SIP_DEFAULTS.RECONNECT_TOTAL_DURATION;
|
|
|
+ this.isReconnecting = false; this.reconnectTimerId = null; this._isHandlingDisconnect = false;
|
|
|
+ this._deferredReconnectAfterCall = false;
|
|
|
+ this._intentionalTerminate = false;
|
|
|
+ this._intentionalTerminateUntil = 0;
|
|
|
+ this.ringbackMedia = new Audio(RINGBACK_AUDIO_URL); this.ringbackMedia.loop = true;
|
|
|
+ this.remoteMedia = new Audio(); this.remoteMedia.autoplay = true;
|
|
|
+ this.remoteMedia.setAttribute('playsinline', 'true');
|
|
|
+ this.localMedia = new Audio(); this.peerConnection = null;
|
|
|
+ this._mountPlaybackElement(this.ringbackMedia, 'sip-ringback-audio');
|
|
|
+ this._mountPlaybackElement(this.remoteMedia, 'sip-remote-audio');
|
|
|
+ this.localStream = null;
|
|
|
+ this._speakerPaused = false;
|
|
|
+ this._speakerVolume = this.profile?.speaker_volume ?? JS_SIP_DEFAULTS.SPEAKER_VOLUME;
|
|
|
+ this._answering = false;
|
|
|
+ this._iceIssueTimer = null;
|
|
|
+ this._remoteAudioSyncTimers = [];
|
|
|
+ this._remotePlayRetryTimer = null;
|
|
|
+ this.initUA();
|
|
|
+ }
|
|
|
+ _unmountPlaybackElement(id) {
|
|
|
+ if (typeof document === 'undefined' || !id) return;
|
|
|
+ const existing = document.getElementById(id);
|
|
|
+ if (existing) {
|
|
|
+ try {
|
|
|
+ existing.pause();
|
|
|
+ if (existing.srcObject) {
|
|
|
+ existing.srcObject.getTracks().forEach((t) => { try { t.stop(); } catch (e) {} });
|
|
|
+ existing.srcObject = null;
|
|
|
+ }
|
|
|
+ } catch (e) {}
|
|
|
+ existing.remove();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ _mountPlaybackElement(el, id) {
|
|
|
+ if (!el || typeof document === 'undefined') return;
|
|
|
+ this._unmountPlaybackElement(id);
|
|
|
+ el.id = id;
|
|
|
+ el.setAttribute('playsinline', 'true');
|
|
|
+ el.setAttribute('webkit-playsinline', 'true');
|
|
|
+ el.preload = 'auto';
|
|
|
+ if (!el.isConnected) {
|
|
|
+ el.style.cssText = 'position:fixed;left:-9999px;width:1px;height:1px;opacity:0;pointer-events:none;';
|
|
|
+ document.body.appendChild(el);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ _clearPeerConnection() {
|
|
|
+ if (!this.peerConnection) return;
|
|
|
+ this.peerConnection.ontrack = null;
|
|
|
+ this.peerConnection.onaddstream = null;
|
|
|
+ this.peerConnection.oniceconnectionstatechange = null;
|
|
|
+ try { this.peerConnection.close(); } catch (err) {}
|
|
|
+ this.peerConnection = null;
|
|
|
+ }
|
|
|
+ _clearRemotePlayback({ stopTracks = true } = {}) {
|
|
|
+ this._clearRemoteAudioSyncTimers();
|
|
|
+ if (this._remotePlayRetryTimer) {
|
|
|
+ clearTimeout(this._remotePlayRetryTimer);
|
|
|
+ this._remotePlayRetryTimer = null;
|
|
|
+ }
|
|
|
+ this.pauseRingback();
|
|
|
+ if (this.remoteMedia) {
|
|
|
+ try { this.remoteMedia.pause(); } catch (e) {}
|
|
|
+ this.remoteMedia.muted = false;
|
|
|
+ if (this.remoteMedia.srcObject) {
|
|
|
+ if (stopTracks) {
|
|
|
+ this.remoteMedia.srcObject.getTracks().forEach((t) => { try { t.stop(); } catch (e) {} });
|
|
|
+ }
|
|
|
+ this.remoteMedia.srcObject = null;
|
|
|
+ }
|
|
|
+ try { this.remoteMedia.load(); } catch (e) {}
|
|
|
+ }
|
|
|
+ this._clearPeerConnection();
|
|
|
+ }
|
|
|
+ /** 通话中远端无声时尝试恢复,不终止当前 SIP 会话 */
|
|
|
+ async recoverRemotePlayback() {
|
|
|
+ if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
|
|
|
+ if (this.remoteMedia?.srcObject) {
|
|
|
+ this._pruneEndedRemoteTracks(this.remoteMedia.srcObject);
|
|
|
+ }
|
|
|
+ return this.resumeRemoteAudio();
|
|
|
+ }
|
|
|
+ /** 新外呼/来电前清理上一轮媒体,避免频繁通话后远端无声 */
|
|
|
+ async prepareForNewCall() {
|
|
|
+ this._answering = false;
|
|
|
+ this.clearIceIssueTimer();
|
|
|
+ this._clearRemotePlayback();
|
|
|
+ if (this.session) {
|
|
|
+ const status = this.session.status;
|
|
|
+ if (status !== 7 && status !== 8) {
|
|
|
+ try { this.session.terminate({ status_code: 486 }); } catch (e) {}
|
|
|
+ } else {
|
|
|
+ this.session = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await this.ensureLocalStream();
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('[音频] 新通话前麦克风初始化失败:', e.message || e);
|
|
|
+ // 向上抛出,便于界面提示;兼容降级已在 requestMicrophoneAccess 内完成
|
|
|
+ throw e;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ _pruneEndedRemoteTracks(stream) {
|
|
|
+ if (!stream?.getAudioTracks) return stream;
|
|
|
+ stream.getAudioTracks().forEach((track) => {
|
|
|
+ if (track.readyState === 'ended') {
|
|
|
+ try { stream.removeTrack(track); } catch (e) {}
|
|
|
+ }
|
|
|
+ });
|
|
|
+ return stream;
|
|
|
+ }
|
|
|
+ _attachRemoteTrack(track) {
|
|
|
+ if (!track || track.kind !== 'audio' || track.readyState === 'ended' || !this.remoteMedia) return false;
|
|
|
+ let stream = this.remoteMedia.srcObject;
|
|
|
+ if (!stream) {
|
|
|
+ stream = new MediaStream();
|
|
|
+ this.remoteMedia.srcObject = stream;
|
|
|
+ } else {
|
|
|
+ this._pruneEndedRemoteTracks(stream);
|
|
|
+ }
|
|
|
+ if (!stream.getTracks().some((t) => t.id === track.id)) {
|
|
|
+ stream.addTrack(track);
|
|
|
+ }
|
|
|
+ track.enabled = true;
|
|
|
+ this.remoteMedia.autoplay = true;
|
|
|
+ this.remoteMedia.setAttribute('playsinline', 'true');
|
|
|
+ this.remoteMedia.volume = this._getEffectiveSpeakerVolume();
|
|
|
+ this.remoteMedia.muted = false;
|
|
|
+ this.pauseRingback();
|
|
|
+ this._scheduleRemotePlayRetry(0);
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ _scheduleRemotePlayRetry(attempt = 0) {
|
|
|
+ if (this._remotePlayRetryTimer) {
|
|
|
+ clearTimeout(this._remotePlayRetryTimer);
|
|
|
+ this._remotePlayRetryTimer = null;
|
|
|
+ }
|
|
|
+ const delays = [0, 120, 300, 600, 1200, 2500];
|
|
|
+ const delay = delays[Math.min(attempt, delays.length - 1)];
|
|
|
+ this._remotePlayRetryTimer = setTimeout(async () => {
|
|
|
+ this._remotePlayRetryTimer = null;
|
|
|
+ if (!this.hasActiveCall() && !this.hasSipSession()) return;
|
|
|
+ const played = await this.resumeRemoteAudio();
|
|
|
+ if (!played && attempt < delays.length - 1 && (this.hasActiveCall() || this.hasSipSession())) {
|
|
|
+ if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
|
|
|
+ this._scheduleRemotePlayRetry(attempt + 1);
|
|
|
+ }
|
|
|
+ }, delay);
|
|
|
+ }
|
|
|
+ isRemotePlaybackPaused() {
|
|
|
+ if (!this.remoteMedia) return true;
|
|
|
+ if (this._speakerPaused) return false;
|
|
|
+ if (this.remoteMedia.paused) return true;
|
|
|
+ // MediaStream 播放时 readyState 可能长期为 0,不能作为暂停依据
|
|
|
+ if (this.remoteMedia.srcObject) return false;
|
|
|
+ return this.remoteMedia.readyState < 2;
|
|
|
+ }
|
|
|
+ isRemotePlaybackActive() {
|
|
|
+ if (!this.remoteMedia || !this.hasRemoteAudioTrack()) return false;
|
|
|
+ if (this._speakerPaused) return true;
|
|
|
+ return !this.remoteMedia.paused;
|
|
|
+ }
|
|
|
+ _clearRemoteAudioSyncTimers() {
|
|
|
+ this._remoteAudioSyncTimers.forEach((t) => clearTimeout(t));
|
|
|
+ this._remoteAudioSyncTimers = [];
|
|
|
+ }
|
|
|
+ _scheduleRemoteAudioSync() {
|
|
|
+ this._clearRemoteAudioSyncTimers();
|
|
|
+ [300, 800, 1500, 3000, 5000, 8000, 12000].forEach((delay) => {
|
|
|
+ const timerId = setTimeout(() => {
|
|
|
+ if (this.hasActiveCall() || this.hasSipSession()) {
|
|
|
+ if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
|
|
|
+ if (!this.isRemotePlaybackActive()) {
|
|
|
+ this.refreshRemoteAudio();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, delay);
|
|
|
+ this._remoteAudioSyncTimers.push(timerId);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ randomUUID() {
|
|
|
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID().replace(/-/g, '');
|
|
|
+ const arr = new Uint8Array(16);
|
|
|
+ if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') crypto.getRandomValues(arr);
|
|
|
+ else for (let i = 0; i < 16; i++) arr[i] = Math.floor(Math.random() * 256);
|
|
|
+ arr[6] = (arr[6] & 0x0f) | 0x40; arr[8] = (arr[8] & 0x3f) | 0x80;
|
|
|
+ return Array.from(arr, (b) => b.toString(16).padStart(2, '0')).join('');
|
|
|
+ }
|
|
|
+ initUA() {
|
|
|
+ if (!this.profile.server || !this.profile.user || !this.profile.domain) {
|
|
|
+ this.emit('OnStatusMessage', { type: 'error', text: '配置不完整' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const socket = new JsSIP.WebSocketInterface(this.profile.server);
|
|
|
+ if (String(this.profile.server || '').startsWith('wss://')) socket.via_transport = 'WS';
|
|
|
+ const user = String(this.profile.user || '');
|
|
|
+ const displayName = this.profile.display_name ? String(this.profile.display_name) : '';
|
|
|
+ const password = this.profile.password ? String(this.profile.password) : '';
|
|
|
+ const server = String(this.profile.server || '');
|
|
|
+ const transport = 'ws';
|
|
|
+ const domain = String(this.profile.domain || '');
|
|
|
+ if (!user || !domain || !password) {
|
|
|
+ this.emit('OnStatusMessage', { type: 'error', text: '账号配置不完整,请检查登录名、域名和密码' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (server.startsWith('wss://') && window.location.protocol === 'http:') {
|
|
|
+ console.warn('[jsSip] 警告: 页面通过 HTTP 加载,但 SIP 服务器使用 WSS 协议。浏览器会阻止混合内容');
|
|
|
+ this.emit('OnStatusMessage', { type: 'warn', text: 'HTTP页面使用WSS会被浏览器阻止' });
|
|
|
+ }
|
|
|
+ const uri = new JsSIP.URI('sip', user, domain);
|
|
|
+ const contactUriStr = `sip:${user}@${domain};transport=${transport}`;
|
|
|
+ this.configuration = {
|
|
|
+ sockets: [socket], authorization_user: user, user_agent: this.settings.user_agent || JS_SIP_DEFAULTS.USER_AGENT,
|
|
|
+ display_name: displayName || undefined, session_timers: false,
|
|
|
+ session_timers_expires: this.settings.session_expires || JS_SIP_DEFAULTS.SESSION_EXPIRES,
|
|
|
+ session_timers_min_se: this.settings.min_session_expires || JS_SIP_DEFAULTS.MIN_SESSION_EXPIRES,
|
|
|
+ session_timers_force_refresher: false,
|
|
|
+ no_answer_timeout: 60, register: true, uri: uri.toAor(), contact_uri: contactUriStr, password: password,
|
|
|
+ log_level: 'error'
|
|
|
+ };
|
|
|
+ }
|
|
|
+ getPcConfig() {
|
|
|
+ if (!this.settings.stun) return undefined;
|
|
|
+ const iceUrl = (this.settings.ice_server || 'stun:stun.l.google.com:19302').trim();
|
|
|
+ if (!iceUrl) return undefined;
|
|
|
+ return { iceServers: [{ urls: iceUrl }] };
|
|
|
+ }
|
|
|
+ hasActiveCall() {
|
|
|
+ if (!this.session) return false;
|
|
|
+ const status = this.session.status;
|
|
|
+ return status === SESSION_CONFIRMED_STATUS || status === SESSION_WAITING_ACK_STATUS || status === SESSION_ANSWERED_STATUS;
|
|
|
+ }
|
|
|
+ hasSipSession() {
|
|
|
+ if (!this.session) return false;
|
|
|
+ const status = this.session.status;
|
|
|
+ return status !== 7 && status !== 8 && status !== 0;
|
|
|
+ }
|
|
|
+ createUA() {
|
|
|
+ this.ua = new JsSIP.UA(this.configuration);
|
|
|
+ this.ua.set('display_name', this.profile.display_name);
|
|
|
+ this.ua.on('connecting', this.connecting.bind(this));
|
|
|
+ this.ua.on('connected', this.connected.bind(this));
|
|
|
+ this.ua.on('disconnected', this.disconnected.bind(this));
|
|
|
+ this.ua.on('registered', this.registered.bind(this));
|
|
|
+ this.ua.on('unregistered', this.unregistered.bind(this));
|
|
|
+ this.ua.on('registrationFailed', this.registrationFailed.bind(this));
|
|
|
+ this.ua.on('registrationExpiring', this.registrationExpiring.bind(this));
|
|
|
+ this.ua.on('newRTCSession', this.newRTCSession.bind(this));
|
|
|
+ this.ua.on('transportError', this.transportError.bind(this));
|
|
|
+ }
|
|
|
+ On(event, callback) { this.events[event] = callback; }
|
|
|
+ Off(event) { if (this.events[event]) delete this.events[event]; }
|
|
|
+ emit(event, ...args) { if (this.events[event]) try { this.events[event](...args); } catch (e) { console.error(e); } }
|
|
|
+ resetReconnectState() {
|
|
|
+ if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
|
|
|
+ this.reconnectTimerId = null; this.isReconnecting = false; this.reconnectAttempts = 0; this.reconnectStartTime = null;
|
|
|
+ this.emit('OnReconnectStatus', { isReconnecting: false, failed: false });
|
|
|
+ }
|
|
|
+ Start(reconnect, isReconnect = false) {
|
|
|
+ console.log(`[jsSip] 启动 ${reconnect ? '正常连接' : '禁用重连'} ${isReconnect ? '(重连模式)' : ''}`);
|
|
|
+ this.reconnectEnabled = reconnect;
|
|
|
+ if (this.ua) { try { if (this.ua.isRegistered()) this.ua.unregister(); this.ua.stop(); } catch (e) {} this.ua = null; }
|
|
|
+ if (!isReconnect) this.resetReconnectState();
|
|
|
+ setTimeout(() => {
|
|
|
+ this.createUA();
|
|
|
+ if (this.ua.isRegistered()) { this.SetQueueIn(); return; }
|
|
|
+ if (this.ua.isConnected()) { this.Register(); return; }
|
|
|
+ try { this.ua.start(); } catch (error) {
|
|
|
+ this.emit('OnStatusMessage', { type: 'error', text: '启动失败: ' + error.message });
|
|
|
+ this.scheduleReconnect();
|
|
|
+ }
|
|
|
+ }, 50);
|
|
|
+ }
|
|
|
+ Register() { if (this.ua) this.ua.register(); }
|
|
|
+ UnRegister() {
|
|
|
+ this.reconnectEnabled = false; this.resetReconnectState();
|
|
|
+ if (this.ua) { try { if (this.ua.isRegistered()) this.ua.unregister(); this.ua.stop(); } catch (e) {} this.ua = null; }
|
|
|
+ if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
|
|
|
+ if (this.callTimerId) clearInterval(this.callTimerId);
|
|
|
+ if (this.dtfmTimerId) clearTimeout(this.dtfmTimerId);
|
|
|
+ }
|
|
|
+ scheduleReconnect() {
|
|
|
+ if (!this.reconnectEnabled || this.hasActiveCall() || this._isIntentionalHangupPhase()) return;
|
|
|
+ if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
|
|
|
+ if (this.isReconnecting) return;
|
|
|
+ const now = Date.now();
|
|
|
+ if (this.reconnectStartTime === null) this.reconnectStartTime = now;
|
|
|
+ const elapsed = now - this.reconnectStartTime;
|
|
|
+ if (elapsed >= this.reconnectTotalDuration) {
|
|
|
+ console.error('[jsSip] 重连超时(超过1分钟)');
|
|
|
+ this.isReconnecting = false;
|
|
|
+ this.emit('OnReconnectStatus', { isReconnecting: false, failed: true });
|
|
|
+ this.emit('OnStatusMessage', { type: 'error', text: '重连超时' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.reconnectAttempts++;
|
|
|
+ const configuredSec = this.settings.reconnect_interval ?? JS_SIP_DEFAULTS.RECONNECT_INTERVAL;
|
|
|
+ let interval = this.reconnectAttempts <= 1 ? 400 : Math.min(configuredSec * 1000, 3000);
|
|
|
+ if (this.reconnectAttempts > 5) interval = 2000;
|
|
|
+ if (elapsed + interval > this.reconnectTotalDuration) {
|
|
|
+ const remainingTime = this.reconnectTotalDuration - elapsed;
|
|
|
+ if (remainingTime < 500) {
|
|
|
+ this.emit('OnReconnectStatus', { isReconnecting: false, failed: true });
|
|
|
+ this.emit('OnStatusMessage', { type: 'error', text: '重连超时' });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ interval = Math.min(interval, remainingTime);
|
|
|
+ }
|
|
|
+ console.log(`[jsSip] ${Math.ceil(interval / 1000)}秒后重连 (第${this.reconnectAttempts}次)`);
|
|
|
+ this.isReconnecting = true;
|
|
|
+ this.emit('OnReconnectStatus', { isReconnecting: true, failed: false });
|
|
|
+ this.reconnectTimerId = setTimeout(() => {
|
|
|
+ this.isReconnecting = false;
|
|
|
+ this.reconnectTimerId = null;
|
|
|
+ this.Start(true, true);
|
|
|
+ }, interval);
|
|
|
+ }
|
|
|
+ transportError(err) {
|
|
|
+ const errorMessage = err?.message || err?.reason || '传输错误';
|
|
|
+ if (this.hasActiveCall() || this.hasSipSession()) {
|
|
|
+ console.debug('[SIP] 传输错误(通话中,保持会话):', errorMessage);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ console.error('[SIP] WebSocket传输错误:', errorMessage);
|
|
|
+ let errorText = 'WSS连接失败';
|
|
|
+ if (errorMessage.includes('SecurityError') || errorMessage.includes('mixed content')) errorText = 'WSS连接被浏览器阻止';
|
|
|
+ else if (err?.code === 1006) errorText = 'WSS连接异常关闭';
|
|
|
+ else if (err?.code === 1005) errorText = 'WSS连接被拒绝';
|
|
|
+ this.emit('OnStatusMessage', { type: 'error', text: errorText });
|
|
|
+ if (!this.hasActiveCall() && !this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
|
|
|
+ }
|
|
|
+ connecting() { this.emit('OnStatusMessage', { type: 'info', text: 'jsSip连接中...' }); }
|
|
|
+ connected() { this.Register(); this.emit('OnStatusMessage', { type: 'success', text: 'jsSip开始注册' }); }
|
|
|
+ disconnected(e) {
|
|
|
+ if (this._isHandlingDisconnect) return;
|
|
|
+ this._isHandlingDisconnect = true;
|
|
|
+ const reason = e?.cause || e?.message || '未知原因';
|
|
|
+ const inCall = this.hasActiveCall() || this.hasSipSession();
|
|
|
+ if (this._isIntentionalHangupPhase()) {
|
|
|
+ console.debug(`[SIP] 主动挂机后信令变化,保持注册不重连: ${reason}`);
|
|
|
+ setTimeout(() => { this._isHandlingDisconnect = false; }, 400);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (inCall) {
|
|
|
+ console.debug(`[SIP] 信令连接断开(通话中,保持会话): ${reason}`);
|
|
|
+ this._deferredReconnectAfterCall = true;
|
|
|
+ } else {
|
|
|
+ console.warn(`[SIP] 连接断开: ${reason}`);
|
|
|
+ this.emit('OnRegister', { registered: false });
|
|
|
+ if (!this.isReconnecting && this.reconnectEnabled) {
|
|
|
+ this.scheduleReconnect();
|
|
|
+ } else if (!this.reconnectEnabled && this.ua) {
|
|
|
+ try { this.ua.stop(); } catch (err) {}
|
|
|
+ this.ua = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ setTimeout(() => { this._isHandlingDisconnect = false; }, 400);
|
|
|
+ }
|
|
|
+ registered() {
|
|
|
+ this._clearIntentionalHangupPhase();
|
|
|
+ this.resetReconnectState();
|
|
|
+ this.emit('OnRegister', { registered: true });
|
|
|
+ this.emit('OnStatusMessage', { type: 'success', text: '已连接' });
|
|
|
+ this.SetQueueIn();
|
|
|
+ }
|
|
|
+ unregistered() { this.emit('OnRegister', { registered: false }); this.emit('OnStatusMessage', { type: 'info', text: 'jsSip已注销' }); }
|
|
|
+ registrationFailed(e) {
|
|
|
+ const cause = e?.cause || e?.message || '未知原因';
|
|
|
+ const statusCode = e?.response?.status_code || '';
|
|
|
+ console.error('[jsSip] 注册失败:', cause);
|
|
|
+ let errorText = '注册失败';
|
|
|
+ if (cause === 'Connection Error') errorText = '注册失败: 无法连接服务器';
|
|
|
+ else if (cause.includes('403') || cause.includes('Forbidden') || cause.includes('401')) errorText = '注册失败: 认证失败';
|
|
|
+ else if (cause.includes('404')) errorText = '注册失败: 用户不存在';
|
|
|
+ else if (cause.includes('408') || cause.includes('Timeout')) errorText = '注册失败: 请求超时';
|
|
|
+ this.emit('OnRegister', { registered: false });
|
|
|
+ this.emit('OnStatusMessage', { type: 'error', text: errorText });
|
|
|
+ if (!this.hasActiveCall() && !this.isReconnecting && this.reconnectEnabled) this.scheduleReconnect();
|
|
|
+ }
|
|
|
+ registrationExpiring() { this.Register(); }
|
|
|
+ releaseLocalStream() {
|
|
|
+ if (this.localStream) {
|
|
|
+ releaseMediaStream(this.localStream);
|
|
|
+ this.localStream = null;
|
|
|
+ }
|
|
|
+ if (this.localMedia) this.localMedia.srcObject = null;
|
|
|
+ }
|
|
|
+ async ensureLocalStream() {
|
|
|
+ if (this.localStream?.getAudioTracks().some(t => t.readyState === 'live')) {
|
|
|
+ return this.localStream;
|
|
|
+ }
|
|
|
+ this.releaseLocalStream();
|
|
|
+ const stream = await requestMicrophoneAccess();
|
|
|
+ this.localStream = stream;
|
|
|
+ this.localMedia.srcObject = stream;
|
|
|
+ this.localMedia.muted = true;
|
|
|
+ this.localMedia.volume = 0;
|
|
|
+ return stream;
|
|
|
+ }
|
|
|
+ _getSpeakerVolume() {
|
|
|
+ return this.profile?.speaker_volume ?? JS_SIP_DEFAULTS.SPEAKER_VOLUME;
|
|
|
+ }
|
|
|
+ _getEffectiveSpeakerVolume() {
|
|
|
+ return this._speakerPaused ? 0 : (this._speakerVolume ?? this._getSpeakerVolume());
|
|
|
+ }
|
|
|
+ async Answer() {
|
|
|
+ if (!this.session || this._answering) return;
|
|
|
+ const status = this.session.status;
|
|
|
+ if (status === SESSION_CONFIRMED_STATUS || status === SESSION_ANSWERED_STATUS || status === SESSION_WAITING_ACK_STATUS) return;
|
|
|
+ if (!SESSION_ANSWERABLE_STATUSES.includes(status)) return;
|
|
|
+ try {
|
|
|
+ await this.ensureLocalStream();
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('[SIP] 麦克风初始化失败:', e.message || e);
|
|
|
+ // 本地流失败时仍尝试用兼容约束接听,避免 JsSIP 再次用增强约束失败
|
|
|
+ }
|
|
|
+ const options = {
|
|
|
+ // 使用兼容约束,减少 Overconstrained / Requested device not found
|
|
|
+ mediaConstraints: { ...MIC_MEDIA_CONSTRAINTS }
|
|
|
+ };
|
|
|
+ if (this.localStream) options.mediaStream = this.localStream;
|
|
|
+ const pcConfig = this.getPcConfig();
|
|
|
+ if (pcConfig) options.pcConfig = pcConfig;
|
|
|
+ this._answering = true;
|
|
|
+ try {
|
|
|
+ this.session.answer(options);
|
|
|
+ } catch (err) {
|
|
|
+ console.warn('[SIP] answer 失败:', err.message || err);
|
|
|
+ } finally {
|
|
|
+ this._answering = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ _scheduleAutoAnswer(attempt = 0) {
|
|
|
+ if (!this.session || attempt > 30) return;
|
|
|
+ const status = this.session.status;
|
|
|
+ if (status === SESSION_CONFIRMED_STATUS || status === SESSION_ANSWERED_STATUS || status === SESSION_WAITING_ACK_STATUS) return;
|
|
|
+ if (SESSION_ANSWERABLE_STATUSES.includes(status)) {
|
|
|
+ this.Answer();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ setTimeout(() => this._scheduleAutoAnswer(attempt + 1), 100);
|
|
|
+ }
|
|
|
+ markIntentionalHangup() {
|
|
|
+ this._intentionalTerminate = true;
|
|
|
+ this._intentionalTerminateUntil = Date.now() + 8000;
|
|
|
+ this._deferredReconnectAfterCall = false;
|
|
|
+ if (this.reconnectTimerId) {
|
|
|
+ clearTimeout(this.reconnectTimerId);
|
|
|
+ this.reconnectTimerId = null;
|
|
|
+ }
|
|
|
+ this.isReconnecting = false;
|
|
|
+ this.emit('OnReconnectStatus', { isReconnecting: false, failed: false });
|
|
|
+ }
|
|
|
+ _isIntentionalHangupPhase() {
|
|
|
+ return this._intentionalTerminate || Date.now() < this._intentionalTerminateUntil;
|
|
|
+ }
|
|
|
+ _clearIntentionalHangupPhase() {
|
|
|
+ this._intentionalTerminate = false;
|
|
|
+ this._intentionalTerminateUntil = 0;
|
|
|
+ }
|
|
|
+ Terminate(code) {
|
|
|
+ this.markIntentionalHangup();
|
|
|
+ if (!this.session) return;
|
|
|
+ if (code) this.session.terminate({ status_code: code });
|
|
|
+ else this.session.terminate();
|
|
|
+ }
|
|
|
+ ToggleHold() { if (this.session && this.session.isEstablished()) { if (this.session.isOnHold().local) this.session.unhold(); else this.session.hold(); } }
|
|
|
+ ToggleMicPhone() {
|
|
|
+ if (!this.session) return;
|
|
|
+ const muted = this.session.isMuted().audio;
|
|
|
+ if (muted) this.session.unmute({ audio: true });
|
|
|
+ else this.session.mute({ audio: true });
|
|
|
+ }
|
|
|
+ isMicMuted() { return this.session ? this.session.isMuted().audio : false; }
|
|
|
+ SetSpeaker(paused, volume) {
|
|
|
+ if (typeof paused === 'boolean') this._speakerPaused = paused;
|
|
|
+ if (typeof volume === 'number' && !Number.isNaN(volume)) {
|
|
|
+ this._speakerVolume = Math.min(1, Math.max(0, volume));
|
|
|
+ }
|
|
|
+ const vol = this._getEffectiveSpeakerVolume();
|
|
|
+ if (this.remoteMedia) this.remoteMedia.volume = vol;
|
|
|
+ if (this.ringbackMedia) this.ringbackMedia.volume = vol;
|
|
|
+ }
|
|
|
+ SetMicPhone(paused, volume) { this.localMedia.volume = paused ? 0 : volume; if (this.localStream) this.localStream.getAudioTracks().forEach((track) => { track.enabled = !paused; }); }
|
|
|
+ SetQueueIn() { if (this.ua && this.ua.isRegistered()) this.ua.sendMessage('execute_available', `${this.profile.user}@${this.profile.domain}`, {}); }
|
|
|
+ SendDTMF(tone) { if (this.session && this.session.isEstablished()) this.session.sendDTMF(tone); }
|
|
|
+ PlayDtmfTone(key) {
|
|
|
+ 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] };
|
|
|
+ const [lowFreq, highFreq] = DTMF_MAP[key] || [697,1209];
|
|
|
+ this.generateDtmfTone(lowFreq, highFreq, 0.2, this.remoteMedia.volume);
|
|
|
+ }
|
|
|
+ generateDtmfTone(lowFreq, highFreq, duration, volume) {
|
|
|
+ if (this.dtfmTimerId) { clearTimeout(this.dtfmTimerId); if (this.oscillatorLow) try { this.oscillatorLow.stop(); } catch(e){} if (this.oscillatorHigh) try { this.oscillatorHigh.stop(); } catch(e){} }
|
|
|
+ if (!this.audioCtx || this.audioCtx.state === 'closed') this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
+ this.gainNode = this.audioCtx.createGain();
|
|
|
+ this.oscillatorLow = this.audioCtx.createOscillator(); this.oscillatorLow.type = 'sine'; this.oscillatorLow.frequency.value = lowFreq;
|
|
|
+ this.oscillatorHigh = this.audioCtx.createOscillator(); this.oscillatorHigh.type = 'sine'; this.oscillatorHigh.frequency.value = highFreq;
|
|
|
+ this.oscillatorLow.connect(this.gainNode); this.oscillatorHigh.connect(this.gainNode);
|
|
|
+ this.gainNode.connect(this.audioCtx.destination);
|
|
|
+ this.gainNode.gain.setValueAtTime(volume, this.audioCtx.currentTime);
|
|
|
+ this.gainNode.gain.linearRampToValueAtTime(0, this.audioCtx.currentTime + duration);
|
|
|
+ this.oscillatorLow.start(); this.oscillatorHigh.start();
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+ IsOnHold() { return this.session ? this.session.isOnHold().local : false; }
|
|
|
+ pauseRingback() { if (this.ringbackMedia && !this.ringbackMedia.paused) this.ringbackMedia.pause(); }
|
|
|
+ playRingback() { if (this.ringbackMedia && this.ringbackMedia.paused) { this.ringbackMedia.currentTime = 0; this.ringbackMedia.play().catch(e => console.warn('[音频] 回铃音播放失败')); } }
|
|
|
+ newRTCSession(event) {
|
|
|
+ const incoming = event.session;
|
|
|
+ const outgoing = incoming.direction === 'outgoing';
|
|
|
+
|
|
|
+ if (this.session && this.session !== incoming && this.hasActiveCall() && !outgoing) {
|
|
|
+ try { incoming.terminate({ status_code: 486 }); } catch (e) {}
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const stale = this.session && this.session !== incoming ? this.session : null;
|
|
|
+ this._clearRemotePlayback();
|
|
|
+ this._answering = false;
|
|
|
+ this.session = incoming;
|
|
|
+ this._bindSessionEvents(incoming);
|
|
|
+
|
|
|
+ if (stale && stale.status !== 7 && stale.status !== 8) {
|
|
|
+ try { stale.terminate({ status_code: 486 }); } catch (e) {}
|
|
|
+ }
|
|
|
+
|
|
|
+ this.emit('OnSessionCreated', { outgoing, callee: incoming.remote_identity?.uri?.user || '', province: event.request?.getHeader('X-Province') || '', city: event.request?.getHeader('X-City') || '' });
|
|
|
+ if (!outgoing && this.settings.auto_answer !== false) {
|
|
|
+ this._scheduleAutoAnswer();
|
|
|
+ }
|
|
|
+ if (outgoing) this.playRingback();
|
|
|
+ }
|
|
|
+ _bindSessionEvents(session) {
|
|
|
+ session.on('progress', (e) => {
|
|
|
+ this.emit('OnRing', {
|
|
|
+ outgoing: session.direction === 'outgoing',
|
|
|
+ province: e.response?.getHeader('X-Province') || '',
|
|
|
+ city: e.response?.getHeader('X-City') || ''
|
|
|
+ });
|
|
|
+ });
|
|
|
+ session.on('confirmed', () => {
|
|
|
+ this.pauseRingback();
|
|
|
+ this.emit('OnAnswered', session.direction === 'outgoing');
|
|
|
+ // 外呼计时以客户真正接通为准,由 IPCC CALLEE_ANSWERED / 来电由 SIP confirmed 启动
|
|
|
+ if (session.direction !== 'outgoing') {
|
|
|
+ this.startCallTimer();
|
|
|
+ }
|
|
|
+ if (this.remoteMedia) {
|
|
|
+ this.remoteMedia.volume = this._getEffectiveSpeakerVolume();
|
|
|
+ }
|
|
|
+ this.refreshRemoteAudio();
|
|
|
+ this._scheduleRemoteAudioSync();
|
|
|
+ });
|
|
|
+ session.on('ended', () => {
|
|
|
+ console.log('[SIP] session ended', session.direction);
|
|
|
+ this.sessionClosed(true, '', session);
|
|
|
+ });
|
|
|
+ session.on('failed', (e) => {
|
|
|
+ const cause = e?.cause || '';
|
|
|
+ console.warn('[SIP] session failed:', cause, session.direction);
|
|
|
+ this.sessionClosed(false, cause, session);
|
|
|
+ });
|
|
|
+ session.on('peerconnection', (data) => {
|
|
|
+ const pc = data?.peerconnection || data;
|
|
|
+ if (pc) this.registerRemoteMedia(pc);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ registerRemoteMedia(connection) {
|
|
|
+ if (!connection) return;
|
|
|
+ this.peerConnection = connection;
|
|
|
+ const onRemoteTrack = (track) => this._attachRemoteTrack(track);
|
|
|
+ connection.ontrack = (e) => {
|
|
|
+ if (e.track) onRemoteTrack(e.track);
|
|
|
+ else if (e.streams?.[0]) e.streams[0].getAudioTracks().forEach(onRemoteTrack);
|
|
|
+ };
|
|
|
+ if (typeof connection.addEventListener === 'function') {
|
|
|
+ connection.addEventListener('track', (e) => {
|
|
|
+ if (e.track) onRemoteTrack(e.track);
|
|
|
+ else if (e.streams?.[0]) e.streams[0].getAudioTracks().forEach(onRemoteTrack);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ connection.onaddstream = (e) => {
|
|
|
+ if (e.stream) e.stream.getAudioTracks().forEach(onRemoteTrack);
|
|
|
+ };
|
|
|
+ this.syncRemoteReceivers(connection);
|
|
|
+ this.registerRemoteMediaIceHandlers(connection);
|
|
|
+ }
|
|
|
+ syncRemoteReceivers(connection) {
|
|
|
+ if (!connection || !this.remoteMedia) return false;
|
|
|
+ let attached = false;
|
|
|
+ const attach = (track) => {
|
|
|
+ if (this._attachRemoteTrack(track)) attached = true;
|
|
|
+ };
|
|
|
+ if (typeof connection.getTransceivers === 'function') {
|
|
|
+ connection.getTransceivers().forEach((tr) => {
|
|
|
+ if (tr.receiver?.track) attach(tr.receiver.track);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ if (typeof connection.getReceivers === 'function') {
|
|
|
+ connection.getReceivers().forEach((receiver) => {
|
|
|
+ if (receiver.track) attach(receiver.track);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return attached;
|
|
|
+ }
|
|
|
+ hasRemoteAudioTrack() {
|
|
|
+ const stream = this.remoteMedia?.srcObject;
|
|
|
+ if (stream?.getAudioTracks?.().some((t) => t.readyState === 'live' && t.enabled)) return true;
|
|
|
+ if (this.peerConnection) {
|
|
|
+ this.syncRemoteReceivers(this.peerConnection);
|
|
|
+ const refreshed = this.remoteMedia?.srcObject;
|
|
|
+ return !!refreshed?.getAudioTracks?.().some((t) => t.readyState === 'live' && t.enabled);
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ registerRemoteMediaIceHandlers(connection) {
|
|
|
+ if (!connection) return;
|
|
|
+ connection.oniceconnectionstatechange = () => {
|
|
|
+ const state = connection.iceConnectionState;
|
|
|
+ if (state === 'failed') {
|
|
|
+ console.warn('[SIP] ICE 状态: failed');
|
|
|
+ this.emit('OnIceMediaIssue', { state, timedOut: true });
|
|
|
+ } else if (state === 'disconnected') {
|
|
|
+ console.warn('[SIP] ICE 状态: disconnected');
|
|
|
+ if (this._iceIssueTimer) clearTimeout(this._iceIssueTimer);
|
|
|
+ this.emit('OnIceMediaIssue', { state, timedOut: false });
|
|
|
+ this._iceIssueTimer = setTimeout(() => {
|
|
|
+ this._iceIssueTimer = null;
|
|
|
+ const cur = connection.iceConnectionState;
|
|
|
+ if (cur === 'disconnected' || cur === 'failed') {
|
|
|
+ this.emit('OnIceMediaIssue', { state: cur, timedOut: true });
|
|
|
+ }
|
|
|
+ }, 12000);
|
|
|
+ } else if (state === 'connected' || state === 'completed') {
|
|
|
+ if (this._iceIssueTimer) {
|
|
|
+ clearTimeout(this._iceIssueTimer);
|
|
|
+ this._iceIssueTimer = null;
|
|
|
+ }
|
|
|
+ this.resumeRemoteAudio();
|
|
|
+ this._scheduleRemotePlayRetry(0);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ }
|
|
|
+ stopCallTimer() {
|
|
|
+ if (this.callTimerId) {
|
|
|
+ clearInterval(this.callTimerId);
|
|
|
+ this.callTimerId = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ clearIceIssueTimer() {
|
|
|
+ if (this._iceIssueTimer) {
|
|
|
+ clearTimeout(this._iceIssueTimer);
|
|
|
+ this._iceIssueTimer = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ sessionClosed(succeed, reason, closedSession) {
|
|
|
+ if (!closedSession || this.session !== closedSession) return;
|
|
|
+ const intentional = this._isIntentionalHangupPhase();
|
|
|
+ const shouldReconnect = !intentional && this._deferredReconnectAfterCall;
|
|
|
+ this._deferredReconnectAfterCall = false;
|
|
|
+ this.clearIceIssueTimer();
|
|
|
+ this.stopCallTimer();
|
|
|
+ this._answering = false;
|
|
|
+ this._clearRemotePlayback();
|
|
|
+ this.session = null;
|
|
|
+ if (!intentional) {
|
|
|
+ this.releaseLocalStream();
|
|
|
+ }
|
|
|
+ this.emit('OnSessionClosed', { succeeded: succeed, reason, intentional });
|
|
|
+ if (shouldReconnect && this.reconnectEnabled && !this.isReconnecting) {
|
|
|
+ this.scheduleReconnect();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ refreshRemoteAudio() {
|
|
|
+ if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
|
|
|
+ return this.resumeRemoteAudio();
|
|
|
+ }
|
|
|
+ async resumeRemoteAudio() {
|
|
|
+ if (this.peerConnection) this.syncRemoteReceivers(this.peerConnection);
|
|
|
+ if (this.audioCtx && this.audioCtx.state === 'suspended') {
|
|
|
+ try { await this.audioCtx.resume(); } catch (e) {}
|
|
|
+ }
|
|
|
+ if (!this.remoteMedia || !this.hasRemoteAudioTrack()) return false;
|
|
|
+ this.remoteMedia.volume = this._getEffectiveSpeakerVolume();
|
|
|
+ this.remoteMedia.muted = false;
|
|
|
+ try {
|
|
|
+ await this.remoteMedia.play();
|
|
|
+ return !this.remoteMedia.paused;
|
|
|
+ } catch (e) {
|
|
|
+ console.warn('[音频] 远端播放 resume 失败:', e.message || e);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ startCallTimer() {
|
|
|
+ if (this.callTimerId) clearInterval(this.callTimerId);
|
|
|
+ let seconds = 0;
|
|
|
+ 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);
|
|
|
+ }
|
|
|
+ destroy() {
|
|
|
+ this.reconnectEnabled = false;
|
|
|
+ this.clearIceIssueTimer();
|
|
|
+ this._clearRemotePlayback();
|
|
|
+ if (this.reconnectTimerId) clearTimeout(this.reconnectTimerId);
|
|
|
+ if (this.callTimerId) clearInterval(this.callTimerId);
|
|
|
+ if (this.dtfmTimerId) clearTimeout(this.dtfmTimerId);
|
|
|
+ if (this.session) {
|
|
|
+ try { this.session.terminate(); } catch (e) {}
|
|
|
+ this.session = null;
|
|
|
+ }
|
|
|
+ this.UnRegister();
|
|
|
+ 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) {} };
|
|
|
+ cleanupAudio(this.ringbackMedia); cleanupAudio(this.remoteMedia); cleanupAudio(this.localMedia);
|
|
|
+ this._unmountPlaybackElement('sip-ringback-audio');
|
|
|
+ this._unmountPlaybackElement('sip-remote-audio');
|
|
|
+ this.releaseLocalStream();
|
|
|
+ this.ringbackMedia = null; this.remoteMedia = null; this.localMedia = null;
|
|
|
+ if (this.audioCtx) { try { if (this.audioCtx.state !== 'closed') this.audioCtx.close(); } catch(e) {} this.audioCtx = null; }
|
|
|
+ this.oscillatorLow = null; this.oscillatorHigh = null; this.gainNode = null;
|
|
|
+ this.events = {}; this.configuration = null; this.profile = null; this.settings = null; this.session = null; this.ua = null;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+export default {
|
|
|
+ WebPhone, ProfileManager, checkMicrophonePermission, releaseMediaStream,
|
|
|
+ RINGBACK_AUDIO_URL, IPCC_DEFAULTS, JS_SIP_DEFAULTS
|
|
|
+};
|