diff --git a/Linphone.xcodeproj/project.pbxproj b/Linphone.xcodeproj/project.pbxproj index 2ad99de6f..fd81ddda0 100644 --- a/Linphone.xcodeproj/project.pbxproj +++ b/Linphone.xcodeproj/project.pbxproj @@ -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 = ""; }; 667E5D802B8E444D00EBCFC4 /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 6691CA7D2B839C2D00B2A7B8 /* NotificationService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; + 66C468FA2D2BE54300A836F7 /* PIPViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PIPViewModel.swift; sourceTree = ""; }; 66C491F82B24D25A00CEA16D /* ConfigExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigExtension.swift; sourceTree = ""; }; 66C491FA2B24D32600CEA16D /* CoreExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreExtension.swift; sourceTree = ""; }; 66C491FC2B24D36500CEA16D /* AudioRouteUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRouteUtils.swift; sourceTree = ""; }; @@ -858,6 +860,7 @@ children = ( D7B99E982B29B39000BE7BF2 /* CallViewModel.swift */, D734499A2BC694C900778C56 /* MeetingWaitingRoomViewModel.swift */, + 66C468FA2D2BE54300A836F7 /* PIPViewModel.swift */, ); path = ViewModel; sourceTree = ""; @@ -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; diff --git a/Linphone/Core/CoreContext.swift b/Linphone/Core/CoreContext.swift index 0b83749d2..a98f92c82 100644 --- a/Linphone/Core/CoreContext.swift +++ b/Linphone/Core/CoreContext.swift @@ -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 diff --git a/Linphone/Info.plist b/Linphone/Info.plist index 89d3fd147..2912e9348 100644 --- a/Linphone/Info.plist +++ b/Linphone/Info.plist @@ -2,8 +2,6 @@ - NSLocalNetworkUsageDescription - App requires access to the local network to establish VoIP connections CFBundleURLTypes @@ -111,6 +109,8 @@ ITSEncryptionExportComplianceCode b5cb085f-772a-4a4f-8c77-5d1332b1f93f + NSCalendarsWriteOnlyAccessUsageDescription + NSSupportsSuddenTermination UIAppFonts @@ -126,15 +126,12 @@ remote-notification voip + audio UILaunchScreen UIImageName linphone - NSCalendarsUsageDescription - Deprecated - Prior to iOS 17 full calendar access is required - NSCalendarsWriteOnlyAccessUsageDescription - diff --git a/Linphone/UI/Call/CallView.swift b/Linphone/UI/Call/CallView.swift index 2674f9f68..be97f7e4f 100644 --- a/Linphone/UI/Call/CallView.swift +++ b/Linphone/UI/Call/CallView.swift @@ -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) + } } } } diff --git a/Linphone/UI/Call/ViewModel/PIPViewModel.swift b/Linphone/UI/Call/ViewModel/PIPViewModel.swift index e69de29bb..6464e9489 100644 --- a/Linphone/UI/Call/ViewModel/PIPViewModel.swift +++ b/Linphone/UI/Call/ViewModel/PIPViewModel.swift @@ -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 . + */ + +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