Merge branch 'media-streaming-9-3' into contest-media-streamin-merge

# Conflicts:
#	submodules/TelegramCallsUI/Sources/GroupCallNavigationAccessoryPanel.swift
This commit is contained in:
Ali 2023-04-13 23:52:25 +04:00
commit 7559664e09
32 changed files with 2824 additions and 795 deletions

View File

@ -5823,6 +5823,8 @@ Sorry for the inconvenience.";
"VoiceChat.Audio" = "audio"; "VoiceChat.Audio" = "audio";
"VoiceChat.Leave" = "leave"; "VoiceChat.Leave" = "leave";
"LiveStream.Expand" = "expand";
"VoiceChat.SpeakPermissionEveryone" = "New participants can speak"; "VoiceChat.SpeakPermissionEveryone" = "New participants can speak";
"VoiceChat.SpeakPermissionAdmin" = "New paricipants are muted"; "VoiceChat.SpeakPermissionAdmin" = "New paricipants are muted";
"VoiceChat.Share" = "Share Invite Link"; "VoiceChat.Share" = "Share Invite Link";
@ -5959,7 +5961,9 @@ Sorry for the inconvenience.";
"LiveStream.RecordingInProgress" = "Live stream is being recorded"; "LiveStream.RecordingInProgress" = "Live stream is being recorded";
"VoiceChat.StopRecordingTitle" = "Stop Recording?"; "VoiceChat.StopRecordingTitle" = "Stop Recording?";
"VoiceChat.StopRecordingStop" = "Stop"; "VoiceChat.StopRecordingStop" = "Stop Recording";
"LiveStream.StopLiveStream" = "Stop Live Stream";
"VoiceChat.RecordingSaved" = "Audio saved to **Saved Messages**."; "VoiceChat.RecordingSaved" = "Audio saved to **Saved Messages**.";
@ -7420,6 +7424,7 @@ Sorry for the inconvenience.";
"LiveStream.NoViewers" = "No viewers"; "LiveStream.NoViewers" = "No viewers";
"LiveStream.ViewerCount_1" = "1 viewer"; "LiveStream.ViewerCount_1" = "1 viewer";
"LiveStream.ViewerCount_any" = "%@ viewers"; "LiveStream.ViewerCount_any" = "%@ viewers";
"LiveStream.Watching" = "watching";
"LiveStream.NoSignalAdminText" = "Oops! Telegram doesn't see any stream\ncoming from your streaming app.\n\nPlease make sure you entered the right Server\nURL and Stream Key in your app."; "LiveStream.NoSignalAdminText" = "Oops! Telegram doesn't see any stream\ncoming from your streaming app.\n\nPlease make sure you entered the right Server\nURL and Stream Key in your app.";
"LiveStream.NoSignalUserText" = "%@ is currently not broadcasting live\nstream data to Telegram."; "LiveStream.NoSignalUserText" = "%@ is currently not broadcasting live\nstream data to Telegram.";

View File

