Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Navigation support for upcoming Element Call Picture in Picture mode. #3174

Merged
merged 4 commits into from
Aug 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ElementX/Sources/Application/AppSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,9 @@ final class AppSettings {

@UserPreference(key: UserDefaultsKeys.timelineItemAuthenticityEnabled, defaultValue: false, storageType: .userDefaults(store))
var timelineItemAuthenticityEnabled

// Not user configurable as it depends on work in EC too.
let elementCallPictureInPictureEnabled = false

#endif

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,28 @@ class NavigationSplitCoordinator: CoordinatorProtocol, ObservableObject, CustomS
fullScreenCoverModule?.coordinator
}

@Published fileprivate var overlayModule: NavigationModule? {
didSet {
if let oldValue {
logPresentationChange("Remove overlay", oldValue)
oldValue.tearDown()
}

if let overlayModule {
logPresentationChange("Set overlay", overlayModule)
overlayModule.coordinator?.start()
}
}
}

/// The currently displayed overlay coordinator
var overlayCoordinator: (any CoordinatorProtocol)? {
overlayModule?.coordinator
}

enum OverlayPresentationMode { case fullScreen, minimized }
@Published fileprivate var overlayPresentationMode: OverlayPresentationMode = .minimized

fileprivate var compactLayoutRootModule: NavigationModule? {
if let sidebarNavigationStackCoordinator = sidebarModule?.coordinator as? NavigationStackCoordinator {
if let sidebarRootModule = sidebarNavigationStackCoordinator.rootModule {
Expand Down Expand Up @@ -282,6 +304,47 @@ class NavigationSplitCoordinator: CoordinatorProtocol, ObservableObject, CustomS
fullScreenCoverModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
}
}

/// Present an overlay on top of the split view
/// - Parameters:
/// - coordinator: the coordinator to display
/// - presentationMode: how the coordinator should be presented
/// - animated: whether the transition should be animated
/// - dismissalCallback: called when the overlay has been dismissed, programatically or otherwise
func setOverlayCoordinator(_ coordinator: (any CoordinatorProtocol)?,
presentationMode: OverlayPresentationMode = .fullScreen,
animated: Bool = true,
dismissalCallback: (() -> Void)? = nil) {
guard let coordinator else {
overlayModule = nil
return
}

if overlayModule?.coordinator === coordinator {
fatalError("Cannot use the same coordinator more than once")
}

var transaction = Transaction()
transaction.disablesAnimations = !animated

withTransaction(transaction) {
overlayPresentationMode = presentationMode
overlayModule = NavigationModule(coordinator, dismissalCallback: dismissalCallback)
}
}

/// Updates the presentation of the overlay coordinator.
/// - Parameters:
/// - mode: The type of presentation to use.
/// - animated: whether the transition should be animated
func setOverlayPresentationMode(_ mode: OverlayPresentationMode, animated: Bool = true) {
var transaction = Transaction()
transaction.disablesAnimations = !animated

withTransaction(transaction) {
overlayPresentationMode = mode
}
}

// MARK: - CoordinatorProtocol

Expand Down Expand Up @@ -385,6 +448,16 @@ private struct NavigationSplitCoordinatorView: View {
module.coordinator?.toPresentable()
.id(module.id)
}
.overlay {
Group {
if let coordinator = navigationSplitCoordinator.overlayModule?.coordinator {
coordinator.toPresentable()
.opacity(navigationSplitCoordinator.overlayPresentationMode == .minimized ? 0 : 1)
.transition(.opacity)
}
}
.animation(.elementDefault, value: navigationSplitCoordinator.overlayPresentationMode)
}
// Handle `horizontalSizeClass` changes breaking the navigation bar
// https://github.com/element-hq/element-x-ios/issues/617
.onChange(of: horizontalSizeClass) { value in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// limitations under the License.
//

import AVKit
import Combine
import SwiftUI

Expand Down Expand Up @@ -557,27 +558,44 @@ class UserSessionFlowCoordinator: FlowCoordinatorProtocol {

// MARK: Calls

private var callScreenPictureInPictureController: AVPictureInPictureController?
private func presentCallScreen(roomProxy: RoomProxyProtocol) {
guard elementCallService.ongoingCallRoomID != roomProxy.id else {
MXLog.info("Returning to existing call.")
callScreenPictureInPictureController?.stopPictureInPicture()
return
}

let colorScheme: ColorScheme = appMediator.windowManager.mainWindow.traitCollection.userInterfaceStyle == .light ? .light : .dark
let callScreenCoordinator = CallScreenCoordinator(parameters: .init(elementCallService: elementCallService,
clientProxy: userSession.clientProxy,
roomProxy: roomProxy,
clientID: InfoPlistReader.main.bundleIdentifier,
elementCallBaseURL: appSettings.elementCallBaseURL,
elementCallBaseURLOverride: appSettings.elementCallBaseURLOverride,
elementCallPictureInPictureEnabled: appSettings.elementCallPictureInPictureEnabled,
colorScheme: colorScheme,
appHooks: appHooks))

callScreenCoordinator.actions
.sink { [weak self] action in
guard let self else { return }
switch action {
case .pictureInPictureStarted(let controller):
MXLog.info("Hiding call for PiP presentation.")
callScreenPictureInPictureController = controller
navigationSplitCoordinator.setOverlayPresentationMode(.minimized)
case .pictureInPictureStopped:
MXLog.info("Restoring call after PiP presentation.")
navigationSplitCoordinator.setOverlayPresentationMode(.fullScreen)
callScreenPictureInPictureController = nil
case .dismiss:
self?.navigationSplitCoordinator.setSheetCoordinator(nil)
navigationSplitCoordinator.setOverlayCoordinator(nil)
}
}
.store(in: &cancellables)

navigationSplitCoordinator.setSheetCoordinator(callScreenCoordinator, animated: true)
navigationSplitCoordinator.setOverlayCoordinator(callScreenCoordinator, animated: true)

analytics.track(screen: .RoomCall)
}
Expand Down
1 change: 1 addition & 0 deletions ElementX/Sources/Mocks/Generated/GeneratedMocks.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4894,6 +4894,7 @@ class ElementCallServiceMock: ElementCallServiceProtocol {
set(value) { underlyingActions = value }
}
var underlyingActions: AnyPublisher<ElementCallServiceAction, Never>!
var ongoingCallRoomID: String?

//MARK: - setClientProxy

Expand Down
12 changes: 12 additions & 0 deletions ElementX/Sources/Screens/CallScreen/CallScreenCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// limitations under the License.
//

import AVKit
import Combine
import SwiftUI

Expand All @@ -24,11 +25,17 @@ struct CallScreenCoordinatorParameters {
let clientID: String
let elementCallBaseURL: URL
let elementCallBaseURLOverride: URL?
let elementCallPictureInPictureEnabled: Bool
let colorScheme: ColorScheme
let appHooks: AppHooks
}

enum CallScreenCoordinatorAction {
/// The call is still ongoing but the user wishes to navigate around the app.
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.
case dismiss
}

Expand All @@ -48,6 +55,7 @@ final class CallScreenCoordinator: CoordinatorProtocol {
clientID: parameters.clientID,
elementCallBaseURL: parameters.elementCallBaseURL,
elementCallBaseURLOverride: parameters.elementCallBaseURLOverride,
elementCallPictureInPictureEnabled: parameters.elementCallPictureInPictureEnabled,
colorScheme: parameters.colorScheme,
appHooks: parameters.appHooks)
}
Expand All @@ -57,6 +65,10 @@ final class CallScreenCoordinator: CoordinatorProtocol {
guard let self else { return }

switch action {
case .pictureInPictureStarted(let controller):
actionsSubject.send(.pictureInPictureStarted(controller))
case .pictureInPictureStopped:
actionsSubject.send(.pictureInPictureStopped)
case .dismiss:
actionsSubject.send(.dismiss)
}
Expand Down
4 changes: 4 additions & 0 deletions ElementX/Sources/Screens/CallScreen/CallScreenModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,12 @@
// limitations under the License.
//

import AVKit
import Foundation

enum CallScreenViewModelAction {
case pictureInPictureStarted(AVPictureInPictureController?)
case pictureInPictureStopped
case dismiss
}

Expand All @@ -39,4 +42,5 @@ struct Bindings {

enum CallScreenViewAction {
case urlChanged(URL?)
case navigateBack
}
46 changes: 46 additions & 0 deletions ElementX/Sources/Screens/CallScreen/CallScreenViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
// limitations under the License.
//

import AVKit
import CallKit
import Combine
import SwiftUI
Expand All @@ -23,6 +24,7 @@ typealias CallScreenViewModelType = StateStoreViewModel<CallScreenViewState, Cal
class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol {
private let elementCallService: ElementCallServiceProtocol
private let roomProxy: RoomProxyProtocol
private let isPictureInPictureEnabled: Bool

private let widgetDriver: ElementCallWidgetDriverProtocol

Expand All @@ -45,12 +47,14 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
clientID: String,
elementCallBaseURL: URL,
elementCallBaseURLOverride: URL?,
elementCallPictureInPictureEnabled: Bool,
colorScheme: ColorScheme,
appHooks: AppHooks) {
guard let deviceID = clientProxy.deviceID else { fatalError("Missing device ID for the call.") }

self.elementCallService = elementCallService
self.roomProxy = roomProxy
isPictureInPictureEnabled = elementCallPictureInPictureEnabled

widgetDriver = roomProxy.elementCallWidgetDriver(deviceID: deviceID)

Expand Down Expand Up @@ -151,13 +155,32 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol
syncUpdateCancellable = nil
}
}, receiveValue: { _ in })

// 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)
}

override func process(viewAction: CallScreenViewAction) {
switch viewAction {
case .urlChanged(let url):
guard let url else { return }
MXLog.info("URL changed to: \(url)")
case .navigateBack:
handleBackwardsNavigation()
}
}

Expand All @@ -171,6 +194,29 @@ class CallScreenViewModel: CallScreenViewModelType, CallScreenViewModelProtocol

// MARK: - Private

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 {
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)
// }
}
}

