[{"data":1,"prerenderedAt":27},["ShallowReactive",2],{"articolo-videochiamate-in-flutter-con-flutter-webrtc-dal-signaling-alla-chiamata-peer-to-peer":3,"comments-article-videochiamate-in-flutter-con-flutter-webrtc-dal-signaling-alla-chiamata-peer-to-peer":26},{"id":4,"title":5,"slug":6,"excerpt":7,"body":8,"cover_image":9,"cover_remote_url":10,"cover_credit":11,"video_url":15,"status":16,"published_at":17,"meta_title":18,"meta_description":19,"category":20,"author":24},116,"Videochiamate in Flutter con flutter_webrtc: dal signaling alla chiamata peer-to-peer","videochiamate-in-flutter-con-flutter-webrtc-dal-signaling-alla-chiamata-peer-to-peer","Guida pratica a flutter_webrtc: come configurare peer connection, gestire il signaling via WebSocket, scambiare offer\u002Fanswer e candidati ICE e costruire una videochiamata affidabile su Android e iOS.","## Perché WebRTC in Flutter\n\nQuando un'app deve offrire videochiamate, audio call o condivisione dello schermo in tempo reale, il protocollo di riferimento è **WebRTC**: uno standard supportato da tutti i browser e disponibile nativamente su Android e iOS. In Flutter il binding ufficiale è il pacchetto [`flutter_webrtc`](https:\u002F\u002Fpub.dev\u002Fpackages\u002Fflutter_webrtc), mantenuto dalla community di CloudWebRTC, che espone un'API molto vicina a quella JavaScript (`getUserMedia`, `RTCPeerConnection`, `RTCDataChannel`).\n\nA differenza di soluzioni \"chiavi in mano\" come Agora, Twilio Video o LiveKit, con `flutter_webrtc` si lavora direttamente sul livello più basso: questo significa controllo totale, zero vendor lock-in e costi legati solo alla propria infrastruttura, ma anche la responsabilità di implementare il **signaling** e di fornire server **STUN\u002FTURN**.\n\nIn questa guida costruiamo una videochiamata 1-a-1 (peer-to-peer) completa, con signaling via WebSocket.\n\n## I tre pezzi del puzzle\n\nPrima di scrivere codice è fondamentale avere chiaro il modello mentale:\n\n1. **Media**: i flussi audio\u002Fvideo locali, ottenuti dalla fotocamera e dal microfono con `getUserMedia`.\n2. **Signaling**: il canale *fuori banda* (non definito da WebRTC) con cui i due peer si scambiano le descrizioni di sessione SDP (offer\u002Fanswer) e i candidati ICE. Può essere un WebSocket, Firestore, MQTT o qualsiasi altra cosa.\n3. **Connettività (ICE)**: STUN per scoprire l'indirizzo pubblico dietro NAT, TURN per fare da relay quando la connessione diretta è impossibile.\n\n> Regola pratica: **senza un server TURN, dal 10% al 20% delle chiamate reali fallisce** (reti aziendali, NAT simmetrici, alcune reti mobili). Lo STUN pubblico di Google va bene per i test, non per la produzione.\n\n## Installazione e configurazione nativa\n\n```yaml\ndependencies:\n  flutter_webrtc: ^0.12.5\n  web_socket_channel: ^3.0.1\n  permission_handler: ^11.3.1\n```\n\n### Android\n\nIn `android\u002Fapp\u002Fsrc\u002Fmain\u002FAndroidManifest.xml`:\n\n```xml\n\u003Cuses-permission android:name=\"android.permission.INTERNET\" \u002F>\n\u003Cuses-permission android:name=\"android.permission.CAMERA\" \u002F>\n\u003Cuses-permission android:name=\"android.permission.RECORD_AUDIO\" \u002F>\n\u003Cuses-permission android:name=\"android.permission.MODIFY_AUDIO_SETTINGS\" \u002F>\n\u003Cuses-permission android:name=\"android.permission.ACCESS_NETWORK_STATE\" \u002F>\n\u003Cuses-permission android:name=\"android.permission.BLUETOOTH\" \u002F>\n\u003Cuses-feature android:name=\"android.hardware.camera\" \u002F>\n```\n\nIl `minSdkVersion` deve essere almeno **23**. Se usi R8\u002FProGuard, il pacchetto include già le regole necessarie.\n\n### iOS\n\nIn `ios\u002FRunner\u002FInfo.plist`:\n\n```xml\n\u003Ckey>NSCameraUsageDescription\u003C\u002Fkey>\n\u003Cstring>L'app usa la fotocamera per le videochiamate\u003C\u002Fstring>\n\u003Ckey>NSMicrophoneUsageDescription\u003C\u002Fkey>\n\u003Cstring>L'app usa il microfono per le chiamate\u003C\u002Fstring>\n```\n\nE nel `Podfile` imposta `platform :ios, '13.0'` o superiore. Per ricevere chiamate ad app chiusa servirà poi l'integrazione con **CallKit + PushKit** (vedi ultima sezione).\n\n## Inizializzare i renderer e il media locale\n\nI flussi video si mostrano con `RTCVideoView`, che richiede un `RTCVideoRenderer` inizializzato (e, importantissimo, smaltito nel `dispose`).\n\n```dart\nimport 'package:flutter_webrtc\u002Fflutter_webrtc.dart';\n\nclass CallController {\n  final localRenderer = RTCVideoRenderer();\n  final remoteRenderer = RTCVideoRenderer();\n\n  MediaStream? _localStream;\n  RTCPeerConnection? _pc;\n\n  Future\u003Cvoid> initRenderers() async {\n    await localRenderer.initialize();\n    await remoteRenderer.initialize();\n  }\n\n  Future\u003Cvoid> openUserMedia() async {\n    final constraints = \u003CString, dynamic>{\n      'audio': true,\n      'video': {\n        'facingMode': 'user',\n        'width': {'ideal': 1280},\n        'height': {'ideal': 720},\n        'frameRate': {'ideal': 30},\n      },\n    };\n\n    _localStream = await navigator.mediaDevices.getUserMedia(constraints);\n    localRenderer.srcObject = _localStream;\n  }\n\n  Future\u003Cvoid> dispose() async {\n    await _localStream?.dispose();\n    await _pc?.close();\n    await localRenderer.dispose();\n    await remoteRenderer.dispose();\n  }\n}\n```\n\nNota: le `constraints` sono **suggerimenti**. WebRTC adatta dinamicamente risoluzione e bitrate alle condizioni di rete, quindi non serve (e non conviene) forzare valori rigidi.\n\n## Creare la RTCPeerConnection\n\n```dart\nFuture\u003Cvoid> createPeerConnection_() async {\n  final config = \u003CString, dynamic>{\n    'iceServers': [\n      {'urls': 'stun:stun.l.google.com:19302'},\n      {\n        'urls': 'turn:turn.miodominio.it:3478',\n        'username': 'utente',\n        'credential': 'segreto-temporaneo',\n      },\n    ],\n    \u002F\u002F 'unified-plan' è lo standard moderno (default nelle versioni recenti)\n    'sdpSemantics': 'unified-plan',\n  };\n\n  _pc = await createPeerConnection(config);\n\n  \u002F\u002F Pubblica le tracce locali verso il peer remoto\n  for (final track in _localStream!.getTracks()) {\n    await _pc!.addTrack(track, _localStream!);\n  }\n\n  \u002F\u002F Riceve le tracce remote\n  _pc!.onTrack = (RTCTrackEvent event) {\n    if (event.track.kind == 'video' && event.streams.isNotEmpty) {\n      remoteRenderer.srcObject = event.streams.first;\n    }\n  };\n\n  \u002F\u002F Candidati ICE da inviare all'altro peer\n  _pc!.onIceCandidate = (RTCIceCandidate candidate) {\n    _signaling.send({\n      'type': 'candidate',\n      'candidate': candidate.toMap(),\n    });\n  };\n\n  _pc!.onConnectionState = (RTCPeerConnectionState state) {\n    debugPrint('Connection state: $state');\n    if (state == RTCPeerConnectionState.RTCPeerConnectionStateFailed) {\n      _pc!.restartIce(); \u002F\u002F tentativo di recupero\n    }\n  };\n}\n```\n\n### Credenziali TURN: mai hardcoded\n\nLe credenziali TURN vanno generate dal backend con il meccanismo delle **ephemeral credentials** (REST API di coturn): username = `timestamp:userId`, password = HMAC-SHA1 del username con un segreto condiviso. Validità tipica: 1-2 ore. Inserire user e password statici nel binario dell'app equivale a regalare banda a chiunque decompili l'APK.\n\n## Il signaling: chi chiama e chi risponde\n\nIl flusso canonico di una chiamata 1-a-1 è questo:\n\n1. **Caller**: `createOffer()` → `setLocalDescription()` → invia l'SDP.\n2. **Callee**: riceve l'offer → `setRemoteDescription()` → `createAnswer()` → `setLocalDescription()` → rinvia l'SDP.\n3. Entrambi si scambiano i candidati ICE man mano che vengono generati (**trickle ICE**).\n\nUn client di signaling minimale su WebSocket:\n\n```dart\nimport 'dart:convert';\nimport 'package:web_socket_channel\u002Fweb_socket_channel.dart';\n\nclass SignalingClient {\n  SignalingClient(this._uri);\n  final Uri _uri;\n  WebSocketChannel? _channel;\n\n  final _controller = StreamController\u003CMap\u003CString, dynamic>>.broadcast();\n  Stream\u003CMap\u003CString, dynamic>> get messages => _controller.stream;\n\n  Future\u003Cvoid> connect(String roomId, String token) async {\n    _channel = WebSocketChannel.connect(\n      _uri.replace(queryParameters: {'room': roomId, 'token': token}),\n    );\n    _channel!.stream.listen(\n      (raw) => _controller.add(jsonDecode(raw as String) as Map\u003CString, dynamic>),\n      onDone: _scheduleReconnect,\n      onError: (_) => _scheduleReconnect(),\n    );\n  }\n\n  void send(Map\u003CString, dynamic> message) =>\n      _channel?.sink.add(jsonEncode(message));\n\n  void _scheduleReconnect() {\n    \u002F\u002F backoff esponenziale con jitter: il signaling deve sopravvivere\n    \u002F\u002F a cambi di rete Wi-Fi\u002F4G senza far cadere la chiamata già stabilita\n  }\n}\n```\n\nPunto spesso sottovalutato: **se il signaling cade dopo che la connessione è stabilita, la chiamata continua a funzionare**. WebRTC scambia media direttamente tra i peer. Il signaling serve di nuovo solo in caso di rinegoziazione o ICE restart.\n\n### Lato caller\n\n```dart\nFuture\u003Cvoid> makeCall() async {\n  await createPeerConnection_();\n\n  final offer = await _pc!.createOffer({\n    'offerToReceiveAudio': true,\n    'offerToReceiveVideo': true,\n  });\n  await _pc!.setLocalDescription(offer);\n\n  _signaling.send({\n    'type': 'offer',\n    'sdp': offer.sdp,\n    'sdpType': offer.type,\n  });\n}\n```\n\n### Lato callee\n\n```dart\nFuture\u003Cvoid> handleOffer(Map\u003CString, dynamic> msg) async {\n  await createPeerConnection_();\n\n  await _pc!.setRemoteDescription(\n    RTCSessionDescription(msg['sdp'] as String, msg['sdpType'] as String),\n  );\n\n  final answer = await _pc!.createAnswer();\n  await _pc!.setLocalDescription(answer);\n\n  _signaling.send({\n    'type': 'answer',\n    'sdp': answer.sdp,\n    'sdpType': answer.type,\n  });\n\n  await _drainPendingCandidates();\n}\n```\n\n### Candidati ICE e il problema dell'ordine\n\nI candidati remoti possono arrivare **prima** che `setRemoteDescription` sia stato chiamato: in quel caso `addCandidate` fallisce. La soluzione è bufferizzarli:\n\n```dart\nfinal List\u003CRTCIceCandidate> _pendingCandidates = [];\nbool _remoteDescriptionSet = false;\n\nFuture\u003Cvoid> handleCandidate(Map\u003CString, dynamic> msg) async {\n  final map = msg['candidate'] as Map\u003CString, dynamic>;\n  final candidate = RTCIceCandidate(\n    map['candidate'] as String?,\n    map['sdpMid'] as String?,\n    map['sdpMLineIndex'] as int?,\n  );\n\n  if (_remoteDescriptionSet) {\n    await _pc!.addCandidate(candidate);\n  } else {\n    _pendingCandidates.add(candidate);\n  }\n}\n\nFuture\u003Cvoid> _drainPendingCandidates() async {\n  _remoteDescriptionSet = true;\n  for (final c in _pendingCandidates) {\n    await _pc!.addCandidate(c);\n  }\n  _pendingCandidates.clear();\n}\n```\n\n## La UI della chiamata\n\n```dart\n@override\nWidget build(BuildContext context) {\n  return Scaffold(\n    backgroundColor: Colors.black,\n    body: Stack(\n      children: [\n        \u002F\u002F Video remoto a tutto schermo\n        Positioned.fill(\n          child: RTCVideoView(\n            controller.remoteRenderer,\n            objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,\n          ),\n        ),\n        \u002F\u002F Anteprima locale in alto a destra\n        Positioned(\n          top: MediaQuery.of(context).padding.top + 16,\n          right: 16,\n          width: 110,\n          height: 160,\n          child: ClipRRect(\n            borderRadius: BorderRadius.circular(12),\n            child: RTCVideoView(\n              controller.localRenderer,\n              mirror: true, \u002F\u002F specchia la fotocamera frontale\n              objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitCover,\n            ),\n          ),\n        ),\n        const Align(\n          alignment: Alignment.bottomCenter,\n          child: CallControlsBar(),\n        ),\n      ],\n    ),\n  );\n}\n```\n\nIl flag `mirror: true` va usato **solo** sull'anteprima locale con fotocamera frontale: specchiare il video remoto è un errore classico che confonde chi legge testo o mostra oggetti.\n\n## Controlli della chiamata\n\n```dart\n\u002F\u002F Mute \u002F unmute del microfono\nvoid toggleMic() {\n  final track = _localStream!.getAudioTracks().first;\n  track.enabled = !track.enabled;\n}\n\n\u002F\u002F Spegnere la fotocamera (la traccia resta negoziata, non serve rinegoziare)\nvoid toggleCamera() {\n  final track = _localStream!.getVideoTracks().first;\n  track.enabled = !track.enabled;\n}\n\n\u002F\u002F Passare da frontale a posteriore\nFuture\u003Cvoid> switchCamera() async {\n  final track = _localStream!.getVideoTracks().first;\n  await Helper.switchCamera(track);\n}\n\n\u002F\u002F Instradare l'audio sull'altoparlante\nFuture\u003Cvoid> enableSpeaker(bool on) async {\n  await Helper.setSpeakerphoneOn(on);\n}\n```\n\nAttenzione: `track.enabled = false` interrompe l'invio dei dati ma **non spegne il LED della fotocamera** né rilascia il device. Se vuoi davvero liberare l'hardware devi fermare la traccia e rinegoziare con `replaceTrack`.\n\n## Chiudere correttamente la chiamata\n\nUna gestione sciatta della chiusura è la prima causa di fotocamere che restano accese e di memory leak:\n\n```dart\nFuture\u003Cvoid> hangUp() async {\n  _signaling.send({'type': 'bye'});\n\n  for (final track in _localStream?.getTracks() ?? \u003CMediaStreamTrack>[]) {\n    await track.stop();\n  }\n  await _localStream?.dispose();\n  _localStream = null;\n\n  await _pc?.close();\n  await _pc?.dispose();\n  _pc = null;\n\n  localRenderer.srcObject = null;\n  remoteRenderer.srcObject = null;\n}\n```\n\n## Monitorare la qualità con getStats\n\nWebRTC espone metriche dettagliate che permettono di mostrare all'utente un indicatore di qualità della rete:\n\n```dart\nFuture\u003Cvoid> logQuality() async {\n  final reports = await _pc!.getStats();\n  for (final report in reports) {\n    if (report.type == 'inbound-rtp' && report.values['kind'] == 'video') {\n      final lost = report.values['packetsLost'];\n      final jitter = report.values['jitter'];\n      final fps = report.values['framesPerSecond'];\n      debugPrint('Video in: lost=$lost jitter=$jitter fps=$fps');\n    }\n    if (report.type == 'candidate-pair' && report.values['state'] == 'succeeded') {\n      debugPrint('RTT: ${report.values['currentRoundTripTime']}');\n    }\n  }\n}\n```\n\nUna buona euristica: RTT sopra i 300 ms o packet loss sopra il 5% → mostra l'avviso \"connessione instabile\" e valuta di disattivare il video mantenendo l'audio.\n\n## Oltre il peer-to-peer: quando serve un SFU\n\nIl modello mesh peer-to-peer funziona bene **solo fino a 3-4 partecipanti**: ogni peer deve inviare il proprio video a tutti gli altri, quindi l'upload cresce linearmente e la batteria si esaurisce rapidamente. Per riunioni con più persone serve un **SFU** (Selective Forwarding Unit): ogni client invia un solo flusso al server, che lo smista agli altri.\n\nOpzioni open source compatibili con `flutter_webrtc`: **LiveKit** (che offre anche un SDK Flutter già pronto), **mediasoup**, **Janus**, **ion-sfu**. Se il progetto prevede stanze multi-utente, partire direttamente da LiveKit fa risparmiare settimane di lavoro.\n\n## Chiamate in arrivo ad app chiusa\n\nPer un'esperienza da \"vera app di telefonia\" servono due pezzi aggiuntivi:\n\n- **Android**: una notifica high-priority tramite FCM con un `ForegroundService` di tipo `microphone|camera`.\n- **iOS**: **PushKit (VoIP push) + CallKit**, obbligatori dal momento che iOS non permette di risvegliare l'app con una notifica normale per mostrare una schermata di chiamata. Importante: iOS richiede che ogni push VoIP ricevuto riporti **immediatamente** una chiamata a CallKit, altrimenti il sistema blocca l'app.\n\nIl pacchetto `flutter_callkit_incoming` astrae entrambe le piattaforme ed è il compagno naturale di `flutter_webrtc`.\n\n## Checklist finale\n\n- Chiedi i permessi di camera e microfono **prima** di `getUserMedia`, con una schermata esplicativa.\n- Configura sempre un server **TURN** con credenziali effimere.\n- Bufferizza i candidati ICE ricevuti prima della remote description.\n- Gestisci `RTCPeerConnectionStateFailed` con `restartIce()`.\n- Ferma le tracce e chiudi la peer connection alla fine della chiamata.\n- Testa su rete mobile reale e con passaggio Wi-Fi → 4G: è lì che emergono i bug veri.\n- Non testare su emulatore: la fotocamera virtuale e lo stack di rete non sono rappresentativi.\n\nWebRTC ha una curva di apprendimento ripida, ma `flutter_webrtc` offre un'API sufficientemente fedele allo standard da permettere di riutilizzare tutta la documentazione e i pattern del mondo web. Una volta compresi offer\u002Fanswer e ICE, il resto è ingegneria di prodotto.","https:\u002F\u002Fflutter.it\u002Fstorage\u002Farticles\u002F8b46d16c-2277-4184-bebb-17ca5f2f1662.jpg","https:\u002F\u002Fimages.unsplash.com\u002Fphoto-1758600432910-14619439ea8d?crop=entropy&cs=tinysrgb&fit=max&fm=jpg&ixid=M3w5NzA2NTJ8MHwxfHJhbmRvbXx8fHx8fHx8fDE3OTEwMDAwOTZ8&ixlib=rb-4.1.0&q=80&w=1080",{"name":12,"author_url":13,"photo_url":14},"Vitaly Gariev","https:\u002F\u002Funsplash.com\u002F@silverkblack","https:\u002F\u002Funsplash.com\u002Fphotos\u002Fwoman-in-yellow-turtleneck-taking-a-selfie-_pHgj6y8E5U",null,"published","2026-10-03T04:01:36+00:00","Videochiamate Flutter con flutter_webrtc: guida","Come implementare videochiamate in Flutter con flutter_webrtc: peer connection, signaling WebSocket, offer\u002Fanswer, candidati ICE, TURN e controlli chiamata.",{"id":21,"name":22,"slug":23},1,"Guide","guide",{"id":21,"name":25},"Flutter Bot",[],1792316907727]