WebSockets والتطبيقات الفورية

بناء مكالمات الفيديو/الصوت

25 دقيقة الدرس 20 من 35

بناء مكالمات الفيديو/الصوت

يتطلب بناء تطبيقات الاتصال الصوتي والمرئي الجاهزة للإنتاج معالجة تدفقات الوسائط وعناصر التحكم في واجهة المستخدم وحالات الاتصال ومعالجة الأخطاء السلسة. لنبني تطبيق مكالمات فيديو كامل.

إعداد مكالمة الفيديو مع WebRTC

هيكل HTML كامل لتطبيق مكالمات الفيديو:

<!DOCTYPE html> <html lang="ar" dir="rtl"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>تطبيق مكالمات الفيديو</title> <style> * { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #1a1a1a; color: white; } .container { max-width: 1400px; margin: 0 auto; padding: 20px; } .video-container { position: relative; width: 100%; height: 80vh; background: #000; border-radius: 12px; overflow: hidden; } #remoteVideo { width: 100%; height: 100%; object-fit: cover; } #localVideo { position: absolute; bottom: 20px; left: 20px; width: 250px; height: 180px; border-radius: 8px; border: 2px solid white; object-fit: cover; } .controls { display: flex; justify-content: center; gap: 15px; margin-top: 20px; } button { padding: 15px 30px; border: none; border-radius: 8px; font-size: 16px; cursor: pointer; transition: all 0.3s; } .btn-primary { background: #2563eb; color: white; } .btn-danger { background: #dc2626; color: white; } .btn-secondary { background: #6b7280; color: white; } button:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.3); } button:disabled { opacity: 0.5; cursor: not-allowed; } .status { text-align: center; padding: 15px; background: #374151; border-radius: 8px; margin-bottom: 20px; } .status.connected { background: #059669; } .status.connecting { background: #d97706; } .status.error { background: #dc2626; } </style> </head> <body> <div class="container"> <div class="status" id="status">جاهز للاتصال</div> <div class="video-container"> <video id="remoteVideo" autoplay playsinline></video> <video id="localVideo" autoplay playsinline muted></video> </div> <div class="controls"> <button id="startCallBtn" class="btn-primary">بدء المكالمة</button> <button id="endCallBtn" class="btn-danger" disabled>إنهاء المكالمة</button> <button id="muteBtn" class="btn-secondary" disabled>كتم الصوت</button> <button id="videoBtn" class="btn-secondary" disabled>إيقاف الفيديو</button> <button id="screenShareBtn" class="btn-secondary" disabled>مشاركة الشاشة</button> </div> </div> <script src="video-call.js"></script> </body> </html>

تنفيذ مكالمة الفيديو الكاملة

// video-call.js class VideoCallApp { constructor() { this.signalingServer = new WebSocket('ws://localhost:8080'); this.peerConnection = null; this.localStream = null; this.remoteStream = null; this.clientId = null; this.remotePeerId = null; // حالة الوسائط this.isAudioMuted = false; this.isVideoOff = false; this.isScreenSharing = false; // عناصر DOM this.localVideo = document.getElementById('localVideo'); this.remoteVideo = document.getElementById('remoteVideo'); this.statusDiv = document.getElementById('status'); this.setupSignaling(); this.setupUI(); } setupSignaling() { this.signalingServer.onopen = () => { console.log('متصل بخادم الإشارات'); this.updateStatus('متصل بالخادم', 'connected'); }; this.signalingServer.onmessage = async (event) => { const data = JSON.parse(event.data); await this.handleSignalingMessage(data); }; this.signalingServer.onerror = (error) => { console.error('خطأ في الإشارات:', error); this.updateStatus('خطأ في الاتصال', 'error'); }; this.signalingServer.onclose = () => { this.updateStatus('منفصل عن الخادم', 'error'); }; } async handleSignalingMessage(data) { switch (data.type) { case 'registered': this.clientId = data.clientId; console.log('معرف العميل الخاص بي:', this.clientId); break; case 'offer': this.remotePeerId = data.from; await this.handleOffer(data.sdp); break; case 'answer': await this.handleAnswer(data.sdp); break; case 'ice-candidate': await this.handleIceCandidate(data.candidate); break; } } setupUI() { document.getElementById('startCallBtn').onclick = () => this.startCall(); document.getElementById('endCallBtn').onclick = () => this.endCall(); document.getElementById('muteBtn').onclick = () => this.toggleAudio(); document.getElementById('videoBtn').onclick = () => this.toggleVideo(); document.getElementById('screenShareBtn').onclick = () => this.toggleScreenShare(); } async startCall() { try { this.updateStatus('بدء المكالمة...', 'connecting'); // الحصول على الوسائط المحلية this.localStream = await navigator.mediaDevices.getUserMedia({ video: { width: { ideal: 1280 }, height: { ideal: 720 } }, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } }); this.localVideo.srcObject = this.localStream; // إنشاء اتصال نظير this.createPeerConnection(); // إضافة المسارات المحلية إلى الاتصال this.localStream.getTracks().forEach(track => { this.peerConnection.addTrack(track, this.localStream); }); // إنشاء وإرسال العرض const offer = await this.peerConnection.createOffer(); await this.peerConnection.setLocalDescription(offer); // للعرض التوضيحي، سنحتاج إلى الحصول على معرف النظير البعيد من المستخدم const remotePeerId = prompt('أدخل معرف النظير البعيد:'); if (!remotePeerId) return; this.remotePeerId = remotePeerId; this.sendSignaling({ type: 'offer', sdp: offer, target: remotePeerId }); this.enableCallControls(); this.updateStatus('جاري الاتصال...', 'connecting'); } catch (error) { console.error('خطأ في بدء المكالمة:', error); this.updateStatus('فشل في بدء المكالمة: ' + error.message, 'error'); } } createPeerConnection() { const configuration = { iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ] }; this.peerConnection = new RTCPeerConnection(configuration); // معالجة التدفق البعيد this.peerConnection.ontrack = (event) => { console.log('تم استقبال مسار بعيد'); if (!this.remoteVideo.srcObject) { this.remoteVideo.srcObject = event.streams[0]; this.updateStatus('متصل', 'connected'); } }; // معالجة مرشحي ICE this.peerConnection.onicecandidate = (event) => { if (event.candidate) { this.sendSignaling({ type: 'ice-candidate', candidate: event.candidate, target: this.remotePeerId }); } }; // معالجة تغييرات حالة الاتصال this.peerConnection.onconnectionstatechange = () => { console.log('حالة الاتصال:', this.peerConnection.connectionState); switch (this.peerConnection.connectionState) { case 'connected': this.updateStatus('المكالمة متصلة', 'connected'); break; case 'disconnected': this.updateStatus('المكالمة منفصلة', 'error'); break; case 'failed': this.updateStatus('فشل الاتصال', 'error'); this.endCall(); break; } }; } async handleOffer(offer) { try { // الحصول على الوسائط المحلية أولاً this.localStream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true }); this.localVideo.srcObject = this.localStream; // إنشاء اتصال نظير this.createPeerConnection(); // إضافة المسارات المحلية this.localStream.getTracks().forEach(track => { this.peerConnection.addTrack(track, this.localStream); }); // تعيين الوصف البعيد await this.peerConnection.setRemoteDescription( new RTCSessionDescription(offer) ); // إنشاء إجابة const answer = await this.peerConnection.createAnswer(); await this.peerConnection.setLocalDescription(answer); // إرسال الإجابة this.sendSignaling({ type: 'answer', sdp: answer, target: this.remotePeerId }); this.enableCallControls(); this.updateStatus('المكالمة متصلة', 'connected'); } catch (error) { console.error('خطأ في معالجة العرض:', error); this.updateStatus('فشل في الرد على المكالمة', 'error'); } } async handleAnswer(answer) { try { await this.peerConnection.setRemoteDescription( new RTCSessionDescription(answer) ); console.log('تم تعيين الوصف البعيد من الإجابة'); } catch (error) { console.error('خطأ في معالجة الإجابة:', error); } } async handleIceCandidate(candidate) { try { await this.peerConnection.addIceCandidate( new RTCIceCandidate(candidate) ); } catch (error) { console.error('خطأ في إضافة مرشح ICE:', error); } } toggleAudio() { if (!this.localStream) return; const audioTrack = this.localStream.getAudioTracks()[0]; if (audioTrack) { audioTrack.enabled = !audioTrack.enabled; this.isAudioMuted = !audioTrack.enabled; const btn = document.getElementById('muteBtn'); btn.textContent = this.isAudioMuted ? 'إلغاء الكتم' : 'كتم الصوت'; } } toggleVideo() { if (!this.localStream) return; const videoTrack = this.localStream.getVideoTracks()[0]; if (videoTrack) { videoTrack.enabled = !videoTrack.enabled; this.isVideoOff = !videoTrack.enabled; const btn = document.getElementById('videoBtn'); btn.textContent = this.isVideoOff ? 'تشغيل الفيديو' : 'إيقاف الفيديو'; } } async toggleScreenShare() { try { if (this.isScreenSharing) { // إيقاف مشاركة الشاشة، العودة إلى الكاميرا const videoTrack = this.localStream.getVideoTracks()[0]; const sender = this.peerConnection.getSenders() .find(s => s.track.kind === 'video'); if (sender) { await sender.replaceTrack(videoTrack); } this.isScreenSharing = false; document.getElementById('screenShareBtn').textContent = 'مشاركة الشاشة'; } else { // بدء مشاركة الشاشة const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: true }); const screenTrack = screenStream.getVideoTracks()[0]; const sender = this.peerConnection.getSenders() .find(s => s.track.kind === 'video'); if (sender) { await sender.replaceTrack(screenTrack); } // معالجة إيقاف مشاركة الشاشة screenTrack.onended = () => { this.toggleScreenShare(); }; this.isScreenSharing = true; document.getElementById('screenShareBtn').textContent = 'إيقاف المشاركة'; } } catch (error) { console.error('خطأ في تبديل مشاركة الشاشة:', error); alert('فشلت مشاركة الشاشة: ' + error.message); } } endCall() { // إيقاف جميع المسارات if (this.localStream) { this.localStream.getTracks().forEach(track => track.stop()); } // إغلاق اتصال النظير if (this.peerConnection) { this.peerConnection.close(); this.peerConnection = null; } // مسح عناصر الفيديو this.localVideo.srcObject = null; this.remoteVideo.srcObject = null; // إعادة تعيين الحالة this.localStream = null; this.remoteStream = null; this.remotePeerId = null; this.isAudioMuted = false; this.isVideoOff = false; this.isScreenSharing = false; this.disableCallControls(); this.updateStatus('انتهت المكالمة', ''); } enableCallControls() { document.getElementById('startCallBtn').disabled = true; document.getElementById('endCallBtn').disabled = false; document.getElementById('muteBtn').disabled = false; document.getElementById('videoBtn').disabled = false; document.getElementById('screenShareBtn').disabled = false; } disableCallControls() { document.getElementById('startCallBtn').disabled = false; document.getElementById('endCallBtn').disabled = true; document.getElementById('muteBtn').disabled = true; document.getElementById('videoBtn').disabled = true; document.getElementById('screenShareBtn').disabled = true; } updateStatus(message, className = '') { this.statusDiv.textContent = message; this.statusDiv.className = 'status ' + className; } sendSignaling(message) { this.signalingServer.send(JSON.stringify(message)); } } // تهيئة التطبيق const app = new VideoCallApp();

المكالمات الصوتية فقط

للمكالمات الصوتية فقط، ما عليك سوى طلب الصوت فقط:

// مكالمة صوتية فقط this.localStream = await navigator.mediaDevices.getUserMedia({ video: false, audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true, sampleRate: 48000 } }); // إخفاء عناصر الفيديو أو إظهار واجهة المستخدم الصوتية فقط document.querySelector('.video-container').classList.add('audio-only');

مشاركة الشاشة

تستخدم مشاركة الشاشة واجهة getDisplayMedia API:

async function startScreenShare() { try { const screenStream = await navigator.mediaDevices.getDisplayMedia({ video: { cursor: 'always', // إظهار المؤشر في مشاركة الشاشة displaySurface: 'monitor' // تفضيل الشاشة الكاملة }, audio: false // صوت الشاشة (اختياري) }); const screenTrack = screenStream.getVideoTracks()[0]; // استبدال مسار الفيديو بمسار الشاشة const sender = peerConnection.getSenders() .find(s => s.track && s.track.kind === 'video'); if (sender) { await sender.replaceTrack(screenTrack); } // معالجة عند توقف المستخدم عن المشاركة screenTrack.onended = () => { console.log('توقفت مشاركة الشاشة'); stopScreenShare(); }; return screenStream; } catch (error) { console.error('خطأ في بدء مشاركة الشاشة:', error); throw error; } } async function stopScreenShare() { // الحصول على مسار الكاميرا الأصلي const cameraTrack = localStream.getVideoTracks()[0]; // استبدال مسار الشاشة بمسار الكاميرا const sender = peerConnection.getSenders() .find(s => s.track && s.track.kind === 'video'); if (sender) { await sender.replaceTrack(cameraTrack); } }

مشاركون متعددون

للمشاركين المتعددين، قم بإنشاء اتصال نظير منفصل لكل مشارك:

class MultiPartyCall { constructor() { this.peerConnections = new Map(); // participantId => RTCPeerConnection this.remoteStreams = new Map(); // participantId => MediaStream } addParticipant(participantId) { const peerConnection = new RTCPeerConnection(configuration); // إضافة المسارات المحلية this.localStream.getTracks().forEach(track => { peerConnection.addTrack(track, this.localStream); }); // معالجة التدفق البعيد peerConnection.ontrack = (event) => { this.remoteStreams.set(participantId, event.streams[0]); this.updateVideoGrid(); }; // معالجة مرشحي ICE peerConnection.onicecandidate = (event) => { if (event.candidate) { this.sendToParticipant(participantId, { type: 'ice-candidate', candidate: event.candidate }); } }; this.peerConnections.set(participantId, peerConnection); } removeParticipant(participantId) { const peerConnection = this.peerConnections.get(participantId); if (peerConnection) { peerConnection.close(); this.peerConnections.delete(participantId); } this.remoteStreams.delete(participantId); this.updateVideoGrid(); } updateVideoGrid() { const container = document.getElementById('videoGrid'); container.innerHTML = ''; this.remoteStreams.forEach((stream, participantId) => { const video = document.createElement('video'); video.srcObject = stream; video.autoplay = true; video.playsinline = true; video.id = `participant-${participantId}`; container.appendChild(video); }); } }
ملاحظة: للمكالمات الجماعية الإنتاجية مع العديد من المشاركين، فكر في استخدام وحدة إعادة التوجيه الانتقائية (SFU) مثل Janus أو Mediasoup بدلاً من طوبولوجيا الشبكة.

أنماط واجهة المستخدم للمكالمات

أنماط واجهة المستخدم الشائعة لمكالمات الفيديو:

// 1. وضع صورة داخل صورة async function enablePiP() { try { if (document.pictureInPictureEnabled) { await localVideo.requestPictureInPicture(); } } catch (error) { console.error('خطأ في PiP:', error); } } // 2. وضع ملء الشاشة function toggleFullScreen() { const container = document.querySelector('.video-container'); if (!document.fullscreenElement) { container.requestFullscreen(); } else { document.exitFullscreen(); } } // 3. تبديل تخطيط الفيديو function switchLayout(layout) { const container = document.querySelector('.video-container'); switch (layout) { case 'grid': container.classList.add('grid-layout'); container.classList.remove('speaker-layout'); break; case 'speaker': container.classList.add('speaker-layout'); container.classList.remove('grid-layout'); break; } } // 4. مؤشر جودة الاتصال function updateConnectionQuality(stats) { const quality = calculateQuality(stats); const indicator = document.getElementById('quality-indicator'); indicator.className = `quality-${quality}`; // quality-good, quality-fair, quality-poor } function calculateQuality(stats) { const packetLoss = stats.packetsLost / stats.packetsReceived; if (packetLoss < 0.02) return 'good'; if (packetLoss < 0.05) return 'fair'; return 'poor'; }

معالجة الأخطاء والاستعادة

class CallErrorHandler { async handleMediaError(error) { console.error('خطأ في الوسائط:', error); if (error.name === 'NotAllowedError') { alert('تم رفض إذن الكاميرا/الميكروفون. يرجى السماح بالوصول.'); } else if (error.name === 'NotFoundError') { alert('لم يتم العثور على كاميرا أو ميكروفون.'); } else if (error.name === 'NotReadableError') { alert('الكاميرا/الميكروفون قيد الاستخدام بالفعل من قبل تطبيق آخر.'); } else { alert('فشل الوصول إلى أجهزة الوسائط: ' + error.message); } } async handleConnectionFailure() { console.log('فشل الاتصال، محاولة إعادة تشغيل ICE...'); try { const offer = await this.peerConnection.createOffer({ iceRestart: true }); await this.peerConnection.setLocalDescription(offer); this.sendSignaling({ type: 'offer', sdp: offer, target: this.remotePeerId }); } catch (error) { console.error('فشل إعادة تشغيل ICE:', error); alert('فقد الاتصال. يرجى إعادة تشغيل المكالمة.'); this.endCall(); } } monitorConnection() { setInterval(() => { if (this.peerConnection) { this.peerConnection.getStats().then(stats => { stats.forEach(report => { if (report.type === 'inbound-rtp') { console.log('الحزم المستلمة:', report.packetsReceived); console.log('الحزم المفقودة:', report.packetsLost); } }); }); } }, 5000); } }
اعتبارات الإنتاج:
  • قم دائمًا بتنفيذ إعادة الاتصال التلقائية عند فشل الاتصال
  • راقب جودة الاتصال وأخطر المستخدمين بالاتصالات الضعيفة
  • تعامل مع تغييرات الجهاز (على سبيل المثال، سماعات الرأس المتصلة)
  • قم بتنفيذ التنظيف المناسب عند تفريغ الصفحة
  • اختبر على شبكات مختلفة (WiFi، 4G، 5G)

التمرين: قم ببناء تطبيق دردشة فيديو كامل

قم بإنشاء تطبيق دردشة فيديو جاهز للإنتاج:
  1. قم بتنفيذ تطبيق مكالمة الفيديو الكامل من هذا الدرس
  2. أضف صالة انتظار/غرفة انتظار قبل الانضمام إلى المكالمات
  3. قم بتنفيذ وظيفة تسجيل المكالمات
  4. أضف خلفيات افتراضية باستخدام Canvas API
  5. اعرض مؤشرات جودة الاتصال
  6. قم بتنفيذ إعادة الاتصال التلقائية عند الفشل
  7. أضف مراسلة الدردشة جنبًا إلى جنب مع الفيديو
  8. دعم 3+ مشاركين في تخطيط شبكي
  9. إضافي: أضف إلغاء الضوضاء وطمس الخلفية

الملخص

  • تتطلب مكالمات الفيديو إعدادًا مناسبًا للوسائط واتصالات النظراء وعناصر التحكم في واجهة المستخدم
  • تعامل دائمًا مع أخطاء getUserMedia بشكل سلس مع رسائل سهلة الاستخدام
  • قم بتنفيذ ميزات كتم الصوت وتبديل الفيديو ومشاركة الشاشة
  • استخدم replaceTrack() للتبديل بين الكاميرا ومشاركة الشاشة
  • للمشاركين المتعددين، قم بإنشاء اتصالات نظير منفصلة
  • راقب جودة الاتصال وقم بتنفيذ الاستعادة التلقائية
  • فكر في استخدام SFU للمكالمات الجماعية القابلة للتوسع
  • اختبر بدقة على أجهزة وظروف شبكة مختلفة
  • قم بتنفيذ التنظيف المناسب عند انتهاء المكالمات أو مغادرة المستخدمين