@ -180,7 +180,7 @@ public final class _UpdatedChildComponent {
var _opacity: CGFloat? var _opacity: CGFloat?
var _cornerRadius: CGFloat? var _cornerRadius: CGFloat?
var _clipsToBounds: Bool? var _clipsToBounds: Bool?
fileprivate var transitionAppear: Transition.Appear? fileprivate var transitionAppear: Transition.Appear?
fileprivate var transitionAppearWithGuide: (Transition.AppearWithGuide, _AnyChildComponent.Id)? fileprivate var transitionAppearWithGuide: (Transition.AppearWithGuide, _AnyChildComponent.Id)?
fileprivate var transitionDisappear: Transition.Disappear? fileprivate var transitionDisappear: Transition.Disappear?
@ -240,7 +240,7 @@ public final class _UpdatedChildComponent {
self._position = position self._position = position
return self return self
} }
@discardableResult public func scale(_ scale: CGFloat) -> _UpdatedChildComponent { @discardableResult public func scale(_ scale: CGFloat) -> _UpdatedChildComponent {
self._scale = scale self._scale = scale
return self return self
@ -702,6 +702,7 @@ public extension CombinedComponent {
} else { } else {
updatedChild.view.frame = updatedChild.size.centered(around: updatedChild._position ?? CGPoint()) updatedChild.view.frame = updatedChild.size.centered(around: updatedChild._position ?? CGPoint())
} }
updatedChild.view.alpha = updatedChild._opacity ?? 1.0 updatedChild.view.alpha = updatedChild._opacity ?? 1.0
updatedChild.view.clipsToBounds = updatedChild._clipsToBounds ?? false updatedChild.view.clipsToBounds = updatedChild._clipsToBounds ?? false
updatedChild.view.layer.cornerRadius = updatedChild._cornerRadius ?? 0.0 updatedChild.view.layer.cornerRadius = updatedChild._cornerRadius ?? 0.0

View File

@ -46,6 +46,9 @@ public enum ContextMenuActionItemTextColor {
public enum ContextMenuActionResult { public enum ContextMenuActionResult {
case `default` case `default`
case dismissWithoutContent case dismissWithoutContent
/// Temporary
static var safeStreamRecordingDismissWithoutContent: ContextMenuActionResult { .dismissWithoutContent }
case custom(ContainedViewLayoutTransition) case custom(ContainedViewLayoutTransition)
} }

View File

@ -499,16 +499,19 @@ private final class ContextControllerActionsListCustomItemNode: ASDisplayNode, C
private let getController: () -> ContextControllerProtocol? private let getController: () -> ContextControllerProtocol?
private let item: ContextMenuCustomItem private let item: ContextMenuCustomItem
private let requestDismiss: (ContextMenuActionResult) -> Void
private var presentationData: PresentationData? private var presentationData: PresentationData?
private var itemNode: ContextMenuCustomNode? private var itemNode: ContextMenuCustomNode?
init( init(
getController: @escaping () -> ContextControllerProtocol?, getController: @escaping () -> ContextControllerProtocol?,
item: ContextMenuCustomItem item: ContextMenuCustomItem,
requestDismiss: @escaping (ContextMenuActionResult) -> Void
) { ) {
self.getController = getController self.getController = getController
self.item = item self.item = item
self.requestDismiss = requestDismiss
super.init() super.init()
} }
@ -529,7 +532,12 @@ private final class ContextControllerActionsListCustomItemNode: ASDisplayNode, C
presentationData: presentationData, presentationData: presentationData,
getController: self.getController, getController: self.getController,
actionSelected: { result in actionSelected: { result in
let _ = result switch result {
case .dismissWithoutContent/* where ContextMenuActionResult.safeStreamRecordingDismissWithoutContent == .dismissWithoutContent*/:
self.requestDismiss(result)
default: break
}
} }
) )
self.itemNode = itemNode self.itemNode = itemNode
@ -601,7 +609,8 @@ final class ContextControllerActionsListStackItem: ContextControllerActionsStack
return Item( return Item(
node: ContextControllerActionsListCustomItemNode( node: ContextControllerActionsListCustomItemNode(
getController: getController, getController: getController,
item: customItem item: customItem,
requestDismiss: requestDismiss
), ),
separatorNode: ASDisplayNode() separatorNode: ASDisplayNode()
) )

View File

