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:
@@ -43,10 +43,10 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
|
||||
private var endedCalls = Set<UUID>()
|
||||
private var room: Room?
|
||||
private var activeUUID: UUID?
|
||||
// Native video: one native VideoView 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
|
||||
// a UIKit view — only ever touched on the main thread.
|
||||
private var tileViews: [String: VideoView] = [:]
|
||||
// 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). UIKit views —
|
||||
// only ever touched on the main thread. TileVideoView adds pinch-zoom/pan for shared screens.
|
||||
private var tileViews: [String: TileVideoView] = [:]
|
||||
|
||||
override public func load() {
|
||||
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
|
||||
// covers the web tile, so these redraw the essentials the web can no longer show through.
|
||||
private func makeTileView(host: UIView, key: String) -> VideoView {
|
||||
let v = VideoView()
|
||||
private func makeTileView(host: UIView, key: String) -> TileVideoView {
|
||||
let v = TileVideoView()
|
||||
v.layoutMode = .fill
|
||||
v.backgroundColor = .black
|
||||
v.clipsToBounds = true
|
||||
v.layer.cornerRadius = 8
|
||||
v.installZoomGestures()
|
||||
host.addSubview(v)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
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).
|
||||
// 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 {
|
||||
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) {
|
||||
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)
|
||||
let vv = self.tileViews[key] ?? self.makeTileView(host: host, key: key)
|
||||
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
|
||||
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.
|
||||
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.
|
||||
final class PaddingLabel: UILabel {
|
||||
var insets = UIEdgeInsets(top: 2, left: 6, bottom: 2, right: 6)
|
||||
|
||||
@@ -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.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;}
|
||||
/* 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){
|
||||
.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;}
|
||||
@@ -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;
|
||||
const sharers=getSharerIds(); const on=sharers.length>0;
|
||||
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;
|
||||
applyStage();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user