Add CallMemberView and CallMemberComposableViews

This commit is contained in:
Marissa Le Coz 2024-01-30 13:44:43 -05:00 committed by GitHub
parent 69a948cbde
commit fcc513d929
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 501 additions and 5 deletions

View File

@ -1607,6 +1607,9 @@
E18C4A7729EF2ECC007534D4 /* SignalAccountTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E18C4A7629EF2ECC007534D4 /* SignalAccountTest.swift */; };
E1A090382A4B909B00F2BE8B /* RecipientHidingManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1A090372A4B909B00F2BE8B /* RecipientHidingManager.swift */; };
E1C2A54B2A8FCB0D00AEC4DA /* DeleteSystemContactViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1C2A54A2A8FCB0D00AEC4DA /* DeleteSystemContactViewController.swift */; };
E1E78CAD2B573B5800B6FC2D /* CallMemberChromeOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E78CAC2B573B5800B6FC2D /* CallMemberChromeOverlayView.swift */; };
E1E78CAF2B573BD100B6FC2D /* CallMemberCameraOffView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E78CAE2B573BD100B6FC2D /* CallMemberCameraOffView.swift */; };
E1E78CB42B575C2700B6FC2D /* CallMemberVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1E78CB32B575C2700B6FC2D /* CallMemberVideoView.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 */; };
@ -4354,6 +4357,9 @@
E1A090372A4B909B00F2BE8B /* RecipientHidingManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecipientHidingManager.swift; sourceTree = "<group>"; };
E1A0AD8B16E13FDD0071E604 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
E1C2A54A2A8FCB0D00AEC4DA /* DeleteSystemContactViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteSystemContactViewController.swift; sourceTree = "<group>"; };
E1E78CAC2B573B5800B6FC2D /* CallMemberChromeOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMemberChromeOverlayView.swift; sourceTree = "<group>"; };
E1E78CAE2B573BD100B6FC2D /* CallMemberCameraOffView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMemberCameraOffView.swift; sourceTree = "<group>"; };
E1E78CB32B575C2700B6FC2D /* CallMemberVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CallMemberVideoView.swift; sourceTree = "<group>"; };
E27EBB962FA58BAC64DD64A3 /* Pods-SignalUITests.app store release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalUITests.app store release.xcconfig"; path = "Pods/Target Support Files/Pods-SignalUITests/Pods-SignalUITests.app store release.xcconfig"; sourceTree = "<group>"; };
E434E65F6E6E989A106307BA /* Pods-SignalTests.profiling.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SignalTests.profiling.xcconfig"; path = "Pods/Target Support Files/Pods-SignalTests/Pods-SignalTests.profiling.xcconfig"; sourceTree = "<group>"; };
E44AD4E524E98F430035D7B8 /* PhotoCaptureDismiss.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotoCaptureDismiss.swift; sourceTree = "<group>"; };
@ -8115,6 +8121,9 @@
88588D1A252D3DFF00405414 /* CallControls.swift */,
E17283DD2B2A713C00302DC7 /* CallControlsConfirmationToast.swift */,
88ABB8B42534070400229EAA /* CallHeader.swift */,
E1E78CAE2B573BD100B6FC2D /* CallMemberCameraOffView.swift */,
E1E78CAC2B573B5800B6FC2D /* CallMemberChromeOverlayView.swift */,
E1E78CB32B575C2700B6FC2D /* CallMemberVideoView.swift */,
88588D1C252D5D3D00405414 /* CallMemberView.swift */,
88D23D1C23CEC0C700B0E74B /* CallUIAdapter.swift */,
1700E33E28B856FB0073D949 /* IncomingCallControls.swift */,
@ -12100,6 +12109,9 @@
88ABB8B52534070400229EAA /* CallHeader.swift in Sources */,
88D23D2423CEC0C700B0E74B /* CallKitCallManager.swift in Sources */,
88D23D2523CEC0C700B0E74B /* CallKitCallUIAdaptee.swift in Sources */,
E1E78CAF2B573BD100B6FC2D /* CallMemberCameraOffView.swift in Sources */,
E1E78CAD2B573B5800B6FC2D /* CallMemberChromeOverlayView.swift in Sources */,
E1E78CB42B575C2700B6FC2D /* CallMemberVideoView.swift in Sources */,
88588D1D252D5D3D00405414 /* CallMemberView.swift in Sources */,
D93108092B337A23006A034E /* CallRecordLoader.swift in Sources */,
88588D26252E59CE00405414 /* CallService.swift in Sources */,

