Add individual call support to group call controls view

This commit is contained in:
Marissa Le Coz 2023-11-15 17:44:14 -05:00 committed by GitHub
parent 1ce1dcc9f1
commit 1e258907c9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 368 additions and 111 deletions

View File

@ -194,6 +194,34 @@ public class IndividualCall: NSObject {
}
}
/// `JoinState` is a group call concept, but we want to bridge
/// between the two call types.
/// TODO: Continue to tweak this as we unify the individual and
/// group call UIs.
var joinState: JoinState {
switch self.state {
case .idle,
.remoteHangup,
.remoteHangupNeedPermission,
.localHangup,
.remoteRinging,
.localRinging_Anticipatory,
.localRinging_ReadyToAnswer,
.remoteBusy,
.localFailure,
.busyElsewhere,
.answeredElsewhere,
.declinedElsewhere:
return .notJoined
case .connected,
.accepting,
.answering,
.reconnecting,
.dialing:
return .joined
}
}
public var offerMediaType: TSRecentCallOfferType = .audio
// We start out muted if the record permission isn't granted. This should generally

View File

@ -119,6 +119,27 @@ public class SignalCall: NSObject, CallManagerCallReference {
}
}
public var isOutgoingVideoMuted: Bool {
switch mode {
case .individual(let call): return !call.hasLocalVideo
case .group(let call): return call.isOutgoingVideoMuted
}
}
public var joinState: JoinState {
switch mode {
case .individual(let call): return call.joinState
case .group(let call): return call.localDeviceState.joinState
}
}
public var canJoin: Bool {
switch mode {
case .individual(_): return true
case .group(let call): return !call.isFull
}
}
/// Returns the remote party for an incoming 1:1 call, or the ringer for a group call ring.
///
/// Returns `nil` for an outgoing 1:1 call, a manually-entered group call,
@ -165,8 +186,16 @@ public class SignalCall: NSObject, CallManagerCallReference {
internal var ringRestrictions: RingRestrictions {
didSet {
AssertIsOnMainThread()
if ringRestrictions != oldValue && groupCall.localDeviceState.joinState == .notJoined {
if
isGroupCall,
ringRestrictions != oldValue,
joinState == .notJoined
{
// Use a fake local state change to refresh the call controls.
//
// If we ever introduce ringing restrictions for 1:1 calls,
// a similar affordance will be needed to refresh the call
// controls.
self.groupCall(onLocalDeviceStateChanged: groupCall)
}
}

View File

@ -19,6 +19,7 @@ protocol CallControlsDelegate: AnyObject {
}
class CallControls: UIView {
private lazy var topStackView = createTopStackView()
private lazy var hangUpButton: CallButton = {
let button = createButton(
iconName: "phone-down-fill-28",
@ -90,19 +91,21 @@ class CallControls: UIView {
return view
}()
private lazy var topStackView = createTopStackView()
private weak var delegate: CallControlsDelegate!
private let call: SignalCall
private let viewModel: CallControlsViewModel
init(call: SignalCall, delegate: CallControlsDelegate) {
self.call = call
init(
call: SignalCall,
callService: CallService,
delegate: CallControlsDelegate
) {
self.viewModel = CallControlsViewModel(call: call, callService: callService)
self.delegate = delegate
super.init(frame: .zero)
call.addObserverAndSyncState(observer: self)
callService.audioService.delegate = self
viewModel.refreshView = { [weak self] in
self?.updateControls()
}
addSubview(gradientView)
gradientView.autoPinEdgesToSuperviewEdges()
@ -113,7 +116,10 @@ class CallControls: UIView {
joinButton.autoPinWidthToSuperviewMargins(relation: .lessThanOrEqual)
joinButton.autoPinHeightToSuperview()
let controlsStack = UIStackView(arrangedSubviews: [topStackView, joinButtonContainer])
let controlsStack = UIStackView(arrangedSubviews: [
topStackView,
joinButtonContainer
])
controlsStack.axis = .vertical
controlsStack.spacing = 40
controlsStack.alignment = .center
@ -129,11 +135,6 @@ class CallControls: UIView {
updateControls()
}
deinit {
call.removeObserver(self)
callService.audioService.delegate = nil
}
func createTopStackView() -> UIStackView {
let stackView = UIStackView()
stackView.axis = .horizontal
@ -150,99 +151,59 @@ class CallControls: UIView {
}
private func updateControls() {
let hasExternalAudioInputs = callService.audioService.hasExternalInputs
let isLocalVideoMuted = call.groupCall.isOutgoingVideoMuted
let joinState = call.groupCall.localDeviceState.joinState
// Top row
audioSourceButton.isHidden = viewModel.audioSourceButtonIsHidden
hangUpButton.isHidden = viewModel.hangUpButtonIsHidden
muteButton.isHidden = viewModel.muteButtonIsHidden
videoButton.isHidden = viewModel.videoButtonIsHidden
flipCameraButton.isHidden = viewModel.flipCameraButtonIsHidden
ringButton.isHidden = viewModel.ringButtonIsHidden
flipCameraButton.isHidden = isLocalVideoMuted
videoButton.isSelected = !isLocalVideoMuted
muteButton.isSelected = call.groupCall.isOutgoingAudioMuted
// Bottom row
joinButton.superview?.isHidden = viewModel.joinButtonIsHidden
ringButton.isHidden = joinState == .joined || call.ringRestrictions.intersects([.notApplicable, .callInProgress])
// Leave the button visible but locked if joining, like the "join call" button.
ringButton.isUserInteractionEnabled = joinState == .notJoined
if call.ringRestrictions.isEmpty, case .shouldRing = call.groupCallRingState {
ringButton.isSelected = true
} else {
ringButton.isSelected = false
}
// Leave the button enabled so we can present an explanatory toast, but show it disabled.
ringButton.shouldDrawAsDisabled = !call.ringRestrictions.isEmpty
hangUpButton.isHidden = joinState != .joined
if !UIDevice.current.isIPad {
// Use small controls if video is enabled and we have external
// audio inputs, because we have five buttons now.
[audioSourceButton, flipCameraButton, videoButton, muteButton, ringButton, hangUpButton].forEach {
let isSmall = hasExternalAudioInputs && !isLocalVideoMuted
$0.isSmall = isSmall
if UIDevice.current.isNarrowerThanIPhone6 {
topStackView.spacing = isSmall ? 12 : 16
}
// Sizing and spacing
let controlCount = topStackView.arrangedSubviews.filter({!$0.isHidden}).count
topStackView.spacing = viewModel.controlSpacing(controlCount: controlCount)
let shouldControlButtonsBeSmall = viewModel.shouldControlButtonsBeSmall(controlCount: controlCount)
for view in topStackView.arrangedSubviews {
if let button = view as? CallButton {
button.isSmall = shouldControlButtonsBeSmall
}
}
// Audio Source Handling
if hasExternalAudioInputs, let audioSource = callService.audioService.currentAudioSource {
audioSourceButton.showDropdownArrow = true
audioSourceButton.isHidden = false
if audioSource.isBuiltInEarPiece {
audioSourceButton.iconName = "phone-fill-28"
} else if audioSource.isBuiltInSpeaker {
audioSourceButton.iconName = "speaker-fill-28"
} else {
audioSourceButton.iconName = "speaker-bt-fill-28"
}
} else if UIDevice.current.isIPad {
// iPad *only* supports speaker mode, if there are no external
// devices connected, so we don't need to show the button unless
// we have alternate audio sources.
audioSourceButton.isHidden = true
} else {
// If there are no external audio sources, and video is enabled,
// speaker mode is always enabled so we don't need to show the button.
audioSourceButton.isHidden = !isLocalVideoMuted
// No bluetooth audio detected
audioSourceButton.iconName = "speaker-fill-28"
audioSourceButton.showDropdownArrow = false
}
// Show/hide the superview to adjust the containing stack.
joinButton.superview?.isHidden = joinState == .joined
gradientView.isHidden = joinState != .joined
gradientView.isHidden = viewModel.gradientViewIsHidden
if call.groupCall.isFull {
// Make the button look disabled, but don't actually disable it.
// We want to show a toast if the user taps anyway.
joinButton.setTitleColor(.ows_whiteAlpha40, for: .normal)
joinButton.adjustsImageWhenHighlighted = false
videoButton.isSelected = viewModel.videoButtonIsSelected
muteButton.isSelected = viewModel.muteButtonIsSelected
joinButton.setTitle(
OWSLocalizedString(
"GROUP_CALL_IS_FULL",
comment: "Text explaining the group call is full"),
for: .normal)
if !viewModel.audioSourceButtonIsHidden {
let config = viewModel.audioSourceButtonConfiguration
audioSourceButton.showDropdownArrow = config.showDropdownArrow
audioSourceButton.iconName = config.iconName
}
} else if joinState == .joining || joinState == .pending {
joinButton.isUserInteractionEnabled = false
joinButtonActivityIndicator.startAnimating()
if
!viewModel.ringButtonIsHidden,
let ringButtonConfig = viewModel.ringButtonConfiguration
{
ringButton.isUserInteractionEnabled = ringButtonConfig.isUserInteractionEnabled
ringButton.isSelected = ringButtonConfig.isSelected
ringButton.shouldDrawAsDisabled = ringButtonConfig.shouldDrawAsDisabled
}
joinButton.setTitle("", for: .normal)
} else {
joinButton.setTitleColor(.white, for: .normal)
joinButton.adjustsImageWhenHighlighted = true
joinButton.isUserInteractionEnabled = true
joinButtonActivityIndicator.stopAnimating()
let startCallText = OWSLocalizedString("GROUP_CALL_START_BUTTON", comment: "Button to start a group call")
let joinCallText = OWSLocalizedString("GROUP_CALL_JOIN_BUTTON", comment: "Button to join an ongoing group call")
joinButton.setTitle(call.ringRestrictions.contains(.callInProgress) ? joinCallText : startCallText,
for: .normal)
if !viewModel.joinButtonIsHidden {
let joinButtonConfig = viewModel.joinButtonConfig
joinButton.setTitle(joinButtonConfig.label, for: .normal)
joinButton.setTitleColor(joinButtonConfig.color, for: .normal)
joinButton.adjustsImageWhenHighlighted = joinButtonConfig.adjustsImageWhenHighlighted
joinButton.isUserInteractionEnabled = joinButtonConfig.isUserInteractionEnabled
if viewModel.shouldJoinButtonActivityIndicatorBeAnimating {
joinButtonActivityIndicator.startAnimating()
} else {
joinButtonActivityIndicator.stopAnimating()
}
}
}
@ -260,31 +221,270 @@ class CallControls: UIView {
}
}
extension CallControls: CallObserver {
private class CallControlsViewModel {
private let call: SignalCall
private let callService: CallService
fileprivate var refreshView: (() -> Void)?
init(call: SignalCall, callService: CallService) {
self.call = call
self.callService = callService
call.addObserverAndSyncState(observer: self)
callService.audioService.delegate = self
}
deinit {
call.removeObserver(self)
callService.audioService.delegate = nil
}
private var hasExternalAudioInputsAndAudioSource: Bool {
let audioService = callService.audioService
return audioService.hasExternalInputs && audioService.currentAudioSource != nil
}
var audioSourceButtonIsHidden: Bool {
if hasExternalAudioInputsAndAudioSource {
return false
} else if UIDevice.current.isIPad {
// iPad *only* supports speaker mode, if there are no external
// devices connected, so we don't need to show the button unless
// we have alternate audio sources.
return true
} else {
return !call.isOutgoingVideoMuted
}
}
struct AudioSourceButtonConfiguration {
let showDropdownArrow: Bool
let iconName: String
}
var audioSourceButtonConfiguration: AudioSourceButtonConfiguration {
let showDropdownArrow: Bool
let iconName: String
if
callService.audioService.hasExternalInputs,
let audioSource = callService.audioService.currentAudioSource
{
showDropdownArrow = true
if audioSource.isBuiltInEarPiece {
iconName = "phone-fill-28"
} else if audioSource.isBuiltInSpeaker {
iconName = "speaker-fill-28"
} else {
iconName = "speaker-bt-fill-28"
}
} else {
// No bluetooth audio detected
showDropdownArrow = false
iconName = "speaker-fill-28"
}
return AudioSourceButtonConfiguration(showDropdownArrow: showDropdownArrow, iconName: iconName)
}
var hangUpButtonIsHidden: Bool {
switch call.mode {
case .individual(_):
return false
case .group(_):
return call.joinState != .joined
}
}
var muteButtonIsHidden: Bool {
return false
}
var videoButtonIsHidden: Bool {
return false
}
var flipCameraButtonIsHidden: Bool {
return call.isOutgoingVideoMuted
}
var joinButtonIsHidden: Bool {
switch call.mode {
case .individual(_):
// TODO: Introduce lobby for starting 1:1 video calls.
return true
case .group(let call):
return call.localDeviceState.joinState == .joined
}
}
struct JoinButtonConfiguration {
let label: String
let color: UIColor
let adjustsImageWhenHighlighted: Bool
let isUserInteractionEnabled: Bool
}
var joinButtonConfig: JoinButtonConfiguration {
if !call.canJoin {
// Make the button look disabled, but don't actually disable it.
// We want to show a toast if the user taps anyway.
return JoinButtonConfiguration(
label: OWSLocalizedString(
"GROUP_CALL_IS_FULL",
comment: "Text explaining the group call is full"
),
color: .ows_whiteAlpha40,
adjustsImageWhenHighlighted: false,
isUserInteractionEnabled: true
)
} else if call.joinState == .joining || call.joinState == .pending {
return JoinButtonConfiguration(
label: "",
color: .ows_whiteAlpha40,
adjustsImageWhenHighlighted: false,
isUserInteractionEnabled: false
)
} else {
let startCallText = OWSLocalizedString(
"CALL_START_BUTTON",
comment: "Button to start a call"
)
let label: String
switch call.mode {
case .individual(_):
// We only show a lobby for 1:1 calls when the call is being initiated.
// TODO: The work of adding the lobby for 1:1 calls in the unified call view
// controller (currently GroupCallViewController) is not yet complete.
label = startCallText
case .group(_):
let joinCallText = OWSLocalizedString(
"GROUP_CALL_JOIN_BUTTON",
comment: "Button to join an ongoing group call"
)
label = call.ringRestrictions.contains(.callInProgress) ? joinCallText : startCallText
}
return JoinButtonConfiguration(
label: label,
color: .white,
adjustsImageWhenHighlighted: true,
isUserInteractionEnabled: true
)
}
}
var shouldJoinButtonActivityIndicatorBeAnimating: Bool {
return (call.joinState == .joining || call.joinState == .pending) && !joinButtonIsHidden
}
var ringButtonIsHidden: Bool {
switch call.mode {
case .individual(_):
return true
case .group(_):
return call.joinState == .joined || call.ringRestrictions.intersects([.notApplicable, .callInProgress])
}
}
struct RingButtonConfiguration {
let isUserInteractionEnabled: Bool
let isSelected: Bool
let shouldDrawAsDisabled: Bool
}
var ringButtonConfiguration: RingButtonConfiguration? {
switch call.mode {
case .individual(_):
// We never show the ring button for 1:1 calls.
return nil
case .group(_):
// Leave the button visible but locked if joining, like the "join call" button.
let isUserInteractionEnabled = call.joinState == .notJoined
let isSelected: Bool
if
call.ringRestrictions.isEmpty,
case .shouldRing = call.groupCallRingState
{
isSelected = true
} else {
isSelected = false
}
// Leave the button enabled so we can present an explanatory toast, but show it disabled.
let shouldDrawAsDisabled = !call.ringRestrictions.isEmpty
return RingButtonConfiguration(
isUserInteractionEnabled: isUserInteractionEnabled,
isSelected: isSelected,
shouldDrawAsDisabled: shouldDrawAsDisabled
)
}
}
var gradientViewIsHidden: Bool {
return call.joinState != .joined
}
var videoButtonIsSelected: Bool {
return !call.isOutgoingVideoMuted
}
var muteButtonIsSelected: Bool {
return call.isOutgoingAudioMuted
}
func controlSpacing(controlCount: Int) -> CGFloat {
return (UIDevice.current.isNarrowerThanIPhone6 && controlCount > 4) ? 12 : 16
}
func shouldControlButtonsBeSmall(controlCount: Int) -> Bool {
return UIDevice.current.isIPad ? false : controlCount > 4
}
}
extension CallControlsViewModel: CallObserver {
func groupCallLocalDeviceStateChanged(_ call: SignalCall) {
owsAssertDebug(call.isGroupCall)
updateControls()
refreshView?()
}
func groupCallPeekChanged(_ call: SignalCall) {
updateControls()
refreshView?()
}
func groupCallRemoteDeviceStatesChanged(_ call: SignalCall) {
updateControls()
refreshView?()
}
func groupCallEnded(_ call: SignalCall, reason: GroupCallEndReason) {
updateControls()
refreshView?()
}
func individualCallStateDidChange(_ call: SignalCall, state: CallState) {
refreshView?()
}
func individualCallLocalVideoMuteDidChange(_ call: SignalCall, isVideoMuted: Bool) {
refreshView?()
}
func individualCallLocalAudioMuteDidChange(_ call: SignalCall, isAudioMuted: Bool) {
refreshView?()
}
func individualCallHoldDidChange(_ call: SignalCall, isOnHold: Bool) {
refreshView?()
}
func individualCallRemoteVideoMuteDidChange(_ call: SignalCall, isVideoMuted: Bool) {
refreshView?()
}
func individualCallRemoteSharingScreenDidChange(_ call: SignalCall, isRemoteSharingScreen: Bool) {
refreshView?()
}
}
extension CallControls: CallAudioServiceDelegate {
extension CallControlsViewModel: CallAudioServiceDelegate {
func callAudioServiceDidChangeAudioSession(_ callAudioService: CallAudioService) {
updateControls()
refreshView?()
}
func callAudioServiceDidChangeAudioSource(_ callAudioService: CallAudioService, audioSource: AudioSource?) {
updateControls()
refreshView?()
}
}

View File

@ -14,7 +14,7 @@ import SignalUI
class GroupCallViewController: UIViewController {
private let call: SignalCall
private var groupCall: GroupCall { call.groupCall }
private lazy var callControls = CallControls(call: call, delegate: self)
private lazy var callControls = CallControls(call: call, callService: callService, delegate: self)
private lazy var incomingCallControls = IncomingCallControls(video: true, delegate: self)
private var incomingCallControlsConstraint: NSLayoutConstraint?
private lazy var noVideoIndicatorView: UIStackView = createNoVideoIndicatorView()
@ -740,7 +740,7 @@ extension GroupCallViewController: CallViewControllerWindowReference {
existingSheet.dismiss(animated: false)
}
let startCallString = OWSLocalizedString("GROUP_CALL_START_BUTTON", comment: "Button to start a group call")
let startCallString = OWSLocalizedString("CALL_START_BUTTON", comment: "Button to start a call")
let joinCallString = OWSLocalizedString("GROUP_CALL_JOIN_BUTTON", comment: "Button to join an ongoing group call")
let continueCallString = OWSLocalizedString("GROUP_CALL_CONTINUE_BUTTON", comment: "Button to continue an ongoing group call")
let leaveCallString = OWSLocalizedString("GROUP_CALL_LEAVE_BUTTON", comment: "Button to leave a group call")

View File

@ -736,6 +736,9 @@
/* Call setup status label after outgoing call times out */
"CALL_SCREEN_STATUS_NO_ANSWER" = "No Answer";
/* Button to start a call */
"CALL_START_BUTTON" = "Start Call";
/* embeds {{Call Status}} in call screen label. For ongoing calls, {{Call Status}} is a seconds timer like 01:23, otherwise {{Call Status}} is a short text like 'Ringing', 'Busy', or 'Failed Call' */
"CALL_STATUS_FORMAT" = "Signal %@";
@ -2701,9 +2704,6 @@
/* Toast view text informing user about swiping to speaker view */
"GROUP_CALL_SPEAKER_VIEW_TOAST" = "Swipe up to change views";
/* Button to start a group call */
"GROUP_CALL_START_BUTTON" = "Start Call";
/* Tooltip highlighting group calls. */
"GROUP_CALL_START_TOOLTIP" = "Tap here to start a group call";