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:
Doug
2024-08-21 17:48:57 +01:00
committed by GitHub
parent 4e3ab9c04a
commit 304070e8cc
23 changed files with 256 additions and 66 deletions
@@ -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