Skip to content

Commit 1fdd82b

Browse files
committed
feat: account for display pixel density in adaptive stream
The adaptive-stream visibility observer fed each view's logical (DIP) size straight to the server, so on retina/HiDPI displays it under-requested by the device pixel ratio and the server returned an upscaled/soft layer. Server layers are sized in physical pixels. - Add AdaptiveStreamPixelDensity (auto | fixed(double)) mirroring the JS SDK's pixelDensity option, plus RoomOptions.adaptiveStreamPixelDensity (default: auto). Fractional densities are supported and the resolved value is capped at 3x to bound bandwidth. - In auto mode the view's real devicePixelRatio is read per-view via MediaQuery; fixed modes use a constant multiplier. - _computeVideoViewVisibility now scales each view's logical size by the resolved density before computing the largest requested dimensions.
1 parent b3cd8e5 commit 1fdd82b

4 files changed

Lines changed: 141 additions & 5 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="improved" "Account for display pixel density (retina) in adaptive stream dimensions"

lib/src/options.dart

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,48 @@ class ConnectOptions {
6969
});
7070
}
7171

72+
/// Controls how a subscribed video view's logical size is scaled to physical
73+
/// pixels when computing adaptive-stream dimensions. Mirrors the JS SDK's
74+
/// `pixelDensity` option (`number | 'screen'`).
75+
///
76+
/// Layers on the server are sized in physical pixels, so on high-density
77+
/// (retina) displays the logical view size under-represents the pixels needed.
78+
class AdaptiveStreamPixelDensity {
79+
/// Upper bound applied to the resolved density to keep bandwidth in check.
80+
static const maxDensity = 3.0;
81+
82+
/// Fixed multiplier, or `null` to use the view's device pixel ratio ([auto]).
83+
final double? value;
84+
85+
const AdaptiveStreamPixelDensity._(this.value);
86+
87+
/// Use the view's actual device pixel ratio, read via `MediaQuery`.
88+
/// Equivalent to the JS SDK's `'screen'` setting. Capped at [maxDensity].
89+
static const auto = AdaptiveStreamPixelDensity._(null);
90+
91+
/// A fixed pixel-density multiplier (fractional allowed, e.g. `1.5`, `2.0`,
92+
/// `2.75`). The effective value is capped at [maxDensity] (3x) when resolved.
93+
const AdaptiveStreamPixelDensity.fixed(double density) : value = density;
94+
95+
/// Resolves the effective multiplier, capped at [maxDensity]. For [auto],
96+
/// falls back to the supplied [devicePixelRatio].
97+
double resolve(double devicePixelRatio) {
98+
final density = value ?? devicePixelRatio;
99+
return density > maxDensity ? maxDensity : density;
100+
}
101+
102+
@override
103+
bool operator ==(Object other) =>
104+
identical(this, other) || (other is AdaptiveStreamPixelDensity && other.value == value);
105+
106+
@override
107+
int get hashCode => value.hashCode;
108+
109+
@override
110+
String toString() =>
111+
value == null ? 'AdaptiveStreamPixelDensity.auto' : 'AdaptiveStreamPixelDensity.fixed($value)';
112+
}
113+
72114
/// Options used to modify the behavior of the [Room].
73115
/// {@category Room}
74116
class RoomOptions {
@@ -98,6 +140,12 @@ class RoomOptions {
98140
/// the data flow until they are visible again.
99141
final bool adaptiveStream;
100142

143+
/// Pixel density used to scale a video view's logical size when computing
144+
/// adaptive-stream dimensions. Defaults to [AdaptiveStreamPixelDensity.auto]
145+
/// (the view's real device pixel ratio), so retina displays request
146+
/// appropriately sized layers. Only used when [adaptiveStream] is enabled.
147+
final AdaptiveStreamPixelDensity adaptiveStreamPixelDensity;
148+
101149
/// enable Dynacast, off by default. With Dynacast dynamically pauses
102150
/// video layers that are not being consumed by any subscribers, significantly
103151
/// reducing publishing CPU and bandwidth usage.
@@ -134,6 +182,7 @@ class RoomOptions {
134182
this.defaultAudioPublishOptions = const AudioPublishOptions(),
135183
this.defaultAudioOutputOptions = const AudioOutputOptions(),
136184
this.adaptiveStream = false,
185+
this.adaptiveStreamPixelDensity = AdaptiveStreamPixelDensity.auto,
137186
this.dynacast = false,
138187
this.stopLocalTrackOnUnpublish = true,
139188
this.e2eeOptions,
@@ -150,6 +199,7 @@ class RoomOptions {
150199
AudioPublishOptions? defaultAudioPublishOptions,
151200
AudioOutputOptions? defaultAudioOutputOptions,
152201
bool? adaptiveStream,
202+
AdaptiveStreamPixelDensity? adaptiveStreamPixelDensity,
153203
bool? dynacast,
154204
bool? stopLocalTrackOnUnpublish,
155205
E2EEOptions? e2eeOptions,
@@ -164,6 +214,7 @@ class RoomOptions {
164214
defaultAudioPublishOptions: defaultAudioPublishOptions ?? this.defaultAudioPublishOptions,
165215
defaultAudioOutputOptions: defaultAudioOutputOptions ?? this.defaultAudioOutputOptions,
166216
adaptiveStream: adaptiveStream ?? this.adaptiveStream,
217+
adaptiveStreamPixelDensity: adaptiveStreamPixelDensity ?? this.adaptiveStreamPixelDensity,
167218
dynacast: dynacast ?? this.dynacast,
168219
stopLocalTrackOnUnpublish: stopLocalTrackOnUnpublish ?? this.stopLocalTrackOnUnpublish,
169220
// ignore: deprecated_member_use_from_same_package

lib/src/publication/remote.dart

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,15 +157,24 @@ class RemoteTrackPublication<T extends RemoteTrack> extends TrackPublication<T>
157157
);
158158

159159
final videoTrack = track as VideoTrack;
160+
final pixelDensity = participant.room.roomOptions.adaptiveStreamPixelDensity;
160161

161-
// filter visible build contexts
162+
// Filter visible build contexts and scale each view's logical size by its
163+
// pixel density, so the server is asked for physical-pixel dimensions
164+
// (retina-aware). With AdaptiveStreamPixelDensity.auto the actual device
165+
// pixel ratio is read per-view via MediaQuery.
162166
final viewSizes = videoTrack.viewKeys
163167
.map((e) => e.currentContext)
164168
.nonNulls
165-
.map((e) => e.findRenderObject() as RenderBox?)
166-
.nonNulls
167-
.where((e) => e.hasSize)
168-
.map((e) => e.size);
169+
.map((context) {
170+
final renderBox = context.findRenderObject() as RenderBox?;
171+
if (renderBox == null || !renderBox.hasSize) return null;
172+
final density = pixelDensity.resolve(
173+
MediaQuery.maybeOf(context)?.devicePixelRatio ?? 1.0,
174+
);
175+
return renderBox.size * density;
176+
})
177+
.nonNulls;
169178

170179
logger.finer('[Visibility] ${track?.sid} watching ${viewSizes.length} views...');
171180

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright 2025 LiveKit, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import 'package:flutter_test/flutter_test.dart';
16+
17+
import 'package:livekit_client/src/options.dart';
18+
19+
void main() {
20+
group('AdaptiveStreamPixelDensity.resolve', () {
21+
test('fixed densities ignore the device pixel ratio', () {
22+
expect(const AdaptiveStreamPixelDensity.fixed(1.0).resolve(3.0), 1.0);
23+
expect(const AdaptiveStreamPixelDensity.fixed(2.0).resolve(1.0), 2.0);
24+
});
25+
26+
test('fractional fixed densities are supported', () {
27+
expect(const AdaptiveStreamPixelDensity.fixed(1.5).resolve(3.0), 1.5);
28+
expect(const AdaptiveStreamPixelDensity.fixed(2.75).resolve(1.0), 2.75);
29+
});
30+
31+
test('auto falls back to the supplied device pixel ratio', () {
32+
expect(AdaptiveStreamPixelDensity.auto.resolve(1.0), 1.0);
33+
expect(AdaptiveStreamPixelDensity.auto.resolve(2.0), 2.0);
34+
expect(AdaptiveStreamPixelDensity.auto.resolve(2.625), 2.625);
35+
});
36+
37+
test('caps at 3x for both fixed and auto', () {
38+
expect(const AdaptiveStreamPixelDensity.fixed(4.0).resolve(1.0), 3.0);
39+
expect(AdaptiveStreamPixelDensity.auto.resolve(4.0), 3.0);
40+
expect(AdaptiveStreamPixelDensity.maxDensity, 3.0);
41+
});
42+
43+
test('value is null only for auto', () {
44+
expect(AdaptiveStreamPixelDensity.auto.value, isNull);
45+
expect(const AdaptiveStreamPixelDensity.fixed(1.5).value, 1.5);
46+
});
47+
48+
test('equality is by value', () {
49+
expect(
50+
const AdaptiveStreamPixelDensity.fixed(2.0),
51+
const AdaptiveStreamPixelDensity.fixed(2.0),
52+
);
53+
expect(
54+
const AdaptiveStreamPixelDensity.fixed(2.0) == AdaptiveStreamPixelDensity.auto,
55+
isFalse,
56+
);
57+
});
58+
});
59+
60+
group('RoomOptions.adaptiveStreamPixelDensity', () {
61+
test('defaults to auto', () {
62+
expect(
63+
const RoomOptions().adaptiveStreamPixelDensity,
64+
AdaptiveStreamPixelDensity.auto,
65+
);
66+
});
67+
68+
test('copyWith overrides the density', () {
69+
final opts = const RoomOptions().copyWith(
70+
adaptiveStreamPixelDensity: const AdaptiveStreamPixelDensity.fixed(1.5),
71+
);
72+
expect(opts.adaptiveStreamPixelDensity, const AdaptiveStreamPixelDensity.fixed(1.5));
73+
});
74+
});
75+
}

0 commit comments

Comments
 (0)