// // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import LibSignalClient import SignalRingRTC import SignalServiceKit import SignalUI class CallMemberCameraOffView: UIView, CallMemberComposableView { private let blurredAvatarBackgroundView = BlurredAvatarBackgroundView() // One of these three is shown depending on the circumstances. private var avatarView: ConversationAvatarView? private var detailedNoVideoIndicatorView: UIStackView? private var noVideoIndicatorImageView: UIImageView? private let type: CallMemberView.MemberType init(type: CallMemberView.MemberType) { self.type = type super.init(frame: .zero) self.isUserInteractionEnabled = false let overlayView = UIView() overlayView.backgroundColor = .ows_blackAlpha40 self.addSubview(overlayView) overlayView.autoPinEdgesToSuperviewEdges() self.addSubview(self.blurredAvatarBackgroundView) self.blurredAvatarBackgroundView.autoPinEdgesToSuperviewEdges() } /// This method initializes the views that have the potential to be shown /// based on whether the call is individual/group and whether the member /// is local/remote. /// /// Spec when camera is off: /// - Local Member: /// - Individual call: /// - PIP: N/A because PIP disappears when camera is off. /// - Fullscreen: Circular avatar. /// - Group call: /// - PIP: Camera-off image. /// - Fullscreen: Camera-off image and message. /// - Remote Member: Circular avatar. private func createOptionalViews(type: CallMemberView.MemberType, call: SignalCall) { switch type { case .local: switch call.mode { case .individual: self.avatarView = ConversationAvatarView(localUserDisplayMode: .asUser, badged: false) case .groupThread, .callLink: self.detailedNoVideoIndicatorView = self.createDetailedVideoOffIndicatorView() self.noVideoIndicatorImageView = self.createVideoOffIndicatorImageView() } case .remoteInGroup, .remoteInIndividual: self.avatarView = ConversationAvatarView(localUserDisplayMode: .asUser, badged: false) } } private var hasConfiguredOnce = false func configure( call: SignalCall, isFullScreen: Bool = false, remoteGroupMemberDeviceState: RemoteDeviceState? ) { if !hasConfiguredOnce { self.createOptionalViews(type: type, call: call) if let avatarView { self.addSubview(avatarView) avatarView.autoCenterInSuperview() } if let detailedNoVideoIndicatorView { self.addSubview(detailedNoVideoIndicatorView) detailedNoVideoIndicatorView.autoCenterInSuperview() detailedNoVideoIndicatorView.isHidden = true } if let noVideoIndicatorImageView { self.addSubview(noVideoIndicatorImageView) noVideoIndicatorImageView.isHidden = true noVideoIndicatorImageView.autoMatch(.height, to: .width, of: noVideoIndicatorImageView) noVideoIndicatorImageView.autoCenterInSuperview() let constraint = noVideoIndicatorImageView.autoSetDimension(.width, toSize: videoOffImageIndicatorWidth) self.videoOffIndicatorImageWidthConstraint = constraint NSLayoutConstraint.activate([constraint]) } hasConfiguredOnce = true } switch type { case .local: self.isHidden = !call.isOutgoingVideoMuted case .remoteInIndividual(let individualCall): self.isHidden = individualCall.isRemoteVideoEnabled case .remoteInGroup: if let videoMuted = remoteGroupMemberDeviceState?.videoMuted { self.isHidden = !videoMuted } else { self.isHidden = false } } if self.isHidden { return } if let detailedNoVideoIndicatorView { detailedNoVideoIndicatorView.isHidden = !isFullScreen } if let noVideoIndicatorImageView { noVideoIndicatorImageView.isHidden = isFullScreen } // Update blurred avatar background self.blurredAvatarBackgroundView.update( type: self.type, remoteGroupMemberDeviceState: remoteGroupMemberDeviceState ) // Update circular avatar switch type { case .local: databaseStorage.read { tx in guard let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx.asV2Read)?.aciAddress else { owsFailDebug("missing local address") return } updateCircularAvatarIfNecessary( address: localAddress, tx: tx ) } case .remoteInGroup: guard let remoteGroupMemberDeviceState else { return } databaseStorage.read { tx in updateCircularAvatarIfNecessary( address: remoteGroupMemberDeviceState.address, tx: tx ) } case .remoteInIndividual(let individualCall): databaseStorage.read { tx in updateCircularAvatarIfNecessary( address: individualCall.remoteAddress, tx: tx ) } } } func updateDimensions() { self.videoOffIndicatorImageWidthConstraint?.constant = videoOffImageIndicatorWidth 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 self.blurredAvatarBackgroundView.clear() avatarView?.reset() } // MARK: - Avatar 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 } } private func updateCircularAvatarIfNecessary( address: SignalServiceAddress, tx: SDSAnyReadTransaction ) { guard let avatarView else { OWSLogger.info("Skipping refresh of avatar view in call member view.") return } let updatedSize = avatarDiameter avatarView.update(tx) { config in config.dataSource = .address(address) config.sizeClass = .customDiameter(updatedSize) } } // MARK: - Detailed Video Off Indicator View private func createDetailedVideoOffIndicatorView() -> UIStackView { let icon = UIImageView() icon.contentMode = .scaleAspectFit icon.setTemplateImageName("video-slash-fill-28", tintColor: .ows_white) let label = UILabel() label.font = .dynamicTypeCaption1 label.text = OWSLocalizedString( "CALLING_MEMBER_VIEW_YOUR_CAMERA_IS_OFF", comment: "Indicates to the user that their camera is currently off." ) label.textAlignment = .center label.textColor = Theme.darkThemePrimaryColor let container = UIStackView(arrangedSubviews: [icon, label]) if UIDevice.current.isIPhone5OrShorter { // Use a horizontal layout to save on vertical space. // Allow the icon to shrink below its natural size of 28pt... icon.setContentCompressionResistancePriority(.defaultLow, for: .vertical) container.axis = .horizontal container.spacing = 4 // ...by always matching the label's height. container.alignment = .fill } else { // Use a simple vertical layout. icon.autoSetDimensions(to: CGSize(square: 28)) container.axis = .vertical container.spacing = 10 container.alignment = .center label.autoPinWidthToSuperview() } return container } // MARK: - Video Off Indicator Image private var videoOffIndicatorImageWidthConstraint: NSLayoutConstraint? private var videoOffImageIndicatorWidth: CGFloat { width > 102 ? 28 : 16 } private func createVideoOffIndicatorImageView() -> UIImageView { let imageView = UIImageView() imageView.contentMode = .scaleAspectFit imageView.setTemplateImageName("video-slash-fill-28", tintColor: .ows_white) return imageView } // MARK: - Required required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } } class BlurredAvatarBackgroundView: UIView { private let backgroundAvatarView = UIImageView() private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial)) init() { super.init(frame: .zero) self.overrideUserInterfaceStyle = .dark backgroundAvatarView.contentMode = .scaleAspectFill self.addSubview(backgroundAvatarView) backgroundAvatarView.autoPinEdgesToSuperviewEdges() self.addSubview(blurView) blurView.autoPinEdgesToSuperviewEdges() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } func update( type: CallMemberView.MemberType, remoteGroupMemberDeviceState: RemoteDeviceState? ) { let backgroundAvatarImage: UIImage? var backgroundColor: UIColor? switch type { case .local: databaseStorage.read { tx in guard let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: tx.asV2Read)?.aciAddress else { owsFailDebug("missing local address") return } backgroundColor = AvatarTheme.forAddress(localAddress).backgroundColor } backgroundAvatarImage = profileManager.localProfileAvatarImage() case .remoteInGroup: guard let remoteGroupMemberDeviceState else { return } let (image, color) = avatarImageAndBackgroundColorWithSneakyTransaction(for: remoteGroupMemberDeviceState.address) backgroundAvatarImage = image backgroundColor = color case .remoteInIndividual(let individualCall): let (image, color) = avatarImageAndBackgroundColorWithSneakyTransaction(for: individualCall.remoteAddress) backgroundAvatarImage = image backgroundColor = color } backgroundAvatarView.image = backgroundAvatarImage self.backgroundColor = backgroundColor } private func avatarImageAndBackgroundColorWithSneakyTransaction( for address: SignalServiceAddress ) -> (UIImage?, UIColor?) { let profileImage = databaseStorage.read { tx in return self.contactsManagerImpl.avatarImage( forAddress: address, shouldValidate: true, transaction: tx ) } return ( profileImage, AvatarTheme.forAddress(address).backgroundColor ) } func clear() { backgroundAvatarView.image = nil } } extension RemoteDeviceState { var aci: Aci { Aci(fromUUID: userId) } var address: SignalServiceAddress { SignalServiceAddress(aci) } }