diff --git a/.changes/keep-processor-across-restart b/.changes/keep-processor-across-restart new file mode 100644 index 000000000..0fc5dd140 --- /dev/null +++ b/.changes/keep-processor-across-restart @@ -0,0 +1 @@ +patch type="fixed" "A track processor now stays attached across mute and unmute with the default stop-on-mute capture options, and across restartTrack such as a camera switch, instead of being silently dropped" diff --git a/lib/src/track/local/local.dart b/lib/src/track/local/local.dart index 7252cb045..e56503577 100644 --- a/lib/src/track/local/local.dart +++ b/lib/src/track/local/local.dart @@ -196,7 +196,7 @@ abstract class LocalTrack extends Track { if (muted) return false; // already muted await disable(); if (!skipStopForTrackMute() && stopOnMute) { - await stop(); + await _stopKeepingProcessor(); } updateMuted(true, shouldSendSignal: true); return true; @@ -218,7 +218,24 @@ abstract class LocalTrack extends Track { @override Future stop() async { - final didStop = await super.stop() || !_stopped; + final didStop = await _disposeMediaTrack(await super.stop()); + // Also runs when a stop-on-mute already stopped the media track and kept + // the processor for the restart. + try { + await stopProcessor(); + } catch (error) { + logger.severe('LocalTrack.stopProcessor did throw: $error'); + } + return didStop; + } + + // Keeps the processor, which restartTrack moves to the new media track. + Future _stopKeepingProcessor() async => _disposeMediaTrack(await super.stop()); + + // Takes the result of super.stop() because stop() has to call it directly + // (Track.stop is @mustCallSuper). + Future _disposeMediaTrack(bool trackStopped) async { + final didStop = trackStopped || !_stopped; if (didStop) { logger.fine('Stopping mediaStreamTrack...'); try { @@ -232,13 +249,6 @@ abstract class LocalTrack extends Track { logger.severe('MediaStreamTrack.dispose() did throw $error'); } _stopped = true; - try { - if (_processor != null) { - await stopProcessor(); - } - } catch (error) { - logger.severe('LocalTrack.stopProcessor did throw: $error'); - } } return didStop; } @@ -314,7 +324,7 @@ abstract class LocalTrack extends Track { currentOptions = options ?? currentOptions; // stop if not already stopped... - await stop(); + await _stopKeepingProcessor(); // create new track with options final newStream = await LocalTrack.createStream(currentOptions); diff --git a/test/track/local_track_processor_test.dart b/test/track/local_track_processor_test.dart new file mode 100644 index 000000000..b45fe040c --- /dev/null +++ b/test/track/local_track_processor_test.dart @@ -0,0 +1,139 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import 'package:flutter/services.dart'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_webrtc/flutter_webrtc.dart' as rtc; + +import 'package:livekit_client/livekit_client.dart'; +import 'package:livekit_client/src/support/native.dart'; + +class _FakeProcessor implements TrackProcessor { + final List initTrackIds = []; + int destroyCount = 0; + + @override + String get name => 'fake'; + + @override + Future init(AudioProcessorOptions options) async => initTrackIds.add(options.track.id!); + + @override + Future restart(AudioProcessorOptions options) async {} + + @override + Future destroy() async => destroyCount++; + + @override + Future onPublish(Room room) async {} + + @override + Future onUnpublish() async {} + + @override + rtc.MediaStreamTrack? get processedTrack => null; +} + +class _FakeSender implements rtc.RTCRtpSender { + final List replacedTrackIds = []; + + @override + Future replaceTrack(rtc.MediaStreamTrack? track) async => replacedTrackIds.add(track?.id); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +class _FakeTransceiver implements rtc.RTCRtpTransceiver { + _FakeTransceiver(this.sender); + + @override + final rtc.RTCRtpSender sender; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + const webrtcChannel = MethodChannel('FlutterWebRTC.Method'); + + var trackCounter = 0; + + setUp(() { + trackCounter = 0; + messenger.setMockMethodCallHandler(webrtcChannel, (call) async { + if (call.method == 'getUserMedia') { + trackCounter++; + return { + 'streamId': 'stream-$trackCounter', + 'audioTracks': [ + {'id': 'mic-$trackCounter', 'label': 'mic', 'kind': 'audio', 'enabled': true}, + ], + 'videoTracks': [], + }; + } + return null; + }); + messenger.setMockMethodCallHandler(Native.channel, (call) async => true); + }); + + tearDown(() { + messenger.setMockMethodCallHandler(webrtcChannel, null); + messenger.setMockMethodCallHandler(Native.channel, null); + }); + + Future<(LocalAudioTrack, _FakeProcessor, _FakeSender)> createPublishedTrack() async { + final processor = _FakeProcessor(); + final track = await LocalAudioTrack.create(AudioCaptureOptions(processor: processor)); + await track.start(); + final sender = _FakeSender(); + track.transceiver = _FakeTransceiver(sender); + return (track, processor, sender); + } + + test('mute and unmute keep the processor on the restarted track', () async { + final (track, processor, sender) = await createPublishedTrack(); + expect(processor.initTrackIds, ['mic-1']); + + await track.mute(); + await track.unmute(); + + expect(sender.replacedTrackIds, ['mic-2']); + expect(track.processor, same(processor)); + expect(processor.initTrackIds, ['mic-1', 'mic-2']); + }); + + test('restartTrack keeps the processor on the new track', () async { + final (track, processor, sender) = await createPublishedTrack(); + + await track.restartTrack(); + + expect(sender.replacedTrackIds, ['mic-2']); + expect(track.processor, same(processor)); + expect(processor.initTrackIds, ['mic-1', 'mic-2']); + }); + + test('stop releases the processor, also after a mute already stopped capture', () async { + final (track, processor, _) = await createPublishedTrack(); + + await track.mute(); + await track.stop(); + + expect(track.processor, isNull); + expect(processor.destroyCount, 1); + }); +}