mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-11-02 21:33:26 +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:
@@ -665,6 +665,7 @@
|
||||
95690DDD9D547D3D842ACBE3 /* AnalyticsSettingsScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4BD371B60E07A5324B9507EF /* AnalyticsSettingsScreenCoordinator.swift */; };
|
||||
9586E90A447C4896C0CA3A8E /* TimelineItemReplyDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE89A8BD65CCE3FCC925CA14 /* TimelineItemReplyDetails.swift */; };
|
||||
95E7B236F7116CACE05A6BC9 /* BlockedUsersScreenCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A16D0F226B1819D017531647 /* BlockedUsersScreenCoordinator.swift */; };
|
||||
9603EEF6DE980BB1D15D4707 /* UIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05A3E8741D199CD1A37F4CBF /* UIView.swift */; };
|
||||
962A4F8AD6312804E2C6BB6E /* PhotoLibraryPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = A232D9156D225BD9FD1D0C43 /* PhotoLibraryPicker.swift */; };
|
||||
964B9D2EC38C488C360CE0C9 /* HomeScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = B902EA6CD3296B0E10EE432B /* HomeScreen.swift */; };
|
||||
968A5B890004526AB58A217C /* AvatarSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = E24B88AD3D1599E8CB1376E0 /* AvatarSize.swift */; };
|
||||
@@ -1203,6 +1204,7 @@
|
||||
054F469E433864CC6FE6EE8E /* ServerSelectionUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionUITests.swift; sourceTree = "<group>"; };
|
||||
05512FB13987D221B7205DE0 /* HomeScreenRecoveryKeyConfirmationBanner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeScreenRecoveryKeyConfirmationBanner.swift; sourceTree = "<group>"; };
|
||||
05596E4A11A8C9346E9E54AE /* SoftLogoutScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SoftLogoutScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
05A3E8741D199CD1A37F4CBF /* UIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIView.swift; sourceTree = "<group>"; };
|
||||
05AF58372CA884A789EB9C5A /* AppMediatorProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppMediatorProtocol.swift; sourceTree = "<group>"; };
|
||||
05F598B1B346DAF223651C91 /* LoginScreenCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenCoordinator.swift; sourceTree = "<group>"; };
|
||||
0685156EB62D7E243F097CFC /* ServerSelectionScreenViewModelProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerSelectionScreenViewModelProtocol.swift; sourceTree = "<group>"; };
|
||||
@@ -3194,6 +3196,7 @@
|
||||
40B21E611DADDEF00307E7AC /* String.swift */,
|
||||
A40C19719687984FD9478FBE /* Task.swift */,
|
||||
287FC98AF2664EAD79C0D902 /* UIDevice.swift */,
|
||||
05A3E8741D199CD1A37F4CBF /* UIView.swift */,
|
||||
BE148A4FFEE853C5A281500C /* UNNotificationContent.swift */,
|
||||
227AC5D71A4CE43512062243 /* URL.swift */,
|
||||
2BFDCA5A09EE70BC17F2EFA7 /* URLComponents.swift */,
|
||||
@@ -6809,6 +6812,7 @@
|
||||
384D6B9A7DFD7260139D6852 /* UITestsNotificationCenter.swift in Sources */,
|
||||
22882C710BC99EC34A5024A0 /* UITestsScreenIdentifier.swift in Sources */,
|
||||
706289B086B0A6B0C211763F /* UITestsSignalling.swift in Sources */,
|
||||
9603EEF6DE980BB1D15D4707 /* UIView.swift in Sources */,
|
||||
D02AA6208C7ACB9BE6332394 /* UNNotificationContent.swift in Sources */,
|
||||
071A017E415AD378F2961B11 /* URL.swift in Sources */,
|
||||
6FD8053301C5FEFA82D2F246 /* URLComponents.swift in Sources */,
|
||||
|
||||
@@ -47,6 +47,7 @@ final class AppSettings {
|
||||
case publicSearchEnabled
|
||||
case fuzzyRoomListSearchEnabled
|
||||
case pinningEnabled
|
||||
case elementCallPictureInPictureEnabled
|
||||
}
|
||||
|
||||
private static var suiteName: String = InfoPlistReader.main.appGroupIdentifier
|
||||
@@ -285,8 +286,8 @@ final class AppSettings {
|
||||
@UserPreference(key: UserDefaultsKeys.pinningEnabled, defaultValue: false, storageType: .userDefaults(store))
|
||||
var pinningEnabled
|
||||
|
||||
// Not user configurable as it depends on work in EC too.
|
||||
let elementCallPictureInPictureEnabled = false
|
||||
@UserPreference(key: UserDefaultsKeys.elementCallPictureInPictureEnabled, defaultValue: false, storageType: .userDefaults(store))
|
||||
var elementCallPictureInPictureEnabled
|
||||
|
||||
#endif
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
private let roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol
|
||||
private let navigationStackCoordinator: NavigationStackCoordinator
|
||||
private let emojiProvider: EmojiProviderProtocol
|
||||
private let ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>
|
||||
private let appMediator: AppMediatorProtocol
|
||||
private let appSettings: AppSettings
|
||||
private let analytics: AnalyticsService
|
||||
@@ -90,6 +91,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
roomTimelineControllerFactory: RoomTimelineControllerFactoryProtocol,
|
||||
navigationStackCoordinator: NavigationStackCoordinator,
|
||||
emojiProvider: EmojiProviderProtocol,
|
||||
ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>,
|
||||
appMediator: AppMediatorProtocol,
|
||||
appSettings: AppSettings,
|
||||
analytics: AnalyticsService,
|
||||
@@ -100,6 +102,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
self.roomTimelineControllerFactory = roomTimelineControllerFactory
|
||||
self.navigationStackCoordinator = navigationStackCoordinator
|
||||
self.emojiProvider = emojiProvider
|
||||
self.ongoingCallRoomIDPublisher = ongoingCallRoomIDPublisher
|
||||
self.appMediator = appMediator
|
||||
self.appSettings = appSettings
|
||||
self.analytics = analytics
|
||||
@@ -580,6 +583,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
voiceMessageMediaManager: userSession.voiceMessageMediaManager,
|
||||
emojiProvider: emojiProvider,
|
||||
completionSuggestionService: completionSuggestionService,
|
||||
ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher,
|
||||
appMediator: appMediator,
|
||||
appSettings: appSettings,
|
||||
composerDraftService: composerDraftService)
|
||||
@@ -1350,6 +1354,7 @@ class RoomFlowCoordinator: FlowCoordinatorProtocol {
|
||||
roomTimelineControllerFactory: roomTimelineControllerFactory,
|
||||
navigationStackCoordinator: navigationStackCoordinator,
|
||||
emojiProvider: emojiProvider,
|
||||
ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher,
|
||||
appMediator: appMediator,
|
||||
appSettings: appSettings,
|
||||
analytics: analytics,
|
||||
|
||||
@@ -477,6 +477,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
roomTimelineControllerFactory: roomTimelineControllerFactory,
|
||||
navigationStackCoordinator: detailNavigationStackCoordinator,
|
||||
emojiProvider: EmojiProvider(),
|
||||
ongoingCallRoomIDPublisher: elementCallService.ongoingCallRoomIDPublisher,
|
||||
appMediator: appMediator,
|
||||
appSettings: appSettings,
|
||||
analytics: analytics,
|
||||
@@ -580,7 +581,7 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {
|
||||
|
||||
private var callScreenPictureInPictureController: AVPictureInPictureController?
|
||||
private func presentCallScreen(configuration: ElementCallConfiguration) {
|
||||
guard elementCallService.ongoingCallRoomID != configuration.callID else {
|
||||
guard elementCallService.ongoingCallRoomIDPublisher.value != configuration.callRoomID else {
|
||||
MXLog.info("Returning to existing call.")
|
||||
callScreenPictureInPictureController?.stopPictureInPicture()
|
||||
return
|
||||
|
||||
@@ -17,12 +17,15 @@
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
struct ElementCallServiceMockConfiguration { }
|
||||
struct ElementCallServiceMockConfiguration {
|
||||
var ongoingCallRoomID: String?
|
||||
}
|
||||
|
||||
extension ElementCallServiceMock {
|
||||
convenience init(_ configuration: ElementCallServiceMockConfiguration) {
|
||||
self.init()
|
||||
|
||||
underlyingActions = PassthroughSubject().eraseToAnyPublisher()
|
||||
underlyingOngoingCallRoomIDPublisher = .init(.init(configuration.ongoingCallRoomID))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4894,7 +4894,11 @@ class ElementCallServiceMock: ElementCallServiceProtocol {
|
||||
set(value) { underlyingActions = value }
|
||||
}
|
||||
var underlyingActions: AnyPublisher<ElementCallServiceAction, Never>!
|
||||
var ongoingCallRoomID: String?
|
||||
var ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never> {
|
||||
get { return underlyingOngoingCallRoomIDPublisher }
|
||||
set(value) { underlyingOngoingCallRoomIDPublisher = value }
|
||||
}
|
||||
var underlyingOngoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>!
|
||||
|
||||
//MARK: - setClientProxy
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
//
|
||||
// Copyright 2024 New Vector Ltd
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension UIView {
|
||||
func addMatchedSubview(_ subview: UIView) {
|
||||
addSubview(subview)
|
||||
subview.translatesAutoresizingMaskIntoConstraints = false
|
||||
NSLayoutConstraint.activate([
|
||||
subview.topAnchor.constraint(equalTo: topAnchor),
|
||||
subview.leadingAnchor.constraint(equalTo: leadingAnchor),
|
||||
subview.trailingAnchor.constraint(equalTo: trailingAnchor),
|
||||
subview.bottomAnchor.constraint(equalTo: bottomAnchor)
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,7 @@ struct RoomScreenCoordinatorParameters {
|
||||
let voiceMessageMediaManager: VoiceMessageMediaManagerProtocol
|
||||
let emojiProvider: EmojiProviderProtocol
|
||||
let completionSuggestionService: CompletionSuggestionServiceProtocol
|
||||
let ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>
|
||||
let appMediator: AppMediatorProtocol
|
||||
let appSettings: AppSettings
|
||||
let composerDraftService: ComposerDraftServiceProtocol
|
||||
@@ -64,6 +65,7 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
|
||||
init(parameters: RoomScreenCoordinatorParameters) {
|
||||
roomViewModel = RoomScreenViewModel(roomProxy: parameters.roomProxy,
|
||||
mediaProvider: parameters.mediaProvider,
|
||||
ongoingCallRoomIDPublisher: parameters.ongoingCallRoomIDPublisher,
|
||||
appMediator: parameters.appMediator,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
|
||||
@@ -46,6 +46,7 @@ struct RoomScreenViewState: BindableState {
|
||||
|
||||
var canJoinCall = false
|
||||
var hasOngoingCall: Bool
|
||||
var shouldShowCallButton = true
|
||||
|
||||
var bindings: RoomScreenViewStateBindings
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
|
||||
init(roomProxy: JoinedRoomProxyProtocol,
|
||||
mediaProvider: MediaProviderProtocol,
|
||||
ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>,
|
||||
appMediator: AppMediatorProtocol,
|
||||
appSettings: AppSettings,
|
||||
analyticsService: AnalyticsService) {
|
||||
@@ -68,7 +69,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
bindings: .init()),
|
||||
mediaProvider: mediaProvider)
|
||||
|
||||
setupSubscriptions()
|
||||
setupSubscriptions(ongoingCallRoomIDPublisher: ongoingCallRoomIDPublisher)
|
||||
}
|
||||
|
||||
override func process(viewAction: RoomScreenViewAction) {
|
||||
@@ -93,7 +94,7 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
state.lastScrollDirection = direction
|
||||
}
|
||||
|
||||
private func setupSubscriptions() {
|
||||
private func setupSubscriptions(ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never>) {
|
||||
let roomInfoSubscription = roomProxy
|
||||
.actionsPublisher
|
||||
.filter { $0 == .roomInfoUpdate }
|
||||
@@ -135,6 +136,14 @@ class RoomScreenViewModel: RoomScreenViewModelType, RoomScreenViewModelProtocol
|
||||
self?.setupPinnedEventsTimelineProviderIfNeeded()
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
ongoingCallRoomIDPublisher
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink { [weak self] ongoingCallRoomID in
|
||||
guard let self else { return }
|
||||
state.shouldShowCallButton = ongoingCallRoomID != roomProxy.id
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
private func buildPinnedEventContents(timelineItems: [TimelineItemProxy]) {
|
||||
@@ -186,6 +195,7 @@ extension RoomScreenViewModel {
|
||||
static func mock(roomProxyMock: JoinedRoomProxyMock) -> RoomScreenViewModel {
|
||||
RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
|
||||
@@ -175,8 +175,10 @@ struct RoomScreen: View {
|
||||
|
||||
if !ProcessInfo.processInfo.isiOSAppOnMac {
|
||||
ToolbarItem(placement: .primaryAction) {
|
||||
callButton
|
||||
.disabled(roomContext.viewState.canJoinCall == false)
|
||||
if roomContext.viewState.shouldShowCallButton {
|
||||
callButton
|
||||
.disabled(!roomContext.viewState.canJoinCall)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -53,6 +53,7 @@ protocol DeveloperOptionsProtocol: AnyObject {
|
||||
var elementCallBaseURLOverride: URL? { get set }
|
||||
var fuzzyRoomListSearchEnabled: Bool { get set }
|
||||
var pinningEnabled: Bool { get set }
|
||||
var elementCallPictureInPictureEnabled: Bool { get set }
|
||||
}
|
||||
|
||||
extension AppSettings: DeveloperOptionsProtocol { }
|
||||
|
||||
+5
@@ -61,6 +61,11 @@ struct DeveloperOptionsScreen: View {
|
||||
.autocorrectionDisabled(true)
|
||||
.autocapitalization(.none)
|
||||
.foregroundColor(URL(string: elementCallBaseURLString) == nil ? .red : .primary)
|
||||
|
||||
Toggle(isOn: $context.elementCallPictureInPictureEnabled) {
|
||||
Text("Picture in Picture support")
|
||||
Text("Requires an Element Call deployment with support for signalling PiP availability.")
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
|
||||
@@ -77,7 +77,7 @@ struct ElementCallConfiguration {
|
||||
}
|
||||
|
||||
/// A string representing the call being configured.
|
||||
var callID: String {
|
||||
var callRoomID: String {
|
||||
switch kind {
|
||||
case .genericCallLink(let url):
|
||||
url.absoluteString
|
||||
|
||||
@@ -57,9 +57,14 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
||||
|
||||
private var endUnansweredCallTask: Task<Void, Never>?
|
||||
|
||||
private var ongoingCallID: CallID?
|
||||
private var ongoingCallID: CallID? {
|
||||
didSet { ongoingCallRoomIDSubject.send(ongoingCallID?.roomID) }
|
||||
}
|
||||
|
||||
var ongoingCallRoomID: String? { ongoingCallID?.roomID }
|
||||
let ongoingCallRoomIDSubject = CurrentValueSubject<String?, Never>(nil)
|
||||
var ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never> {
|
||||
ongoingCallRoomIDSubject.asCurrentValuePublisher()
|
||||
}
|
||||
|
||||
private let actionsSubject: PassthroughSubject<ElementCallServiceAction, Never> = .init()
|
||||
var actions: AnyPublisher<ElementCallServiceAction, Never> {
|
||||
|
||||
@@ -26,7 +26,7 @@ enum ElementCallServiceAction {
|
||||
protocol ElementCallServiceProtocol {
|
||||
var actions: AnyPublisher<ElementCallServiceAction, Never> { get }
|
||||
|
||||
var ongoingCallRoomID: String? { get }
|
||||
var ongoingCallRoomIDPublisher: CurrentValuePublisher<String?, Never> { get }
|
||||
|
||||
func setClientProxy(_ clientProxy: ClientProxyProtocol)
|
||||
|
||||
|
||||
@@ -255,6 +255,7 @@ class MockScreen: Identifiable {
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||
@@ -272,6 +273,7 @@ class MockScreen: Identifiable {
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||
@@ -289,6 +291,7 @@ class MockScreen: Identifiable {
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||
@@ -306,6 +309,7 @@ class MockScreen: Identifiable {
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||
@@ -326,6 +330,7 @@ class MockScreen: Identifiable {
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||
@@ -346,6 +351,7 @@ class MockScreen: Identifiable {
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||
@@ -366,6 +372,7 @@ class MockScreen: Identifiable {
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||
@@ -387,6 +394,7 @@ class MockScreen: Identifiable {
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||
@@ -407,6 +415,7 @@ class MockScreen: Identifiable {
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||
@@ -426,6 +435,7 @@ class MockScreen: Identifiable {
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||
@@ -459,6 +469,7 @@ class MockScreen: Identifiable {
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||
@@ -479,6 +490,7 @@ class MockScreen: Identifiable {
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||
@@ -499,6 +511,7 @@ class MockScreen: Identifiable {
|
||||
voiceMessageMediaManager: VoiceMessageMediaManagerMock(),
|
||||
emojiProvider: EmojiProvider(),
|
||||
completionSuggestionService: CompletionSuggestionServiceMock(configuration: .init()),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
composerDraftService: ComposerDraftServiceMock(.init()))
|
||||
|
||||
@@ -308,6 +308,7 @@ class RoomFlowCoordinatorTests: XCTestCase {
|
||||
roomTimelineControllerFactory: timelineControllerFactory,
|
||||
navigationStackCoordinator: navigationStackCoordinator,
|
||||
emojiProvider: EmojiProvider(),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analytics: ServiceLocator.shared.analytics,
|
||||
|
||||
@@ -44,6 +44,7 @@ class RoomScreenViewModelTests: XCTestCase {
|
||||
roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher()
|
||||
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
@@ -112,6 +113,7 @@ class RoomScreenViewModelTests: XCTestCase {
|
||||
roomProxyMock.underlyingActionsPublisher = updateSubject.eraseToAnyPublisher()
|
||||
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
ongoingCallRoomIDPublisher: .init(.init(nil)),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
@@ -140,4 +142,42 @@ class RoomScreenViewModelTests: XCTestCase {
|
||||
updateSubject.send(.roomInfoUpdate)
|
||||
try await deferred.fulfill()
|
||||
}
|
||||
|
||||
func testCallButtonVisibility() async throws {
|
||||
// Given a room screen with no ongoing call.
|
||||
let ongoingCallRoomIDSubject = CurrentValueSubject<String?, Never>(nil)
|
||||
let roomProxyMock = JoinedRoomProxyMock(.init(id: "MyRoomID"))
|
||||
let viewModel = RoomScreenViewModel(roomProxy: roomProxyMock,
|
||||
mediaProvider: MockMediaProvider(),
|
||||
ongoingCallRoomIDPublisher: ongoingCallRoomIDSubject.asCurrentValuePublisher(),
|
||||
appMediator: AppMediatorMock.default,
|
||||
appSettings: ServiceLocator.shared.settings,
|
||||
analyticsService: ServiceLocator.shared.analytics)
|
||||
self.viewModel = viewModel
|
||||
XCTAssertTrue(viewModel.state.shouldShowCallButton)
|
||||
|
||||
// When a call starts in this room.
|
||||
var deferred = deferFulfillment(viewModel.context.$viewState) { !$0.shouldShowCallButton }
|
||||
ongoingCallRoomIDSubject.send("MyRoomID")
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the call button should be hidden.
|
||||
XCTAssertFalse(viewModel.state.shouldShowCallButton)
|
||||
|
||||
// When a call starts in a different room.
|
||||
deferred = deferFulfillment(viewModel.context.$viewState) { $0.shouldShowCallButton }
|
||||
ongoingCallRoomIDSubject.send("OtherRoomID")
|
||||
try await deferred.fulfill()
|
||||
|
||||
// Then the call button should be shown again.
|
||||
XCTAssertTrue(viewModel.state.shouldShowCallButton)
|
||||
|
||||
// When the call from the other room finishes.
|
||||
let deferredFailure = deferFailure(viewModel.context.$viewState, timeout: 1) { !$0.shouldShowCallButton }
|
||||
ongoingCallRoomIDSubject.send(nil)
|
||||
try await deferredFailure.fulfill()
|
||||
|
||||
// Then the call button should remain visible shown.
|
||||
XCTAssertTrue(viewModel.state.shouldShowCallButton)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user