AzureCommunicationUI/sdk/AzureCommunicationUICalling/Sources/Presentation/SwiftUI/Calling/CallingViewComponent/ControlBarViewModel.swift (303 lines of code) (raw):
//
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
//
import Combine
import Foundation
import UIKit
import SwiftUI
// swiftlint:disable type_body_length
class ControlBarViewModel: ObservableObject {
private let logger: Logger
private let localizationProvider: LocalizationProviderProtocol
private let dispatch: ActionDispatch
private var isCameraStateUpdating = false
private var isDefaultUserStateMapped = false
private(set) var cameraButtonViewModel: IconButtonViewModel!
private let controlBarOptions: CallScreenControlBarOptions?
private let audioVideoMode: CallCompositeAudioVideoMode
var onDrawerViewDidDisappearBlock: (() -> Void)?
private let accessibilityProvider: AccessibilityProviderProtocol
@Published var cameraPermission: AppPermission.Status = .unknown
@Published var isShareActivityDisplayed = false
@Published var isDisplayed = false
@Published var isCameraDisplayed = true
@Published var totalButtonCount = 5
@Published var isMoreButtonShouldFocused = false
var micButtonViewModel: IconButtonViewModel!
var audioDeviceButtonViewModel: IconButtonViewModel!
var hangUpButtonViewModel: IconButtonViewModel!
var moreButtonViewModel: IconButtonViewModel!
var debugInfoSharingActivityViewModel: DebugInfoSharingActivityViewModel!
var callingStatus: CallingStatus = .none
var operationStatus: OperationStatus = .none
var cameraState = LocalUserState.CameraState(operation: .off,
device: .front,
transmission: .local)
var audioState = LocalUserState.AudioState(operation: .off,
device: .receiverSelected)
var buttonViewDataState = ButtonViewDataState()
var onEndCallTapped: (() -> Void)
var capabilitiesManager: CapabilitiesManager
var capabilities: Set<ParticipantCapabilityType>
// swaftlint:disable function_body_length
init(compositeViewModelFactory: CompositeViewModelFactoryProtocol,
logger: Logger,
localizationProvider: LocalizationProviderProtocol,
dispatchAction: @escaping ActionDispatch,
onEndCallTapped: @escaping (() -> Void),
localUserState: LocalUserState,
accessibilityProvider: AccessibilityProviderProtocol,
audioVideoMode: CallCompositeAudioVideoMode,
capabilitiesManager: CapabilitiesManager,
controlBarOptions: CallScreenControlBarOptions?,
buttonViewDataState: ButtonViewDataState) {
self.logger = logger
self.localizationProvider = localizationProvider
self.dispatch = dispatchAction
self.onEndCallTapped = onEndCallTapped
self.audioVideoMode = audioVideoMode
self.accessibilityProvider = accessibilityProvider
self.capabilitiesManager = capabilitiesManager
self.capabilities = localUserState.capabilities
self.controlBarOptions = controlBarOptions
self.buttonViewDataState = buttonViewDataState
setupCameraButtonViewModel(factory: compositeViewModelFactory)
setupMicButtonViewModel(factory: compositeViewModelFactory)
setupAudioDeviceButtonViewModel(factory: compositeViewModelFactory)
setupHangUpButtonViewModel(factory: compositeViewModelFactory)
setupMoreButtonViewModel(factory: compositeViewModelFactory)
debugInfoSharingActivityViewModel = compositeViewModelFactory.makeDebugInfoSharingActivityViewModel()
updateTotalButtonCount()
accessibilityProvider.subscribeToUIFocusDidUpdateNotification(self)
accessibilityProvider.subscribeToVoiceOverStatusDidChangeNotification(self)
}
private func setupCameraButtonViewModel(factory: CompositeViewModelFactoryProtocol) {
cameraButtonViewModel = factory.makeIconButtonViewModel(
iconName: .videoOff,
buttonType: .controlButton,
isDisabled: isCameraDisabled(),
isVisible: isCameraVisible()) { [weak self] in
guard let self = self else {
return
}
self.logger.debug("Toggle camera button tapped")
self.cameraButtonTapped()
}
cameraButtonViewModel.accessibilityLabel = localizationProvider.getLocalizedString(.videoOffAccessibilityLabel)
}
private func setupMicButtonViewModel(factory: CompositeViewModelFactoryProtocol) {
micButtonViewModel = factory.makeIconButtonViewModel(
iconName: .micOff,
buttonType: .controlButton,
isDisabled: isMicDisabled(),
isVisible: isMicVisible()) { [weak self] in
guard let self = self else {
return
}
self.logger.debug("Toggle microphone button tapped")
self.microphoneButtonTapped()
}
micButtonViewModel.accessibilityLabel = localizationProvider.getLocalizedString(.micOffAccessibilityLabel)
}
private func setupAudioDeviceButtonViewModel(factory: CompositeViewModelFactoryProtocol) {
audioDeviceButtonViewModel = factory.makeIconButtonViewModel(
iconName: .speakerFilled,
buttonType: .controlButton,
isDisabled: false,
isVisible: isAudioDeviceVisible()) { [weak self] in
guard let self = self else {
return
}
self.logger.debug("Select audio device button tapped")
self.selectAudioDeviceButtonTapped()
}
audioDeviceButtonViewModel.accessibilityLabel = localizationProvider.getLocalizedString(
.deviceAccesibiiltyLabel
)
}
private func setupHangUpButtonViewModel(factory: CompositeViewModelFactoryProtocol) {
hangUpButtonViewModel = factory.makeIconButtonViewModel(
iconName: .endCall,
buttonType: .roundedRectButton,
isDisabled: false,
isVisible: true) { [weak self] in
guard let self = self else {
return
}
self.logger.debug("Hangup button tapped")
self.onEndCallTapped()
}
hangUpButtonViewModel.accessibilityLabel = localizationProvider.getLocalizedString(.leaveCall)
}
private func setupMoreButtonViewModel(factory: CompositeViewModelFactoryProtocol) {
moreButtonViewModel = factory.makeIconButtonViewModel(
iconName: .more,
buttonType: .controlButton,
isDisabled: false,
isVisible: isMoreButtonVisible()) { [weak self] in
guard let self = self else {
return
}
self.moreButtonTapped()
}
moreButtonViewModel.accessibilityLabel = localizationProvider.getLocalizedString(.moreAccessibilityLabel)
}
func setAccessibilityFocus(_ focusType: any View) {
UIAccessibility.post(notification: .layoutChanged, argument: focusType)
}
func cameraButtonTapped() {
guard !isCameraStateUpdating else {
return
}
isCameraStateUpdating = true
let action: LocalUserAction = cameraState.operation == .on ?
.cameraOffTriggered : .cameraOnTriggered
dispatch(.localUserAction(action))
}
func isMoreButtonVisible() -> Bool {
buttonViewDataState.callScreenCustomButtonsState.filter({ button in button.visible }).isEmpty == false ||
buttonViewDataState.liveCaptionsButton?.visible ?? true ||
buttonViewDataState.liveCaptionsToggleButton?.visible ?? true ||
buttonViewDataState.captionsLanguageButton?.visible ?? true ||
buttonViewDataState.spokenLanguageButton?.visible ?? true ||
buttonViewDataState.shareDiagnosticsButton?.visible ?? true ||
buttonViewDataState.reportIssueButton?.visible ?? true
}
func isMicVisible() -> Bool {
buttonViewDataState.callScreenMicButtonState?.visible ?? true
}
func isMicDisabled() -> Bool {
buttonViewDataState.callScreenMicButtonState?.enabled == false ||
audioState.operation == .pending ||
callingStatus == .localHold ||
isBypassLoadingOverlay() ||
callingStatus == .inLobby ||
!self.capabilitiesManager.hasCapability(capabilities: self.capabilities,
capability: ParticipantCapabilityType.unmuteMicrophone)
}
func isBypassLoadingOverlay() -> Bool {
operationStatus == .skipSetupRequested &&
callingStatus != .connected &&
callingStatus != .inLobby
}
func isAudioDeviceVisible() -> Bool {
buttonViewDataState.callScreenAudioDeviceButtonState?.visible ?? true
}
func isAudioDeviceDisabled() -> Bool {
buttonViewDataState.callScreenAudioDeviceButtonState?.enabled == false ||
callingStatus == .localHold ||
isBypassLoadingOverlay() ||
callingStatus == .inLobby
}
func microphoneButtonTapped() {
self.callCustomOnClickHandler(controlBarOptions?.microphoneButton)
let action: LocalUserAction = audioState.operation == .on ?
.microphoneOffTriggered : .microphoneOnTriggered
dispatch(.localUserAction(action))
}
func selectAudioDeviceButtonTapped() {
self.callCustomOnClickHandler(controlBarOptions?.audioDeviceButton)
dispatch(.showAudioSelection)
}
func moreButtonTapped() {
NotificationCenter.default.addObserver(
forName: Notification.Name(NotificationCenterName.drawerViewDidDisappear.rawValue),
object: nil, queue: .main) { _ in
self.onDrawerViewDidDisappearBlock?()
}
dispatch(.showMoreOptions)
}
func isMoreButtonDisabled() -> Bool {
isBypassLoadingOverlay() || callingStatus == .inLobby
}
func isCameraVisible() -> Bool {
return audioVideoMode != .audioOnly &&
buttonViewDataState.callScreenCameraButtonState?.visible ?? true
}
func isCameraDisabled() -> Bool {
buttonViewDataState.callScreenCameraButtonState?.enabled == false ||
cameraPermission == .denied ||
cameraState.operation == .pending ||
callingStatus == .localHold ||
callingStatus == .inLobby ||
isCameraStateUpdating ||
isBypassLoadingOverlay() ||
!capabilitiesManager.hasCapability(capabilities: self.capabilities,
capability: ParticipantCapabilityType.turnVideoOn)
}
func update(localUserState: LocalUserState,
permissionState: PermissionState,
callingState: CallingState,
visibilityState: VisibilityState,
navigationState: NavigationState,
buttonViewDataState: ButtonViewDataState
) {
isShareActivityDisplayed = navigationState.supportShareSheetVisible
callingStatus = callingState.status
operationStatus = callingState.operationStatus
self.buttonViewDataState = buttonViewDataState
self.capabilities = localUserState.capabilities
if cameraPermission != permissionState.cameraPermission {
cameraPermission = permissionState.cameraPermission
}
if isCameraStateUpdating,
cameraState.operation != localUserState.cameraState.operation {
isCameraStateUpdating = localUserState.cameraState.operation != .on &&
localUserState.cameraState.operation != .off
}
cameraState = localUserState.cameraState
cameraButtonViewModel.update(iconName: cameraState.operation == .on ? .videoOn : .videoOff)
cameraButtonViewModel.update(accessibilityLabel: cameraState.operation == .on
? localizationProvider.getLocalizedString(.videoOnAccessibilityLabel)
: localizationProvider.getLocalizedString(.videoOffAccessibilityLabel))
cameraButtonViewModel.update(isDisabled: isCameraDisabled())
cameraButtonViewModel.update(isVisible: isCameraVisible())
audioState = localUserState.audioState
micButtonViewModel.update(iconName: audioState.operation == .on ? .micOn : .micOff)
micButtonViewModel.update(accessibilityLabel: audioState.operation == .on
? localizationProvider.getLocalizedString(.micOnAccessibilityLabel)
: localizationProvider.getLocalizedString(.micOffAccessibilityLabel))
micButtonViewModel.update(isDisabled: isMicDisabled())
micButtonViewModel.update(isVisible: isMicVisible())
audioDeviceButtonViewModel.update(isDisabled: isAudioDeviceDisabled())
let audioDeviceState = localUserState.audioState.device
audioDeviceButtonViewModel.update(
iconName: audioDeviceState.icon
)
audioDeviceButtonViewModel.update(
accessibilityValue: audioDeviceState.getLabel(localizationProvider: localizationProvider))
audioDeviceButtonViewModel.update(isVisible: isAudioDeviceVisible())
moreButtonViewModel.update(isDisabled: isMoreButtonDisabled())
moreButtonViewModel.update(isVisible: isMoreButtonVisible())
isDisplayed = visibilityState.currentStatus != .pipModeEntered
isMoreButtonShouldFocused = true
updateTotalButtonCount()
}
func callCustomOnClickHandler(_ button: ButtonViewData?) {
guard let button = button else {
return
}
button.onClick?(button)
}
private func updateTotalButtonCount() {
// we always have a hangUp button
var newCount = 1
if cameraButtonViewModel.isVisible {
newCount += 1
}
if micButtonViewModel.isVisible {
newCount += 1
}
if audioDeviceButtonViewModel.isVisible {
newCount += 1
}
if moreButtonViewModel.isVisible {
newCount += 1
}
if newCount != totalButtonCount {
totalButtonCount = newCount
}
}
}
// swiftlint:enable type_body_length
extension ControlBarViewModel: AccessibilityProviderNotificationsObserver {
func didChangeVoiceOverStatus(_ notification: NSNotification) {
// Call the closure to handle the drawer view disappearance
onDrawerViewDidDisappearBlock?()
}
func didUIFocusUpdateNotification(_ notification: NSNotification) {
// Call the closure to handle the drawer view disappearance
onDrawerViewDidDisappearBlock?()
}
}