From e1ee0fbec2088583499ab86b1d69660c7e119930 Mon Sep 17 00:00:00 2001 From: shijing xian Date: Fri, 7 Aug 2026 15:58:16 +0800 Subject: [PATCH 1/3] Default degradation preference by track source, incl. backup codec Camera tracks now default to maintaining framerate, screen share tracks to maintaining resolution, and other sources to balanced, instead of every video track defaulting to maintaining resolution. The preference was also only applied to camera and screen share tracks, so custom sources got whatever WebRTC derived implicitly. It is now applied to every video sender. Degradation preference is a sender-level property, and a backup codec publishes over its own sender, so it needs the preference applied separately. Apply the resolved preference there too, and keep every sender in sync when the preference changes after publish. Co-Authored-By: Claude Opus 5 (1M context) --- .../default-degradation-preference-by-source | 1 + lib/src/options.dart | 30 +++++++++++++ lib/src/participant/local.dart | 18 ++++---- lib/src/track/local/video.dart | 24 ++++++++-- test/options/degradation_preference_test.dart | 44 +++++++++++++++++++ 5 files changed, 106 insertions(+), 11 deletions(-) create mode 100644 .changes/default-degradation-preference-by-source create mode 100644 test/options/degradation_preference_test.dart diff --git a/.changes/default-degradation-preference-by-source b/.changes/default-degradation-preference-by-source new file mode 100644 index 000000000..9a1c5212b --- /dev/null +++ b/.changes/default-degradation-preference-by-source @@ -0,0 +1 @@ +patch type="changed" "Default video degradation preference is now based on the track source (camera maintains framerate, screen share maintains resolution, others balanced) and is applied to the backup codec's sender as well" diff --git a/lib/src/options.dart b/lib/src/options.dart index 25b807eed..a5d7f3304 100644 --- a/lib/src/options.dart +++ b/lib/src/options.dart @@ -348,6 +348,29 @@ enum DegradationPreference { maintainFramerateAndResolution, } +/// Returns the degradation preference to use for a video track published under +/// [source], when the application did not set one explicitly. +/// +/// - Camera: [DegradationPreference.maintainFramerate] (smoother video for +/// real-time communication) +/// - Screen share: [DegradationPreference.maintainResolution] (clarity is +/// critical for reading text/UI) +/// - Other/unknown: [DegradationPreference.balanced] +/// +/// Any other source means the application declined to declare a +/// motion-vs-detail intent, so this falls back to balanced, the preference the +/// WebRTC spec mandates as the default. +DegradationPreference getDefaultDegradationPreference(TrackSource source) { + switch (source) { + case TrackSource.camera: + return DegradationPreference.maintainFramerate; + case TrackSource.screenShareVideo: + return DegradationPreference.maintainResolution; + default: + return DegradationPreference.balanced; + } +} + class BackupVideoCodec { const BackupVideoCodec({ this.enabled = true, @@ -415,6 +438,13 @@ class VideoPublishOptions extends PublishOptions { /// Defaults to true. final bool simulcast; + /// Controls how the encoder trades off between resolution and framerate when + /// bandwidth is constrained. + /// + /// When null, the SDK picks a default based on the track's source, see + /// [getDefaultDegradationPreference]. A preference is always applied to video + /// senders, so leaving this null selects that default rather than deferring to + /// WebRTC's own implicit choice. final DegradationPreference? degradationPreference; final List videoSimulcastLayers; diff --git a/lib/src/participant/local.dart b/lib/src/participant/local.dart index 654437b86..b367f6c9e 100644 --- a/lib/src/participant/local.dart +++ b/lib/src/participant/local.dart @@ -390,10 +390,9 @@ class LocalParticipant extends Participant { ); } - if ([TrackSource.camera, TrackSource.screenShareVideo].contains(track.source)) { - final degradationPreference = options.degradationPreference ?? DegradationPreference.maintainResolution; - await track.setDegradationPreference(degradationPreference); - } + await track.setDegradationPreference( + options.degradationPreference ?? getDefaultDegradationPreference(track.source), + ); if (kIsWeb && lkBrowser() == BrowserType.firefox && track.kind == TrackType.AUDIO) { //TOOD: @@ -489,10 +488,9 @@ class LocalParticipant extends Participant { ); } - if ([TrackSource.camera, TrackSource.screenShareVideo].contains(track.source)) { - final degradationPreference = publishOptions.degradationPreference ?? DegradationPreference.maintainResolution; - await track.setDegradationPreference(degradationPreference); - } + await track.setDegradationPreference( + publishOptions.degradationPreference ?? getDefaultDegradationPreference(track.source), + ); if (kIsWeb && lkBrowser() == BrowserType.firefox && track.kind == TrackType.AUDIO) { //TOOD: @@ -944,6 +942,10 @@ class LocalParticipant extends Participant { backupCodec, ); + // the backup codec publishes over its own sender, so it needs the same + // degradation preference the primary sender resolved to. + await track.applyDegradationPreference(simulcastTrack.sender); + final cid = simulcastTrack.sender!.senderId; final req = lk_rtc.AddTrackRequest( diff --git a/lib/src/track/local/video.dart b/lib/src/track/local/video.dart index 47030edb6..14f0955de 100644 --- a/lib/src/track/local/video.dart +++ b/lib/src/track/local/video.dart @@ -68,6 +68,8 @@ class LocalVideoTrack extends LocalTrack with VideoTrack { Map simulcastCodecs = {}; Map<(String, int), rtc.RTCRtpEncoding> encodingBackups = {}; + DegradationPreference? _degradationPreference; + List subscribedCodecs = []; @override @@ -503,11 +505,27 @@ extension LocalVideoTrackExt on LocalVideoTrack { } Future setDegradationPreference(DegradationPreference preference) async { - final params = sender?.parameters; - if (params == null) { + _degradationPreference = preference; + await applyDegradationPreference(sender); + for (final simulcastCodec in simulcastCodecs.values) { + await applyDegradationPreference(simulcastCodec.sender); + } + } + + /// Applies the degradation preference resolved for this track to [sender]. + /// + /// Degradation preference is a property of the sender, not of the track, so + /// every sender publishing this track needs it applied separately. A backup + /// codec publishes over its own sender, which would otherwise resolve a + /// preference implicitly and diverge from the primary encoder. + @internal + Future applyDegradationPreference(rtc.RTCRtpSender? sender) async { + final preference = _degradationPreference; + if (sender == null || preference == null) { return; } + final params = sender.parameters; params.degradationPreference = preference.toRTCType(); - await sender?.setParameters(params); + await sender.setParameters(params); } } diff --git a/test/options/degradation_preference_test.dart b/test/options/degradation_preference_test.dart new file mode 100644 index 000000000..4118d19eb --- /dev/null +++ b/test/options/degradation_preference_test.dart @@ -0,0 +1,44 @@ +// 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_test/flutter_test.dart'; + +import 'package:livekit_client/src/options.dart'; +import 'package:livekit_client/src/types/other.dart'; + +void main() { + group('getDefaultDegradationPreference', () { + test('camera prefers framerate', () { + expect( + getDefaultDegradationPreference(TrackSource.camera), + DegradationPreference.maintainFramerate, + ); + }); + + test('screen share prefers resolution', () { + expect( + getDefaultDegradationPreference(TrackSource.screenShareVideo), + DegradationPreference.maintainResolution, + ); + }); + + test('other sources fall back to balanced', () { + // the application declined to declare a motion-vs-detail intent + expect( + getDefaultDegradationPreference(TrackSource.unknown), + DegradationPreference.balanced, + ); + }); + }); +} From 11101c46445dd99f1c7f215b1f785109d3f69601 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:04:03 +0900 Subject: [PATCH 2/3] Guard degradation preference setParameters against stale senders setDegradationPreference now fans out over simulcastCodecs, whose senders are never cleared and can outlive their peer connection. After a full reconnect, rePublishAllTracks reuses the same track object, so the fanout would call setParameters on a sender from the disposed connection and the resulting platform error aborted the whole republish. Catch and warn instead, matching how setPublishingLayers handles the same call. --- lib/src/track/local/video.dart | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/src/track/local/video.dart b/lib/src/track/local/video.dart index 14f0955de..a3453b039 100644 --- a/lib/src/track/local/video.dart +++ b/lib/src/track/local/video.dart @@ -526,6 +526,10 @@ extension LocalVideoTrackExt on LocalVideoTrack { } final params = sender.parameters; params.degradationPreference = preference.toRTCType(); - await sender.setParameters(params); + try { + await sender.setParameters(params); + } catch (e) { + logger.warning('Failed to set degradation preference on sender $e'); + } } } From 8c359c6cfb4c9f99570465c952ff8b5b3495cc58 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 7 Aug 2026 19:15:15 +0900 Subject: [PATCH 3/3] Snapshot simulcastCodecs before iterating with awaits setDegradationPreference awaits inside the loop over simulcastCodecs, and addSimulcastTrack can insert into the map during that await when the server requests a backup codec. Iterate over a snapshot to avoid a ConcurrentModificationError. --- lib/src/track/local/video.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/src/track/local/video.dart b/lib/src/track/local/video.dart index a3453b039..c6b0c8239 100644 --- a/lib/src/track/local/video.dart +++ b/lib/src/track/local/video.dart @@ -507,7 +507,7 @@ extension LocalVideoTrackExt on LocalVideoTrack { Future setDegradationPreference(DegradationPreference preference) async { _degradationPreference = preference; await applyDegradationPreference(sender); - for (final simulcastCodec in simulcastCodecs.values) { + for (final simulcastCodec in simulcastCodecs.values.toList()) { await applyDegradationPreference(simulcastCodec.sender); } }