Skip to content

Fix pixel density in AdaptiveStream#1098

Merged
hiroshihorie merged 23 commits into
mainfrom
hiroshi/adaptive-stream-pixel-density
Jun 1, 2026
Merged

Fix pixel density in AdaptiveStream#1098
hiroshihorie merged 23 commits into
mainfrom
hiroshi/adaptive-stream-pixel-density

Conversation

@hiroshihorie
Copy link
Copy Markdown
Member

@hiroshihorie hiroshihorie commented May 29, 2026

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 VideoTrackRenderer.adaptiveStreamPixelDensity (default: auto), set per view rather than room-wide. Fractional densities are supported and the resolved value is capped at 3x to bound bandwidth.
  • Track each rendered view with a VideoTrackViewRegistration that owns both the GlobalKey and pixel density, so density updates stay tied to the renderer lifecycle.
  • Resolve auto mode from each view's devicePixelRatio via MediaQuery.maybeDevicePixelRatioOf; fixed modes use a constant multiplier.
  • _computeVideoViewVisibility scales each view's logical size by the resolved density, materializes the visible sizes once per tick, and requests the largest scaled dimensions across all views.

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.
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.
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.
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.
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.
_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.
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).
@hiroshihorie hiroshihorie force-pushed the hiroshi/adaptive-stream-pixel-density branch from 1fdd82b to 86a4ee3 Compare May 29, 2026 09:24
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)) capped at 3x, mirroring
  the JS pixelDensity option.
- Add a per-view VideoTrackRenderer.adaptiveStreamPixelDensity (default auto);
  the density is stored per view-key on the track.
- _computeVideoViewVisibility scales each view's logical size by its own
  resolved density (auto = that view's MediaQuery devicePixelRatio) before
  taking the largest size across the track's views.

Because density is resolved per view, a track shown on screens with different
scales (e.g. built-in retina + external 1x) requests correctly for each.
@hiroshihorie hiroshihorie force-pushed the hiroshi/adaptive-stream-pixel-density branch from 86a4ee3 to a4c0dbe Compare May 29, 2026 09:41
Comment thread lib/src/track/local/local.dart Outdated

@internal
void updateViewKeyPixelDensity(GlobalKey key, AdaptiveStreamPixelDensity pixelDensity) {
if (viewPixelDensities.containsKey(key)) viewPixelDensities[key] = pixelDensity;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we log or handle the case where if (!viewPixelDensities.containsKey(key)) ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point 🙂 I changed this to keep the key and pixel density together in VideoTrackViewRegistration, so there is no separate map lookup anymore. Missing context/size is still skipped as part of the normal widget lifecycle.

Copy link
Copy Markdown
Contributor

@xianshijing-lk xianshijing-lk left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

one question, lgtm in general

…tion

The map().nonNulls iterable was lazy and consumed three times per 300ms visibility tick (length, isNotEmpty, reduce), re-running findRenderObject and MediaQuery lookups on each pass. Materialize once with toList().
Use MediaQuery.maybeDevicePixelRatioOf instead of maybeOf(context)?.devicePixelRatio so the view depends only on the pixel ratio rather than every MediaQuery field (padding, text scale, etc.).
@hiroshihorie hiroshihorie changed the title feat: account for display pixel density in adaptive stream fix: account for display pixel density in adaptive stream May 31, 2026
@hiroshihorie hiroshihorie marked this pull request as ready for review May 31, 2026 09:01
@hiroshihorie hiroshihorie requested a review from cloudwebrtc as a code owner May 31, 2026 09:01
@hiroshihorie hiroshihorie changed the title fix: account for display pixel density in adaptive stream Fix pixel density in AdaptiveStream May 31, 2026
Base automatically changed from hiroshi/better-track-settings to main June 1, 2026 14:26
@hiroshihorie hiroshihorie merged commit a05a782 into main Jun 1, 2026
17 checks passed
@hiroshihorie hiroshihorie deleted the hiroshi/adaptive-stream-pixel-density branch June 1, 2026 14:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants