450 lines
16 KiB
Swift
450 lines
16 KiB
Swift
|
//
|
||
|
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
|
||
|
//
|
||
|
|
||
|
import Foundation
|
||
|
import SignalRingRTC
|
||
|
|
||
|
protocol GroupCallMemberViewDelegate: AnyObject {
|
||
|
func memberView(_: GroupCallMemberView, userRequestedInfoAboutError: GroupCallMemberView.ErrorState)
|
||
|
}
|
||
|
|
||
|
class GroupCallMemberView: UIView {
|
||
|
weak var delegate: GroupCallMemberViewDelegate?
|
||
|
let noVideoView = UIView()
|
||
|
|
||
|
let backgroundAvatarView = UIImageView()
|
||
|
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .dark))
|
||
|
let muteIndicatorImage = UIImageView()
|
||
|
|
||
|
lazy var muteLeadingConstraint = muteIndicatorImage.autoPinEdge(toSuperviewEdge: .leading, withInset: muteInsets)
|
||
|
lazy var muteBottomConstraint = muteIndicatorImage.autoPinEdge(toSuperviewEdge: .bottom, withInset: muteInsets)
|
||
|
lazy var muteHeightConstraint = muteIndicatorImage.autoSetDimension(.height, toSize: muteHeight)
|
||
|
|
||
|
var muteInsets: CGFloat {
|
||
|
layoutIfNeeded()
|
||
|
|
||
|
if width > 102 {
|
||
|
return 9
|
||
|
} else {
|
||
|
return 4
|
||
|
}
|
||
|
}
|
||
|
|
||
|
var muteHeight: CGFloat {
|
||
|
layoutIfNeeded()
|
||
|
|
||
|
if width > 200 && UIDevice.current.isIPad {
|
||
|
return 20
|
||
|
} else {
|
||
|
return 16
|
||
|
}
|
||
|
}
|
||
|
|
||
|
init() {
|
||
|
super.init(frame: .zero)
|
||
|
|
||
|
backgroundColor = .ows_gray90
|
||
|
clipsToBounds = true
|
||
|
|
||
|
addSubview(noVideoView)
|
||
|
noVideoView.autoPinEdgesToSuperviewEdges()
|
||
|
|
||
|
let overlayView = UIView()
|
||
|
overlayView.backgroundColor = .ows_blackAlpha40
|
||
|
noVideoView.addSubview(overlayView)
|
||
|
overlayView.autoPinEdgesToSuperviewEdges()
|
||
|
|
||
|
backgroundAvatarView.contentMode = .scaleAspectFill
|
||
|
noVideoView.addSubview(backgroundAvatarView)
|
||
|
backgroundAvatarView.autoPinEdgesToSuperviewEdges()
|
||
|
|
||
|
noVideoView.addSubview(blurView)
|
||
|
blurView.autoPinEdgesToSuperviewEdges()
|
||
|
|
||
|
muteIndicatorImage.contentMode = .scaleAspectFit
|
||
|
muteIndicatorImage.setTemplateImage(#imageLiteral(resourceName: "mic-off-solid-28"), tintColor: .ows_white)
|
||
|
addSubview(muteIndicatorImage)
|
||
|
muteIndicatorImage.autoMatch(.width, to: .height, of: muteIndicatorImage)
|
||
|
}
|
||
|
|
||
|
required init?(coder: NSCoder) {
|
||
|
fatalError("init(coder:) has not been implemented")
|
||
|
}
|
||
|
|
||
|
enum ErrorState {
|
||
|
case blocked(SignalServiceAddress)
|
||
|
case noMediaKeys(SignalServiceAddress)
|
||
|
}
|
||
|
}
|
||
|
|
||
|
class GroupCallLocalMemberView: GroupCallMemberView {
|
||
|
let videoView = LocalVideoView()
|
||
|
|
||
|
let videoOffIndicatorImage = UIImageView()
|
||
|
let videoOffLabel = UILabel()
|
||
|
|
||
|
var videoOffIndicatorWidth: CGFloat {
|
||
|
if width > 102 {
|
||
|
return 28
|
||
|
} else {
|
||
|
return 16
|
||
|
}
|
||
|
}
|
||
|
|
||
|
override var bounds: CGRect {
|
||
|
didSet { updateDimensions() }
|
||
|
}
|
||
|
|
||
|
override var frame: CGRect {
|
||
|
didSet { updateDimensions() }
|
||
|
}
|
||
|
|
||
|
lazy var videoOffIndicatorWidthConstraint = videoOffIndicatorImage.autoSetDimension(.width, toSize: videoOffIndicatorWidth)
|
||
|
|
||
|
lazy var callFullLabel: UILabel = {
|
||
|
let label = UILabel()
|
||
|
label.numberOfLines = 0
|
||
|
label.lineBreakMode = .byWordWrapping
|
||
|
label.font = .ows_dynamicTypeSubheadline
|
||
|
label.textAlignment = .center
|
||
|
label.textColor = Theme.darkThemePrimaryColor
|
||
|
return label
|
||
|
}()
|
||
|
|
||
|
lazy var callFullStack: UIStackView = {
|
||
|
let callFullStack = UIStackView()
|
||
|
callFullStack.axis = .vertical
|
||
|
callFullStack.spacing = 8
|
||
|
|
||
|
let imageView = UIImageView(image: #imageLiteral(resourceName: "sad-cat"))
|
||
|
imageView.contentMode = .scaleAspectFit
|
||
|
imageView.autoSetDimensions(to: CGSize(square: 200))
|
||
|
callFullStack.addArrangedSubview(imageView)
|
||
|
|
||
|
let titleLabel = UILabel()
|
||
|
titleLabel.text = NSLocalizedString(
|
||
|
"GROUP_CALL_IS_FULL",
|
||
|
comment: "Text explaining the group call is full"
|
||
|
)
|
||
|
titleLabel.font = UIFont.ows_dynamicTypeSubheadline.ows_semibold
|
||
|
titleLabel.textAlignment = .center
|
||
|
titleLabel.textColor = Theme.darkThemePrimaryColor
|
||
|
callFullStack.addArrangedSubview(titleLabel)
|
||
|
|
||
|
callFullStack.addArrangedSubview(callFullLabel)
|
||
|
|
||
|
return callFullStack
|
||
|
}()
|
||
|
|
||
|
override init() {
|
||
|
super.init()
|
||
|
|
||
|
videoOffIndicatorImage.contentMode = .scaleAspectFit
|
||
|
videoOffIndicatorImage.setTemplateImage(#imageLiteral(resourceName: "video-off-solid-28"), tintColor: .ows_white)
|
||
|
noVideoView.addSubview(videoOffIndicatorImage)
|
||
|
videoOffIndicatorImage.autoMatch(.height, to: .width, of: videoOffIndicatorImage)
|
||
|
videoOffIndicatorImage.autoCenterInSuperview()
|
||
|
|
||
|
videoOffLabel.font = .ows_dynamicTypeSubheadline
|
||
|
videoOffLabel.text = NSLocalizedString("CALLING_MEMBER_VIEW_YOUR_CAMERA_IS_OFF",
|
||
|
comment: "Indicates to the user that their camera is currently off.")
|
||
|
videoOffLabel.textAlignment = .center
|
||
|
videoOffLabel.textColor = Theme.darkThemePrimaryColor
|
||
|
noVideoView.addSubview(videoOffLabel)
|
||
|
videoOffLabel.autoPinWidthToSuperview()
|
||
|
videoOffLabel.autoPinEdge(.top, to: .bottom, of: videoOffIndicatorImage, withOffset: 10)
|
||
|
|
||
|
videoView.contentMode = .scaleAspectFill
|
||
|
insertSubview(videoView, belowSubview: muteIndicatorImage)
|
||
|
videoView.frame = bounds
|
||
|
|
||
|
addSubview(callFullStack)
|
||
|
callFullStack.autoAlignAxis(.horizontal, toSameAxisOf: self, withOffset: -30)
|
||
|
callFullStack.autoPinWidthToSuperview(withMargin: 16)
|
||
|
|
||
|
layer.shadowOffset = .zero
|
||
|
layer.shadowOpacity = 0.25
|
||
|
layer.shadowRadius = 4
|
||
|
}
|
||
|
|
||
|
required init?(coder: NSCoder) {
|
||
|
fatalError("init(coder:) has not been implemented")
|
||
|
}
|
||
|
|
||
|
private var hasBeenConfigured = false
|
||
|
func configure(call: SignalCall, isFullScreen: Bool = false) {
|
||
|
hasBeenConfigured = true
|
||
|
|
||
|
videoView.isHidden = call.groupCall.isOutgoingVideoMuted
|
||
|
videoView.captureSession = call.videoCaptureController.captureSession
|
||
|
noVideoView.isHidden = !videoView.isHidden
|
||
|
|
||
|
if isFullScreen,
|
||
|
call.groupCall.isFull,
|
||
|
case .notJoined = call.groupCall.localDeviceState.joinState {
|
||
|
|
||
|
let text: String
|
||
|
if let maxDevices = call.groupCall.maxDevices {
|
||
|
let formatString = NSLocalizedString(
|
||
|
"GROUP_CALL_HAS_MAX_DEVICES_FORMAT",
|
||
|
comment: "An error displayed to the user when the group call ends because it has exceeded the max devices. Embeds {{max device count}}."
|
||
|
)
|
||
|
text = String(format: formatString, maxDevices)
|
||
|
} else {
|
||
|
text = NSLocalizedString(
|
||
|
"GROUP_CALL_HAS_MAX_DEVICES_UNKNOWN_COUNT",
|
||
|
comment: "An error displayed to the user when the group call ends because it has exceeded the max devices."
|
||
|
)
|
||
|
}
|
||
|
|
||
|
callFullLabel.text = text
|
||
|
callFullStack.isHidden = false
|
||
|
videoOffLabel.isHidden = true
|
||
|
videoOffIndicatorImage.isHidden = true
|
||
|
} else {
|
||
|
callFullStack.isHidden = true
|
||
|
videoOffLabel.isHidden = !videoView.isHidden || !isFullScreen
|
||
|
videoOffIndicatorImage.isHidden = !videoView.isHidden
|
||
|
}
|
||
|
|
||
|
guard let localAddress = tsAccountManager.localAddress else {
|
||
|
return owsFailDebug("missing local address")
|
||
|
}
|
||
|
|
||
|
backgroundAvatarView.image = profileManager.localProfileAvatarImage()
|
||
|
|
||
|
muteIndicatorImage.isHidden = isFullScreen || !call.groupCall.isOutgoingAudioMuted
|
||
|
muteLeadingConstraint.constant = muteInsets
|
||
|
muteBottomConstraint.constant = -muteInsets
|
||
|
muteHeightConstraint.constant = muteHeight
|
||
|
|
||
|
videoOffIndicatorWidthConstraint.constant = videoOffIndicatorWidth
|
||
|
|
||
|
noVideoView.backgroundColor = ChatColors.avatarColor(forAddress: localAddress)
|
||
|
|
||
|
layer.cornerRadius = isFullScreen ? 0 : 10
|
||
|
clipsToBounds = true
|
||
|
}
|
||
|
|
||
|
private func updateDimensions() {
|
||
|
guard hasBeenConfigured else { return }
|
||
|
videoView.frame = bounds
|
||
|
muteLeadingConstraint.constant = muteInsets
|
||
|
muteBottomConstraint.constant = -muteInsets
|
||
|
muteHeightConstraint.constant = muteHeight
|
||
|
videoOffIndicatorWidthConstraint.constant = videoOffIndicatorWidth
|
||
|
}
|
||
|
}
|
||
|
|
||
|
class GroupCallRemoteMemberView: GroupCallMemberView {
|
||
|
private weak var videoView: GroupCallRemoteVideoView?
|
||
|
|
||
|
var deferredReconfigTimer: Timer?
|
||
|
let errorView = GroupCallErrorView()
|
||
|
let avatarView = ConversationAvatarView(diameterPoints: 0,
|
||
|
localUserDisplayMode: .asUser)
|
||
|
let spinner = UIActivityIndicatorView(style: .whiteLarge)
|
||
|
lazy var avatarWidthConstraint = avatarView.autoSetDimension(.width, toSize: CGFloat(avatarDiameter))
|
||
|
|
||
|
var isCallMinimized: Bool = false {
|
||
|
didSet {
|
||
|
// Currently only updated for the speaker view, since that's the only visible cell
|
||
|
// while minimized.
|
||
|
errorView.forceCompactAppearance = isCallMinimized
|
||
|
errorView.isUserInteractionEnabled = !isCallMinimized
|
||
|
}
|
||
|
}
|
||
|
|
||
|
override var bounds: CGRect {
|
||
|
didSet { updateDimensions() }
|
||
|
}
|
||
|
|
||
|
override var frame: CGRect {
|
||
|
didSet { updateDimensions() }
|
||
|
}
|
||
|
|
||
|
var avatarDiameter: UInt {
|
||
|
layoutIfNeeded()
|
||
|
|
||
|
if width > 180 {
|
||
|
return 112
|
||
|
} else if width > 102 {
|
||
|
return 96
|
||
|
} else if width > 36 {
|
||
|
return UInt(width) - 36
|
||
|
} else {
|
||
|
return 16
|
||
|
}
|
||
|
}
|
||
|
|
||
|
let mode: Mode
|
||
|
enum Mode: Equatable {
|
||
|
case videoGrid, videoOverflow, speaker
|
||
|
}
|
||
|
|
||
|
init(mode: Mode) {
|
||
|
self.mode = mode
|
||
|
super.init()
|
||
|
|
||
|
noVideoView.insertSubview(avatarView, belowSubview: muteIndicatorImage)
|
||
|
noVideoView.insertSubview(errorView, belowSubview: muteIndicatorImage)
|
||
|
noVideoView.insertSubview(spinner, belowSubview: muteIndicatorImage)
|
||
|
|
||
|
avatarView.autoCenterInSuperview()
|
||
|
errorView.autoPinEdgesToSuperviewEdges()
|
||
|
spinner.autoCenterInSuperview()
|
||
|
}
|
||
|
|
||
|
required init?(coder: NSCoder) {
|
||
|
fatalError("init(coder:) has not been implemented")
|
||
|
}
|
||
|
|
||
|
private var hasBeenConfigured = false
|
||
|
func configure(call: SignalCall, device: RemoteDeviceState) {
|
||
|
hasBeenConfigured = true
|
||
|
deferredReconfigTimer?.invalidate()
|
||
|
|
||
|
let profileImage = databaseStorage.read { transaction -> UIImage? in
|
||
|
avatarView.configure(address: device.address,
|
||
|
diameterPoints: avatarDiameter,
|
||
|
localUserDisplayMode: .asUser,
|
||
|
transaction: transaction)
|
||
|
avatarWidthConstraint.constant = CGFloat(avatarDiameter)
|
||
|
|
||
|
return self.contactsManagerImpl.avatarImage(forAddress: device.address,
|
||
|
shouldValidate: true,
|
||
|
transaction: transaction)
|
||
|
}
|
||
|
|
||
|
backgroundAvatarView.image = profileImage
|
||
|
|
||
|
muteIndicatorImage.isHidden = mode == .speaker || device.audioMuted != true
|
||
|
muteLeadingConstraint.constant = muteInsets
|
||
|
muteBottomConstraint.constant = -muteInsets
|
||
|
muteHeightConstraint.constant = muteHeight
|
||
|
|
||
|
noVideoView.backgroundColor = ChatColors.avatarColor(forAddress: device.address)
|
||
|
|
||
|
configureRemoteVideo(device: device)
|
||
|
let isRemoteDeviceBlocked = blockingManager.isAddressBlocked(device.address)
|
||
|
let errorDeferralInterval: TimeInterval = 5.0
|
||
|
let addedDate = Date(millisecondsSince1970: device.addedTime)
|
||
|
let connectionDuration = -addedDate.timeIntervalSinceNow
|
||
|
|
||
|
// Hide these views. They'll be unhidden below.
|
||
|
[errorView, avatarView, videoView, spinner].forEach { $0?.isHidden = true }
|
||
|
|
||
|
if !device.mediaKeysReceived, !isRemoteDeviceBlocked, connectionDuration < errorDeferralInterval {
|
||
|
// No media keys, but that's expected since we just joined the call.
|
||
|
// Schedule a timer to re-check and show a spinner in the meantime
|
||
|
spinner.isHidden = false
|
||
|
if !spinner.isAnimating { spinner.startAnimating() }
|
||
|
|
||
|
let configuredDemuxId = device.demuxId
|
||
|
let scheduledInterval = errorDeferralInterval - connectionDuration
|
||
|
deferredReconfigTimer = Timer.scheduledTimer(
|
||
|
withTimeInterval: scheduledInterval,
|
||
|
repeats: false,
|
||
|
block: { [weak self] _ in
|
||
|
guard let self = self else { return }
|
||
|
guard call.isGroupCall, let groupCall = call.groupCall else { return }
|
||
|
guard let updatedState = groupCall.remoteDeviceStates.values
|
||
|
.first(where: { $0.demuxId == configuredDemuxId }) else { return }
|
||
|
self.configure(call: call, device: updatedState)
|
||
|
})
|
||
|
|
||
|
} else if !device.mediaKeysReceived {
|
||
|
// No media keys. Display error view
|
||
|
errorView.isHidden = false
|
||
|
configureErrorView(for: device.address, isBlocked: isRemoteDeviceBlocked)
|
||
|
|
||
|
} else if let videoView = videoView, device.videoTrack != nil {
|
||
|
// We have a video track! If we don't know the mute state, show both.
|
||
|
// Otherwise, show one or the other.
|
||
|
videoView.isHidden = (device.videoMuted == true)
|
||
|
avatarView.isHidden = (device.videoMuted == false)
|
||
|
|
||
|
} else {
|
||
|
// No video. Display avatar
|
||
|
avatarView.isHidden = false
|
||
|
}
|
||
|
}
|
||
|
|
||
|
func clearConfiguration() {
|
||
|
deferredReconfigTimer?.invalidate()
|
||
|
|
||
|
cleanupVideoViews()
|
||
|
|
||
|
noVideoView.backgroundColor = .ows_black
|
||
|
backgroundAvatarView.image = nil
|
||
|
avatarView.image = nil
|
||
|
|
||
|
[errorView, spinner, muteIndicatorImage].forEach { $0.isHidden = true }
|
||
|
}
|
||
|
|
||
|
private func updateDimensions() {
|
||
|
guard hasBeenConfigured else { return }
|
||
|
videoView?.frame = bounds
|
||
|
muteLeadingConstraint.constant = muteInsets
|
||
|
muteBottomConstraint.constant = -muteInsets
|
||
|
muteHeightConstraint.constant = muteHeight
|
||
|
avatarWidthConstraint.constant = CGFloat(avatarDiameter)
|
||
|
}
|
||
|
|
||
|
func cleanupVideoViews() {
|
||
|
if videoView?.superview == self { videoView?.removeFromSuperview() }
|
||
|
videoView = nil
|
||
|
}
|
||
|
|
||
|
func configureRemoteVideo(device: RemoteDeviceState) {
|
||
|
if videoView?.superview == self { videoView?.removeFromSuperview() }
|
||
|
let newVideoView = callService.groupCallRemoteVideoManager.remoteVideoView(for: device, mode: mode)
|
||
|
insertSubview(newVideoView, belowSubview: muteIndicatorImage)
|
||
|
newVideoView.frame = bounds
|
||
|
newVideoView.isScreenShare = device.sharingScreen == true
|
||
|
videoView = newVideoView
|
||
|
owsAssertDebug(videoView != nil, "Missing remote video view")
|
||
|
}
|
||
|
|
||
|
func configureErrorView(for address: SignalServiceAddress, isBlocked: Bool) {
|
||
|
let displayName: String
|
||
|
if address.isLocalAddress {
|
||
|
displayName = NSLocalizedString(
|
||
|
"GROUP_CALL_YOU_ON_ANOTHER_DEVICE",
|
||
|
comment: "Text describing the local user in the group call members sheet when connected from another device.")
|
||
|
} else {
|
||
|
displayName = self.contactsManager.displayName(for: address)
|
||
|
}
|
||
|
|
||
|
let blockFormat = NSLocalizedString(
|
||
|
"GROUP_CALL_BLOCKED_USER_FORMAT",
|
||
|
comment: "String displayed in group call grid cell when a user is blocked. Embeds {user's name}")
|
||
|
let missingKeyFormat = NSLocalizedString(
|
||
|
"GROUP_CALL_MISSING_MEDIA_KEYS_FORMAT",
|
||
|
comment: "String displayed in cell when media from a user can't be displayed in group call grid. Embeds {user's name}")
|
||
|
|
||
|
let labelFormat = isBlocked ? blockFormat : missingKeyFormat
|
||
|
let label = String(format: labelFormat, arguments: [displayName])
|
||
|
let image = isBlocked ? UIImage(named: "block-24") : UIImage(named: "error-solid-24")
|
||
|
|
||
|
errorView.iconImage = image
|
||
|
errorView.labelText = label
|
||
|
errorView.userTapAction = { [weak self] _ in
|
||
|
guard let self = self else { return }
|
||
|
|
||
|
if isBlocked {
|
||
|
self.delegate?.memberView(self, userRequestedInfoAboutError: .blocked(address))
|
||
|
} else {
|
||
|
self.delegate?.memberView(self, userRequestedInfoAboutError: .noMediaKeys(address))
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
extension RemoteDeviceState {
|
||
|
var address: SignalServiceAddress {
|
||
|
return SignalServiceAddress(uuid: userId)
|
||
|
}
|
||
|
}
|