From bfdaf4a80178ec39826615a87b7ea2842c13f97b Mon Sep 17 00:00:00 2001 From: ImBenji Date: Mon, 4 May 2026 08:21:31 +0100 Subject: [PATCH] inital --- android/app/src/main/AndroidManifest.xml | 9 + .../java/net/imbenji/pulsar/MainActivity.java | 249 +++++++++++ lib/screens/host_screen.dart | 247 ++++++++--- lib/screens/viewer_screen.dart | 276 ++++++++++-- lib/services/signalling.dart | 4 +- macos/Podfile.lock | 10 +- macos/Runner/AppDelegate.swift | 401 +++++++++++++++++- pubspec.lock | 32 +- pubspec.yaml | 2 +- 9 files changed, 1081 insertions(+), 149 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fd4e279..48faf63 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,13 @@ + + + + + + + + + decoders = new HashMap<>(); + + @Override + public void configureFlutterEngine(FlutterEngine flutterEngine) { + super.configureFlutterEngine(flutterEngine); + + TextureRegistry registry = flutterEngine.getRenderer(); + + new MethodChannel(flutterEngine.getDartExecutor().getBinaryMessenger(), CHANNEL) + .setMethodCallHandler((call, result) -> { + switch (call.method) { + + case "createDecoder": { + byte[] spsPps = call.argument("spsPps"); + if (spsPps == null) { + result.error("BAD_ARGS", "spsPps required", null); + break; + } + + TextureRegistry.SurfaceTextureEntry entry = registry.createSurfaceTexture(); + long texId = entry.id(); + + // we don't know the video size yet — configure with a resonable + // placeholder; MediaCodec will adapt once it sees the stream + int width = 1280; + int height = 720; + + MediaFormat fmt = MediaFormat.createVideoFormat(MediaFormat.MIMETYPE_VIDEO_AVC, width, height); + + // csd-0 must include Annex B start codes — pass the whole SPS+PPS blob + // (MediaCodec H264 accepts combined SPS+PPS with start codes in csd-0) + fmt.setByteBuffer("csd-0", ByteBuffer.wrap(spsPps)); + + entry.surfaceTexture().setDefaultBufferSize(width, height); + + fmt.setInteger(MediaFormat.KEY_COLOR_FORMAT, + MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface); + + Surface surface = new Surface(entry.surfaceTexture()); + + MediaCodec codec; + try { + codec = MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_VIDEO_AVC); + codec.configure(fmt, surface, null, 0); + codec.start(); + } catch (IOException e) { + surface.release(); + entry.release(); + result.error("CODEC_FAIL", e.getMessage(), null); + break; + } + + DecoderEntry dec = new DecoderEntry(); + dec.textureEntry = entry; + dec.surface = surface; + dec.codec = codec; + + HandlerThread ot = new HandlerThread("decoder-out-" + texId); + ot.start(); + dec.outputThread = ot; + dec.outputHandler = new Handler(ot.getLooper()); + + HandlerThread it = new HandlerThread("decoder-in-" + texId); + it.start(); + dec.inputThread = it; + dec.inputHandler = new Handler(it.getLooper()); + + drainOutputLoop(dec); + + decoders.put(texId, dec); + + Map res = new HashMap<>(); + res.put("textureId", texId); + result.success(res); + break; + } + + case "feedNal": { + long texId = ((Number) call.argument("textureId")).longValue(); + byte[] nal = call.argument("nal"); + + DecoderEntry dec = decoders.get(texId); + if (dec == null || nal == null) { + result.error("NO_DECODER", "decoder not found", null); + break; + } + + dec.inputHandler.post(() -> { + try { + int idx = dec.codec.dequeueInputBuffer(0); + if (idx >= 0) { + ByteBuffer buf = dec.codec.getInputBuffer(idx); + if (buf != null) { + buf.clear(); + buf.put(nal); + dec.codec.queueInputBuffer(idx, 0, nal.length, + System.nanoTime() / 1000, 0); + } + } + // if idx < 0 the decoder is backed up — drop this NAL silently + } catch (Exception ignored) {} + }); + + result.success(null); + break; + } + + case "stopDecoder": { + long texId = ((Number) call.argument("textureId")).longValue(); + DecoderEntry dec = decoders.remove(texId); + if (dec != null) releaseDecoder(dec); + result.success(null); + break; + } + + case "start": + case "stop": + case "forceKeyframe": + result.success(null); + break; + + default: + result.notImplemented(); + } + }); + } + + private void drainOutputLoop(DecoderEntry dec) { + dec.outputHandler.post(() -> { + MediaCodec.BufferInfo info = new MediaCodec.BufferInfo(); + while (dec.running.get()) { + try { + // block up to 10ms waiting for a decoded frame + int idx = dec.codec.dequeueOutputBuffer(info, 10_000); + if (idx >= 0) { + dec.codec.releaseOutputBuffer(idx, true); + } + // idx == INFO_TRY_AGAIN_LATER / FORMAT_CHANGED / BUFFERS_CHANGED → loop + } catch (Exception e) { + break; + } + } + }); + } + + private final Handler mainHandler = new Handler(android.os.Looper.getMainLooper()); + + private void releaseDecoder(DecoderEntry dec) { + dec.running.set(false); + // codec stop/release must happen on the decoder thread (after the drain loop exits) + // but entry.release() calls FlutterJNI.unregisterTexture which requires the main thread + dec.outputHandler.post(() -> { + try { + dec.codec.stop(); + dec.codec.release(); + } catch (Exception ignored) {} + dec.surface.release(); + mainHandler.post(() -> dec.textureEntry.release()); + }); + dec.outputThread.quitSafely(); + dec.inputThread.quitSafely(); + } + + // splits an Annex B blob into individual NAL unit ByteBuffers + // returns null if fewer than 2 NALUs found + private ByteBuffer[] splitAnnexB(byte[] data) { + java.util.List units = new java.util.ArrayList<>(); + int start = -1; + + for (int i = 0; i < data.length - 3; i++) { + if (data[i] == 0 && data[i+1] == 0 && data[i+2] == 0 && data[i+3] == 1) { + if (start >= 0) { + ByteBuffer bb = ByteBuffer.wrap(data, start, i - start); + units.add(bb); + } + start = i + 4; + i += 3; + } else if (data[i] == 0 && data[i+1] == 0 && data[i+2] == 1) { + if (start >= 0) { + ByteBuffer bb = ByteBuffer.wrap(data, start, i - start); + units.add(bb); + } + start = i + 3; + i += 2; + } + } + + if (start >= 0 && start < data.length) { + units.add(ByteBuffer.wrap(data, start, data.length - start)); + } + + return units.size() >= 2 ? units.toArray(new ByteBuffer[0]) : null; + } + + // crude SPS parser — reads width/height from the first SPS NALU + // returns null if it cant parse + private int[] dimsFromSps(ByteBuffer spsBuf) { + try { + byte[] sps = new byte[spsBuf.remaining()]; + spsBuf.duplicate().get(sps); + + // skip NAL header byte (forbidden_zero_bit + nal_ref_idc + nal_unit_type) + // a minimal parse isn't reliable without a full RBSP/Exp-Golomb parser, + // so just return null and let MediaCodec figure it out + return null; + } catch (Exception e) { + return null; + } + } + + @Override + protected void onDestroy() { + for (DecoderEntry dec : decoders.values()) { + releaseDecoder(dec); + } + decoders.clear(); + super.onDestroy(); + } } diff --git a/lib/screens/host_screen.dart b/lib/screens/host_screen.dart index ad28136..d51756f 100644 --- a/lib/screens/host_screen.dart +++ b/lib/screens/host_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -8,8 +9,9 @@ import 'package:flutter_webrtc/flutter_webrtc.dart'; import '../services/signalling.dart'; -// platform channel for injecting input into the OS const _inputChannel = MethodChannel('com.pulsar/input'); +const _videoChannel = MethodChannel('com.pulsar/video'); +const _frameEvents = EventChannel('com.pulsar/video/frames'); class HostScreen extends StatefulWidget { final String serverUrl; @@ -23,13 +25,31 @@ class _HostScreenState extends State { final _sig = SignallingService(); RTCPeerConnection? _pc; RTCDataChannel? _dataChannel; - MediaStream? _localStream; String _roomId = ''; String _status = 'Initialising...'; bool _connected = false; StreamSubscription? _sigSub; + StreamSubscription? _frameSub; + + // stats + bool _showStats = false; + int _statFps = 0; + double _statBitrateMbps = 0; + int _statDropped = 0; + int _statBufferKb = 0; + double _statSendMs = 0; + + int _frameCount = 0; + int _byteCount = 0; + int _droppedCount = 0; + double _sendMsSum = 0; + int _sendMsCount = 0; + Timer? _statsTimer; + + // viewer stats received over data channel + Map? _viewerStats; @override void initState() { @@ -67,7 +87,6 @@ class _HostScreenState extends State { switch (type) { case 'created': _setState('Waiting for viewer...'); - await _startCapture(); break; case 'peer_joined': @@ -82,6 +101,7 @@ class _HostScreenState extends State { case 'peer_disconnected': _setState('Viewer disconnected'); + _stopCapture(); setState(() => _connected = false); break; @@ -91,17 +111,6 @@ class _HostScreenState extends State { } } - Future _startCapture() async { - try { - _localStream = await navigator.mediaDevices.getDisplayMedia({ - 'video': {'cursor': 'always'}, - 'audio': false, - }); - } catch (e) { - _setState('Screen capture failed: $e'); - } - } - Future _createOffer() async { final config = { 'iceServers': [ @@ -111,31 +120,34 @@ class _HostScreenState extends State { _pc = await createPeerConnection(config); - // data channel for input events (host creates it as offerer) - _dataChannel = await _pc!.createDataChannel( - 'input', - RTCDataChannelInit()..ordered = true, - ); + // unordered + unreliable — drop stale frames, only the latest matters + final dcInit = RTCDataChannelInit() + ..ordered = false + ..maxRetransmits = 0; + + _dataChannel = await _pc!.createDataChannel('frames', dcInit); _dataChannel!.onMessage = (msg) { - _handleInputEvent(msg.text); - }; - - // add screen tracks - if (_localStream != null) { - for (final track in _localStream!.getTracks()) { - await _pc!.addTrack(track, _localStream!); + if (msg.isBinary) return; + try { + final parsed = jsonDecode(msg.text) as Map; + if (parsed['type'] == 'stats') { + setState(() { + _viewerStats = parsed; + }); + } else { + _handleInputEvent(msg.text); + } + } catch (_) { + _handleInputEvent(msg.text); } - } + }; _pc!.onIceCandidate = (candidate) { _sig.send({ 'type': 'signal', 'roomId': _roomId, - 'data': { - 'type': 'candidate', - 'candidate': candidate.toMap(), - }, + 'data': {'type': 'candidate', 'candidate': candidate.toMap()}, }); }; @@ -143,9 +155,11 @@ class _HostScreenState extends State { if (state == RTCPeerConnectionState.RTCPeerConnectionStateConnected) { _setState('Connected'); setState(() => _connected = true); + _startCapture(); } else if (state == RTCPeerConnectionState.RTCPeerConnectionStateFailed || state == RTCPeerConnectionState.RTCPeerConnectionStateDisconnected) { _setState('Connection lost'); + _stopCapture(); setState(() => _connected = false); } }; @@ -177,6 +191,58 @@ class _HostScreenState extends State { } } + Future _startCapture() async { + _statsTimer = Timer.periodic(const Duration(seconds: 1), (_) => _flushStats()); + + // subscribe BEFORE starting the encoder so the initial SPS+PPS+keyframe isn't dropped + _frameSub = _frameEvents.receiveBroadcastStream().listen((dynamic raw) { + final bytes = (raw as Uint8List); + final dc = _dataChannel; + if (dc == null) return; + + final buffered = dc.bufferedAmount ?? 0; + if (buffered > 262144) { + _droppedCount++; + return; + } + + try { + final sendStart = DateTime.now(); + dc.send(RTCDataChannelMessage.fromBinary(bytes)); + _sendMsSum += DateTime.now().difference(sendStart).inMicroseconds / 1000.0; + _sendMsCount++; + _frameCount++; + _byteCount += bytes.length; + } catch (_) {} + }); + + await _videoChannel.invokeMethod('start'); + await _videoChannel.invokeMethod('forceKeyframe'); + } + + void _stopCapture() { + _frameSub?.cancel(); + _frameSub = null; + _statsTimer?.cancel(); + _statsTimer = null; + _videoChannel.invokeMethod('stop'); + } + + void _flushStats() { + final buf = _dataChannel?.bufferedAmount ?? 0; + setState(() { + _statFps = _frameCount; + _statBitrateMbps = _byteCount * 8 / 1e6; + _statDropped = _droppedCount; + _statBufferKb = (buf / 1024).round(); + _statSendMs = _sendMsCount > 0 ? _sendMsSum / _sendMsCount : 0; + _frameCount = 0; + _byteCount = 0; + _droppedCount = 0; + _sendMsSum = 0; _sendMsCount = 0; + }); + } + void _handleInputEvent(String json) async { try { final ev = jsonDecode(json) as Map; @@ -190,10 +256,10 @@ class _HostScreenState extends State { @override void dispose() { + _stopCapture(); _sigSub?.cancel(); _sig.dispose(); _pc?.close(); - _localStream?.dispose(); super.dispose(); } @@ -204,37 +270,100 @@ class _HostScreenState extends State { title: const Text('Pulsar — Host'), backgroundColor: Theme.of(context).colorScheme.inversePrimary, ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Icon(Icons.monitor, size: 80, color: Colors.indigo), - const SizedBox(height: 24), - Text('Room Code', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 8), - SelectableText( - _roomId, - style: Theme.of(context).textTheme.displaySmall?.copyWith( - fontFamily: 'monospace', - fontWeight: FontWeight.bold, - letterSpacing: 8, - ), - ), - const SizedBox(height: 32), - Row( - mainAxisSize: MainAxisSize.min, + floatingActionButton: FloatingActionButton( + mini: true, + tooltip: 'Stats', + onPressed: () => setState(() => _showStats = !_showStats), + child: const Icon(Icons.bar_chart), + ), + body: Stack( + children: [ + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon( - _connected ? Icons.circle : Icons.circle_outlined, - color: _connected ? Colors.green : Colors.orange, - size: 14, + const Icon(Icons.monitor, size: 80, color: Colors.indigo), + const SizedBox(height: 24), + Text('Room Code', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + SelectableText( + _roomId, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.bold, + letterSpacing: 8, + ), + ), + const SizedBox(height: 32), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _connected ? Icons.circle : Icons.circle_outlined, + color: _connected ? Colors.green : Colors.orange, + size: 14, + ), + const SizedBox(width: 8), + Text(_status), + ], ), - const SizedBox(width: 8), - Text(_status), ], ), - ], - ), + ), + + if (_showStats) + Positioned( + top: 12, + left: 12, + child: _StatsOverlay(lines: [ + 'Host:', + ' FPS: $_statFps', + ' Bitrate: ${_statBitrateMbps.toStringAsFixed(2)} Mbps', + ' Dropped: $_statDropped/s', + ' Buffer: $_statBufferKb KB', + ' Send: ${_statSendMs.toStringAsFixed(1)}ms', + '', + if (_viewerStats == null) + 'Viewer: no data' + else ...[ + 'Viewer:', + ' FPS: ${_viewerStats!['fps']}', + ' Bitrate: ${(_viewerStats!['bitrateMbps'] as num).toStringAsFixed(2)} Mbps', + ' Dropped: ${_viewerStats!['dropped']}/s', + ' Res: ${_viewerStats!['resolution']}', + ' Latency: ${_viewerStats!['latencyMs']}ms', + ' Submit: ${_viewerStats!['decodeMs'] ?? '—'}ms', + ], + ]), + ), + ], + ), + ); + } +} + +class _StatsOverlay extends StatelessWidget { + final List lines; + const _StatsOverlay({required this.lines}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: lines.map((l) => Text( + l, + style: const TextStyle( + color: Colors.white, + fontFamily: 'monospace', + fontSize: 12, + ), + )).toList(), ), ); } diff --git a/lib/screens/viewer_screen.dart b/lib/screens/viewer_screen.dart index 0b6b897..a8fc8ac 100644 --- a/lib/screens/viewer_screen.dart +++ b/lib/screens/viewer_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -16,10 +17,11 @@ class ViewerScreen extends StatefulWidget { } class _ViewerScreenState extends State { + static const _videoCh = MethodChannel("com.pulsar/video"); + final _sig = SignallingService(); RTCPeerConnection? _pc; RTCDataChannel? _dataChannel; - final _remoteRenderer = RTCVideoRenderer(); final _roomCodeCtrl = TextEditingController(); String _status = 'Enter a room code to connect'; @@ -28,23 +30,46 @@ class _ViewerScreenState extends State { StreamSubscription? _sigSub; - // size of the rendered video widget, used to normalise pointer coords - Size _videoSize = Size.zero; - final _videoKey = GlobalKey(); + int? _textureId; + bool _decoderReady = false; + bool _settingUpDecoder = false; - @override - void initState() { - super.initState(); - _remoteRenderer.initialize(); - } + // frames that arrive while createDecoder is in-flight are queued here and + // drained once the decoder is ready, so we don't miss the initial keyframe + final _pendingNals = <(Uint8List, int)>[]; + + // widget key for normalising input coords + final _videoKey = GlobalKey(); + Size _videoSize = Size.zero; + + // stats + bool _showStats = false; + int _statFps = 0; + double _statBitrateMbps = 0; + int _statDropped = 0; + String _statResolution = '—'; + int _statLatencyMs = 0; + double _statDecodeMs = 0; // actually submit time for feedNal + + int _frameCount = 0; + int _byteCount = 0; + int _droppedCount = 0; + double _decodeMsSum = 0; + int _decodeMsCount = 0; + Timer? _statsTimer; @override void dispose() { + _statsTimer?.cancel(); _sigSub?.cancel(); _sig.dispose(); _pc?.close(); - _remoteRenderer.dispose(); _roomCodeCtrl.dispose(); + + if (_textureId != null) { + _videoCh.invokeMethod('stopDecoder', {'textureId': _textureId}); + } + super.dispose(); } @@ -92,6 +117,7 @@ class _ViewerScreenState extends State { break; case 'host_disconnected': + _statsTimer?.cancel(); setState(() { _status = 'Host disconnected'; _streaming = false; @@ -109,9 +135,9 @@ class _ViewerScreenState extends State { Future _initPeerConnection(String roomId) async { final config = { - 'iceServers': [ - {'urls': 'stun:stun.l.google.com:19302'}, - ], + 'iceServers': [{'urls': 'stun:stun.l.google.com:19302'}], + 'sdpSemantics': 'unified-plan', + 'encodedInsertableStreams': false, }; _pc = await createPeerConnection(config); @@ -120,18 +146,17 @@ class _ViewerScreenState extends State { _sig.send({ 'type': 'signal', 'roomId': roomId, - 'data': { - 'type': 'candidate', - 'candidate': candidate.toMap(), - }, + 'data': {'type': 'candidate', 'candidate': candidate.toMap()}, }); }; _pc!.onConnectionState = (state) { if (state == RTCPeerConnectionState.RTCPeerConnectionStateConnected) { setState(() => _status = 'Connected'); + _statsTimer = Timer.periodic(const Duration(seconds: 1), (_) => _flushStats()); } else if (state == RTCPeerConnectionState.RTCPeerConnectionStateFailed || state == RTCPeerConnectionState.RTCPeerConnectionStateDisconnected) { + _statsTimer?.cancel(); setState(() { _status = 'Connection lost'; _streaming = false; @@ -139,21 +164,133 @@ class _ViewerScreenState extends State { } }; - _pc!.onTrack = (event) { - if (event.track.kind == 'video') { - setState(() { - _remoteRenderer.srcObject = event.streams[0]; - _streaming = true; - _status = 'Streaming'; - }); - } - }; - _pc!.onDataChannel = (channel) { _dataChannel = channel; + + channel.onMessage = (msg) { + if (msg.isBinary) _onData(msg.binary); + }; }; } + void _flushStats() { + setState(() { + _statFps = _frameCount; + _statBitrateMbps = _byteCount * 8 / 1e6; + _statDropped = _droppedCount; + _statDecodeMs = _decodeMsCount > 0 ? _decodeMsSum / _decodeMsCount : 0; + _frameCount = 0; + _byteCount = 0; + _droppedCount = 0; + _decodeMsSum = 0; _decodeMsCount = 0; + }); + _sendInput({ + 'type': 'stats', + 'fps': _statFps, + 'bitrateMbps': _statBitrateMbps, + 'dropped': _statDropped, + 'resolution': _statResolution, + 'latencyMs': _statLatencyMs, + 'decodeMs': double.parse(_statDecodeMs.toStringAsFixed(1)), + 'paintMs': 0.0, + }); + } + + void _onData(Uint8List bytes) { + if (bytes.isEmpty) return; + + _byteCount += bytes.length; + + final type = bytes[0]; + + if (type == 0x01) { + // config packet: SPS+PPS — create/reconfigure the decoder + final spsPps = Uint8List.sublistView(bytes, 1); + _setupDecoder(spsPps); + + } else if (type == 0x02) { + // frame packet: [0x02][8-byte ts][Annex B NALU] + if (bytes.length < 10) return; + + final bd = ByteData.sublistView(bytes, 1, 9); + final ts = bd.getInt64(0, Endian.big); + final latency = DateTime.now().millisecondsSinceEpoch - ts; + + final nal = Uint8List.sublistView(bytes, 9); + + // drop frames that are already stale — prevents burst playback after a lag spike + if (latency > 300) return; + + if (!_decoderReady) { + _pendingNals.add((nal, latency)); + } else { + _feedNal(nal, latency); + } + } + } + + Future _setupDecoder(Uint8List spsPps) async { + // a second 0x01 packet can arrive while createDecoder is still awaiting; + // ignore it — the first decoder will be ready soon enough + if (_settingUpDecoder) return; + _settingUpDecoder = true; + + _decoderReady = false; + _pendingNals.clear(); + + if (_textureId != null) { + await _videoCh.invokeMethod('stopDecoder', {'textureId': _textureId}); + _textureId = null; + } + + try { + final res = await _videoCh.invokeMethod('createDecoder', {'spsPps': spsPps}); + final id = (res?['textureId'] as num?)?.toInt(); + + if (!mounted) return; + setState(() { + _textureId = id; + if (!_streaming) _streaming = true; + }); + + _decoderReady = true; + + // drain frames that queued up while createDecoder was in-flight + final pending = List.of(_pendingNals); + _pendingNals.clear(); + for (final (nal, latency) in pending) { + _feedNal(nal, latency); + } + } catch (_) { + _decoderReady = false; + } finally { + _settingUpDecoder = false; + } + } + + Future _feedNal(Uint8List nal, int latency) async { + if (_textureId == null) return; + + try { + final t0 = DateTime.now(); + await _videoCh.invokeMethod('feedNal', { + 'textureId': _textureId, + 'nal': nal, + }); + final elapsed = DateTime.now().difference(t0).inMicroseconds / 1000.0; + + if (!mounted) return; + + _decodeMsSum += elapsed; + _decodeMsCount++; + _frameCount++; + + setState(() { + _statLatencyMs = latency.clamp(0, 9999); + }); + } catch (_) {} + } + Future _handleSignalData(Map data, String roomId) async { final sigType = data['type'] as String?; @@ -169,7 +306,6 @@ class _ViewerScreenState extends State { 'roomId': roomId, 'data': {'type': 'answer', 'sdp': answer.sdp}, }); - } else if (sigType == 'candidate') { final raw = data['candidate'] as Map; final candidate = RTCIceCandidate( @@ -188,7 +324,6 @@ class _ViewerScreenState extends State { } catch (_) {} } - // get video widget size via the global key void _updateVideoSize() { final ctx = _videoKey.currentContext; if (ctx != null) { @@ -206,8 +341,6 @@ class _ViewerScreenState extends State { ); } - // ---- input capture - void _onPointerMove(PointerMoveEvent e) { final n = _normalise(e.localPosition); _sendInput({'type': 'move', 'x': n.dx, 'y': n.dy}); @@ -215,7 +348,6 @@ class _ViewerScreenState extends State { void _onPointerDown(PointerDownEvent e) { final n = _normalise(e.localPosition); - // button 0 = left, 1 = right (PointerDeviceKind bit not directly available) _sendInput({'type': 'click', 'x': n.dx, 'y': n.dy, 'button': 0}); } @@ -234,6 +366,14 @@ class _ViewerScreenState extends State { title: const Text('Pulsar — Viewer'), backgroundColor: Theme.of(context).colorScheme.inversePrimary, ), + floatingActionButton: _streaming + ? FloatingActionButton( + mini: true, + tooltip: 'Stats', + onPressed: () => setState(() => _showStats = !_showStats), + child: const Icon(Icons.bar_chart), + ) + : null, body: _streaming ? _buildStream() : _buildJoinView(), ); } @@ -296,20 +436,66 @@ class _ViewerScreenState extends State { } Widget _buildStream() { - return Focus( - autofocus: true, - onKeyEvent: (_, e) { - _onKeyEvent(e); - return KeyEventResult.handled; - }, - child: Listener( - onPointerMove: _onPointerMove, - onPointerDown: _onPointerDown, - child: RTCVideoView( - _remoteRenderer, - key: _videoKey, - objectFit: RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, + return Stack( + children: [ + Focus( + autofocus: true, + onKeyEvent: (_, e) { + _onKeyEvent(e); + return KeyEventResult.handled; + }, + child: Listener( + onPointerMove: _onPointerMove, + onPointerDown: _onPointerDown, + child: _textureId != null + ? SizedBox.expand( + key: _videoKey, + child: Texture(textureId: _textureId!), + ) + : const Center(child: CircularProgressIndicator()), + ), ), + + if (_showStats) + Positioned( + top: 12, + left: 12, + child: _StatsOverlay(lines: [ + 'FPS: $_statFps', + 'Bitrate: ${_statBitrateMbps.toStringAsFixed(2)} Mbps', + 'Dropped: $_statDropped/s', + 'Res: $_statResolution', + 'Latency: ${_statLatencyMs}ms', + 'Submit: ${_statDecodeMs.toStringAsFixed(1)}ms', + ]), + ), + ], + ); + } +} + +class _StatsOverlay extends StatelessWidget { + final List lines; + const _StatsOverlay({required this.lines}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(6), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: lines.map((l) => Text( + l, + style: const TextStyle( + color: Colors.white, + fontFamily: 'monospace', + fontSize: 12, + ), + )).toList(), ), ); } diff --git a/lib/services/signalling.dart b/lib/services/signalling.dart index a7fa9fc..9262a61 100644 --- a/lib/services/signalling.dart +++ b/lib/services/signalling.dart @@ -20,10 +20,10 @@ class SignallingService { } catch (_) {} }, onError: (err) { - _controller.addError(err); + if (!_controller.isClosed) _controller.addError(err); }, onDone: () { - _controller.addError(Exception('WebSocket connection closed')); + if (!_controller.isClosed) _controller.addError(Exception('WebSocket connection closed')); }, ); } diff --git a/macos/Podfile.lock b/macos/Podfile.lock index 8b8390f..c8fee9a 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,9 +1,9 @@ PODS: - - flutter_webrtc (0.9.36): + - flutter_webrtc (1.4.0): - FlutterMacOS - - WebRTC-SDK (= 114.5735.08) + - WebRTC-SDK (= 144.7559.01) - FlutterMacOS (1.0.0) - - WebRTC-SDK (114.5735.08) + - WebRTC-SDK (144.7559.01) DEPENDENCIES: - flutter_webrtc (from `Flutter/ephemeral/.symlinks/plugins/flutter_webrtc/macos`) @@ -20,9 +20,9 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral SPEC CHECKSUMS: - flutter_webrtc: cf7dc44d26cbb5c5f1ae5f583dab545871f287f9 + flutter_webrtc: dbacf72a9bd951ccfa5997198f0a6214bb848ede FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 - WebRTC-SDK: c24d2a6c9f571f2ed42297cb8ffba9557093142b + WebRTC-SDK: ab9b5319e458c2bfebdc92b3600740da35d5630d PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009 diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift index ea52703..d4f860e 100644 --- a/macos/Runner/AppDelegate.swift +++ b/macos/Runner/AppDelegate.swift @@ -1,20 +1,37 @@ import Cocoa import FlutterMacOS import CoreGraphics +import ScreenCaptureKit +import CoreMedia +import CoreVideo +import VideoToolbox @main class AppDelegate: FlutterAppDelegate { private var inputChannel: FlutterMethodChannel? + private var videoChannel: FlutterMethodChannel? + private var frameEventSink: FlutterEventSink? + + // SCK objects (typed Any? to satisfy 10.15 deployment target) + private var scStream: Any? + private var screenDelegate: Any? + private var streamReady = false + + // VTCompressionSession (Any? for same reason) + private var vtSession: Any? + private var encoderReady = false + + // resolution last seen from SCK (to build config) + private var captureWidth = 1280 + private var captureHeight = 720 override func applicationDidFinishLaunching(_ notification: Notification) { guard let window = mainFlutterWindow, let ctrl = window.contentViewController as? FlutterViewController else { return } - inputChannel = FlutterMethodChannel( - name: "com.pulsar/input", - binaryMessenger: ctrl.engine.binaryMessenger - ) + let messenger = ctrl.engine.binaryMessenger + inputChannel = FlutterMethodChannel(name: "com.pulsar/input", binaryMessenger: messenger) inputChannel?.setMethodCallHandler { [weak self] call, result in if call.method == "injectInput", let args = call.arguments as? [String: Any] { @@ -24,8 +41,302 @@ class AppDelegate: FlutterAppDelegate { result(FlutterMethodNotImplemented) } } + + // event channel for pushing encoded NAL units to Flutter + let frameChannel = FlutterEventChannel(name: "com.pulsar/video/frames", binaryMessenger: messenger) + frameChannel.setStreamHandler(self) + + videoChannel = FlutterMethodChannel(name: "com.pulsar/video", binaryMessenger: messenger) + videoChannel?.setMethodCallHandler { [weak self] call, result in + guard let self = self else { return } + switch call.method { + case "start": + self.startEncoder(result: result) + case "stop": + self.stopEncoder() + result(nil) + case "forceKeyframe": + self.requestKeyframe() + result(nil) + default: + result(FlutterMethodNotImplemented) + } + } + + if #available(macOS 12.3, *) { + Task { await self.ensureSCKStream() } + } } + // MARK: - SCK stream setup + + @available(macOS 12.3, *) + private func ensureSCKStream() async { + guard scStream == nil else { return } + + guard let content = try? await SCShareableContent.current else { return } + guard let display = content.displays.first(where: { $0.displayID == CGMainDisplayID() }) else { return } + + let filter = SCContentFilter(display: display, excludingWindows: []) + + let srcW = Double(display.width) + let srcH = Double(display.height) + let scale = min(1.0, 1280.0 / srcW) + let dstW = Int(srcW * scale) + let dstH = Int(srcH * scale) + + captureWidth = dstW + captureHeight = dstH + + let cfg = SCStreamConfiguration() + cfg.width = dstW + cfg.height = dstH + cfg.pixelFormat = kCVPixelFormatType_32BGRA + cfg.showsCursor = true + cfg.minimumFrameInterval = CMTime(value: 1, timescale: 60) + + let del = ScreenDelegate { [weak self] sampleBuffer in + self?.encodeFrame(sampleBuffer) + } + screenDelegate = del + + let stream = SCStream(filter: filter, configuration: cfg, delegate: nil) + try? stream.addStreamOutput(del, type: .screen, sampleHandlerQueue: .global(qos: .userInteractive)) + + do { + try await stream.startCapture() + scStream = stream + streamReady = true + } catch {} + } + + // MARK: - VTCompressionSession + + private func startEncoder(result: @escaping FlutterResult) { + let w = captureWidth + let h = captureHeight + + var session: VTCompressionSession? + let status = VTCompressionSessionCreate( + allocator: kCFAllocatorDefault, + width: Int32(w), + height: Int32(h), + codecType: kCMVideoCodecType_H264, + encoderSpecification: nil, + imageBufferAttributes: nil, + compressedDataAllocator: nil, + outputCallback: vtOutputCallback, + refcon: Unmanaged.passUnretained(self).toOpaque(), + compressionSessionOut: &session + ) + + guard status == noErr, let session = session else { + result(FlutterError(code: "VT_CREATE_FAILED", message: "VTCompressionSessionCreate status \(status)", details: nil)) + return + } + + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ProfileLevel, + value: kVTProfileLevel_H264_Baseline_AutoLevel) + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_RealTime, value: kCFBooleanTrue) + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AllowFrameReordering, value: kCFBooleanFalse) + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_AverageBitRate, + value: 6_000_000 as CFNumber) + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_ExpectedFrameRate, + value: 60 as CFNumber) + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameInterval, + value: 120 as CFNumber) + VTSessionSetProperty(session, key: kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, + value: 0 as CFNumber) + + VTCompressionSessionPrepareToEncodeFrames(session) + vtSession = VTSessionBox(session) + encoderReady = true + + // extract SPS+PPS from a forced keyframe by encoding a dummy frame + // instead, return empty bytes and let the real SPS+PPS come through the callback + // host_screen will call forceKeyframe right after start + result(FlutterStandardTypedData(bytes: Data())) + } + + private func stopEncoder() { + guard let box = vtSession as? VTSessionBox else { return } + VTCompressionSessionInvalidate(box.session) + vtSession = nil + encoderReady = false + } + + func requestKeyframe() { + forceKeyframeNext = true + } + + private var forceKeyframeNext = false + + private func encodeFrame(_ sampleBuffer: CMSampleBuffer) { + guard encoderReady, let box = vtSession as? VTSessionBox else { return } + let s = box.session + guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return } + + var frameProps: CFDictionary? = nil + if forceKeyframeNext { + frameProps = [kVTEncodeFrameOptionKey_ForceKeyFrame as String: true] as CFDictionary + forceKeyframeNext = false + } + + let pts = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + VTCompressionSessionEncodeFrame(s, imageBuffer: pixelBuffer, presentationTimeStamp: pts, + duration: .invalid, frameProperties: frameProps, + sourceFrameRefcon: nil, infoFlagsOut: nil) + } + + // MARK: - VT output callback (C function) + + // called by VideoToolbox on an internal thread — collect Data packets here, + // then hop to main before touching the event sink + func handleEncodedFrame(_ sampleBuffer: CMSampleBuffer) { + var packets: [Data] = [] + + let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, createIfNecessary: false) as? [[CFString: Any]] + let isKeyframe = !(attachments?.first?[kCMSampleAttachmentKey_NotSync] as? Bool ?? false) + + if isKeyframe, let fmt = CMSampleBufferGetFormatDescription(sampleBuffer) { + if let cfg = buildParameterSetsPacket(fmt) { packets.append(cfg) } + } + + if let dataBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) { + var totalLen = 0 + var rawPtr: UnsafeMutablePointer? = nil + if CMBlockBufferGetDataPointer(dataBuffer, atOffset: 0, lengthAtOffsetOut: nil, + totalLengthOut: &totalLen, dataPointerOut: &rawPtr) == noErr, + let ptr = rawPtr { + + let ts = Int64(Date().timeIntervalSince1970 * 1000) + var offset = 0 + + while offset + 4 <= totalLen { + // read AVCC 4-byte big-endian length byte-by-byte to avoid alignment issues + let b0 = UInt32(UInt8(bitPattern: ptr[offset])) + let b1 = UInt32(UInt8(bitPattern: ptr[offset + 1])) + let b2 = UInt32(UInt8(bitPattern: ptr[offset + 2])) + let b3 = UInt32(UInt8(bitPattern: ptr[offset + 3])) + let naluLen = Int((b0 << 24) | (b1 << 16) | (b2 << 8) | b3) + offset += 4 + + guard naluLen > 0, offset + naluLen <= totalLen else { break } + + var packet = Data(capacity: 1 + 8 + 4 + naluLen) + packet.append(0x02) + withUnsafeBytes(of: ts.bigEndian) { packet.append(contentsOf: $0) } + packet.append(contentsOf: [0x00, 0x00, 0x00, 0x01]) + packet.append(Data(bytes: ptr.advanced(by: offset), count: naluLen)) + packets.append(packet) + + offset += naluLen + } + } + } + + guard !packets.isEmpty else { return } + DispatchQueue.main.async { [weak self] in + guard let sink = self?.frameEventSink else { return } + for packet in packets { + sink(FlutterStandardTypedData(bytes: packet)) + } + } + } + + private func buildParameterSetsPacket(_ fmt: CMFormatDescription) -> Data? { + var spsCount = 0 + CMVideoFormatDescriptionGetH264ParameterSetAtIndex(fmt, parameterSetIndex: 0, parameterSetPointerOut: nil, + parameterSetSizeOut: nil, parameterSetCountOut: &spsCount, + nalUnitHeaderLengthOut: nil) + var packet = Data() + packet.append(0x01) + + for i in 0 ..< spsCount { + var ptr: UnsafePointer? = nil + var len = 0 + CMVideoFormatDescriptionGetH264ParameterSetAtIndex(fmt, parameterSetIndex: i, + parameterSetPointerOut: &ptr, + parameterSetSizeOut: &len, + parameterSetCountOut: nil, + nalUnitHeaderLengthOut: nil) + if let ptr = ptr { + packet.append(contentsOf: [0x00, 0x00, 0x00, 0x01]) + packet.append(Data(bytes: ptr, count: len)) + } + } + + // PPS sits at index spsCount + var ppsPtr: UnsafePointer? = nil + var ppsLen = 0 + if CMVideoFormatDescriptionGetH264ParameterSetAtIndex(fmt, parameterSetIndex: spsCount, + parameterSetPointerOut: &ppsPtr, + parameterSetSizeOut: &ppsLen, + parameterSetCountOut: nil, + nalUnitHeaderLengthOut: nil) == noErr, + let ppsPtr = ppsPtr { + packet.append(contentsOf: [0x00, 0x00, 0x00, 0x01]) + packet.append(Data(bytes: ppsPtr, count: ppsLen)) + } + + return packet.count > 1 ? packet : nil + } + + // MARK: - CGWindowListCreateImage fallback (kept for non-SCK paths — unused in normal operation) + + private func captureCG() -> Data? { + let displayID = CGMainDisplayID() + guard let cgImage = CGDisplayCreateImage(displayID) else { return nil } + let composited = compositeCursor(onto: cgImage, displayID: displayID) ?? cgImage + let srcW = CGFloat(composited.width) + let scale = min(1.0, 1280.0 / srcW) + let dstW = Int(srcW * scale) + let dstH = Int(CGFloat(composited.height) * scale) + let colorSpace = composited.colorSpace ?? CGColorSpaceCreateDeviceRGB() + guard let ctx = CGContext(data: nil, width: dstW, height: dstH, bitsPerComponent: 8, + bytesPerRow: 0, space: colorSpace, + bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else { return nil } + ctx.interpolationQuality = .high + ctx.draw(composited, in: CGRect(x: 0, y: 0, width: dstW, height: dstH)) + guard let scaled = ctx.makeImage() else { return nil } + return jpegData(from: scaled, quality: 0.4) + } + + private func jpegData(from cgImage: CGImage, quality: Double) -> Data? { + let nsImage = NSImage(cgImage: cgImage, size: .zero) + guard let tiff = nsImage.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff) else { return nil } + return bitmap.representation(using: .jpeg, properties: [.compressionFactor: quality]) + } + + private func compositeCursor(onto cgImage: CGImage, displayID: CGDirectDisplayID) -> CGImage? { + let imgW = cgImage.width + let imgH = cgImage.height + let mousePt = NSEvent.mouseLocation + let scale = CGFloat(imgW) / CGFloat(CGDisplayPixelsWide(displayID)) + let screenH = CGFloat(CGDisplayPixelsHigh(displayID)) / scale + let cursorX = mousePt.x * scale + let cursorY = (screenH - mousePt.y) * scale + let cursor = NSCursor.current + let cursorImg = cursor.image + let hotspot = cursor.hotSpot + let cw = cursorImg.size.width * scale + let ch = cursorImg.size.height * scale + let drawX = cursorX - hotspot.x * scale + let drawY = cursorY - hotspot.y * scale + let colorSpace = cgImage.colorSpace ?? CGColorSpaceCreateDeviceRGB() + guard let ctx = CGContext(data: nil, width: imgW, height: imgH, bitsPerComponent: 8, + bytesPerRow: 0, space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue) else { return nil } + ctx.draw(cgImage, in: CGRect(x: 0, y: 0, width: imgW, height: imgH)) + var cursorCGRect = NSRect(x: 0, y: 0, width: cursorImg.size.width, height: cursorImg.size.height) + if let cursorCG = cursorImg.cgImage(forProposedRect: &cursorCGRect, context: nil, hints: nil) { + ctx.draw(cursorCG, in: CGRect(x: drawX, y: CGFloat(imgH) - drawY - ch, width: cw, height: ch)) + } + return ctx.makeImage() + } + + // MARK: - Input injection + private func injectInput(_ ev: [String: Any]) { guard let type = ev["type"] as? String else { return } let screen = NSScreen.main?.frame ?? CGRect(x: 0, y: 0, width: 1920, height: 1080) @@ -34,24 +345,17 @@ class AppDelegate: FlutterAppDelegate { case "move": let nx = ev["x"] as? Double ?? 0 let ny = ev["y"] as? Double ?? 0 - // CoreGraphics origin is top-left on screen, NSScreen origin is bottom-left - let px = CGFloat(nx) * screen.width - let py = CGFloat(ny) * screen.height - let pt = CGPoint(x: px, y: py) + let pt = CGPoint(x: CGFloat(nx) * screen.width, y: CGFloat(ny) * screen.height) CGEvent(mouseEventSource: nil, mouseType: .mouseMoved, mouseCursorPosition: pt, mouseButton: .left)? .post(tap: .cghidEventTap) case "click": - let nx = ev["x"] as? Double ?? 0 - let ny = ev["y"] as? Double ?? 0 + let nx = ev["x"] as? Double ?? 0 + let ny = ev["y"] as? Double ?? 0 let btn = ev["button"] as? Int ?? 0 - let px = CGFloat(nx) * screen.width - let py = CGFloat(ny) * screen.height - let pt = CGPoint(x: px, y: py) - + let pt = CGPoint(x: CGFloat(nx) * screen.width, y: CGFloat(ny) * screen.height) let (downType, upType, mouseBtn): (CGEventType, CGEventType, CGMouseButton) = btn == 1 ? (.rightMouseDown, .rightMouseUp, .right) : (.leftMouseDown, .leftMouseUp, .left) - CGEvent(mouseEventSource: nil, mouseType: downType, mouseCursorPosition: pt, mouseButton: mouseBtn)? .post(tap: .cghidEventTap) CGEvent(mouseEventSource: nil, mouseType: upType, mouseCursorPosition: pt, mouseButton: mouseBtn)? @@ -69,16 +373,71 @@ class AppDelegate: FlutterAppDelegate { .post(tap: .cghidEventTap) } - default: - break + default: break } } - override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { - return true + // MARK: - App lifecycle + + override func applicationWillTerminate(_ notification: Notification) { + stopEncoder() + if #available(macOS 12.3, *), let stream = scStream as? SCStream { + Task { try? await stream.stopCapture() } + } } - override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { - return true + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { true } +} + +// MARK: - VTCompressionSession wrapper (CF types can't be conditionally cast from Any?) + +private class VTSessionBox { + let session: VTCompressionSession + init(_ s: VTCompressionSession) { session = s } +} + +// MARK: - VT output C callback + +private func vtOutputCallback( + outputCallbackRefCon: UnsafeMutableRawPointer?, + sourceFrameRefCon: UnsafeMutableRawPointer?, + status: OSStatus, + infoFlags: VTEncodeInfoFlags, + sampleBuffer: CMSampleBuffer? +) { + guard status == noErr, let sampleBuffer = sampleBuffer, + let refcon = outputCallbackRefCon else { return } + let delegate = Unmanaged.fromOpaque(refcon).takeUnretainedValue() + delegate.handleEncodedFrame(sampleBuffer) +} + +// MARK: - SCK delegate + +@available(macOS 12.3, *) +class ScreenDelegate: NSObject, SCStreamOutput { + private let onFrame: (CMSampleBuffer) -> Void + + init(onFrame: @escaping (CMSampleBuffer) -> Void) { + self.onFrame = onFrame + } + + func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) { + guard type == .screen else { return } + onFrame(sampleBuffer) + } +} + +// MARK: - FlutterStreamHandler (AppDelegate handles frame events directly) + +extension AppDelegate: FlutterStreamHandler { + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + frameEventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + frameEventSink = nil + return nil } } diff --git a/pubspec.lock b/pubspec.lock index 1524de5..6d9384d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -77,10 +77,10 @@ packages: dependency: transitive description: name: dart_webrtc - sha256: ac7ef077084b3e54004716f1d736fcd839e1b60bc3f21f4122a35a9bb5ca2e47 + sha256: f6d615bddea5e458ce180a914f3055c234ffb52fb7397a51b3491e76d6d7edb2 url: "https://pub.dev" source: hosted - version: "1.4.8" + version: "1.8.1" fake_async: dependency: transitive description: @@ -127,10 +127,10 @@ packages: dependency: "direct main" description: name: flutter_webrtc - sha256: "2f17fb96e0c9c6ff75f6b1c36d94755461fc7f36a5c28386f5ee5a18b98688c8" + sha256: c7b0a67ca2c878575fc5c146d801cd874f58f5f1ef5fa6e8eb0c93d413beb948 url: "https://pub.dev" source: hosted - version: "0.9.48+hotfix.1" + version: "1.4.1" glob: dependency: transitive description: @@ -203,6 +203,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logger: + dependency: transitive + description: + name: logger + sha256: "25aee487596a6257655a1e091ec2ae66bc30e7af663592cc3a27e6591e05035c" + url: "https://pub.dev" + source: hosted + version: "2.7.0" logging: dependency: transitive description: @@ -323,14 +331,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.6" - platform_detect: - dependency: transitive - description: - name: platform_detect - sha256: "6e91e2158b9db7f6f4581a0f0b3a77171570b392108de196ba0c52e3c1976334" - url: "https://pub.dev" - source: hosted - version: "2.1.6" plugin_platform_interface: dependency: transitive description: @@ -444,18 +444,18 @@ packages: dependency: transitive description: name: web - sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" url: "https://pub.dev" source: hosted - version: "0.5.1" + version: "1.1.1" web_socket_channel: dependency: "direct main" description: name: web_socket_channel - sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + sha256: d88238e5eac9a42bb43ca4e721edba3c08c6354d4a53063afaa568516217621b url: "https://pub.dev" source: hosted - version: "2.4.5" + version: "2.4.0" webrtc_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 890de9c..8a311e0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -34,7 +34,7 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 - flutter_webrtc: ^0.9.47 + flutter_webrtc: ^1.4.1 web_socket_channel: ^2.4.0 dev_dependencies: