Native screen-share: maximize the shared screen + pinch-to-zoom

Better visibility: on a native call, a shared screen now fills the whole
meeting area and hides the small participant tiles (new .scr-full grid
class, toggled when meetNative && sharing). The plugin renders the screen
over that full-area stage rect.

Zoom: the tile video view is now TileVideoView with pinch-to-zoom + pan
(and double-tap to reset) enabled only while it's showing a screen. While
zoomed the view holds a transform and syncVideoTiles stops overwriting its
frame; name/mute overlays hide during zoom.

Needs a Codemagic build (plugin change). Web deployed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 20:56:27 +05:30
parent e6d3d2d66a
commit aed3d22675
2 changed files with 70 additions and 10 deletions
@@ -43,10 +43,10 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
private var endedCalls = Set<UUID>() private var endedCalls = Set<UUID>()
private var room: Room? private var room: Room?
private var activeUUID: UUID? private var activeUUID: UUID?
// Native video: one native VideoView per visible participant (key "__local" or the remote user id), // Native video: one native video view per visible participant (key "__local" or the remote user id),
// drawn over the WebView and positioned to match the web meeting tiles (see syncVideoTiles). VideoView is // drawn over the WebView and positioned to match the web meeting tiles (see syncVideoTiles). UIKit views
// a UIKit view only ever touched on the main thread. // only ever touched on the main thread. TileVideoView adds pinch-zoom/pan for shared screens.
private var tileViews: [String: VideoView] = [:] private var tileViews: [String: TileVideoView] = [:]
override public func load() { override public func load() {
let config = CXProviderConfiguration(localizedName: "Biz Connect") let config = CXProviderConfiguration(localizedName: "Biz Connect")
@@ -164,12 +164,13 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
// Build a tile VideoView with its native name label (bottom-left) + mute badge (bottom-right) the video // Build a tile VideoView with its native name label (bottom-left) + mute badge (bottom-right) the video
// covers the web tile, so these redraw the essentials the web can no longer show through. // covers the web tile, so these redraw the essentials the web can no longer show through.
private func makeTileView(host: UIView, key: String) -> VideoView { private func makeTileView(host: UIView, key: String) -> TileVideoView {
let v = VideoView() let v = TileVideoView()
v.layoutMode = .fill v.layoutMode = .fill
v.backgroundColor = .black v.backgroundColor = .black
v.clipsToBounds = true v.clipsToBounds = true
v.layer.cornerRadius = 8 v.layer.cornerRadius = 8
v.installZoomGestures()
host.addSubview(v) host.addSubview(v)
// Name chip (bottom-left): white text on a dark translucent pill so it's legible over ANY video // Name chip (bottom-left): white text on a dark translucent pill so it's legible over ANY video
@@ -209,13 +210,15 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
return v return v
} }
private func updateTileOverlay(_ vv: VideoView, name: String, muted: Bool) { private func updateTileOverlay(_ vv: TileVideoView, name: String, muted: Bool) {
// Keep the overlays above the video renderer (VideoView adds its renderer when the track is set). // Keep the overlays above the video renderer (VideoView adds its renderer when the track is set).
// Hide them while zoomed into a screen a scaled name chip over content just gets in the way.
let zoomed = vv.isZoomed
if let label = vv.viewWithTag(NativeCallPlugin.nameTag) as? UILabel { if let label = vv.viewWithTag(NativeCallPlugin.nameTag) as? UILabel {
label.text = name; label.isHidden = name.isEmpty; vv.bringSubviewToFront(label) label.text = name; label.isHidden = zoomed || name.isEmpty; vv.bringSubviewToFront(label)
} }
if let badge = vv.viewWithTag(NativeCallPlugin.muteTag) { if let badge = vv.viewWithTag(NativeCallPlugin.muteTag) {
badge.isHidden = !muted; vv.bringSubviewToFront(badge) badge.isHidden = zoomed || !muted; vv.bringSubviewToFront(badge)
} }
} }
@@ -353,9 +356,10 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
wanted.insert(key) wanted.insert(key)
let vv = self.tileViews[key] ?? self.makeTileView(host: host, key: key) let vv = self.tileViews[key] ?? self.makeTileView(host: host, key: key)
if vv.superview !== host { host.addSubview(vv) } if vv.superview !== host { host.addSubview(vv) }
vv.isScreen = wantScreen // enables pinch-zoom on shared screens (resets zoom if turned off)
vv.layoutMode = wantScreen ? .fit : .fill vv.layoutMode = wantScreen ? .fit : .fill
if vv.track !== track { vv.track = track } if vv.track !== track { vv.track = track }
vv.frame = CGRect(x: x, y: y, width: w, height: h) if vv.transform.isIdentity { vv.frame = CGRect(x: x, y: y, width: w, height: h) } // don't fight an active zoom
// Native video covers the web tile, so redraw the essentials (name + muted) natively. // Native video covers the web tile, so redraw the essentials (name + muted) natively.
self.updateTileOverlay(vv, name: (t["name"] as? String) ?? "", muted: (t["muted"] as? Bool) ?? false) self.updateTileOverlay(vv, name: (t["name"] as? String) ?? "", muted: (t["muted"] as? Bool) ?? false)
} }
@@ -516,6 +520,56 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
} }
} }
// A tile video view that supports pinch-to-zoom + pan while it's showing a shared SCREEN (so fine print is
// legible); double-tap resets. Camera tiles keep isScreen=false and never zoom. While zoomed the view carries
// a transform, and syncVideoTiles stops overwriting its frame so the zoom isn't fought each poll.
final class TileVideoView: VideoView, UIGestureRecognizerDelegate {
var isScreen = false { didSet { if !isScreen { resetZoom() } } }
var isZoomed: Bool { !transform.isIdentity }
private var zoomScale: CGFloat = 1
private var zoomOffset: CGPoint = .zero
func installZoomGestures() {
isUserInteractionEnabled = true
let pinch = UIPinchGestureRecognizer(target: self, action: #selector(onPinch(_:))); pinch.delegate = self
let pan = UIPanGestureRecognizer(target: self, action: #selector(onPan(_:))); pan.delegate = self
let dtap = UITapGestureRecognizer(target: self, action: #selector(onDoubleTap)); dtap.numberOfTapsRequired = 2
addGestureRecognizer(pinch); addGestureRecognizer(pan); addGestureRecognizer(dtap)
}
func resetZoom() {
zoomScale = 1; zoomOffset = .zero
if !transform.isIdentity { transform = .identity }
}
private func apply() {
transform = CGAffineTransform(translationX: zoomOffset.x, y: zoomOffset.y).scaledBy(x: zoomScale, y: zoomScale)
}
@objc private func onPinch(_ g: UIPinchGestureRecognizer) {
guard isScreen else { return }
if g.state == .changed {
zoomScale = min(5, max(1, zoomScale * g.scale)); g.scale = 1
if zoomScale <= 1 { zoomOffset = .zero }
apply()
} else if (g.state == .ended || g.state == .cancelled), zoomScale <= 1.01 {
resetZoom()
}
}
@objc private func onPan(_ g: UIPanGestureRecognizer) {
guard isScreen, zoomScale > 1 else { return }
let t = g.translation(in: self); g.setTranslation(.zero, in: self)
zoomOffset.x += t.x; zoomOffset.y += t.y
apply()
}
@objc private func onDoubleTap() { guard isScreen else { return }; resetZoom() }
// Let pinch + pan run together for a natural zoom/pan.
func gestureRecognizer(_ g: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith other: UIGestureRecognizer) -> Bool { true }
}
// A UILabel with padding, so the name chip has breathing room around its text. // A UILabel with padding, so the name chip has breathing room around its text.
final class PaddingLabel: UILabel { final class PaddingLabel: UILabel {
var insets = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6) var insets = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6)
+6
View File
@@ -410,6 +410,11 @@
.meet-grid.sharing-mode .meet-tile:not(.stage){width:160px;height:94px;flex:0 0 auto;} .meet-grid.sharing-mode .meet-tile:not(.stage){width:160px;height:94px;flex:0 0 auto;}
.meet-grid.sharing-mode .meet-tile.sharing:not(.stage){cursor:pointer;} .meet-grid.sharing-mode .meet-tile.sharing:not(.stage){cursor:pointer;}
.meet-grid.sharing-mode .meet-tile.sharing:not(.stage)::after{content:"Click to view";position:absolute;left:0;right:0;bottom:0;font-size:.58rem;text-align:center;background:rgba(37,99,235,.88);color:#fff;padding:1px 0;} .meet-grid.sharing-mode .meet-tile.sharing:not(.stage)::after{content:"Click to view";position:absolute;left:0;right:0;bottom:0;font-size:.58rem;text-align:center;background:rgba(37,99,235,.88);color:#fff;padding:1px 0;}
/* Native call: a shared screen should DOMINATE — fill the whole meeting area and hide the small participant
tiles (the plugin renders the screen natively over this stage rect; pinch-to-zoom happens there). */
.meet-grid.sharing-mode.scr-full{height:100%;}
.meet-grid.sharing-mode.scr-full .meet-tile.stage{width:100%;height:100%;min-height:0;}
.meet-grid.sharing-mode.scr-full .meet-tile:not(.stage){display:none;}
@media (max-width:760px){ @media (max-width:760px){
.meet-grid.sharing-mode{flex-flow:row wrap;height:auto;} .meet-grid.sharing-mode{flex-flow:row wrap;height:auto;}
.meet-grid.sharing-mode .meet-tile.stage{width:100%;height:auto;flex:1 1 100%;min-height:40vh;} .meet-grid.sharing-mode .meet-tile.stage{width:100%;height:auto;flex:1 1 100%;min-height:40vh;}
@@ -5270,6 +5275,7 @@ function getSharerIds(){ const a=[]; meetSharers.forEach(id=>a.push(id)); return
function updateShareMode(){ const g=document.getElementById('meetGrid'); if(!g) return; function updateShareMode(){ const g=document.getElementById('meetGrid'); if(!g) return;
const sharers=getSharerIds(); const on=sharers.length>0; const sharers=getSharerIds(); const on=sharers.length>0;
g.classList.toggle('sharing-mode', on); g.classList.toggle('sharing-mode', on);
g.classList.toggle('scr-full', on && meetNative); // native: maximize the shared screen, hide participant tiles
if(on){ if(!meetStageId || sharers.indexOf(meetStageId)<0) meetStageId=sharers[0]; } else meetStageId=null; if(on){ if(!meetStageId || sharers.indexOf(meetStageId)<0) meetStageId=sharers[0]; } else meetStageId=null;
applyStage(); applyStage();
} }