mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-03 15:45:11 +00:00
Use native video call picture in picture! (#3190)
* Use native video call picture in picture! * Handle isSupported, isPossible and web view errors. * Use new canEnterPip method. * Hide a room's Call button when already joined to the call. * Tweak feature flag description. * PR comments.
This commit is contained in:
@@ -27,7 +27,7 @@ struct CallScreenCoordinatorParameters {
|
||||
|
||||
enum CallScreenCoordinatorAction {
|
||||
/// The call is still ongoing but the user wishes to navigate around the app.
|
||||
case pictureInPictureStarted(AVPictureInPictureController?)
|
||||
case pictureInPictureStarted(AVPictureInPictureController)
|
||||
/// The call is hidden and the user wishes to return to it.
|
||||
case pictureInPictureStopped
|
||||
/// The call is finished and the screen is done with.
|
||||
|
||||
@@ -18,7 +18,7 @@ import AVKit
|
||||
import Foundation
|
||||
|
||||
enum CallScreenViewModelAction {
|
||||
case pictureInPictureStarted(AVPictureInPictureController?)
|
||||
case pictureInPictureStarted(AVPictureInPictureController)
|
||||
case pictureInPictureStopped
|
||||
case dismiss
|
||||
}
|
||||
@@ -36,6 +36,7 @@ struct CallScreenViewState: BindableState {
|
||||
struct Bindings {
|
||||
var javaScriptMessageHandler: ((Any) -> Void)?
|
||||
var javaScriptEvaluator: ((String) async throws -> Any)?
|
||||
var requestPictureInPictureHandler: (() async -> Result<AVPictureInPictureController, CallScreenError>)?
|
||||
|
||||
var alertInfo: AlertInfo<UUID>?
|
||||
}
|
||||
@@ -43,4 +44,10 @@ struct Bindings {
|
||||
enum CallScreenViewAction {
|
||||
case urlChanged(URL?)
|
||||
case navigateBack
|
||||
case pictureInPictureWillStop
|
||||
case endCall
|
||||
}
|
||||
|
||||
enum CallScreenError: Error {
|
||||
case pictureInPictureNotAvailable
|
||||
}
|
||||
|
||||
@@ -73,8 +73,8 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
||||
|
||||
switch action {
|
||||
case let .setAudioEnabled(enabled, roomID):
|
||||
guard roomID == configuration.callID else {
|
||||
MXLog.error("Received mute request for a different room: \(roomID) != \(configuration.callID)")
|
||||
guard roomID == configuration.callRoomID else {
|
||||
MXLog.error("Received mute request for a different room: \(roomID) != \(configuration.callRoomID)")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -107,28 +107,11 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
||||
case .callEnded:
|
||||
actionsSubject.send(.dismiss)
|
||||
case .mediaStateChanged(let audioEnabled, _):
|
||||
elementCallService.setAudioEnabled(audioEnabled, roomID: configuration.callID)
|
||||
elementCallService.setAudioEnabled(audioEnabled, roomID: configuration.callRoomID)
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Use did start otherwise there's a black box left on the screen during the pip controller animation.
|
||||
NotificationCenter.default.publisher(for: .init("AVPictureInPictureControllerDidStartNotification"))
|
||||
.sink { [weak self] notification in
|
||||
guard let self else { return }
|
||||
let controller = notification.object as? AVPictureInPictureController
|
||||
actionsSubject.send(.pictureInPictureStarted(controller))
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
NotificationCenter.default.publisher(for: .init("AVPictureInPictureControllerWillStopNotification"))
|
||||
.sink { [weak self] _ in
|
||||
guard let self else { return }
|
||||
actionsSubject.send(.pictureInPictureStopped)
|
||||
Task { try await self.state.bindings.javaScriptEvaluator?("controls.disableCompatPip()") }
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
setupCall()
|
||||
}
|
||||
|
||||
@@ -138,7 +121,11 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
||||
guard let url else { return }
|
||||
MXLog.info("URL changed to: \(url)")
|
||||
case .navigateBack:
|
||||
handleBackwardsNavigation()
|
||||
Task { await handleBackwardsNavigation() }
|
||||
case .pictureInPictureWillStop:
|
||||
actionsSubject.send(.pictureInPictureStopped)
|
||||
case .endCall:
|
||||
actionsSubject.send(.dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,26 +187,19 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
|
||||
}
|
||||
}
|
||||
|
||||
private func handleBackwardsNavigation() {
|
||||
#if targetEnvironment(simulator)
|
||||
if UIDevice.current.isPhone {
|
||||
MXLog.warning("The iPhone simulator doesn't support PiP.")
|
||||
actionsSubject.send(.dismiss)
|
||||
return
|
||||
}
|
||||
#endif
|
||||
|
||||
guard isPictureInPictureEnabled, state.url != nil else {
|
||||
private func handleBackwardsNavigation() async {
|
||||
guard state.url != nil,
|
||||
isPictureInPictureEnabled,
|
||||
let requestPictureInPictureHandler = state.bindings.requestPictureInPictureHandler else {
|
||||
actionsSubject.send(.dismiss)
|
||||
return
|
||||
}
|
||||
|
||||
Task {
|
||||
try await state.bindings.javaScriptEvaluator?("controls.enableCompatPip()")
|
||||
// Enable this check when implemented on web.
|
||||
// if result as? Bool != true {
|
||||
// actionsSubject.send(.dismiss)
|
||||
// }
|
||||
switch await requestPictureInPictureHandler() {
|
||||
case .success(let controller):
|
||||
actionsSubject.send(.pictureInPictureStarted(controller))
|
||||
case .failure:
|
||||
actionsSubject.send(.dismiss)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import Combine
|
||||
import SFSafeSymbols
|
||||
import SwiftUI
|
||||
@@ -46,7 +47,7 @@ struct CallScreen: View {
|
||||
if context.viewState.url == nil {
|
||||
ProgressView()
|
||||
} else {
|
||||
WebView(url: context.viewState.url, viewModelContext: context)
|
||||
CallView(url: context.viewState.url, viewModelContext: context)
|
||||
// This URL is stable, forces view reloads if this representable is ever reused for another url
|
||||
.id(context.viewState.url)
|
||||
.ignoresSafeArea(edges: .bottom)
|
||||
@@ -54,44 +55,52 @@ struct CallScreen: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct WebView: UIViewRepresentable {
|
||||
private struct CallView: UIViewRepresentable {
|
||||
/// The top-level view this representable displays. It wraps the web view when picture in picture isn't running.
|
||||
typealias WebViewWrapper = UIView
|
||||
|
||||
let url: URL?
|
||||
let viewModelContext: CallScreenViewModel.Context
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
context.coordinator.webView
|
||||
func makeUIView(context: Context) -> WebViewWrapper {
|
||||
context.coordinator.webViewWrapper
|
||||
}
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(viewModelContext: viewModelContext)
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
func updateUIView(_ callWebView: WebViewWrapper, context: Context) {
|
||||
if let url {
|
||||
context.coordinator.url = url
|
||||
let request = URLRequest(url: url)
|
||||
webView.load(request)
|
||||
context.coordinator.load(url)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate {
|
||||
class Coordinator: NSObject, WKUIDelegate, WKNavigationDelegate, AVPictureInPictureControllerDelegate {
|
||||
private weak var viewModelContext: CallScreenViewModel.Context?
|
||||
private let certificateValidator: CertificateValidatorHookProtocol
|
||||
|
||||
private(set) var webView: WKWebView!
|
||||
private var webView: WKWebView!
|
||||
private var pictureInPictureController: AVPictureInPictureController?
|
||||
private let pictureInPictureViewController: AVPictureInPictureVideoCallViewController
|
||||
|
||||
var url: URL!
|
||||
/// The view to be shown in the app. This will contain the web view when picture in picture isn't running.
|
||||
let webViewWrapper = WebViewWrapper(frame: .zero)
|
||||
|
||||
private var url: URL!
|
||||
|
||||
init(viewModelContext: CallScreenViewModel.Context) {
|
||||
self.viewModelContext = viewModelContext
|
||||
certificateValidator = viewModelContext.viewState.certificateValidator
|
||||
pictureInPictureViewController = AVPictureInPictureVideoCallViewController()
|
||||
pictureInPictureViewController.preferredContentSize = CGSize(width: 1920, height: 1080)
|
||||
|
||||
super.init()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
// Avoid `Publishing changes from within view update warnings`
|
||||
viewModelContext.javaScriptEvaluator = self.evaluateJavaScript(_:)
|
||||
DispatchQueue.main.async { // Avoid `Publishing changes from within view update warnings`
|
||||
viewModelContext.javaScriptEvaluator = self.evaluateJavaScript
|
||||
viewModelContext.requestPictureInPictureHandler = self.requestPictureInPicture
|
||||
}
|
||||
|
||||
let configuration = WKWebViewConfiguration()
|
||||
@@ -120,6 +129,21 @@ private struct WebView: UIViewRepresentable {
|
||||
webView.isOpaque = false
|
||||
webView.backgroundColor = .compound.bgCanvasDefault
|
||||
webView.scrollView.backgroundColor = .compound.bgCanvasDefault
|
||||
|
||||
webViewWrapper.addMatchedSubview(webView)
|
||||
|
||||
if AVPictureInPictureController.isPictureInPictureSupported() {
|
||||
let pictureInPictureController = AVPictureInPictureController(contentSource: .init(activeVideoCallSourceView: webViewWrapper,
|
||||
contentViewController: pictureInPictureViewController))
|
||||
pictureInPictureController.delegate = self
|
||||
self.pictureInPictureController = pictureInPictureController
|
||||
}
|
||||
}
|
||||
|
||||
func load(_ url: URL) {
|
||||
self.url = url
|
||||
let request = URLRequest(url: url)
|
||||
webView.load(request)
|
||||
}
|
||||
|
||||
func evaluateJavaScript(_ script: String) async throws -> Any? {
|
||||
@@ -181,6 +205,57 @@ private struct WebView: UIViewRepresentable {
|
||||
viewModelContext?.send(viewAction: .urlChanged(webView.url))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Picture in Picture
|
||||
|
||||
func requestPictureInPicture() async -> Result<AVPictureInPictureController, CallScreenError> {
|
||||
guard let pictureInPictureController,
|
||||
pictureInPictureController.isPictureInPicturePossible,
|
||||
case .success(true) = await webViewCanEnterPictureInPicture() else {
|
||||
return .failure(.pictureInPictureNotAvailable)
|
||||
}
|
||||
|
||||
pictureInPictureController.startPictureInPicture()
|
||||
return .success(pictureInPictureController)
|
||||
}
|
||||
|
||||
func stopPictureInPicture() {
|
||||
pictureInPictureController?.stopPictureInPicture()
|
||||
}
|
||||
|
||||
nonisolated func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
Task { @MainActor in
|
||||
// We move the view via the delegate so it works when you background the app without calling requestPictureInPicture
|
||||
pictureInPictureViewController.view.addMatchedSubview(webView)
|
||||
_ = try? await evaluateJavaScript("controls.enablePip()")
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
Task { await viewModelContext?.send(viewAction: .pictureInPictureWillStop) }
|
||||
}
|
||||
|
||||
nonisolated func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
||||
Task { @MainActor in
|
||||
webViewWrapper.addMatchedSubview(webView)
|
||||
_ = try? await evaluateJavaScript("controls.disablePip()")
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the web view can do picture in picture or not (e.g. it is showing an error or the page didn't load).
|
||||
private func webViewCanEnterPictureInPicture() async -> Result<Bool, CallScreenError> {
|
||||
do {
|
||||
guard let canEnterPictureInPicture = try await evaluateJavaScript("controls.canEnterPip()") as? Bool else {
|
||||
MXLog.error("canEnterPip returned an unexpected value, skipping picture in picture.")
|
||||
return .failure(.pictureInPictureNotAvailable)
|
||||
}
|
||||
MXLog.info("canEnterPip returned \(canEnterPictureInPicture)")
|
||||
return .success(canEnterPictureInPicture)
|
||||
} catch {
|
||||
MXLog.error("Error checking canEnterPip: \(error)")
|
||||
return .failure(.pictureInPictureNotAvailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Avoids retain loops between the configuration and webView coordinator
|
||||
|
||||
Reference in New Issue
Block a user