session-ios/Session/Calls/UserInterface/Group/GroupCallMemberView.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)
}
}