private func setAudioEnabled(_ enabled: Bool) async {
let message = ElementCallWidgetMessage(direction: .toWidget,
action: .mediaState,
Expand Down
23 changes: 23 additions & 0 deletions ElementX/Sources/Screens/CallScreen/View/CallScreen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,34 @@
//

import Combine
import SFSafeSymbols
import SwiftUI
import WebKit

struct CallScreen: View {
@ObservedObject var context: CallScreenViewModel.Context

var body: some View {
NavigationStack {
content
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.compound.bgCanvasDefault.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button { context.send(viewAction: .navigateBack) } label: {
Image(systemSymbol: .chevronBackward)
.fontWeight(.semibold)
}
.offset(y: -8)
// .padding(.leading, -8) // Fixes the button alignment, but harder to tap.
}
}
}
}

@ViewBuilder
var content: some View {
if context.viewState.url == nil {
ProgressView()
} else {
Expand Down Expand Up @@ -187,6 +208,7 @@ struct CallScreen_Previews: PreviewProvider {
static let viewModel = {
let clientProxy = ClientProxyMock()
clientProxy.getElementWellKnownReturnValue = .success(nil)
clientProxy.deviceID = "call-device-id"

let roomProxy = RoomProxyMock()
roomProxy.sendCallNotificationIfNeeededReturnValue = .success(())
Expand All @@ -204,6 +226,7 @@ struct CallScreen_Previews: PreviewProvider {
clientID: "io.element.elementx",
elementCallBaseURL: "https://call.element.io",
elementCallBaseURLOverride: nil,
elementCallPictureInPictureEnabled: false,
colorScheme: .light,
appHooks: AppHooks())
}()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,8 @@ final class RoomScreenCoordinator: CoordinatorProtocol {
actionsSubject.send(.presentRoomDetails)
case .displayCall:
actionsSubject.send(.presentCallScreen)
case .removeComposerFocus:
composerViewModel.process(timelineAction: .removeFocus)
}
}
.store(in: &cancellables)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ enum RoomScreenViewModelAction {
case displayPinnedEventsTimeline
case displayRoomDetails
case displayCall
case removeComposerFocus
}

enum RoomScreenViewAction {
Expand Down
Loading
Loading