View File

@ -0,0 +1,124 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import LibSignalClient
import SignalMessaging
import SignalRingRTC
import SignalUI
// TODO: Before enabling FeatureFlags.useCallMemberComposableViewsForRemoteUsersIn[Group|Individual]Calls,
// show this view while waiting for remote video to be received and displayed.
class CallMemberCameraOffView: UIView, CallMemberComposableView {
private let backgroundAvatarView = UIImageView()
private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
private let avatarView = ConversationAvatarView(localUserDisplayMode: .asUser, badged: false)
private var avatarDiameter: UInt {
if width > 180 {
return 112
} else if width > 102 {
return 96
} else if width > 48 {
return UInt(width) - 36
} else {
return 16
}
}
init() {
super.init(frame: .zero)
let overlayView = UIView()
overlayView.backgroundColor = .ows_blackAlpha40
self.addSubview(overlayView)
overlayView.autoPinEdgesToSuperviewEdges()
backgroundAvatarView.contentMode = .scaleAspectFill
self.addSubview(backgroundAvatarView)
backgroundAvatarView.autoPinEdgesToSuperviewEdges()
self.addSubview(blurView)
blurView.autoPinEdgesToSuperviewEdges()
self.addSubview(avatarView)
avatarView.autoCenterInSuperview()
}
func configure(
call: SignalCall,
isFullScreen: Bool = false,
memberType: CallMemberView.ConfigurationType
) {
self.isHidden = !call.isOutgoingVideoMuted
self.avatarView.isHidden = isFullScreen
let backgroundAvatarImage: UIImage?
let backgroundColor: UIColor?
switch memberType {
case .local:
let avatar = profileManager.localProfileAvatarImage()
backgroundAvatarImage = avatar
let localAddress: SignalServiceAddress? = databaseStorage.read { tx in
guard let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx.asV2Read)?.aciAddress else {
owsFailDebug("missing local address")
return nil
}
let updatedSize = avatarDiameter
avatarView.update(tx) { config in
config.dataSource = .address(localAddress)
config.sizeClass = .customDiameter(updatedSize)
}
return localAddress
}
if let localAddress {
backgroundColor = AvatarTheme.forAddress(localAddress).backgroundColor
} else {
backgroundColor = nil
}
case .remote(let remoteDeviceState, _):
let profileImage = databaseStorage.read { tx in
let updatedSize = avatarDiameter
avatarView.update(tx) { config in
config.dataSource = .address(remoteDeviceState.address)
config.sizeClass = .customDiameter(updatedSize)
}
let profileImage = self.contactsManagerImpl.avatarImage(
forAddress: remoteDeviceState.address,
shouldValidate: true,
transaction: tx
)
return profileImage
}
backgroundAvatarImage = profileImage
backgroundColor = AvatarTheme.forAddress(remoteDeviceState.address).backgroundColor
}
backgroundAvatarView.image = backgroundAvatarImage
self.backgroundColor = backgroundColor
}
func updateDimensions() {
// Will this be a problem being called from layout subviews?
avatarView.updateWithSneakyTransactionIfNecessary { config in
config.sizeClass = .customDiameter(avatarDiameter)
}
}
func rotateForPhoneOrientation(_ rotationAngle: CGFloat) {
/// TODO: Add support for rotating other elements too.
self.avatarView.transform = CGAffineTransform(rotationAngle: rotationAngle)
}
func clearConfiguration() {
self.backgroundColor = .ows_black
backgroundAvatarView.image = nil
avatarView.reset()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,80 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
class CallMemberChromeOverlayView: UIView, CallMemberComposableView {
private let muteIndicatorImage = UIImageView()
private var muteLeadingConstraint: NSLayoutConstraint?
private var muteBottomConstraint: NSLayoutConstraint?
private var muteHeightConstraint: NSLayoutConstraint?
private var muteInsets: CGFloat {
return width > 102 ? 9 : 4
}
private var muteHeight: CGFloat {
return width > 200 && UIDevice.current.isIPad ? 20 : 16
}
init() {
super.init(frame: .zero)
muteIndicatorImage.isHidden = true
muteIndicatorImage.setTemplateImageName("mic-slash-fill-28", tintColor: .ows_white)
addSubview(muteIndicatorImage)
muteIndicatorImage.autoMatch(.width, to: .height, of: muteIndicatorImage)
createAndActivateMuteLayoutConstraints()
}
private func createAndActivateMuteLayoutConstraints() {
let muteLeadingConstraint = muteIndicatorImage.autoPinEdge(toSuperviewEdge: .leading, withInset: muteInsets)
let muteBottomConstraint = muteIndicatorImage.autoPinEdge(toSuperviewEdge: .bottom, withInset: muteInsets)
let muteHeightConstraint = muteIndicatorImage.autoSetDimension(.height, toSize: muteHeight)
NSLayoutConstraint.activate([
muteLeadingConstraint,
muteBottomConstraint,
muteHeightConstraint
])
self.muteLeadingConstraint = muteLeadingConstraint
self.muteBottomConstraint = muteBottomConstraint
self.muteHeightConstraint = muteHeightConstraint
}
func rotateForPhoneOrientation(_ rotationAngle: CGFloat) {
/// TODO: Add support for rotating other elements too.
self.muteIndicatorImage.transform = CGAffineTransform(rotationAngle: rotationAngle)
}
func configure(
call: SignalCall,
isFullScreen: Bool = false,
memberType: CallMemberView.ConfigurationType
) {
switch memberType {
case .local:
muteIndicatorImage.isHidden = !call.isOutgoingAudioMuted || isFullScreen
case .remote(let remoteDeviceState, let context):
muteIndicatorImage.isHidden = context == .speaker || remoteDeviceState.audioMuted != true || isFullScreen
}
}
func updateDimensions() {
let constraints = [
muteLeadingConstraint,
muteBottomConstraint,
muteHeightConstraint
].compactMap { $0 }
NSLayoutConstraint.deactivate(constraints)
createAndActivateMuteLayoutConstraints()
}
func clearConfiguration() {
muteIndicatorImage.isHidden = true
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -0,0 +1,114 @@
//
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import AVFoundation
import Foundation
import SignalMessaging
import SignalRingRTC
class CallMemberVideoView: UIView, CallMemberComposableView {
/// See note on ```CallMemberView.Configurationtype```.
enum MemberType {
case local(SignalCall)
case remote(isGroupCall: Bool)
}
init(type: MemberType) {
super.init(frame: .zero)
switch type {
case .local(let call):
let localVideoView = LocalVideoView(shouldUseAutolayout: true)
localVideoView.captureSession = call.videoCaptureController.captureSession
self.addSubview(localVideoView)
localVideoView.contentMode = .scaleAspectFill
localVideoView.autoPinEdgesToSuperviewEdges()
self.callViewWrapper = .local(localVideoView)
case .remote(let isGroupCall):
if !isGroupCall {
let remoteVideoView = RemoteVideoView()
remoteVideoView.isGroupCall = false
remoteVideoView.isUserInteractionEnabled = false
self.addSubview(remoteVideoView)
remoteVideoView.autoPinEdgesToSuperviewEdges()
self.callViewWrapper = .remoteInIndividual(remoteVideoView)
}
// The view for group calls is set upon `configure`.
}
}
private enum CallViewWrapper {
case local(LocalVideoView)
case remoteInIndividual(RemoteVideoView)
case remoteInGroup(GroupCallRemoteVideoView)
}
private var callViewWrapper: CallViewWrapper?
func configure(
call: SignalCall,
isFullScreen: Bool = false,
memberType: CallMemberView.ConfigurationType
) {
self.isHidden = call.isOutgoingVideoMuted
switch memberType {
case .local:
if case let .local(videoView) = callViewWrapper {
videoView.captureSession = call.videoCaptureController.captureSession
} else {
owsFailDebug("This should not be called when we're dealing with a remote video!")
}
case .remote(let remoteDeviceState, let context):
if remoteDeviceState.mediaKeysReceived, remoteDeviceState.videoTrack != nil {
self.isHidden = (remoteDeviceState.videoMuted == true)
}
if !self.isHidden {
configureRemoteVideo(device: remoteDeviceState, context: context)
}
}
}
private func unwrapVideoView() -> UIView? {
switch self.callViewWrapper {
case .local(let localVideoView):
return localVideoView
case .remoteInIndividual(let remoteVideoView):
return remoteVideoView
case .remoteInGroup(let groupCallRemoteVideoView):
return groupCallRemoteVideoView
case .none:
return nil
}
}
func configureRemoteVideo(
device: RemoteDeviceState,
context: CallMemberVisualContext
) {
if case let .remoteInGroup(videoView) = callViewWrapper {
if videoView.superview == self { videoView.removeFromSuperview() }
} else {
owsFailDebug("Can only call configureRemoteVideo for groups!")
}
let remoteVideoView = callService.groupCallRemoteVideoManager.remoteVideoView(for: device, context: context)
self.addSubview(remoteVideoView)
self.callViewWrapper = .remoteInGroup(remoteVideoView)
remoteVideoView.autoPinEdgesToSuperviewEdges()
remoteVideoView.isScreenShare = device.sharingScreen == true
}
func rotateForPhoneOrientation(_ rotationAngle: CGFloat) {
/// TODO: Add support for rotating.
}
func updateDimensions() {}
func clearConfiguration() {
if unwrapVideoView()?.superview === self { unwrapVideoView()?.removeFromSuperview() }
callViewWrapper = nil
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -3,6 +3,156 @@
// SPDX-License-Identifier: AGPL-3.0-only
//
import Foundation
import LibSignalClient
import SignalMessaging
import SignalRingRTC
import SignalUI
enum CallMemberVisualContext: Equatable {
case videoGrid, videoOverflow, speaker
}
protocol CallMemberComposableView: UIView {
func configure(
call: SignalCall,
isFullScreen: Bool,
memberType: CallMemberView.ConfigurationType
)
func rotateForPhoneOrientation(_ rotationAngle: CGFloat)
func updateDimensions()
func clearConfiguration()
}
class CallMemberView: UIView {
private let callMemberCameraOffView: CallMemberCameraOffView
private let callMemberVideoView: CallMemberVideoView
private let callMemberChromeOverlayView: CallMemberChromeOverlayView
/// Must be specified with the lowest view first; views will be added
/// as subviews in the order they are given.
private var orderedComposableViews = [CallMemberComposableView]()
override func layoutSubviews() {
super.layoutSubviews()
updateDimensions()
}
private let type: CallMemberVideoView.MemberType
init(type: CallMemberVideoView.MemberType) {
self.type = type
self.callMemberCameraOffView = CallMemberCameraOffView()
self.callMemberVideoView = CallMemberVideoView(type: type)
self.callMemberChromeOverlayView = CallMemberChromeOverlayView()
super.init(frame: .zero)
switch type {
case .local:
self.orderedComposableViews = [
callMemberCameraOffView,
callMemberVideoView,
callMemberChromeOverlayView
]
case .remote(_):
self.orderedComposableViews = [
callMemberCameraOffView,
callMemberVideoView,
// TODO: Add waiting and error views
callMemberChromeOverlayView
]
}
backgroundColor = .ows_gray90
clipsToBounds = true
self.orderedComposableViews.forEach { view in
self.addSubview(view)
view.autoPinEdgesToSuperviewEdges()
}
NotificationCenter.default.addObserver(
self,
selector: #selector(updateOrientationForPhone),
name: CallService.phoneOrientationDidChange,
object: nil
)
}
@objc
private func updateOrientationForPhone(_ notification: Notification) {
let rotationAngle = notification.object as! CGFloat
if window == nil {
self.orderedComposableViews.forEach { view in
view.rotateForPhoneOrientation(rotationAngle)
}
} else {
UIView.animate(withDuration: 0.3) {
self.orderedComposableViews.forEach { view in
view.rotateForPhoneOrientation(rotationAngle)
}
}
}
}
/// Not to be confused with ``CallMemberVideoView.MemberType``, which is used on
/// init. This is an imperfect solution to the issue of (group,individual)x(local,remote)
/// call member views having different needs for set up and configuration. The alternative
/// is having optional params on `init` and `configure`.
/// TODO: Eventually iterate to a point that the enum params can become non-optional
/// `configure` params.
enum ConfigurationType {
case local
case remote(RemoteDeviceState, CallMemberVisualContext)
}
private var hasBeenConfigured = false
func configure(
call: SignalCall,
isFullScreen: Bool = false,
memberType: ConfigurationType
) {
hasBeenConfigured = true
switch memberType {
case .local:
guard case .local = self.type else {
owsAssertBeta(false, "Member type on init must match member type on config!")
return
}
layer.shadowOffset = .zero
layer.shadowOpacity = 0.25
layer.shadowRadius = 4
layer.cornerRadius = isFullScreen ? 0 : 10
case .remote(_, _):
guard case .remote = self.type else {
owsAssertBeta(false, "Member type on init must match member type on config!")
return
}
}
self.orderedComposableViews.forEach { view in
view.configure(
call: call,
isFullScreen: isFullScreen,
memberType: memberType
)
}
}
private func updateDimensions() {
guard hasBeenConfigured else { return }
self.orderedComposableViews.forEach { view in
view.updateDimensions()
}
}
func clearConfiguration() {
self.orderedComposableViews.forEach { view in
view.clearConfiguration()
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}

View File

@ -105,7 +105,7 @@ class GroupCallMemberView: UIView {
}
class GroupCallLocalMemberView: GroupCallMemberView {
let videoView = LocalVideoView()
let videoView = LocalVideoView(shouldUseAutolayout: false)
let videoOffIndicatorImage = UIImageView()

View File

@ -130,7 +130,7 @@ class IndividualCallViewController: OWSViewController, CallObserver {
private weak var remoteVideoTrack: RTCVideoTrack?
private lazy var localVideoView: LocalVideoView = {
let localVideoView = LocalVideoView()
let localVideoView = LocalVideoView(shouldUseAutolayout: false)
localVideoView.captureSession = call.videoCaptureController.captureSession
return localVideoView
}()

View File

@ -10,6 +10,8 @@ import WebRTC
class LocalVideoView: UIView {
private let localVideoCapturePreview = RTCCameraPreviewView()
private let shouldUseAutolayout: Bool
var captureSession: AVCaptureSession? {
get { localVideoCapturePreview.captureSession }
set { localVideoCapturePreview.captureSession = newValue }
@ -19,10 +21,14 @@ class LocalVideoView: UIView {
didSet { localVideoCapturePreview.contentMode = contentMode }
}
override init(frame: CGRect) {
init(shouldUseAutolayout: Bool) {
self.shouldUseAutolayout = shouldUseAutolayout
super.init(frame: .zero)
addSubview(localVideoCapturePreview)
if shouldUseAutolayout {
localVideoCapturePreview.autoPinEdgesToSuperviewEdges()
}
if Platform.isSimulator {
backgroundColor = .brown
@ -41,12 +47,22 @@ class LocalVideoView: UIView {
}
override var frame: CGRect {
didSet { updateLocalVideoOrientation() }
didSet {
if !shouldUseAutolayout {
updateLocalVideoOrientation()
}
}
}
@objc
private func updateLocalVideoOrientation() {
defer { localVideoCapturePreview.frame = bounds }
defer {
if shouldUseAutolayout {
setNeedsUpdateConstraints()
} else {
localVideoCapturePreview.frame = bounds
}
}
// iPad supports rotating this view controller directly,
// so we don't need to do anything here.