mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
[WIP] Video chat v2
This commit is contained in:
parent
53cb586691
commit
d2b4622ef0
@ -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,
|
||||
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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))
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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
|
||||
|
@ -0,0 +1,6 @@
|
||||
import Foundation
|
||||
import SwiftSignalKit
|
||||
|
||||
final class VideoChatVideoContext {
|
||||
|
||||
}
|
@ -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 {
|
||||
|
Loading…
x
Reference in New Issue
Block a user