diff --git a/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/CallVideo.kt b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/CallVideo.kt index 7fc2bd8..fa80c5b 100644 --- a/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/CallVideo.kt +++ b/mobile/plugins/native-call/android/src/main/java/com/bizgaze/connect/nativecall/CallVideo.kt @@ -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() private val bound = HashMap() + // 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() // written on the render thread, read on the UI thread + private val aspectSinks = HashMap() // 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() - r.layoutParams = lp + 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