Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
@@ -0,0 +1,407 @@
import Foundation
import UIKit
import Display
import ComponentFlow
private let purple = UIColor(rgb: 0xdf44b8)
private let pink = UIColor(rgb: 0x3851eb)
public final class AnimatedCountView: UIView {
let countLabel = AnimatedCountLabel()
let subtitleLabel = UILabel()
private let foregroundView = UIView()
private let foregroundGradientLayer = CAGradientLayer()
private let maskingView = UIView()
private var scaleFactor: CGFloat { 0.7 }
override init(frame: CGRect = .zero) {
super.init(frame: frame)
self.foregroundGradientLayer.type = .radial
self.foregroundGradientLayer.locations = [0.0, 0.85, 1.0]
self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0)
self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0)
self.foregroundView.mask = self.maskingView
self.foregroundView.layer.addSublayer(self.foregroundGradientLayer)
self.addSubview(self.foregroundView)
self.addSubview(self.subtitleLabel)
self.maskingView.addSubview(countLabel)
countLabel.clipsToBounds = false
subtitleLabel.textAlignment = .center
self.clipsToBounds = false
subtitleLabel.textColor = .white
}
override public func layoutSubviews() {
super.layoutSubviews()
self.updateFrames()
}
func updateFrames(transition: ComponentFlow.ComponentTransition? = nil) {
let subtitleHeight: CGFloat = subtitleLabel.intrinsicContentSize.height
let subtitleFrame = CGRect(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: self.countLabel.attributedText?.length == 0 ? bounds.midY - subtitleHeight / 2 : bounds.height - subtitleHeight, width: subtitleLabel.intrinsicContentSize.width + 20, height: subtitleHeight)
if let transition {
transition.setFrame(view: self.foregroundView, frame: CGRect(origin: CGPoint.zero, size: bounds.size))
transition.setFrame(layer: self.foregroundGradientLayer, frame: CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60))
transition.setFrame(view: self.maskingView, frame: CGRect(origin: CGPoint.zero, size: bounds.size))
transition.setFrame(view: self.countLabel, frame: CGRect(origin: CGPoint.zero, size: bounds.size))
transition.setFrame(view: self.subtitleLabel, frame: subtitleFrame)
} else {
self.foregroundView.frame = CGRect(origin: CGPoint.zero, size: bounds.size)// .insetBy(dx: -40, dy: -40)
self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60)
self.maskingView.frame = CGRect(origin: .zero, size: bounds.size)
countLabel.frame = CGRect(origin: .zero, size: CGSize(width: bounds.width, height: bounds.height))
subtitleLabel.frame = subtitleFrame
}
}
func update(countString: String, subtitle: String, fontSize: CGFloat = 48.0, gradientColors: [CGColor] = [pink.cgColor, purple.cgColor, purple.cgColor]) {
self.setupGradientAnimations()
let backgroundGradientColors: [CGColor]
if gradientColors.count == 1 {
backgroundGradientColors = [gradientColors[0], gradientColors[0]]
} else {
backgroundGradientColors = gradientColors
}
self.foregroundGradientLayer.colors = backgroundGradientColors
let text: String = countString
self.countLabel.fontSize = fontSize
self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: fontSize, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white)
self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, attributes: [.font: UIFont.systemFont(ofSize: max(floor((fontSize + 4.0) / 3.0), 12.0), weight: .semibold)])
self.subtitleLabel.isHidden = subtitle.isEmpty
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func setupGradientAnimations() {
if let _ = self.foregroundGradientLayer.animation(forKey: "movement") {
} else {
let previousValue = self.foregroundGradientLayer.startPoint
let newValue = CGPoint(x: CGFloat.random(in: 0.65 ..< 0.85), y: CGFloat.random(in: 0.1 ..< 0.45))
self.foregroundGradientLayer.startPoint = newValue
CATransaction.begin()
let animation = CABasicAnimation(keyPath: "startPoint")
animation.duration = Double.random(in: 0.8 ..< 1.4)
animation.fromValue = previousValue
animation.toValue = newValue
CATransaction.setCompletionBlock { [weak self] in
self?.setupGradientAnimations()
}
self.foregroundGradientLayer.add(animation, forKey: "movement")
CATransaction.commit()
}
}
}
class AnimatedCharLayer: CATextLayer {
var text: String? {
get {
self.string as? String ?? (self.string as? NSAttributedString)?.string
}
set {
self.string = newValue
}
}
var attributedText: NSAttributedString? {
get {
self.string as? NSAttributedString
}
set {
self.string = newValue
}
}
var layer: CALayer { self }
override init() {
super.init()
self.contentsScale = UIScreen.main.scale
self.masksToBounds = false
}
override init(layer: Any) {
super.init(layer: layer)
self.contentsScale = UIScreen.main.scale
self.masksToBounds = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
class AnimatedCountLabel: UILabel {
override var text: String? {
get {
chars.reduce("") { $0 + ($1.text ?? "") }
}
set {
// update(with: newValue ?? "")
}
}
override var attributedText: NSAttributedString? {
get {
let string = NSMutableAttributedString()
for char in chars {
string.append(char.attributedText ?? NSAttributedString())
}
return string
}
set {
udpateAttributed(with: newValue ?? NSAttributedString())
}
}
private var chars = [AnimatedCharLayer]()
private let containerView = UIView()
var itemWidth: CGFloat { 36 * fontSize / 60 }
var commaWidthForSpacing: CGFloat { 12 * fontSize / 60 }
var commaFrameWidth: CGFloat { 36 * fontSize / 60 }
var interItemSpacing: CGFloat { 0 * fontSize / 60 }
var didBegin = false
var fontSize: CGFloat = 60
var scaleFactor: CGFloat { 1 }
override init(frame: CGRect = .zero) {
super.init(frame: frame)
containerView.clipsToBounds = false
addSubview(containerView)
self.clipsToBounds = false
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private func offsetForChar(at index: Int, within characters: [NSAttributedString]? = nil) -> CGFloat {
if let characters {
var offset = characters[0..<index].reduce(0) {
if $1.string == "," {
return $0 + commaWidthForSpacing + interItemSpacing
}
return $0 + itemWidth + interItemSpacing
}
if characters.count > index && characters[index].string == "," {
if index > 0, ["1", "7"].contains(characters[index - 1].string) {
offset -= commaWidthForSpacing * 0.5
} else {
offset -= commaWidthForSpacing / 6// 3
}
}
return offset
} else {
return offsetForChar(at: index, within: self.chars.compactMap(\.attributedText))
}
}
override func layoutSubviews() {
super.layoutSubviews()
let countWidth = offsetForChar(at: chars.count) - interItemSpacing
containerView.frame = .init(x: bounds.midX - countWidth / 2 * scaleFactor, y: 0, width: countWidth * scaleFactor, height: bounds.height)
chars.enumerated().forEach { (index, char) in
let offset = offsetForChar(at: index)
char.frame.origin.x = offset
char.frame.origin.y = 0
char.frame.size.height = containerView.bounds.height
}
}
func udpateAttributed(with newString: NSAttributedString) {
let interItemSpacing: CGFloat = 0
let separatedStrings = Array(newString.string).map { String($0) }
var range = NSRange(location: 0, length: 0)
var newChars = [NSAttributedString]()
for string in separatedStrings {
range.length = string.count
let attributedString = newString.attributedSubstring(from: range)
newChars.append(attributedString)
range.location += range.length
}
let currentChars = chars.map { $0.attributedText ?? .init() }
let maxAnimationDuration: TimeInterval = 1.2
var numberOfChanges = abs(newChars.count - currentChars.count)
for index in 0..<min(newChars.count, currentChars.count) {
let newCharIndex = newChars.count - 1 - index
let currCharIndex = currentChars.count - 1 - index
if newChars[newCharIndex] != currentChars[currCharIndex] {
numberOfChanges += 1
}
}
let initialDuration: TimeInterval = min(0.25, maxAnimationDuration / Double(numberOfChanges))
let interItemDelay: TimeInterval = 0.08
var changeIndex = 0
var newLayers = [AnimatedCharLayer]()
let isInitialSet = currentChars.isEmpty
for index in 0..<min(newChars.count, currentChars.count) {
let newCharIndex = newChars.count - 1 - index
let currCharIndex = currentChars.count - 1 - index
if newChars[newCharIndex] != currentChars[currCharIndex] {
let initialDuration = newChars[newCharIndex] != currentChars[currCharIndex] ? initialDuration : 0
if !isInitialSet && newChars[newCharIndex] != currentChars[currCharIndex] {
animateOut(for: chars[currCharIndex].layer, duration: initialDuration, beginTime: TimeInterval(changeIndex) * interItemDelay)
} else {
chars[currCharIndex].layer.removeFromSuperlayer()
}
let newLayer = AnimatedCharLayer()
newLayer.attributedText = newChars[newCharIndex]
let offset = offsetForChar(at: newCharIndex, within: newChars)
newLayer.frame = .init(
x: offset,
y: 0,
width: newChars[newCharIndex].string == "," ? commaFrameWidth : itemWidth,
height: itemWidth * 1.8 + (newChars[newCharIndex].string == "," ? 4 : 0)
)
containerView.layer.addSublayer(newLayer)
if !isInitialSet && newChars[newCharIndex] != currentChars[currCharIndex] {
newLayer.layer.opacity = 0
animateIn(for: newLayer.layer, duration: initialDuration, beginTime: TimeInterval(changeIndex) * interItemDelay)
changeIndex += 1
}
newLayers.append(newLayer)
} else {
newLayers.append(chars[currCharIndex])
let offset = offsetForChar(at: newCharIndex, within: newChars)
chars[currCharIndex].frame = .init(
x: offset,
y: 0,
width: newChars[newCharIndex].string == "," ? commaFrameWidth : itemWidth,
height: itemWidth * 1.8 + (newChars[newCharIndex].string == "," ? 4 : 0)
)
}
}
for index in min(newChars.count, currentChars.count)..<currentChars.count {
let currCharIndex = currentChars.count - 1 - index
animateOut(for: chars[currCharIndex].layer, duration: initialDuration, beginTime: TimeInterval(changeIndex) * interItemDelay)
changeIndex += 1
}
for index in min(newChars.count, currentChars.count)..<newChars.count {
let newCharIndex = newChars.count - 1 - index
let newLayer = AnimatedCharLayer()
newLayer.attributedText = newChars[newCharIndex]
let offset = offsetForChar(at: newCharIndex, within: newChars)
newLayer.frame = .init(x: offset, y: 0, width: newChars[newCharIndex].string == "," ? commaFrameWidth : itemWidth, height: itemWidth * 1.8 + (newChars[newCharIndex].string == "," ? 4 : 0))
containerView.layer.addSublayer(newLayer)
if !isInitialSet {
animateIn(for: newLayer.layer, duration: initialDuration, beginTime: TimeInterval(changeIndex) * interItemDelay)
}
newLayers.append(newLayer)
changeIndex += 1
}
let prevCount = chars.count
chars = newLayers.reversed()
let countWidth = offsetForChar(at: newChars.count, within: newChars) - interItemSpacing
if didBegin && prevCount != chars.count {
UIView.animate(withDuration: Double(changeIndex) * initialDuration) { [self] in
containerView.frame = .init(x: self.bounds.midX - countWidth / 2, y: 0, width: countWidth, height: self.bounds.height)
if countWidth * scaleFactor > self.bounds.width {
let scale = (self.bounds.width - 32) / (countWidth * scaleFactor)
containerView.transform = .init(scaleX: scale, y: scale)
} else {
containerView.transform = .init(scaleX: scaleFactor, y: scaleFactor)
}
}
} else if countWidth > 0 {
containerView.frame = .init(x: self.bounds.midX - countWidth / 2 * scaleFactor, y: 0, width: countWidth * scaleFactor, height: self.bounds.height)
didBegin = true
}
self.clipsToBounds = false
}
func animateOut(for layer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) {
let beginTimeOffset: CFTimeInterval = 0
DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) {
let beginTime: CFTimeInterval = 0
let opacityInAnimation = CABasicAnimation(keyPath: "opacity")
opacityInAnimation.fromValue = 1
opacityInAnimation.toValue = 0
opacityInAnimation.fillMode = .forwards
opacityInAnimation.isRemovedOnCompletion = false
let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleOutAnimation.fromValue = 1
scaleOutAnimation.toValue = 0.0
let translate = CABasicAnimation(keyPath: "transform.translation")
translate.fromValue = CGPoint.zero
translate.toValue = CGPoint(x: 0, y: -layer.bounds.height * 0.3)
let group = CAAnimationGroup()
group.animations = [opacityInAnimation, scaleOutAnimation, translate]
group.duration = duration
group.beginTime = beginTimeOffset + beginTime
group.fillMode = .forwards
group.isRemovedOnCompletion = false
group.completion = { _ in
layer.removeFromSuperlayer()
}
layer.add(group, forKey: "out")
}
}
func animateIn(for newLayer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) {
let beginTimeOffset: CFTimeInterval = 0 // CACurrentMediaTime()
DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) { [self] in
let beginTime: CFTimeInterval = 0
newLayer.opacity = 0
let opacityInAnimation = CABasicAnimation(keyPath: "opacity")
opacityInAnimation.fromValue = 0
opacityInAnimation.toValue = 1
opacityInAnimation.duration = duration
opacityInAnimation.beginTime = beginTimeOffset + beginTime
opacityInAnimation.fillMode = .backwards
newLayer.opacity = 1
newLayer.add(opacityInAnimation, forKey: "opacity")
let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale")
scaleOutAnimation.fromValue = 0
scaleOutAnimation.toValue = 1
scaleOutAnimation.duration = duration
scaleOutAnimation.beginTime = beginTimeOffset + beginTime
newLayer.add(scaleOutAnimation, forKey: "scalein")
let animation = CAKeyframeAnimation()
animation.keyPath = "position.y"
animation.values = [20 * fontSize / 60, -6 * fontSize / 60, 0]
animation.keyTimes = [0, 0.64, 1]
animation.timingFunction = CAMediaTimingFunction.init(name: .easeInEaseOut)
animation.duration = duration / 0.64
animation.beginTime = beginTimeOffset + beginTime
animation.isAdditive = true
newLayer.add(animation, forKey: "pos")
}
}
}
@@ -0,0 +1,279 @@
import Foundation
import UIKit
import AVFoundation
import SwiftSignalKit
import UniversalMediaPlayer
import Postbox
import TelegramCore
import AccountContext
import TelegramAudio
import Display
import TelegramVoip
import RangeSet
import ManagedFile
import FFMpegBinding
import TelegramUniversalVideoContent
final class LivestreamVideoViewV1: UIView {
private final class PartContext {
let part: DirectMediaStreamingContext.Playlist.Part
let disposable = MetaDisposable()
var resolvedTimeOffset: Double?
var data: TempBoxFile?
var info: FFMpegMediaInfo?
init(part: DirectMediaStreamingContext.Playlist.Part) {
self.part = part
}
deinit {
self.disposable.dispose()
}
}
private let context: AccountContext
private let audioSessionManager: ManagedAudioSession
private let call: PresentationGroupCall
private let chunkPlayerPartsState = Promise<ChunkMediaPlayerPartsState>(ChunkMediaPlayerPartsState(duration: 10000000.0, content: .parts([])))
private var parts: [ChunkMediaPlayerPart] = [] {
didSet {
self.chunkPlayerPartsState.set(.single(ChunkMediaPlayerPartsState(duration: 10000000.0, content: .parts(self.parts))))
}
}
private let player: ChunkMediaPlayer
private let playerNode: MediaPlayerNode
private var playerStatus: MediaPlayerStatus?
private var playerStatusDisposable: Disposable?
private var streamingContextDisposable: Disposable?
private var streamingContext: DirectMediaStreamingContext?
private var playlistDisposable: Disposable?
private var partContexts: [Int: PartContext] = [:]
private var requestedSeekTimestamp: Double?
init(
context: AccountContext,
audioSessionManager: ManagedAudioSession,
call: PresentationGroupCall
) {
self.context = context
self.audioSessionManager = audioSessionManager
self.call = call
self.playerNode = MediaPlayerNode()
var onSeeked: (() -> Void)?
self.player = ChunkMediaPlayerV2(
params: ChunkMediaPlayerV2.MediaDataReaderParams(context: context),
audioSessionManager: audioSessionManager,
source: .externalParts(self.chunkPlayerPartsState.get()),
video: true,
enableSound: true,
baseRate: 1.0,
onSeeked: {
onSeeked?()
},
playerNode: self.playerNode
)
super.init(frame: CGRect())
self.addSubview(self.playerNode.view)
onSeeked = {
}
self.playerStatusDisposable = (self.player.status
|> deliverOnMainQueue).startStrict(next: { [weak self] status in
guard let self else {
return
}
self.updatePlayerStatus(status: status)
})
var didProcessFramesToDisplay = false
self.playerNode.isHidden = true
self.playerNode.hasSentFramesToDisplay = { [weak self] in
guard let self, !didProcessFramesToDisplay else {
return
}
didProcessFramesToDisplay = true
self.playerNode.isHidden = false
}
if let call = call as? PresentationGroupCallImpl {
self.streamingContextDisposable = (call.externalMediaStream.get()
|> deliverOnMainQueue).startStrict(next: { [weak self] externalMediaStream in
guard let self else {
return
}
self.streamingContext = externalMediaStream
self.resetPlayback()
})
}
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
self.playerStatusDisposable?.dispose()
self.streamingContextDisposable?.dispose()
self.playlistDisposable?.dispose()
}
private func updatePlayerStatus(status: MediaPlayerStatus) {
self.playerStatus = status
self.updatePlaybackPositionIfNeeded()
}
private func resetPlayback() {
self.parts = []
self.playlistDisposable?.dispose()
self.playlistDisposable = nil
guard let streamingContext = self.streamingContext else {
return
}
self.playlistDisposable = (streamingContext.playlistData()
|> deliverOnMainQueue).startStrict(next: { [weak self] playlist in
guard let self else {
return
}
self.updatePlaylist(playlist: playlist)
})
}
private func updatePlaylist(playlist: DirectMediaStreamingContext.Playlist) {
var validPartIds: [Int] = []
for part in playlist.parts.prefix(upTo: 4) {
validPartIds.append(part.index)
if self.partContexts[part.index] == nil {
let partContext = PartContext(part: part)
self.partContexts[part.index] = partContext
if let streamingContext = self.streamingContext {
partContext.disposable.set((streamingContext.partData(index: part.index)
|> deliverOn(Queue.concurrentDefaultQueue())
|> map { data -> (file: TempBoxFile, info: FFMpegMediaInfo)? in
guard let data else {
return nil
}
let tempFile = TempBox.shared.tempFile(fileName: "part.mp4")
if let _ = try? data.write(to: URL(fileURLWithPath: tempFile.path), options: .atomic) {
if let info = extractFFMpegMediaInfo(path: tempFile.path) {
return (tempFile, info)
} else {
return nil
}
} else {
TempBox.shared.dispose(tempFile)
return nil
}
}
|> deliverOnMainQueue).startStrict(next: { [weak self, weak partContext] fileAndInfo in
guard let self, let partContext else {
return
}
if let (file, info) = fileAndInfo {
partContext.data = file
partContext.info = info
} else {
partContext.data = nil
}
self.updatePartContexts()
}))
}
}
}
var removedPartIds: [Int] = []
for (id, _) in self.partContexts {
if !validPartIds.contains(id) {
removedPartIds.append(id)
}
}
for id in removedPartIds {
self.partContexts.removeValue(forKey: id)
}
}
private func updatePartContexts() {
var readyParts: [ChunkMediaPlayerPart] = []
let sortedContexts = self.partContexts.values.sorted(by: { $0.part.timestamp < $1.part.timestamp })
outer: for i in 0 ..< sortedContexts.count {
let partContext = sortedContexts[i]
if let data = partContext.data {
let offsetTime: Double
if i != 0 {
var foundOffset: Double?
inner: for j in 0 ..< i {
let previousContext = sortedContexts[j]
if previousContext.part.index == partContext.part.index - 1 {
if let previousInfo = previousContext.info {
if let previousResolvedOffset = previousContext.resolvedTimeOffset {
if let audio = previousInfo.audio {
foundOffset = previousResolvedOffset + audio.duration.seconds
} else {
foundOffset = partContext.part.timestamp
}
}
}
break inner
}
}
if let foundOffset {
partContext.resolvedTimeOffset = foundOffset
offsetTime = foundOffset
} else {
continue outer
}
} else {
if let resolvedOffset = partContext.resolvedTimeOffset {
offsetTime = resolvedOffset
} else {
offsetTime = partContext.part.timestamp
partContext.resolvedTimeOffset = offsetTime
}
}
readyParts.append(ChunkMediaPlayerPart(
startTime: partContext.part.timestamp,
endTime: partContext.part.timestamp + partContext.part.duration,
content: ChunkMediaPlayerPart.TempFile(file: data),
codecName: nil,
offsetTime: offsetTime
))
}
}
readyParts.sort(by: { $0.startTime < $1.startTime })
self.parts = readyParts
self.updatePlaybackPositionIfNeeded()
}
private func updatePlaybackPositionIfNeeded() {
if let part = self.parts.first {
if let playerStatus = self.playerStatus, playerStatus.timestamp < part.startTime {
if self.requestedSeekTimestamp != part.startTime {
self.requestedSeekTimestamp = part.startTime
self.player.seek(timestamp: part.startTime, play: true)
}
}
}
}
public func update(size: CGSize, transition: ContainedViewLayoutTransition) {
//transition.updateFrame(view: self.playerNode.view, frame: CGRect(origin: CGPoint(), size: size))
self.playerNode.frame = CGRect(origin: CGPoint(), size: size)
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,823 @@
import Foundation
import UIKit
import ComponentFlow
import AccountContext
import AVKit
import MultilineTextComponent
import Display
import ShimmerEffect
import TelegramCore
import SwiftSignalKit
import AvatarNode
import Postbox
import TelegramVoip
import ComponentDisplayAdapters
public final class MediaStreamVideoComponent: Component {
let call: PresentationGroupCallImpl
let videoEndpointId: String?
let isVisible: Bool
let isAdmin: Bool
let peerTitle: String
let enablePictureInPicture: Bool
let activatePictureInPicture: ActionSlot<Action<Void>>
let deactivatePictureInPicture: ActionSlot<Void>
let bringBackControllerForPictureInPictureDeactivation: (@escaping () -> Void) -> Void
let pictureInPictureClosed: () -> Void
let addInset: Bool
let isFullscreen: Bool
let onVideoSizeRetrieved: (CGSize) -> Void
let videoLoading: Bool
let callPeer: Peer?
let onVideoPlaybackLiveChange: (Bool) -> Void
public init(
call: PresentationGroupCallImpl,
videoEndpointId: String?,
isVisible: Bool,
isAdmin: Bool,
peerTitle: String,
addInset: Bool,
isFullscreen: Bool,
videoLoading: Bool,
callPeer: Peer?,
enablePictureInPicture: Bool,
activatePictureInPicture: ActionSlot<Action<Void>>,
deactivatePictureInPicture: ActionSlot<Void>,
bringBackControllerForPictureInPictureDeactivation: @escaping (@escaping () -> Void) -> Void,
pictureInPictureClosed: @escaping () -> Void,
onVideoSizeRetrieved: @escaping (CGSize) -> Void,
onVideoPlaybackLiveChange: @escaping (Bool) -> Void
) {
self.call = call
self.videoEndpointId = videoEndpointId
self.isVisible = isVisible
self.isAdmin = isAdmin
self.peerTitle = peerTitle
self.videoLoading = videoLoading
self.enablePictureInPicture = enablePictureInPicture
self.activatePictureInPicture = activatePictureInPicture
self.deactivatePictureInPicture = deactivatePictureInPicture
self.bringBackControllerForPictureInPictureDeactivation = bringBackControllerForPictureInPictureDeactivation
self.pictureInPictureClosed = pictureInPictureClosed
self.onVideoPlaybackLiveChange = onVideoPlaybackLiveChange
self.callPeer = callPeer
self.addInset = addInset
self.isFullscreen = isFullscreen
self.onVideoSizeRetrieved = onVideoSizeRetrieved
}
public static func ==(lhs: MediaStreamVideoComponent, rhs: MediaStreamVideoComponent) -> Bool {
if lhs.call !== rhs.call {
return false
}
if lhs.videoEndpointId != rhs.videoEndpointId {
return false
}
if lhs.isVisible != rhs.isVisible {
return false
}
if lhs.isAdmin != rhs.isAdmin {
return false
}
if lhs.peerTitle != rhs.peerTitle {
return false
}
if lhs.addInset != rhs.addInset {
return false
}
if lhs.isFullscreen != rhs.isFullscreen {
return false
}
if lhs.videoLoading != rhs.videoLoading {
return false
}
if lhs.enablePictureInPicture != rhs.enablePictureInPicture {
return false
}
return true
}
public final class State: ComponentState {
override init() {
super.init()
}
}
public func makeState() -> State {
return State()
}
public final class View: UIView, AVPictureInPictureControllerDelegate, ComponentTaggedView {
public final class Tag {
}
private let videoRenderingContext = VideoRenderingContext()
private let blurTintView: UIView
private var videoBlurView: VideoRenderingView?
private var videoView: VideoRenderingView?
private var videoPlaceholderView: UIView?
private var noSignalView: ComponentHostView<Empty>?
private let loadingBlurView = CustomIntensityVisualEffectView(effect: UIBlurEffect(style: .light), intensity: 0.4)
private let shimmerOverlayView = CALayer()
private var pictureInPictureController: AVPictureInPictureController?
private var component: MediaStreamVideoComponent?
private var hadVideo: Bool = false
private var requestedExpansion: Bool = false
private var noSignalTimer: Foundation.Timer?
private var noSignalTimeout: Bool = false
private let videoBlurGradientMask = CAGradientLayer()
private let videoBlurSolidMask = CALayer()
private var wasVisible = true
private var borderShimmer = StandaloneShimmerEffect()
private let shimmerBorderLayer = CALayer()
private let placeholderView = UIImageView()
private var videoStalled = false {
didSet {
if videoStalled != oldValue {
self.updateVideoStalled(isStalled: self.videoStalled, transition: nil)
// state?.updated()
}
}
}
var onVideoPlaybackChange: ((Bool) -> Void) = { _ in }
private var frameInputDisposable: Disposable?
private var stallTimer: Foundation.Timer?
private let fullScreenBackgroundPlaceholder = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
private var avatarDisposable: Disposable?
private var didBeginLoadingAvatar = false
private var timeLastFrameReceived: CFAbsoluteTime?
private var isFullscreen: Bool = false
private let videoLoadingThrottler = Throttler<Bool>(duration: 1, queue: .main)
private var wasFullscreen: Bool = false
private var isAnimating = false
private var didRequestBringBack = false
private weak var state: State?
private var lastPresentation: UIView?
private var pipTrackDisplayLink: CADisplayLink?
private var livestreamVideoView: LivestreamVideoViewV1?
override init(frame: CGRect) {
self.blurTintView = UIView()
self.blurTintView.backgroundColor = UIColor(white: 0.0, alpha: 0.55)
super.init(frame: frame)
self.isUserInteractionEnabled = false
self.clipsToBounds = true
self.addSubview(self.blurTintView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
deinit {
avatarDisposable?.dispose()
frameInputDisposable?.dispose()
self.pipTrackDisplayLink?.invalidate()
self.pipTrackDisplayLink = nil
}
public func matches(tag: Any) -> Bool {
if let _ = tag as? Tag {
return true
}
return false
}
func expandFromPictureInPicture() {
if let pictureInPictureController = self.pictureInPictureController, pictureInPictureController.isPictureInPictureActive {
self.requestedExpansion = true
self.pictureInPictureController?.stopPictureInPicture()
}
}
private func updateVideoStalled(isStalled: Bool, transition: ComponentTransition?) {
if isStalled {
guard let component = self.component else { return }
if let peerId = component.call.peerId, let frameView = lastFrame[peerId.id.description] {
frameView.removeFromSuperview()
placeholderView.subviews.forEach { $0.removeFromSuperview() }
placeholderView.addSubview(frameView)
frameView.frame = placeholderView.bounds
}
if !hadVideo && placeholderView.superview == nil {
addSubview(placeholderView)
}
let needsFadeInAnimation = hadVideo
if loadingBlurView.superview == nil {
//addSubview(loadingBlurView)
if needsFadeInAnimation {
let anim = CABasicAnimation(keyPath: "opacity")
anim.duration = 0.5
anim.fromValue = 0
anim.toValue = 1
loadingBlurView.layer.opacity = 1
anim.fillMode = .forwards
anim.isRemovedOnCompletion = false
loadingBlurView.layer.add(anim, forKey: "opacity")
}
}
loadingBlurView.layer.zPosition = 998
self.noSignalView?.layer.zPosition = loadingBlurView.layer.zPosition + 1
if shimmerBorderLayer.superlayer == nil {
loadingBlurView.contentView.layer.addSublayer(shimmerBorderLayer)
}
loadingBlurView.clipsToBounds = true
let cornerRadius = loadingBlurView.layer.cornerRadius
shimmerBorderLayer.cornerRadius = cornerRadius
shimmerBorderLayer.masksToBounds = true
shimmerBorderLayer.compositingFilter = "softLightBlendMode"
let borderMask = CAShapeLayer()
shimmerBorderLayer.mask = borderMask
if let transition, shimmerBorderLayer.mask != nil {
let initialPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil)
borderMask.path = initialPath
transition.setFrame(layer: shimmerBorderLayer, frame: loadingBlurView.bounds)
let borderMaskPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil)
transition.setShapeLayerPath(layer: borderMask, path: borderMaskPath)
} else {
shimmerBorderLayer.frame = loadingBlurView.bounds
let borderMaskPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil)
borderMask.path = borderMaskPath
}
borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor
borderMask.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor
borderMask.lineWidth = 3
borderMask.compositingFilter = "softLightBlendMode"
borderShimmer = StandaloneShimmerEffect()
borderShimmer.layer = shimmerBorderLayer
borderShimmer.updateHorizontal(background: .clear, foreground: .white)
loadingBlurView.alpha = 1
} else {
if hadVideo && !isAnimating && loadingBlurView.layer.opacity == 1 {
let anim = CABasicAnimation(keyPath: "opacity")
anim.duration = 0.4
anim.fromValue = 1.0
anim.toValue = 0.0
self.loadingBlurView.layer.opacity = 0
anim.fillMode = .forwards
anim.isRemovedOnCompletion = false
isAnimating = true
anim.completion = { [weak self] _ in
guard self?.videoStalled == false else { return }
self?.loadingBlurView.removeFromSuperview()
self?.placeholderView.removeFromSuperview()
self?.isAnimating = false
}
loadingBlurView.layer.add(anim, forKey: "opacity")
}
}
}
func update(component: MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: ComponentTransition) -> CGSize {
self.state = state
self.component = component
self.onVideoPlaybackChange = component.onVideoPlaybackLiveChange
self.isFullscreen = component.isFullscreen
if let peer = component.callPeer, !didBeginLoadingAvatar {
didBeginLoadingAvatar = true
avatarDisposable = peerAvatarCompleteImage(account: component.call.account, peer: EnginePeer(peer), size: CGSize(width: 250.0, height: 250.0), round: false, font: Font.regular(16.0), drawLetters: false, fullSize: false, blurred: true).start(next: { [weak self] image in
DispatchQueue.main.async {
self?.placeholderView.contentMode = .scaleAspectFill
self?.placeholderView.image = image
}
})
}
if component.videoEndpointId == nil || component.videoLoading || self.videoStalled {
updateVideoStalled(isStalled: true, transition: transition)
} else {
updateVideoStalled(isStalled: false, transition: transition)
}
if let videoEndpointId = component.videoEndpointId, self.videoView == nil {
if let input = component.call.video(endpointId: videoEndpointId) {
var _stallTimer: Foundation.Timer { Foundation.Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in
guard let strongSelf = self else { return timer.invalidate() }
let currentTime = CFAbsoluteTimeGetCurrent()
if let lastFrameTime = strongSelf.timeLastFrameReceived,
currentTime - lastFrameTime > 0.5 {
strongSelf.videoLoadingThrottler.publish(true, includingLatest: true) { isStalled in
strongSelf.videoStalled = isStalled
strongSelf.onVideoPlaybackChange(!isStalled)
}
}
} }
// TODO: use mapToThrottled (?)
frameInputDisposable = input.start(next: { [weak self] input in
guard let strongSelf = self else { return }
strongSelf.timeLastFrameReceived = CFAbsoluteTimeGetCurrent()
strongSelf.videoLoadingThrottler.publish(false, includingLatest: true) { isStalled in
strongSelf.videoStalled = isStalled
strongSelf.onVideoPlaybackChange(!isStalled)
}
})
stallTimer = _stallTimer
self.clipsToBounds = true
if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) {
self.videoView = videoView
self.addSubview(videoView)
videoView.alpha = 0
UIView.animate(withDuration: 0.3) {
videoView.alpha = 1
}
if component.enablePictureInPicture, let sampleBufferVideoView = videoView as? SampleBufferVideoRenderingView {
sampleBufferVideoView.sampleBufferLayer.masksToBounds = true
if #available(iOS 13.0, *) {
sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true
}
final class PlaybackDelegateImpl: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate {
var onTransitionFinished: (() -> Void)?
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) {
}
func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange {
return CMTimeRange(start: .zero, duration: .positiveInfinity)
}
func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
return false
}
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) {
onTransitionFinished?()
}
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) {
completionHandler()
}
public func pictureInPictureControllerShouldProhibitBackgroundAudioPlayback(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
return false
}
}
var pictureInPictureController: AVPictureInPictureController? = nil
if #available(iOS 15.0, *) {
pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: {
let delegate = PlaybackDelegateImpl()
delegate.onTransitionFinished = {
}
return delegate
}()))
pictureInPictureController?.playerLayer.masksToBounds = false
pictureInPictureController?.playerLayer.cornerRadius = 10
} else if AVPictureInPictureController.isPictureInPictureSupported() {
pictureInPictureController = AVPictureInPictureController.init(playerLayer: AVPlayerLayer(player: AVPlayer()))
}
pictureInPictureController?.delegate = self
if #available(iOS 14.2, *) {
pictureInPictureController?.canStartPictureInPictureAutomaticallyFromInline = true
}
if #available(iOS 14.0, *) {
pictureInPictureController?.requiresLinearPlayback = true
}
self.pictureInPictureController = pictureInPictureController
}
videoView.setOnOrientationUpdated { [weak state] _, _ in
state?.updated(transition: .immediate)
}
videoView.setOnFirstFrameReceived { [weak self, weak state] _ in
guard let strongSelf = self else {
return
}
strongSelf.hadVideo = true
strongSelf.noSignalTimer?.invalidate()
strongSelf.noSignalTimer = nil
strongSelf.noSignalTimeout = false
strongSelf.noSignalView?.removeFromSuperview()
strongSelf.noSignalView = nil
state?.updated(transition: .immediate)
}
}
if component.addInset, let videoView = self.videoView, let videoBlurView = self.videoRenderingContext.makeBlurView(input: input, mainView: videoView) {
self.videoBlurView = videoBlurView
self.insertSubview(videoBlurView, belowSubview: self.blurTintView)
videoBlurView.alpha = 0
UIView.animate(withDuration: 0.3) {
videoBlurView.alpha = 1
}
self.videoBlurGradientMask.type = .radial
self.videoBlurGradientMask.colors = [UIColor(rgb: 0x000000, alpha: 0.5).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor]
self.videoBlurGradientMask.startPoint = CGPoint(x: 0.5, y: 0.5)
self.videoBlurGradientMask.endPoint = CGPoint(x: 1.0, y: 1.0)
self.videoBlurSolidMask.backgroundColor = UIColor.black.cgColor
self.videoBlurGradientMask.addSublayer(videoBlurSolidMask)
}
}
} else if component.isFullscreen {
if fullScreenBackgroundPlaceholder.superview == nil {
insertSubview(fullScreenBackgroundPlaceholder, at: 0)
transition.setAlpha(view: self.fullScreenBackgroundPlaceholder, alpha: 1)
}
fullScreenBackgroundPlaceholder.backgroundColor = UIColor.black.withAlphaComponent(0.5)
} else {
transition.setAlpha(view: self.fullScreenBackgroundPlaceholder, alpha: 0, completion: { didComplete in
if didComplete {
self.fullScreenBackgroundPlaceholder.removeFromSuperview()
}
})
}
fullScreenBackgroundPlaceholder.frame = .init(origin: .zero, size: availableSize)
let videoInset: CGFloat
if component.addInset && !component.isFullscreen {
videoInset = 16
} else {
videoInset = 0
}
let videoSize: CGSize
let videoCornerRadius: CGFloat = (component.isFullscreen || !component.addInset) ? 0 : 10
let videoFrameUpdateTransition: ComponentTransition
if self.wasFullscreen != component.isFullscreen {
videoFrameUpdateTransition = transition
} else {
videoFrameUpdateTransition = transition.withAnimation(.none)
}
if let videoView = self.videoView {
if let peerId = component.call.peerId, videoView.bounds.size.width > 0,
videoView.alpha > 0,
self.hadVideo,
let snapshot = videoView.snapshotView(afterScreenUpdates: false) ?? videoView.snapshotView(afterScreenUpdates: true) {
lastFrame[peerId.id.description] = snapshot
}
var aspect = videoView.getAspect()
if component.isFullscreen && self.hadVideo {
if aspect <= 0.01 {
aspect = 16.0 / 9
}
} else if !self.hadVideo {
aspect = 16.0 / 9
}
if component.isFullscreen || !component.addInset {
videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height))
} else {
// Limiting by smallest side -- redundant if passing precalculated availableSize
let availableVideoWidth = min(availableSize.width, availableSize.height) - videoInset * 2
let availableVideoHeight = availableVideoWidth * 9.0 / 16
videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableVideoWidth, height: availableVideoHeight))
}
let blurredVideoSize = component.isFullscreen ? availableSize : videoSize.aspectFilled(availableSize)
component.onVideoSizeRetrieved(videoSize)
var isVideoVisible = component.isVisible
if !self.wasVisible && component.isVisible {
videoView.layer.animateAlpha(from: 0, to: 1, duration: 0.2)
} else if self.wasVisible && !component.isVisible {
videoView.layer.animateAlpha(from: 1, to: 0, duration: 0.2)
}
if let pictureInPictureController = self.pictureInPictureController {
if pictureInPictureController.isPictureInPictureActive {
isVideoVisible = true
}
}
videoView.updateIsEnabled(isVideoVisible)
videoView.clipsToBounds = true
videoView.layer.cornerRadius = videoCornerRadius
self.wasFullscreen = component.isFullscreen
let newVideoFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize)
videoFrameUpdateTransition.setFrame(view: videoView, frame: newVideoFrame, completion: nil)
if let videoBlurView = self.videoBlurView {
videoBlurView.updateIsEnabled(component.isVisible)
if component.isFullscreen {
videoFrameUpdateTransition.setFrame(view: videoBlurView, frame: CGRect(
origin: CGPoint(x: floor((availableSize.width - blurredVideoSize.width) / 2.0), y: floor((availableSize.height - blurredVideoSize.height) / 2.0)),
size: blurredVideoSize
), completion: nil)
} else {
videoFrameUpdateTransition.setFrame(view: videoBlurView, frame: videoView.frame.insetBy(dx: -70.0 * aspect, dy: -70.0))
}
videoBlurView.layer.mask = videoBlurGradientMask
if !component.isFullscreen {
transition.setAlpha(layer: videoBlurSolidMask, alpha: 0)
} else {
transition.setAlpha(layer: videoBlurSolidMask, alpha: 1)
}
videoFrameUpdateTransition.setFrame(layer: self.videoBlurGradientMask, frame: videoBlurView.bounds)
videoFrameUpdateTransition.setFrame(layer: self.videoBlurSolidMask, frame: self.videoBlurGradientMask.bounds)
}
if component.call.accountContext.sharedContext.immediateExperimentalUISettings.liveStreamV2 && self.livestreamVideoView == nil {
let livestreamVideoView = LivestreamVideoViewV1(context: component.call.accountContext, audioSessionManager: component.call.accountContext.sharedContext.mediaManager.audioSession, call: component.call)
self.livestreamVideoView = livestreamVideoView
livestreamVideoView.layer.masksToBounds = true
self.addSubview(livestreamVideoView)
livestreamVideoView.frame = newVideoFrame
livestreamVideoView.layer.cornerRadius = videoCornerRadius
livestreamVideoView.update(size: newVideoFrame.size, transition: .immediate)
/*var pictureInPictureController: AVPictureInPictureController? = nil
if #available(iOS 15.0, *) {
pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(playerLayer: livePlayerView.playerLayer))
pictureInPictureController?.playerLayer.masksToBounds = false
pictureInPictureController?.playerLayer.cornerRadius = 10
} else if AVPictureInPictureController.isPictureInPictureSupported() {
pictureInPictureController = AVPictureInPictureController.init(playerLayer: AVPlayerLayer(player: AVPlayer()))
}
pictureInPictureController?.delegate = self
if #available(iOS 14.2, *) {
pictureInPictureController?.canStartPictureInPictureAutomaticallyFromInline = true
}
if #available(iOS 14.0, *) {
pictureInPictureController?.requiresLinearPlayback = true
}
self.pictureInPictureController = pictureInPictureController*/
}
if let livestreamVideoView = self.livestreamVideoView {
videoFrameUpdateTransition.setFrame(view: livestreamVideoView, frame: newVideoFrame, completion: nil)
videoFrameUpdateTransition.setCornerRadius(layer: livestreamVideoView.layer, cornerRadius: videoCornerRadius)
livestreamVideoView.update(size: newVideoFrame.size, transition: transition.containedViewLayoutTransition)
videoView.isHidden = true
}
} else {
let availableVideoWidth = min(availableSize.width, availableSize.height) - videoInset * 2
let availableVideoHeight = availableVideoWidth * 9.0 / 16
videoSize = CGSize(width: 16 / 9 * 100.0, height: 100.0).aspectFitted(.init(width: availableVideoWidth, height: availableVideoHeight))
}
let loadingBlurViewFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize)
if self.loadingBlurView.frame == .zero {
self.loadingBlurView.frame = loadingBlurViewFrame
} else {
// Using ComponentTransition.setFrame on UIVisualEffectView causes instant update of sublayers
switch videoFrameUpdateTransition.animation {
case let .curve(duration, curve):
UIView.animate(withDuration: duration, delay: 0, options: curve.containedViewLayoutTransitionCurve.viewAnimationOptions, animations: { [weak self] in
guard let self else {
return
}
self.loadingBlurView.frame = loadingBlurViewFrame
})
default:
self.loadingBlurView.frame = loadingBlurViewFrame
}
}
videoFrameUpdateTransition.setCornerRadius(layer: self.loadingBlurView.layer, cornerRadius: videoCornerRadius)
videoFrameUpdateTransition.setFrame(view: self.placeholderView, frame: loadingBlurViewFrame)
videoFrameUpdateTransition.setCornerRadius(layer: self.placeholderView.layer, cornerRadius: videoCornerRadius)
self.placeholderView.clipsToBounds = true
self.placeholderView.subviews.forEach {
videoFrameUpdateTransition.setFrame(view: $0, frame: self.placeholderView.bounds)
}
let initialShimmerBounds = self.shimmerBorderLayer.bounds
videoFrameUpdateTransition.setFrame(layer: self.shimmerBorderLayer, frame: loadingBlurView.bounds)
let borderMask = CAShapeLayer()
let initialPath = CGPath(roundedRect: .init(x: 0, y: 0, width: initialShimmerBounds.width, height: initialShimmerBounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil)
borderMask.path = initialPath
videoFrameUpdateTransition.setShapeLayerPath(layer: borderMask, path: CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil))
borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor
borderMask.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor
borderMask.lineWidth = 3
self.shimmerBorderLayer.mask = borderMask
self.shimmerBorderLayer.cornerRadius = videoCornerRadius
if !self.hadVideo && !component.call.accountContext.sharedContext.immediateExperimentalUISettings.liveStreamV2 {
if self.noSignalTimer == nil {
let noSignalTimer = Timer(timeInterval: 20.0, repeats: false, block: { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.noSignalTimeout = true
strongSelf.state?.updated(transition: .immediate)
})
self.noSignalTimer = noSignalTimer
RunLoop.main.add(noSignalTimer, forMode: .common)
}
if self.noSignalTimeout, !"".isEmpty {
var noSignalTransition = transition
let noSignalView: ComponentHostView<Empty>
if let current = self.noSignalView {
noSignalView = current
} else {
noSignalTransition = transition.withAnimation(.none)
noSignalView = ComponentHostView<Empty>()
self.noSignalView = noSignalView
self.addSubview(noSignalView)
noSignalView.layer.zPosition = loadingBlurView.layer.zPosition + 1
noSignalView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
}
let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with { $0 }
let noSignalSize = noSignalView.update(
transition: transition,
component: AnyComponent(MultilineTextComponent(
text: .plain(NSAttributedString(string: component.isAdmin ? presentationData.strings.LiveStream_NoSignalAdminText : presentationData.strings.LiveStream_NoSignalUserText(component.peerTitle).string, font: Font.regular(16.0), textColor: .white, paragraphAlignment: .center)),
horizontalAlignment: .center,
maximumNumberOfLines: 0
)),
environment: {},
containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 1000.0)
)
noSignalTransition.setFrame(view: noSignalView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - noSignalSize.width) / 2.0), y: (availableSize.height - noSignalSize.height) / 2.0), size: noSignalSize), completion: nil)
}
}
self.component = component
component.activatePictureInPicture.connect { [weak self] completion in
guard let strongSelf = self, let pictureInPictureController = strongSelf.pictureInPictureController else {
return
}
pictureInPictureController.startPictureInPicture()
completion(Void())
}
component.deactivatePictureInPicture.connect { [weak self] _ in
guard let strongSelf = self else {
return
}
strongSelf.expandFromPictureInPicture()
}
return availableSize
}
public func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
if let videoView = self.videoView, let presentation = videoView.snapshotView(afterScreenUpdates: false) {
let presentationParent = self.window ?? self
presentationParent.addSubview(presentation)
presentation.frame = presentationParent.convert(videoView.frame, from: self)
if let callId = self.component?.call.peerId?.id.description {
lastFrame[callId] = presentation
}
videoView.alpha = 0
lastPresentation?.removeFromSuperview()
lastPresentation = presentation
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
self.lastPresentation?.removeFromSuperview()
self.lastPresentation = nil
self.pipTrackDisplayLink?.invalidate()
self.pipTrackDisplayLink = nil
}
}
UIView.animate(withDuration: 0.1) { [self] in
videoBlurView?.alpha = 0
}
// TODO: assure player window
UIApplication.shared.windows.first?.layer.cornerRadius = 10.0
UIApplication.shared.windows.first?.layer.masksToBounds = true
self.pipTrackDisplayLink?.invalidate()
self.pipTrackDisplayLink = CADisplayLink(target: self, selector: #selector(observePiPWindow))
self.pipTrackDisplayLink?.add(to: .main, forMode: .default)
}
@objc func observePiPWindow() {
let pipViewDidBecomeVisible = (UIApplication.shared.windows.first?.layer.animationKeys()?.count ?? 0) > 0
if pipViewDidBecomeVisible {
lastPresentation?.removeFromSuperview()
lastPresentation = nil
self.pipTrackDisplayLink?.invalidate()
self.pipTrackDisplayLink = nil
}
}
public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
guard let component = self.component else {
completionHandler(false)
return
}
didRequestBringBack = true
component.bringBackControllerForPictureInPictureDeactivation {
completionHandler(true)
}
}
public func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
self.didRequestBringBack = false
self.state?.updated(transition: .immediate)
}
public func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
if self.requestedExpansion {
self.requestedExpansion = false
} else if !didRequestBringBack {
self.component?.pictureInPictureClosed()
}
didRequestBringBack = false
// TODO: extract precise animation timing or observe window changes
// Handle minimized case separatelly (can we detect minimized?)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) {
self.videoView?.alpha = 1
}
UIView.animate(withDuration: 0.3) { [self] in
self.videoBlurView?.alpha = 1
}
}
public func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
self.videoView?.alpha = 1
self.state?.updated(transition: .immediate)
}
}
public func makeView() -> View {
return View(frame: CGRect())
}
public func update(view: View, availableSize: CGSize, state: State, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, transition: transition)
}
}
// TODO: move to appropriate place
fileprivate var lastFrame: [String: UIView] = [:]
private final class CustomIntensityVisualEffectView: UIVisualEffectView {
private var animator: UIViewPropertyAnimator!
init(effect: UIVisualEffect, intensity: CGFloat) {
super.init(effect: nil)
animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [weak self] in self?.effect = effect }
animator.startAnimation()
animator.pauseAnimation()
animator.fractionComplete = intensity
animator.pausesOnCompletion = true
}
required init?(coder aDecoder: NSCoder) {
fatalError()
}
deinit {
animator.stopAnimation(true)
}
}
@@ -0,0 +1,513 @@
import Foundation
import Display
import UIKit
import ComponentFlow
import SwiftSignalKit
import Postbox
import TelegramCore
import AvatarNode
import GlassBackgroundComponent
import MultilineTextComponent
import MultilineTextWithEntitiesComponent
import AccountContext
import TextFormat
import TelegramPresentationData
import ReactionSelectionNode
import BundleIconComponent
import LottieComponent
import Markdown
private let glassColor = UIColor(rgb: 0x25272e, alpha: 0.72)
final class MessageItemComponent: Component {
public enum Icon: Equatable {
case peer(EnginePeer)
case icon(String)
case animation(String)
}
public enum Style {
case generic
case notification
case status
}
private let context: AccountContext
private let icon: Icon
private let style: Style
private let text: String
private let entities: [MessageTextEntity]
private let availableReactions: [ReactionItem]?
private let openPeer: ((EnginePeer) -> Void)?
init(
context: AccountContext,
icon: Icon,
style: Style,
text: String,
entities: [MessageTextEntity],
availableReactions: [ReactionItem]?,
openPeer: ((EnginePeer) -> Void)?
) {
self.context = context
self.icon = icon
self.style = style
self.text = text
self.entities = entities
self.availableReactions = availableReactions
self.openPeer = openPeer
}
static func == (lhs: MessageItemComponent, rhs: MessageItemComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.icon != rhs.icon {
return false
}
if lhs.style != rhs.style {
return false
}
if lhs.text != rhs.text {
return false
}
if lhs.entities != rhs.entities {
return false
}
if (lhs.availableReactions ?? []).isEmpty != (rhs.availableReactions ?? []).isEmpty {
return false
}
return true
}
final class View: UIView {
private let container: UIView
private let background: GlassBackgroundView
private let avatarNode: AvatarNode
private let icon: ComponentView<Empty>
private let text: ComponentView<Empty>
weak var standaloneReactionAnimation: StandaloneReactionAnimation?
private var cachedEntities: [MessageTextEntity]?
private var entityFiles: [MediaId: TelegramMediaFile] = [:]
private var component: MessageItemComponent?
override init(frame: CGRect) {
self.container = UIView()
self.container.transform = CGAffineTransform(scaleX: 1.0, y: -1.0)
self.background = GlassBackgroundView()
self.avatarNode = AvatarNode(font: avatarPlaceholderFont(size: 12.0))
self.avatarNode.hitTestSlop = UIEdgeInsets(top: -5.0, left: -5.0, bottom: -5.0, right: -8.0)
self.icon = ComponentView()
self.text = ComponentView()
super.init(frame: frame)
self.addSubview(self.container)
self.container.addSubview(self.background)
self.container.addSubview(self.avatarNode.view)
self.avatarNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.avatarTapped)))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
@objc private func avatarTapped() {
guard let component = self.component, case let .peer(peer) = component.icon else {
return
}
component.openPeer?(peer)
}
func animateFrom(globalFrame: CGRect, cornerRadius: CGFloat, textSnapshotView: UIView, transition: ComponentTransition) {
guard let superview = self.superview?.superview?.superview else {
return
}
let originalCenter = self.container.center
let originalTransform = self.container.transform
let superviewCenter = self.convert(self.container.center, to: superview)
self.container.center = superviewCenter
self.container.transform = .identity
superview.addSubview(self.container)
let hasRTL = (self.text.view as? MultilineTextWithEntitiesComponent.View)?.hasRTL ?? false
let direction: CGFloat = hasRTL ? -1.0 : 1.0
let initialSize = self.background.frame.size
self.container.addSubview(textSnapshotView)
transition.setAlpha(view: textSnapshotView, alpha: 0.0, completion: { _ in
textSnapshotView.removeFromSuperview()
})
let additionalOffset = hasRTL ? globalFrame.size.width - initialSize.width : 0.0
transition.setPosition(view: textSnapshotView, position: CGPoint(x: textSnapshotView.center.x + 71.0 * direction - additionalOffset, y: textSnapshotView.center.y))
self.background.update(size: globalFrame.size, cornerRadius: cornerRadius, isDark: true, tintColor: .init(kind: .custom, color: glassColor), transition: .immediate)
self.background.update(size: initialSize, cornerRadius: 18.0, isDark: true, tintColor: .init(kind: .custom, color: glassColor), transition: transition)
let deltaX = (globalFrame.width - self.container.frame.width) / 2.0
let deltaY = (globalFrame.height - self.container.frame.height) / 2.0
let fromFrame = superview.convert(globalFrame, from: nil).offsetBy(dx: -deltaX, dy: -deltaY)
self.container.center = fromFrame.center
transition.setPosition(view: self.container, position: superviewCenter, completion: { _ in
self.container.center = originalCenter
self.container.transform = originalTransform
self.insertSubview(self.container, at: 0)
})
if let textView = self.text.view {
transition.animatePosition(view: textView, from: CGPoint(x: -71.0 * direction, y: 0.0), to: .zero, additive: true)
transition.animateAlpha(view: textView, from: 0.0, to: 1.0)
}
transition.animateAlpha(view: self.avatarNode.view, from: 0.0, to: 1.0)
transition.animateScale(view: self.avatarNode.view, from: 0.01, to: 1.0)
}
func update(component: MessageItemComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
let isFirstTime = self.component == nil
var transition = transition
if isFirstTime {
transition = .immediate
}
self.component = component
let theme = defaultDarkPresentationTheme
var fontSize: CGFloat = 14.0
let minimalHeight: CGFloat
let avatarSpacing: CGFloat
let avatarInset: CGFloat
let avatarSize: CGSize
let rightInset: CGFloat
var maximumNumberOfLines: Int = 0
var hasBackground = true
var textVerticalMargin: CGFloat = 15.0
switch component.style {
case .generic:
minimalHeight = 36.0
avatarSpacing = 10.0
avatarInset = 4.0
avatarSize = CGSize(width: 28.0, height: 28.0)
rightInset = 13.0
case .notification:
minimalHeight = 50.0
avatarSpacing = 10.0
avatarInset = 10.0
avatarSize = CGSize(width: 30.0, height: 30.0)
rightInset = 15.0
case .status:
minimalHeight = 24.0
avatarSpacing = 4.0
avatarInset = 4.0
avatarSize = CGSize(width: 16.0, height: 16.0)
rightInset = 0.0
maximumNumberOfLines = 1
hasBackground = false
fontSize = 13.0
textVerticalMargin = 0.0
}
let textFont = Font.regular(fontSize)
let boldTextFont = Font.semibold(fontSize)
let italicFont = Font.italic(fontSize)
let boldItalicTextFont = Font.semiboldItalic(fontSize)
let monospaceFont = Font.monospace(fontSize)
let textColor: UIColor = .white
let linkColor: UIColor = component.style == .status ? textColor : UIColor(rgb: 0x59b6fa)
let cornerRadius = minimalHeight * 0.5
let iconSpacing: CGFloat = 10.0
var peerName = ""
if case let .peer(peer) = component.icon {
if case .notification = component.style {
} else {
peerName = peer.compactDisplayTitle
if peerName.count > 40 {
peerName = "\(peerName.prefix(40))"
}
}
}
let text = component.text
var entities = component.entities
if let cachedEntities = self.cachedEntities {
entities = cachedEntities
} else if let availableReactions = component.availableReactions, text.count == 1 {
let emoji = component.text.strippedEmoji
var reactionItem: ReactionItem?
for item in availableReactions {
if case .builtin(emoji) = item.reaction.rawValue {
reactionItem = item
break
}
}
if case .builtin = reactionItem?.reaction.rawValue, let item = component.context.animatedEmojiStickersValue[emoji]?.first {
self.entityFiles[item.file.fileId] = item.file._parse()
entities.insert(MessageTextEntity(range: 0 ..< (text as NSString).length, type: .CustomEmoji(stickerPack: nil, fileId: item.file.fileId.id)), at: 0)
self.cachedEntities = entities
}
} else {
entities = entities.filter { entity in
switch entity.type {
case .Bold, .Italic, .Strikethrough, .Underline, .Spoiler:
return true
case .CustomEmoji:
if case let .peer(peer) = component.icon, peer.isPremium {
return true
}
return false
default:
return false
}
}
self.cachedEntities = entities
}
let attributedText: NSAttributedString
if case .notification = component.style {
attributedText = parseMarkdownIntoAttributedString(
text,
attributes: MarkdownAttributes(
body: MarkdownAttributeSet(font: textFont, textColor: textColor),
bold: MarkdownAttributeSet(font: boldTextFont, textColor: textColor),
link: MarkdownAttributeSet(font: textFont, textColor: linkColor),
linkAttribute: { _ in return nil }
)
)
} else {
let textWithAppliedEntities = stringWithAppliedEntities(text, entities: entities, baseColor: textColor, linkColor: linkColor, baseFont: textFont, linkFont: textFont, boldFont: boldTextFont, italicFont: italicFont, boldItalicFont: boldItalicTextFont, fixedFont: monospaceFont, blockQuoteFont: textFont, message: nil, entityFiles: self.entityFiles).mutableCopy() as! NSMutableAttributedString
if !peerName.isEmpty {
textWithAppliedEntities.insert(NSAttributedString(string: "\u{2066}\(peerName)\u{2069} ", font: boldTextFont, textColor: textColor), at: 0)
}
attributedText = textWithAppliedEntities
}
let spacing: CGFloat
switch component.icon {
case .peer:
spacing = avatarSpacing
case .icon:
spacing = iconSpacing
case .animation:
spacing = iconSpacing
}
let textSize = self.text.update(
transition: transition,
component: AnyComponent(MultilineTextWithEntitiesComponent(
context: component.context,
animationCache: component.context.animationCache,
animationRenderer: component.context.animationRenderer,
placeholderColor: UIColor(rgb: 0xffffff, alpha: 0.3),
text: .plain(attributedText),
maximumNumberOfLines: maximumNumberOfLines,
lineSpacing: 0.1,
spoilerColor: .white,
handleSpoilers: true
)),
environment: {},
containerSize: CGSize(width: availableSize.width - avatarInset - avatarSize.width - spacing - rightInset, height: .greatestFiniteMagnitude)
)
let hasRTL = (self.text.view as? MultilineTextWithEntitiesComponent.View)?.hasRTL ?? false
let size = CGSize(
width: avatarInset + avatarSize.width + spacing + textSize.width + rightInset,
height: max(minimalHeight, textSize.height + textVerticalMargin)
)
switch component.icon {
case let .peer(peer):
if peer.smallProfileImage != nil {
self.avatarNode.setPeerV2(
context: component.context,
theme: theme,
peer: peer,
authorOfMessage: nil,
overrideImage: nil,
emptyColor: nil,
clipStyle: .round,
synchronousLoad: true,
displayDimensions: avatarSize
)
} else {
self.avatarNode.setPeer(
context: component.context,
theme: theme,
peer: peer,
clipStyle: .round,
synchronousLoad: true,
displayDimensions: avatarSize
)
}
let avatarFrame = CGRect(origin: CGPoint(x: avatarInset, y: avatarInset), size: avatarSize)
if self.avatarNode.bounds.isEmpty {
self.avatarNode.frame = mappedFrame(avatarFrame, containerSize: size, hasRTL: hasRTL)
} else {
transition.setFrame(view: self.avatarNode.view, frame: mappedFrame(avatarFrame, containerSize: size, hasRTL: hasRTL))
}
self.avatarNode.isHidden = false
case let .icon(iconName):
let iconSize = self.icon.update(
transition: transition,
component: AnyComponent(BundleIconComponent(name: iconName, tintColor: .white)),
environment: {},
containerSize: CGSize(width: 44.0, height: 44.0)
)
let iconFrame = CGRect(
origin: CGPoint(
x: avatarInset,
y: floorToScreenPixels((size.height - iconSize.height) / 2.0)
),
size: iconSize
)
if let iconView = self.icon.view {
if iconView.superview == nil {
self.container.addSubview(iconView)
}
transition.setFrame(view: iconView, frame: mappedFrame(iconFrame, containerSize: size, hasRTL: hasRTL))
}
self.avatarNode.isHidden = true
case let .animation(animationName):
let iconSize = self.icon.update(
transition: transition,
component: AnyComponent(LottieComponent(
content: LottieComponent.AppBundleContent(
name: animationName
),
placeholderColor: nil,
startingPosition: .end,
size: CGSize(width: 40.0, height: 40.0),
loop: false
)),
environment: {},
containerSize: CGSize(width: 40.0, height: 40.0)
)
let iconFrame = CGRect(
origin: CGPoint(
x: avatarInset - 3.0,
y: floorToScreenPixels((size.height - iconSize.height) / 2.0)
),
size: iconSize
)
if let iconView = self.icon.view as? LottieComponent.View {
if iconView.superview == nil {
self.container.addSubview(iconView)
iconView.playOnce()
}
transition.setFrame(view: iconView, frame: mappedFrame(iconFrame, containerSize: size, hasRTL: hasRTL))
}
self.avatarNode.isHidden = true
}
func mappedFrame(_ frame: CGRect, containerSize: CGSize, hasRTL: Bool) -> CGRect {
return CGRect(
origin: CGPoint(x: hasRTL ? containerSize.width - frame.minX - frame.width : frame.minX, y: frame.origin.y),
size: frame.size
)
}
let textFrame = CGRect(
origin: CGPoint(
x: avatarInset + avatarSize.width + spacing,
y: floorToScreenPixels((size.height - textSize.height) / 2.0)
),
size: textSize
)
if let textView = self.text.view {
if textView.superview == nil {
self.container.addSubview(textView)
}
transition.setFrame(view: textView, frame: mappedFrame(textFrame, containerSize: size, hasRTL: hasRTL))
}
transition.setFrame(view: self.container, frame: CGRect(origin: CGPoint(), size: size))
self.background.isHidden = !hasBackground
self.background.update(size: size, cornerRadius: cornerRadius, isDark: true, tintColor: .init(kind: .custom, color: glassColor), transition: transition)
transition.setFrame(view: self.background, frame: CGRect(origin: CGPoint(), size: size))
if isFirstTime, let availableReactions = component.availableReactions, let textView = self.text.view {
var reactionItem: ReactionItem?
for item in availableReactions {
if case .builtin(component.text.strippedEmoji) = item.reaction.rawValue {
reactionItem = item
break
}
}
if let reactionItem {
Queue.mainQueue().justDispatch {
guard let listView = self.superview else {
return
}
let emojiTargetView = UIView(frame: CGRect(origin: CGPoint(x: textView.frame.width - 32.0, y: -17.0), size: CGSize(width: 44.0, height: 44.0)))
emojiTargetView.isUserInteractionEnabled = false
textView.addSubview(emojiTargetView)
let standaloneReactionAnimation = StandaloneReactionAnimation(genericReactionEffect: nil, useDirectRendering: false)
self.container.addSubview(standaloneReactionAnimation.view)
if let standaloneReactionAnimation = self.standaloneReactionAnimation {
self.standaloneReactionAnimation = nil
standaloneReactionAnimation.view.removeFromSuperview()
}
self.standaloneReactionAnimation = standaloneReactionAnimation
standaloneReactionAnimation.frame = listView.bounds
standaloneReactionAnimation.animateReactionSelection(
context: component.context,
theme: theme,
animationCache: component.context.animationCache,
reaction: reactionItem,
avatarPeers: [],
playHaptic: false,
isLarge: false,
hideCenterAnimation: true,
targetView: emojiTargetView,
addStandaloneReactionAnimation: { [weak self] standaloneReactionAnimation in
guard let self else {
return
}
if let standaloneReactionAnimation = self.standaloneReactionAnimation {
self.standaloneReactionAnimation = nil
standaloneReactionAnimation.view.removeFromSuperview()
}
self.standaloneReactionAnimation = standaloneReactionAnimation
standaloneReactionAnimation.frame = self.bounds
listView.addSubview(standaloneReactionAnimation.view)
},
completion: { [weak standaloneReactionAnimation] in
standaloneReactionAnimation?.view.removeFromSuperview()
}
)
}
}
}
return size
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,277 @@
import Foundation
import Display
import UIKit
import ComponentFlow
import SwiftSignalKit
import TelegramCore
import AccountContext
import ReactionSelectionNode
final class MessageListComponent: Component {
struct Item: Equatable {
let id: AnyHashable
let icon: MessageItemComponent.Icon
let isNotification: Bool
let text: String
let entities: [MessageTextEntity]
}
class SendActionTransition {
public let randomId: Int64
public let textSnapshotView: UIView
public let globalFrame: CGRect
public let cornerRadius: CGFloat
init(randomId: Int64, textSnapshotView: UIView, globalFrame: CGRect, cornerRadius: CGFloat) {
self.randomId = randomId
self.textSnapshotView = textSnapshotView
self.globalFrame = globalFrame
self.cornerRadius = cornerRadius
}
}
private let context: AccountContext
private let items: [Item]
private let availableReactions: [ReactionItem]?
private let sendActionTransition: SendActionTransition?
private let openPeer: (EnginePeer) -> Void
init(
context: AccountContext,
items: [Item],
availableReactions: [ReactionItem]?,
sendActionTransition: SendActionTransition?,
openPeer: @escaping (EnginePeer) -> Void
) {
self.context = context
self.items = items
self.availableReactions = availableReactions
self.sendActionTransition = sendActionTransition
self.openPeer = openPeer
}
static func == (lhs: MessageListComponent, rhs: MessageListComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.items != rhs.items {
return false
}
if (lhs.availableReactions ?? []).isEmpty != (rhs.availableReactions ?? []).isEmpty {
return false
}
if lhs.sendActionTransition !== rhs.sendActionTransition {
return false
}
return true
}
private final class ScrollView: UIScrollView {
override func touchesShouldCancel(in view: UIView) -> Bool {
return true
}
}
final class View: UIView, UIScrollViewDelegate {
private let scrollView: ScrollView
private var component: MessageListComponent?
private weak var state: EmptyComponentState?
private var isUpdating = false
private var nextSendActionTransition: MessageListComponent.SendActionTransition?
private var itemViews: [AnyHashable: ComponentView<Empty>] = [:]
private let topInset: CGFloat = 8.0
private let bottomInset: CGFloat = 8.0
private let itemSpacing: CGFloat = 6.0
private var ignoreScrolling: Bool = false
override init(frame: CGRect) {
self.scrollView = ScrollView()
super.init(frame: frame)
self.scrollView.delaysContentTouches = false
self.scrollView.canCancelContentTouches = true
self.scrollView.contentInsetAdjustmentBehavior = .never
if #available(iOS 13.0, *) {
self.scrollView.automaticallyAdjustsScrollIndicatorInsets = false
}
self.scrollView.showsVerticalScrollIndicator = false
self.scrollView.showsHorizontalScrollIndicator = false
self.scrollView.alwaysBounceHorizontal = false
self.scrollView.alwaysBounceVertical = true
self.scrollView.scrollsToTop = false
self.scrollView.delegate = self
self.scrollView.clipsToBounds = false
self.scrollView.transform = CGAffineTransform(scaleX: 1.0, y: -1.0)
self.addSubview(self.scrollView)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func scrollViewDidScroll(_ scrollView: UIScrollView) {
}
private func isAtBottom(tolerance: CGFloat = 1.0) -> Bool {
let bottomY = -self.scrollView.adjustedContentInset.top
return self.scrollView.contentOffset.y <= bottomY + tolerance
}
private func scrollToBottom(animated: Bool) {
let targetY = -self.scrollView.adjustedContentInset.top
if animated {
self.scrollView.setContentOffset(CGPoint(x: 0, y: targetY), animated: true)
} else {
self.scrollView.contentOffset = CGPoint(x: 0, y: targetY)
}
}
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
for (_, itemView) in self.itemViews {
if let view = itemView.view, view.point(inside: self.convert(point, to: view), with: event) {
return true
}
}
return false
}
func update(component: MessageListComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
self.component = component
self.state = state
if let _ = component.sendActionTransition {
self.nextSendActionTransition = component.sendActionTransition
}
let originalTransition = transition
transition.setFrame(view: self.scrollView, frame: CGRect(origin: .zero, size: availableSize))
let previousContentHeight = self.scrollView.contentSize.height
let wasAtBottom = self.isAtBottom(tolerance: 1.0)
let maxWidth: CGFloat = min(availableSize.width - 16.0, 330.0)
var measured: [(id: AnyHashable, size: CGSize, item: MessageListComponent.Item, itemTransition: ComponentTransition)] = []
measured.reserveCapacity(component.items.count)
for item in component.items {
var itemTransition = transition
let key = item.id
let container = self.itemViews[key] ?? {
itemTransition = .immediate
let v = ComponentView<Empty>()
self.itemViews[key] = v
return v
}()
let size = container.update(
transition: transition,
component: AnyComponent(MessageItemComponent(
context: component.context,
icon: item.icon,
style: item.isNotification ? .notification : .generic,
text: item.text,
entities: item.entities,
availableReactions: component.availableReactions,
openPeer: component.openPeer
)),
environment: {},
containerSize: CGSize(width: maxWidth, height: .greatestFiniteMagnitude)
)
measured.append((id: key, size: size, item: item, itemTransition: itemTransition))
}
let itemsHeight: CGFloat = measured.reduce(0) { $0 + $1.size.height } +
CGFloat(max(0, measured.count - 1)) * self.itemSpacing
let contentHeight = self.topInset + itemsHeight + self.bottomInset
var y = self.bottomInset
var validKeys = Set<AnyHashable>()
for (index, entry) in measured.enumerated() {
validKeys.insert(entry.id)
if let itemView = self.itemViews[entry.id]?.view {
var customAnimation = false
if let nextSendActionTransition = self.nextSendActionTransition, entry.id == AnyHashable(nextSendActionTransition.randomId) {
customAnimation = true
}
let itemFrame = CGRect(
origin: CGPoint(x: floor((availableSize.width - entry.size.width) / 2.0), y: y),
size: entry.size
)
if itemView.superview == nil {
if !originalTransition.animation.isImmediate && !customAnimation {
originalTransition.animateAlpha(view: itemView, from: 0.0, to: 1.0)
originalTransition.animateScale(view: itemView, from: 0.01, to: 1.0)
}
if customAnimation, let nextSendActionTransition = self.nextSendActionTransition {
self.nextSendActionTransition = nil
itemView.frame = itemFrame
if let itemView = itemView as? MessageItemComponent.View {
itemView.isHidden = true
Queue.mainQueue().justDispatch {
itemView.animateFrom(globalFrame: nextSendActionTransition.globalFrame, cornerRadius: nextSendActionTransition.cornerRadius, textSnapshotView: nextSendActionTransition.textSnapshotView, transition: originalTransition)
itemView.isHidden = false
}
}
}
self.scrollView.addSubview(itemView)
}
entry.itemTransition.setFrame(view: itemView, frame: itemFrame)
}
y += entry.size.height
if index != measured.count - 1 { y += self.itemSpacing }
}
let finalContentHeight = max(availableSize.height, contentHeight)
self.scrollView.contentSize = CGSize(width: availableSize.width, height: finalContentHeight)
let delta = self.scrollView.contentSize.height - previousContentHeight
if !wasAtBottom && abs(delta) > .ulpOfOne {
self.scrollView.contentOffset.y += delta
} else if wasAtBottom {
self.scrollToBottom(animated: false)
}
if self.itemViews.count > validKeys.count {
let toRemove = self.itemViews.keys.filter { !validKeys.contains($0) }
for key in toRemove {
if let itemView = self.itemViews[key]?.view {
if transition.animation.isImmediate {
itemView.removeFromSuperview()
} else {
transition.setAlpha(view: itemView, alpha: 0.0, completion: { _ in
itemView.removeFromSuperview()
})
transition.setScale(view: itemView, scale: 0.01)
}
}
self.itemViews.removeValue(forKey: key)
}
}
if wasAtBottom {
self.scrollToBottom(animated: false)
}
return availableSize
}
}
func makeView() -> View {
return View()
}
func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<EnvironmentType>, transition: ComponentTransition) -> CGSize {
return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition)
}
}
@@ -0,0 +1,82 @@
import Foundation
import Display
import UIKit
import ComponentFlow
import TelegramPresentationData
import TelegramStringFormatting
private let purple = UIColor(rgb: 0x3252ef)
private let pink = UIColor(rgb: 0xe4436c)
final class ParticipantsComponent: Component {
private let strings: PresentationStrings
private let count: Int
private let showsSubtitle: Bool
private let fontSize: CGFloat
private let gradientColors: [CGColor]
init(strings: PresentationStrings, count: Int, showsSubtitle: Bool = true, fontSize: CGFloat = 48.0, gradientColors: [CGColor] = [pink.cgColor, purple.cgColor, purple.cgColor]) {
self.strings = strings
self.count = count
self.showsSubtitle = showsSubtitle
self.fontSize = fontSize
self.gradientColors = gradientColors
}
static func == (lhs: ParticipantsComponent, rhs: ParticipantsComponent) -> Bool {
if lhs.strings !== rhs.strings {
return false
}
if lhs.count != rhs.count {
return false
}
if lhs.showsSubtitle != rhs.showsSubtitle {
return false
}
if lhs.fontSize != rhs.fontSize {
return false
}
return true
}
func makeView() -> View {
View(frame: .zero)
}
func update(view: View, availableSize: CGSize, state: ComponentFlow.EmptyComponentState, environment: ComponentFlow.Environment<ComponentFlow.Empty>, transition: ComponentFlow.ComponentTransition) -> CGSize {
view.counter.update(
countString: self.count > 0 ? presentationStringsFormattedNumber(Int32(count), ",") : "",
subtitle: self.showsSubtitle ? (self.count > 0 ? self.strings.LiveStream_Watching.lowercased() : self.strings.LiveStream_NoViewers.lowercased()) : "",
fontSize: self.fontSize,
gradientColors: self.gradientColors
)
switch transition.animation {
case let .curve(duration, curve):
UIView.animate(withDuration: duration, delay: 0, options: curve.containedViewLayoutTransitionCurve.viewAnimationOptions, animations: {
view.bounds.size = availableSize
view.counter.frame.size = availableSize
view.counter.updateFrames(transition: transition)
})
default:
view.bounds.size = availableSize
view.counter.frame.size = availableSize
view.counter.updateFrames()
}
return availableSize
}
final class View: UIView {
let counter = AnimatedCountView()
override init(frame: CGRect) {
super.init(frame: frame)
self.addSubview(counter)
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
}
@@ -0,0 +1,269 @@
import Foundation
import UIKit
import ComponentFlow
import ActivityIndicatorComponent
import AccountContext
import AVKit
import MultilineTextComponent
import Display
import TelegramPresentationData
final class StreamSheetComponent: CombinedComponent {
let strings: PresentationStrings
let sheetHeight: CGFloat
let topOffset: CGFloat
let backgroundColor: UIColor
let participantsCount: Int
let bottomPadding: CGFloat
let isFullyExtended: Bool
let deviceCornerRadius: CGFloat
let videoHeight: CGFloat
let isFullscreen: Bool
let fullscreenTopComponent: AnyComponent<Empty>
let fullscreenBottomComponent: AnyComponent<Empty>
init(
strings: PresentationStrings,
topOffset: CGFloat,
sheetHeight: CGFloat,
backgroundColor: UIColor,
bottomPadding: CGFloat,
participantsCount: Int,
isFullyExtended: Bool,
deviceCornerRadius: CGFloat,
videoHeight: CGFloat,
isFullscreen: Bool,
fullscreenTopComponent: AnyComponent<Empty>,
fullscreenBottomComponent: AnyComponent<Empty>
) {
self.strings = strings
self.topOffset = topOffset
self.sheetHeight = sheetHeight
self.backgroundColor = backgroundColor
self.bottomPadding = bottomPadding
self.participantsCount = participantsCount
self.isFullyExtended = isFullyExtended
self.deviceCornerRadius = deviceCornerRadius
self.videoHeight = videoHeight
self.isFullscreen = isFullscreen
self.fullscreenTopComponent = fullscreenTopComponent
self.fullscreenBottomComponent = fullscreenBottomComponent
}
static func ==(lhs: StreamSheetComponent, rhs: StreamSheetComponent) -> Bool {
if lhs.strings !== rhs.strings {
return false
}
if lhs.topOffset != rhs.topOffset {
return false
}
if lhs.backgroundColor != rhs.backgroundColor {
return false
}
if lhs.sheetHeight != rhs.sheetHeight {
return false
}
if !lhs.backgroundColor.isEqual(rhs.backgroundColor) {
return false
}
if lhs.bottomPadding != rhs.bottomPadding {
return false
}
if lhs.participantsCount != rhs.participantsCount {
return false
}
if lhs.isFullyExtended != rhs.isFullyExtended {
return false
}
if lhs.videoHeight != rhs.videoHeight {
return false
}
if lhs.isFullscreen != rhs.isFullscreen {
return false
}
if lhs.fullscreenTopComponent != rhs.fullscreenTopComponent {
return false
}
if lhs.fullscreenBottomComponent != rhs.fullscreenBottomComponent {
return false
}
return true
}
final class View: UIView {
var overlayComponentsFrames = [CGRect]()
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
for subframe in overlayComponentsFrames {
if subframe.contains(point) { return true }
}
return false
}
func update(component: StreamSheetComponent, availableSize: CGSize, state: State, transition: ComponentTransition) -> CGSize {
return availableSize
}
override func draw(_ rect: CGRect) {
super.draw(rect)
// Debug interactive area
// guard let context = UIGraphicsGetCurrentContext() else { return }
// context.setFillColor(UIColor.red.withAlphaComponent(0.3).cgColor)
// overlayComponentsFrames.forEach { frame in
// context.addRect(frame)
// context.fillPath()
// }
}
}
func makeView() -> View {
View()
}
public final class State: ComponentState {
override init() {
super.init()
}
}
public func makeState() -> State {
return State()
}
private weak var state: State?
static var body: Body {
let background = Child(SheetBackgroundComponent.self)
let viewerCounter = Child(ParticipantsComponent.self)
return { context in
let size = context.availableSize
let topOffset = context.component.topOffset
let backgroundExtraOffset: CGFloat
if #available(iOS 16.0, *) {
// In iOS 16 context.view does not inherit safeAreaInsets, quick fix:
let safeAreaTopInView = context.view.window.flatMap { $0.convert(CGPoint(x: 0, y: $0.safeAreaInsets.top), to: context.view).y } ?? 0
backgroundExtraOffset = context.component.isFullyExtended ? -safeAreaTopInView : 0
} else {
backgroundExtraOffset = context.component.isFullyExtended ? -context.view.safeAreaInsets.top : 0
}
let background = background.update(
component: SheetBackgroundComponent(
color: context.component.backgroundColor,
radius: context.component.isFullyExtended ? context.component.deviceCornerRadius : 10.0,
offset: backgroundExtraOffset
),
availableSize: CGSize(width: size.width, height: context.component.sheetHeight),
transition: context.transition
)
let viewerCounter = viewerCounter.update(
component: ParticipantsComponent(strings: context.component.strings, count: context.component.participantsCount, fontSize: 44.0),
availableSize: CGSize(width: context.availableSize.width, height: 70),
transition: context.transition
)
let isFullscreen = context.component.isFullscreen
context.add(background
.position(CGPoint(x: size.width / 2.0, y: topOffset + context.component.sheetHeight / 2))
)
(context.view as? StreamSheetComponent.View)?.overlayComponentsFrames = []
context.view.backgroundColor = .clear
let videoHeight = context.component.videoHeight
let sheetHeight = context.component.sheetHeight
let animatedParticipantsVisible = !isFullscreen
context.add(viewerCounter
.position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50.0 + videoHeight + (sheetHeight - 69.0 - videoHeight - 50.0 - context.component.bottomPadding) / 2 - 10.0))
.opacity(animatedParticipantsVisible ? 1 : 0)
)
return size
}
}
}
final class SheetBackgroundComponent: Component {
private let color: UIColor
private let radius: CGFloat
private let offset: CGFloat
class View: UIView {
private let backgroundView = UIView()
func update(availableSize: CGSize, color: UIColor, cornerRadius: CGFloat, offset: CGFloat, transition: ComponentTransition) {
if backgroundView.superview == nil {
self.addSubview(backgroundView)
}
let extraBottomForReleaseAnimation: CGFloat = 500
if backgroundView.backgroundColor != color && backgroundView.backgroundColor != nil {
if transition.animation.isImmediate {
UIView.animate(withDuration: 0.4) { [self] in
backgroundView.backgroundColor = color
backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation))
}
let anim = CABasicAnimation(keyPath: "cornerRadius")
anim.fromValue = backgroundView.layer.cornerRadius
backgroundView.layer.cornerRadius = cornerRadius
anim.toValue = cornerRadius
anim.duration = 0.4
backgroundView.layer.add(anim, forKey: "cornerRadius")
} else {
transition.setBackgroundColor(view: backgroundView, color: color)
transition.setFrame(view: backgroundView, frame: CGRect(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation)))
transition.setCornerRadius(layer: backgroundView.layer, cornerRadius: cornerRadius)
}
} else {
backgroundView.backgroundColor = color
backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation))
backgroundView.layer.cornerRadius = cornerRadius
}
backgroundView.isUserInteractionEnabled = false
backgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner]
backgroundView.clipsToBounds = true
backgroundView.layer.masksToBounds = true
}
}
func makeView() -> View {
View()
}
static func ==(lhs: SheetBackgroundComponent, rhs: SheetBackgroundComponent) -> Bool {
if !lhs.color.isEqual(rhs.color) {
return false
}
if lhs.radius != rhs.radius {
return false
}
if lhs.offset != rhs.offset {
return false
}
return true
}
public init(color: UIColor, radius: CGFloat, offset: CGFloat) {
self.color = color
self.radius = radius
self.offset = offset
}
public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment<Empty>, transition: ComponentTransition) -> CGSize {
view.update(availableSize: availableSize, color: color, cornerRadius: radius, offset: offset, transition: transition)
return availableSize
}
}