From 3b3ccf3688bd98f8ad0a3444d3a809837de899b5 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:01:24 +0800 Subject: [PATCH 01/10] impl 1 --- lib/src/publication/remote.dart | 127 +++++++++++++------- lib/src/publication/track_settings.dart | 75 ++++++++++++ test/publication/track_settings_test.dart | 136 ++++++++++++++++++++++ 3 files changed, 293 insertions(+), 45 deletions(-) create mode 100644 lib/src/publication/track_settings.dart create mode 100644 test/publication/track_settings_test.dart diff --git a/lib/src/publication/remote.dart b/lib/src/publication/remote.dart index f9e30bb5e..b8bf9ef60 100644 --- a/lib/src/publication/remote.dart +++ b/lib/src/publication/remote.dart @@ -33,6 +33,7 @@ import '../types/other.dart'; import '../types/video_dimensions.dart'; import '../utils.dart'; import 'track_publication.dart'; +import 'track_settings.dart'; /// Represents a track publication from a RemoteParticipant. Provides methods to /// control if we should subscribe to the track, and its quality (for video). @@ -48,11 +49,16 @@ class RemoteTrackPublication extends TrackPublication int? _fps; int get fps => _fps ?? 0; - VideoQuality? _videoQuality = VideoQuality.HIGH; - VideoQuality get videoQuality => _videoQuality ?? VideoQuality.HIGH; + // Manual settings (set by user via setVideoQuality / setVideoDimensions) + VideoQuality? _requestedMaxQuality; + VideoDimensions? _requestedVideoDimensions; - VideoDimensions? _videoDimensions; - VideoDimensions? get videoDimensions => _videoDimensions; + // Adaptive stream state (set automatically by visibility observer) + VideoDimensions? _adaptiveStreamDimensions; + bool _adaptiveStreamEnabled = true; + + VideoQuality get videoQuality => _requestedMaxQuality ?? VideoQuality.HIGH; + VideoDimensions? get videoDimensions => _requestedVideoDimensions; /// The server may pause the track when they are bandwidth limitations and resume /// when there is more capacity. This property will be updated when the track is @@ -144,11 +150,6 @@ class RemoteTrackPublication extends TrackPublication final videoTrack = track as VideoTrack; - final settings = lk_rtc.UpdateTrackSettings( - trackSids: [sid], - disabled: true, - ); - // filter visible build contexts final viewSizes = videoTrack.viewKeys .map((e) => e.currentContext) @@ -161,15 +162,19 @@ class RemoteTrackPublication extends TrackPublication logger.finer('[Visibility] ${track?.sid} watching ${viewSizes.length} views...'); if (viewSizes.isNotEmpty) { - // compute largest size final largestSize = viewSizes.reduce((value, element) => maxOfSizes(value, element)); - - settings - ..disabled = false - ..width = largestSize.width.ceil() - ..height = largestSize.height.ceil(); + _adaptiveStreamDimensions = VideoDimensions( + largestSize.width.ceil(), + largestSize.height.ceil(), + ); + _adaptiveStreamEnabled = true; + } else { + _adaptiveStreamDimensions = null; + _adaptiveStreamEnabled = false; } + final settings = _buildTrackSettings(); + // Only send new settings to server if it changed if (settings != _lastSentTrackSettings) { _lastSentTrackSettings = settings; @@ -229,7 +234,7 @@ class RemoteTrackPublication extends TrackPublication return didUpdate; } - bool _canUpdateManualVideoSettings() { + bool _isManualOperationAllowed() { if (kind != TrackType.VIDEO) { logger.warning('Manual video setting updates are only supported for video tracks'); return false; @@ -240,55 +245,62 @@ class RemoteTrackPublication extends TrackPublication return false; } - if (participant.room.roomOptions.adaptiveStream) { - logger.warning('Manual video setting update ignored because adaptive stream is enabled'); - return false; - } - return true; } + /// For tracks that support simulcasting, adjust subscribed quality. + /// + /// This indicates the highest quality the client can accept. If network + /// bandwidth does not allow, the server will automatically reduce quality to + /// optimize for uninterrupted video. + /// + /// When adaptive stream is enabled, the server will use the smaller of + /// this setting and the adaptive stream dimensions. Future setVideoQuality(VideoQuality newValue) async { - if (newValue == _videoQuality) return; - if (!_canUpdateManualVideoSettings()) return; - _videoQuality = newValue; - _videoDimensions = null; - sendUpdateTrackSettings(); + if (newValue == _requestedMaxQuality) return; + if (!_isManualOperationAllowed()) return; + _requestedMaxQuality = newValue; + _requestedVideoDimensions = null; + _emitTrackUpdate(); } /// Set preferred video dimensions for this track. /// /// Server will choose the appropriate layer based on these dimensions. /// Will override previous calls to [setVideoQuality]. + /// + /// When adaptive stream is enabled, the server will use the smaller of + /// this setting and the adaptive stream dimensions. Future setVideoDimensions(VideoDimensions newValue) async { - if (newValue.width == _videoDimensions?.width && newValue.height == _videoDimensions?.height) { + if (newValue.width == _requestedVideoDimensions?.width && + newValue.height == _requestedVideoDimensions?.height) { return; } - if (!_canUpdateManualVideoSettings()) return; - _videoDimensions = newValue; - _videoQuality = null; - sendUpdateTrackSettings(); + if (!_isManualOperationAllowed()) return; + _requestedVideoDimensions = newValue; + _requestedMaxQuality = null; + _emitTrackUpdate(); } /// Set desired FPS, server will do its best to return FPS close to this. /// It's only supported for video codecs that support SVC currently. Future setVideoFPS(int newValue) async { if (newValue == _fps) return; - if (!_canUpdateManualVideoSettings()) return; + if (!_isManualOperationAllowed()) return; _fps = newValue; - sendUpdateTrackSettings(); + _emitTrackUpdate(); } Future enable() async { if (_enabled) return; _enabled = true; - sendUpdateTrackSettings(); + _emitTrackUpdate(); } Future disable() async { if (!_enabled) return; _enabled = false; - sendUpdateTrackSettings(); + _emitTrackUpdate(); } Future subscribe() async { @@ -333,26 +345,51 @@ class RemoteTrackPublication extends TrackPublication participant.room.engine.signalClient.sendUpdateSubscription(subscription); } - @internal - void sendUpdateTrackSettings() { + lk_rtc.UpdateTrackSettings _buildTrackSettings() { + // disabled if manually disabled or adaptive stream says no views visible + final isDisabled = !_enabled || !_adaptiveStreamEnabled; + final settings = lk_rtc.UpdateTrackSettings( trackSids: [sid], - disabled: !_enabled, + disabled: isDisabled, ); + if (kind == TrackType.VIDEO) { - if (_videoDimensions != null) { - settings.width = _videoDimensions!.width; - settings.height = _videoDimensions!.height; - } else if (_videoQuality != null) { - settings.quality = _videoQuality!.toPBType(); - } else { - settings.quality = VideoQuality.HIGH.toPBType(); + final resolved = resolveVideoSettings( + adaptiveStreamDimensions: _adaptiveStreamDimensions, + requestedDimensions: _requestedVideoDimensions, + requestedMaxQuality: _requestedMaxQuality, + layerDimensionsForQuality: (quality) { + final pbQuality = quality.toPBType(); + final layer = latestInfo?.layers + .where((l) => l.quality == pbQuality) + .firstOrNull; + if (layer == null) return null; + return VideoDimensions(layer.width, layer.height); + }, + ); + + if (resolved.dimensions != null) { + settings.width = resolved.dimensions!.width; + settings.height = resolved.dimensions!.height; + } else if (resolved.quality != null) { + settings.quality = resolved.quality!.toPBType(); } if (_fps != null) settings.fps = _fps!; } + return settings; + } + + void _emitTrackUpdate() { + final settings = _buildTrackSettings(); + _lastSentTrackSettings = settings; participant.room.engine.signalClient.sendUpdateTrackSettings(settings); } + @internal + @Deprecated('Use _emitTrackUpdate instead') + void sendUpdateTrackSettings() => _emitTrackUpdate(); + @internal // Update internal var and return true if changed Future updateSubscriptionAllowed(bool allowed) async { diff --git a/lib/src/publication/track_settings.dart b/lib/src/publication/track_settings.dart new file mode 100644 index 000000000..20965b6fa --- /dev/null +++ b/lib/src/publication/track_settings.dart @@ -0,0 +1,75 @@ +// Copyright 2024 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 '../types/other.dart'; +import '../types/video_dimensions.dart'; + +/// The result of merging adaptive stream and manual video settings. +/// +/// Separates the "what to send" decision from protobuf serialization, +/// making the merge logic testable without protobuf dependencies. +class ResolvedVideoSettings { + /// If non-null, send dimensions (width/height) to the server. + final VideoDimensions? dimensions; + + /// If non-null and [dimensions] is null, send quality to the server. + final VideoQuality? quality; + + const ResolvedVideoSettings({this.dimensions, this.quality}); +} + +/// Merges adaptive stream dimensions with manual quality/dimension settings, +/// always picking the more conservative (smaller) of the two. +/// +/// This matches the JS SDK's merge behavior in `emitTrackUpdate()`. +/// +/// [adaptiveStreamDimensions] — set automatically by the visibility observer. +/// [requestedDimensions] — set manually via `setVideoDimensions()`. +/// [requestedMaxQuality] — set manually via `setVideoQuality()`. +/// [layerDimensionsForQuality] — resolves a quality to dimensions using the +/// track's published layer info. Passed as a callback so callers can provide +/// it from whatever source they have (protobuf TrackInfo, test fixture, etc). +ResolvedVideoSettings resolveVideoSettings({ + VideoDimensions? adaptiveStreamDimensions, + VideoDimensions? requestedDimensions, + VideoQuality? requestedMaxQuality, + VideoDimensions? Function(VideoQuality quality)? layerDimensionsForQuality, +}) { + VideoDimensions? minDimensions = requestedDimensions; + + if (adaptiveStreamDimensions != null) { + if (minDimensions != null) { + // Use the smaller of adaptive vs manually requested dimensions + if (adaptiveStreamDimensions.area() < minDimensions.area()) { + minDimensions = adaptiveStreamDimensions; + } + } else if (requestedMaxQuality != null) { + // Compare adaptive dimensions with the max quality layer dimensions + final maxQualityLayer = layerDimensionsForQuality?.call(requestedMaxQuality); + if (maxQualityLayer != null && + adaptiveStreamDimensions.area() < maxQualityLayer.area()) { + minDimensions = adaptiveStreamDimensions; + } + } else { + minDimensions = adaptiveStreamDimensions; + } + } + + if (minDimensions != null) { + return ResolvedVideoSettings(dimensions: minDimensions); + } else if (requestedMaxQuality != null) { + return ResolvedVideoSettings(quality: requestedMaxQuality); + } + return ResolvedVideoSettings(quality: VideoQuality.HIGH); +} diff --git a/test/publication/track_settings_test.dart b/test/publication/track_settings_test.dart new file mode 100644 index 000000000..62bcac543 --- /dev/null +++ b/test/publication/track_settings_test.dart @@ -0,0 +1,136 @@ +// Copyright 2024 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/publication/track_settings.dart'; +import 'package:livekit_client/src/types/other.dart'; +import 'package:livekit_client/src/types/video_dimensions.dart'; + +/// Test helper: returns layer dimensions for a standard 3-layer SVC/simulcast track. +VideoDimensions? _testLayerDimensions(VideoQuality quality) { + return { + VideoQuality.LOW: VideoDimensions(320, 180), + VideoQuality.MEDIUM: VideoDimensions(640, 360), + VideoQuality.HIGH: VideoDimensions(1280, 720), + }[quality]; +} + +void main() { + group('resolveVideoSettings', () { + group('no adaptive stream', () { + test('defaults to HIGH quality when nothing set', () { + final r = resolveVideoSettings(); + expect(r.quality, VideoQuality.HIGH); + expect(r.dimensions, isNull); + }); + + test('uses requested quality', () { + final r = resolveVideoSettings( + requestedMaxQuality: VideoQuality.LOW, + ); + expect(r.quality, VideoQuality.LOW); + expect(r.dimensions, isNull); + }); + + test('uses requested dimensions', () { + final r = resolveVideoSettings( + requestedDimensions: VideoDimensions(800, 600), + ); + expect(r.dimensions, VideoDimensions(800, 600)); + expect(r.quality, isNull); + }); + }); + + group('adaptive stream only', () { + test('uses adaptive stream dimensions', () { + final r = resolveVideoSettings( + adaptiveStreamDimensions: VideoDimensions(480, 270), + ); + expect(r.dimensions, VideoDimensions(480, 270)); + expect(r.quality, isNull); + }); + }); + + group('adaptive stream + requested dimensions', () { + test('adaptive wins when smaller', () { + final r = resolveVideoSettings( + adaptiveStreamDimensions: VideoDimensions(320, 180), + requestedDimensions: VideoDimensions(1280, 720), + ); + expect(r.dimensions, VideoDimensions(320, 180)); + }); + + test('requested wins when smaller', () { + final r = resolveVideoSettings( + adaptiveStreamDimensions: VideoDimensions(1920, 1080), + requestedDimensions: VideoDimensions(640, 360), + ); + expect(r.dimensions, VideoDimensions(640, 360)); + }); + + test('equal areas keep requested', () { + final r = resolveVideoSettings( + adaptiveStreamDimensions: VideoDimensions(640, 360), + requestedDimensions: VideoDimensions(640, 360), + ); + expect(r.dimensions, VideoDimensions(640, 360)); + }); + }); + + group('adaptive stream + requested quality', () { + test('adaptive wins when smaller than quality layer', () { + final r = resolveVideoSettings( + adaptiveStreamDimensions: VideoDimensions(320, 180), + requestedMaxQuality: VideoQuality.HIGH, + layerDimensionsForQuality: _testLayerDimensions, + ); + // adaptive 320*180 < HIGH 1280*720 → sends adaptive dimensions + expect(r.dimensions, VideoDimensions(320, 180)); + expect(r.quality, isNull); + }); + + test('quality wins when adaptive is larger than quality layer', () { + final r = resolveVideoSettings( + adaptiveStreamDimensions: VideoDimensions(1920, 1080), + requestedMaxQuality: VideoQuality.LOW, + layerDimensionsForQuality: _testLayerDimensions, + ); + // adaptive 1920*1080 > LOW 320*180 → sends quality directly + expect(r.quality, VideoQuality.LOW); + expect(r.dimensions, isNull); + }); + + test('quality sent directly when no layer info available', () { + final r = resolveVideoSettings( + adaptiveStreamDimensions: VideoDimensions(320, 180), + requestedMaxQuality: VideoQuality.LOW, + // no layerDimensionsForQuality → can't compare + ); + expect(r.quality, VideoQuality.LOW); + expect(r.dimensions, isNull); + }); + + test('quality sent when layer lookup returns null', () { + final r = resolveVideoSettings( + adaptiveStreamDimensions: VideoDimensions(320, 180), + requestedMaxQuality: VideoQuality.MEDIUM, + layerDimensionsForQuality: (_) => null, + ); + expect(r.quality, VideoQuality.MEDIUM); + expect(r.dimensions, isNull); + }); + }); + }); +} From e32605a7e3854276f3ca555c77d147b1e8138ac8 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:14:24 +0800 Subject: [PATCH 02/10] Create adaptive-stream-manual-quality-merge --- .changes/adaptive-stream-manual-quality-merge | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/adaptive-stream-manual-quality-merge diff --git a/.changes/adaptive-stream-manual-quality-merge b/.changes/adaptive-stream-manual-quality-merge new file mode 100644 index 000000000..083a3def6 --- /dev/null +++ b/.changes/adaptive-stream-manual-quality-merge @@ -0,0 +1 @@ +patch type="improved" "Allow manual video quality selection with adaptive stream enabled" From e80813d970ed5cb6f26dcef7e394f341d7ffb351 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Wed, 1 Apr 2026 20:17:51 +0800 Subject: [PATCH 03/10] refactor --- lib/src/publication/remote.dart | 27 ++++-------- lib/src/publication/track_settings.dart | 53 +++++++++++------------ test/publication/track_settings_test.dart | 31 +++++++------ 3 files changed, 49 insertions(+), 62 deletions(-) diff --git a/lib/src/publication/remote.dart b/lib/src/publication/remote.dart index b8bf9ef60..aa74cb87d 100644 --- a/lib/src/publication/remote.dart +++ b/lib/src/publication/remote.dart @@ -50,15 +50,14 @@ class RemoteTrackPublication extends TrackPublication int get fps => _fps ?? 0; // Manual settings (set by user via setVideoQuality / setVideoDimensions) - VideoQuality? _requestedMaxQuality; - VideoDimensions? _requestedVideoDimensions; + VideoSettings? _userPreference; // Adaptive stream state (set automatically by visibility observer) VideoDimensions? _adaptiveStreamDimensions; bool _adaptiveStreamEnabled = true; - VideoQuality get videoQuality => _requestedMaxQuality ?? VideoQuality.HIGH; - VideoDimensions? get videoDimensions => _requestedVideoDimensions; + VideoQuality get videoQuality => _userPreference?.quality ?? VideoQuality.HIGH; + VideoDimensions? get videoDimensions => _userPreference?.dimensions; /// The server may pause the track when they are bandwidth limitations and resume /// when there is more capacity. This property will be updated when the track is @@ -257,10 +256,9 @@ class RemoteTrackPublication extends TrackPublication /// When adaptive stream is enabled, the server will use the smaller of /// this setting and the adaptive stream dimensions. Future setVideoQuality(VideoQuality newValue) async { - if (newValue == _requestedMaxQuality) return; + if (newValue == _userPreference?.quality) return; if (!_isManualOperationAllowed()) return; - _requestedMaxQuality = newValue; - _requestedVideoDimensions = null; + _userPreference = VideoSettings.quality(newValue); _emitTrackUpdate(); } @@ -272,13 +270,9 @@ class RemoteTrackPublication extends TrackPublication /// When adaptive stream is enabled, the server will use the smaller of /// this setting and the adaptive stream dimensions. Future setVideoDimensions(VideoDimensions newValue) async { - if (newValue.width == _requestedVideoDimensions?.width && - newValue.height == _requestedVideoDimensions?.height) { - return; - } + if (newValue == _userPreference?.dimensions) return; if (!_isManualOperationAllowed()) return; - _requestedVideoDimensions = newValue; - _requestedMaxQuality = null; + _userPreference = VideoSettings.dimensions(newValue); _emitTrackUpdate(); } @@ -357,13 +351,10 @@ class RemoteTrackPublication extends TrackPublication if (kind == TrackType.VIDEO) { final resolved = resolveVideoSettings( adaptiveStreamDimensions: _adaptiveStreamDimensions, - requestedDimensions: _requestedVideoDimensions, - requestedMaxQuality: _requestedMaxQuality, + userPreference: _userPreference, layerDimensionsForQuality: (quality) { final pbQuality = quality.toPBType(); - final layer = latestInfo?.layers - .where((l) => l.quality == pbQuality) - .firstOrNull; + final layer = latestInfo?.layers.where((l) => l.quality == pbQuality).firstOrNull; if (layer == null) return null; return VideoDimensions(layer.width, layer.height); }, diff --git a/lib/src/publication/track_settings.dart b/lib/src/publication/track_settings.dart index 20965b6fa..0853969a0 100644 --- a/lib/src/publication/track_settings.dart +++ b/lib/src/publication/track_settings.dart @@ -12,41 +12,39 @@ // See the License for the specific language governing permissions and // limitations under the License. +import 'package:meta/meta.dart' show immutable, internal; + import '../types/other.dart'; import '../types/video_dimensions.dart'; -/// The result of merging adaptive stream and manual video settings. +/// Represents a video quality setting — either explicit dimensions or a +/// quality level (LOW/MEDIUM/HIGH), never both. /// -/// Separates the "what to send" decision from protobuf serialization, -/// making the merge logic testable without protobuf dependencies. -class ResolvedVideoSettings { - /// If non-null, send dimensions (width/height) to the server. +/// Used for both user-requested settings and the resolved merge result. +@internal +@immutable +class VideoSettings { final VideoDimensions? dimensions; - - /// If non-null and [dimensions] is null, send quality to the server. final VideoQuality? quality; - const ResolvedVideoSettings({this.dimensions, this.quality}); + const VideoSettings.dimensions(VideoDimensions this.dimensions) : quality = null; + + const VideoSettings.quality(VideoQuality this.quality) : dimensions = null; + + static const high = VideoSettings.quality(VideoQuality.HIGH); } -/// Merges adaptive stream dimensions with manual quality/dimension settings, +/// Merges adaptive stream dimensions with manual [VideoSettings], /// always picking the more conservative (smaller) of the two. /// /// This matches the JS SDK's merge behavior in `emitTrackUpdate()`. -/// -/// [adaptiveStreamDimensions] — set automatically by the visibility observer. -/// [requestedDimensions] — set manually via `setVideoDimensions()`. -/// [requestedMaxQuality] — set manually via `setVideoQuality()`. -/// [layerDimensionsForQuality] — resolves a quality to dimensions using the -/// track's published layer info. Passed as a callback so callers can provide -/// it from whatever source they have (protobuf TrackInfo, test fixture, etc). -ResolvedVideoSettings resolveVideoSettings({ +@internal +VideoSettings resolveVideoSettings({ VideoDimensions? adaptiveStreamDimensions, - VideoDimensions? requestedDimensions, - VideoQuality? requestedMaxQuality, + VideoSettings? userPreference, VideoDimensions? Function(VideoQuality quality)? layerDimensionsForQuality, }) { - VideoDimensions? minDimensions = requestedDimensions; + VideoDimensions? minDimensions = userPreference?.dimensions; if (adaptiveStreamDimensions != null) { if (minDimensions != null) { @@ -54,11 +52,10 @@ ResolvedVideoSettings resolveVideoSettings({ if (adaptiveStreamDimensions.area() < minDimensions.area()) { minDimensions = adaptiveStreamDimensions; } - } else if (requestedMaxQuality != null) { + } else if (userPreference?.quality != null) { // Compare adaptive dimensions with the max quality layer dimensions - final maxQualityLayer = layerDimensionsForQuality?.call(requestedMaxQuality); - if (maxQualityLayer != null && - adaptiveStreamDimensions.area() < maxQualityLayer.area()) { + final maxQualityLayer = layerDimensionsForQuality?.call(userPreference!.quality!); + if (maxQualityLayer != null && adaptiveStreamDimensions.area() < maxQualityLayer.area()) { minDimensions = adaptiveStreamDimensions; } } else { @@ -67,9 +64,9 @@ ResolvedVideoSettings resolveVideoSettings({ } if (minDimensions != null) { - return ResolvedVideoSettings(dimensions: minDimensions); - } else if (requestedMaxQuality != null) { - return ResolvedVideoSettings(quality: requestedMaxQuality); + return VideoSettings.dimensions(minDimensions); + } else if (userPreference?.quality != null) { + return VideoSettings.quality(userPreference!.quality!); } - return ResolvedVideoSettings(quality: VideoQuality.HIGH); + return VideoSettings.high; } diff --git a/test/publication/track_settings_test.dart b/test/publication/track_settings_test.dart index 62bcac543..b85336dd9 100644 --- a/test/publication/track_settings_test.dart +++ b/test/publication/track_settings_test.dart @@ -36,17 +36,17 @@ void main() { expect(r.dimensions, isNull); }); - test('uses requested quality', () { + test('uses preferred quality', () { final r = resolveVideoSettings( - requestedMaxQuality: VideoQuality.LOW, + userPreference: VideoSettings.quality(VideoQuality.LOW), ); expect(r.quality, VideoQuality.LOW); expect(r.dimensions, isNull); }); - test('uses requested dimensions', () { + test('uses preferred dimensions', () { final r = resolveVideoSettings( - requestedDimensions: VideoDimensions(800, 600), + userPreference: VideoSettings.dimensions(VideoDimensions(800, 600)), ); expect(r.dimensions, VideoDimensions(800, 600)); expect(r.quality, isNull); @@ -63,37 +63,37 @@ void main() { }); }); - group('adaptive stream + requested dimensions', () { + group('adaptive stream + preferred dimensions', () { test('adaptive wins when smaller', () { final r = resolveVideoSettings( adaptiveStreamDimensions: VideoDimensions(320, 180), - requestedDimensions: VideoDimensions(1280, 720), + userPreference: VideoSettings.dimensions(VideoDimensions(1280, 720)), ); expect(r.dimensions, VideoDimensions(320, 180)); }); - test('requested wins when smaller', () { + test('preferred wins when smaller', () { final r = resolveVideoSettings( adaptiveStreamDimensions: VideoDimensions(1920, 1080), - requestedDimensions: VideoDimensions(640, 360), + userPreference: VideoSettings.dimensions(VideoDimensions(640, 360)), ); expect(r.dimensions, VideoDimensions(640, 360)); }); - test('equal areas keep requested', () { + test('equal areas keep preferred', () { final r = resolveVideoSettings( adaptiveStreamDimensions: VideoDimensions(640, 360), - requestedDimensions: VideoDimensions(640, 360), + userPreference: VideoSettings.dimensions(VideoDimensions(640, 360)), ); expect(r.dimensions, VideoDimensions(640, 360)); }); }); - group('adaptive stream + requested quality', () { + group('adaptive stream + preferred quality', () { test('adaptive wins when smaller than quality layer', () { final r = resolveVideoSettings( adaptiveStreamDimensions: VideoDimensions(320, 180), - requestedMaxQuality: VideoQuality.HIGH, + userPreference: VideoSettings.quality(VideoQuality.HIGH), layerDimensionsForQuality: _testLayerDimensions, ); // adaptive 320*180 < HIGH 1280*720 → sends adaptive dimensions @@ -104,7 +104,7 @@ void main() { test('quality wins when adaptive is larger than quality layer', () { final r = resolveVideoSettings( adaptiveStreamDimensions: VideoDimensions(1920, 1080), - requestedMaxQuality: VideoQuality.LOW, + userPreference: VideoSettings.quality(VideoQuality.LOW), layerDimensionsForQuality: _testLayerDimensions, ); // adaptive 1920*1080 > LOW 320*180 → sends quality directly @@ -115,8 +115,7 @@ void main() { test('quality sent directly when no layer info available', () { final r = resolveVideoSettings( adaptiveStreamDimensions: VideoDimensions(320, 180), - requestedMaxQuality: VideoQuality.LOW, - // no layerDimensionsForQuality → can't compare + userPreference: VideoSettings.quality(VideoQuality.LOW), ); expect(r.quality, VideoQuality.LOW); expect(r.dimensions, isNull); @@ -125,7 +124,7 @@ void main() { test('quality sent when layer lookup returns null', () { final r = resolveVideoSettings( adaptiveStreamDimensions: VideoDimensions(320, 180), - requestedMaxQuality: VideoQuality.MEDIUM, + userPreference: VideoSettings.quality(VideoQuality.MEDIUM), layerDimensionsForQuality: (_) => null, ); expect(r.quality, VideoQuality.MEDIUM); From 46a62bcd55a06bf38c0ba9d74eec39924aef3f9a Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 29 May 2026 15:28:40 +0900 Subject: [PATCH 04/10] fix: cancel pending debounced visibility update on manual track update A manual setVideoQuality/Dimensions/FPS/enable/disable sent immediately via _emitTrackUpdate() but did not cancel a pending debounced visibility send. The debounce captured a stale settings snapshot that could fire ~1.5s later and overwrite the manual update; since the visibility send never updated _lastSentTrackSettings, the dedup gate then suppressed any re-correction, leaving the server permanently on stale settings. - _emitTrackUpdate() now cancels any pending debounced send before sending. - the debounced send re-builds from current state at fire time and updates _lastSentTrackSettings, so it can never deliver or wedge on stale settings. --- lib/src/publication/remote.dart | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/lib/src/publication/remote.dart b/lib/src/publication/remote.dart index aa74cb87d..2043f3728 100644 --- a/lib/src/publication/remote.dart +++ b/lib/src/publication/remote.dart @@ -186,7 +186,13 @@ class RemoteTrackPublication extends TrackPublication } } - void _sendPendingTrackSettingsUpdateRequest(lk_rtc.UpdateTrackSettings settings) { + void _sendPendingTrackSettingsUpdateRequest(lk_rtc.UpdateTrackSettings _) { + // Re-build from the current state at fire time instead of replaying the + // snapshot captured when the debounce was scheduled. Otherwise a stale + // snapshot could be sent after newer state (e.g. a manual setVideoQuality) + // has already been applied, clobbering it. + final settings = _buildTrackSettings(); + _lastSentTrackSettings = settings; logger.fine('[Visibility] Sending... ${settings.toProto3Json()}'); participant.room.engine.signalClient.sendUpdateTrackSettings(settings); } @@ -372,6 +378,9 @@ class RemoteTrackPublication extends TrackPublication } void _emitTrackUpdate() { + // Cancel any pending debounced visibility update so its (now potentially + // stale) snapshot cannot fire after — and clobber — this immediate update. + _cancelPendingTrackSettingsUpdateRequest?.call(); final settings = _buildTrackSettings(); _lastSentTrackSettings = settings; participant.room.engine.signalClient.sendUpdateTrackSettings(settings); From 059317ecb3eb5f4e984ddd5941455f8e40877fa8 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 29 May 2026 15:30:14 +0900 Subject: [PATCH 05/10] fix: let explicit enable/disable override adaptive-stream visibility Mirrors the JS SDK tri-state. Previously `disabled` was computed as `!_enabled || !_adaptiveStreamEnabled`, an OR that meant an explicit enable() could never keep an off-screen track streaming when adaptive stream was on. - Replace the plain bool _enabled with a tri-state bool? _requestedDisabled (null = no explicit request); an explicit enable()/disable() now takes precedence over visibility, matching JS isEnabled. - Decouple the misnamed _adaptiveStreamEnabled into _adaptiveStreamActive (feature active for this pub) and _adaptiveStreamVisible (views visible). - Extract the precedence into a pure resolveDisabled() in track_settings.dart. --- lib/src/publication/remote.dart | 36 +++++++++++++++++-------- lib/src/publication/track_settings.dart | 17 ++++++++++++ 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/lib/src/publication/remote.dart b/lib/src/publication/remote.dart index 2043f3728..9c61a0e9a 100644 --- a/lib/src/publication/remote.dart +++ b/lib/src/publication/remote.dart @@ -42,8 +42,12 @@ class RemoteTrackPublication extends TrackPublication @override final RemoteParticipant participant; - bool get enabled => _enabled; - bool _enabled = true; + bool get enabled => !(_requestedDisabled ?? false); + + /// Whether the user has explicitly requested this track enabled/disabled via + /// [enable] / [disable]. `null` means no explicit request, in which case + /// adaptive-stream visibility decides. Takes precedence over visibility. + bool? _requestedDisabled; /// The current desired FPS of the track. This is only available for video tracks that support SVC. int? _fps; @@ -54,7 +58,11 @@ class RemoteTrackPublication extends TrackPublication // Adaptive stream state (set automatically by visibility observer) VideoDimensions? _adaptiveStreamDimensions; - bool _adaptiveStreamEnabled = true; + // Whether adaptive stream is active for this publication (room option on + + // remote video track). When false, view visibility never gates `disabled`. + bool _adaptiveStreamActive = false; + // Whether at least one view of this track is currently visible/sized. + bool _adaptiveStreamVisible = true; VideoQuality get videoQuality => _userPreference?.quality ?? VideoQuality.HIGH; VideoDimensions? get videoDimensions => _userPreference?.dimensions; @@ -166,10 +174,10 @@ class RemoteTrackPublication extends TrackPublication largestSize.width.ceil(), largestSize.height.ceil(), ); - _adaptiveStreamEnabled = true; + _adaptiveStreamVisible = true; } else { _adaptiveStreamDimensions = null; - _adaptiveStreamEnabled = false; + _adaptiveStreamVisible = false; } final settings = _buildTrackSettings(); @@ -210,6 +218,7 @@ class RemoteTrackPublication extends TrackPublication final roomOptions = participant.room.roomOptions; if (roomOptions.adaptiveStream && newValue is RemoteVideoTrack) { + _adaptiveStreamActive = true; // Start monitoring visibility _visibilityTimer = Timer.periodic( const Duration(milliseconds: 300), @@ -224,6 +233,8 @@ class RemoteTrackPublication extends TrackPublication _computeVideoViewVisibility(quick: true); } }; + } else { + _adaptiveStreamActive = false; } if (newValue != null) { @@ -292,14 +303,14 @@ class RemoteTrackPublication extends TrackPublication } Future enable() async { - if (_enabled) return; - _enabled = true; + if (_requestedDisabled == false) return; + _requestedDisabled = false; _emitTrackUpdate(); } Future disable() async { - if (!_enabled) return; - _enabled = false; + if (_requestedDisabled == true) return; + _requestedDisabled = true; _emitTrackUpdate(); } @@ -346,8 +357,11 @@ class RemoteTrackPublication extends TrackPublication } lk_rtc.UpdateTrackSettings _buildTrackSettings() { - // disabled if manually disabled or adaptive stream says no views visible - final isDisabled = !_enabled || !_adaptiveStreamEnabled; + final isDisabled = resolveDisabled( + requestedDisabled: _requestedDisabled, + adaptiveStreamActive: _adaptiveStreamActive, + adaptiveStreamVisible: _adaptiveStreamVisible, + ); final settings = lk_rtc.UpdateTrackSettings( trackSids: [sid], diff --git a/lib/src/publication/track_settings.dart b/lib/src/publication/track_settings.dart index 0853969a0..b86d30b06 100644 --- a/lib/src/publication/track_settings.dart +++ b/lib/src/publication/track_settings.dart @@ -70,3 +70,20 @@ VideoSettings resolveVideoSettings({ } return VideoSettings.high; } + +/// Resolves whether a subscribed track should be sent as `disabled`. +/// +/// Mirrors the JS SDK's `isEnabled` precedence: an explicit user +/// enable/disable ([requestedDisabled] non-null) always wins; otherwise, when +/// adaptive stream is active for the track, view visibility decides; otherwise +/// the track is enabled. +@internal +bool resolveDisabled({ + bool? requestedDisabled, + required bool adaptiveStreamActive, + required bool adaptiveStreamVisible, +}) { + if (requestedDisabled != null) return requestedDisabled; + if (adaptiveStreamActive) return !adaptiveStreamVisible; + return false; +} From 37ba69b300f234cecfb59477d1264955693ed8e7 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 29 May 2026 15:30:31 +0900 Subject: [PATCH 06/10] refactor: remove broken @Deprecated on sendUpdateTrackSettings The annotation pointed callers at the private _emitTrackUpdate (unusable externally), contradicted the @internal annotation, and was suppressed for the only same-package caller (room.dart) by analysis_options anyway. Keep it as a plain @internal shim. --- lib/src/publication/remote.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/src/publication/remote.dart b/lib/src/publication/remote.dart index 9c61a0e9a..743d00eff 100644 --- a/lib/src/publication/remote.dart +++ b/lib/src/publication/remote.dart @@ -401,7 +401,6 @@ class RemoteTrackPublication extends TrackPublication } @internal - @Deprecated('Use _emitTrackUpdate instead') void sendUpdateTrackSettings() => _emitTrackUpdate(); @internal From b3b18a03c0dec525d4ba0a7deef544fa2391ae24 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 29 May 2026 15:30:51 +0900 Subject: [PATCH 07/10] docs: correct setVideoQuality/Dimensions adaptive-stream merge wording The merge is performed client-side (resolveVideoSettings), and only the single resolved value is sent; the server does not receive both and pick the smaller. --- lib/src/publication/remote.dart | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lib/src/publication/remote.dart b/lib/src/publication/remote.dart index 743d00eff..92243a68c 100644 --- a/lib/src/publication/remote.dart +++ b/lib/src/publication/remote.dart @@ -270,8 +270,9 @@ class RemoteTrackPublication extends TrackPublication /// bandwidth does not allow, the server will automatically reduce quality to /// optimize for uninterrupted video. /// - /// When adaptive stream is enabled, the server will use the smaller of - /// this setting and the adaptive stream dimensions. + /// When adaptive stream is active, this preference is merged client-side with + /// the dimensions computed from the visible views, and the smaller (more + /// conservative) of the two is sent to the server. Future setVideoQuality(VideoQuality newValue) async { if (newValue == _userPreference?.quality) return; if (!_isManualOperationAllowed()) return; @@ -284,8 +285,9 @@ class RemoteTrackPublication extends TrackPublication /// Server will choose the appropriate layer based on these dimensions. /// Will override previous calls to [setVideoQuality]. /// - /// When adaptive stream is enabled, the server will use the smaller of - /// this setting and the adaptive stream dimensions. + /// When adaptive stream is active, this preference is merged client-side with + /// the dimensions computed from the visible views, and the smaller (more + /// conservative) of the two is sent to the server. Future setVideoDimensions(VideoDimensions newValue) async { if (newValue == _userPreference?.dimensions) return; if (!_isManualOperationAllowed()) return; From d802eeb33f87437cac7b5eb8500c82828d07d642 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 29 May 2026 15:31:14 +0900 Subject: [PATCH 08/10] test: make equal-area tie-break test actually exercise strict < The existing 'equal areas keep preferred' test passed identical dimensions as both adaptive and preferred, so the assertion held regardless of which branch ran and could not catch a < -> <= regression. Use distinct same-area dimensions (720x320 vs 640x360), and add a one-px-smaller case where adaptive should win. --- test/publication/track_settings_test.dart | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/test/publication/track_settings_test.dart b/test/publication/track_settings_test.dart index b85336dd9..993813eac 100644 --- a/test/publication/track_settings_test.dart +++ b/test/publication/track_settings_test.dart @@ -81,12 +81,24 @@ void main() { }); test('equal areas keep preferred', () { + // 720*320 == 640*360 == 230400. Distinct dimensions with equal area + // so the assertion can actually distinguish strict `<` (keep preferred) + // from `<=` (switch to adaptive), matching JS areDimensionsSmaller. final r = resolveVideoSettings( - adaptiveStreamDimensions: VideoDimensions(640, 360), + adaptiveStreamDimensions: VideoDimensions(720, 320), userPreference: VideoSettings.dimensions(VideoDimensions(640, 360)), ); expect(r.dimensions, VideoDimensions(640, 360)); }); + + test('adaptive wins when area is one smaller', () { + // 639*360 = 230040 < 640*360 = 230400, so adaptive is strictly smaller. + final r = resolveVideoSettings( + adaptiveStreamDimensions: VideoDimensions(639, 360), + userPreference: VideoSettings.dimensions(VideoDimensions(640, 360)), + ); + expect(r.dimensions, VideoDimensions(639, 360)); + }); }); group('adaptive stream + preferred quality', () { From 8d82c7e26471fb21dc55fe867cc69d5b222c2dc2 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 29 May 2026 15:38:46 +0900 Subject: [PATCH 09/10] test: extract buildUpdateTrackSettings and cover disabled + proto build _buildTrackSettings had no coverage for the disabled computation, fps passthrough, or the proto field mapping. Extract the proto assembly into a pure buildUpdateTrackSettings() (alongside the new resolveDisabled()) so both are unit-testable without a live RemoteTrackPublication, and add tests for the tri-state disabled precedence and the dimensions/quality/fps mapping. --- lib/src/publication/remote.dart | 44 +++++----- lib/src/publication/track_settings.dart | 28 ++++++ test/publication/track_settings_test.dart | 102 ++++++++++++++++++++++ 3 files changed, 150 insertions(+), 24 deletions(-) diff --git a/lib/src/publication/remote.dart b/lib/src/publication/remote.dart index 92243a68c..e727cd9cb 100644 --- a/lib/src/publication/remote.dart +++ b/lib/src/publication/remote.dart @@ -365,32 +365,28 @@ class RemoteTrackPublication extends TrackPublication adaptiveStreamVisible: _adaptiveStreamVisible, ); - final settings = lk_rtc.UpdateTrackSettings( - trackSids: [sid], - disabled: isDisabled, - ); + if (kind != TrackType.VIDEO) { + return buildUpdateTrackSettings(sid: sid, disabled: isDisabled); + } - if (kind == TrackType.VIDEO) { - final resolved = resolveVideoSettings( - adaptiveStreamDimensions: _adaptiveStreamDimensions, - userPreference: _userPreference, - layerDimensionsForQuality: (quality) { - final pbQuality = quality.toPBType(); - final layer = latestInfo?.layers.where((l) => l.quality == pbQuality).firstOrNull; - if (layer == null) return null; - return VideoDimensions(layer.width, layer.height); - }, - ); + final resolved = resolveVideoSettings( + adaptiveStreamDimensions: _adaptiveStreamDimensions, + userPreference: _userPreference, + layerDimensionsForQuality: (quality) { + final pbQuality = quality.toPBType(); + final layer = latestInfo?.layers.where((l) => l.quality == pbQuality).firstOrNull; + if (layer == null) return null; + return VideoDimensions(layer.width, layer.height); + }, + ); - if (resolved.dimensions != null) { - settings.width = resolved.dimensions!.width; - settings.height = resolved.dimensions!.height; - } else if (resolved.quality != null) { - settings.quality = resolved.quality!.toPBType(); - } - if (_fps != null) settings.fps = _fps!; - } - return settings; + return buildUpdateTrackSettings( + sid: sid, + disabled: isDisabled, + dimensions: resolved.dimensions, + quality: resolved.quality?.toPBType(), + fps: _fps, + ); } void _emitTrackUpdate() { diff --git a/lib/src/publication/track_settings.dart b/lib/src/publication/track_settings.dart index b86d30b06..f57f7e26b 100644 --- a/lib/src/publication/track_settings.dart +++ b/lib/src/publication/track_settings.dart @@ -14,6 +14,8 @@ import 'package:meta/meta.dart' show immutable, internal; +import '../proto/livekit_models.pb.dart' as lk_models; +import '../proto/livekit_rtc.pb.dart' as lk_rtc; import '../types/other.dart'; import '../types/video_dimensions.dart'; @@ -87,3 +89,29 @@ bool resolveDisabled({ if (adaptiveStreamActive) return !adaptiveStreamVisible; return false; } + +/// Builds the [lk_rtc.UpdateTrackSettings] request sent to the server from the +/// already-resolved [disabled] flag and, for video, the resolved [dimensions] +/// or [quality] plus an optional [fps]. [dimensions] takes precedence over +/// [quality]; pass neither for non-video tracks. +@internal +lk_rtc.UpdateTrackSettings buildUpdateTrackSettings({ + required String sid, + required bool disabled, + VideoDimensions? dimensions, + lk_models.VideoQuality? quality, + int? fps, +}) { + final settings = lk_rtc.UpdateTrackSettings( + trackSids: [sid], + disabled: disabled, + ); + if (dimensions != null) { + settings.width = dimensions.width; + settings.height = dimensions.height; + } else if (quality != null) { + settings.quality = quality; + } + if (fps != null) settings.fps = fps; + return settings; +} diff --git a/test/publication/track_settings_test.dart b/test/publication/track_settings_test.dart index 993813eac..f123aac8f 100644 --- a/test/publication/track_settings_test.dart +++ b/test/publication/track_settings_test.dart @@ -14,6 +14,7 @@ import 'package:flutter_test/flutter_test.dart'; +import 'package:livekit_client/src/proto/livekit_models.pb.dart' as lk_models; import 'package:livekit_client/src/publication/track_settings.dart'; import 'package:livekit_client/src/types/other.dart'; import 'package:livekit_client/src/types/video_dimensions.dart'; @@ -144,4 +145,105 @@ void main() { }); }); }); + + group('resolveDisabled', () { + test('not disabled by default (no explicit request, adaptive inactive)', () { + expect( + resolveDisabled(adaptiveStreamActive: false, adaptiveStreamVisible: true), + isFalse, + ); + }); + + test('explicit disable wins even when visible', () { + expect( + resolveDisabled( + requestedDisabled: true, + adaptiveStreamActive: true, + adaptiveStreamVisible: true, + ), + isTrue, + ); + }); + + test('explicit enable wins even when not visible (JS tri-state parity)', () { + expect( + resolveDisabled( + requestedDisabled: false, + adaptiveStreamActive: true, + adaptiveStreamVisible: false, + ), + isFalse, + ); + }); + + test('adaptive visibility decides when no explicit request', () { + expect( + resolveDisabled(adaptiveStreamActive: true, adaptiveStreamVisible: true), + isFalse, + ); + expect( + resolveDisabled(adaptiveStreamActive: true, adaptiveStreamVisible: false), + isTrue, + ); + }); + + test('visibility is ignored when adaptive stream is inactive', () { + expect( + resolveDisabled(adaptiveStreamActive: false, adaptiveStreamVisible: false), + isFalse, + ); + }); + }); + + group('buildUpdateTrackSettings', () { + test('sets sid and disabled flag', () { + final s = buildUpdateTrackSettings(sid: 'TR_abc', disabled: true); + expect(s.trackSids, ['TR_abc']); + expect(s.disabled, isTrue); + expect(s.hasWidth(), isFalse); + expect(s.hasQuality(), isFalse); + expect(s.hasFps(), isFalse); + }); + + test('dimensions are written, quality is not', () { + final s = buildUpdateTrackSettings( + sid: 'TR_abc', + disabled: false, + dimensions: VideoDimensions(640, 360), + quality: lk_models.VideoQuality.LOW, + ); + expect(s.width, 640); + expect(s.height, 360); + expect(s.hasQuality(), isFalse); + }); + + test('quality is written when no dimensions', () { + final s = buildUpdateTrackSettings( + sid: 'TR_abc', + disabled: false, + quality: lk_models.VideoQuality.HIGH, + ); + expect(s.quality, lk_models.VideoQuality.HIGH); + expect(s.hasWidth(), isFalse); + expect(s.hasHeight(), isFalse); + }); + + test('fps is forwarded when set and omitted when null', () { + final withFps = buildUpdateTrackSettings( + sid: 'TR_abc', + disabled: false, + quality: lk_models.VideoQuality.HIGH, + fps: 30, + ); + expect(withFps.hasFps(), isTrue); + expect(withFps.fps, 30); + + final withoutFps = buildUpdateTrackSettings( + sid: 'TR_abc', + disabled: false, + quality: lk_models.VideoQuality.HIGH, + ); + expect(withoutFps.hasFps(), isFalse); + }); + }); } From b3cd8e5046a77b5b9de174230d9bde5ca29f09d6 Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Fri, 29 May 2026 15:45:18 +0900 Subject: [PATCH 10/10] refactor: model enable/disable tri-state as an internal enum Replace the nullable bool _requestedDisabled (null/false/true) with an @internal TrackEnabledPreference { unset, enabled, disabled } enum. Removes the double-negative (requestedDisabled = false meaning 'enabled') and makes the precedence in resolveDisabled() read as an explicit switch. Behavior is unchanged; the enum is internal-only (non-exported file, @internal). --- lib/src/publication/remote.dart | 21 ++++++++------- lib/src/publication/track_settings.dart | 33 ++++++++++++++++++----- test/publication/track_settings_test.dart | 32 ++++++++++++++++------ 3 files changed, 61 insertions(+), 25 deletions(-) diff --git a/lib/src/publication/remote.dart b/lib/src/publication/remote.dart index e727cd9cb..3d2ceb46d 100644 --- a/lib/src/publication/remote.dart +++ b/lib/src/publication/remote.dart @@ -42,12 +42,13 @@ class RemoteTrackPublication extends TrackPublication @override final RemoteParticipant participant; - bool get enabled => !(_requestedDisabled ?? false); + bool get enabled => _enabledPreference != TrackEnabledPreference.disabled; - /// Whether the user has explicitly requested this track enabled/disabled via - /// [enable] / [disable]. `null` means no explicit request, in which case - /// adaptive-stream visibility decides. Takes precedence over visibility. - bool? _requestedDisabled; + /// The user's explicit enable/disable request via [enable] / [disable]. + /// [TrackEnabledPreference.unset] means no explicit request, in which case + /// adaptive-stream visibility decides. An explicit request takes precedence + /// over visibility. + TrackEnabledPreference _enabledPreference = TrackEnabledPreference.unset; /// The current desired FPS of the track. This is only available for video tracks that support SVC. int? _fps; @@ -305,14 +306,14 @@ class RemoteTrackPublication extends TrackPublication } Future enable() async { - if (_requestedDisabled == false) return; - _requestedDisabled = false; + if (_enabledPreference == TrackEnabledPreference.enabled) return; + _enabledPreference = TrackEnabledPreference.enabled; _emitTrackUpdate(); } Future disable() async { - if (_requestedDisabled == true) return; - _requestedDisabled = true; + if (_enabledPreference == TrackEnabledPreference.disabled) return; + _enabledPreference = TrackEnabledPreference.disabled; _emitTrackUpdate(); } @@ -360,7 +361,7 @@ class RemoteTrackPublication extends TrackPublication lk_rtc.UpdateTrackSettings _buildTrackSettings() { final isDisabled = resolveDisabled( - requestedDisabled: _requestedDisabled, + enabledPreference: _enabledPreference, adaptiveStreamActive: _adaptiveStreamActive, adaptiveStreamVisible: _adaptiveStreamVisible, ); diff --git a/lib/src/publication/track_settings.dart b/lib/src/publication/track_settings.dart index f57f7e26b..08a054cdc 100644 --- a/lib/src/publication/track_settings.dart +++ b/lib/src/publication/track_settings.dart @@ -73,21 +73,40 @@ VideoSettings resolveVideoSettings({ return VideoSettings.high; } +/// The user's explicit enable/disable request for a track, used to decide +/// whether visibility may gate the track. Equivalent to the JS SDK's +/// `requestedDisabled` tri-state (`undefined` / `false` / `true`). +@internal +enum TrackEnabledPreference { + /// No explicit request; adaptive-stream visibility decides. + unset, + + /// User explicitly enabled; overrides visibility (track keeps streaming). + enabled, + + /// User explicitly disabled; overrides visibility (track stays off). + disabled, +} + /// Resolves whether a subscribed track should be sent as `disabled`. /// /// Mirrors the JS SDK's `isEnabled` precedence: an explicit user -/// enable/disable ([requestedDisabled] non-null) always wins; otherwise, when -/// adaptive stream is active for the track, view visibility decides; otherwise -/// the track is enabled. +/// enable/disable always wins; otherwise, when adaptive stream is active for +/// the track, view visibility decides; otherwise the track is enabled. @internal bool resolveDisabled({ - bool? requestedDisabled, + required TrackEnabledPreference enabledPreference, required bool adaptiveStreamActive, required bool adaptiveStreamVisible, }) { - if (requestedDisabled != null) return requestedDisabled; - if (adaptiveStreamActive) return !adaptiveStreamVisible; - return false; + switch (enabledPreference) { + case TrackEnabledPreference.enabled: + return false; + case TrackEnabledPreference.disabled: + return true; + case TrackEnabledPreference.unset: + return adaptiveStreamActive ? !adaptiveStreamVisible : false; + } } /// Builds the [lk_rtc.UpdateTrackSettings] request sent to the server from the diff --git a/test/publication/track_settings_test.dart b/test/publication/track_settings_test.dart index f123aac8f..e63f6a581 100644 --- a/test/publication/track_settings_test.dart +++ b/test/publication/track_settings_test.dart @@ -147,9 +147,13 @@ void main() { }); group('resolveDisabled', () { - test('not disabled by default (no explicit request, adaptive inactive)', () { + test('not disabled by default (unset preference, adaptive inactive)', () { expect( - resolveDisabled(adaptiveStreamActive: false, adaptiveStreamVisible: true), + resolveDisabled( + enabledPreference: TrackEnabledPreference.unset, + adaptiveStreamActive: false, + adaptiveStreamVisible: true, + ), isFalse, ); }); @@ -157,7 +161,7 @@ void main() { test('explicit disable wins even when visible', () { expect( resolveDisabled( - requestedDisabled: true, + enabledPreference: TrackEnabledPreference.disabled, adaptiveStreamActive: true, adaptiveStreamVisible: true, ), @@ -168,7 +172,7 @@ void main() { test('explicit enable wins even when not visible (JS tri-state parity)', () { expect( resolveDisabled( - requestedDisabled: false, + enabledPreference: TrackEnabledPreference.enabled, adaptiveStreamActive: true, adaptiveStreamVisible: false, ), @@ -176,20 +180,32 @@ void main() { ); }); - test('adaptive visibility decides when no explicit request', () { + test('adaptive visibility decides when preference is unset', () { expect( - resolveDisabled(adaptiveStreamActive: true, adaptiveStreamVisible: true), + resolveDisabled( + enabledPreference: TrackEnabledPreference.unset, + adaptiveStreamActive: true, + adaptiveStreamVisible: true, + ), isFalse, ); expect( - resolveDisabled(adaptiveStreamActive: true, adaptiveStreamVisible: false), + resolveDisabled( + enabledPreference: TrackEnabledPreference.unset, + adaptiveStreamActive: true, + adaptiveStreamVisible: false, + ), isTrue, ); }); test('visibility is ignored when adaptive stream is inactive', () { expect( - resolveDisabled(adaptiveStreamActive: false, adaptiveStreamVisible: false), + resolveDisabled( + enabledPreference: TrackEnabledPreference.unset, + adaptiveStreamActive: false, + adaptiveStreamVisible: false, + ), isFalse, ); });