@@ -33,6 +33,7 @@ import '../types/other.dart';
3333import '../types/video_dimensions.dart' ;
3434import '../utils.dart' ;
3535import '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