Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .changes/adaptive-stream-pixel-density
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
patch type="fixed" "Fix adaptive stream dimensions on high-density displays"
75 changes: 72 additions & 3 deletions Sources/LiveKit/Views/VideoView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,33 @@ public class VideoView: NativeView, Loggable {
case flip
}

/// Controls how the view's logical (point) size is scaled to physical pixels
/// when reporting the adaptive-stream size to the server. Server simulcast/SVC
/// layers are sized in physical pixels, so this avoids under-requesting (and
/// thus a soft/upscaled layer) on retina / high-density displays.
public enum AdaptiveStreamPixelDensity: Sendable, Equatable {
/// Use the view's own screen scale (`UIScreen.scale` on iOS/tvOS,
/// `NSScreen.backingScaleFactor` on macOS, `traitCollection.displayScale`
/// on visionOS). This is the default.
case auto
/// A fixed multiplier (e.g. `1.0`, `1.5`, `2.0`). Capped at ``maxDensity``.
case fixed(Double)

/// Upper bound applied to the resolved density to keep bandwidth in check.
public static let maxDensity: Double = 3.0

/// Resolves the effective multiplier, capped at ``maxDensity``. For
/// ``auto``, falls back to the supplied screen scale.
func resolve(screenScale: CGFloat) -> CGFloat {
let density: CGFloat = switch self {
case .auto: screenScale
case let .fixed(value): CGFloat(value)
}
guard !density.isNaN, density > 0 else { return 1 }
return Swift.min(density, CGFloat(Self.maxDensity))
}
}

/// ``LayoutMode-swift.enum`` of the ``VideoView``.
@objc
public nonisolated var layoutMode: LayoutMode {
Expand All @@ -93,6 +120,16 @@ public class VideoView: NativeView, Loggable {
set { _state.mutate { $0.rotationOverride = newValue } }
}

/// Controls how this view's logical (point) size is converted to the
/// physical-pixel dimensions requested from the server when adaptive stream
/// is enabled. Defaults to ``AdaptiveStreamPixelDensity/auto`` (the view's
/// own screen scale), which avoids requesting an under-sized layer on
/// retina / high-density displays.
public nonisolated var adaptiveStreamPixelDensity: AdaptiveStreamPixelDensity {
get { _state.adaptiveStreamPixelDensity }
set { _state.mutate { $0.adaptiveStreamPixelDensity = newValue } }
}

/// Calls addRenderer and/or removeRenderer internally for convenience.
@objc
public nonisolated weak var track: VideoTrack? {
Expand Down Expand Up @@ -215,6 +252,10 @@ public class VideoView: NativeView, Loggable {
var mirrorMode: MirrorMode = .auto
var renderMode: RenderMode = .auto
var rotationOverride: VideoRotation?
var adaptiveStreamPixelDensity: AdaptiveStreamPixelDensity = .auto
// Screen scale captured at layout time (main thread), so adaptiveStreamSize
// can resolve `.auto` density without touching main-actor view APIs.
var screenScale: CGFloat = 1

var isDebugMode: Bool = false

Expand Down Expand Up @@ -423,6 +464,24 @@ public class VideoView: NativeView, Loggable {
fatalError("init(coder:) has not been implemented")
}

/// The pixel scale of the screen this view is currently on, used to convert
/// logical point sizes to physical pixels for adaptive stream. Must be called
/// on the main thread.
func currentScreenScale() -> CGFloat {
#if os(macOS)
return window?.backingScaleFactor ?? NSScreen.main?.backingScaleFactor ?? 2.0
#elseif os(visionOS)
let scale = traitCollection.displayScale
return scale > 0 ? scale : 2.0
#elseif os(iOS) || os(tvOS)
let scale = traitCollection.displayScale
if scale > 0 { return scale }
return window?.screen.scale ?? UIScreen.main.scale
#else
return 1.0
#endif
}

// swiftlint:disable:next cyclomatic_complexity function_body_length
override public func performLayout() {
super.performLayout()
Expand Down Expand Up @@ -498,9 +557,13 @@ public class VideoView: NativeView, Loggable {
width: size.width,
height: size.height)

if state.rendererSize != rendererFrame.size {
let screenScale = currentScreenScale()
if state.rendererSize != rendererFrame.size || state.screenScale != screenScale {
// mutate if required
_state.mutate { $0.rendererSize = rendererFrame.size }
_state.mutate {
$0.rendererSize = rendererFrame.size
$0.screenScale = screenScale
}
}

if let _primaryRenderer {
Expand Down Expand Up @@ -602,7 +665,13 @@ extension VideoView: VideoRenderer {
}

public var adaptiveStreamSize: CGSize {
_state.rendererSize ?? .zero
_state.read { state in
guard let size = state.rendererSize else { return .zero }
// Scale the logical (point) size to physical pixels so the server is
// asked for the resolution actually displayed on retina/HiDPI screens.
let density = state.adaptiveStreamPixelDensity.resolve(screenScale: state.screenScale)
return CGSize(width: size.width * density, height: size.height * density)
}
}

public func set(size: CGSize) {
Expand Down
47 changes: 47 additions & 0 deletions Tests/LiveKitCoreTests/AdaptiveStreamPixelDensityTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright 2026 LiveKit
*
* 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 CoreGraphics
@testable import LiveKit
import Testing

struct AdaptiveStreamPixelDensityTests {
@Test func autoUsesScreenScale() {
#expect(VideoView.AdaptiveStreamPixelDensity.auto.resolve(screenScale: 1) == 1)
#expect(VideoView.AdaptiveStreamPixelDensity.auto.resolve(screenScale: 2) == 2)
#expect(VideoView.AdaptiveStreamPixelDensity.auto.resolve(screenScale: 2.75) == 2.75)
}

@Test func fixedIgnoresScreenScale() {
#expect(VideoView.AdaptiveStreamPixelDensity.fixed(1).resolve(screenScale: 3) == 1)
#expect(VideoView.AdaptiveStreamPixelDensity.fixed(1.5).resolve(screenScale: 1) == 1.5)
}

@Test func capsAtMaxDensity() {
#expect(VideoView.AdaptiveStreamPixelDensity.auto.resolve(screenScale: 4) == 3)
#expect(VideoView.AdaptiveStreamPixelDensity.fixed(5).resolve(screenScale: 1) == 3)
#expect(VideoView.AdaptiveStreamPixelDensity.maxDensity == 3)
}

@Test func invalidDensityFallsBackToOne() {
#expect(VideoView.AdaptiveStreamPixelDensity.auto.resolve(screenScale: 0) == 1)
#expect(VideoView.AdaptiveStreamPixelDensity.auto.resolve(screenScale: -2) == 1)
#expect(VideoView.AdaptiveStreamPixelDensity.auto.resolve(screenScale: .nan) == 1)
#expect(VideoView.AdaptiveStreamPixelDensity.fixed(0).resolve(screenScale: 2) == 1)
#expect(VideoView.AdaptiveStreamPixelDensity.fixed(-1).resolve(screenScale: 2) == 1)
#expect(VideoView.AdaptiveStreamPixelDensity.fixed(.nan).resolve(screenScale: 2) == 1)
}
}
Loading