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:
1 parent
db6c00cbcf
commit
3b5dd81016
1 file changed
+57
-6
+57
-6
@@ -9,7 +9,10 @@ import android.webkit.WebView
|
|||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
import io.livekit.android.renderer.TextureViewRenderer
|
import io.livekit.android.renderer.TextureViewRenderer
|
||||||
import io.livekit.android.room.track.VideoTrack
|
import io.livekit.android.room.track.VideoTrack
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import livekit.org.webrtc.RendererCommon
|
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
|
// 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
|
// 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).
|
// key ("__local" | peerId) -> renderer, and the track currently bound to it (to avoid needless re-binds).
|
||||||
private val renderers = HashMap<String, TextureViewRenderer>()
|
private val renderers = HashMap<String, TextureViewRenderer>()
|
||||||
private val bound = HashMap<String, VideoTrack>()
|
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).
|
// One meeting tile from the web (coords are CSS px, viewport-relative — same space as getBoundingClientRect).
|
||||||
data class Tile(
|
data class Tile(
|
||||||
@@ -91,17 +99,37 @@ object CallVideo {
|
|||||||
renderers[t.key] = r
|
renderers[t.key] = r
|
||||||
}
|
}
|
||||||
r.setMirror(t.isLocal && !t.wantScreen) // mirror the local camera preview only
|
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 tileW = t.w * density; val tileH = t.h * density
|
||||||
val lp = FrameLayout.LayoutParams((t.w * density).toInt(), (t.h * density).toInt())
|
val tileX = t.x * density; val tileY = t.y * density
|
||||||
lp.leftMargin = (t.x * density).toInt()
|
if (t.wantScreen) {
|
||||||
lp.topMargin = (t.y * density).toInt()
|
// 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
|
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]
|
val cur = bound[t.key]
|
||||||
if (cur !== track) {
|
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) {}
|
try { track.addRenderer(r) } catch (_: Throwable) {}
|
||||||
bound[t.key] = track
|
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).
|
// Drop renderers for tiles that are gone or whose camera turned off (so the web avatar shows).
|
||||||
val it = renderers.entries.iterator()
|
val it = renderers.entries.iterator()
|
||||||
@@ -110,7 +138,7 @@ object CallVideo {
|
|||||||
if (!wanted.contains(e.key)) {
|
if (!wanted.contains(e.key)) {
|
||||||
val r = e.value
|
val r = e.value
|
||||||
val tr = bound.remove(e.key)
|
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 { c.removeView(r) } catch (_: Throwable) {}
|
||||||
try { r.release() } catch (_: Throwable) {}
|
try { r.release() } catch (_: Throwable) {}
|
||||||
it.remove()
|
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) {
|
private fun teardown(webView: WebView) {
|
||||||
for ((key, r) in renderers) {
|
for ((key, r) in renderers) {
|
||||||
val tr = bound[key]
|
val tr = bound[key]
|
||||||
if (tr != null) try { tr.removeRenderer(r) } catch (_: Throwable) {}
|
if (tr != null) try { tr.removeRenderer(r) } catch (_: Throwable) {}
|
||||||
try { r.release() } 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()
|
renderers.clear()
|
||||||
bound.clear()
|
bound.clear()
|
||||||
val c = container
|
val c = container
|
||||||
|
|||||||
Reference in new issue
Block a user