[WIP] Video chat v2

This commit is contained in:
Isaac 2024-09-03 22:34:04 +08:00
parent 53cb586691
commit d2b4622ef0
14 changed files with 1544 additions and 165 deletions

View File

@ -17,7 +17,7 @@ public extension UIView {
}
private extension CALayer {
func animate(from: AnyObject, to: AnyObject, keyPath: String, duration: Double, delay: Double, curve: ComponentTransition.Animation.Curve, removeOnCompletion: Bool, additive: Bool, completion: ((Bool) -> Void)? = nil) {
func animate(from: Any, to: Any, keyPath: String, duration: Double, delay: Double, curve: ComponentTransition.Animation.Curve, removeOnCompletion: Bool, additive: Bool, completion: ((Bool) -> Void)? = nil) {
let timingFunction: String
let mediaTimingFunction: CAMediaTimingFunction?
switch curve {
@ -1184,6 +1184,44 @@ public struct ComponentTransition {
}
}
public func setGradientColors(layer: CAGradientLayer, colors: [UIColor], completion: ((Bool) -> Void)? = nil) {
if let current = layer.colors {
if current.count == colors.count {
let currentColors = current.map { UIColor(cgColor: $0 as! CGColor) }
if currentColors == colors {
completion?(true)
return
}
}
}
switch self.animation {
case .none:
layer.colors = colors.map(\.cgColor)
completion?(true)
case let .curve(duration, curve):
let previousColors: [Any]
if let current = layer.colors {
previousColors = current
} else {
previousColors = (0 ..< colors.count).map { _ in UIColor.clear.cgColor as Any }
}
layer.colors = colors.map(\.cgColor)
layer.animate(
from: previousColors,
to: colors.map(\.cgColor),
keyPath: "colors",
duration: duration,
delay: 0.0,
curve: curve,
removeOnCompletion: true,
additive: false,
completion: completion
)
}
}
public func animateContentsImage(layer: CALayer, from fromImage: CGImage, to toImage: CGImage, duration: Double, curve: ComponentTransition.Animation.Curve, completion: ((Bool) -> Void)? = nil) {
layer.animate(
from: fromImage,

View File

@ -251,3 +251,142 @@ public final class FilledRoundedRectangleComponent: Component {
return view.update(component: self, availableSize: availableSize, transition: transition)
}
}
open class SolidRoundedCornersContainer: UIView {
public final class Params: Equatable {
public let size: CGSize
public let color: UIColor
public let cornerRadius: CGFloat
public let smoothCorners: Bool
public init(
size: CGSize,
color: UIColor,
cornerRadius: CGFloat,
smoothCorners: Bool
) {
self.size = size
self.color = color
self.cornerRadius = cornerRadius
self.smoothCorners = smoothCorners
}
public static func ==(lhs: Params, rhs: Params) -> Bool {
if lhs === rhs {
return true
}
if lhs.size != rhs.size {
return false
}
if lhs.color != rhs.color {
return false
}
if lhs.cornerRadius != rhs.cornerRadius {
return false
}
if lhs.smoothCorners != rhs.smoothCorners {
return false
}
return true
}
}
public let cornersView: UIImageView
private var params: Params?
private var currentCornerRadius: CGFloat?
private var cornerImage: UIImage?
override public init(frame: CGRect) {
self.cornersView = UIImageView()
super.init(frame: frame)
self.clipsToBounds = true
self.addSubview(self.cornersView)
}
required public init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func applyStaticCornerRadius() {
guard let params = self.params else {
return
}
guard let cornerRadius = self.currentCornerRadius else {
return
}
if cornerRadius == 0.0 {
if let cornerImage = self.cornerImage, cornerImage.size.width == 1.0 {
} else {
self.cornerImage = generateImage(CGSize(width: 1.0, height: 1.0), rotatedContext: { size, context in
context.setFillColor(UIColor.clear.cgColor)
context.fill(CGRect(origin: CGPoint(), size: size))
})?.stretchableImage(withLeftCapWidth: Int(cornerRadius) + 5, topCapHeight: Int(cornerRadius) + 5).withRenderingMode(.alwaysTemplate)
}
} else {
if params.smoothCorners {
let size = CGSize(width: cornerRadius * 2.0 + 10.0, height: cornerRadius * 2.0 + 10.0)
if let cornerImage = self.cornerImage, cornerImage.size == size {
} else {
self.cornerImage = generateImage(size, rotatedContext: { size, context in
context.setFillColor(UIColor.white.cgColor)
context.fill(CGRect(origin: CGPoint(), size: size))
context.addPath(UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size), cornerRadius: cornerRadius).cgPath)
context.setFillColor(UIColor.clear.cgColor)
context.setBlendMode(.copy)
context.fillPath()
})?.stretchableImage(withLeftCapWidth: Int(cornerRadius) + 5, topCapHeight: Int(cornerRadius) + 5).withRenderingMode(.alwaysTemplate)
}
} else {
let size = CGSize(width: cornerRadius * 2.0, height: cornerRadius * 2.0)
if let cornerImage = self.cornerImage, cornerImage.size == size {
} else {
self.cornerImage = generateStretchableFilledCircleImage(diameter: size.width, color: nil, backgroundColor: .white)?.withRenderingMode(.alwaysTemplate)
}
}
}
self.cornersView.image = self.cornerImage
self.backgroundColor = nil
self.layer.cornerRadius = 0.0
}
public func update(params: Params, transition: ComponentTransition) {
if self.params == params {
return
}
self.params = params
transition.setTintColor(view: self.cornersView, color: params.color)
if self.currentCornerRadius != params.cornerRadius {
let previousCornerRadius = self.currentCornerRadius
self.currentCornerRadius = params.cornerRadius
if transition.animation.isImmediate {
self.applyStaticCornerRadius()
} else {
self.cornersView.image = nil
self.clipsToBounds = true
if let previousCornerRadius, self.layer.animation(forKey: "cornerRadius") == nil {
self.layer.cornerRadius = previousCornerRadius
}
if #available(iOS 13.0, *) {
if params.smoothCorners {
self.layer.cornerCurve = .continuous
} else {
self.layer.cornerCurve = .circular
}
}
transition.setCornerRadius(layer: self.layer, cornerRadius: params.cornerRadius, completion: { [weak self] completed in
guard let self, completed else {
return
}
self.applyStaticCornerRadius()
})
}
}
}
}

View File

@ -69,7 +69,7 @@ private func adjustFrameRate(animation: CAAnimation) {
}
public extension CALayer {
func makeAnimation(from: AnyObject?, to: AnyObject, keyPath: String, timingFunction: String, duration: Double, delay: Double = 0.0, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) -> CAAnimation {
func makeAnimation(from: Any?, to: Any, keyPath: String, timingFunction: String, duration: Double, delay: Double = 0.0, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil) -> CAAnimation {
if timingFunction.hasPrefix(kCAMediaTimingFunctionCustomSpringPrefix) {
let components = timingFunction.components(separatedBy: "_")
let damping = Float(components[1]) ?? 100.0
@ -203,7 +203,7 @@ public extension CALayer {
}
}
func animate(from: AnyObject?, to: AnyObject, keyPath: String, timingFunction: String, duration: Double, delay: Double = 0.0, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil, key: String? = nil) {
func animate(from: Any?, to: Any, keyPath: String, timingFunction: String, duration: Double, delay: Double = 0.0, mediaTimingFunction: CAMediaTimingFunction? = nil, removeOnCompletion: Bool = true, additive: Bool = false, completion: ((Bool) -> Void)? = nil, key: String? = nil) {
let animation = self.makeAnimation(from: from, to: to, keyPath: keyPath, timingFunction: timingFunction, duration: duration, delay: delay, mediaTimingFunction: mediaTimingFunction, removeOnCompletion: removeOnCompletion, additive: additive, completion: completion)
self.add(animation, forKey: key ?? (additive ? nil : keyPath))
}

View File

@ -61,7 +61,7 @@ final class VideoChatActionButtonComponent: Component {
final class View: HighlightTrackingButton {
private let icon = ComponentView<Empty>()
private let background = ComponentView<Empty>()
private let background: UIImageView
private let title = ComponentView<Empty>()
private var component: VideoChatActionButtonComponent?
@ -70,6 +70,8 @@ final class VideoChatActionButtonComponent: Component {
private var contentImage: UIImage?
override init(frame: CGRect) {
self.background = UIImageView()
super.init(frame: frame)
}
@ -96,7 +98,7 @@ final class VideoChatActionButtonComponent: Component {
titleText = "video"
switch component.microphoneState {
case .connecting:
backgroundColor = UIColor(white: 1.0, alpha: 0.1)
backgroundColor = UIColor(white: 0.1, alpha: 1.0)
case .muted:
backgroundColor = !isActive ? UIColor(rgb: 0x002E5D) : UIColor(rgb: 0x027FFF)
case .unmuted:
@ -144,22 +146,20 @@ final class VideoChatActionButtonComponent: Component {
let size = CGSize(width: availableSize.width, height: availableSize.height)
let _ = self.background.update(
transition: transition,
component: AnyComponent(FilledRoundedRectangleComponent(
color: backgroundColor,
cornerRadius: size.width * 0.5,
smoothCorners: false
)),
environment: {},
containerSize: size
)
if let backgroundView = self.background.view {
if backgroundView.superview == nil {
self.addSubview(backgroundView)
}
transition.setFrame(view: backgroundView, frame: CGRect(origin: CGPoint(), size: size))
if self.background.superview == nil {
self.addSubview(self.background)
self.background.image = generateStretchableFilledCircleImage(diameter: 56.0, color: .white)?.withRenderingMode(.alwaysTemplate)
self.background.tintColor = backgroundColor
}
transition.setFrame(view: self.background, frame: CGRect(origin: CGPoint(), size: size))
let tintTransition: ComponentTransition
if !transition.animation.isImmediate {
tintTransition = .easeInOut(duration: 0.2)
} else {
tintTransition = .immediate
}
tintTransition.setTintColor(layer: self.background.layer, color: backgroundColor)
let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: size.height + 8.0), size: titleSize)
if let titleView = self.title.view {

View File

@ -18,6 +18,7 @@ final class VideoChatParticipantThumbnailComponent: Component {
let participant: GroupCallParticipantsContext.Participant
let isPresentation: Bool
let isSelected: Bool
let isSpeaking: Bool
let action: (() -> Void)?
init(
@ -26,6 +27,7 @@ final class VideoChatParticipantThumbnailComponent: Component {
participant: GroupCallParticipantsContext.Participant,
isPresentation: Bool,
isSelected: Bool,
isSpeaking: Bool,
action: (() -> Void)?
) {
self.call = call
@ -33,6 +35,7 @@ final class VideoChatParticipantThumbnailComponent: Component {
self.participant = participant
self.isPresentation = isPresentation
self.isSelected = isSelected
self.isSpeaking = isSpeaking
self.action = action
}
@ -52,6 +55,9 @@ final class VideoChatParticipantThumbnailComponent: Component {
if lhs.isSelected != rhs.isSelected {
return false
}
if lhs.isSpeaking != rhs.isSpeaking {
return false
}
return true
}
@ -121,6 +127,20 @@ final class VideoChatParticipantThumbnailComponent: Component {
self.backgroundColor = UIColor(rgb: 0x1C1C1E)
}
let previousComponent = self.component
let wasSpeaking = previousComponent?.isSpeaking ?? false
let speakingAlphaTransition: ComponentTransition
if transition.animation.isImmediate {
speakingAlphaTransition = .immediate
} else {
if !wasSpeaking {
speakingAlphaTransition = .easeInOut(duration: 0.1)
} else {
speakingAlphaTransition = .easeInOut(duration: 0.25)
}
}
self.component = component
self.componentState = state
@ -148,19 +168,22 @@ final class VideoChatParticipantThumbnailComponent: Component {
transition: transition,
component: AnyComponent(VideoChatMuteIconComponent(
color: .white,
isFilled: true,
isMuted: component.participant.muteState != nil
)),
environment: {},
containerSize: CGSize(width: 36.0, height: 36.0)
)
let muteStatusFrame = CGRect(origin: CGPoint(x: availableSize.width + 5.0 - muteStatusSize.width, y: availableSize.height + 5.0 - muteStatusSize.height), size: muteStatusSize)
if let muteStatusView = self.muteStatus.view {
if let muteStatusView = self.muteStatus.view as? VideoChatMuteIconComponent.View {
if muteStatusView.superview == nil {
self.addSubview(muteStatusView)
}
transition.setPosition(view: muteStatusView, position: muteStatusFrame.center)
transition.setBounds(view: muteStatusView, bounds: CGRect(origin: CGPoint(), size: muteStatusFrame.size))
transition.setScale(view: muteStatusView, scale: 0.65)
speakingAlphaTransition.setTintColor(layer: muteStatusView.iconView.layer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : .white)
}
let titleSize = self.title.update(
@ -180,6 +203,8 @@ final class VideoChatParticipantThumbnailComponent: Component {
}
transition.setPosition(view: titleView, position: titleFrame.origin)
titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size)
speakingAlphaTransition.setTintColor(layer: titleView.layer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : .white)
}
if let videoDescription = component.isPresentation ? component.participant.presentationDescription : component.participant.videoDescription {
@ -296,25 +321,45 @@ final class VideoChatParticipantThumbnailComponent: Component {
self.videoSpec = nil
}
if component.isSelected {
if component.isSelected || component.isSpeaking {
let selectedBorderView: UIImageView
if let current = self.selectedBorderView {
selectedBorderView = current
speakingAlphaTransition.setTintColor(layer: selectedBorderView.layer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor)
} else {
selectedBorderView = UIImageView()
self.selectedBorderView = selectedBorderView
self.addSubview(selectedBorderView)
selectedBorderView.image = View.selectedBorderImage
selectedBorderView.frame = CGRect(origin: CGPoint(), size: availableSize)
ComponentTransition.immediate.setTintColor(layer: selectedBorderView.layer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor)
}
selectedBorderView.tintColor = component.theme.list.itemAccentColor
selectedBorderView.frame = CGRect(origin: CGPoint(), size: availableSize)
} else {
if let selectedBorderView = self.selectedBorderView {
} else if let selectedBorderView = self.selectedBorderView {
if !speakingAlphaTransition.animation.isImmediate {
if selectedBorderView.alpha != 0.0 {
speakingAlphaTransition.setAlpha(view: selectedBorderView, alpha: 0.0, completion: { [weak self, weak selectedBorderView] completed in
guard let self, let component = self.component, let selectedBorderView, self.selectedBorderView === selectedBorderView, completed else {
return
}
if !component.isSelected && !component.isSpeaking {
selectedBorderView.removeFromSuperview()
self.selectedBorderView = nil
}
})
}
} else {
self.selectedBorderView = nil
selectedBorderView.removeFromSuperview()
}
}
if let selectedBorderView = self.selectedBorderView {
transition.setFrame(view: selectedBorderView, frame: CGRect(origin: CGPoint(), size: availableSize))
}
return availableSize
}
}
@ -373,6 +418,7 @@ final class VideoChatExpandedParticipantThumbnailsComponent: Component {
let theme: PresentationTheme
let participants: [Participant]
let selectedParticipant: Participant.Key?
let speakingParticipants: Set<EnginePeer.Id>
let updateSelectedParticipant: (Participant.Key) -> Void
init(
@ -380,12 +426,14 @@ final class VideoChatExpandedParticipantThumbnailsComponent: Component {
theme: PresentationTheme,
participants: [Participant],
selectedParticipant: Participant.Key?,
speakingParticipants: Set<EnginePeer.Id>,
updateSelectedParticipant: @escaping (Participant.Key) -> Void
) {
self.call = call
self.theme = theme
self.participants = participants
self.selectedParticipant = selectedParticipant
self.speakingParticipants = speakingParticipants
self.updateSelectedParticipant = updateSelectedParticipant
}
@ -402,6 +450,9 @@ final class VideoChatExpandedParticipantThumbnailsComponent: Component {
if lhs.selectedParticipant != rhs.selectedParticipant {
return false
}
if lhs.speakingParticipants != rhs.speakingParticipants {
return false
}
return true
}
@ -441,9 +492,9 @@ final class VideoChatExpandedParticipantThumbnailsComponent: Component {
return (0, -1)
}
let offsetRect = rect.offsetBy(dx: -self.containerInsets.left, dy: 0.0)
var minVisibleRow = Int(floor((offsetRect.minY) / (self.itemSize.width)))
var minVisibleRow = Int(floor((offsetRect.minX) / (self.itemSize.width + self.itemSpacing)))
minVisibleRow = max(0, minVisibleRow)
let maxVisibleRow = Int(ceil((offsetRect.maxY) / (self.itemSize.width)))
let maxVisibleRow = Int(ceil((offsetRect.maxX) / (self.itemSize.width + self.itemSpacing)))
let minVisibleIndex = minVisibleRow
let maxVisibleIndex = min(self.itemCount - 1, (maxVisibleRow + 1) - 1)
@ -536,6 +587,7 @@ final class VideoChatExpandedParticipantThumbnailsComponent: Component {
participant: participant.participant,
isPresentation: participant.isPresentation,
isSelected: component.selectedParticipant == participant.key,
isSpeaking: component.speakingParticipants.contains(participant.participant.peer.id),
action: { [weak self] in
guard let self, let component = self.component else {
return

View File

@ -6,23 +6,192 @@ import MultilineTextComponent
import TelegramPresentationData
import LottieComponent
import VoiceChatActionButton
import CallScreen
import MetalEngine
import SwiftSignalKit
import AccountContext
final class VideoChatMicButtonComponent: Component {
enum Content {
case connecting
case muted
case unmuted
private final class BlobView: UIView {
let blobsLayer: CallBlobsLayer
private let maxLevel: CGFloat
private var displayLinkAnimator: ConstantDisplayLinkAnimator?
private var audioLevel: CGFloat = 0.0
var presentationAudioLevel: CGFloat = 0.0
var scaleUpdated: ((CGFloat) -> Void)? {
didSet {
}
}
private(set) var isAnimating = false
private let hierarchyTrackingNode: HierarchyTrackingNode
private var isCurrentlyInHierarchy = true
init(
frame: CGRect,
maxLevel: CGFloat
) {
var updateInHierarchy: ((Bool) -> Void)?
self.hierarchyTrackingNode = HierarchyTrackingNode({ value in
updateInHierarchy?(value)
})
self.maxLevel = maxLevel
self.blobsLayer = CallBlobsLayer()
super.init(frame: frame)
self.addSubnode(self.hierarchyTrackingNode)
self.layer.addSublayer(self.blobsLayer)
self.displayLinkAnimator = ConstantDisplayLinkAnimator() { [weak self] in
guard let self else {
return
}
if !self.isCurrentlyInHierarchy {
return
}
self.presentationAudioLevel = self.presentationAudioLevel * 0.9 + self.audioLevel * 0.1
self.updateAudioLevel()
}
updateInHierarchy = { [weak self] value in
guard let self else {
return
}
self.isCurrentlyInHierarchy = value
if value {
self.startAnimating()
} else {
self.stopAnimating()
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func setColor(_ color: UIColor) {
}
public func updateLevel(_ level: CGFloat, immediately: Bool) {
let normalizedLevel = min(1, max(level / maxLevel, 0))
self.audioLevel = normalizedLevel
if immediately {
self.presentationAudioLevel = normalizedLevel
}
}
private func updateAudioLevel() {
let additionalAvatarScale = CGFloat(max(0.0, min(self.presentationAudioLevel * 18.0, 5.0)) * 0.05)
let blobAmplificationFactor: CGFloat = 2.0
let blobScale = 1.0 + additionalAvatarScale * blobAmplificationFactor
self.blobsLayer.transform = CATransform3DMakeScale(blobScale, blobScale, 1.0)
self.scaleUpdated?(blobScale)
}
public func startAnimating() {
guard !self.isAnimating else { return }
self.isAnimating = true
self.updateBlobsState()
self.displayLinkAnimator?.isPaused = false
}
public func stopAnimating() {
self.stopAnimating(duration: 0.15)
}
public func stopAnimating(duration: Double) {
guard isAnimating else { return }
self.isAnimating = false
self.updateBlobsState()
self.displayLinkAnimator?.isPaused = true
}
private func updateBlobsState() {
/*if self.isAnimating {
if self.mediumBlob.frame.size != .zero {
self.mediumBlob.startAnimating()
self.bigBlob.startAnimating()
}
} else {
self.mediumBlob.stopAnimating()
self.bigBlob.stopAnimating()
}*/
}
override public func layoutSubviews() {
super.layoutSubviews()
//self.mediumBlob.frame = bounds
//self.bigBlob.frame = bounds
let blobsFrame = bounds.insetBy(dx: floor(bounds.width * 0.12), dy: floor(bounds.height * 0.12))
self.blobsLayer.position = blobsFrame.center
self.blobsLayer.bounds = CGRect(origin: CGPoint(), size: blobsFrame.size)
self.updateBlobsState()
}
}
private final class GlowView: UIView {
let maskGradientLayer: SimpleGradientLayer
override init(frame: CGRect) {
self.maskGradientLayer = SimpleGradientLayer()
self.maskGradientLayer.type = .radial
self.maskGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
self.maskGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0)
super.init(frame: frame)
self.layer.addSublayer(self.maskGradientLayer)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(size: CGSize, color: UIColor, transition: ComponentTransition, colorTransition: ComponentTransition) {
transition.setFrame(layer: self.maskGradientLayer, frame: CGRect(origin: CGPoint(), size: size))
colorTransition.setGradientColors(layer: self.maskGradientLayer, colors: [color.withMultipliedAlpha(1.0), color.withMultipliedAlpha(0.0)])
}
}
final class VideoChatMicButtonComponent: Component {
enum Content: Equatable {
case connecting
case muted
case unmuted(pushToTalk: Bool)
}
let call: PresentationGroupCall
let content: Content
let isCollapsed: Bool
let updateUnmutedStateIsPushToTalk: (Bool?) -> Void
init(
call: PresentationGroupCall,
content: Content,
isCollapsed: Bool,
updateUnmutedStateIsPushToTalk: @escaping (Bool?) -> Void
) {
self.call = call
self.content = content
self.isCollapsed = isCollapsed
self.updateUnmutedStateIsPushToTalk = updateUnmutedStateIsPushToTalk
@ -39,9 +208,12 @@ final class VideoChatMicButtonComponent: Component {
}
final class View: HighlightTrackingButton {
private let background = ComponentView<Empty>()
private let background: UIImageView
private let title = ComponentView<Empty>()
private let icon: VoiceChatActionButtonIconNode
private var glowView: GlowView?
private var blobView: BlobView?
private var component: VideoChatMicButtonComponent?
private var isUpdating: Bool = false
@ -49,12 +221,19 @@ final class VideoChatMicButtonComponent: Component {
private var beginTrackingTimestamp: Double = 0.0
private var beginTrackingWasPushToTalk: Bool = false
private var audioLevelDisposable: Disposable?
override init(frame: CGRect) {
self.background = UIImageView()
self.icon = VoiceChatActionButtonIconNode(isColored: false)
super.init(frame: frame)
}
deinit {
self.audioLevelDisposable?.dispose()
}
override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool {
self.beginTrackingTimestamp = CFAbsoluteTimeGetCurrent()
if let component = self.component {
@ -117,24 +296,21 @@ final class VideoChatMicButtonComponent: Component {
self.isUpdating = false
}
let previousComponent = self.component
self.component = component
let alphaTransition: ComponentTransition = transition.animation.isImmediate ? .immediate : .easeInOut(duration: 0.2)
let titleText: String
let backgroundColor: UIColor
var isEnabled = true
switch component.content {
case .connecting:
titleText = "Connecting..."
backgroundColor = UIColor(white: 1.0, alpha: 0.1)
isEnabled = false
case .muted:
titleText = "Unmute"
backgroundColor = UIColor(rgb: 0x0086FF)
case .unmuted:
titleText = "Mute"
backgroundColor = UIColor(rgb: 0x34C659)
case let .unmuted(isPushToTalk):
titleText = isPushToTalk ? "You are Live" : "Tap to Mute"
}
self.isEnabled = isEnabled
@ -149,22 +325,52 @@ final class VideoChatMicButtonComponent: Component {
let size = CGSize(width: availableSize.width, height: availableSize.height)
let _ = self.background.update(
transition: transition,
component: AnyComponent(FilledRoundedRectangleComponent(
color: backgroundColor,
cornerRadius: size.width * 0.5,
smoothCorners: false
)),
environment: {},
containerSize: size
)
if let backgroundView = self.background.view {
if backgroundView.superview == nil {
backgroundView.isUserInteractionEnabled = false
self.addSubview(backgroundView)
if self.background.superview == nil {
self.background.isUserInteractionEnabled = false
self.addSubview(self.background)
self.background.frame = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0))
}
transition.setPosition(view: self.background, position: CGRect(origin: CGPoint(), size: size).center)
transition.setScale(view: self.background, scale: size.width / 116.0)
if previousComponent?.content != component.content {
let backgroundContentsTransition: ComponentTransition
if !transition.animation.isImmediate {
backgroundContentsTransition = .easeInOut(duration: 0.2)
} else {
backgroundContentsTransition = .immediate
}
let backgroundImage = generateImage(CGSize(width: 116.0, height: 116.0), rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.addEllipse(in: CGRect(origin: CGPoint(), size: size))
context.clip()
switch component.content {
case .connecting:
context.setFillColor(UIColor(white: 0.1, alpha: 1.0).cgColor)
context.fill(CGRect(origin: CGPoint(), size: size))
case .muted, .unmuted:
let colors: [UIColor]
if case .muted = component.content {
colors = [UIColor(rgb: 0x0080FF), UIColor(rgb: 0x00A1FE)]
} else {
colors = [UIColor(rgb: 0x33C659), UIColor(rgb: 0x0BA8A5)]
}
let gradientColors = colors.map { $0.cgColor } as CFArray
let colorSpace = DeviceGraphicsContextSettings.shared.colorSpace
var locations: [CGFloat] = [0.0, 1.0]
let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors, locations: &locations)!
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: size.height), end: CGPoint(x: size.width, y: 0.0), options: CGGradientDrawingOptions())
}
})!
if let previousImage = self.background.image {
self.background.image = backgroundImage
backgroundContentsTransition.animateContentsImage(layer: self.background.layer, from: previousImage.cgImage!, to: backgroundImage.cgImage!, duration: 0.2, curve: .easeInOut)
} else {
self.background.image = backgroundImage
}
transition.setFrame(view: backgroundView, frame: CGRect(origin: CGPoint(), size: size))
}
let titleFrame = CGRect(origin: CGPoint(x: floor((size.width - titleSize.width) * 0.5), y: size.height + 16.0), size: titleSize)
@ -198,6 +404,90 @@ final class VideoChatMicButtonComponent: Component {
self.icon.enqueueState(.unmute)
}
switch component.content {
case .muted, .unmuted:
let blobSize = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0)).insetBy(dx: -40.0, dy: -40.0).size
let blobTintTransition: ComponentTransition
let blobView: BlobView
if let current = self.blobView {
blobView = current
blobTintTransition = .easeInOut(duration: 0.2)
} else {
blobTintTransition = .immediate
blobView = BlobView(frame: CGRect(), maxLevel: 1.5)
blobView.isUserInteractionEnabled = false
self.blobView = blobView
self.insertSubview(blobView, at: 0)
blobView.center = CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5)
blobView.bounds = CGRect(origin: CGPoint(), size: blobSize)
ComponentTransition.immediate.setScale(view: blobView, scale: 0.001)
if !transition.animation.isImmediate {
blobView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
}
}
transition.setPosition(view: blobView, position: CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5))
transition.setScale(view: blobView, scale: availableSize.width / 116.0)
blobTintTransition.setTintColor(layer: blobView.blobsLayer, color: component.content == .muted ? UIColor(rgb: 0x0086FF) : UIColor(rgb: 0x33C758))
if self.audioLevelDisposable == nil {
self.audioLevelDisposable = (component.call.myAudioLevel
|> deliverOnMainQueue).startStrict(next: { [weak self] value in
guard let self, let blobView = self.blobView else {
return
}
blobView.updateLevel(CGFloat(value), immediately: false)
})
}
var glowFrame = CGRect(origin: CGPoint(), size: availableSize)
if component.isCollapsed {
glowFrame = glowFrame.insetBy(dx: -20.0, dy: -20.0)
} else {
glowFrame = glowFrame.insetBy(dx: -60.0, dy: -60.0)
}
let glowView: GlowView
if let current = self.glowView {
glowView = current
} else {
glowView = GlowView(frame: CGRect())
glowView.isUserInteractionEnabled = false
self.glowView = glowView
self.insertSubview(glowView, aboveSubview: blobView)
transition.animateScale(view: glowView, from: 0.001, to: 1.0)
glowView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
let glowColor: UIColor = component.content == .muted ? UIColor(rgb: 0x0086FF) : UIColor(rgb: 0x33C758)
glowView.update(size: glowFrame.size, color: glowColor.withMultipliedAlpha(component.isCollapsed ? 0.5 : 0.7), transition: transition, colorTransition: blobTintTransition)
transition.setFrame(view: glowView, frame: glowFrame)
default:
if let blobView = self.blobView {
self.blobView = nil
transition.setScale(view: blobView, scale: 0.001, completion: { [weak blobView] _ in
blobView?.removeFromSuperview()
})
}
if let glowView = self.glowView {
self.glowView = nil
transition.setScale(view: glowView, scale: 0.001, completion: { [weak glowView] _ in
glowView?.removeFromSuperview()
})
}
if let audioLevelDisposable = self.audioLevelDisposable {
self.audioLevelDisposable = nil
audioLevelDisposable.dispose()
}
}
return size
}
}

View File

@ -9,13 +9,16 @@ import LottieComponent
final class VideoChatMuteIconComponent: Component {
let color: UIColor
let isFilled: Bool
let isMuted: Bool
init(
color: UIColor,
isFilled: Bool,
isMuted: Bool
) {
self.color = color
self.isFilled = isFilled
self.isMuted = isMuted
}
@ -23,6 +26,9 @@ final class VideoChatMuteIconComponent: Component {
if lhs.color != rhs.color {
return false
}
if lhs.isFilled != rhs.isFilled {
return false
}
if lhs.isMuted != rhs.isMuted {
return false
}
@ -37,6 +43,10 @@ final class VideoChatMuteIconComponent: Component {
private var contentImage: UIImage?
var iconView: UIView {
return self.icon.view
}
override init(frame: CGRect) {
self.icon = VoiceChatMicrophoneNode()
@ -62,7 +72,7 @@ final class VideoChatMuteIconComponent: Component {
self.addSubview(self.icon.view)
}
transition.setFrame(view: self.icon.view, frame: animationFrame)
self.icon.update(state: VoiceChatMicrophoneNode.State(muted: component.isMuted, filled: true, color: component.color), animated: !transition.animation.isImmediate)
self.icon.update(state: VoiceChatMicrophoneNode.State(muted: component.isMuted, filled: component.isFilled, color: component.color), animated: !transition.animation.isImmediate)
return availableSize
}

View File

@ -0,0 +1,364 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
import TelegramCore
import AccountContext
import AvatarNode
import VoiceChatActionButton
import CallScreen
import MetalEngine
import SwiftSignalKit
private final class BlobView: UIView {
let blobsLayer: CallBlobsLayer
private let maxLevel: CGFloat
private var displayLinkAnimator: ConstantDisplayLinkAnimator?
private var audioLevel: CGFloat = 0.0
var presentationAudioLevel: CGFloat = 0.0
var scaleUpdated: ((CGFloat) -> Void)? {
didSet {
}
}
private(set) var isAnimating = false
public typealias BlobRange = (min: CGFloat, max: CGFloat)
private let hierarchyTrackingNode: HierarchyTrackingNode
private var isCurrentlyInHierarchy = true
init(
frame: CGRect,
maxLevel: CGFloat,
mediumBlobRange: BlobRange,
bigBlobRange: BlobRange
) {
var updateInHierarchy: ((Bool) -> Void)?
self.hierarchyTrackingNode = HierarchyTrackingNode({ value in
updateInHierarchy?(value)
})
self.maxLevel = maxLevel
self.blobsLayer = CallBlobsLayer()
super.init(frame: frame)
self.addSubnode(self.hierarchyTrackingNode)
self.layer.addSublayer(self.blobsLayer)
self.displayLinkAnimator = ConstantDisplayLinkAnimator() { [weak self] in
guard let self else {
return
}
if !self.isCurrentlyInHierarchy {
return
}
self.presentationAudioLevel = self.presentationAudioLevel * 0.9 + self.audioLevel * 0.1
self.updateAudioLevel()
}
updateInHierarchy = { [weak self] value in
guard let self else {
return
}
self.isCurrentlyInHierarchy = value
if value {
self.startAnimating()
} else {
self.stopAnimating()
}
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func setColor(_ color: UIColor) {
}
public func updateLevel(_ level: CGFloat, immediately: Bool) {
let normalizedLevel = min(1, max(level / maxLevel, 0))
self.audioLevel = normalizedLevel
if immediately {
self.presentationAudioLevel = normalizedLevel
}
}
private func updateAudioLevel() {
let additionalAvatarScale = CGFloat(max(0.0, min(self.presentationAudioLevel * 18.0, 5.0)) * 0.05)
let blobAmplificationFactor: CGFloat = 2.0
let blobScale = 1.0 + additionalAvatarScale * blobAmplificationFactor
self.blobsLayer.transform = CATransform3DMakeScale(blobScale, blobScale, 1.0)
self.scaleUpdated?(blobScale)
}
public func startAnimating() {
guard !self.isAnimating else { return }
self.isAnimating = true
self.updateBlobsState()
self.displayLinkAnimator?.isPaused = false
}
public func stopAnimating() {
self.stopAnimating(duration: 0.15)
}
public func stopAnimating(duration: Double) {
guard isAnimating else { return }
self.isAnimating = false
self.updateBlobsState()
self.displayLinkAnimator?.isPaused = true
}
private func updateBlobsState() {
/*if self.isAnimating {
if self.mediumBlob.frame.size != .zero {
self.mediumBlob.startAnimating()
self.bigBlob.startAnimating()
}
} else {
self.mediumBlob.stopAnimating()
self.bigBlob.stopAnimating()
}*/
}
override public func layoutSubviews() {
super.layoutSubviews()
//self.mediumBlob.frame = bounds
//self.bigBlob.frame = bounds
let blobsFrame = bounds.insetBy(dx: floor(bounds.width * 0.12), dy: floor(bounds.height * 0.12))
self.blobsLayer.position = blobsFrame.center
self.blobsLayer.bounds = CGRect(origin: CGPoint(), size: blobsFrame.size)
self.updateBlobsState()
}
}
final class VideoChatParticipantAvatarComponent: Component {
let call: PresentationGroupCall
let peer: EnginePeer
let isSpeaking: Bool
let theme: PresentationTheme
init(
call: PresentationGroupCall,
peer: EnginePeer,
isSpeaking: Bool,
theme: PresentationTheme
) {
self.call = call
self.peer = peer
self.isSpeaking = isSpeaking
self.theme = theme
}
static func ==(lhs: VideoChatParticipantAvatarComponent, rhs: VideoChatParticipantAvatarComponent) -> Bool {
if lhs.call !== rhs.call {
return false
}
if lhs.peer != rhs.peer {
return false
}
if lhs.isSpeaking != rhs.isSpeaking {
return false
}
if lhs.theme !== rhs.theme {
return false
}
return true
}
final class View: UIView {
private var component: VideoChatParticipantAvatarComponent?
private var isUpdating: Bool = false
private var avatarNode: AvatarNode?
private var blobView: BlobView?
private var audioLevelDisposable: Disposable?
private var wasSpeaking: Bool?
private var noAudioTimer: Foundation.Timer?
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.audioLevelDisposable?.dispose()
self.noAudioTimer?.invalidate()
}
func update(component: VideoChatParticipantAvatarComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let previousComponent = self.component
self.component = component
let avatarNode: AvatarNode
if let current = self.avatarNode {
avatarNode = current
} else {
avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 15.0))
self.avatarNode = avatarNode
self.addSubview(avatarNode.view)
}
let avatarSize = availableSize
let clipStyle: AvatarNodeClipStyle
if case let .channel(channel) = component.peer, channel.flags.contains(.isForum) {
clipStyle = .roundedRect
} else {
clipStyle = .round
}
if let blobView = self.blobView {
let tintTransition: ComponentTransition
if let previousComponent, previousComponent.isSpeaking != component.isSpeaking {
if component.isSpeaking {
tintTransition = .easeInOut(duration: 0.15)
} else {
tintTransition = .easeInOut(duration: 0.25)
}
} else {
tintTransition = .immediate
}
tintTransition.setTintColor(layer: blobView.blobsLayer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor)
}
if component.peer.smallProfileImage != nil {
avatarNode.setPeerV2(
context: component.call.accountContext,
theme: component.theme,
peer: component.peer,
authorOfMessage: nil,
overrideImage: nil,
emptyColor: nil,
clipStyle: .round,
synchronousLoad: false,
displayDimensions: avatarSize
)
} else {
avatarNode.setPeer(context: component.call.accountContext, theme: component.theme, peer: component.peer, clipStyle: clipStyle, synchronousLoad: false, displayDimensions: avatarSize)
}
transition.setFrame(view: avatarNode.view, frame: CGRect(origin: CGPoint(), size: avatarSize))
avatarNode.updateSize(size: avatarSize)
if self.audioLevelDisposable == nil {
let peerId = component.peer.id
struct Level {
var value: Float
var isSpeaking: Bool
}
self.audioLevelDisposable = (component.call.audioLevels
|> map { levels -> Level? in
for level in levels {
if level.0 == peerId {
return Level(value: level.2, isSpeaking: level.3)
}
}
return nil
}
|> distinctUntilChanged(isEqual: { lhs, rhs in
if (lhs == nil) != (rhs == nil) {
return false
}
if lhs != nil {
return true
} else {
return false
}
})
|> deliverOnMainQueue).startStrict(next: { [weak self] level in
guard let self, let component = self.component, let avatarNode = self.avatarNode else {
return
}
if let level {
let blobView: BlobView
if let current = self.blobView {
blobView = current
} else {
self.wasSpeaking = nil
blobView = BlobView(
frame: avatarNode.frame,
maxLevel: 1.5,
mediumBlobRange: (0.69, 0.87),
bigBlobRange: (0.71, 1.0)
)
self.blobView = blobView
blobView.frame = avatarNode.frame
self.insertSubview(blobView, belowSubview: avatarNode.view)
blobView.layer.animateScale(from: 0.001, to: 1.0, duration: 0.2)
ComponentTransition.immediate.setTintColor(layer: blobView.blobsLayer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor)
}
blobView.updateLevel(CGFloat(level.value), immediately: false)
if let noAudioTimer = self.noAudioTimer {
self.noAudioTimer = nil
noAudioTimer.invalidate()
}
} else {
if self.noAudioTimer == nil {
self.noAudioTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.4, repeats: false, block: { [weak self] _ in
guard let self else {
return
}
self.noAudioTimer?.invalidate()
self.noAudioTimer = nil
if let blobView = self.blobView {
self.blobView = nil
blobView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak blobView] _ in
blobView?.removeFromSuperview()
})
blobView.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, removeOnCompletion: false)
}
})
}
}
})
}
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}

View File

@ -0,0 +1,97 @@
import Foundation
import UIKit
import Display
import ComponentFlow
import TelegramPresentationData
final class VideoChatParticipantStatusComponent: Component {
let isMuted: Bool
let isSpeaking: Bool
let theme: PresentationTheme
init(
isMuted: Bool,
isSpeaking: Bool,
theme: PresentationTheme
) {
self.isMuted = isMuted
self.isSpeaking = isSpeaking
self.theme = theme
}
static func ==(lhs: VideoChatParticipantStatusComponent, rhs: VideoChatParticipantStatusComponent) -> Bool {
if lhs.isMuted != rhs.isMuted {
return false
}
if lhs.isSpeaking != rhs.isSpeaking {
return false
}
if lhs.theme !== rhs.theme {
return false
}
return true
}
final class View: UIView {
private let muteStatus = ComponentView<Empty>()
private var component: VideoChatParticipantStatusComponent?
private var isUpdating: Bool = false
override init(frame: CGRect) {
super.init(frame: frame)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
}
func update(component: VideoChatParticipantStatusComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.isUpdating = true
defer {
self.isUpdating = false
}
let size = CGSize(width: 44.0, height: 44.0)
let muteStatusSize = self.muteStatus.update(
transition: transition,
component: AnyComponent(VideoChatMuteIconComponent(
color: .white,
isFilled: false,
isMuted: component.isMuted && !component.isSpeaking
)),
environment: {},
containerSize: CGSize(width: 36.0, height: 36.0)
)
let muteStatusFrame = CGRect(origin: CGPoint(x: floor((size.width - muteStatusSize.width) * 0.5), y: floor((size.height - muteStatusSize.height) * 0.5)), size: muteStatusSize)
if let muteStatusView = self.muteStatus.view as? VideoChatMuteIconComponent.View {
if muteStatusView.superview == nil {
self.addSubview(muteStatusView)
}
transition.setFrame(view: muteStatusView, frame: muteStatusFrame)
let tintTransition: ComponentTransition
if !transition.animation.isImmediate {
tintTransition = .easeInOut(duration: 0.2)
} else {
tintTransition = .immediate
}
tintTransition.setTintColor(layer: muteStatusView.iconView.layer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : UIColor(white: 1.0, alpha: 0.4))
}
return size
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}

View File

@ -30,10 +30,15 @@ private func blurredAvatarImage(_ dataImage: UIImage) -> UIImage? {
}
}
private let activityBorderImage: UIImage = {
return generateStretchableFilledCircleImage(diameter: 20.0, color: nil, strokeColor: .white, strokeWidth: 2.0)!.withRenderingMode(.alwaysTemplate)
}()
final class VideoChatParticipantVideoComponent: Component {
let call: PresentationGroupCall
let participant: GroupCallParticipantsContext.Participant
let isPresentation: Bool
let isSpeaking: Bool
let isExpanded: Bool
let bottomInset: CGFloat
let action: (() -> Void)?
@ -42,6 +47,7 @@ final class VideoChatParticipantVideoComponent: Component {
call: PresentationGroupCall,
participant: GroupCallParticipantsContext.Participant,
isPresentation: Bool,
isSpeaking: Bool,
isExpanded: Bool,
bottomInset: CGFloat,
action: (() -> Void)?
@ -49,6 +55,7 @@ final class VideoChatParticipantVideoComponent: Component {
self.call = call
self.participant = participant
self.isPresentation = isPresentation
self.isSpeaking = isSpeaking
self.isExpanded = isExpanded
self.bottomInset = bottomInset
self.action = action
@ -61,6 +68,9 @@ final class VideoChatParticipantVideoComponent: Component {
if lhs.isPresentation != rhs.isPresentation {
return false
}
if lhs.isSpeaking != rhs.isSpeaking {
return false
}
if lhs.isExpanded != rhs.isExpanded {
return false
}
@ -87,6 +97,7 @@ final class VideoChatParticipantVideoComponent: Component {
private var component: VideoChatParticipantVideoComponent?
private weak var componentState: EmptyComponentState?
private var isUpdating: Bool = false
private var previousSize: CGSize?
private let muteStatus = ComponentView<Empty>()
private let title = ComponentView<Empty>()
@ -100,6 +111,8 @@ final class VideoChatParticipantVideoComponent: Component {
private var videoLayer: PrivateCallVideoLayer?
private var videoSpec: VideoSpec?
private var activityBorderView: UIImageView?
override init(frame: CGRect) {
super.init(frame: frame)
@ -192,6 +205,7 @@ final class VideoChatParticipantVideoComponent: Component {
transition: transition,
component: AnyComponent(VideoChatMuteIconComponent(
color: .white,
isFilled: true,
isMuted: component.participant.muteState != nil
)),
environment: {},
@ -415,6 +429,48 @@ final class VideoChatParticipantVideoComponent: Component {
self.videoSpec = nil
}
if component.isSpeaking && !component.isExpanded {
let activityBorderView: UIImageView
if let current = self.activityBorderView {
activityBorderView = current
} else {
activityBorderView = UIImageView()
self.activityBorderView = activityBorderView
self.addSubview(activityBorderView)
activityBorderView.image = activityBorderImage
activityBorderView.tintColor = UIColor(rgb: 0x33C758)
if let previousSize {
activityBorderView.frame = CGRect(origin: CGPoint(), size: previousSize)
}
}
} else if let activityBorderView = self.activityBorderView {
if !transition.animation.isImmediate {
let alphaTransition: ComponentTransition = .easeInOut(duration: 0.2)
if activityBorderView.alpha != 0.0 {
alphaTransition.setAlpha(view: activityBorderView, alpha: 0.0, completion: { [weak self, weak activityBorderView] completed in
guard let self, let component = self.component, let activityBorderView, self.activityBorderView === activityBorderView, completed else {
return
}
if !component.isSpeaking {
activityBorderView.removeFromSuperview()
self.activityBorderView = nil
}
})
}
} else {
self.activityBorderView = nil
activityBorderView.removeFromSuperview()
}
}
if let activityBorderView = self.activityBorderView {
transition.setFrame(view: activityBorderView, frame: CGRect(origin: CGPoint(), size: availableSize))
}
self.previousSize = availableSize
return availableSize
}
}

View File

@ -12,6 +12,39 @@ import TelegramPresentationData
import PeerListItemComponent
final class VideoChatParticipantsComponent: Component {
final class Participants: Equatable {
let myPeerId: EnginePeer.Id
let participants: [GroupCallParticipantsContext.Participant]
let totalCount: Int
let loadMoreToken: String?
init(myPeerId: EnginePeer.Id, participants: [GroupCallParticipantsContext.Participant], totalCount: Int, loadMoreToken: String?) {
self.myPeerId = myPeerId
self.participants = participants
self.totalCount = totalCount
self.loadMoreToken = loadMoreToken
}
static func ==(lhs: Participants, rhs: Participants) -> Bool {
if lhs === rhs {
return true
}
if lhs.myPeerId != rhs.myPeerId {
return false
}
if lhs.participants != rhs.participants {
return false
}
if lhs.totalCount != rhs.totalCount {
return false
}
if lhs.loadMoreToken != rhs.loadMoreToken {
return false
}
return true
}
}
struct VideoParticipantKey: Hashable {
var id: EnginePeer.Id
var isPresentation: Bool
@ -46,7 +79,8 @@ final class VideoChatParticipantsComponent: Component {
}
let call: PresentationGroupCall
let members: PresentationGroupCallMembers?
let participants: Participants?
let speakingParticipants: Set<EnginePeer.Id>
let expandedVideoState: ExpandedVideoState?
let theme: PresentationTheme
let strings: PresentationStrings
@ -58,7 +92,8 @@ final class VideoChatParticipantsComponent: Component {
init(
call: PresentationGroupCall,
members: PresentationGroupCallMembers?,
participants: Participants?,
speakingParticipants: Set<EnginePeer.Id>,
expandedVideoState: ExpandedVideoState?,
theme: PresentationTheme,
strings: PresentationStrings,
@ -69,7 +104,8 @@ final class VideoChatParticipantsComponent: Component {
updateIsMainParticipantPinned: @escaping (Bool) -> Void
) {
self.call = call
self.members = members
self.participants = participants
self.speakingParticipants = speakingParticipants
self.expandedVideoState = expandedVideoState
self.theme = theme
self.strings = strings
@ -81,7 +117,10 @@ final class VideoChatParticipantsComponent: Component {
}
static func ==(lhs: VideoChatParticipantsComponent, rhs: VideoChatParticipantsComponent) -> Bool {
if lhs.members != rhs.members {
if lhs.participants != rhs.participants {
return false
}
if lhs.speakingParticipants != rhs.speakingParticipants {
return false
}
if lhs.expandedVideoState != rhs.expandedVideoState {
@ -274,6 +313,7 @@ final class VideoChatParticipantsComponent: Component {
}
result += self.list.contentHeight()
result += self.collapsedContainerInsets.bottom
result += 32.0
return result
}
@ -349,8 +389,9 @@ final class VideoChatParticipantsComponent: Component {
}
final class View: UIView, UIScrollViewDelegate {
private let scollViewClippingContainer: UIView
private let scrollViewClippingContainer: SolidRoundedCornersContainer
private let scrollView: ScrollView
private let scrollViewClippingShadowView: UIImageView
private var component: VideoChatParticipantsComponent?
private var isUpdating: Bool = false
@ -380,8 +421,8 @@ final class VideoChatParticipantsComponent: Component {
private var appliedGridIsEmpty: Bool = true
override init(frame: CGRect) {
self.scollViewClippingContainer = UIView()
self.scollViewClippingContainer.clipsToBounds = true
self.scrollViewClippingContainer = SolidRoundedCornersContainer()
self.scrollViewClippingShadowView = UIImageView()
self.scrollView = ScrollView()
@ -412,8 +453,10 @@ final class VideoChatParticipantsComponent: Component {
self.scrollView.delegate = self
self.scrollView.clipsToBounds = true
self.scollViewClippingContainer.addSubview(self.scrollView)
self.addSubview(self.scollViewClippingContainer)
self.scrollViewClippingContainer.addSubview(self.scrollView)
self.addSubview(self.scrollViewClippingContainer)
self.addSubview(self.scrollViewClippingContainer.cornersView)
self.addSubview(self.scrollViewClippingShadowView)
self.scrollView.addSubview(self.listItemViewContainer)
self.scrollView.addSubview(self.gridItemViewContainer)
@ -436,7 +479,7 @@ final class VideoChatParticipantsComponent: Component {
return self
}
} else {
if let result = self.scollViewClippingContainer.hitTest(self.convert(point, to: self.scollViewClippingContainer), with: event) {
if let result = self.scrollViewClippingContainer.hitTest(self.convert(point, to: self.scrollViewClippingContainer), with: event) {
return result
} else {
return nil
@ -555,6 +598,7 @@ final class VideoChatParticipantsComponent: Component {
call: component.call,
participant: videoParticipant.participant,
isPresentation: videoParticipant.isPresentation,
isSpeaking: component.speakingParticipants.contains(videoParticipant.participant.peer.id),
isExpanded: isItemExpanded,
bottomInset: isItemExpanded ? 96.0 : 0.0,
action: { [weak self] in
@ -672,10 +716,18 @@ final class VideoChatParticipantsComponent: Component {
let subtitle: PeerListItemComponent.Subtitle
if participant.peer.id == component.call.accountContext.account.peerId {
subtitle = PeerListItemComponent.Subtitle(text: "this is you", color: .accent)
} else if component.speakingParticipants.contains(participant.peer.id) {
subtitle = PeerListItemComponent.Subtitle(text: "speaking", color: .constructive)
} else {
subtitle = PeerListItemComponent.Subtitle(text: participant.about ?? "listening", color: .neutral)
}
let rightAccessoryComponent: AnyComponent<Empty> = AnyComponent(VideoChatParticipantStatusComponent(
isMuted: participant.muteState != nil,
isSpeaking: component.speakingParticipants.contains(participant.peer.id),
theme: component.theme
))
let _ = itemView.view.update(
transition: itemTransition,
component: AnyComponent(PeerListItemComponent(
@ -685,10 +737,17 @@ final class VideoChatParticipantsComponent: Component {
style: .generic,
sideInset: 0.0,
title: EnginePeer(participant.peer).displayTitle(strings: component.strings, displayOrder: .firstLast),
avatarComponent: AnyComponent(VideoChatParticipantAvatarComponent(
call: component.call,
peer: EnginePeer(participant.peer),
isSpeaking: component.speakingParticipants.contains(participant.peer.id),
theme: component.theme
)),
peer: EnginePeer(participant.peer),
subtitle: subtitle,
subtitleAccessory: .none,
presence: nil,
rightAccessoryComponent: rightAccessoryComponent,
selectionState: .none,
hasNext: false,
action: { [weak self] peer, _, _ in
@ -779,7 +838,16 @@ final class VideoChatParticipantsComponent: Component {
transition.setFrame(view: self.listItemViewContainer, frame: itemLayout.listItemContainerFrame())
transition.setFrame(layer: self.listItemViewSeparatorContainer, frame: CGRect(origin: CGPoint(), size: itemLayout.listItemContainerFrame().size))
transition.setFrame(view: self.expandedGridItemContainer, frame: expandedGridItemContainerFrame)
if self.expandedGridItemContainer.frame != expandedGridItemContainerFrame {
self.expandedGridItemContainer.layer.cornerRadius = 10.0
transition.setFrame(view: self.expandedGridItemContainer, frame: expandedGridItemContainerFrame, completion: { [weak self] completed in
guard let self, completed else {
return
}
self.expandedGridItemContainer.layer.cornerRadius = 0.0
})
}
if let expandedVideoState = component.expandedVideoState {
var thumbnailParticipants: [VideoChatExpandedParticipantThumbnailsComponent.Participant] = []
@ -814,6 +882,7 @@ final class VideoChatParticipantsComponent: Component {
selectedParticipant: component.expandedVideoState.flatMap { expandedVideoState in
return VideoChatExpandedParticipantThumbnailsComponent.Participant.Key(id: expandedVideoState.mainParticipant.id, isPresentation: expandedVideoState.mainParticipant.isPresentation)
},
speakingParticipants: component.speakingParticipants,
updateSelectedParticipant: { [weak self] key in
guard let self, let component = self.component else {
return
@ -836,7 +905,7 @@ final class VideoChatParticipantsComponent: Component {
fromReferenceFrame = previousExpandedGridItemContainerFrame
}
expandedThumbnailsComponentView.frame = CGRect(origin: CGPoint(x: fromReferenceFrame.minX - previousExpandedGridItemContainerFrame.minX, y: fromReferenceFrame.height - expandedThumbnailsSize.height), size: expandedThumbnailsFrame.size)
expandedThumbnailsComponentView.frame = CGRect(origin: CGPoint(x: fromReferenceFrame.minX - previousExpandedGridItemContainerFrame.minX, y: fromReferenceFrame.maxY - expandedThumbnailsSize.height), size: expandedThumbnailsFrame.size)
if !transition.animation.isImmediate {
expandedThumbnailsComponentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
@ -976,13 +1045,13 @@ final class VideoChatParticipantsComponent: Component {
var gridParticipants: [VideoParticipant] = []
var listParticipants: [GroupCallParticipantsContext.Participant] = []
if let members = component.members {
for participant in members.participants {
if let participants = component.participants {
for participant in participants.participants {
var hasVideo = false
if participant.videoDescription != nil {
hasVideo = true
let videoParticipant = VideoParticipant(participant: participant, isPresentation: false)
if participant.peer.id == component.call.accountContext.account.peerId {
if participant.peer.id == component.call.accountContext.account.peerId || participant.peer.id == participants.myPeerId {
gridParticipants.insert(videoParticipant, at: 0)
} else {
gridParticipants.append(videoParticipant)
@ -1040,8 +1109,8 @@ final class VideoChatParticipantsComponent: Component {
}
var requestedVideo: [PresentationGroupCallRequestedVideo] = []
if let members = component.members {
for participant in members.participants {
if let participants = component.participants {
for participant in participants.participants {
var maxVideoQuality: PresentationGroupCallRequestedVideo.Quality = .medium
if let expandedVideoState = component.expandedVideoState {
if expandedVideoState.mainParticipant.id == participant.peer.id, !expandedVideoState.mainParticipant.isPresentation {
@ -1074,9 +1143,47 @@ final class VideoChatParticipantsComponent: Component {
}
(component.call as! PresentationGroupCallImpl).setRequestedVideoList(items: requestedVideo)
let scrollClippingFrame = CGRect(origin: CGPoint(x: 0.0, y: component.collapsedContainerInsets.top), size: CGSize(width: availableSize.width, height: availableSize.height - component.collapsedContainerInsets.top - component.collapsedContainerInsets.bottom))
transition.setPosition(view: self.scollViewClippingContainer, position: scrollClippingFrame.center)
transition.setBounds(view: self.scollViewClippingContainer, bounds: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.minY), size: scrollClippingFrame.size))
let scrollClippingFrame = CGRect(origin: CGPoint(x: itemLayout.sideInset, y: component.collapsedContainerInsets.top), size: CGSize(width: availableSize.width - itemLayout.sideInset * 2.0, height: availableSize.height - component.collapsedContainerInsets.top - component.collapsedContainerInsets.bottom))
transition.setPosition(view: self.scrollViewClippingContainer, position: scrollClippingFrame.center)
transition.setBounds(view: self.scrollViewClippingContainer, bounds: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.minY), size: scrollClippingFrame.size))
transition.setFrame(view: self.scrollViewClippingContainer.cornersView, frame: scrollClippingFrame)
self.scrollViewClippingContainer.update(params: SolidRoundedCornersContainer.Params(
size: scrollClippingFrame.size,
color: .black,
cornerRadius: 10.0,
smoothCorners: false
), transition: transition)
if self.scrollViewClippingShadowView.image == nil {
let height: CGFloat = 24.0
let baseGradientAlpha: CGFloat = 1.0
let numSteps = 8
let firstStep = 0
let firstLocation = 0.0
let colors = (0 ..< numSteps).map { i -> UIColor in
if i < firstStep {
return UIColor(white: 1.0, alpha: 1.0)
} else {
let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1)
let value: CGFloat = 1.0 - bezierPoint(0.42, 0.0, 0.58, 1.0, step)
return UIColor(white: 0.0, alpha: baseGradientAlpha * value)
}
}
let locations = (0 ..< numSteps).map { i -> CGFloat in
if i < firstStep {
return 0.0
} else {
let step: CGFloat = CGFloat(i - firstStep) / CGFloat(numSteps - firstStep - 1)
return (firstLocation + (1.0 - firstLocation) * step)
}
}
self.scrollViewClippingShadowView.image = generateGradientImage(size: CGSize(width: 8.0, height: height), colors: colors.reversed(), locations: locations.reversed().map { 1.0 - $0 })!.withRenderingMode(.alwaysTemplate).stretchableImage(withLeftCapWidth: 0, topCapHeight: Int(height - 1.0))
self.scrollViewClippingShadowView.tintColor = .black
}
let scrollViewClippingShadowHeight: CGFloat = 24.0
let scrollViewClippingShadowOffset: CGFloat = 0.0
transition.setFrame(view: self.scrollViewClippingShadowView, frame: CGRect(origin: CGPoint(x: scrollClippingFrame.minX, y: scrollClippingFrame.maxY + scrollViewClippingShadowOffset - scrollViewClippingShadowHeight), size: CGSize(width: scrollClippingFrame.width, height: scrollViewClippingShadowHeight)))
self.ignoreScrolling = true
if self.scrollView.bounds.size != availableSize {

View File

@ -280,10 +280,65 @@ private final class VideoChatScreenComponent: Component {
return
}
if self.members != members {
#if DEBUG
var members = members
if let membersValue = members {
var participants = membersValue.participants
for i in 1 ... 20 {
for participant in membersValue.participants {
guard let user = participant.peer as? TelegramUser else {
continue
}
let mappedUser = TelegramUser(
id: EnginePeer.Id(namespace: Namespaces.Peer.CloudUser, id: EnginePeer.Id.Id._internalFromInt64Value(user.id.id._internalGetInt64Value() + Int64(i))),
accessHash: user.accessHash,
firstName: user.firstName,
lastName: user.lastName,
username: user.username,
phone: user.phone,
photo: user.photo,
botInfo: user.botInfo,
restrictionInfo: user.restrictionInfo,
flags: user.flags,
emojiStatus: user.emojiStatus,
usernames: user.usernames,
storiesHidden: user.storiesHidden,
nameColor: user.nameColor,
backgroundEmojiId: user.backgroundEmojiId,
profileColor: user.profileColor,
profileBackgroundEmojiId: user.profileBackgroundEmojiId,
subscriberCount: user.subscriberCount
)
participants.append(GroupCallParticipantsContext.Participant(
peer: mappedUser,
ssrc: participant.ssrc,
videoDescription: participant.videoDescription,
presentationDescription: participant.presentationDescription,
joinTimestamp: participant.joinTimestamp,
raiseHandRating: participant.raiseHandRating,
hasRaiseHand: participant.hasRaiseHand,
activityTimestamp: participant.activityTimestamp,
activityRank: participant.activityRank,
muteState: participant.muteState,
volume: participant.volume,
about: participant.about,
joinedVideo: participant.joinedVideo
))
}
}
members = PresentationGroupCallMembers(
participants: participants,
speakingParticipants: membersValue.speakingParticipants,
totalCount: membersValue.totalCount,
loadMoreToken: membersValue.loadMoreToken
)
}
#endif
self.members = members
if let expandedParticipantsVideoState = self.expandedParticipantsVideoState {
if let _ = members?.participants.first(where: { participant in
if let expandedParticipantsVideoState = self.expandedParticipantsVideoState, let members {
if let _ = members.participants.first(where: { participant in
if participant.peer.id == expandedParticipantsVideoState.mainParticipant.id {
if expandedParticipantsVideoState.mainParticipant.isPresentation {
if participant.presentationDescription == nil {
@ -298,9 +353,25 @@ private final class VideoChatScreenComponent: Component {
}
return false
}) {
} else if let participant = members.participants.first(where: { participant in
if participant.presentationDescription != nil {
return true
}
if participant.videoDescription != nil {
return true
}
return false
}) {
if participant.presentationDescription != nil {
self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: VideoChatParticipantsComponent.VideoParticipantKey(id: participant.peer.id, isPresentation: true), isMainParticipantPinned: false)
} else {
self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: VideoChatParticipantsComponent.VideoParticipantKey(id: participant.peer.id, isPresentation: false), isMainParticipantPinned: false)
}
} else {
self.expandedParticipantsVideoState = nil
}
} else {
self.expandedParticipantsVideoState = nil
}
if !self.isUpdating {
@ -477,7 +548,10 @@ private final class VideoChatScreenComponent: Component {
}
let actionButtonDiameter: CGFloat = 56.0
let microphoneButtonDiameter: CGFloat = self.expandedParticipantsVideoState == nil ? 116.0 : actionButtonDiameter
let expandedMicrophoneButtonDiameter: CGFloat = actionButtonDiameter
let collapsedMicrophoneButtonDiameter: CGFloat = 116.0
let microphoneButtonDiameter: CGFloat = self.expandedParticipantsVideoState == nil ? collapsedMicrophoneButtonDiameter : expandedMicrophoneButtonDiameter
let maxActionMicrophoneButtonSpacing: CGFloat = 38.0
let buttonsSideInset: CGFloat = 42.0
@ -486,32 +560,41 @@ private final class VideoChatScreenComponent: Component {
let remainingButtonsSpace: CGFloat = availableSize.width - buttonsSideInset * 2.0 - buttonsWidth
let actionMicrophoneButtonSpacing = min(maxActionMicrophoneButtonSpacing, floor(remainingButtonsSpace * 0.5))
let collapsedMicrophoneButtonFrame: CGRect = CGRect(origin: CGPoint(x: floor((availableSize.width - collapsedMicrophoneButtonDiameter) * 0.5), y: availableSize.height - 48.0 - environment.safeInsets.bottom - collapsedMicrophoneButtonDiameter), size: CGSize(width: collapsedMicrophoneButtonDiameter, height: collapsedMicrophoneButtonDiameter))
let expandedMicrophoneButtonFrame: CGRect = CGRect(origin: CGPoint(x: floor((availableSize.width - expandedMicrophoneButtonDiameter) * 0.5), y: availableSize.height - environment.safeInsets.bottom - expandedMicrophoneButtonDiameter - 12.0), size: CGSize(width: expandedMicrophoneButtonDiameter, height: expandedMicrophoneButtonDiameter))
let microphoneButtonFrame: CGRect
if self.expandedParticipantsVideoState == nil {
microphoneButtonFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - microphoneButtonDiameter) * 0.5), y: availableSize.height - 48.0 - environment.safeInsets.bottom - microphoneButtonDiameter), size: CGSize(width: microphoneButtonDiameter, height: microphoneButtonDiameter))
microphoneButtonFrame = collapsedMicrophoneButtonFrame
} else {
microphoneButtonFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - microphoneButtonDiameter) * 0.5), y: availableSize.height - environment.safeInsets.bottom - microphoneButtonDiameter - 12.0), size: CGSize(width: microphoneButtonDiameter, height: microphoneButtonDiameter))
microphoneButtonFrame = expandedMicrophoneButtonFrame
}
let participantsClippingY: CGFloat
if self.expandedParticipantsVideoState == nil {
participantsClippingY = microphoneButtonFrame.minY
} else {
participantsClippingY = microphoneButtonFrame.minY - 24.0
}
let collapsedParticipantsClippingY: CGFloat = collapsedMicrophoneButtonFrame.minY
let expandedParticipantsClippingY: CGFloat = expandedMicrophoneButtonFrame.minY - 24.0
let leftActionButtonFrame = CGRect(origin: CGPoint(x: microphoneButtonFrame.minX - actionMicrophoneButtonSpacing - actionButtonDiameter, y: microphoneButtonFrame.minY + floor((microphoneButtonFrame.height - actionButtonDiameter) * 0.5)), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter))
let rightActionButtonFrame = CGRect(origin: CGPoint(x: microphoneButtonFrame.maxX + actionMicrophoneButtonSpacing, y: microphoneButtonFrame.minY + floor((microphoneButtonFrame.height - actionButtonDiameter) * 0.5)), size: CGSize(width: actionButtonDiameter, height: actionButtonDiameter))
let participantsSize = availableSize
let participantsCollapsedInsets = UIEdgeInsets(top: navigationHeight, left: environment.safeInsets.left, bottom: availableSize.height - participantsClippingY, right: environment.safeInsets.right)
let participantsExpandedInsets = UIEdgeInsets(top: environment.statusBarHeight, left: environment.safeInsets.left, bottom: availableSize.height - participantsClippingY, right: environment.safeInsets.right)
let participantsCollapsedInsets = UIEdgeInsets(top: navigationHeight, left: environment.safeInsets.left, bottom: availableSize.height - collapsedParticipantsClippingY, right: environment.safeInsets.right)
let participantsExpandedInsets = UIEdgeInsets(top: environment.statusBarHeight, left: environment.safeInsets.left, bottom: availableSize.height - expandedParticipantsClippingY, right: environment.safeInsets.right)
var mappedParticipants: VideoChatParticipantsComponent.Participants?
if let members = self.members, let callState = self.callState {
mappedParticipants = VideoChatParticipantsComponent.Participants(
myPeerId: callState.myPeerId,
participants: members.participants,
totalCount: members.totalCount,
loadMoreToken: members.loadMoreToken
)
}
let _ = self.participants.update(
transition: transition,
component: AnyComponent(VideoChatParticipantsComponent(
call: component.call,
members: self.members,
participants: mappedParticipants,
speakingParticipants: members?.speakingParticipants ?? Set(),
expandedVideoState: self.expandedParticipantsVideoState,
theme: environment.theme,
strings: environment.strings,
@ -557,14 +640,14 @@ private final class VideoChatScreenComponent: Component {
case .connected:
if let _ = callState.muteState {
if self.isPushToTalkActive {
micButtonContent = .unmuted
micButtonContent = .unmuted(pushToTalk: self.isPushToTalkActive)
actionButtonMicrophoneState = .unmuted
} else {
micButtonContent = .muted
actionButtonMicrophoneState = .muted
}
} else {
micButtonContent = .unmuted
micButtonContent = .unmuted(pushToTalk: false)
actionButtonMicrophoneState = .unmuted
}
}
@ -576,6 +659,7 @@ private final class VideoChatScreenComponent: Component {
let _ = self.microphoneButton.update(
transition: transition,
component: AnyComponent(VideoChatMicButtonComponent(
call: component.call,
content: micButtonContent,
isCollapsed: self.expandedParticipantsVideoState != nil,
updateUnmutedStateIsPushToTalk: { [weak self] unmutedStateIsPushToTalk in

View File

@ -0,0 +1,6 @@
import Foundation
import SwiftSignalKit
final class VideoChatVideoContext {
}

View File

@ -168,6 +168,7 @@ public final class PeerListItemComponent: Component {
public enum Color: Equatable {
case neutral
case accent
case constructive
}
public var text: String
@ -186,12 +187,14 @@ public final class PeerListItemComponent: Component {
let sideInset: CGFloat
let title: String
let avatar: Avatar?
let avatarComponent: AnyComponent<Empty>?
let peer: EnginePeer?
let storyStats: PeerStoryStats?
let subtitle: Subtitle?
let subtitleAccessory: SubtitleAccessory
let presence: EnginePeer.Presence?
let rightAccessory: RightAccessory
let rightAccessoryComponent: AnyComponent<Empty>?
let reaction: Reaction?
let story: EngineStoryItem?
let message: EngineMessage?
@ -212,12 +215,14 @@ public final class PeerListItemComponent: Component {
sideInset: CGFloat,
title: String,
avatar: Avatar? = nil,
avatarComponent: AnyComponent<Empty>? = nil,
peer: EnginePeer?,
storyStats: PeerStoryStats? = nil,
subtitle: Subtitle?,
subtitleAccessory: SubtitleAccessory,
presence: EnginePeer.Presence?,
rightAccessory: RightAccessory = .none,
rightAccessoryComponent: AnyComponent<Empty>? = nil,
reaction: Reaction? = nil,
story: EngineStoryItem? = nil,
message: EngineMessage? = nil,
@ -237,12 +242,14 @@ public final class PeerListItemComponent: Component {
self.sideInset = sideInset
self.title = title
self.avatar = avatar
self.avatarComponent = avatarComponent
self.peer = peer
self.storyStats = storyStats
self.subtitle = subtitle
self.subtitleAccessory = subtitleAccessory
self.presence = presence
self.rightAccessory = rightAccessory
self.rightAccessoryComponent = rightAccessoryComponent
self.reaction = reaction
self.story = story
self.message = message
@ -278,6 +285,9 @@ public final class PeerListItemComponent: Component {
if lhs.avatar != rhs.avatar {
return false
}
if lhs.avatarComponent != rhs.avatarComponent {
return false
}
if lhs.peer != rhs.peer {
return false
}
@ -296,6 +306,9 @@ public final class PeerListItemComponent: Component {
if lhs.rightAccessory != rhs.rightAccessory {
return false
}
if lhs.rightAccessoryComponent != rhs.rightAccessoryComponent {
return false
}
if lhs.reaction != rhs.reaction {
return false
}
@ -330,15 +343,18 @@ public final class PeerListItemComponent: Component {
private let swipeOptionContainer: ListItemSwipeOptionContainer
private let title = ComponentView<Empty>()
private let label = ComponentView<Empty>()
private var label = ComponentView<Empty>()
private let separatorLayer: SimpleLayer
private let avatarNode: AvatarNode
private var avatarNode: AvatarNode?
private var avatarImageView: UIImageView?
private let avatarButtonView: HighlightTrackingButton
private var avatarIcon: ComponentView<Empty>?
private var avatarComponentView: ComponentView<Empty>?
private var iconView: UIImageView?
private var checkLayer: CheckLayer?
private var rightAccessoryComponentView: ComponentView<Empty>?
private var reactionLayer: InlineStickerItemLayer?
private var heartReactionIcon: UIImageView?
@ -355,7 +371,13 @@ public final class PeerListItemComponent: Component {
private var presenceManager: PeerPresenceStatusManager?
public var avatarFrame: CGRect {
return self.avatarNode.frame
if let avatarComponentView = self.avatarComponentView, let avatarComponentViewImpl = avatarComponentView.view {
return avatarComponentViewImpl.frame
} else if let avatarNode = self.avatarNode {
return avatarNode.frame
} else {
return CGRect(origin: CGPoint(), size: CGSize())
}
}
public var titleFrame: CGRect? {
@ -388,10 +410,6 @@ public final class PeerListItemComponent: Component {
self.swipeOptionContainer = ListItemSwipeOptionContainer(frame: CGRect())
self.avatarNode = AvatarNode(font: avatarFont)
self.avatarNode.isLayerBacked = false
self.avatarNode.isUserInteractionEnabled = false
self.avatarButtonView = HighlightTrackingButton()
super.init(frame: frame)
@ -404,7 +422,6 @@ public final class PeerListItemComponent: Component {
self.swipeOptionContainer.addSubview(self.containerButton)
self.layer.addSublayer(self.separatorLayer)
self.containerButton.layer.addSublayer(self.avatarNode.layer)
self.containerButton.addTarget(self, action: #selector(self.pressed), for: .touchUpInside)
@ -490,7 +507,9 @@ public final class PeerListItemComponent: Component {
guard let component = self.component, let peer = component.peer else {
return
}
component.openStories?(peer, self.avatarNode)
if let avatarNode = self.avatarNode {
component.openStories?(peer, avatarNode)
}
}
private func updateReactionLayer() {
@ -634,6 +653,31 @@ public final class PeerListItemComponent: Component {
rightInset += 40.0
}
var rightAccessoryComponentSize: CGSize?
if let rightAccessoryComponent = component.rightAccessoryComponent {
var rightAccessoryComponentTransition = transition
let rightAccessoryComponentView: ComponentView<Empty>
if let current = self.rightAccessoryComponentView {
rightAccessoryComponentView = current
} else {
rightAccessoryComponentTransition = rightAccessoryComponentTransition.withAnimation(.none)
rightAccessoryComponentView = ComponentView()
self.rightAccessoryComponentView = rightAccessoryComponentView
}
rightAccessoryComponentSize = rightAccessoryComponentView.update(
transition: rightAccessoryComponentTransition,
component: rightAccessoryComponent,
environment: {},
containerSize: CGSize(width: 100.0, height: 100.0)
)
} else if let rightAccessoryComponentView = self.rightAccessoryComponentView {
self.rightAccessoryComponentView = nil
rightAccessoryComponentView.view?.removeFromSuperview()
}
if let rightAccessoryComponentSize {
rightInset += 8.0 + rightAccessoryComponentSize.width
}
var avatarLeftInset: CGFloat = component.sideInset + 10.0
if case let .editing(isSelected, isTinted) = component.selectionState {
@ -685,16 +729,117 @@ public final class PeerListItemComponent: Component {
let avatarSize: CGFloat = component.style == .compact ? 30.0 : 40.0
let avatarFrame = CGRect(origin: CGPoint(x: avatarLeftInset, y: floorToScreenPixels((height - verticalInset * 2.0 - avatarSize) / 2.0)), size: CGSize(width: avatarSize, height: avatarSize))
if self.avatarNode.bounds.isEmpty {
self.avatarNode.frame = avatarFrame
var statusIcon: EmojiStatusComponent.Content?
if let peer = component.peer {
if peer.isScam {
statusIcon = .text(color: component.theme.chat.message.incoming.scamColor, string: component.strings.Message_ScamAccount.uppercased())
} else if peer.isFake {
statusIcon = .text(color: component.theme.chat.message.incoming.scamColor, string: component.strings.Message_FakeAccount.uppercased())
} else if let emojiStatus = peer.emojiStatus {
statusIcon = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 20.0, height: 20.0), placeholderColor: component.theme.list.mediaPlaceholderColor, themeColor: component.theme.list.itemAccentColor, loopMode: .count(2))
} else if peer.isVerified {
statusIcon = .verified(fillColor: component.theme.list.itemCheckColors.fillColor, foregroundColor: component.theme.list.itemCheckColors.foregroundColor, sizeType: .compact)
} else if peer.isPremium {
statusIcon = .premium(color: component.theme.list.itemAccentColor)
}
}
if let avatarComponent = component.avatarComponent {
let avatarComponentView: ComponentView<Empty>
var avatarComponentTransition = transition
if let current = self.avatarComponentView {
avatarComponentView = current
} else {
avatarComponentTransition = avatarComponentTransition.withAnimation(.none)
avatarComponentView = ComponentView()
self.avatarComponentView = avatarComponentView
}
let _ = avatarComponentView.update(
transition: avatarComponentTransition,
component: avatarComponent,
environment: {},
containerSize: avatarFrame.size
)
if let avatarComponentViewImpl = avatarComponentView.view {
if avatarComponentViewImpl.superview == nil {
self.containerButton.insertSubview(avatarComponentViewImpl, at: 0)
}
avatarComponentTransition.setFrame(view: avatarComponentViewImpl, frame: avatarFrame)
}
if let avatarNode = self.avatarNode {
self.avatarNode = nil
avatarNode.layer.removeFromSuperlayer()
}
} else {
transition.setFrame(layer: self.avatarNode.layer, frame: avatarFrame)
let avatarNode: AvatarNode
if let current = self.avatarNode {
avatarNode = current
} else {
avatarNode = AvatarNode(font: avatarFont)
avatarNode.isLayerBacked = false
avatarNode.isUserInteractionEnabled = false
self.avatarNode = avatarNode
self.containerButton.layer.insertSublayer(avatarNode.layer, at: 0)
}
if avatarNode.bounds.isEmpty {
avatarNode.frame = avatarFrame
} else {
transition.setFrame(layer: avatarNode.layer, frame: avatarFrame)
}
if let peer = component.peer {
let clipStyle: AvatarNodeClipStyle
if case let .channel(channel) = peer, channel.flags.contains(.isForum) {
clipStyle = .roundedRect
} else {
clipStyle = .round
}
let _ = clipStyle
let _ = synchronousLoad
if peer.smallProfileImage != nil {
avatarNode.setPeerV2(
context: component.context,
theme: component.theme,
peer: peer,
authorOfMessage: nil,
overrideImage: nil,
emptyColor: nil,
clipStyle: .round,
synchronousLoad: synchronousLoad,
displayDimensions: CGSize(width: avatarSize, height: avatarSize)
)
} else {
avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, clipStyle: clipStyle, synchronousLoad: synchronousLoad, displayDimensions: CGSize(width: avatarSize, height: avatarSize))
}
avatarNode.setStoryStats(storyStats: component.storyStats.flatMap { storyStats -> AvatarNode.StoryStats in
return AvatarNode.StoryStats(
totalCount: storyStats.totalCount == 0 ? 0 : 1,
unseenCount: storyStats.unseenCount == 0 ? 0 : 1,
hasUnseenCloseFriendsItems: storyStats.hasUnseenCloseFriends
)
}, presentationParams: AvatarNode.StoryPresentationParams(
colors: AvatarNode.Colors(theme: component.theme),
lineWidth: 1.33,
inactiveLineWidth: 1.33
), transition: transition)
avatarNode.isHidden = false
} else {
avatarNode.isHidden = true
}
if let avatarComponentView = self.avatarComponentView {
self.avatarComponentView = nil
avatarComponentView.view?.removeFromSuperview()
}
}
transition.setFrame(view: self.avatarButtonView, frame: avatarFrame)
var statusIcon: EmojiStatusComponent.Content?
if let avatar = component.avatar {
let avatarImageView: UIImageView
if let current = self.avatarImageView {
@ -714,59 +859,6 @@ public final class PeerListItemComponent: Component {
avatarImageView.removeFromSuperview()
}
}
if let peer = component.peer {
let clipStyle: AvatarNodeClipStyle
if case let .channel(channel) = peer, channel.flags.contains(.isForum) {
clipStyle = .roundedRect
} else {
clipStyle = .round
}
let _ = clipStyle
let _ = synchronousLoad
if peer.smallProfileImage != nil {
self.avatarNode.setPeerV2(
context: component.context,
theme: component.theme,
peer: peer,
authorOfMessage: nil,
overrideImage: nil,
emptyColor: nil,
clipStyle: .round,
synchronousLoad: synchronousLoad,
displayDimensions: CGSize(width: avatarSize, height: avatarSize)
)
} else {
self.avatarNode.setPeer(context: component.context, theme: component.theme, peer: peer, clipStyle: clipStyle, synchronousLoad: synchronousLoad, displayDimensions: CGSize(width: avatarSize, height: avatarSize))
}
self.avatarNode.setStoryStats(storyStats: component.storyStats.flatMap { storyStats -> AvatarNode.StoryStats in
return AvatarNode.StoryStats(
totalCount: storyStats.totalCount == 0 ? 0 : 1,
unseenCount: storyStats.unseenCount == 0 ? 0 : 1,
hasUnseenCloseFriendsItems: storyStats.hasUnseenCloseFriends
)
}, presentationParams: AvatarNode.StoryPresentationParams(
colors: AvatarNode.Colors(theme: component.theme),
lineWidth: 1.33,
inactiveLineWidth: 1.33
), transition: transition)
self.avatarNode.isHidden = false
if peer.isScam {
statusIcon = .text(color: component.theme.chat.message.incoming.scamColor, string: component.strings.Message_ScamAccount.uppercased())
} else if peer.isFake {
statusIcon = .text(color: component.theme.chat.message.incoming.scamColor, string: component.strings.Message_FakeAccount.uppercased())
} else if let emojiStatus = peer.emojiStatus {
statusIcon = .animation(content: .customEmoji(fileId: emojiStatus.fileId), size: CGSize(width: 20.0, height: 20.0), placeholderColor: component.theme.list.mediaPlaceholderColor, themeColor: component.theme.list.itemAccentColor, loopMode: .count(2))
} else if peer.isVerified {
statusIcon = .verified(fillColor: component.theme.list.itemCheckColors.fillColor, foregroundColor: component.theme.list.itemCheckColors.foregroundColor, sizeType: .compact)
} else if peer.isPremium {
statusIcon = .premium(color: component.theme.list.itemAccentColor)
}
} else {
self.avatarNode.isHidden = true
}
let previousTitleFrame = self.title.view?.frame
var previousTitleContents: UIView?
@ -801,7 +893,29 @@ public final class PeerListItemComponent: Component {
labelColor = component.theme.list.itemSecondaryTextColor
case .accent:
labelColor = component.theme.list.itemAccentColor
case .constructive:
//TODO:release
labelColor = UIColor(rgb: 0x33C758)
}
var animateLabelDirection: Bool?
if !transition.animation.isImmediate, let previousComponent, let previousSubtitle = previousComponent.subtitle, let subtitle = component.subtitle, subtitle.color != previousSubtitle.color {
let animateLabelDirectionValue: Bool
if case .constructive = subtitle.color {
animateLabelDirectionValue = true
} else {
animateLabelDirectionValue = false
}
animateLabelDirection = animateLabelDirectionValue
if let labelView = self.label.view {
transition.setPosition(view: labelView, position: labelView.center.offsetBy(dx: 0.0, dy: animateLabelDirectionValue ? -6.0 : 6.0))
labelView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false, completion: { [weak labelView] _ in
labelView?.removeFromSuperview()
})
}
self.label = ComponentView()
}
let labelSize = self.label.update(
transition: .immediate,
component: AnyComponent(MultilineTextComponent(
@ -921,11 +1035,6 @@ public final class PeerListItemComponent: Component {
}
}
if labelView.superview == nil {
labelView.isUserInteractionEnabled = false
self.containerButton.addSubview(labelView)
}
let labelFrame: CGRect
switch component.style {
case .generic:
@ -934,7 +1043,25 @@ public final class PeerListItemComponent: Component {
labelFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + 4.0, y: floor((height - verticalInset * 2.0 - centralContentHeight) / 2.0)), size: labelSize)
}
transition.setFrame(view: labelView, frame: labelFrame)
if labelView.superview == nil {
labelView.isUserInteractionEnabled = false
labelView.layer.anchorPoint = CGPoint()
self.containerButton.addSubview(labelView)
labelView.center = labelFrame.origin
} else {
transition.setPosition(view: labelView, position: labelFrame.origin)
}
labelView.bounds = CGRect(origin: CGPoint(), size: labelFrame.size)
if let animateLabelDirection {
transition.animatePosition(view: labelView, from: CGPoint(x: 0.0, y: animateLabelDirection ? 6.0 : -6.0), to: CGPoint(), additive: true)
if !transition.animation.isImmediate {
labelView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15)
}
}
}
let imageSize = CGSize(width: 22.0, height: 22.0)
@ -974,6 +1101,15 @@ public final class PeerListItemComponent: Component {
}
}
if let rightAccessoryComponentViewImpl = self.rightAccessoryComponentView?.view, let rightAccessoryComponentSize {
var rightAccessoryComponentTransition = transition
if rightAccessoryComponentViewImpl.superview == nil {
rightAccessoryComponentTransition = rightAccessoryComponentTransition.withAnimation(.none)
self.containerButton.addSubview(rightAccessoryComponentViewImpl)
}
rightAccessoryComponentTransition.setFrame(view: rightAccessoryComponentViewImpl, frame: CGRect(origin: CGPoint(x: availableSize.width - rightAccessoryComponentSize.width, y: floor((height - verticalInset * 2.0 - rightAccessoryComponentSize.width) / 2.0)), size: rightAccessoryComponentSize))
}
var reactionIconTransition = transition
if previousComponent?.reaction != component.reaction {
if let reaction = component.reaction, case .builtin("") = reaction.reaction {