Implement PiP for video calls

This commit is contained in:
QuentinArguillere 2025-01-13 14:38:59 +01:00
parent b3d83c1580
commit 511c6e4093
5 changed files with 180 additions and 6 deletions

View file

@ -22,6 +22,7 @@
667E5D812B8E444E00EBCFC4 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 667E5D802B8E444D00EBCFC4 /* GoogleService-Info.plist */; };
6691CA7E2B839C2D00B2A7B8 /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6691CA7D2B839C2D00B2A7B8 /* NotificationService.swift */; };
66A3E5B72CAE8E5C00FCB7FA /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = D70C93DD2AC2D0F60063CA3B /* Localizable.xcstrings */; };
66C468FB2D2BE54800A836F7 /* PIPViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C468FA2D2BE54300A836F7 /* PIPViewModel.swift */; };
66C491F92B24D25B00CEA16D /* ConfigExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */; };
66C491FB2B24D32600CEA16D /* CoreExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FA2B24D32600CEA16D /* CoreExtension.swift */; };
66C491FD2B24D36500CEA16D /* AudioRouteUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 66C491FC2B24D36500CEA16D /* AudioRouteUtils.swift */; };
@ -227,6 +228,7 @@
6646A7A22BB2E224006B842A /* ScheduleMeetingFragment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScheduleMeetingFragment.swift; sourceTree = "<group>"; };
667E5D802B8E444D00EBCFC4 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
6691CA7D2B839C2D00B2A7B8 /* NotificationService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = "<group>"; };
66C468FA2D2BE54300A836F7 /* PIPViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PIPViewModel.swift; sourceTree = "<group>"; };
66C491F82B24D25A00CEA16D /* ConfigExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigExtension.swift; sourceTree = "<group>"; };
66C491FA2B24D32600CEA16D /* CoreExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreExtension.swift; sourceTree = "<group>"; };
66C491FC2B24D36500CEA16D /* AudioRouteUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouteUtils.swift; sourceTree = "<group>"; };
@ -858,6 +860,7 @@
children = (
D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */,
D734499A2BC694C900778C56 /* MeetingWaitingRoomViewModel.swift */,
66C468FA2D2BE54300A836F7 /* PIPViewModel.swift */,
);
path = ViewModel;
sourceTree = "<group>";
@ -1307,6 +1310,7 @@
D72250692ADFBF2D008FB426 /* SideMenu.swift in Sources */,
C6DC4E3F2C19C289009096FD /* SideMenuEntry.swift in Sources */,
D714DE622C1C4636006C1F1D /* RegisterCodeConfirmationFragment.swift in Sources */,
66C468FB2D2BE54800A836F7 /* PIPViewModel.swift in Sources */,
D7CEE0352B7A210300FD79B7 /* ConversationsView.swift in Sources */,
D717071E2AC5922E0037746F /* ColorExtension.swift in Sources */,
D71968922B86369D00DF4459 /* ChatBubbleView.swift in Sources */,
@ -1557,8 +1561,11 @@
);
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Linphone/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Linphone;
INFOPLIST_KEY_NSCalendarsUsageDescription = "Deprecated - Prior to iOS 17 full calendar access is required";
INFOPLIST_KEY_NSCameraUsageDescription = "Camera usage is required for video VOIP calls";
INFOPLIST_KEY_NSContactsUsageDescription = "Make calls with your friends";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "App requires access to the local network to establish VoIP connections";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone usage is required for VOIP calls";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Share photos with your friends and customize avatars";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
@ -1610,8 +1617,10 @@
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Linphone/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = Linphone;
INFOPLIST_KEY_NSCalendarsUsageDescription = "Deprecated - Prior to iOS 17 full calendar access is required";
INFOPLIST_KEY_NSCameraUsageDescription = "Camera usage is required for video VOIP calls";
INFOPLIST_KEY_NSContactsUsageDescription = "Make calls with your friends";
INFOPLIST_KEY_NSLocalNetworkUsageDescription = "App requires access to the local network to establish VoIP connections";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Microphone usage is required for VOIP calls";
INFOPLIST_KEY_NSPhotoLibraryUsageDescription = "Share photos with your friends and customize avatars";
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;

