From 0f906c8104ee665e313be510d4f537b5b8bc062a Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Tue, 27 Oct 2026 11:26:06 +0100 Subject: [PATCH 1/2] Add a camera switch to the example. The same published track can use the front or back camera, and the local tile stays mirrored only for the front camera. Co-authored-by: Cursor --- example/lib/room_screen.dart | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/example/lib/room_screen.dart b/example/lib/room_screen.dart index cc547e9..cd1afe2 100644 --- a/example/lib/room_screen.dart +++ b/example/lib/room_screen.dart @@ -8,7 +8,7 @@ import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'sandbox.dart'; /// A minimal room: publishes camera and microphone, renders remote video, -/// mute toggles and leave. +/// mute toggles, camera switch, picture-in-picture and leave. class RoomScreen extends StatefulWidget { const RoomScreen({ super.key, @@ -59,6 +59,7 @@ class _RoomScreenState extends State { String? _microphoneTrackId; bool _cameraOn = true; bool _microphoneOn = true; + bool _frontCamera = true; String _status = 'Connecting…'; final _audioOutput = AudioOutputManager.instance; final _callKit = CallKit.instance; @@ -325,6 +326,19 @@ class _RoomScreenState extends State { ); } + /// Same track, so the published camera keeps its id. `true` means the + /// front camera is now active. + Future _switchCamera() async { + final camera = _camera; + if (camera == null) return; + try { + final front = await Helper.switchCamera(camera); + if (mounted) setState(() => _frontCamera = front); + } on Object catch (e) { + _setStatus('Error: $e'); + } + } + Future _toggleMicrophone() async { final on = !_microphoneOn; setState(() => _microphoneOn = on); @@ -394,7 +408,7 @@ class _RoomScreenState extends State { _VideoTile( stream: _localStream, label: '${widget.displayName} (you)', - mirror: true, + mirror: _frontCamera, pip: _localPip, pipOptions: _noPip, ), @@ -443,6 +457,11 @@ class _RoomScreenState extends State { onPressed: _toggleCamera, icon: Icon(_cameraOn ? Icons.videocam : Icons.videocam_off), ), + IconButton.filledTonal( + tooltip: 'Switch camera', + onPressed: _camera == null ? null : _switchCamera, + icon: const Icon(Icons.cameraswitch), + ), IconButton.filledTonal( tooltip: 'Picture in picture', onPressed: From 4d79956145d5da58f8d4918c4ce8bdce7a83dadc Mon Sep 17 00:00:00 2001 From: Angelika Serwa Date: Tue, 27 Oct 2026 19:10:31 +0100 Subject: [PATCH 2/2] Fix the mirror flicker when switching cameras. RTCView, cameraDevices, and selectCamera follow the React Native SDK, so the example mirrors and switches through the SDK instead of flutter_webrtc. On Android the switch pauses drawing, changes the camera, then sets the mirror before drawing resumes. Co-authored-by: Cursor --- AGENTS.md | 1 + SPEC.md | 13 +- .../io/fishjam/client/FishjamClientPlugin.kt | 43 +++ .../kotlin/io/fishjam/client/VideoMirror.kt | 50 +++ example/integration_test/room_test.dart | 1 - example/lib/room_screen.dart | 85 +++--- lib/fishjam_client.dart | 5 + lib/src/video/rtc_view.dart | 286 ++++++++++++++++++ test/import_boundary_test.dart | 1 + test/video/rtc_view_test.dart | 245 +++++++++++++++ 10 files changed, 692 insertions(+), 38 deletions(-) create mode 100644 android/src/main/kotlin/io/fishjam/client/VideoMirror.kt create mode 100644 lib/src/video/rtc_view.dart create mode 100644 test/video/rtc_view_test.dart diff --git a/AGENTS.md b/AGENTS.md index 19b8b1f..d14b7b1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -65,6 +65,7 @@ Run analyze, format and tests before every commit. | `lib/src/stats/` | periodic stats report | | `lib/src/livestream/` | WHIP/WHEP over plain HTTP, independent of the room connection | | `lib/src/audio_output/`, `lib/src/background/` | Dart side of the SDK's native plugin: audio routing, Android foreground service, iOS CallKit | +| `lib/src/video/` | `RTCView`, `cameraDevices`, and `selectCamera`. May import `flutter_webrtc` (`test/import_boundary_test.dart`) | | `lib/src/models/`, `wire.dart`, `logger.dart`, `version.dart` | public immutable types and shared helpers | | `lib/src/fishjam_client.dart`, `lib/src/livestream_api.dart`, `lib/fishjam_client.dart` | the public facade; wiring and type conversion only | | `lib/src/connectivity_network_source.dart` | the `NetworkSource` (`signaling/network_source.dart`) on `connectivity_plus`; the only file that imports it | diff --git a/SPEC.md b/SPEC.md index 9027552..d440e59 100644 --- a/SPEC.md +++ b/SPEC.md @@ -743,7 +743,6 @@ when it joins and stops them when it leaves. - The app adds `audio` and `voip` to `UIBackgroundModes`. Out of scope: VoIP push, incoming calls, answering, ring timeouts. - ## 20d. Picture-in-picture `PictureInPictureView` renders a `MediaStream` and offers its first video track for picture-in-picture (PiP), @@ -817,6 +816,18 @@ native plugin over the method channel `io.fishjam.client/pip` and the event chan - When there is no video track, the app and the window both draw `fallbackLabel` on `fallbackBackgroundColor` in `fallbackTextColor` (D24). +## 20e. Camera preview + +`RTCView` renders a `MediaStream`. `mirror` (default `false`) mirrors that picture. `objectFit` is `contain` (default) or `cover`. + +`cameraDevices()` lists cameras as `{deviceId, label}`, the same entries as the React Native SDK's `useCamera().cameraDevices`. + +`selectCamera(deviceId, {current})` selects that camera and returns the video stream to show. [current] is the preview already showing. The same `deviceId` returns [current]. When [current] already has a video track, that track is switched in place and [current] is returned. Opening a second camera and stopping the first makes `replaceTrack` fail on Android and iOS. With no video track, `selectCamera` opens [deviceId]. An empty `deviceId` throws `ArgumentError`. This is the React Native SDK's `selectCamera(deviceId)`. + +On Android, `mirror` is set on the texture renderer and takes effect on the next drawn frame. `selectCamera` pauses that drawing, switches the camera, then resumes after `RTCView` applies its current `mirror`. The frame already on screen is not flipped. Calls are `setMirror` `{textureId, mirror}` and `setPaused` `{textureId, paused}` on `io.fishjam.client/video_mirror`. + +On iOS, `mirror` is a view transform. It updates when the widget rebuilds. + ## 21. Logging One SDK logger, created from `FishjamClient(debug: bool)`. Messages are prefixed with `[FISHJAM]` and emitted diff --git a/android/src/main/kotlin/io/fishjam/client/FishjamClientPlugin.kt b/android/src/main/kotlin/io/fishjam/client/FishjamClientPlugin.kt index 03029f9..c0b8ea3 100644 --- a/android/src/main/kotlin/io/fishjam/client/FishjamClientPlugin.kt +++ b/android/src/main/kotlin/io/fishjam/client/FishjamClientPlugin.kt @@ -20,6 +20,7 @@ class FishjamClientPlugin : FlutterPlugin, ActivityAware { private lateinit var audioOutput: AudioOutputManager private var audioEventSink: EventChannel.EventSink? = null private lateinit var backgroundChannel: MethodChannel + private lateinit var videoChannel: MethodChannel private val notificationPermission = NotificationPermission() private var activityBinding: ActivityPluginBinding? = null private lateinit var pipChannel: MethodChannel @@ -62,6 +63,8 @@ class FishjamClientPlugin : FlutterPlugin, ActivityAware { pipEventSink = null } }) + videoChannel = MethodChannel(binding.binaryMessenger, VIDEO_CHANNEL) + videoChannel.setMethodCallHandler(::onVideoMethodCall) } private fun onAudioMethodCall(call: MethodCall, result: Result) { @@ -98,6 +101,44 @@ class FishjamClientPlugin : FlutterPlugin, ActivityAware { } } + private fun onVideoMethodCall(call: MethodCall, result: Result) { + when (call.method) { + "setMirror" -> { + val args = call.arguments as? Map<*, *> + val textureId = (args?.get("textureId") as? Number)?.toLong() + val mirror = args?.get("mirror") as? Boolean + if (textureId == null || mirror == null) { + result.error("invalid", "textureId and mirror are required", null) + return + } + try { + VideoMirror.setMirror(textureId, mirror) + result.success(null) + } catch (e: Exception) { + Log.w(TAG, "Could not set video mirror", e) + result.error("mirror", e.message, null) + } + } + "setPaused" -> { + val args = call.arguments as? Map<*, *> + val textureId = (args?.get("textureId") as? Number)?.toLong() + val paused = args?.get("paused") as? Boolean + if (textureId == null || paused == null) { + result.error("invalid", "textureId and paused are required", null) + return + } + try { + VideoMirror.setPaused(textureId, paused) + result.success(null) + } catch (e: Exception) { + Log.w(TAG, "Could not pause video", e) + result.error("paused", e.message, null) + } + } + else -> result.notImplemented() + } + } + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { audioChannel.setMethodCallHandler(null) audioEvents.setStreamHandler(null) @@ -107,6 +148,7 @@ class FishjamClientPlugin : FlutterPlugin, ActivityAware { pipChannel.setMethodCallHandler(null) pipEvents.setStreamHandler(null) pipEventSink = null + videoChannel.setMethodCallHandler(null) } override fun onAttachedToActivity(binding: ActivityPluginBinding) { @@ -160,5 +202,6 @@ class FishjamClientPlugin : FlutterPlugin, ActivityAware { private const val BACKGROUND_CHANNEL = "io.fishjam.client/background" private const val PIP_CHANNEL = "io.fishjam.client/pip" private const val PIP_EVENTS_CHANNEL = "io.fishjam.client/pip_events" + private const val VIDEO_CHANNEL = "io.fishjam.client/video_mirror" } } diff --git a/android/src/main/kotlin/io/fishjam/client/VideoMirror.kt b/android/src/main/kotlin/io/fishjam/client/VideoMirror.kt new file mode 100644 index 0000000..ad214d0 --- /dev/null +++ b/android/src/main/kotlin/io/fishjam/client/VideoMirror.kt @@ -0,0 +1,50 @@ +package io.fishjam.client + +import android.util.LongSparseArray + +/** + * Mirror and pause for flutter_webrtc's texture renderer, looked up by + * reflection so this plugin does not depend on flutter_webrtc. + * `com.cloudwebrtc.webrtc.**` is kept by that package, so these names + * survive shrinking. + * + * [setPaused] `true` calls `pauseVideo` (the last frame stays). `false` + * calls `disableFpsReduction`. The mirror is set while drawing is paused. + */ +internal object VideoMirror { + fun setMirror(textureId: Long, mirror: Boolean) { + invoke(textureId, "setMirror", mirror) + } + + fun setPaused(textureId: Long, paused: Boolean) { + invoke(textureId, if (paused) "pauseVideo" else "disableFpsReduction") + } + + private fun invoke(textureId: Long, methodName: String, argument: Boolean? = null) { + val renderer = renderer(textureId) + val method = renderer.javaClass.methods.firstOrNull { method -> + method.name == methodName && + method.parameterTypes.size == if (argument == null) 0 else 1 + } ?: error("video renderer has no $methodName") + if (argument == null) method.invoke(renderer) else method.invoke(renderer, argument) + } + + private fun renderer(textureId: Long): Any { + val pluginClass = Class.forName("com.cloudwebrtc.webrtc.FlutterWebRTCPlugin") + val plugin = pluginClass.getField("sharedSingleton").get(null) + ?: error("FlutterWebRTCPlugin.sharedSingleton is null") + val handler = readField(plugin, "methodCallHandler") + ?: error("flutter_webrtc has no method handler") + val renders = readField(handler, "renders") as LongSparseArray<*> + val render = renders.get(textureId) + ?: error("no video renderer for texture $textureId") + return readField(render, "surfaceTextureRenderer") + ?: error("video renderer has no surface") + } + + private fun readField(target: Any, name: String): Any? { + val field = target.javaClass.getDeclaredField(name) + field.isAccessible = true + return field.get(target) + } +} diff --git a/example/integration_test/room_test.dart b/example/integration_test/room_test.dart index e752018..66ef713 100644 --- a/example/integration_test/room_test.dart +++ b/example/integration_test/room_test.dart @@ -5,7 +5,6 @@ import 'dart:typed_data'; import 'package:fishjam_client/fishjam_client.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'package:http/http.dart' as http; import 'package:integration_test/integration_test.dart'; diff --git a/example/lib/room_screen.dart b/example/lib/room_screen.dart index cd1afe2..ab3f81e 100644 --- a/example/lib/room_screen.dart +++ b/example/lib/room_screen.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:fishjam_client/fishjam_client.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_webrtc/flutter_webrtc.dart'; import 'sandbox.dart'; @@ -53,6 +52,7 @@ class _RoomScreenState extends State { final _subscriptions = >[]; MediaStream? _localStream; + MediaStream? _cameraStream; MediaStreamTrack? _camera; MediaStreamTrack? _microphone; String? _cameraTrackId; @@ -196,6 +196,7 @@ class _RoomScreenState extends State { 'video': {'facingMode': 'user'}, }); _localStream = stream; + _cameraStream = stream; _camera = stream.getVideoTracks().firstOrNull; _microphone = stream.getAudioTracks().firstOrNull; if (mounted) setState(() {}); @@ -326,14 +327,30 @@ class _RoomScreenState extends State { ); } - /// Same track, so the published camera keeps its id. `true` means the - /// front camera is now active. Future _switchCamera() async { final camera = _camera; if (camera == null) return; try { - final front = await Helper.switchCamera(camera); - if (mounted) setState(() => _frontCamera = front); + final cameras = await cameraDevices(); + final currentId = camera.getSettings()['deviceId']; + final next = cameras + .where((device) => device.deviceId != currentId) + .firstOrNull; + if (next == null) return; + final stream = await selectCamera(next.deviceId, current: _cameraStream); + final track = stream.getVideoTracks().firstOrNull; + if (track != null && _cameraTrackId != null && track.id != camera.id) { + await _client.replaceTrack(_cameraTrackId!, track); + } + final label = next.label.toLowerCase(); + final front = label.contains('front'); + final back = label.contains('back'); + if (!mounted) return; + setState(() { + _cameraStream = stream; + _camera = track; + if (front || back) _frontCamera = front; + }); } on Object catch (e) { _setStatus('Error: $e'); } @@ -399,6 +416,13 @@ class _RoomScreenState extends State { } unawaited(stream.dispose()); } + final cameraStream = _cameraStream; + if (cameraStream != null && cameraStream != stream) { + for (final track in cameraStream.getTracks()) { + unawaited(track.stop()); + } + unawaited(cameraStream.dispose()); + } super.dispose(); } @@ -406,20 +430,26 @@ class _RoomScreenState extends State { Widget build(BuildContext context) { final tiles = [ _VideoTile( - stream: _localStream, label: '${widget.displayName} (you)', - mirror: _frontCamera, - pip: _localPip, - pipOptions: _noPip, + child: RTCView( + mediaStream: _cameraStream, + mirror: _frontCamera, + objectFit: RTCViewObjectFit.cover, + ), ), - for (final video in _remote.values) + for (final entry in _remote.entries) _VideoTile( - stream: video.stream, - label: video.label, - pip: identical(video, _remote.values.first) ? _remotePip : null, - pipOptions: identical(video, _remote.values.first) - ? _pipOptions - : _noPip, + key: ValueKey(entry.key), + label: entry.value.label, + child: PictureInPictureView( + mediaStream: entry.value.stream, + controller: identical(entry.value, _remote.values.first) + ? _remotePip + : null, + pipOptions: identical(entry.value, _remote.values.first) + ? _pipOptions + : _noPip, + ), ), ]; return Scaffold( @@ -487,19 +517,10 @@ class _RoomScreenState extends State { } class _VideoTile extends StatelessWidget { - const _VideoTile({ - required this.label, - required this.pipOptions, - this.stream, - this.pip, - this.mirror = false, - }); + const _VideoTile({super.key, required this.child, required this.label}); - final MediaStream? stream; + final Widget child; final String label; - final PictureInPictureController? pip; - final PictureInPictureOptions pipOptions; - final bool mirror; @override Widget build(BuildContext context) => ClipRRect( @@ -507,15 +528,7 @@ class _VideoTile extends StatelessWidget { child: Stack( fit: StackFit.expand, children: [ - ColoredBox( - color: Colors.black, - child: PictureInPictureView( - mediaStream: stream, - controller: pip, - mirror: mirror, - pipOptions: pipOptions, - ), - ), + ColoredBox(color: Colors.black, child: child), Positioned( left: 8, bottom: 8, diff --git a/lib/fishjam_client.dart b/lib/fishjam_client.dart index a388b49..c5aa936 100644 --- a/lib/fishjam_client.dart +++ b/lib/fishjam_client.dart @@ -1,6 +1,9 @@ /// Fishjam client SDK for Flutter. Start with `FishjamClient`. library; +export 'package:flutter_webrtc/flutter_webrtc.dart' + show MediaStream, MediaStreamTrack, navigator; + export 'src/audio_output/audio_output_manager.dart' show AndroidAudioOutput, @@ -61,3 +64,5 @@ export 'src/rtc/rtc_adapter.dart' show MediaKind, PeerConnectionState, RtcStats; export 'src/signaling/auth.dart' show fishjamUrlFromId; export 'src/tracks/bandwidth.dart' show simulcastCapsKbps, singleStreamCapKbps; export 'src/version.dart' show packageVersion; +export 'src/video/rtc_view.dart' + show CameraDevice, RTCView, RTCViewObjectFit, cameraDevices, selectCamera; diff --git a/lib/src/video/rtc_view.dart b/lib/src/video/rtc_view.dart new file mode 100644 index 0000000..27b5594 --- /dev/null +++ b/lib/src/video/rtc_view.dart @@ -0,0 +1,286 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +const _channel = MethodChannel('io.fishjam.client/video_mirror'); + +/// How [RTCView] fits the video inside its box. +enum RTCViewObjectFit { contain, cover } + +/// Renders [mediaStream]. [mirror] mirrors the picture. +/// +/// On Android the mirror is applied by the renderer on the next drawn +/// frame. [selectCamera] pauses that drawing until the mirror is applied. +class RTCView extends StatefulWidget { + const RTCView({ + required this.mediaStream, + super.key, + this.mirror = false, + this.objectFit = RTCViewObjectFit.contain, + }); + + final MediaStream? mediaStream; + final bool mirror; + final RTCViewObjectFit objectFit; + + @override + State createState() => _RTCViewState(); +} + +class _RTCViewState extends State { + final _renderer = RTCVideoRenderer(); + var _ready = false; + + @override + void initState() { + super.initState(); + unawaited(_open()); + } + + Future _open() async { + await _renderer.initialize(); + if (!mounted) { + await _renderer.dispose(); + return; + } + final textureId = _renderer.textureId; + if (textureId != null) { + registerVideoPreview(textureId: textureId, shows: _shows); + await applyVideoMirror(textureId, mirror: widget.mirror); + } + if (!mounted) { + if (textureId != null) unregisterVideoPreview(textureId); + await _renderer.dispose(); + return; + } + _renderer.srcObject = widget.mediaStream; + setState(() => _ready = true); + } + + bool _shows(MediaStreamTrack track) { + final stream = widget.mediaStream; + if (stream == null) return false; + return stream.getVideoTracks().any((item) => item.id == track.id); + } + + @override + void didUpdateWidget(RTCView oldWidget) { + super.didUpdateWidget(oldWidget); + if (_ready && oldWidget.mediaStream != widget.mediaStream) { + _renderer.srcObject = widget.mediaStream; + } + final textureId = _renderer.textureId; + final mirrorChanged = oldWidget.mirror != widget.mirror; + final streamChanged = oldWidget.mediaStream != widget.mediaStream; + if (textureId != null && + (mirrorChanged || (streamChanged && _paused.contains(textureId)))) { + unawaited(applyVideoMirror(textureId, mirror: widget.mirror)); + } + } + + @override + void dispose() { + final textureId = _renderer.textureId; + if (textureId != null) unregisterVideoPreview(textureId); + unawaited(_renderer.dispose()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_ready) return const ColoredBox(color: Color(0xFF000000)); + return RTCVideoView( + _renderer, + mirror: !videoPreviewIsAndroid() && widget.mirror, + objectFit: widget.objectFit == RTCViewObjectFit.cover + ? RTCVideoViewObjectFit.RTCVideoViewObjectFitCover + : RTCVideoViewObjectFit.RTCVideoViewObjectFitContain, + ); + } +} + +/// A camera from [cameraDevices]. Same fields as the React Native SDK's +/// `cameraDevices` entries: `deviceId` and `label`. +class CameraDevice { + const CameraDevice({required this.deviceId, required this.label}); + + final String deviceId; + final String label; +} + +/// Cameras available to [selectCamera]. +Future> cameraDevices() async { + final devices = await enumerateCameras(); + return [ + for (final device in devices) + if (device.kind == 'videoinput') + CameraDevice(deviceId: device.deviceId, label: device.label), + ]; +} + +/// Selects [deviceId] and returns the video stream to show. +/// +/// [current] is the preview already showing. The same `deviceId` returns +/// [current]. A camera that is already open is switched in place and +/// [current] is returned; opening a second camera and then stopping the +/// first makes `replaceTrack` fail on Android and iOS. With no [current] +/// video track, this opens [deviceId]. An empty [deviceId] throws +/// [ArgumentError]. +Future selectCamera( + String deviceId, { + MediaStream? current, +}) async { + if (deviceId.isEmpty) throw ArgumentError.value(deviceId, 'deviceId'); + final video = current?.getVideoTracks().firstOrNull; + if (video?.getSettings()['deviceId'] == deviceId) return current!; + if (video != null) { + final paused = await _pauseShowing(video); + try { + await switchVideoCamera(video); + if (paused.isNotEmpty) _scheduleResume(); + return current!; + } on Object { + await _resume(paused); + rethrow; + } + } + return await openCamera(deviceId); +} + +/// Remembers a preview so [selectCamera] can pause it. +@visibleForTesting +void registerVideoPreview({ + required int textureId, + required bool Function(MediaStreamTrack track) shows, +}) { + _previews.removeWhere((preview) => preview.textureId == textureId); + _previews.add(_Preview(textureId, shows)); +} + +/// Drops a preview registered with [registerVideoPreview]. +@visibleForTesting +void unregisterVideoPreview(int textureId) { + _previews.removeWhere((preview) => preview.textureId == textureId); + _paused.remove(textureId); +} + +/// Applies [mirror] on Android. Resumes a preview paused for a switch. +@visibleForTesting +Future applyVideoMirror(int textureId, {required bool mirror}) async { + if (!videoPreviewIsAndroid()) return; + final resume = _paused.remove(textureId); + await sendVideoMirror(textureId: textureId, mirror: mirror); + if (resume) await sendVideoPaused(textureId: textureId, paused: false); +} + +@visibleForTesting +Future Function({required int textureId, required bool mirror}) +sendVideoMirror = _sendMirror; + +@visibleForTesting +Future Function({required int textureId, required bool paused}) +sendVideoPaused = _sendPaused; + +@visibleForTesting +Future> Function() enumerateCameras = + navigator.mediaDevices.enumerateDevices; + +@visibleForTesting +Future Function(String deviceId) openCamera = _openCamera; + +@visibleForTesting +Future Function(MediaStreamTrack track) switchVideoCamera = + Helper.switchCamera; + +@visibleForTesting +bool Function() videoPreviewIsAndroid = _defaultAndroid; + +@visibleForTesting +void resetVideoPreview() { + _previews.clear(); + _paused.clear(); + _resumeScheduled = false; + sendVideoMirror = _sendMirror; + sendVideoPaused = _sendPaused; + enumerateCameras = navigator.mediaDevices.enumerateDevices; + openCamera = _openCamera; + switchVideoCamera = Helper.switchCamera; + videoPreviewIsAndroid = _defaultAndroid; +} + +final _previews = <_Preview>[]; +final _paused = {}; +var _resumeScheduled = false; + +class _Preview { + _Preview(this.textureId, this.shows); + + final int textureId; + final bool Function(MediaStreamTrack track) shows; +} + +bool _defaultAndroid() { + if (kIsWeb) return false; + return Platform.isAndroid; +} + +Future _openCamera(String deviceId) { + return navigator.mediaDevices.getUserMedia({ + 'video': { + // flutter_webrtc reads this value as a String on Android and iOS. + // `{exact: deviceId}` is the web constraint shape and crashes both. + 'deviceId': deviceId, + }, + }); +} + +Future> _pauseShowing(MediaStreamTrack track) async { + if (!videoPreviewIsAndroid()) return const []; + final ids = [ + for (final preview in _previews) + if (preview.shows(track)) preview.textureId, + ]; + for (final id in ids) { + await sendVideoPaused(textureId: id, paused: true); + _paused.add(id); + } + return ids; +} + +Future _resume(List ids) async { + for (final id in ids) { + _paused.remove(id); + await sendVideoPaused(textureId: id, paused: false); + } +} + +void _scheduleResume() { + if (_resumeScheduled) return; + _resumeScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _resumeScheduled = false; + final ids = _paused.toList(); + for (final id in ids) { + _paused.remove(id); + unawaited(sendVideoPaused(textureId: id, paused: false)); + } + }); +} + +Future _sendMirror({required int textureId, required bool mirror}) { + return _channel.invokeMethod('setMirror', { + 'textureId': textureId, + 'mirror': mirror, + }); +} + +Future _sendPaused({required int textureId, required bool paused}) { + return _channel.invokeMethod('setPaused', { + 'textureId': textureId, + 'paused': paused, + }); +} diff --git a/test/import_boundary_test.dart b/test/import_boundary_test.dart index 00541ee..497161e 100644 --- a/test/import_boundary_test.dart +++ b/test/import_boundary_test.dart @@ -9,6 +9,7 @@ const _allowed = { 'lib/src/livestream_api.dart', 'lib/src/pip/picture_in_picture.dart', 'lib/src/rtc/flutter_webrtc_adapter.dart', + 'lib/src/video/rtc_view.dart', }; /// Internal modules: pure Dart, tested with fakes. They must not import diff --git a/test/video/rtc_view_test.dart b/test/video/rtc_view_test.dart new file mode 100644 index 0000000..d79989a --- /dev/null +++ b/test/video/rtc_view_test.dart @@ -0,0 +1,245 @@ +import 'package:fishjam_client/src/video/rtc_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart'; + +class _VideoTrack extends MediaStreamTrack { + _VideoTrack(this._id, this._settings); + + final String _id; + final Map _settings; + + @override + String? get id => _id; + + @override + String? get label => 'camera'; + + @override + String? get kind => 'video'; + + @override + bool get enabled => true; + + @override + set enabled(bool b) {} + + @override + bool? get muted => false; + + @override + Map getSettings() => _settings; + + @override + Future stop() async {} + + @override + Future dispose() async {} +} + +class _Stream extends MediaStream { + _Stream(this.tracks) : super('stream', 'local'); + + final List tracks; + + @override + bool? get active => true; + + @override + Future addTrack( + MediaStreamTrack track, { + bool addToNative = true, + }) async {} + + @override + Future removeTrack( + MediaStreamTrack track, { + bool removeFromNative = true, + }) async {} + + @override + List getTracks() => tracks; + + @override + List getAudioTracks() => const []; + + @override + List getVideoTracks() => tracks; + + @override + Future getMediaTracks() async {} +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const webrtc = MethodChannel('FlutterWebRTC.Method'); + + setUp(() { + resetVideoPreview(); + videoPreviewIsAndroid = () => true; + }); + + tearDown(resetVideoPreview); + + test( + 'selectCamera pauses, switches the open camera, then mirror resumes', + () async { + final events = []; + final current = _Stream([ + _VideoTrack('old', {'deviceId': 'front'}), + ]); + openCamera = (deviceId) async { + events.add('open $deviceId'); + return _Stream(const []); + }; + switchVideoCamera = (track) async { + events.add('switch ${track.id}'); + return false; + }; + sendVideoPaused = ({required textureId, required paused}) async { + events.add('${paused ? 'pause' : 'resume'} $textureId'); + }; + sendVideoMirror = ({required textureId, required mirror}) async { + events.add('mirror $textureId $mirror'); + }; + registerVideoPreview(textureId: 4, shows: (track) => track.id == 'old'); + + final stream = await selectCamera('back', current: current); + expect(stream, same(current)); + expect(events, ['pause 4', 'switch old']); + + await applyVideoMirror(4, mirror: false); + expect(events, ['pause 4', 'switch old', 'mirror 4 false', 'resume 4']); + }, + ); + + test('selecting the camera already in use does nothing', () async { + var opened = 0; + openCamera = (deviceId) async { + opened += 1; + return _Stream(const []); + }; + final current = _Stream([ + _VideoTrack('old', {'deviceId': 'front'}), + ]); + + final stream = await selectCamera('front', current: current); + expect(stream, same(current)); + expect(opened, 0); + }); + + test('a failed switch resumes drawing', () async { + final events = []; + switchVideoCamera = (track) async => throw StateError('camera'); + sendVideoPaused = ({required textureId, required paused}) async { + events.add(paused ? 'pause' : 'resume'); + }; + registerVideoPreview(textureId: 4, shows: (_) => true); + final current = _Stream([ + _VideoTrack('old', {'deviceId': 'front'}), + ]); + + await expectLater( + selectCamera('back', current: current), + throwsA(isA()), + ); + expect(events, ['pause', 'resume']); + }); + + test('selectCamera opens a camera when none is running', () async { + final opened = _Stream([ + _VideoTrack('new', {'deviceId': 'back'}), + ]); + openCamera = (deviceId) async { + expect(deviceId, 'back'); + return opened; + }; + + expect(await selectCamera('back'), same(opened)); + }); + + test('an empty device id does not open a camera', () async { + var opened = 0; + openCamera = (deviceId) async { + opened += 1; + return _Stream(const []); + }; + + await expectLater(selectCamera(''), throwsArgumentError); + expect(opened, 0); + }); + + test('cameraDevices keeps video inputs only', () async { + enumerateCameras = () async => [ + MediaDeviceInfo(deviceId: 'mic', kind: 'audioinput', label: 'Mic'), + MediaDeviceInfo(deviceId: 'back', kind: 'videoinput', label: 'Back'), + ]; + + final cameras = await cameraDevices(); + expect(cameras.map((device) => device.deviceId), ['back']); + expect(cameras.single.label, 'Back'); + }); + + testWidgets('Android does not mirror with a widget transform', ( + tester, + ) async { + final calls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(webrtc, ( + call, + ) async { + if (call.method == 'createVideoRenderer') return {'textureId': 3}; + return null; + }); + sendVideoMirror = ({required textureId, required mirror}) async { + calls.add( + MethodCall('setMirror', {'textureId': textureId, 'mirror': mirror}), + ); + }; + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + webrtc, + null, + ), + ); + + await tester.pumpWidget( + const MaterialApp(home: RTCView(mediaStream: null, mirror: true)), + ); + await tester.pump(); + + expect(find.byType(RTCVideoView), findsOneWidget); + expect( + tester.widget(find.byType(RTCVideoView)).mirror, + isFalse, + ); + expect(calls.single.arguments, {'textureId': 3, 'mirror': true}); + }); + + testWidgets('iOS mirrors with the view transform', (tester) async { + videoPreviewIsAndroid = () => false; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler(webrtc, ( + call, + ) async { + if (call.method == 'createVideoRenderer') return {'textureId': 3}; + return null; + }); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + webrtc, + null, + ), + ); + + await tester.pumpWidget( + const MaterialApp(home: RTCView(mediaStream: null, mirror: true)), + ); + await tester.pump(); + + expect( + tester.widget(find.byType(RTCVideoView)).mirror, + isTrue, + ); + }); +}