From 5c1cf635ff6c191c6b716b4e3dfb9baaa77a0bd6 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 7 Oct 2026 01:45:46 -0400 Subject: [PATCH] fix: keep the track processor across mute and restartTrack restartTrack called stop(), which destroys the processor and clears _processor, before it read _processor to attach it to the new track, so the processor was always dropped. Mute went through stop() too. With the default stopAudioCaptureOnMute and stopCameraCaptureOnMute (both true), muting stops the media track and unmuting restarts it, so a microphone or camera processor was lost after the first mute and unmute, and on every camera switch. mute and restartTrack now stop the media track and keep the processor, and restartTrack moves it to the new track as before. stop() still releases it, also when a mute already stopped the media track. --- .changes/keep-processor-across-restart | 1 + lib/src/track/local/local.dart | 30 +++-- test/track/local_track_processor_test.dart | 139 +++++++++++++++++++++ 3 files changed, 160 insertions(+), 10 deletions(-) create mode 100644 .changes/keep-processor-across-restart create mode 100644 test/track/local_track_processor_test.dart 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); + }); +}