Native video: readable tile labels (red mute, name chip) + flip camera

Label legibility: the name + mute overlays were both white/invisible.
- Mute badge is now a RED (#dc2626) circle with a white mic-slash, matching
  the web tile's .meet-mute, moved to the top-left.
- Name is now white on a dark translucent pill (PaddingLabel) so it stays
  legible over any video, bottom-left.

Front/back camera switch: new NativeCall.switchCamera() flips the local
CameraCapturer front<->back (switchCameraPosition, verified in 2.15.3). New
"Flip camera" button on the meeting bar (switchCamera icon), shown only
while a native call's camera is on (updateFlipBtn). Mirroring auto-corrects
(VideoView mirrorMode .auto only mirrors the front camera).

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 15:58:12 +05:30
parent 34bdd8ea16
commit a5e2ed8a9d
3 changed files with 49 additions and 13 deletions
@@ -28,6 +28,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
CAPPluginMethod(name: "reportIncomingCall", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "reportIncomingCall", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setCamera", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "setCamera", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "switchCamera", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "syncVideoTiles", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "syncVideoTiles", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise) CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise)
] ]
@@ -163,33 +164,38 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
v.layer.cornerRadius = 8 v.layer.cornerRadius = 8
host.addSubview(v) host.addSubview(v)
let label = UILabel() // Name chip (bottom-left): white text on a dark translucent pill so it's legible over ANY video
// (plain white text vanished on bright footage).
let label = PaddingLabel()
label.tag = NativeCallPlugin.nameTag label.tag = NativeCallPlugin.nameTag
label.font = .systemFont(ofSize: 12, weight: .semibold) label.font = .systemFont(ofSize: 12, weight: .semibold)
label.textColor = .white label.textColor = .white
label.shadowColor = UIColor.black.withAlphaComponent(0.9) // legible over any video label.backgroundColor = UIColor.black.withAlphaComponent(0.5)
label.shadowOffset = CGSize(width: 0, height: 1) label.layer.cornerRadius = 7
label.clipsToBounds = true
label.translatesAutoresizingMaskIntoConstraints = false label.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(label) v.addSubview(label)
let badge = UIImageView(image: UIImage(systemName: "mic.slash.fill")) // Mute badge (top-left): a RED circle with a white mic-slash matches the web tile's .meet-mute (#dc2626).
let badge = UIImageView(image: UIImage(systemName: "mic.slash.fill")?.withConfiguration(
UIImage.SymbolConfiguration(pointSize: 11, weight: .semibold)))
badge.tag = NativeCallPlugin.muteTag badge.tag = NativeCallPlugin.muteTag
badge.tintColor = .white badge.tintColor = .white
badge.contentMode = .center badge.contentMode = .center
badge.backgroundColor = UIColor.black.withAlphaComponent(0.55) badge.backgroundColor = UIColor(red: 0.863, green: 0.149, blue: 0.149, alpha: 1) // #dc2626
badge.layer.cornerRadius = 9 badge.layer.cornerRadius = 10
badge.clipsToBounds = true badge.clipsToBounds = true
badge.translatesAutoresizingMaskIntoConstraints = false badge.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(badge) v.addSubview(badge)
NSLayoutConstraint.activate([ NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 6), label.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 6),
label.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -5), label.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -6),
label.trailingAnchor.constraint(lessThanOrEqualTo: badge.leadingAnchor, constant: -4), label.trailingAnchor.constraint(lessThanOrEqualTo: v.trailingAnchor, constant: -6),
badge.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -6), badge.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 6),
badge.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -5), badge.topAnchor.constraint(equalTo: v.topAnchor, constant: 6),
badge.widthAnchor.constraint(equalToConstant: 18), badge.widthAnchor.constraint(equalToConstant: 20),
badge.heightAnchor.constraint(equalToConstant: 18), badge.heightAnchor.constraint(equalToConstant: 20),
]) ])
tileViews[key] = v tileViews[key] = v
return v return v
@@ -301,6 +307,19 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
} }
} }
// Flip the local camera between front and back.
@objc func switchCamera(_ call: CAPPluginCall) {
guard let r = room else { call.reject("no active call"); return }
let pub = r.localParticipant.videoTracks.first(where: { $0.source == .camera })
guard let track = pub?.track as? LocalVideoTrack, let cam = track.capturer as? CameraCapturer else {
call.reject("camera not active"); return
}
Task {
do { _ = try await cam.switchCameraPosition(); call.resolve() }
catch { call.reject("switch failed: \(String(describing: error))") }
}
}
// Position native video views to match the web meeting tiles. `tiles` = [{uid, local, x, y, w, h}] in // Position native video views to match the web meeting tiles. `tiles` = [{uid, local, x, y, w, h}] in
// CSS px (== points; getBoundingClientRect coords). We create/move a VideoView for each participant that // CSS px (== points; getBoundingClientRect coords). We create/move a VideoView for each participant that
// has a live camera track, and remove views for tiles that are gone or whose camera is off (so the web // has a live camera track, and remove views for tiles that are gone or whose camera is off (so the web
@@ -483,3 +502,13 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
notifyListeners("audioDeactivated", data: ["ok": true]) notifyListeners("audioDeactivated", data: ["ok": 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)
override func drawText(in rect: CGRect) { super.drawText(in: rect.inset(by: insets)) }
override var intrinsicContentSize: CGSize {
let s = super.intrinsicContentSize
return CGSize(width: s.width + insets.left + insets.right, height: s.height + insets.top + insets.bottom)
}
}
+7 -1
View File
@@ -4881,6 +4881,7 @@ function renderCall(){
+ '<span class="mic-grp"><button class="meet-ic'+(meetMic?'':' off')+'" id="meetMicBtn" title="'+(meetMic?'Mute':'Unmute')+'">'+ic(meetMic?'mic':'micOff',20)+'</button>' + '<span class="mic-grp"><button class="meet-ic'+(meetMic?'':' off')+'" id="meetMicBtn" title="'+(meetMic?'Mute':'Unmute')+'">'+ic(meetMic?'mic':'micOff',20)+'</button>'
+ '<button class="mic-caret" id="meetAudioBtn" title="Audio devices (mic & speaker)">'+ic('chevronUp',12)+'</button></span>' + '<button class="mic-caret" id="meetAudioBtn" title="Audio devices (mic & speaker)">'+ic('chevronUp',12)+'</button></span>'
+ '<button class="meet-ic'+(meetCam?'':' off')+'" id="meetCamBtn" title="'+(meetCam?'Turn camera off':'Turn camera on')+'">'+ic(meetCam?'video':'videoOff',20)+'</button>' + '<button class="meet-ic'+(meetCam?'':' off')+'" id="meetCamBtn" title="'+(meetCam?'Turn camera off':'Turn camera on')+'">'+ic(meetCam?'video':'videoOff',20)+'</button>'
+ '<button class="meet-ic" id="meetFlipBtn" title="Flip camera" style="display:none">'+ic('switchCamera',20)+'</button>' // native video calls only (see updateFlipBtn)
// Mobile keeps only Mic / Camera / Speaker / End on the bar (#8e); everything else moves behind ⋮ More. // Mobile keeps only Mic / Camera / Speaker / End on the bar (#8e); everything else moves behind ⋮ More.
+ '<button class="meet-ic sec-btn" id="meetScreenBtn" title="Share screen">'+ic('monitor',20)+'</button>' + '<button class="meet-ic sec-btn" id="meetScreenBtn" title="Share screen">'+ic('monitor',20)+'</button>'
+ '<button class="meet-ic host-only sec-btn" id="meetRecBtn" title="Record meeting" style="display:none">'+ic('record',20)+'</button>' + '<button class="meet-ic host-only sec-btn" id="meetRecBtn" title="Record meeting" style="display:none">'+ic('record',20)+'</button>'
@@ -4892,6 +4893,7 @@ function renderCall(){
+ '<button class="meet-ic leave" id="meetLeaveBtn" title="Leave">'+ic('callEnd',20)+'</button></div></div>'; + '<button class="meet-ic leave" id="meetLeaveBtn" title="Leave">'+ic('callEnd',20)+'</button></div></div>';
document.getElementById('meetMicBtn').onclick=toggleMic; document.getElementById('meetMicBtn').onclick=toggleMic;
document.getElementById('meetCamBtn').onclick=toggleCam; document.getElementById('meetCamBtn').onclick=toggleCam;
{ const fb=document.getElementById('meetFlipBtn'); if(fb) fb.onclick=flipCamera; }
document.getElementById('meetScreenBtn').onclick=toggleScreen; document.getElementById('meetScreenBtn').onclick=toggleScreen;
document.getElementById('meetRecBtn').onclick=toggleRecord; document.getElementById('meetRecBtn').onclick=toggleRecord;
{ const tb=document.getElementById('meetTransBtn'); if(tb) tb.onclick=toggleTranscribe; } { const tb=document.getElementById('meetTransBtn'); if(tb) tb.onclick=toggleTranscribe; }
@@ -5473,7 +5475,11 @@ async function onMeetMsg(e){
if(m.type==='error'){ const msg=m.message; leaveMeeting(true); const e2=document.getElementById('meetErr'); if(e2) e2.textContent=msg||'Meeting error'; return; } if(m.type==='error'){ const msg=m.message; leaveMeeting(true); const e2=document.getElementById('meetErr'); if(e2) e2.textContent=msg||'Meeting error'; return; }
} }
function updateMicBtn(){ const b=document.getElementById('meetMicBtn'); if(b){ b.classList.toggle('off',!meetMic); b.title=meetMic?'Mute':'Unmute'; b.innerHTML=ic(meetMic?'mic':'micOff',20); } } function updateMicBtn(){ const b=document.getElementById('meetMicBtn'); if(b){ b.classList.toggle('off',!meetMic); b.title=meetMic?'Mute':'Unmute'; b.innerHTML=ic(meetMic?'mic':'micOff',20); } }
function updateCamBtn(){ const b=document.getElementById('meetCamBtn'); if(b){ b.classList.toggle('off',!meetCam); b.title=meetCam?'Turn camera off':'Turn camera on'; b.innerHTML=ic(meetCam?'video':'videoOff',20); } } function updateCamBtn(){ const b=document.getElementById('meetCamBtn'); if(b){ b.classList.toggle('off',!meetCam); b.title=meetCam?'Turn camera off':'Turn camera on'; b.innerHTML=ic(meetCam?'video':'videoOff',20); } updateFlipBtn(); }
// Flip front/back camera — native calls only (the plugin owns the camera). The button shows only while a
// native call's camera is on.
function flipCamera(){ const NC=nativeCallPlugin(); if(NC&&NC.switchCamera){ try{ NC.switchCamera(); }catch(_){} } }
function updateFlipBtn(){ const b=document.getElementById('meetFlipBtn'); if(b) b.style.display=(meetNative&&meetCam)?'inline-flex':'none'; }
// Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers. // Unmute acquires the mic on demand (no prompt until then) and renegotiates with peers.
async function toggleMic(){ async function toggleMic(){
if(!meetLocalStream) return; if(!meetLocalStream) return;
+1
View File
@@ -64,6 +64,7 @@
calendarClock:'<path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h5"/><circle cx="16" cy="16" r="6"/><path d="M16 14v2l1.5 1"/>', calendarClock:'<path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h3.5"/><path d="M16 2v4"/><path d="M8 2v4"/><path d="M3 10h5"/><circle cx="16" cy="16" r="6"/><path d="M16 14v2l1.5 1"/>',
fileText: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>', fileText: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/>',
externalLink:'<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6"/>', externalLink:'<path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h6"/>',
switchCamera:'<path d="M11 19H4a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2h5"/><path d="M13 5h7a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2h-5"/><circle cx="12" cy="12" r="3"/><path d="m18 22-3-3 3-3"/><path d="m6 2 3 3-3 3"/>',
record: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/>', record: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5" fill="currentColor"/>',
callEnd: '<g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g>', callEnd: '<g transform="rotate(135 12 12)"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></g>',
settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>', settings: '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',