@ -50,6 +50,7 @@ private final class CreateExternalMediaStreamScreenComponent: CombinedComponent
let peerId: EnginePeer.Id let peerId: EnginePeer.Id
private(set) var credentials: GroupCallStreamCredentials? private(set) var credentials: GroupCallStreamCredentials?
var isDelayingLoadingIndication: Bool = true
private var credentialsDisposable: Disposable? private var credentialsDisposable: Disposable?
private let activeActionDisposable = MetaDisposable() private let activeActionDisposable = MetaDisposable()
@ -100,6 +101,13 @@ private final class CreateExternalMediaStreamScreenComponent: CombinedComponent
return return
} }
strongSelf.isDelayingLoadingIndication = true
DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak strongSelf] in
guard let strongSelf else { return }
strongSelf.isDelayingLoadingIndication = false
strongSelf.updated(transition: .easeInOut(duration: 0.3))
}
var cancelImpl: (() -> Void)? var cancelImpl: (() -> Void)?
let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
let progressSignal = Signal<Never, NoError> { [weak baseController] subscriber in let progressSignal = Signal<Never, NoError> { [weak baseController] subscriber in
@ -397,7 +405,7 @@ private final class CreateExternalMediaStreamScreenComponent: CombinedComponent
context.add(credentialsCopyKeyButton context.add(credentialsCopyKeyButton
.position(CGPoint(x: credentialsFrame.maxX - 12.0 - credentialsCopyKeyButton.size.width / 2.0, y: credentialsFrame.minY + credentialsItemHeight + credentialsItemHeight / 2.0)) .position(CGPoint(x: credentialsFrame.maxX - 12.0 - credentialsCopyKeyButton.size.width / 2.0, y: credentialsFrame.minY + credentialsItemHeight + credentialsItemHeight / 2.0))
) )
} else { } else if !context.state.isDelayingLoadingIndication {
let activityIndicator = activityIndicator.update( let activityIndicator = activityIndicator.update(
component: ActivityIndicatorComponent(color: environment.theme.list.controlSecondaryColor), component: ActivityIndicatorComponent(color: environment.theme.list.controlSecondaryColor),
availableSize: CGSize(width: 100.0, height: 100.0), availableSize: CGSize(width: 100.0, height: 100.0),

View File

@ -477,6 +477,35 @@ public final class StandaloneShimmerEffect {
self.updateLayer() self.updateLayer()
} }
public func updateHorizontal(background: UIColor, foreground: UIColor) {
if self.background == background && self.foreground == foreground {
return
}
self.background = background
self.foreground = foreground
self.image = generateImage(CGSize(width: 320, height: 1), opaque: false, scale: 1.0, rotatedContext: { size, context in
context.clear(CGRect(origin: CGPoint(), size: size))
context.setFillColor(background.cgColor)
context.fill(CGRect(origin: CGPoint(), size: size))
context.clip(to: CGRect(origin: CGPoint(), size: size))
let transparentColor = foreground.withAlphaComponent(0.0).cgColor
let peakColor = foreground.cgColor
var locations: [CGFloat] = [0.0, 0.44, 0.55, 1.0]
let colors: [CGColor] = [transparentColor, peakColor, peakColor, transparentColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations) else { return }
context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.2), end: CGPoint(x: size.width, y: 0.8), options: CGGradientDrawingOptions())
})
self.updateHorizontalLayer()
}
public func updateLayer() { public func updateLayer() {
guard let layer = self.layer, let image = self.image else { guard let layer = self.layer, let image = self.image else {
return return
@ -495,4 +524,24 @@ public final class StandaloneShimmerEffect {
layer.add(animation, forKey: "shimmer") layer.add(animation, forKey: "shimmer")
} }
} }
private func updateHorizontalLayer() {
guard let layer = self.layer, let image = self.image else {
return
}
layer.contents = image.cgImage
if layer.animation(forKey: "shimmer") == nil {
var delay: TimeInterval { 1.6 }
let animation = CABasicAnimation(keyPath: "contentsRect.origin.x")
animation.fromValue = NSNumber(floatLiteral: delay)
animation.toValue = NSNumber(floatLiteral: -delay)
animation.isAdditive = true
animation.repeatCount = .infinity
animation.duration = 0.8 * delay
animation.timingFunction = .init(name: .easeInEaseOut)
layer.add(animation, forKey: "shimmer")
}
}
} }

View File

@ -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.Transition? = 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")
}
}
}

View File

