import 'dart:convert'; import 'package:flutter/material.dart'; import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; const _wsUrl = 'wss://elwcam.duckdns.org/ws'; const _camPin = '1306'; class CamScreen extends StatefulWidget { const CamScreen({super.key}); @override State createState() => _CamScreenState(); } class _CamScreenState extends State { final _localRenderer = RTCVideoRenderer(); MediaStream? _localStream; RTCPeerConnection? _pc; WebSocketChannel? _ws; bool _streaming = false; bool _loading = false; bool _viewerOn = false; bool _frontCamera = true; String _status = 'Pronto para transmitir'; @override void initState() { super.initState(); _localRenderer.initialize(); _initForegroundTask(); } void _initForegroundTask() { FlutterForegroundTask.init( androidNotificationOptions: AndroidNotificationOptions( channelId: 'elwcam', channelName: 'ELW Cam', channelDescription: 'Transmissão ao vivo ativa', channelImportance: NotificationChannelImportance.LOW, priority: NotificationPriority.LOW, iconData: const NotificationIconData( resType: ResourceType.mipmap, resPrefix: ResourcePrefix.ic, name: 'launcher', ), ), iosNotificationOptions: const IOSNotificationOptions(), foregroundTaskOptions: const ForegroundTaskOptions( interval: 5000, isOnceEvent: false, autoRunOnBoot: false, allowWakeLock: true, allowWifiLock: true, ), ); } Future _startForegroundService() async { await FlutterForegroundTask.startService( notificationTitle: '🔴 ELW Cam — Ao vivo', notificationText: 'Transmissão ativa. Toque para abrir.', callback: null, ); } Future _stopForegroundService() async { await FlutterForegroundTask.stopService(); } Future _startStream() async { setState(() { _loading = true; _status = 'Acessando câmera...'; }); try { final constraints = { 'audio': true, 'video': { 'facingMode': _frontCamera ? 'user' : 'environment', 'width': {'ideal': 1280}, 'height': {'ideal': 720}, }, }; _localStream = await navigator.mediaDevices.getUserMedia(constraints); _localRenderer.srcObject = _localStream; _connectWs(); await _startForegroundService(); setState(() { _streaming = true; _loading = false; _status = 'Conectando...'; }); } catch (e) { setState(() { _loading = false; _status = 'Erro: $e'; }); } } void _connectWs() { _ws?.sink.close(); final uri = Uri.parse('$_wsUrl?role=cam&pin=$_camPin'); _ws = WebSocketChannel.connect(uri); _ws!.stream.listen( (raw) async { final msg = jsonDecode(raw as String); final type = msg['type'] as String; if (type == 'viewer_joined') { setState(() { _viewerOn = true; _status = '🔴 Ao vivo — viewer conectado!'; }); await _sendOffer(); } else if (type == 'viewer_left') { setState(() { _viewerOn = false; _status = 'Aguardando viewer...'; }); _pc?.close(); _pc = null; } else if (type == 'answer_single') { await _pc?.setRemoteDescription( RTCSessionDescription(msg['sdp'] as String, 'answer')); } else if (type == 'ice') { final c = msg['candidate']; await _pc?.addCandidate(RTCIceCandidate( c['candidate'] as String, c['sdpMid'] as String?, c['sdpMLineIndex'] as int?, )); } }, onDone: () { if (_streaming) { setState(() { _status = 'Reconectando...'; }); Future.delayed(const Duration(seconds: 3), _connectWs); } }, onError: (_) { if (_streaming) { Future.delayed(const Duration(seconds: 3), _connectWs); } }, ); setState(() { _status = 'Aguardando viewer...'; }); } Future _sendOffer() async { _pc?.close(); _pc = await createPeerConnection({ 'iceServers': [{'urls': 'stun:stun.l.google.com:19302'}], }); _localStream!.getTracks().forEach((t) => _pc!.addTrack(t, _localStream!)); _pc!.onIceCandidate = (candidate) { if (candidate.candidate != null) { _ws?.sink.add(jsonEncode({ 'type': 'ice', 'cam': 'single', 'candidate': candidate.toMap(), })); } }; final offer = await _pc!.createOffer({'offerToReceiveVideo': 0}); await _pc!.setLocalDescription(offer); _ws?.sink.add(jsonEncode({'type': 'offer_single', 'sdp': offer.sdp})); } Future _stopStream() async { _ws?.sink.close(); _ws = null; _pc?.close(); _pc = null; _localStream?.getTracks().forEach((t) => t.stop()); _localStream?.dispose(); _localStream = null; _localRenderer.srcObject = null; await _stopForegroundService(); setState(() { _streaming = false; _viewerOn = false; _status = 'Transmissão encerrada'; }); } Future _flipCamera() async { if (_localStream == null) { setState(() { _frontCamera = !_frontCamera; }); return; } setState(() { _frontCamera = !_frontCamera; }); final videoTrack = _localStream!.getVideoTracks().first; await Helper.switchCamera(videoTrack); } @override void dispose() { _stopStream(); _localRenderer.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Colors.black, appBar: AppBar( backgroundColor: Colors.black, foregroundColor: Colors.white, title: const Text('ELW Cam'), actions: [ if (_streaming) IconButton( icon: Icon(_frontCamera ? Icons.camera_front : Icons.camera_rear), onPressed: _flipCamera, tooltip: 'Trocar câmera', ), ], ), body: Column( children: [ // Preview câmera Expanded( child: _localStream != null ? ClipRRect( borderRadius: BorderRadius.circular(12), child: RTCVideoView(_localRenderer, mirror: _frontCamera), ) : Center( child: Icon(Icons.videocam_off, size: 80, color: Colors.grey[800]), ), ), // Status Container( width: double.infinity, padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), color: _viewerOn ? const Color(0xFF1a3a1a) : const Color(0xFF1a1a1a), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ if (_viewerOn) ...[ const Icon(Icons.circle, color: Colors.red, size: 10), const SizedBox(width: 6), ], Text(_status, style: TextStyle( color: _viewerOn ? Colors.greenAccent : Colors.white60, fontSize: 14, )), ], ), ), // Botões Padding( padding: const EdgeInsets.all(20), child: _loading ? const CircularProgressIndicator(color: Colors.red) : _streaming ? SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: _stopStream, icon: const Icon(Icons.stop), label: const Text('Parar Transmissão'), style: ElevatedButton.styleFrom( backgroundColor: Colors.grey[800], foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ), ) : SizedBox( width: double.infinity, child: ElevatedButton.icon( onPressed: _startStream, icon: const Icon(Icons.videocam), label: const Text('Iniciar Transmissão'), style: ElevatedButton.styleFrom( backgroundColor: Colors.red, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ), ), ), Padding( padding: const EdgeInsets.only(bottom: 16), child: Text( 'Pai acessa: elwcam.duckdns.org — PIN: $_camPin', style: const TextStyle(color: Colors.white24, fontSize: 11), ), ), ], ), ); } }