diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index dad37cd77a..356f39f29d 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -2092,6 +2092,7 @@ E1E78CAF2B573BD100B6FC2D /* CallMemberCameraOffView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E78CAE2B573BD100B6FC2D /* CallMemberCameraOffView.swift */; }; E1E78CB12B573C3100B6FC2D /* CallMemberWaitingAndErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E78CB02B573C3100B6FC2D /* CallMemberWaitingAndErrorView.swift */; }; E1E78CB42B575C2700B6FC2D /* CallMemberVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E78CB32B575C2700B6FC2D /* CallMemberVideoView.swift */; }; + E1F7F1792C65666100F2754E /* SupplementalCallControlsForFullscreenLocalMember.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1F7F1782C65666100F2754E /* SupplementalCallControlsForFullscreenLocalMember.swift */; }; E44AD4E624E98F440035D7B8 /* PhotoCaptureDismiss.swift in Sources */ = {isa = PBXBuildFile; fileRef = E44AD4E524E98F430035D7B8 /* PhotoCaptureDismiss.swift */; }; E75DD3E02810CDBD00E32C36 /* SubscriptionManagerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E75DD3DF2810CDBD00E32C36 /* SubscriptionManagerTest.swift */; }; E7D7C93F28B580AC003F043B /* Bundle+OWS.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7D7C93E28B580AC003F043B /* Bundle+OWS.swift */; }; @@ -5196,6 +5197,7 @@ E1E78CAE2B573BD100B6FC2D /* CallMemberCameraOffView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMemberCameraOffView.swift; sourceTree = ""; }; E1E78CB02B573C3100B6FC2D /* CallMemberWaitingAndErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMemberWaitingAndErrorView.swift; sourceTree = ""; }; E1E78CB32B575C2700B6FC2D /* CallMemberVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMemberVideoView.swift; sourceTree = ""; }; + E1F7F1782C65666100F2754E /* SupplementalCallControlsForFullscreenLocalMember.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SupplementalCallControlsForFullscreenLocalMember.swift; sourceTree = ""; }; E44AD4E524E98F430035D7B8 /* PhotoCaptureDismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCaptureDismiss.swift; sourceTree = ""; }; E75DD3DF2810CDBD00E32C36 /* SubscriptionManagerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubscriptionManagerTest.swift; sourceTree = ""; }; E7D7C93E28B580AC003F043B /* Bundle+OWS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Bundle+OWS.swift"; sourceTree = ""; }; @@ -9552,6 +9554,7 @@ 766CE0D52A323DFE00AD609D /* RemoteVideoView.swift */, 342FFE7D271EF5B1000AC89F /* ReturnToCallViewController.swift */, 88D23D1823CEC0C700B0E74B /* SimulatorCallUIAdaptee.swift */, + E1F7F1782C65666100F2754E /* SupplementalCallControlsForFullscreenLocalMember.swift */, ); path = UserInterface; sourceTree = ""; @@ -14036,6 +14039,7 @@ 884DB94527DD70F700C6A309 /* StoryViewModel.swift in Sources */, 88B00D4D28A3346100BC9CA0 /* StoryViewsViewController.swift in Sources */, 88863A50280CAE4400977F69 /* StoryZoomAnimator.swift in Sources */, + E1F7F1792C65666100F2754E /* SupplementalCallControlsForFullscreenLocalMember.swift in Sources */, 6675F64F29261C39007A311E /* SyncPushTokensJob.swift in Sources */, 344A761324B36C8C009D69A5 /* TestingViewController.swift in Sources */, 506A9252290326A600A2EFC2 /* TextHelper.swift in Sources */, diff --git a/Signal/Calls/GroupCall.swift b/Signal/Calls/GroupCall.swift index 41484b63d5..14af4477ee 100644 --- a/Signal/Calls/GroupCall.swift +++ b/Signal/Calls/GroupCall.swift @@ -83,6 +83,15 @@ class GroupCall: SignalRingRTC.GroupCallDelegate { ) } + public var isJustMe: Bool { + switch ringRtcCall.localDeviceState.joinState { + case .notJoined, .joining, .pending: + return true + case .joined: + return ringRtcCall.remoteDeviceStates.isEmpty + } + } + // MARK: - Concrete Type enum ConcreteType { diff --git a/Signal/Calls/UserInterface/CallButton.swift b/Signal/Calls/UserInterface/CallButton.swift index 1dd4a5d1f0..29ff032493 100644 --- a/Signal/Calls/UserInterface/CallButton.swift +++ b/Signal/Calls/UserInterface/CallButton.swift @@ -24,6 +24,12 @@ class CallButton: UIButton { var unselectedBackgroundColor = UIColor.ows_whiteAlpha40 { didSet { updateAppearance() } } var selectedBackgroundColor = UIColor.ows_white { didSet { updateAppearance() } } + // TODO: When `FeatureFlags.groupCallDrawerSupport` is removed, have + // these replace [un]selectedBackgroundColor` in this file and make + // private and non-static. + static private(set) var unselectedBackgroundColorInDrawer = UIColor(rgbHex: 0x4A4A4A).withAlphaComponent(0.63) + static private(set) var selectedIconColorInDrawer: UIColor = .ows_black + var currentBackgroundColor: UIColor { return isSelected ? selectedBackgroundColor : unselectedBackgroundColor } diff --git a/Signal/Calls/UserInterface/CallControls.swift b/Signal/Calls/UserInterface/CallControls.swift index e4de7ec151..1980b82a38 100644 --- a/Signal/Calls/UserInterface/CallControls.swift +++ b/Signal/Calls/UserInterface/CallControls.swift @@ -280,10 +280,8 @@ class CallControls: UIView { button.setContentHuggingHorizontalHigh() button.setCompressionResistanceHorizontalLow() if useCallDrawerStyling { - // TODO: When feature flag is removed, set these colors in `CallButton`. - let unselectedBackgroundColor = UIColor(rgbHex: 0x4A4A4A).withAlphaComponent(0.63) - button.unselectedBackgroundColor = unselectedBackgroundColor - button.selectedIconColor = .ows_black + button.unselectedBackgroundColor = CallButton.unselectedBackgroundColorInDrawer + button.selectedIconColor = CallButton.selectedIconColorInDrawer } else { button.alpha = 0.9 } @@ -431,17 +429,9 @@ private class CallControlsViewModel { case .individual(let call): return ![.idle, .dialing, .remoteRinging, .localRinging_Anticipatory, .localRinging_ReadyToAnswer].contains(call.state) case .groupThread(let call as GroupCall), .callLink(let call as GroupCall): - // Because joined group calls include the `moreButton`, we're out of - // space for the `flipCameraButton`. - // - // Recall that the flip camera button is in the local pip once someone - // else has joined the call. That leaves the issue of where to locate - // the flip camera button when the user is fullscreen because they have - // joined and are the sole member in the call. - // - // TODO: Implement the future designs for this. In the meantime, Design - // wants us to omit the flip camera button from Call Controls when in - // joined fullscreen. + if FeatureFlags.groupCallDrawerSupport && call.isJustMe { + return true + } return call.hasJoinedOrIsWaitingForAdminApproval } } diff --git a/Signal/Calls/UserInterface/CallMemberChromeOverlayView.swift b/Signal/Calls/UserInterface/CallMemberChromeOverlayView.swift index 12c70e2b03..b75d0e6da6 100644 --- a/Signal/Calls/UserInterface/CallMemberChromeOverlayView.swift +++ b/Signal/Calls/UserInterface/CallMemberChromeOverlayView.swift @@ -250,8 +250,8 @@ class CallMemberChromeOverlayView: UIView, CallMemberComposableView { private var flipCameraButtonWidthConstraint: NSLayoutConstraint? private var flipCameraImageWidthConstraint: NSLayoutConstraint? - private lazy var flipCameraCircleView: CircleView = { - let circleView = CircleView() + private lazy var flipCameraCircleView: CircleBlurView = { + let circleView = CircleBlurView(effect: UIBlurEffect(style: .systemThinMaterialDark)) circleView.isUserInteractionEnabled = false return circleView }() @@ -269,7 +269,7 @@ class CallMemberChromeOverlayView: UIView, CallMemberComposableView { }() private lazy var flipCameraButton: UIButton = { - flipCameraCircleView.addSubview(flipCameraImageView) + flipCameraCircleView.contentView.addSubview(flipCameraImageView) flipCameraImageView.autoCenterInSuperview() let button = UIButton() button.addSubview(flipCameraCircleView) diff --git a/Signal/Calls/UserInterface/GroupCallViewController.swift b/Signal/Calls/UserInterface/GroupCallViewController.swift index 60b0bb770b..9d25613a6d 100644 --- a/Signal/Calls/UserInterface/GroupCallViewController.swift +++ b/Signal/Calls/UserInterface/GroupCallViewController.swift @@ -48,6 +48,15 @@ class GroupCallViewController: UIViewController { sheetPanDelegate: self ) }() + private lazy var fullscreenLocalMemberAddOnsView: SupplementalCallControlsForFullscreenLocalMember = { + let view = SupplementalCallControlsForFullscreenLocalMember( + call: call, + groupCall: groupCall, + callService: callService + ) + view.isHidden = true + return view + }() private lazy var callControlsConfirmationToastContainerView = UIView() private var callService: CallService { AppEnvironment.shared.callService } private var incomingCallControls: IncomingCallControls? @@ -386,6 +395,15 @@ class GroupCallViewController: UIViewController { swipeToastView.autoPinEdge(toSuperviewMargin: .leading, relation: .greaterThanOrEqual) swipeToastView.autoPinEdge(toSuperviewMargin: .trailing, relation: .greaterThanOrEqual) + view.addSubview(fullscreenLocalMemberAddOnsView) + fullscreenLocalMemberAddOnsView.autoPinLeadingToSuperviewMargin() + fullscreenLocalMemberAddOnsView.autoPinTrailingToSuperviewMargin() + fullscreenLocalMemberAddOnsView.autoPinEdge( + .bottom, + to: .bottom, + of: bottomVStack + ) + view.addSubview(callControlsConfirmationToastContainerView) callControlsConfirmationToastContainerView.autoHCenterInSuperview() view.addSubview(callControlsOverflowView) @@ -518,7 +536,9 @@ class GroupCallViewController: UIViewController { return } bottomSheet.setBottomSheetMinimizedHeight() - present(self.bottomSheet, animated: true) + present(self.bottomSheet, animated: true) { + self.fullscreenLocalMemberAddOnsView.isHidden = false + } } private func dismissBottomSheet(animated: Bool = true) { @@ -678,7 +698,7 @@ class GroupCallViewController: UIViewController { speakerView.applyChangesToCallMemberViewAndVideoView { view in view.removeFromSuperview() } - if self.isJustMe { + if groupCall.isJustMe { localMemberView.applyChangesToCallMemberViewAndVideoView { view in speakerPage.addSubview(view) view.frame = CGRect(origin: .zero, size: size) @@ -798,7 +818,7 @@ class GroupCallViewController: UIViewController { shouldAnimateViewFrames: Bool = false, bottomSheetChangedStateFrom oldBottomSheetState: BottomSheetState? = nil ) { - let isFullScreen = self.isJustMe + let isFullScreen = groupCall.isJustMe localMemberView.configure( call: call, isFullScreen: isFullScreen @@ -1114,6 +1134,8 @@ class GroupCallViewController: UIViewController { return } + fullscreenLocalMemberAddOnsView.isHidden = true + bottomSheetStateManager.submitState(.callControls) self.raisedHandsToast.raisedHands.removeAll() @@ -1154,15 +1176,6 @@ class GroupCallViewController: UIViewController { return true } - private var isJustMe: Bool { - switch ringRtcCall.localDeviceState.joinState { - case .notJoined, .joining, .pending: - return true - case .joined: - return ringRtcCall.remoteDeviceStates.isEmpty - } - } - private var hasAtLeastTwoOthers: Bool { switch ringRtcCall.localDeviceState.joinState { case .notJoined, .joining, .pending: @@ -1195,7 +1208,7 @@ class GroupCallViewController: UIViewController { } private var bottomSheetMustBeVisible: Bool { - return self.isJustMe + return groupCall.isJustMe } private var sheetTimeoutTimer: Timer? diff --git a/Signal/Calls/UserInterface/SupplementalCallControlsForFullscreenLocalMember.swift b/Signal/Calls/UserInterface/SupplementalCallControlsForFullscreenLocalMember.swift new file mode 100644 index 0000000000..1513e505c5 --- /dev/null +++ b/Signal/Calls/UserInterface/SupplementalCallControlsForFullscreenLocalMember.swift @@ -0,0 +1,117 @@ +// +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import SignalRingRTC +import SignalServiceKit +import SignalUI +import UIKit + +/// Once at least one remote member joins a call, the local member's video +/// reduces down to a pip, which includes certain call controls such as +/// the flip camera button (with more to come, per future designs). While +/// the local member is alone (either because they're in the lobby or +/// because they're the only one in the call), they're fullscreen, and +/// those call controls need to be somewhere! This view is the place! +class SupplementalCallControlsForFullscreenLocalMember: UIView { + private lazy var flipCameraCircleView: CircleBlurView = { + let circleView = CircleBlurView(effect: UIBlurEffect(style: .systemThinMaterialDark)) + circleView.backgroundColor = CallButton.unselectedBackgroundColorInDrawer + circleView.isUserInteractionEnabled = false + return circleView + }() + + private lazy var flipCameraImageView = { + let imageView = UIImageView(image: UIImage(named: "switch-camera-28")) + imageView.tintColor = .ows_white + imageView.autoSetDimension( + .height, + toSize: 24 + ) + imageView.autoMatch(.width, to: .height, of: imageView) + return imageView + }() + + private lazy var flipCameraButton: UIButton = { + flipCameraCircleView.contentView.addSubview(flipCameraImageView) + flipCameraImageView.autoCenterInSuperview() + let button = UIButton() + button.addSubview(flipCameraCircleView) + flipCameraCircleView.autoPinEdgesToSuperviewEdges() + + button.autoSetDimension( + .height, + toSize: 48 + ) + button.autoMatch(.width, to: .height, of: button) + button.accessibilityLabel = flipCameraButtonAccessibilityLabel + button.addTarget(self, action: #selector(didPressFlipCamera), for: .touchUpInside) + return button + }() + + @objc + private func didPressFlipCamera() { + if let isUsingFrontCamera = call.videoCaptureController.isUsingFrontCamera { + callService.updateCameraSource(call: call, isUsingFrontCamera: !isUsingFrontCamera) + } + } + + private var flipCameraButtonAccessibilityLabel: String { + return OWSLocalizedString( + "CALL_VIEW_SWITCH_CAMERA_DIRECTION", + comment: "Accessibility label to toggle front- vs. rear-facing camera" + ) + } + + private let call: SignalCall + private let groupCall: GroupCall + private let callService: CallService + + init( + call: SignalCall, + groupCall: GroupCall, + callService: CallService + ) { + self.call = call + self.groupCall = groupCall + self.callService = callService + super.init(frame: .zero) + self.translatesAutoresizingMaskIntoConstraints = false + + groupCall.addObserver(self) + + if FeatureFlags.groupCallDrawerSupport { + flipCameraButton.translatesAutoresizingMaskIntoConstraints = false + addSubview(flipCameraButton) + NSLayoutConstraint.activate([ + flipCameraButton.trailingAnchor.constraint(equalTo: self.trailingAnchor), + flipCameraButton.topAnchor.constraint(equalTo: self.topAnchor), + flipCameraButton.bottomAnchor.constraint(equalTo: self.bottomAnchor) + ]) + updateView() + } + } + + private func updateView() { + if FeatureFlags.groupCallDrawerSupport { + flipCameraButton.isHidden = !groupCall.isJustMe || call.isOutgoingVideoMuted + } + } + + // MARK: - Required + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +extension SupplementalCallControlsForFullscreenLocalMember: GroupCallObserver { + func groupCallLocalDeviceStateChanged(_ call: GroupCall) { + updateView() + } + + func groupCallRemoteDeviceStatesChanged(_ call: GroupCall) { + updateView() + } +}