@ -1,11 +1,16 @@
import Foundation import Foundation
import UIKit import UIKit
import ComponentFlow import ComponentFlow
import ActivityIndicatorComponent
import AccountContext import AccountContext
import AVKit import AVKit
import MultilineTextComponent import MultilineTextComponent
import Display import Display
import ShimmerEffect
import TelegramCore
import SwiftSignalKit
import AvatarNode
import Postbox
final class MediaStreamVideoComponent: Component { final class MediaStreamVideoComponent: Component {
let call: PresentationGroupCallImpl let call: PresentationGroupCallImpl
@ -17,6 +22,11 @@ final class MediaStreamVideoComponent: Component {
let deactivatePictureInPicture: ActionSlot<Void> let deactivatePictureInPicture: ActionSlot<Void>
let bringBackControllerForPictureInPictureDeactivation: (@escaping () -> Void) -> Void let bringBackControllerForPictureInPictureDeactivation: (@escaping () -> Void) -> Void
let pictureInPictureClosed: () -> Void let pictureInPictureClosed: () -> Void
let isFullscreen: Bool
let onVideoSizeRetrieved: (CGSize) -> Void
let videoLoading: Bool
let callPeer: Peer?
let onVideoPlaybackLiveChange: (Bool) -> Void
init( init(
call: PresentationGroupCallImpl, call: PresentationGroupCallImpl,
@ -24,20 +34,31 @@ final class MediaStreamVideoComponent: Component {
isVisible: Bool, isVisible: Bool,
isAdmin: Bool, isAdmin: Bool,
peerTitle: String, peerTitle: String,
isFullscreen: Bool,
videoLoading: Bool,
callPeer: Peer?,
activatePictureInPicture: ActionSlot<Action<Void>>, activatePictureInPicture: ActionSlot<Action<Void>>,
deactivatePictureInPicture: ActionSlot<Void>, deactivatePictureInPicture: ActionSlot<Void>,
bringBackControllerForPictureInPictureDeactivation: @escaping (@escaping () -> Void) -> Void, bringBackControllerForPictureInPictureDeactivation: @escaping (@escaping () -> Void) -> Void,
pictureInPictureClosed: @escaping () -> Void pictureInPictureClosed: @escaping () -> Void,
onVideoSizeRetrieved: @escaping (CGSize) -> Void,
onVideoPlaybackLiveChange: @escaping (Bool) -> Void
) { ) {
self.call = call self.call = call
self.hasVideo = hasVideo self.hasVideo = hasVideo
self.isVisible = isVisible self.isVisible = isVisible
self.isAdmin = isAdmin self.isAdmin = isAdmin
self.peerTitle = peerTitle self.peerTitle = peerTitle
self.videoLoading = videoLoading
self.activatePictureInPicture = activatePictureInPicture self.activatePictureInPicture = activatePictureInPicture
self.deactivatePictureInPicture = deactivatePictureInPicture self.deactivatePictureInPicture = deactivatePictureInPicture
self.bringBackControllerForPictureInPictureDeactivation = bringBackControllerForPictureInPictureDeactivation self.bringBackControllerForPictureInPictureDeactivation = bringBackControllerForPictureInPictureDeactivation
self.pictureInPictureClosed = pictureInPictureClosed self.pictureInPictureClosed = pictureInPictureClosed
self.onVideoPlaybackLiveChange = onVideoPlaybackLiveChange
self.callPeer = callPeer
self.isFullscreen = isFullscreen
self.onVideoSizeRetrieved = onVideoSizeRetrieved
} }
public static func ==(lhs: MediaStreamVideoComponent, rhs: MediaStreamVideoComponent) -> Bool { public static func ==(lhs: MediaStreamVideoComponent, rhs: MediaStreamVideoComponent) -> Bool {
@ -56,7 +77,12 @@ final class MediaStreamVideoComponent: Component {
if lhs.peerTitle != rhs.peerTitle { if lhs.peerTitle != rhs.peerTitle {
return false return false
} }
if lhs.isFullscreen != rhs.isFullscreen {
return false
}
if lhs.videoLoading != rhs.videoLoading {
return false
}
return true return true
} }
@ -70,7 +96,7 @@ final class MediaStreamVideoComponent: Component {
return State() return State()
} }
public final class View: UIScrollView, AVPictureInPictureControllerDelegate, ComponentTaggedView { public final class View: UIView, AVPictureInPictureControllerDelegate, ComponentTaggedView {
public final class Tag { public final class Tag {
} }
@ -78,9 +104,11 @@ final class MediaStreamVideoComponent: Component {
private let blurTintView: UIView private let blurTintView: UIView
private var videoBlurView: VideoRenderingView? private var videoBlurView: VideoRenderingView?
private var videoView: VideoRenderingView? private var videoView: VideoRenderingView?
private var activityIndicatorView: ComponentHostView<Empty>?
private var noSignalView: ComponentHostView<Empty>?
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 pictureInPictureController: AVPictureInPictureController?
private var component: MediaStreamVideoComponent? private var component: MediaStreamVideoComponent?
@ -88,15 +116,50 @@ final class MediaStreamVideoComponent: Component {
private var requestedExpansion: Bool = false private var requestedExpansion: Bool = false
private var noSignalTimer: Timer? private var noSignalTimer: Foundation.Timer?
private var noSignalTimeout: Bool = false 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 weak var state: State?
private var lastPresentation: UIView?
private var pipTrackDisplayLink: CADisplayLink?
override init(frame: CGRect) { override init(frame: CGRect) {
self.blurTintView = UIView() self.blurTintView = UIView()
self.blurTintView.backgroundColor = UIColor(white: 0.0, alpha: 0.55) self.blurTintView.backgroundColor = UIColor(white: 0.0, alpha: 0.55)
super.init(frame: frame) super.init(frame: frame)
self.isUserInteractionEnabled = false self.isUserInteractionEnabled = false
@ -109,6 +172,13 @@ final class MediaStreamVideoComponent: Component {
fatalError("init(coder:) has not been implemented") fatalError("init(coder:) has not been implemented")
} }
deinit {
avatarDisposable?.dispose()
frameInputDisposable?.dispose()
self.pipTrackDisplayLink?.invalidate()
self.pipTrackDisplayLink = nil
}
public func matches(tag: Any) -> Bool { public func matches(tag: Any) -> Bool {
if let _ = tag as? Tag { if let _ = tag as? Tag {
return true return true
@ -123,60 +193,224 @@ final class MediaStreamVideoComponent: Component {
} }
} }
private func updateVideoStalled(isStalled: Bool, transition: Transition?) {
if isStalled {
guard let component = self.component else { return }
if let frameView = lastFrame[component.call.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: Transition) -> CGSize { func update(component: MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize {
self.state = state 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.hasVideo || component.videoLoading || self.videoStalled {
updateVideoStalled(isStalled: true, transition: transition)
} else {
updateVideoStalled(isStalled: false, transition: transition)
}
if component.hasVideo, self.videoView == nil { if component.hasVideo, self.videoView == nil {
if let input = component.call.video(endpointId: "unified") { if let input = component.call.video(endpointId: "unified") {
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 = component.isFullscreen // or just true
if let videoBlurView = self.videoRenderingContext.makeView(input: input, blur: true) { if let videoBlurView = self.videoRenderingContext.makeView(input: input, blur: true) {
self.videoBlurView = videoBlurView self.videoBlurView = videoBlurView
self.insertSubview(videoBlurView, belowSubview: self.blurTintView) 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)
} }
if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) { if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) {
self.videoView = videoView self.videoView = videoView
self.addSubview(videoView) self.addSubview(videoView)
videoView.alpha = 0
UIView.animate(withDuration: 0.3) {
videoView.alpha = 1
}
if let sampleBufferVideoView = videoView as? SampleBufferVideoRenderingView { if let sampleBufferVideoView = videoView as? SampleBufferVideoRenderingView {
sampleBufferVideoView.sampleBufferLayer.masksToBounds = true
if #available(iOS 13.0, *) { if #available(iOS 13.0, *) {
sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true
} }
if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { final class PlaybackDelegateImpl: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate {
final class PlaybackDelegateImpl: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate { var onTransitionFinished: (() -> Void)?
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) { 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) {
}
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) {
completionHandler()
}
public func pictureInPictureControllerShouldProhibitBackgroundAudioPlayback(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
return false
}
} }
let pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: PlaybackDelegateImpl())) func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange {
return CMTimeRange(start: .zero, duration: .positiveInfinity)
}
pictureInPictureController.delegate = self func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
pictureInPictureController.canStartPictureInPictureAutomaticallyFromInline = true return false
pictureInPictureController.requiresLinearPlayback = true }
self.pictureInPictureController = pictureInPictureController 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 videoView.setOnOrientationUpdated { [weak state] _, _ in
@ -189,26 +423,86 @@ final class MediaStreamVideoComponent: Component {
strongSelf.hadVideo = true strongSelf.hadVideo = true
strongSelf.activityIndicatorView?.removeFromSuperview()
strongSelf.activityIndicatorView = nil
strongSelf.noSignalTimer?.invalidate() strongSelf.noSignalTimer?.invalidate()
strongSelf.noSignalTimer = nil strongSelf.noSignalTimer = nil
strongSelf.noSignalTimeout = false strongSelf.noSignalTimeout = false
strongSelf.noSignalView?.removeFromSuperview() strongSelf.noSignalView?.removeFromSuperview()
strongSelf.noSignalView = nil strongSelf.noSignalView = nil
//strongSelf.translatesAutoresizingMaskIntoConstraints = false
//strongSelf.maximumZoomScale = 4.0
state?.updated(transition: .immediate) state?.updated(transition: .immediate)
} }
} }
} }
} 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.isFullscreen {
videoInset = 16
} else {
videoInset = 0
}
let videoSize: CGSize
let videoCornerRadius: CGFloat = component.isFullscreen ? 0 : 10
let videoFrameUpdateTransition: Transition
if self.wasFullscreen != component.isFullscreen {
videoFrameUpdateTransition = transition
} else {
videoFrameUpdateTransition = transition.withAnimation(.none)
} }
if let videoView = self.videoView { if let videoView = self.videoView {
if videoView.bounds.size.width > 0,
videoView.alpha > 0,
self.hadVideo,
let snapshot = videoView.snapshotView(afterScreenUpdates: false) ?? videoView.snapshotView(afterScreenUpdates: true) {
lastFrame[component.call.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 {
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 var isVideoVisible = component.isVisible
if !wasVisible && component.isVisible {
videoView.layer.animateAlpha(from: 0, to: 1, duration: 0.2)
} else if wasVisible && !component.isVisible {
videoView.layer.animateAlpha(from: 1, to: 0, duration: 0.2)
}
if let pictureInPictureController = self.pictureInPictureController { if let pictureInPictureController = self.pictureInPictureController {
if pictureInPictureController.isPictureInPictureActive { if pictureInPictureController.isPictureInPictureActive {
isVideoVisible = true isVideoVisible = true
@ -216,44 +510,81 @@ final class MediaStreamVideoComponent: Component {
} }
videoView.updateIsEnabled(isVideoVisible) videoView.updateIsEnabled(isVideoVisible)
videoView.clipsToBounds = true
videoView.layer.cornerRadius = videoCornerRadius
var aspect = videoView.getAspect() self.wasFullscreen = component.isFullscreen
if aspect <= 0.01 { let newVideoFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize)
aspect = 3.0 / 4.0
}
let videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(availableSize) videoFrameUpdateTransition.setFrame(view: videoView, frame: newVideoFrame, completion: nil)
let blurredVideoSize = videoSize.aspectFilled(availableSize)
transition.withAnimation(.none).setFrame(view: videoView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize), completion: nil)
if let videoBlurView = self.videoBlurView { if let videoBlurView = self.videoBlurView {
videoBlurView.updateIsEnabled(component.isVisible)
transition.withAnimation(.none).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) 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)
} }
} else {
videoSize = CGSize(width: 16 / 9 * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height))
} }
if !self.hadVideo { let loadingBlurViewFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize)
var activityIndicatorTransition = transition
let activityIndicatorView: ComponentHostView<Empty> if loadingBlurView.frame == .zero {
if let current = self.activityIndicatorView { loadingBlurView.frame = loadingBlurViewFrame
activityIndicatorView = current } else {
} else { // Using Transition.setFrame on UIVisualEffectView causes instant update of sublayers
activityIndicatorTransition = transition.withAnimation(.none) switch videoFrameUpdateTransition.animation {
activityIndicatorView = ComponentHostView<Empty>() case let .curve(duration, curve):
self.activityIndicatorView = activityIndicatorView UIView.animate(withDuration: duration, delay: 0, options: curve.containedViewLayoutTransitionCurve.viewAnimationOptions, animations: { [self] in
self.addSubview(activityIndicatorView) loadingBlurView.frame = loadingBlurViewFrame
})
default:
loadingBlurView.frame = loadingBlurViewFrame
} }
}
let activityIndicatorSize = activityIndicatorView.update( videoFrameUpdateTransition.setCornerRadius(layer: loadingBlurView.layer, cornerRadius: videoCornerRadius)
transition: transition, videoFrameUpdateTransition.setFrame(view: placeholderView, frame: loadingBlurViewFrame)
component: AnyComponent(ActivityIndicatorComponent(color: .white)), videoFrameUpdateTransition.setCornerRadius(layer: placeholderView.layer, cornerRadius: videoCornerRadius)
environment: {}, placeholderView.clipsToBounds = true
containerSize: CGSize(width: 100.0, height: 100.0) placeholderView.subviews.forEach {
) videoFrameUpdateTransition.setFrame(view: $0, frame: placeholderView.bounds)
let activityIndicatorFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - activityIndicatorSize.width) / 2.0), y: floor((availableSize.height - activityIndicatorSize.height) / 2.0)), size: activityIndicatorSize) }
activityIndicatorTransition.setFrame(view: activityIndicatorView, frame: activityIndicatorFrame, completion: nil)
let initialShimmerBounds = shimmerBorderLayer.bounds
videoFrameUpdateTransition.setFrame(layer: 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
shimmerBorderLayer.mask = borderMask
shimmerBorderLayer.cornerRadius = videoCornerRadius
if !self.hadVideo {
if self.noSignalTimer == nil { if self.noSignalTimer == nil {
if #available(iOS 10.0, *) { if #available(iOS 10.0, *) {
@ -278,7 +609,10 @@ final class MediaStreamVideoComponent: Component {
noSignalTransition = transition.withAnimation(.none) noSignalTransition = transition.withAnimation(.none)
noSignalView = ComponentHostView<Empty>() noSignalView = ComponentHostView<Empty>()
self.noSignalView = noSignalView self.noSignalView = noSignalView
self.addSubview(noSignalView) self.addSubview(noSignalView)
noSignalView.layer.zPosition = loadingBlurView.layer.zPosition + 1
noSignalView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) noSignalView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
} }
@ -293,7 +627,7 @@ final class MediaStreamVideoComponent: Component {
environment: {}, environment: {},
containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 1000.0) 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: activityIndicatorFrame.maxY + 24.0), size: noSignalSize), completion: nil) 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)
} }
} }
@ -320,30 +654,84 @@ final class MediaStreamVideoComponent: Component {
return availableSize return availableSize
} }
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) { public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
guard let component = self.component else { guard let component = self.component else {
completionHandler(false) completionHandler(false)
return return
} }
didRequestBringBack = true
component.bringBackControllerForPictureInPictureDeactivation { component.bringBackControllerForPictureInPictureDeactivation {
completionHandler(true) completionHandler(true)
} }
} }
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
self.didRequestBringBack = false
self.state?.updated(transition: .immediate) self.state?.updated(transition: .immediate)
} }
func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
if self.requestedExpansion { if self.requestedExpansion {
self.requestedExpansion = false self.requestedExpansion = false
} else { } else if !didRequestBringBack {
self.component?.pictureInPictureClosed() 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
}
} }
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
self.videoView?.alpha = 1
self.state?.updated(transition: .immediate) self.state?.updated(transition: .immediate)
} }
} }
@ -356,3 +744,27 @@ final class MediaStreamVideoComponent: Component {
return view.update(component: self, availableSize: availableSize, state: state, transition: transition) 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)
}
}

