From 149e32b8d8858a0575f354e1a59e51517f449781 Mon Sep 17 00:00:00 2001 From: sravan Date: Wed, 5 Aug 2026 23:10:19 +0530 Subject: [PATCH] Native video Increment 2a: publish local camera + self-view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the camera button on native calls to the plugin instead of a "not available" toast. New NativeCall.setCamera({on}) calls LiveKit localParticipant.setCamera(enabled:) so the iOS user's camera is published to the room — every web/desktop peer renders it via their existing SFU subscription. Locally the plugin shows a small rounded self-view (VideoView) pinned top-right over the WebView. APIs verified against client-sdk-swift 2.15.3 source: setCamera -> LocalTrackPublication?, TrackPublication.track, VideoView(.track/.layoutMode), CameraCaptureOptions(position:.front). Front camera only for now. Rendering the OTHER participants as native tiles synced to the web meeting grid is Increment 2b (the fragile part) — next build. Until the new IPA ships, the web branch falls back to an "update the app" toast. Co-Authored-By: Claude Opus 4.8 --- .../NativeCallPlugin/NativeCallPlugin.swift | 61 +++++++++++++++++++ server/public/home.html | 11 +++- 2 files changed, 71 insertions(+), 1 deletion(-) diff --git a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift index be92119..c5564f5 100644 --- a/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift +++ b/mobile/plugins/native-call/ios/Sources/NativeCallPlugin/NativeCallPlugin.swift @@ -26,6 +26,7 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega CAPPluginMethod(name: "reportOutgoingCall", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "reportIncomingCall", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "setMuted", returnType: CAPPluginReturnPromise), + CAPPluginMethod(name: "setCamera", returnType: CAPPluginReturnPromise), CAPPluginMethod(name: "endCall", returnType: CAPPluginReturnPromise) ] @@ -39,6 +40,11 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega private var endedCalls = Set() private var room: Room? private var activeUUID: UUID? + // Native video (Increment 2a): the local camera is published to the room (so everyone else sees this + // user) and mirrored into a small self-view (PiP) drawn over the WebView. Rendering the OTHER + // participants as native tiles synced to the web meeting grid is Increment 2b. VideoView is a UIKit + // view — only ever touched on the main thread (helpers below dispatch there). + private var localVideoView: VideoView? override public func load() { let config = CXProviderConfiguration(localizedName: "Biz Connect") @@ -91,6 +97,41 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega let r = room room = nil Task { await r?.disconnect() } + hideSelfView() + } + + // MARK: - Native video (local self-view) + + // Show the local camera as a small rounded self-view pinned to the top-right safe area, above the WebView. + // Increment 2b will render the REMOTE participants as native views synced to the web meeting tiles. + private func showSelfView(track: VideoTrack?) { + DispatchQueue.main.async { [weak self] in + guard let self = self, let track = track, let host = self.bridge?.viewController?.view else { return } + let vv = self.localVideoView ?? VideoView() + vv.layoutMode = .fill // fill the PiP box (crop) instead of letterboxing + vv.track = track + if vv.superview == nil { + vv.backgroundColor = .black + vv.clipsToBounds = true + vv.layer.cornerRadius = 10 + vv.translatesAutoresizingMaskIntoConstraints = false + host.addSubview(vv) + NSLayoutConstraint.activate([ + vv.widthAnchor.constraint(equalToConstant: 96), + vv.heightAnchor.constraint(equalToConstant: 132), + vv.topAnchor.constraint(equalTo: host.safeAreaLayoutGuide.topAnchor, constant: 16), + vv.trailingAnchor.constraint(equalTo: host.safeAreaLayoutGuide.trailingAnchor, constant: -12), + ]) + } + self.localVideoView = vv + } + } + + private func hideSelfView() { + DispatchQueue.main.async { [weak self] in + self?.localVideoView?.removeFromSuperview() + self?.localVideoView = nil + } } // Route call audio to the LOUDSPEAKER when we'd otherwise be on the quiet earpiece. Guarded so a connected @@ -171,6 +212,26 @@ public class NativeCallPlugin: CAPPlugin, CAPBridgedPlugin, PKPushRegistryDelega call.resolve() } + // Native video (Increment 2a): enable/disable the local camera. Publishing it makes this user's video + // appear for everyone else (their web/desktop clients render it via their own SFU subscription); locally + // we mirror it into a small self-view. Seeing the OTHER participants' video (native tiles) is Increment 2b. + // Front camera only for now. + @objc func setCamera(_ call: CAPPluginCall) { + guard let r = room else { call.reject("no active call"); return } + let on = call.getBool("on") ?? false + Task { [weak self] in + do { + let pub = try await r.localParticipant.setCamera( + enabled: on, + captureOptions: CameraCaptureOptions(position: .front)) + if on { self?.showSelfView(track: pub?.track as? VideoTrack) } else { self?.hideSelfView() } + call.resolve(["on": on]) + } catch { + call.reject("camera failed: \(String(describing: error))") + } + } + } + // End the CallKit call (remote hung up / user ended from the web UI). No callUUID -> end all. @objc func endCall(_ call: CAPPluginCall) { if let uuidStr = call.getString("callUUID"), let uuid = UUID(uuidString: uuidStr) { diff --git a/server/public/home.html b/server/public/home.html index 4445df7..d20215a 100644 --- a/server/public/home.html +++ b/server/public/home.html @@ -5469,7 +5469,16 @@ async function toggleMic(){ // renegotiates with every peer, so you can always turn video on once a meeting has started. async function toggleCam(){ if(!meetLocalStream) return; - if(meetNative){ toast('Video isn’t available on native calls yet'); return; } // native path is audio-only for now (video rendering is the next phase) + if(meetNative){ // native call: the plugin owns the LiveKit media, so the camera is published natively. + const next=!meetCam; const NC=nativeCallPlugin(); + if(!NC||!NC.setCamera){ toast('Update the app to use video on calls'); return; } + try{ await NC.setCamera({ on:next }); }catch(e){ toast('Could not turn the camera '+(next?'on':'off')); return; } + meetCam=next; meetAudioOnly=false; updateCamBtn(); + // The web __local tile stays an avatar — the WebView has no camera stream in a native call (the plugin + // owns media and draws its own self-view). Tell peers our camera state so their SFU tile shows the video. + meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); + return; + } if(SFU.on){ const next=!meetCam; try{ await sfuSetCam(next); meetCam=next; meetAudioOnly=false; }catch(e){ toast(mediaErrMsg(e,'camera')); return; } updateCamBtn(); addTile('__local', meetLocalStream, (ME&&ME.name)?ME.name:'You', true); setTileMute('__local', !meetMic); meetSend({type:'meeting-state', muted:!meetMic, camOff:!meetCam}); return; } const hasTrack=meetLocalStream.getVideoTracks().length>0; if(!hasTrack){