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: '',