View file

@ -35,6 +35,7 @@ final class CoreContext: ObservableObject {
static let shared = CoreContext()
private var sharedMainViewModel = SharedMainViewModel.shared
var pipViewModel = PIPViewModel()
var coreVersion: String = Core.getVersion
@Published var loggedIn: Bool = false

View file

@ -2,8 +2,6 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSLocalNetworkUsageDescription</key>
<string>App requires access to the local network to establish VoIP connections</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
@ -111,6 +109,8 @@
<true/>
<key>ITSEncryptionExportComplianceCode</key>
<string>b5cb085f-772a-4a4f-8c77-5d1332b1f93f</string>
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
<string></string>
<key>NSSupportsSuddenTermination</key>
<false/>
<key>UIAppFonts</key>
@ -126,15 +126,12 @@
<array>
<string>remote-notification</string>
<string>voip</string>
<string>audio</string>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIImageName</key>
<string>linphone</string>
</dict>
<key>NSCalendarsUsageDescription</key>
<string>Deprecated - Prior to iOS 17 full calendar access is required</string>
<key>NSCalendarsWriteOnlyAccessUsageDescription</key>
<string></string>
</dict>
</plist>

View file

@ -534,6 +534,10 @@ struct CallView: View {
LinphoneVideoViewHolder { view in
coreContext.doOnCoreQueue { core in
core.nativeVideoWindow = view
Log.info("debugtrace -- LinphoneVideoViewHolder set view 1")
DispatchQueue.main.async {
CoreContext.shared.pipViewModel.setupPiPViewController(remoteView: view)
}
}
}
.frame(
@ -549,6 +553,9 @@ struct CallView: View {
}
.onAppear {
if callViewModel.videoDisplayed {
if coreContext.pipViewModel.pipController?.isPictureInPictureActive ?? false {
coreContext.pipViewModel.pipController?.stopPictureInPicture()
}
callViewModel.videoDisplayed = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
callViewModel.videoDisplayed = true
@ -557,6 +564,9 @@ struct CallView: View {
}
.onDisappear {
if callViewModel.videoDisplayed {
if !(coreContext.pipViewModel.pipController?.isPictureInPictureActive ?? false){
coreContext.pipViewModel.pipController?.startPictureInPicture()
}
callViewModel.videoDisplayed = false
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
callViewModel.videoDisplayed = true
@ -893,6 +903,10 @@ struct CallView: View {
LinphoneVideoViewHolder { view in
coreContext.doOnCoreQueue { core in
core.nativeVideoWindow = view
Log.info("debugtrace -- LinphoneVideoViewHolder set view 2")
DispatchQueue.main.async {
CoreContext.shared.pipViewModel.setupPiPViewController(remoteView: view)
}
}
}
}

View file

@ -0,0 +1,153 @@
/*
* Copyright (c) 2010-2025 Belledonne Communications SARL.
*
* This file is part of linphone-iphone
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
import linphonesw
import SwiftUI
import AVKit
class PIPViewModel: NSObject, AVPictureInPictureControllerDelegate {
var pipController: AVPictureInPictureController?
var pipRemoteVideoView = SampleBufferVideoCallView()
var videoCallView = UIView()
var callStateChangedDelegate: CallDelegate?
func setupPiPViewController(remoteView: UIView) {
Log.info("debugtrace setupPiPViewController")
videoCallView = remoteView
let pipVideoCallController = PictureInPictureVideoCallViewController()
pipRemoteVideoView = pipVideoCallController.pipRemoteVideoView
let pipContentSource = AVPictureInPictureController.ContentSource(
activeVideoCallSourceView: videoCallView,
contentViewController: pipVideoCallController)
pipController = AVPictureInPictureController(contentSource: pipContentSource)
pipController?.delegate = self
pipController?.canStartPictureInPictureAutomaticallyFromInline = true
CoreContext.shared.doOnCoreQueue { core in
if let call = core.currentCall {
self.callStateChangedDelegate = CallDelegateStub(onStateChanged: { (_: Call, cstate: Call.State, _: String) in
if cstate != .StreamsRunning && CoreContext.shared.pipViewModel.pipController?.isPictureInPictureActive ?? false {
Log.info("debugtrace -- callstate changed stop pip")
CoreContext.shared.pipViewModel.pipController?.stopPictureInPicture()
if cstate == .End || cstate == .Error {
self.callStateChangedDelegate = nil
}
}
})
call.addDelegate(delegate: self.callStateChangedDelegate!)
Log.info("debugtrace -- added callstatechanged delegate")
} else {
Log.info("debugtrace -- no current call")
}
}
/*
ControlsViewModel.shared.isVideoEnabled.readCurrentAndObserve{ (video) in
pipVideoCallController.matchVideoDimension()
self.pipController?.canStartPictureInPictureAutomaticallyFromInline = video == true
}
CallsViewModel.shared.currentCallData.observe(onChange: { callData in
if (callData??.call.state != .StreamsRunning && self.pipController?.isPictureInPictureActive) {
self.pipController?.stopPictureInPicture()
}
})
*/
}
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
Log.info("debugtrace pictureInPictureControllerWillStartPictureInPicture")
CoreContext.shared.doOnCoreQueue { core in
core.nativeVideoWindow = self.pipRemoteVideoView
}
}
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
Log.info("debugtrace pictureInPictureControllerDidStopPictureInPicture")
CoreContext.shared.doOnCoreQueue { core in
core.nativeVideoWindow = self.videoCallView
}
}
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
CoreContext.shared.doOnCoreQueue { core in
core.nativeVideoWindow = self.videoCallView
}
Log.error("Start Picture in Picture video call error : \(error)")
// DispatchQueue.main.async { self.configurationPiPViewController() }
}
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
Log.info("debugtrace restoreUserInterfaceForPictureInPictureStopWithCompletionHandler")
TelecomManager.shared.callDisplayed = true
/* a
if (CallsViewModel.shared.currentCallData.value??.call.state == .StreamsRunning && PhoneMainView.instance().currentView != self.compositeViewDescription()) {
PhoneMainView.instance().changeCurrentView(self.compositeViewDescription())
//Core.get().nativeVideoWindow = pipRemoteVideoView // let the video on the pip view during the stop animation
}
//pictureInPictureController.contentSource?.activeVideoCallContentViewController.view.layer.cornerRadius = ActiveCallView.center_view_corner_radius
*/
completionHandler(true)
}
}
class PictureInPictureVideoCallViewController: AVPictureInPictureVideoCallViewController {
var pipRemoteVideoView = SampleBufferVideoCallView()
override func viewDidLoad() {
super.viewDidLoad()
view.backgroundColor = .black
view.clipsToBounds = true
view.addSubview(pipRemoteVideoView)
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
view.layer.cornerRadius = 0
}
override func viewDidLayoutSubviews() {
matchVideoDimension()
super.viewDidLayoutSubviews()
}
func matchVideoDimension() {
Log.info("debugtrace - matchVideoDimension")
self.preferredContentSize = CGSize(width: Double(720), height: Double(480))
pipRemoteVideoView.frame = view.bounds
}
}
// swiftlint:disable force_cast
class SampleBufferVideoCallView: UIView {
override class var layerClass: AnyClass {
AVSampleBufferDisplayLayer.self
}
var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer {
layer as! AVSampleBufferDisplayLayer
}
}
// swiftlint:enable force_cast