mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-16 05:55:20 +00:00
1152 lines
50 KiB
Swift
1152 lines
50 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import SwiftSignalKit
|
|
import Display
|
|
import AnimationCache
|
|
import Accelerate
|
|
import IOSurface
|
|
|
|
public protocol MultiAnimationRenderer: AnyObject {
|
|
func add(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, unique: Bool, size: CGSize, fetch: @escaping (AnimationCacheFetchOptions) -> Disposable) -> Disposable
|
|
func loadFirstFrameSynchronously(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize) -> Bool
|
|
func loadFirstFrame(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, fetch: ((AnimationCacheFetchOptions) -> Disposable)?, completion: @escaping (Bool, Bool) -> Void) -> Disposable
|
|
func loadFirstFrameAsImage(cache: AnimationCache, itemId: String, size: CGSize, fetch: ((AnimationCacheFetchOptions) -> Disposable)?, completion: @escaping (CGImage?) -> Void) -> Disposable
|
|
func setFrameIndex(itemId: String, size: CGSize, frameIndex: Int, placeholder: UIImage)
|
|
}
|
|
|
|
private var nextRenderTargetId: Int64 = 1
|
|
|
|
open class MultiAnimationRenderTarget: SimpleLayer {
|
|
public let id: Int64
|
|
public var numFrames: Int?
|
|
|
|
let deinitCallbacks = Bag<() -> Void>()
|
|
let updateStateCallbacks = Bag<() -> Void>()
|
|
|
|
public final var shouldBeAnimating: Bool = false {
|
|
didSet {
|
|
if self.shouldBeAnimating != oldValue {
|
|
for f in self.updateStateCallbacks.copyItems() {
|
|
f()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public var blurredRepresentationBackgroundColor: UIColor?
|
|
public var blurredRepresentationTarget: CALayer? {
|
|
didSet {
|
|
if self.blurredRepresentationTarget !== oldValue {
|
|
for f in self.updateStateCallbacks.copyItems() {
|
|
f()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public override init() {
|
|
assert(Thread.isMainThread)
|
|
|
|
self.id = nextRenderTargetId
|
|
nextRenderTargetId += 1
|
|
|
|
super.init()
|
|
}
|
|
|
|
public override init(layer: Any) {
|
|
guard let layer = layer as? MultiAnimationRenderTarget else {
|
|
preconditionFailure()
|
|
}
|
|
|
|
self.id = layer.id
|
|
|
|
super.init(layer: layer)
|
|
}
|
|
|
|
required public init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
deinit {
|
|
for f in self.deinitCallbacks.copyItems() {
|
|
f()
|
|
}
|
|
}
|
|
|
|
open func updateDisplayPlaceholder(displayPlaceholder: Bool) {
|
|
}
|
|
|
|
open func transitionToContents(_ contents: AnyObject, didLoop: Bool) {
|
|
}
|
|
}
|
|
|
|
private final class LoadFrameGroupTask {
|
|
let task: () -> () -> Void
|
|
let queueAffinity: Int
|
|
|
|
init(task: @escaping () -> () -> Void, queueAffinity: Int) {
|
|
self.task = task
|
|
self.queueAffinity = queueAffinity
|
|
}
|
|
}
|
|
|
|
private var yuvToRgbConversion: vImage_YpCbCrToARGB = {
|
|
var info = vImage_YpCbCrToARGB()
|
|
var pixelRange = vImage_YpCbCrPixelRange(Yp_bias: 16, CbCr_bias: 128, YpRangeMax: 235, CbCrRangeMax: 240, YpMax: 255, YpMin: 0, CbCrMax: 255, CbCrMin: 0)
|
|
vImageConvert_YpCbCrToARGB_GenerateConversion(kvImage_YpCbCrToARGBMatrix_ITU_R_709_2, &pixelRange, &info, kvImage420Yp8_Cb8_Cr8, kvImageARGB8888, 0)
|
|
return info
|
|
}()
|
|
|
|
private final class ItemAnimationContext {
|
|
fileprivate final class Frame {
|
|
let frame: AnimationCacheItemFrame
|
|
let duration: Double
|
|
|
|
let contentsAsImage: UIImage?
|
|
let contentsAsCVPixelBuffer: CVPixelBuffer?
|
|
|
|
let size: CGSize
|
|
|
|
var remainingDuration: Double
|
|
|
|
private var blurredRepresentationValue: UIImage?
|
|
|
|
init?(frame: AnimationCacheItemFrame) {
|
|
self.frame = frame
|
|
self.duration = frame.duration
|
|
self.remainingDuration = frame.duration
|
|
|
|
switch frame.format {
|
|
case let .rgba(data, width, height, bytesPerRow):
|
|
guard let context = DrawingContext(size: CGSize(width: CGFloat(width), height: CGFloat(height)), scale: 1.0, opaque: false, bytesPerRow: bytesPerRow) else {
|
|
return nil
|
|
}
|
|
|
|
data.withUnsafeBytes { bytes -> Void in
|
|
memcpy(context.bytes, bytes.baseAddress!, height * bytesPerRow)
|
|
}
|
|
|
|
guard let image = context.generateImage() else {
|
|
return nil
|
|
}
|
|
|
|
self.contentsAsImage = image
|
|
self.contentsAsCVPixelBuffer = nil
|
|
self.size = CGSize(width: CGFloat(width), height: CGFloat(height))
|
|
case let .yuva(y, u, v, a):
|
|
var pixelBuffer: CVPixelBuffer? = nil
|
|
let _ = CVPixelBufferCreate(kCFAllocatorDefault, y.width, y.height, kCVPixelFormatType_420YpCbCr8VideoRange_8A_TriPlanar, [
|
|
kCVPixelBufferIOSurfacePropertiesKey: NSDictionary()
|
|
] as CFDictionary, &pixelBuffer)
|
|
guard let pixelBuffer else {
|
|
return nil
|
|
}
|
|
|
|
CVPixelBufferLockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))
|
|
defer {
|
|
CVPixelBufferUnlockBaseAddress(pixelBuffer, CVPixelBufferLockFlags(rawValue: 0))
|
|
}
|
|
guard let baseAddressY = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0) else {
|
|
return nil
|
|
}
|
|
guard let baseAddressCbCr = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1) else {
|
|
return nil
|
|
}
|
|
guard let baseAddressA = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 2) else {
|
|
return nil
|
|
}
|
|
|
|
let dstBufferY = vImage_Buffer(data: UnsafeMutableRawPointer(mutating: baseAddressY), height: vImagePixelCount(y.height), width: vImagePixelCount(y.width), rowBytes: CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 0))
|
|
let dstBufferCbCr = vImage_Buffer(data: UnsafeMutableRawPointer(mutating: baseAddressCbCr), height: vImagePixelCount(y.height / 2), width: vImagePixelCount(y.width / 2), rowBytes: CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 1))
|
|
let dstBufferA = vImage_Buffer(data: UnsafeMutableRawPointer(mutating: baseAddressA), height: vImagePixelCount(y.height), width: vImagePixelCount(y.width), rowBytes: CVPixelBufferGetBytesPerRowOfPlane(pixelBuffer, 2))
|
|
|
|
y.data.withUnsafeBytes { (yBytes: UnsafeRawBufferPointer) -> Void in
|
|
if dstBufferY.rowBytes == y.bytesPerRow {
|
|
memcpy(dstBufferY.data, yBytes.baseAddress!, yBytes.count)
|
|
} else {
|
|
for i in 0 ..< y.height {
|
|
memcpy(dstBufferY.data.advanced(by: dstBufferY.rowBytes * i), yBytes.baseAddress!.advanced(by: y.bytesPerRow * i), y.bytesPerRow)
|
|
}
|
|
}
|
|
}
|
|
|
|
a.data.withUnsafeBytes { (aBytes: UnsafeRawBufferPointer) -> Void in
|
|
if dstBufferA.rowBytes == a.bytesPerRow {
|
|
memcpy(dstBufferA.data, aBytes.baseAddress!, aBytes.count)
|
|
} else {
|
|
for i in 0 ..< y.height {
|
|
memcpy(dstBufferA.data.advanced(by: dstBufferA.rowBytes * i), aBytes.baseAddress!.advanced(by: a.bytesPerRow * i), a.bytesPerRow)
|
|
}
|
|
}
|
|
}
|
|
|
|
u.data.withUnsafeBytes { (uBytes: UnsafeRawBufferPointer) -> Void in
|
|
v.data.withUnsafeBytes { (vBytes: UnsafeRawBufferPointer) -> Void in
|
|
let sourceU = vImage_Buffer(
|
|
data: UnsafeMutableRawPointer(mutating: uBytes.baseAddress!),
|
|
height: vImagePixelCount(u.height),
|
|
width: vImagePixelCount(u.width),
|
|
rowBytes: u.bytesPerRow
|
|
)
|
|
let sourceV = vImage_Buffer(
|
|
data: UnsafeMutableRawPointer(mutating: vBytes.baseAddress!),
|
|
height: vImagePixelCount(v.height),
|
|
width: vImagePixelCount(v.width),
|
|
rowBytes: v.bytesPerRow
|
|
)
|
|
|
|
withUnsafePointer(to: sourceU, { sourceU in
|
|
withUnsafePointer(to: sourceV, { sourceV in
|
|
var srcPlanarBuffers: [
|
|
UnsafePointer<vImage_Buffer>?
|
|
] = [sourceU, sourceV]
|
|
var destChannels: [UnsafeMutableRawPointer?] = [
|
|
dstBufferCbCr.data.advanced(by: 1),
|
|
dstBufferCbCr.data
|
|
]
|
|
|
|
let channelCount = 2
|
|
|
|
vImageConvert_PlanarToChunky8(
|
|
&srcPlanarBuffers,
|
|
&destChannels,
|
|
UInt32(channelCount),
|
|
MemoryLayout<Pixel_8>.stride * channelCount,
|
|
vImagePixelCount(u.width),
|
|
vImagePixelCount(u.height),
|
|
dstBufferCbCr.rowBytes,
|
|
vImage_Flags(kvImageDoNotTile)
|
|
)
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
self.contentsAsImage = nil
|
|
self.contentsAsCVPixelBuffer = pixelBuffer
|
|
self.size = CGSize(width: CGFloat(y.width), height: CGFloat(y.height))
|
|
}
|
|
}
|
|
|
|
func blurredRepresentation(color: UIColor?) -> UIImage? {
|
|
if let blurredRepresentationValue = self.blurredRepresentationValue {
|
|
return blurredRepresentationValue
|
|
}
|
|
|
|
switch frame.format {
|
|
case let .rgba(data, width, height, bytesPerRow):
|
|
let blurredWidth = 12
|
|
let blurredHeight = 12
|
|
guard let context = DrawingContext(size: CGSize(width: CGFloat(blurredWidth), height: CGFloat(blurredHeight)), scale: 1.0, opaque: true, bytesPerRow: bytesPerRow) else {
|
|
return nil
|
|
}
|
|
|
|
let size = CGSize(width: CGFloat(blurredWidth), height: CGFloat(blurredHeight))
|
|
|
|
data.withUnsafeBytes { bytes -> Void in
|
|
if let dataProvider = CGDataProvider(dataInfo: nil, data: bytes.baseAddress!, size: bytes.count, releaseData: { _, _, _ in }) {
|
|
let image = CGImage(
|
|
width: width,
|
|
height: height,
|
|
bitsPerComponent: 8,
|
|
bitsPerPixel: 32,
|
|
bytesPerRow: bytesPerRow,
|
|
space: DeviceGraphicsContextSettings.shared.colorSpace,
|
|
bitmapInfo: DeviceGraphicsContextSettings.shared.transparentBitmapInfo,
|
|
provider: dataProvider,
|
|
decode: nil,
|
|
shouldInterpolate: true,
|
|
intent: .defaultIntent
|
|
)
|
|
if let image = image {
|
|
context.withFlippedContext { c in
|
|
c.setFillColor((color ?? .white).cgColor)
|
|
c.fill(CGRect(origin: CGPoint(), size: size))
|
|
c.draw(image, in: CGRect(origin: CGPoint(x: -size.width / 2.0, y: -size.height / 2.0), size: CGSize(width: size.width * 1.8, height: size.height * 1.8)))
|
|
}
|
|
}
|
|
}
|
|
|
|
var destinationBuffer = vImage_Buffer()
|
|
destinationBuffer.width = UInt(blurredWidth)
|
|
destinationBuffer.height = UInt(blurredHeight)
|
|
destinationBuffer.data = context.bytes
|
|
destinationBuffer.rowBytes = context.bytesPerRow
|
|
|
|
vImageBoxConvolve_ARGB8888(&destinationBuffer,
|
|
&destinationBuffer,
|
|
nil,
|
|
0, 0,
|
|
UInt32(15),
|
|
UInt32(15),
|
|
nil,
|
|
vImage_Flags(kvImageTruncateKernel))
|
|
|
|
let divisor: Int32 = 0x1000
|
|
|
|
let rwgt: CGFloat = 0.3086
|
|
let gwgt: CGFloat = 0.6094
|
|
let bwgt: CGFloat = 0.0820
|
|
|
|
let adjustSaturation: CGFloat = 1.7
|
|
|
|
let a = (1.0 - adjustSaturation) * rwgt + adjustSaturation
|
|
let b = (1.0 - adjustSaturation) * rwgt
|
|
let c = (1.0 - adjustSaturation) * rwgt
|
|
let d = (1.0 - adjustSaturation) * gwgt
|
|
let e = (1.0 - adjustSaturation) * gwgt + adjustSaturation
|
|
let f = (1.0 - adjustSaturation) * gwgt
|
|
let g = (1.0 - adjustSaturation) * bwgt
|
|
let h = (1.0 - adjustSaturation) * bwgt
|
|
let i = (1.0 - adjustSaturation) * bwgt + adjustSaturation
|
|
|
|
let satMatrix: [CGFloat] = [
|
|
a, b, c, 0,
|
|
d, e, f, 0,
|
|
g, h, i, 0,
|
|
0, 0, 0, 1
|
|
]
|
|
|
|
var matrix: [Int16] = satMatrix.map { value in
|
|
return Int16(value * CGFloat(divisor))
|
|
}
|
|
|
|
vImageMatrixMultiply_ARGB8888(&destinationBuffer, &destinationBuffer, &matrix, divisor, nil, nil, vImage_Flags(kvImageDoNotTile))
|
|
|
|
context.withFlippedContext { c in
|
|
c.setFillColor((color ?? .white).withMultipliedAlpha(0.6).cgColor)
|
|
c.fill(CGRect(origin: CGPoint(), size: size))
|
|
}
|
|
}
|
|
|
|
self.blurredRepresentationValue = context.generateImage()
|
|
return self.blurredRepresentationValue
|
|
case let .yuva(y, u, v, a):
|
|
let blurredWidth = 12
|
|
let blurredHeight = 12
|
|
let size = CGSize(width: blurredWidth, height: blurredHeight)
|
|
|
|
var sourceY = vImage_Buffer(
|
|
data: UnsafeMutableRawPointer(mutating: y.data.withUnsafeBytes { $0.baseAddress! }),
|
|
height: vImagePixelCount(y.height),
|
|
width: vImagePixelCount(y.width),
|
|
rowBytes: y.bytesPerRow
|
|
)
|
|
|
|
var sourceU = vImage_Buffer(
|
|
data: UnsafeMutableRawPointer(mutating: u.data.withUnsafeBytes { $0.baseAddress! }),
|
|
height: vImagePixelCount(u.height),
|
|
width: vImagePixelCount(u.width),
|
|
rowBytes: u.bytesPerRow
|
|
)
|
|
|
|
var sourceV = vImage_Buffer(
|
|
data: UnsafeMutableRawPointer(mutating: v.data.withUnsafeBytes { $0.baseAddress! }),
|
|
height: vImagePixelCount(v.height),
|
|
width: vImagePixelCount(v.width),
|
|
rowBytes: v.bytesPerRow
|
|
)
|
|
|
|
var sourceA = vImage_Buffer(
|
|
data: UnsafeMutableRawPointer(mutating: a.data.withUnsafeBytes { $0.baseAddress! }),
|
|
height: vImagePixelCount(a.height),
|
|
width: vImagePixelCount(a.width),
|
|
rowBytes: a.bytesPerRow
|
|
)
|
|
|
|
let scaledYData = malloc(blurredWidth * blurredHeight)!
|
|
defer {
|
|
free(scaledYData)
|
|
}
|
|
|
|
let scaledUData = malloc(blurredWidth * blurredHeight / 4)!
|
|
defer {
|
|
free(scaledUData)
|
|
}
|
|
|
|
let scaledVData = malloc(blurredWidth * blurredHeight / 4)!
|
|
defer {
|
|
free(scaledVData)
|
|
}
|
|
|
|
let scaledAData = malloc(blurredWidth * blurredHeight)!
|
|
defer {
|
|
free(scaledAData)
|
|
}
|
|
|
|
var scaledY = vImage_Buffer(
|
|
data: scaledYData,
|
|
height: vImagePixelCount(blurredHeight),
|
|
width: vImagePixelCount(blurredWidth),
|
|
rowBytes: blurredWidth
|
|
)
|
|
|
|
var scaledU = vImage_Buffer(
|
|
data: scaledUData,
|
|
height: vImagePixelCount(blurredHeight / 2),
|
|
width: vImagePixelCount(blurredWidth / 2),
|
|
rowBytes: blurredWidth / 2
|
|
)
|
|
|
|
var scaledV = vImage_Buffer(
|
|
data: scaledVData,
|
|
height: vImagePixelCount(blurredHeight / 2),
|
|
width: vImagePixelCount(blurredWidth / 2),
|
|
rowBytes: blurredWidth / 2
|
|
)
|
|
|
|
var scaledA = vImage_Buffer(
|
|
data: scaledAData,
|
|
height: vImagePixelCount(blurredHeight),
|
|
width: vImagePixelCount(blurredWidth),
|
|
rowBytes: blurredWidth
|
|
)
|
|
|
|
vImageScale_Planar8(&sourceY, &scaledY, nil, vImage_Flags(kvImageHighQualityResampling))
|
|
vImageScale_Planar8(&sourceU, &scaledU, nil, vImage_Flags(kvImageHighQualityResampling))
|
|
vImageScale_Planar8(&sourceV, &scaledV, nil, vImage_Flags(kvImageHighQualityResampling))
|
|
vImageScale_Planar8(&sourceA, &scaledA, nil, vImage_Flags(kvImageHighQualityResampling))
|
|
|
|
guard let context = DrawingContext(size: size, scale: 1.0, clear: true) else {
|
|
return nil
|
|
}
|
|
|
|
var destinationBuffer = vImage_Buffer(
|
|
data: context.bytes,
|
|
height: vImagePixelCount(blurredHeight),
|
|
width: vImagePixelCount(blurredWidth),
|
|
rowBytes: context.bytesPerRow
|
|
)
|
|
|
|
var result = kvImageNoError
|
|
|
|
var permuteMap: [UInt8] = [1, 2, 3, 0]
|
|
result = vImageConvert_420Yp8_Cb8_Cr8ToARGB8888(&scaledY, &scaledU, &scaledV, &destinationBuffer, &yuvToRgbConversion, &permuteMap, 255, vImage_Flags(kvImageDoNotTile))
|
|
if result != kvImageNoError {
|
|
return nil
|
|
}
|
|
|
|
result = vImageOverwriteChannels_ARGB8888(&scaledA, &destinationBuffer, &destinationBuffer, 1 << 0, vImage_Flags(kvImageDoNotTile));
|
|
if result != kvImageNoError {
|
|
return nil
|
|
}
|
|
|
|
vImageBoxConvolve_ARGB8888(&destinationBuffer,
|
|
&destinationBuffer,
|
|
nil,
|
|
0, 0,
|
|
UInt32(15),
|
|
UInt32(15),
|
|
nil,
|
|
vImage_Flags(kvImageTruncateKernel))
|
|
|
|
let divisor: Int32 = 0x1000
|
|
|
|
let rwgt: CGFloat = 0.3086
|
|
let gwgt: CGFloat = 0.6094
|
|
let bwgt: CGFloat = 0.0820
|
|
|
|
let adjustSaturation: CGFloat = 1.7
|
|
|
|
let a = (1.0 - adjustSaturation) * rwgt + adjustSaturation
|
|
let b = (1.0 - adjustSaturation) * rwgt
|
|
let c = (1.0 - adjustSaturation) * rwgt
|
|
let d = (1.0 - adjustSaturation) * gwgt
|
|
let e = (1.0 - adjustSaturation) * gwgt + adjustSaturation
|
|
let f = (1.0 - adjustSaturation) * gwgt
|
|
let g = (1.0 - adjustSaturation) * bwgt
|
|
let h = (1.0 - adjustSaturation) * bwgt
|
|
let i = (1.0 - adjustSaturation) * bwgt + adjustSaturation
|
|
|
|
let satMatrix: [CGFloat] = [
|
|
a, b, c, 0,
|
|
d, e, f, 0,
|
|
g, h, i, 0,
|
|
0, 0, 0, 1
|
|
]
|
|
|
|
var matrix: [Int16] = satMatrix.map { value in
|
|
return Int16(value * CGFloat(divisor))
|
|
}
|
|
|
|
vImageMatrixMultiply_ARGB8888(&destinationBuffer, &destinationBuffer, &matrix, divisor, nil, nil, vImage_Flags(kvImageDoNotTile))
|
|
|
|
context.withFlippedContext { c in
|
|
c.setFillColor((color ?? .white).withMultipliedAlpha(0.6).cgColor)
|
|
c.fill(CGRect(origin: CGPoint(), size: size))
|
|
}
|
|
|
|
self.blurredRepresentationValue = context.generateImage()
|
|
return self.blurredRepresentationValue
|
|
}
|
|
}
|
|
}
|
|
|
|
static let queue0 = Queue(name: "ItemAnimationContext-0", qos: .default)
|
|
static let queue1 = Queue(name: "ItemAnimationContext-1", qos: .default)
|
|
|
|
private let useYuvA: Bool
|
|
|
|
private let cache: AnimationCache
|
|
let queueAffinity: Int
|
|
private let stateUpdated: () -> Void
|
|
|
|
private var disposable: Disposable?
|
|
private var displayLink: ConstantDisplayLinkAnimator?
|
|
private var item: Atomic<AnimationCacheItem>?
|
|
private var itemPlaceholderAndFrameIndex: (UIImage, Int)?
|
|
|
|
private var currentFrame: Frame?
|
|
private var loadingFrameTaskId: Int?
|
|
private var nextLoadingFrameTaskId: Int = 0
|
|
|
|
private(set) var isPlaying: Bool = false {
|
|
didSet {
|
|
if self.isPlaying != oldValue {
|
|
self.stateUpdated()
|
|
}
|
|
}
|
|
}
|
|
|
|
let targets = Bag<Weak<MultiAnimationRenderTarget>>()
|
|
|
|
init(cache: AnimationCache, queueAffinity: Int, itemId: String, size: CGSize, useYuvA: Bool, fetch: @escaping (AnimationCacheFetchOptions) -> Disposable, stateUpdated: @escaping () -> Void) {
|
|
self.cache = cache
|
|
self.queueAffinity = queueAffinity
|
|
self.useYuvA = useYuvA
|
|
self.stateUpdated = stateUpdated
|
|
|
|
self.disposable = cache.get(sourceId: itemId, size: size, fetch: fetch).start(next: { [weak self] result in
|
|
Queue.mainQueue().async {
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
if let item = result.item {
|
|
strongSelf.item = Atomic(value: item)
|
|
}
|
|
if let (placeholder, index) = strongSelf.itemPlaceholderAndFrameIndex {
|
|
strongSelf.itemPlaceholderAndFrameIndex = nil
|
|
strongSelf.setFrameIndex(index: index, placeholder: placeholder)
|
|
}
|
|
strongSelf.updateIsPlaying()
|
|
}
|
|
})
|
|
}
|
|
|
|
deinit {
|
|
self.disposable?.dispose()
|
|
self.displayLink?.invalidate()
|
|
}
|
|
|
|
func setFrameIndex(index: Int, placeholder: UIImage) {
|
|
if let item = self.item {
|
|
let nextFrame = item.with { item -> AnimationCacheItemFrame? in
|
|
item.reset()
|
|
for i in 0 ... index {
|
|
let result = item.advance(advance: .frames(1), requestedFormat: .rgba)
|
|
if i == index {
|
|
return result?.frame
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
self.loadingFrameTaskId = nil
|
|
|
|
if let nextFrame = nextFrame, let currentFrame = Frame(frame: nextFrame) {
|
|
self.currentFrame = currentFrame
|
|
|
|
for target in self.targets.copyItems() {
|
|
if let target = target.value {
|
|
if let image = currentFrame.contentsAsImage {
|
|
target.transitionToContents(image.cgImage!, didLoop: false)
|
|
} else if let pixelBuffer = currentFrame.contentsAsCVPixelBuffer {
|
|
target.transitionToContents(pixelBuffer, didLoop: false)
|
|
}
|
|
|
|
if let blurredRepresentationTarget = target.blurredRepresentationTarget {
|
|
blurredRepresentationTarget.contents = currentFrame.blurredRepresentation(color: target.blurredRepresentationBackgroundColor)?.cgImage
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
for target in self.targets.copyItems() {
|
|
if let target = target.value {
|
|
target.transitionToContents(placeholder.cgImage!, didLoop: false)
|
|
}
|
|
}
|
|
|
|
self.itemPlaceholderAndFrameIndex = (placeholder, index)
|
|
}
|
|
}
|
|
|
|
func updateAddedTarget(target: MultiAnimationRenderTarget) {
|
|
if let currentFrame = self.currentFrame {
|
|
if let cgImage = currentFrame.contentsAsImage?.cgImage {
|
|
target.transitionToContents(cgImage, didLoop: false)
|
|
|
|
if let blurredRepresentationTarget = target.blurredRepresentationTarget {
|
|
blurredRepresentationTarget.contents = currentFrame.blurredRepresentation(color: target.blurredRepresentationBackgroundColor)?.cgImage
|
|
}
|
|
} else if let pixelBuffer = currentFrame.contentsAsCVPixelBuffer {
|
|
target.transitionToContents(pixelBuffer, didLoop: false)
|
|
|
|
if let blurredRepresentationTarget = target.blurredRepresentationTarget {
|
|
blurredRepresentationTarget.contents = currentFrame.blurredRepresentation(color: target.blurredRepresentationBackgroundColor)?.cgImage
|
|
}
|
|
}
|
|
}
|
|
|
|
self.updateIsPlaying()
|
|
}
|
|
|
|
func updateIsPlaying() {
|
|
var isPlaying = true
|
|
if self.item == nil {
|
|
isPlaying = false
|
|
}
|
|
|
|
var shouldBeAnimating = false
|
|
for target in self.targets.copyItems() {
|
|
if let target = target.value {
|
|
if target.shouldBeAnimating {
|
|
shouldBeAnimating = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
if !shouldBeAnimating {
|
|
isPlaying = false
|
|
}
|
|
|
|
self.isPlaying = isPlaying
|
|
}
|
|
|
|
func animationTick(advanceTimestamp: Double) -> LoadFrameGroupTask? {
|
|
return self.update(advanceTimestamp: advanceTimestamp)
|
|
}
|
|
|
|
private func update(advanceTimestamp: Double) -> LoadFrameGroupTask? {
|
|
guard let item = self.item else {
|
|
return nil
|
|
}
|
|
|
|
var frameAdvance: AnimationCacheItem.Advance?
|
|
if self.loadingFrameTaskId == nil {
|
|
if let currentFrame = self.currentFrame, advanceTimestamp > 0.0 {
|
|
let divisionFactor = advanceTimestamp / currentFrame.remainingDuration
|
|
let wholeFactor = round(divisionFactor)
|
|
if abs(wholeFactor - divisionFactor) < 0.005 {
|
|
currentFrame.remainingDuration = 0.0
|
|
frameAdvance = .frames(Int(wholeFactor))
|
|
} else {
|
|
currentFrame.remainingDuration -= advanceTimestamp
|
|
if currentFrame.remainingDuration <= 0.0 {
|
|
frameAdvance = .duration(currentFrame.duration + max(0.0, -currentFrame.remainingDuration))
|
|
}
|
|
}
|
|
} else if self.currentFrame == nil {
|
|
frameAdvance = .frames(1)
|
|
}
|
|
}
|
|
|
|
if let frameAdvance = frameAdvance, self.loadingFrameTaskId == nil {
|
|
let taskId = self.nextLoadingFrameTaskId
|
|
self.nextLoadingFrameTaskId += 1
|
|
|
|
self.loadingFrameTaskId = taskId
|
|
let useYuvA = self.useYuvA
|
|
|
|
return LoadFrameGroupTask(task: { [weak self] in
|
|
let currentFrame: (frame: Frame, didLoop: Bool)?
|
|
do {
|
|
if let (frame, didLoop) = try item.tryWith({ item -> (AnimationCacheItemFrame, Bool)? in
|
|
let defaultFormat: AnimationCacheItemFrame.RequestedFormat
|
|
if useYuvA {
|
|
defaultFormat = .yuva(rowAlignment: 1)
|
|
} else {
|
|
defaultFormat = .rgba
|
|
}
|
|
|
|
if let result = item.advance(advance: frameAdvance, requestedFormat: defaultFormat) {
|
|
return (result.frame, result.didLoop)
|
|
} else {
|
|
return nil
|
|
}
|
|
}), let mappedFrame = Frame(frame: frame) {
|
|
currentFrame = (mappedFrame, didLoop)
|
|
} else {
|
|
currentFrame = nil
|
|
}
|
|
} catch {
|
|
assertionFailure()
|
|
currentFrame = nil
|
|
}
|
|
|
|
return {
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
|
|
if strongSelf.loadingFrameTaskId != taskId {
|
|
return
|
|
}
|
|
|
|
strongSelf.loadingFrameTaskId = nil
|
|
|
|
if let currentFrame = currentFrame {
|
|
strongSelf.currentFrame = currentFrame.frame
|
|
for target in strongSelf.targets.copyItems() {
|
|
if let target = target.value {
|
|
if let image = currentFrame.frame.contentsAsImage {
|
|
target.transitionToContents(image.cgImage!, didLoop: currentFrame.didLoop)
|
|
} else if let pixelBuffer = currentFrame.frame.contentsAsCVPixelBuffer {
|
|
target.transitionToContents(pixelBuffer, didLoop: currentFrame.didLoop)
|
|
}
|
|
|
|
if let blurredRepresentationTarget = target.blurredRepresentationTarget {
|
|
blurredRepresentationTarget.contents = currentFrame.frame.blurredRepresentation(color: target.blurredRepresentationBackgroundColor)?.cgImage
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}, queueAffinity: self.queueAffinity)
|
|
}
|
|
|
|
if let _ = self.currentFrame {
|
|
for target in self.targets.copyItems() {
|
|
if let target = target.value {
|
|
target.updateDisplayPlaceholder(displayPlaceholder: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public final class MultiAnimationRendererImpl: MultiAnimationRenderer {
|
|
private final class GroupContext {
|
|
private let firstFrameQueue: Queue
|
|
private let stateUpdated: () -> Void
|
|
|
|
private struct ItemKey: Hashable {
|
|
var id: String
|
|
var width: Int
|
|
var height: Int
|
|
var uniqueId: Int
|
|
}
|
|
|
|
private var itemContexts: [ItemKey: ItemAnimationContext] = [:]
|
|
private var nextQueueAffinity: Int = 0
|
|
private var nextUniqueId: Int = 1
|
|
|
|
private(set) var isPlaying: Bool = false {
|
|
didSet {
|
|
if self.isPlaying != oldValue {
|
|
self.stateUpdated()
|
|
}
|
|
}
|
|
}
|
|
|
|
init(firstFrameQueue: Queue, stateUpdated: @escaping () -> Void) {
|
|
self.firstFrameQueue = firstFrameQueue
|
|
self.stateUpdated = stateUpdated
|
|
}
|
|
|
|
func add(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, unique: Bool, size: CGSize, useYuvA: Bool, fetch: @escaping (AnimationCacheFetchOptions) -> Disposable) -> Disposable {
|
|
var uniqueId = 0
|
|
if unique {
|
|
uniqueId = self.nextUniqueId
|
|
self.nextUniqueId += 1
|
|
}
|
|
|
|
let itemKey = ItemKey(id: itemId, width: Int(size.width), height: Int(size.height), uniqueId: uniqueId)
|
|
let itemContext: ItemAnimationContext
|
|
if let current = self.itemContexts[itemKey] {
|
|
itemContext = current
|
|
} else {
|
|
let queueAffinity = self.nextQueueAffinity
|
|
self.nextQueueAffinity += 1
|
|
itemContext = ItemAnimationContext(cache: cache, queueAffinity: queueAffinity, itemId: itemId, size: size, useYuvA: useYuvA, fetch: fetch, stateUpdated: { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.updateIsPlaying()
|
|
})
|
|
self.itemContexts[itemKey] = itemContext
|
|
}
|
|
|
|
let index = itemContext.targets.add(Weak(target))
|
|
itemContext.updateAddedTarget(target: target)
|
|
|
|
let deinitIndex = target.deinitCallbacks.add { [weak self, weak itemContext] in
|
|
Queue.mainQueue().async {
|
|
guard let strongSelf = self, let itemContext = itemContext, strongSelf.itemContexts[itemKey] === itemContext else {
|
|
return
|
|
}
|
|
itemContext.targets.remove(index)
|
|
if itemContext.targets.isEmpty {
|
|
strongSelf.itemContexts.removeValue(forKey: itemKey)
|
|
}
|
|
}
|
|
}
|
|
|
|
let updateStateIndex = target.updateStateCallbacks.add { [weak itemContext] in
|
|
guard let itemContext = itemContext else {
|
|
return
|
|
}
|
|
itemContext.updateIsPlaying()
|
|
}
|
|
|
|
return ActionDisposable { [weak self, weak itemContext, weak target] in
|
|
guard let strongSelf = self, let itemContext = itemContext, strongSelf.itemContexts[itemKey] === itemContext else {
|
|
return
|
|
}
|
|
if let target = target {
|
|
target.deinitCallbacks.remove(deinitIndex)
|
|
target.updateStateCallbacks.remove(updateStateIndex)
|
|
}
|
|
itemContext.targets.remove(index)
|
|
if itemContext.targets.isEmpty {
|
|
strongSelf.itemContexts.removeValue(forKey: itemKey)
|
|
}
|
|
}.strict()
|
|
}
|
|
|
|
func loadFirstFrameSynchronously(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize) -> Bool {
|
|
if let item = cache.getFirstFrameSynchronously(sourceId: itemId, size: size) {
|
|
guard let frame = item.advance(advance: .frames(1), requestedFormat: .rgba) else {
|
|
return false
|
|
}
|
|
guard let loadedFrame = ItemAnimationContext.Frame(frame: frame.frame) else {
|
|
return false
|
|
}
|
|
|
|
if let image = loadedFrame.contentsAsImage {
|
|
target.contents = image.cgImage
|
|
} else if let pixelBuffer = loadedFrame.contentsAsCVPixelBuffer {
|
|
target.contents = pixelBuffer
|
|
}
|
|
target.numFrames = item.numFrames
|
|
|
|
if let blurredRepresentationTarget = target.blurredRepresentationTarget {
|
|
blurredRepresentationTarget.contents = loadedFrame.blurredRepresentation(color: target.blurredRepresentationBackgroundColor)?.cgImage
|
|
}
|
|
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
func loadFirstFrame(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, fetch: ((AnimationCacheFetchOptions) -> Disposable)?, completion: @escaping (Bool, Bool) -> Void) -> Disposable {
|
|
var hadIntermediateUpdate = false
|
|
return cache.getFirstFrame(queue: self.firstFrameQueue, sourceId: itemId, size: size, fetch: fetch, completion: { [weak target] item in
|
|
guard let item = item.item else {
|
|
let isFinal = item.isFinal
|
|
hadIntermediateUpdate = true
|
|
Queue.mainQueue().async {
|
|
completion(false, isFinal)
|
|
}
|
|
return
|
|
}
|
|
|
|
let loadedFrame: ItemAnimationContext.Frame?
|
|
if let frame = item.advance(advance: .frames(1), requestedFormat: .rgba) {
|
|
loadedFrame = ItemAnimationContext.Frame(frame: frame.frame)
|
|
} else {
|
|
loadedFrame = nil
|
|
}
|
|
|
|
Queue.mainQueue().async {
|
|
guard let target = target else {
|
|
completion(false, true)
|
|
return
|
|
}
|
|
target.numFrames = item.numFrames
|
|
if let loadedFrame = loadedFrame {
|
|
if let cgImage = loadedFrame.contentsAsImage?.cgImage {
|
|
if hadIntermediateUpdate {
|
|
target.transitionToContents(cgImage, didLoop: false)
|
|
} else {
|
|
target.contents = cgImage
|
|
}
|
|
} else if let pixelBuffer = loadedFrame.contentsAsCVPixelBuffer {
|
|
if hadIntermediateUpdate {
|
|
target.transitionToContents(pixelBuffer, didLoop: false)
|
|
} else {
|
|
target.contents = pixelBuffer
|
|
}
|
|
}
|
|
|
|
if let blurredRepresentationTarget = target.blurredRepresentationTarget {
|
|
blurredRepresentationTarget.contents = loadedFrame.blurredRepresentation(color: target.blurredRepresentationBackgroundColor)?.cgImage
|
|
}
|
|
|
|
completion(true, true)
|
|
} else {
|
|
completion(false, true)
|
|
}
|
|
}
|
|
}).strict()
|
|
}
|
|
|
|
func loadFirstFrameAsImage(cache: AnimationCache, itemId: String, size: CGSize, fetch: ((AnimationCacheFetchOptions) -> Disposable)?, completion: @escaping (CGImage?) -> Void) -> Disposable {
|
|
return cache.getFirstFrame(queue: self.firstFrameQueue, sourceId: itemId, size: size, fetch: fetch, completion: { item in
|
|
guard let item = item.item else {
|
|
Queue.mainQueue().async {
|
|
completion(nil)
|
|
}
|
|
return
|
|
}
|
|
|
|
let loadedFrame: ItemAnimationContext.Frame?
|
|
if let frame = item.advance(advance: .frames(1), requestedFormat: .rgba) {
|
|
loadedFrame = ItemAnimationContext.Frame(frame: frame.frame)
|
|
} else {
|
|
loadedFrame = nil
|
|
}
|
|
|
|
Queue.mainQueue().async {
|
|
if let loadedFrame = loadedFrame {
|
|
if let cgImage = loadedFrame.contentsAsImage?.cgImage {
|
|
completion(cgImage)
|
|
} else {
|
|
completion(nil)
|
|
}
|
|
} else {
|
|
completion(nil)
|
|
}
|
|
}
|
|
}).strict()
|
|
}
|
|
|
|
func setFrameIndex(itemId: String, size: CGSize, frameIndex: Int, placeholder: UIImage) {
|
|
if let itemContext = self.itemContexts[ItemKey(id: itemId, width: Int(size.width), height: Int(size.height), uniqueId: 0)] {
|
|
itemContext.setFrameIndex(index: frameIndex, placeholder: placeholder)
|
|
}
|
|
}
|
|
|
|
private func updateIsPlaying() {
|
|
var isPlaying = false
|
|
for (_, itemContext) in self.itemContexts {
|
|
if itemContext.isPlaying {
|
|
isPlaying = true
|
|
break
|
|
}
|
|
}
|
|
|
|
self.isPlaying = isPlaying
|
|
}
|
|
|
|
func animationTick(advanceTimestamp: Double) -> [LoadFrameGroupTask] {
|
|
var tasks: [LoadFrameGroupTask] = []
|
|
for (_, itemContext) in self.itemContexts {
|
|
if itemContext.isPlaying {
|
|
if let task = itemContext.animationTick(advanceTimestamp: advanceTimestamp) {
|
|
tasks.append(task)
|
|
}
|
|
}
|
|
}
|
|
|
|
return tasks
|
|
}
|
|
}
|
|
|
|
public static let firstFrameQueue = Queue(name: "MultiAnimationRenderer-FirstFrame", qos: .userInteractive)
|
|
|
|
public var useYuvA: Bool = false
|
|
private var groupContext: GroupContext?
|
|
private var frameSkip: Int
|
|
private var displayTimer: Foundation.Timer?
|
|
|
|
private(set) var isPlaying: Bool = false {
|
|
didSet {
|
|
if self.isPlaying != oldValue {
|
|
if self.isPlaying {
|
|
if self.displayTimer == nil {
|
|
final class TimerTarget: NSObject {
|
|
private let f: () -> Void
|
|
|
|
init(_ f: @escaping () -> Void) {
|
|
self.f = f
|
|
}
|
|
|
|
@objc func timerEvent() {
|
|
self.f()
|
|
}
|
|
}
|
|
let frameInterval = Double(self.frameSkip) / 60.0
|
|
let displayTimer = Foundation.Timer(timeInterval: frameInterval, target: TimerTarget { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.animationTick(frameInterval: frameInterval)
|
|
}, selector: #selector(TimerTarget.timerEvent), userInfo: nil, repeats: true)
|
|
self.displayTimer = displayTimer
|
|
RunLoop.main.add(displayTimer, forMode: .common)
|
|
}
|
|
} else {
|
|
if let displayTimer = self.displayTimer {
|
|
self.displayTimer = nil
|
|
displayTimer.invalidate()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
public init() {
|
|
if !ProcessInfo.processInfo.isLowPowerModeEnabled && ProcessInfo.processInfo.processorCount > 2 {
|
|
self.frameSkip = 1
|
|
} else {
|
|
self.frameSkip = 2
|
|
}
|
|
}
|
|
|
|
public func add(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, unique: Bool, size: CGSize, fetch: @escaping (AnimationCacheFetchOptions) -> Disposable) -> Disposable {
|
|
let groupContext: GroupContext
|
|
if let current = self.groupContext {
|
|
groupContext = current
|
|
} else {
|
|
groupContext = GroupContext(firstFrameQueue: MultiAnimationRendererImpl.firstFrameQueue, stateUpdated: { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.updateIsPlaying()
|
|
})
|
|
self.groupContext = groupContext
|
|
}
|
|
|
|
let disposable = groupContext.add(target: target, cache: cache, itemId: itemId, unique: unique, size: size, useYuvA: self.useYuvA, fetch: fetch)
|
|
|
|
return ActionDisposable {
|
|
disposable.dispose()
|
|
}.strict()
|
|
}
|
|
|
|
public func loadFirstFrameSynchronously(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize) -> Bool {
|
|
let groupContext: GroupContext
|
|
if let current = self.groupContext {
|
|
groupContext = current
|
|
} else {
|
|
groupContext = GroupContext(firstFrameQueue: MultiAnimationRendererImpl.firstFrameQueue, stateUpdated: { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.updateIsPlaying()
|
|
})
|
|
self.groupContext = groupContext
|
|
}
|
|
|
|
return groupContext.loadFirstFrameSynchronously(target: target, cache: cache, itemId: itemId, size: size)
|
|
}
|
|
|
|
public func loadFirstFrame(target: MultiAnimationRenderTarget, cache: AnimationCache, itemId: String, size: CGSize, fetch: ((AnimationCacheFetchOptions) -> Disposable)?, completion: @escaping (Bool, Bool) -> Void) -> Disposable {
|
|
let groupContext: GroupContext
|
|
if let current = self.groupContext {
|
|
groupContext = current
|
|
} else {
|
|
groupContext = GroupContext(firstFrameQueue: MultiAnimationRendererImpl.firstFrameQueue, stateUpdated: { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.updateIsPlaying()
|
|
})
|
|
self.groupContext = groupContext
|
|
}
|
|
|
|
return groupContext.loadFirstFrame(target: target, cache: cache, itemId: itemId, size: size, fetch: fetch, completion: completion).strict()
|
|
}
|
|
|
|
public func loadFirstFrameAsImage(cache: AnimationCache, itemId: String, size: CGSize, fetch: ((AnimationCacheFetchOptions) -> Disposable)?, completion: @escaping (CGImage?) -> Void) -> Disposable {
|
|
let groupContext: GroupContext
|
|
if let current = self.groupContext {
|
|
groupContext = current
|
|
} else {
|
|
groupContext = GroupContext(firstFrameQueue: MultiAnimationRendererImpl.firstFrameQueue, stateUpdated: { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.updateIsPlaying()
|
|
})
|
|
self.groupContext = groupContext
|
|
}
|
|
|
|
return groupContext.loadFirstFrameAsImage(cache: cache, itemId: itemId, size: size, fetch: fetch, completion: completion).strict()
|
|
}
|
|
|
|
public func setFrameIndex(itemId: String, size: CGSize, frameIndex: Int, placeholder: UIImage) {
|
|
if let groupContext = self.groupContext {
|
|
groupContext.setFrameIndex(itemId: itemId, size: size, frameIndex: frameIndex, placeholder: placeholder)
|
|
}
|
|
}
|
|
|
|
private func updateIsPlaying() {
|
|
var isPlaying = false
|
|
if let groupContext = self.groupContext {
|
|
if groupContext.isPlaying {
|
|
isPlaying = true
|
|
}
|
|
}
|
|
|
|
self.isPlaying = isPlaying
|
|
}
|
|
|
|
private func animationTick(frameInterval: Double) {
|
|
let secondsPerFrame = frameInterval
|
|
|
|
var tasks: [LoadFrameGroupTask] = []
|
|
if let groupContext = self.groupContext {
|
|
if groupContext.isPlaying {
|
|
tasks.append(contentsOf: groupContext.animationTick(advanceTimestamp: secondsPerFrame))
|
|
}
|
|
}
|
|
|
|
if !tasks.isEmpty {
|
|
let tasks0 = tasks.filter { $0.queueAffinity % 2 == 0 }
|
|
let tasks1 = tasks.filter { $0.queueAffinity % 2 == 1 }
|
|
let allTasks = [tasks0, tasks1]
|
|
|
|
let taskCompletions = Atomic<[Int: [() -> Void]]>(value: [:])
|
|
let queues: [Queue] = [ItemAnimationContext.queue0, ItemAnimationContext.queue1]
|
|
|
|
for i in 0 ..< 2 {
|
|
let partTasks = allTasks[i]
|
|
let id = i
|
|
queues[i].async {
|
|
var completions: [() -> Void] = []
|
|
for task in partTasks {
|
|
let complete = task.task()
|
|
completions.append(complete)
|
|
}
|
|
|
|
var complete = false
|
|
let _ = taskCompletions.modify { current in
|
|
var current = current
|
|
current[id] = completions
|
|
if current.count == 2 {
|
|
complete = true
|
|
}
|
|
return current
|
|
}
|
|
|
|
if complete {
|
|
Queue.mainQueue().async {
|
|
let allCompletions = taskCompletions.with { $0 }
|
|
for (_, fs) in allCompletions {
|
|
for f in fs {
|
|
f()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|