View File

@ -0,0 +1,78 @@
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 count: Int
private let showsSubtitle: Bool
private let fontSize: CGFloat
private let gradientColors: [CGColor]
init(count: Int, showsSubtitle: Bool = true, fontSize: CGFloat = 48.0, gradientColors: [CGColor] = [pink.cgColor, purple.cgColor, purple.cgColor]) {
self.count = count
self.showsSubtitle = showsSubtitle
self.fontSize = fontSize
self.gradientColors = gradientColors
}
static func == (lhs: ParticipantsComponent, rhs: ParticipantsComponent) -> Bool {
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.Transition) -> CGSize {
view.counter.update(
countString: self.count > 0 ? presentationStringsFormattedNumber(Int32(count), ",") : "",
// TODO: localize
subtitle: self.showsSubtitle ? (self.count > 0 ? /*environment.strings.LiveStream_Watching*/"watching" : /*environment.strings.LiveStream_NoViewers.lowercased()*/"no viewers") : "",
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")
}
}
}

View File

@ -0,0 +1,262 @@
import Foundation
import UIKit
import ComponentFlow
import ActivityIndicatorComponent
import AccountContext
import AVKit
import MultilineTextComponent
import Display
final class StreamSheetComponent: CombinedComponent {
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(
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.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.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: Transition) -> 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(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: Transition) {
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: Transition) -> CGSize {
view.update(availableSize: availableSize, color: color, cornerRadius: radius, offset: offset, transition: transition)
return availableSize
}
}

