Skip to content

Commit ead71ca

Browse files
authored
Merge manual video quality/dimensions with adaptive stream (#1047)
## Summary Previously, `setVideoQuality` / `setVideoDimensions` on a remote track were **ignored** (with a warning) whenever `adaptiveStream` was enabled. This PR adopts the JS SDK's approach instead: the manual preference is **merged** client-side with the dimensions derived from the visible views, and the **smaller (more conservative)** of the two is sent to the server. Along the way it fixes a few related correctness issues in the adaptive-stream path. The logic mirrors `client-sdk-js`'s `RemoteTrackPublication` (`isEnabled`, `emitTrackUpdate`, `areDimensionsSmaller`, `layerDimensionsFor`). ## Changes **Manual quality + adaptive stream merge** - `setVideoQuality` / `setVideoDimensions` / `setVideoFPS` are no longer rejected when adaptive stream is on; the request is merged with the visibility-derived dimensions, smaller area wins (matching JS `areDimensionsSmaller`, strict `<`). - When only a quality is requested, it's compared against that quality's simulcast layer dimensions before deciding which to send. **Explicit enable/disable overrides visibility** - Enable/disable state is modeled as an internal tri-state (`TrackEnabledPreference`: unset / enabled / disabled), mirroring JS's `requestedDisabled`. - An explicit `enable()` / `disable()` now always wins over adaptive-stream visibility. Previously the visibility timer computed `disabled` independently and silently ignored the user's request. **Debounce correctness** - A manual update cancels any pending debounced visibility update, and the debounced send rebuilds settings from current state at fire time — so a stale snapshot can't clobber a newer manual update. **Misc** - `fps` is now preserved across adaptive-stream visibility updates (previously dropped). - Adaptive-stream visibility state is reset when the track changes, so stale dimensions can't leak into a later update. - Merge/disable/build logic extracted into pure functions (`resolveVideoSettings`, `resolveDisabled`, `buildUpdateTrackSettings`) in `track_settings.dart`. ## Tests - Unit tests for the pure resolution/build logic: merge precedence, equal-area tie-break, tri-state disable, and proto building. - Publication-level test asserting the `UpdateTrackSettings` actually sent to the signal client for `enable()` / `disable()`.
1 parent d83f272 commit ead71ca

5 files changed

Lines changed: 867 additions & 59 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="improved" "Allow manual video quality selection with adaptive stream enabled"

lib/src/publication/remote.dart

Lines changed: 122 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import '../types/other.dart';
3333
import '../types/video_dimensions.dart';
3434
import '../utils.dart';
3535
import 'track_publication.dart';
36+
import 'track_settings.dart';
3637

3738
/// Represents a track publication from a RemoteParticipant. Provides methods to
3839
/// control if we should subscribe to the track, and its quality (for video).
@@ -41,18 +42,35 @@ class RemoteTrackPublication<T extends RemoteTrack> extends TrackPublication<T>
4142
@override
4243
final RemoteParticipant participant;
4344

44-
bool get enabled => _enabled;
45-
bool _enabled = true;
45+
bool get enabled => !resolveDisabled(
46+
enabledPreference: _enabledPreference,
47+
adaptiveStreamActive: _adaptiveStreamActive,
48+
adaptiveStreamVisible: _adaptiveStreamVisible,
49+
);
50+
51+
/// The user's explicit enable/disable request via [enable] / [disable].
52+
/// [TrackEnabledPreference.unset] means no explicit request, in which case
53+
/// adaptive-stream visibility decides. An explicit request takes precedence
54+
/// over visibility.
55+
TrackEnabledPreference _enabledPreference = TrackEnabledPreference.unset;
4656

4757
/// The current desired FPS of the track. This is only available for video tracks that support SVC.
4858
int? _fps;
4959
int get fps => _fps ?? 0;
5060

51-
VideoQuality? _videoQuality = VideoQuality.HIGH;
52-
VideoQuality get videoQuality => _videoQuality ?? VideoQuality.HIGH;
61+
// Manual settings (set by user via setVideoQuality / setVideoDimensions)
62+
VideoSettings? _userPreference;
63+
64+
// Adaptive stream state (set automatically by visibility observer)
65+
VideoDimensions? _adaptiveStreamDimensions;
66+
// Whether adaptive stream is active for this publication (room option on +
67+
// remote video track). When false, view visibility never gates `disabled`.
68+
bool _adaptiveStreamActive = false;
69+
// Whether at least one view of this track is currently visible/sized.
70+
bool _adaptiveStreamVisible = true;
5371

54-
VideoDimensions? _videoDimensions;
55-
VideoDimensions? get videoDimensions => _videoDimensions;
72+
VideoQuality get videoQuality => _userPreference?.quality ?? VideoQuality.HIGH;
73+
VideoDimensions? get videoDimensions => _userPreference?.dimensions;
5674

5775
/// The server may pause the track when they are bandwidth limitations and resume
5876
/// when there is more capacity. This property will be updated when the track is
@@ -144,11 +162,6 @@ class RemoteTrackPublication<T extends RemoteTrack> extends TrackPublication<T>
144162

145163
final videoTrack = track as VideoTrack;
146164

147-
final settings = lk_rtc.UpdateTrackSettings(
148-
trackSids: [sid],
149-
disabled: true,
150-
);
151-
152165
// filter visible build contexts
153166
final viewSizes = videoTrack.viewKeys
154167
.map((e) => e.currentContext)
@@ -161,15 +174,19 @@ class RemoteTrackPublication<T extends RemoteTrack> extends TrackPublication<T>
161174
logger.finer('[Visibility] ${track?.sid} watching ${viewSizes.length} views...');
162175

163176
if (viewSizes.isNotEmpty) {
164-
// compute largest size
165177
final largestSize = viewSizes.reduce((value, element) => maxOfSizes(value, element));
166-
167-
settings
168-
..disabled = false
169-
..width = largestSize.width.ceil()
170-
..height = largestSize.height.ceil();
178+
_adaptiveStreamDimensions = VideoDimensions(
179+
largestSize.width.ceil(),
180+
largestSize.height.ceil(),
181+
);
182+
_adaptiveStreamVisible = true;
183+
} else {
184+
_adaptiveStreamDimensions = null;
185+
_adaptiveStreamVisible = false;
171186
}
172187

188+
final settings = _buildTrackSettings();
189+
173190
// Only send new settings to server if it changed
174191
if (settings != _lastSentTrackSettings) {
175192
_lastSentTrackSettings = settings;
@@ -182,7 +199,13 @@ class RemoteTrackPublication<T extends RemoteTrack> extends TrackPublication<T>
182199
}
183200
}
184201

185-
void _sendPendingTrackSettingsUpdateRequest(lk_rtc.UpdateTrackSettings settings) {
202+
void _sendPendingTrackSettingsUpdateRequest(lk_rtc.UpdateTrackSettings _) {
203+
// Re-build from the current state at fire time instead of replaying the
204+
// snapshot captured when the debounce was scheduled. Otherwise a stale
205+
// snapshot could be sent after newer state (e.g. a manual setVideoQuality)
206+
// has already been applied, clobbering it.
207+
final settings = _buildTrackSettings();
208+
_lastSentTrackSettings = settings;
186209
logger.fine('[Visibility] Sending... ${settings.toProto3Json()}');
187210
participant.room.engine.signalClient.sendUpdateTrackSettings(settings);
188211
}
@@ -198,8 +221,17 @@ class RemoteTrackPublication<T extends RemoteTrack> extends TrackPublication<T>
198221
_cancelPendingTrackSettingsUpdateRequest?.call();
199222
_visibilityTimer?.cancel();
200223

224+
// The track changed, so any adaptive-stream visibility computed for the
225+
// previous track is stale. Reset to the construction defaults so it can't
226+
// leak into a later _buildTrackSettings (e.g. via enable() / disable(),
227+
// which emit regardless of visibility). Repopulated by the visibility
228+
// observer below while adaptive stream is active.
229+
_adaptiveStreamDimensions = null;
230+
_adaptiveStreamVisible = true;
231+
201232
final roomOptions = participant.room.roomOptions;
202233
if (roomOptions.adaptiveStream && newValue is RemoteVideoTrack) {
234+
_adaptiveStreamActive = true;
203235
// Start monitoring visibility
204236
_visibilityTimer = Timer.periodic(
205237
const Duration(milliseconds: 300),
@@ -214,6 +246,10 @@ class RemoteTrackPublication<T extends RemoteTrack> extends TrackPublication<T>
214246
_computeVideoViewVisibility(quick: true);
215247
}
216248
};
249+
250+
_computeVideoViewVisibility(quick: true);
251+
} else {
252+
_adaptiveStreamActive = false;
217253
}
218254

219255
if (newValue != null) {
@@ -229,7 +265,7 @@ class RemoteTrackPublication<T extends RemoteTrack> extends TrackPublication<T>
229265
return didUpdate;
230266
}
231267

232-
bool _canUpdateManualVideoSettings() {
268+
bool _isManualOperationAllowed() {
233269
if (kind != TrackType.VIDEO) {
234270
logger.warning('Manual video setting updates are only supported for video tracks');
235271
return false;
@@ -240,55 +276,59 @@ class RemoteTrackPublication<T extends RemoteTrack> extends TrackPublication<T>
240276
return false;
241277
}
242278

243-
if (participant.room.roomOptions.adaptiveStream) {
244-
logger.warning('Manual video setting update ignored because adaptive stream is enabled');
245-
return false;
246-
}
247-
248279
return true;
249280
}
250281

282+
/// For tracks that support simulcasting, adjust subscribed quality.
283+
///
284+
/// This indicates the highest quality the client can accept. If network
285+
/// bandwidth does not allow, the server will automatically reduce quality to
286+
/// optimize for uninterrupted video.
287+
///
288+
/// When adaptive stream is active, this preference is merged client-side with
289+
/// the dimensions computed from the visible views, and the smaller (more
290+
/// conservative) of the two is sent to the server.
251291
Future<void> setVideoQuality(VideoQuality newValue) async {
252-
if (newValue == _videoQuality) return;
253-
if (!_canUpdateManualVideoSettings()) return;
254-
_videoQuality = newValue;
255-
_videoDimensions = null;
256-
sendUpdateTrackSettings();
292+
if (newValue == _userPreference?.quality) return;
293+
if (!_isManualOperationAllowed()) return;
294+
_userPreference = VideoSettings.quality(newValue);
295+
_emitTrackUpdate();
257296
}
258297

259298
/// Set preferred video dimensions for this track.
260299
///
261300
/// Server will choose the appropriate layer based on these dimensions.
262301
/// Will override previous calls to [setVideoQuality].
302+
///
303+
/// When adaptive stream is active, this preference is merged client-side with
304+
/// the dimensions computed from the visible views, and the smaller (more
305+
/// conservative) of the two is sent to the server.
263306
Future<void> setVideoDimensions(VideoDimensions newValue) async {
264-
if (newValue.width == _videoDimensions?.width && newValue.height == _videoDimensions?.height) {
265-
return;
266-
}
267-
if (!_canUpdateManualVideoSettings()) return;
268-
_videoDimensions = newValue;
269-
_videoQuality = null;
270-
sendUpdateTrackSettings();
307+
if (newValue == _userPreference?.dimensions) return;
308+
if (!_isManualOperationAllowed()) return;
309+
_userPreference = VideoSettings.dimensions(newValue);
310+
_emitTrackUpdate();
271311
}
272312

273313
/// Set desired FPS, server will do its best to return FPS close to this.
274314
/// It's only supported for video codecs that support SVC currently.
275315
Future<void> setVideoFPS(int newValue) async {
276316
if (newValue == _fps) return;
277-
if (!_canUpdateManualVideoSettings()) return;
317+
if (!_isManualOperationAllowed()) return;
278318
_fps = newValue;
279-
sendUpdateTrackSettings();
319+
_emitTrackUpdate();
280320
}
281321

282322
Future<void> enable() async {
283-
if (_enabled) return;
284-
_enabled = true;
285-
sendUpdateTrackSettings();
323+
if (_enabledPreference == TrackEnabledPreference.enabled) return;
324+
_enabledPreference = TrackEnabledPreference.enabled;
325+
_emitTrackUpdate();
286326
}
287327

288328
Future<void> disable() async {
289-
if (!_enabled) return;
290-
_enabled = false;
291-
sendUpdateTrackSettings();
329+
if (_enabledPreference == TrackEnabledPreference.disabled) return;
330+
_enabledPreference = TrackEnabledPreference.disabled;
331+
_emitTrackUpdate();
292332
}
293333

294334
Future<void> subscribe() async {
@@ -333,26 +373,49 @@ class RemoteTrackPublication<T extends RemoteTrack> extends TrackPublication<T>
333373
participant.room.engine.signalClient.sendUpdateSubscription(subscription);
334374
}
335375

336-
@internal
337-
void sendUpdateTrackSettings() {
338-
final settings = lk_rtc.UpdateTrackSettings(
339-
trackSids: [sid],
340-
disabled: !_enabled,
376+
lk_rtc.UpdateTrackSettings _buildTrackSettings() {
377+
final isDisabled = resolveDisabled(
378+
enabledPreference: _enabledPreference,
379+
adaptiveStreamActive: _adaptiveStreamActive,
380+
adaptiveStreamVisible: _adaptiveStreamVisible,
341381
);
342-
if (kind == TrackType.VIDEO) {
343-
if (_videoDimensions != null) {
344-
settings.width = _videoDimensions!.width;
345-
settings.height = _videoDimensions!.height;
346-
} else if (_videoQuality != null) {
347-
settings.quality = _videoQuality!.toPBType();
348-
} else {
349-
settings.quality = VideoQuality.HIGH.toPBType();
350-
}
351-
if (_fps != null) settings.fps = _fps!;
382+
383+
if (kind != TrackType.VIDEO) {
384+
return buildUpdateTrackSettings(sid: sid, disabled: isDisabled);
352385
}
386+
387+
final resolved = resolveVideoSettings(
388+
adaptiveStreamDimensions: _adaptiveStreamDimensions,
389+
userPreference: _userPreference,
390+
layerDimensionsForQuality: (quality) {
391+
final pbQuality = quality.toPBType();
392+
final layer = latestInfo?.layers.where((l) => l.quality == pbQuality).firstOrNull;
393+
if (layer == null) return null;
394+
return VideoDimensions(layer.width, layer.height);
395+
},
396+
);
397+
398+
return buildUpdateTrackSettings(
399+
sid: sid,
400+
disabled: isDisabled,
401+
dimensions: resolved.dimensions,
402+
quality: resolved.quality?.toPBType(),
403+
fps: _fps,
404+
);
405+
}
406+
407+
void _emitTrackUpdate() {
408+
// Cancel any pending debounced visibility update so its (now potentially
409+
// stale) snapshot cannot fire after — and clobber — this immediate update.
410+
_cancelPendingTrackSettingsUpdateRequest?.call();
411+
final settings = _buildTrackSettings();
412+
_lastSentTrackSettings = settings;
353413
participant.room.engine.signalClient.sendUpdateTrackSettings(settings);
354414
}
355415

416+
@internal
417+
void sendUpdateTrackSettings() => _emitTrackUpdate();
418+
356419
@internal
357420
// Update internal var and return true if changed
358421
Future<bool> updateSubscriptionAllowed(bool allowed) async {

0 commit comments

Comments
 (0)