mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
500 lines
24 KiB
Swift
500 lines
24 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import Display
|
|
import SwiftSignalKit
|
|
import UniversalMediaPlayer
|
|
import TelegramUniversalVideoContent
|
|
import TelegramCore
|
|
import AccountContext
|
|
import ComponentFlow
|
|
import GradientBackground
|
|
import AnimationCache
|
|
import MultiAnimationRenderer
|
|
import EntityKeyboard
|
|
import AnimatedStickerNode
|
|
import TelegramAnimatedStickerNode
|
|
import StickerResources
|
|
|
|
private let maxVideoLoopCount = 2
|
|
|
|
public final class AvatarVideoNode: ASDisplayNode {
|
|
private let context: AccountContext
|
|
|
|
private var backgroundNode: ASImageNode
|
|
|
|
private var emojiMarkup: TelegramMediaImage.EmojiMarkup?
|
|
|
|
private var videoFileDisposable: Disposable?
|
|
private var fileDisposable = MetaDisposable()
|
|
private var animationFile: TelegramMediaFile?
|
|
private var itemLayer: EmojiKeyboardItemLayer?
|
|
private var useAnimationNode = false
|
|
private var animationNode: AnimatedStickerNode?
|
|
private let stickerFetchedDisposable = MetaDisposable()
|
|
|
|
private var videoItemLayer: EmojiKeyboardItemLayer?
|
|
private var videoNode: UniversalVideoNode?
|
|
private var videoContent: NativeVideoContent?
|
|
private let playbackStartDisposable = MetaDisposable()
|
|
private var videoLoopCount = 0
|
|
|
|
private var validLayout: (CGSize, CGFloat)?
|
|
private var internalSize = CGSize(width: 60.0, height: 60.0)
|
|
|
|
public init(context: AccountContext) {
|
|
self.context = context
|
|
|
|
self.backgroundNode = ASImageNode()
|
|
self.backgroundNode.displaysAsynchronously = false
|
|
self.backgroundNode.isHidden = true
|
|
|
|
super.init()
|
|
|
|
self.clipsToBounds = true
|
|
|
|
self.addSubnode(self.backgroundNode)
|
|
}
|
|
|
|
deinit {
|
|
self.videoFileDisposable?.dispose()
|
|
self.fileDisposable.dispose()
|
|
self.stickerFetchedDisposable.dispose()
|
|
self.playbackStartDisposable.dispose()
|
|
}
|
|
|
|
public override func didLoad() {
|
|
super.didLoad()
|
|
|
|
if #available(iOS 13.0, *) {
|
|
self.layer.cornerCurve = .circular
|
|
}
|
|
}
|
|
|
|
private var didAppear = false
|
|
|
|
private func setupAnimation() {
|
|
guard let animationFile = self.animationFile else {
|
|
return
|
|
}
|
|
|
|
if self.useAnimationNode {
|
|
self.stickerFetchedDisposable.set(freeMediaFileResourceInteractiveFetched(account: self.context.account, userLocation: .other, fileReference: stickerPackFileReference(animationFile), resource: chatMessageStickerResource(file: animationFile, small: false)).startStrict())
|
|
|
|
let animationNode = DefaultAnimatedStickerNodeImpl()
|
|
animationNode.autoplay = false
|
|
self.animationNode = animationNode
|
|
animationNode.started = { [weak self] in
|
|
if let self {
|
|
if !self.didAppear {
|
|
self.didAppear = true
|
|
Queue.mainQueue().after(0.15) {
|
|
self.backgroundNode.isHidden = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if animationFile.isCustomTemplateEmoji {
|
|
animationNode.dynamicColor = .white
|
|
} else {
|
|
animationNode.dynamicColor = nil
|
|
}
|
|
self.backgroundNode.addSubnode(animationNode)
|
|
} else {
|
|
let itemNativeFitSize = self.internalSize.width > 100.0 ? CGSize(width: 192.0, height: 192.0) : CGSize(width: 64.0, height: 64.0)
|
|
|
|
let animationData = EntityKeyboardAnimationData(file: TelegramMediaFile.Accessor(animationFile))
|
|
let itemLayer = EmojiKeyboardItemLayer(
|
|
item: EmojiPagerContentComponent.Item(
|
|
animationData: animationData,
|
|
content: .animation(animationData),
|
|
itemFile: TelegramMediaFile.Accessor(animationFile),
|
|
subgroupId: nil,
|
|
icon: .none,
|
|
tintMode: animationData.isTemplate ? .primary : .none
|
|
),
|
|
context: context,
|
|
attemptSynchronousLoad: false,
|
|
content: .animation(animationData),
|
|
cache: context.animationCache,
|
|
renderer: context.animationRenderer,
|
|
placeholderColor: .clear,
|
|
blurredBadgeColor: .clear,
|
|
accentIconColor: .white,
|
|
pointSize: itemNativeFitSize,
|
|
onUpdateDisplayPlaceholder: { _, _ in
|
|
}
|
|
)
|
|
itemLayer.onContentsUpdate = { [weak self] in
|
|
if let self {
|
|
if !self.didAppear {
|
|
self.didAppear = true
|
|
Queue.mainQueue().after(0.15) {
|
|
self.backgroundNode.isHidden = false
|
|
}
|
|
}
|
|
}
|
|
}
|
|
itemLayer.onLoop = { [weak self] in
|
|
if let self {
|
|
self.videoLoopCount += 1
|
|
if self.videoLoopCount >= maxVideoLoopCount {
|
|
self.itemLayer?.isVisibleForAnimations = false
|
|
self.videoItemLayer?.isVisibleForAnimations = false
|
|
}
|
|
}
|
|
}
|
|
itemLayer.layerTintColor = UIColor.white.cgColor
|
|
|
|
self.itemLayer = itemLayer
|
|
self.backgroundNode.layer.addSublayer(itemLayer)
|
|
}
|
|
self.updateVisibility(self.visibility)
|
|
|
|
if let (size, cornerRadius) = self.validLayout {
|
|
self.updateLayout(size: size, cornerRadius: cornerRadius, transition: .immediate)
|
|
}
|
|
}
|
|
|
|
public func update(markup: TelegramMediaImage.EmojiMarkup, size: CGSize, useAnimationNode: Bool = true) {
|
|
guard markup != self.emojiMarkup else {
|
|
return
|
|
}
|
|
self.emojiMarkup = markup
|
|
self.internalSize = size
|
|
self.useAnimationNode = useAnimationNode
|
|
self.didSetupAnimation = false
|
|
|
|
let colors = markup.backgroundColors.map { UInt32(bitPattern: $0) }
|
|
if colors.count == 1 {
|
|
backgroundNode.backgroundColor = UIColor(rgb: colors.first!)
|
|
self.backgroundNode.image = nil
|
|
} else if colors.count == 2 {
|
|
self.backgroundNode.image = generateGradientImage(size: size, colors: colors.map { UIColor(rgb: $0) }, locations: [0.0, 1.0])!
|
|
} else {
|
|
self.backgroundNode.image = GradientBackgroundNode.generatePreview(size: size, colors: colors.map { UIColor(rgb: $0) })
|
|
}
|
|
self.backgroundNode.isHidden = true
|
|
|
|
switch markup.content {
|
|
case let .emoji(fileId):
|
|
self.fileDisposable.set((self.context.engine.stickers.resolveInlineStickers(fileIds: [fileId])
|
|
|> deliverOnMainQueue).startStrict(next: { [weak self] files in
|
|
if let strongSelf = self, let file = files.values.first {
|
|
strongSelf.animationFile = file
|
|
strongSelf.setupAnimation()
|
|
}
|
|
}))
|
|
case let .sticker(packReference, fileId):
|
|
self.fileDisposable.set((self.context.engine.stickers.loadedStickerPack(reference: packReference, forceActualized: false)
|
|
|> map { pack -> TelegramMediaFile? in
|
|
if case let .result(_, items, _) = pack, let item = items.first(where: { $0.file.fileId.id == fileId }) {
|
|
return item.file._parse()
|
|
}
|
|
return nil
|
|
}
|
|
|> deliverOnMainQueue).startStrict(next: { [weak self] file in
|
|
if let strongSelf = self, let file {
|
|
strongSelf.animationFile = file
|
|
strongSelf.setupAnimation()
|
|
}
|
|
}))
|
|
}
|
|
}
|
|
|
|
public func update(peer: EnginePeer, photo: TelegramMediaImage, size: CGSize) {
|
|
self.internalSize = size
|
|
if let markup = photo.emojiMarkup {
|
|
self.update(markup: markup, size: size, useAnimationNode: false)
|
|
} else if let video = smallestVideoRepresentation(photo.videoRepresentations), let peerReference = PeerReference(peer._asPeer()) {
|
|
self.backgroundNode.image = nil
|
|
|
|
let videoId = photo.id?.id ?? peer.id.id._internalGetInt64Value()
|
|
let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.resource, previewRepresentations: photo.representations, videoThumbnails: [], immediateThumbnailData: photo.immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: []))
|
|
let videoContent = NativeVideoContent(id: .profileVideo(videoId, nil), userLocation: .other, fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, useLargeThumbnail: true, autoFetchFullSizeThumbnail: true, startTimestamp: video.startTimestamp, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear, captureProtected: false, storeAfterDownload: nil)
|
|
if videoContent.id != self.videoContent?.id {
|
|
self.videoNode?.removeFromSupernode()
|
|
self.videoContent = videoContent
|
|
|
|
self.videoFileDisposable?.dispose()
|
|
self.videoFileDisposable = fetchedMediaResource(mediaBox: self.context.account.postbox.mediaBox, userLocation: .peer(peer.id), userContentType: .avatar, reference: videoFileReference.resourceReference(videoFileReference.media.resource)).startStrict()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var didSetupAnimation = false
|
|
private var visibility = false
|
|
public func updateVisibility(_ isVisible: Bool) {
|
|
self.visibility = isVisible
|
|
if isVisible, let animationNode = self.animationNode, let file = self.animationFile {
|
|
if !self.didSetupAnimation {
|
|
self.didSetupAnimation = true
|
|
let pathPrefix = self.context.account.postbox.mediaBox.shortLivedResourceCachePathPrefix(file.resource.id)
|
|
let dimensions = file.dimensions ?? PixelDimensions(width: 512, height: 512)
|
|
let fittedDimensions = dimensions.cgSize.aspectFitted(CGSize(width: 384.0, height: 384.0))
|
|
let source = AnimatedStickerResourceSource(account: self.context.account, resource: file.resource, isVideo: file.isVideoSticker || file.mimeType == "video/webm")
|
|
animationNode.setup(source: source, width: Int(fittedDimensions.width), height: Int(fittedDimensions.height), playbackMode: .loop, mode: .direct(cachePathPrefix: pathPrefix))
|
|
}
|
|
}
|
|
self.animationNode?.visibility = isVisible
|
|
if isVisible, let videoContent = self.videoContent, self.videoLoopCount < maxVideoLoopCount {
|
|
var useDirectCache = false
|
|
if self.internalSize.width <= 200.0 {
|
|
useDirectCache = true
|
|
}
|
|
|
|
if useDirectCache {
|
|
if self.videoItemLayer == nil {
|
|
let animationData = EntityKeyboardAnimationData(file: TelegramMediaFile.Accessor(videoContent.fileReference.media))
|
|
let videoItemLayer = EmojiKeyboardItemLayer(
|
|
item: EmojiPagerContentComponent.Item(
|
|
animationData: animationData,
|
|
content: .animation(animationData),
|
|
itemFile: TelegramMediaFile.Accessor(videoContent.fileReference.media),
|
|
subgroupId: nil,
|
|
icon: .none,
|
|
tintMode: .none
|
|
),
|
|
context: self.context,
|
|
attemptSynchronousLoad: false,
|
|
content: .animation(animationData),
|
|
cache: self.context.animationCache,
|
|
renderer: self.context.animationRenderer,
|
|
placeholderColor: .clear,
|
|
blurredBadgeColor: .clear,
|
|
accentIconColor: .white,
|
|
pointSize: self.internalSize,
|
|
onUpdateDisplayPlaceholder: { _, _ in
|
|
}
|
|
)
|
|
videoItemLayer.onLoop = { [weak self] in
|
|
if let self {
|
|
self.videoLoopCount += 1
|
|
if self.videoLoopCount >= maxVideoLoopCount {
|
|
self.itemLayer?.isVisibleForAnimations = false
|
|
}
|
|
}
|
|
}
|
|
|
|
self.videoItemLayer = videoItemLayer
|
|
self.layer.addSublayer(videoItemLayer)
|
|
}
|
|
} else {
|
|
if let videoItemLayer = self.videoItemLayer {
|
|
self.videoItemLayer = nil
|
|
videoItemLayer.removeFromSuperlayer()
|
|
}
|
|
}
|
|
|
|
if useDirectCache {
|
|
if let videoNode = self.videoNode {
|
|
self.videoNode = nil
|
|
videoNode.removeFromSupernode()
|
|
}
|
|
} else {
|
|
if self.videoNode == nil {
|
|
let context = self.context
|
|
let mediaManager = context.sharedContext.mediaManager
|
|
let videoNode = UniversalVideoNode(context: context, postbox: context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: VideoDecoration(), content: videoContent, priority: .embedded)
|
|
videoNode.clipsToBounds = true
|
|
videoNode.isUserInteractionEnabled = false
|
|
videoNode.isHidden = true
|
|
videoNode.playbackCompleted = { [weak self] in
|
|
if let strongSelf = self {
|
|
strongSelf.videoLoopCount += 1
|
|
if strongSelf.videoLoopCount >= maxVideoLoopCount {
|
|
if let videoNode = strongSelf.videoNode {
|
|
strongSelf.videoNode = nil
|
|
videoNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak videoNode] _ in
|
|
videoNode?.removeFromSupernode()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let _ = videoContent.startTimestamp {
|
|
self.playbackStartDisposable.set((videoNode.status
|
|
|> map { status -> Bool in
|
|
if let status = status, case .playing = status.status {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|> filter { playing in
|
|
return playing
|
|
}
|
|
|> take(1)
|
|
|> deliverOnMainQueue).startStrict(completed: { [weak self] in
|
|
if let strongSelf = self {
|
|
Queue.mainQueue().after(0.15) {
|
|
strongSelf.videoNode?.isHidden = false
|
|
}
|
|
}
|
|
}))
|
|
} else {
|
|
self.playbackStartDisposable.set(nil)
|
|
videoNode.isHidden = false
|
|
}
|
|
videoNode.canAttachContent = true
|
|
videoNode.play()
|
|
|
|
self.addSubnode(videoNode)
|
|
self.videoNode = videoNode
|
|
}
|
|
}
|
|
} else if let videoNode = self.videoNode {
|
|
self.videoNode = nil
|
|
videoNode.removeFromSupernode()
|
|
}
|
|
if self.videoLoopCount < maxVideoLoopCount {
|
|
self.itemLayer?.isVisibleForAnimations = isVisible
|
|
}
|
|
self.videoItemLayer?.isVisibleForAnimations = isVisible
|
|
}
|
|
|
|
public func updateLayout(size: CGSize, cornerRadius: CGFloat, transition: ContainedViewLayoutTransition) {
|
|
self.validLayout = (size, cornerRadius)
|
|
self.layer.cornerRadius = cornerRadius
|
|
|
|
self.backgroundNode.frame = CGRect(origin: .zero, size: size)
|
|
|
|
if let videoNode = self.videoNode {
|
|
videoNode.frame = CGRect(origin: .zero, size: size)
|
|
videoNode.updateLayout(size: size, transition: transition)
|
|
}
|
|
if let videoItemLayer = self.videoItemLayer {
|
|
videoItemLayer.frame = CGRect(origin: .zero, size: size)
|
|
}
|
|
|
|
let itemSize = CGSize(width: size.width * 0.67, height: size.height * 0.67)
|
|
let itemFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - itemSize.width) / 2.0), y: floorToScreenPixels((size.height - itemSize.height) / 2.0)), size: itemSize)
|
|
if let animationNode = self.animationNode {
|
|
animationNode.frame = itemFrame
|
|
animationNode.updateLayout(size: itemSize)
|
|
}
|
|
if let itemLayer = self.itemLayer {
|
|
itemLayer.frame = itemFrame
|
|
}
|
|
}
|
|
|
|
public func resetPlayback() {
|
|
self.videoLoopCount = 0
|
|
}
|
|
}
|
|
|
|
private final class VideoDecoration: UniversalVideoDecoration {
|
|
public let backgroundNode: ASDisplayNode? = nil
|
|
public let contentContainerNode: ASDisplayNode
|
|
public let foregroundNode: ASDisplayNode? = nil
|
|
|
|
private var contentNode: (ASDisplayNode & UniversalVideoContentNode)?
|
|
|
|
private var validLayout: (size: CGSize, actualSize: CGSize)?
|
|
|
|
public init() {
|
|
self.contentContainerNode = ASDisplayNode()
|
|
}
|
|
|
|
public func updateContentNode(_ contentNode: (UniversalVideoContentNode & ASDisplayNode)?) {
|
|
if self.contentNode !== contentNode {
|
|
let previous = self.contentNode
|
|
self.contentNode = contentNode
|
|
|
|
if let previous = previous {
|
|
if previous.supernode === self.contentContainerNode {
|
|
previous.removeFromSupernode()
|
|
}
|
|
}
|
|
|
|
if let contentNode = contentNode {
|
|
if contentNode.supernode !== self.contentContainerNode {
|
|
self.contentContainerNode.addSubnode(contentNode)
|
|
if let validLayout = self.validLayout {
|
|
contentNode.frame = CGRect(origin: CGPoint(), size: validLayout.size)
|
|
contentNode.updateLayout(size: validLayout.size, actualSize: validLayout.actualSize, transition: .immediate)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public func updateCorners(_ corners: ImageCorners) {
|
|
self.contentContainerNode.clipsToBounds = true
|
|
if isRoundEqualCorners(corners) {
|
|
self.contentContainerNode.cornerRadius = corners.topLeft.radius
|
|
} else {
|
|
let boundingSize: CGSize = CGSize(width: max(corners.topLeft.radius, corners.bottomLeft.radius) + max(corners.topRight.radius, corners.bottomRight.radius), height: max(corners.topLeft.radius, corners.topRight.radius) + max(corners.bottomLeft.radius, corners.bottomRight.radius))
|
|
let size: CGSize = CGSize(width: boundingSize.width + corners.extendedEdges.left + corners.extendedEdges.right, height: boundingSize.height + corners.extendedEdges.top + corners.extendedEdges.bottom)
|
|
let arguments = TransformImageArguments(corners: corners, imageSize: size, boundingSize: boundingSize, intrinsicInsets: UIEdgeInsets())
|
|
guard let context = DrawingContext(size: size, clear: true) else {
|
|
return
|
|
}
|
|
context.withContext { ctx in
|
|
ctx.setFillColor(UIColor.black.cgColor)
|
|
ctx.fill(arguments.drawingRect)
|
|
}
|
|
addCorners(context, arguments: arguments)
|
|
|
|
if let maskImage = context.generateImage() {
|
|
let mask = CALayer()
|
|
mask.contents = maskImage.cgImage
|
|
mask.contentsScale = maskImage.scale
|
|
mask.contentsCenter = CGRect(x: max(corners.topLeft.radius, corners.bottomLeft.radius) / maskImage.size.width, y: max(corners.topLeft.radius, corners.topRight.radius) / maskImage.size.height, width: (maskImage.size.width - max(corners.topLeft.radius, corners.bottomLeft.radius) - max(corners.topRight.radius, corners.bottomRight.radius)) / maskImage.size.width, height: (maskImage.size.height - max(corners.topLeft.radius, corners.topRight.radius) - max(corners.bottomLeft.radius, corners.bottomRight.radius)) / maskImage.size.height)
|
|
|
|
self.contentContainerNode.layer.mask = mask
|
|
self.contentContainerNode.layer.mask?.frame = self.contentContainerNode.bounds
|
|
}
|
|
}
|
|
}
|
|
|
|
public func updateClippingFrame(_ frame: CGRect, completion: (() -> Void)?) {
|
|
self.contentContainerNode.layer.animate(from: NSValue(cgRect: self.contentContainerNode.bounds), to: NSValue(cgRect: frame), keyPath: "bounds", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
|
|
})
|
|
|
|
if let maskLayer = self.contentContainerNode.layer.mask {
|
|
maskLayer.animate(from: NSValue(cgRect: self.contentContainerNode.bounds), to: NSValue(cgRect: frame), keyPath: "bounds", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
|
|
})
|
|
|
|
maskLayer.animate(from: NSValue(cgPoint: maskLayer.position), to: NSValue(cgPoint: frame.center), keyPath: "position", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
|
|
})
|
|
}
|
|
|
|
if let contentNode = self.contentNode {
|
|
contentNode.layer.animate(from: NSValue(cgPoint: contentNode.layer.position), to: NSValue(cgPoint: frame.center), keyPath: "position", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
|
|
completion?()
|
|
})
|
|
}
|
|
}
|
|
|
|
public func updateContentNodeSnapshot(_ snapshot: UIView?) {
|
|
}
|
|
|
|
public func updateLayout(size: CGSize, actualSize: CGSize, transition: ContainedViewLayoutTransition) {
|
|
self.validLayout = (size, actualSize)
|
|
|
|
let bounds = CGRect(origin: CGPoint(), size: size)
|
|
if let backgroundNode = self.backgroundNode {
|
|
transition.updateFrame(node: backgroundNode, frame: bounds)
|
|
}
|
|
if let foregroundNode = self.foregroundNode {
|
|
transition.updateFrame(node: foregroundNode, frame: bounds)
|
|
}
|
|
transition.updateFrame(node: self.contentContainerNode, frame: bounds)
|
|
if let maskLayer = self.contentContainerNode.layer.mask {
|
|
transition.updateFrame(layer: maskLayer, frame: bounds)
|
|
}
|
|
if let contentNode = self.contentNode {
|
|
transition.updateFrame(node: contentNode, frame: CGRect(origin: CGPoint(), size: size))
|
|
contentNode.updateLayout(size: size, actualSize: actualSize, transition: transition)
|
|
}
|
|
}
|
|
|
|
public func setStatus(_ status: Signal<MediaPlayerStatus?, NoError>) {
|
|
}
|
|
|
|
public func tap() {
|
|
}
|
|
}
|