android(call): shared screen fits (contain) in portrait, not center-cropped

Received shared screen fit the window in landscape but was center-cropped in
portrait (only the middle showed). The LiveKit TextureViewRenderer's own
SCALE_ASPECT_FIT didn't letterbox a wide screen in a tall tile — it filled and
cropped. Fix: compute the contain (letterbox) rect ourselves and size the
renderer to it, centred in the tile, then FILL (renderer == video aspect → no
crop), so the WHOLE screen shows in both orientations. Landscape unchanged
(tile aspect ~= video aspect → rect ~= full tile).

The live frame aspect comes from a small VideoSink attached to the screen
track alongside the renderer; frameAspect is a ConcurrentHashMap (written on
the render thread, read on the UI thread). Sinks + aspect are cleaned up on
tile removal / track change / hole-punch teardown. Camera tiles unchanged
(full-tile cover). Pinch-zoom still applies on top of the fitted renderer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
SravanandClaude Opus 4.8 committed 2026-11-04 22:39:24 +05:30
1 parent db6c00cbcf
commit 3b5dd81016
1 file changed
+57 -6
@@ -9,7 +9,10 @@ import android.webkit.WebView
import android.widget.FrameLayout
import io.livekit.android.renderer.TextureViewRenderer
import io.livekit.android.room.track.VideoTrack
import java.util.concurrent.ConcurrentHashMap
import livekit.org.webrtc.RendererCommon
import livekit.org.webrtc.VideoFrame
import livekit.org.webrtc.VideoSink
// Native video tiles for Android native calls (Increment 2b) — the Android counterpart to the iOS plugin's
// TileVideoView path. The WebView owns the meeting UI (grid, roster, controls) but, on a native call, has NO
@@ -36,6 +39,11 @@ object CallVideo {
// key ("__local" | peerId) -> renderer, and the track currently bound to it (to avoid needless re-binds).
private val renderers = HashMap<String, TextureViewRenderer>()
private val bound = HashMap<String, VideoTrack>()
// Shared-screen fit: the renderer's own aspect-fit crops a wide screen in a tall (portrait) tile, so we size
// the renderer to the letterboxed rect ourselves. For that we need the live frame aspect (w/h), captured by a
// tiny VideoSink attached to the screen track alongside the renderer.
private val frameAspect = ConcurrentHashMap<String, Float>() // written on the render thread, read on the UI thread
private val aspectSinks = HashMap<String, VideoSink>()
// One meeting tile from the web (coords are CSS px, viewport-relative — same space as getBoundingClientRect).
data class Tile(
@@ -91,17 +99,37 @@ object CallVideo {
renderers[t.key] = r
}
r.setMirror(t.isLocal && !t.wantScreen) // mirror the local camera preview only
r.setScalingType(if (t.wantScreen) RendererCommon.ScalingType.SCALE_ASPECT_FIT else RendererCommon.ScalingType.SCALE_ASPECT_FILL)
val lp = FrameLayout.LayoutParams((t.w * density).toInt(), (t.h * density).toInt())
lp.leftMargin = (t.x * density).toInt()
lp.topMargin = (t.y * density).toInt()
val tileW = t.w * density; val tileH = t.h * density
val tileX = t.x * density; val tileY = t.y * density
if (t.wantScreen) {
// A shared screen must show in FULL (contain) in BOTH orientations. The renderer's own
// SCALE_ASPECT_FIT was cropping a wide screen in a tall (portrait) tile, so we compute the
// letterbox rect ourselves from the live frame aspect and size the renderer to it (centred in
// the tile), then FILL it (renderer == video aspect → no crop). Until the first frame's aspect
// is known we fall back to the whole tile.
val a = frameAspect[t.key] ?: 0f
var rw = tileW; var rh = tileH; var ox = 0.0; var oy = 0.0
if (a > 0f) {
if (a > tileW / tileH) { rh = tileW / a; oy = (tileH - rh) / 2.0 } // wider than tile → bars top/bottom
else { rw = tileH * a; ox = (tileW - rw) / 2.0 } // taller than tile → bars left/right
}
r.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL)
val lp = FrameLayout.LayoutParams(rw.toInt(), rh.toInt())
lp.leftMargin = (tileX + ox).toInt(); lp.topMargin = (tileY + oy).toInt()
r.layoutParams = lp
} else {
r.setScalingType(RendererCommon.ScalingType.SCALE_ASPECT_FILL)
val lp = FrameLayout.LayoutParams(tileW.toInt(), tileH.toInt())
lp.leftMargin = tileX.toInt(); lp.topMargin = tileY.toInt()
r.layoutParams = lp
}
val cur = bound[t.key]
if (cur !== track) {
if (cur != null) try { cur.removeRenderer(r) } catch (_: Throwable) {}
if (cur != null) { try { cur.removeRenderer(r) } catch (_: Throwable) {}; detachAspectSink(t.key, cur) }
try { track.addRenderer(r) } catch (_: Throwable) {}
bound[t.key] = track
}
if (t.wantScreen) attachAspectSink(t.key, track) else detachAspectSink(t.key, track)
}
// Drop renderers for tiles that are gone or whose camera turned off (so the web avatar shows).
val it = renderers.entries.iterator()
@@ -110,7 +138,7 @@ object CallVideo {
if (!wanted.contains(e.key)) {
val r = e.value
val tr = bound.remove(e.key)
if (tr != null) try { tr.removeRenderer(r) } catch (_: Throwable) {}
if (tr != null) { try { tr.removeRenderer(r) } catch (_: Throwable) {}; detachAspectSink(e.key, tr) }
try { c.removeView(r) } catch (_: Throwable) {}
try { r.release() } catch (_: Throwable) {}
it.remove()
@@ -139,12 +167,35 @@ object CallVideo {
}
}
// A screen track's live frame aspect is captured by this sink so syncTiles can size the renderer to the exact
// letterbox rect. One per screen tile key, attached to the same track as the renderer.
private fun attachAspectSink(key: String, track: VideoTrack) {
if (aspectSinks[key] != null) return
val sink = VideoSink { frame: VideoFrame ->
val w = frame.rotatedWidth; val h = frame.rotatedHeight
if (w > 0 && h > 0) frameAspect[key] = w.toFloat() / h.toFloat()
}
aspectSinks[key] = sink
try { track.addRenderer(sink) } catch (_: Throwable) {}
}
private fun detachAspectSink(key: String, track: VideoTrack) {
val s = aspectSinks.remove(key) ?: return
try { track.removeRenderer(s) } catch (_: Throwable) {}
frameAspect.remove(key)
}
private fun teardown(webView: WebView) {
for ((key, r) in renderers) {
val tr = bound[key]
if (tr != null) try { tr.removeRenderer(r) } catch (_: Throwable) {}
try { r.release() } catch (_: Throwable) {}
}
for ((key, s) in aspectSinks) {
val tr = bound[key]
if (tr != null) try { tr.removeRenderer(s) } catch (_: Throwable) {}
}
aspectSinks.clear()
frameAspect.clear()
renderers.clear()
bound.clear()
val c = container