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