View File

@ -36,12 +36,12 @@ import DeviceAccess
let panelBackgroundColor = UIColor(rgb: 0x1c1c1e) let panelBackgroundColor = UIColor(rgb: 0x1c1c1e)
let secondaryPanelBackgroundColor = UIColor(rgb: 0x2c2c2e) let secondaryPanelBackgroundColor = UIColor(rgb: 0x2c2c2e)
let fullscreenBackgroundColor = UIColor(rgb: 0x000000) let fullscreenBackgroundColor = UIColor(rgb: 0x000000)
private let smallButtonSize = CGSize(width: 36.0, height: 36.0) let smallButtonSize = CGSize(width: 36.0, height: 36.0)
private let sideButtonSize = CGSize(width: 56.0, height: 56.0) let sideButtonSize = CGSize(width: 56.0, height: 56.0)
private let topPanelHeight: CGFloat = 63.0 let topPanelHeight: CGFloat = 63.0
let bottomAreaHeight: CGFloat = 206.0 let bottomAreaHeight: CGFloat = 206.0
private let fullscreenBottomAreaHeight: CGFloat = 80.0 let fullscreenBottomAreaHeight: CGFloat = 80.0
private let bottomGradientHeight: CGFloat = 70.0 let bottomGradientHeight: CGFloat = 70.0
func decorationCornersImage(top: Bool, bottom: Bool, dark: Bool) -> UIImage? { func decorationCornersImage(top: Bool, bottom: Bool, dark: Bool) -> UIImage? {
if !top && !bottom { if !top && !bottom {

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "close.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "close@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "close@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 724 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "expand.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "expand@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "expand@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 491 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "more.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "more@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "more@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "pip.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "pip@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "pip@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 316 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 590 B

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "share.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "share@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "share@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 959 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB