diff --git a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift
index 544fd3d..8bd80e6 100644
--- a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift
+++ b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift
@@ -28,6 +28,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
CAPPluginMethod(name: "reportIncomingCall", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "setCamera", returnType: CAPPluginReturnPromise),
+ CAPPluginMethod(name: "switchCamera", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "syncVideoTiles", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise)
]
@@ -163,33 +164,38 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
v.layer.cornerRadius = 8
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.font = .systemFont(ofSize: 12, weight: .semibold)
label.textColor = .white
- label.shadowColor = UIColor.black.withAlphaComponent(0.9) // legible over any video
- label.shadowOffset = CGSize(width: 0, height: 1)
+ label.backgroundColor = UIColor.black.withAlphaComponent(0.5)
+ label.layer.cornerRadius = 7
+ label.clipsToBounds = true
label.translatesAutoresizingMaskIntoConstraints = false
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.tintColor = .white
badge.contentMode = .center
- badge.backgroundColor = UIColor.black.withAlphaComponent(0.55)
- badge.layer.cornerRadius = 9
+ badge.backgroundColor = UIColor(red: 0.863, green: 0.149, blue: 0.149, alpha: 1) // #dc2626
+ badge.layer.cornerRadius = 10
badge.clipsToBounds = true
badge.translatesAutoresizingMaskIntoConstraints = false
v.addSubview(badge)
NSLayoutConstraint.activate([
label.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 6),
- label.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -5),
- label.trailingAnchor.constraint(lessThanOrEqualTo: badge.leadingAnchor, constant: -4),
- badge.trailingAnchor.constraint(equalTo: v.trailingAnchor, constant: -6),
- badge.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -5),
- badge.widthAnchor.constraint(equalToConstant: 18),
- badge.heightAnchor.constraint(equalToConstant: 18),
+ label.bottomAnchor.constraint(equalTo: v.bottomAnchor, constant: -6),
+ label.trailingAnchor.constraint(lessThanOrEqualTo: v.trailingAnchor, constant: -6),
+ badge.leadingAnchor.constraint(equalTo: v.leadingAnchor, constant: 6),
+ badge.topAnchor.constraint(equalTo: v.topAnchor, constant: 6),
+ badge.widthAnchor.constraint(equalToConstant: 20),
+ badge.heightAnchor.constraint(equalToConstant: 20),
])
tileViews[key] = 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
// 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
@@ -483,3 +502,13 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega
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)
+ }
+}
diff --git a/server/public/home.html b/server/public/home.html
index 479b119..467cefa 100644
--- a/server/public/home.html
+++ b/server/public/home.html
@@ -4881,6 +4881,7 @@ function renderCall(){
+ ''
+ ''
+ ''
+ + '' // native video calls only (see updateFlipBtn)
// Mobile keeps only Mic / Camera / Speaker / End on the bar (#8e); everything else moves behind ⋮ More.
+ ''
+ ''
@@ -4892,6 +4893,7 @@ function renderCall(){
+ '';
document.getElementById('meetMicBtn').onclick=toggleMic;
document.getElementById('meetCamBtn').onclick=toggleCam;
+ { const fb=document.getElementById('meetFlipBtn'); if(fb) fb.onclick=flipCamera; }
document.getElementById('meetScreenBtn').onclick=toggleScreen;
document.getElementById('meetRecBtn').onclick=toggleRecord;
{ 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; }
}
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.
async function toggleMic(){
if(!meetLocalStream) return;
diff --git a/server/public/icons.js b/server/public/icons.js
index 2225ce2..499be8c 100644
--- a/server/public/icons.js
+++ b/server/public/icons.js
@@ -64,6 +64,7 @@
calendarClock:'',
fileText: '',
externalLink:'',
+ switchCamera:'',
record: '',
callEnd: '',
settings: '',