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:
@@ -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