import Foundation import UIKit import AsyncDisplayKit import Display import Postbox import TelegramCore import SyncCore import SwiftSignalKit import TelegramPresentationData import ItemListUI import PresentationDataUtils import AccountContext import TelegramStringFormatting import AccountContext import RadialStatusNode import PhotoResources import MusicAlbumArtResources import UniversalMediaPlayer private let extensionImageCache = Atomic<[UInt32: UIImage]>(value: [:]) private let redColors: (UInt32, UInt32) = (0xf0625d, 0xde524e) private let greenColors: (UInt32, UInt32) = (0x72ce76, 0x54b658) private let blueColors: (UInt32, UInt32) = (0x60b0e8, 0x4597d1) private let yellowColors: (UInt32, UInt32) = (0xf5c565, 0xe5a64e) private let extensionColorsMap: [String: (UInt32, UInt32)] = [ "ppt": redColors, "pptx": redColors, "pdf": redColors, "key": redColors, "xls": greenColors, "xlsx": greenColors, "csv": greenColors, "zip": yellowColors, "rar": yellowColors, "gzip": yellowColors, "ai": yellowColors ] private func generateExtensionImage(colors: (UInt32, UInt32)) -> UIImage? { return generateImage(CGSize(width: 42.0, height: 42.0), contextGenerator: { size, context in context.clear(CGRect(origin: CGPoint(), size: size)) context.translateBy(x: size.width / 2.0, y: size.height / 2.0) context.scaleBy(x: 1.0, y: -1.0) context.translateBy(x: -size.width / 2.0 + 1.0, y: -size.height / 2.0 + 1.0) let radius: CGFloat = 2.0 let cornerSize: CGFloat = 10.0 let size = CGSize(width: 42.0, height: 42.0) context.setFillColor(UIColor(rgb: colors.0).cgColor) context.beginPath() context.move(to: CGPoint(x: 0.0, y: radius)) if !radius.isZero { context.addArc(tangent1End: CGPoint(x: 0.0, y: 0.0), tangent2End: CGPoint(x: radius, y: 0.0), radius: radius) } context.addLine(to: CGPoint(x: size.width - cornerSize, y: 0.0)) context.addLine(to: CGPoint(x: size.width - cornerSize + cornerSize / 4.0, y: cornerSize - cornerSize / 4.0)) context.addLine(to: CGPoint(x: size.width, y: cornerSize)) context.addLine(to: CGPoint(x: size.width, y: size.height - radius)) if !radius.isZero { context.addArc(tangent1End: CGPoint(x: size.width, y: size.height), tangent2End: CGPoint(x: size.width - radius, y: size.height), radius: radius) } context.addLine(to: CGPoint(x: radius, y: size.height)) if !radius.isZero { context.addArc(tangent1End: CGPoint(x: 0.0, y: size.height), tangent2End: CGPoint(x: 0.0, y: size.height - radius), radius: radius) } context.closePath() context.fillPath() context.setFillColor(UIColor(rgb: colors.1).cgColor) context.beginPath() context.move(to: CGPoint(x: size.width - cornerSize, y: 0.0)) context.addLine(to: CGPoint(x: size.width, y: cornerSize)) context.addLine(to: CGPoint(x: size.width - cornerSize + radius, y: cornerSize)) if !radius.isZero { context.addArc(tangent1End: CGPoint(x: size.width - cornerSize, y: cornerSize), tangent2End: CGPoint(x: size.width - cornerSize, y: cornerSize - radius), radius: radius) } context.closePath() context.fillPath() }) } private func extensionImage(fileExtension: String?) -> UIImage? { let colors: (UInt32, UInt32) if let fileExtension = fileExtension { if let extensionColors = extensionColorsMap[fileExtension] { colors = extensionColors } else { colors = blueColors } } else { colors = blueColors } if let cachedImage = (extensionImageCache.with { dict in return dict[colors.0] }) { return cachedImage } else if let image = generateExtensionImage(colors: colors) { let _ = extensionImageCache.modify { dict in var dict = dict dict[colors.0] = image return dict } return image } else { return nil } } private let extensionFont = Font.medium(13.0) private struct FetchControls { let fetch: () -> Void let cancel: () -> Void } private enum FileIconImage: Equatable { case imageRepresentation(TelegramMediaFile, TelegramMediaImageRepresentation) case albumArt(TelegramMediaFile, SharedMediaPlaybackAlbumArt) case roundVideo(TelegramMediaFile) static func ==(lhs: FileIconImage, rhs: FileIconImage) -> Bool { switch lhs { case let .imageRepresentation(file, value): if case .imageRepresentation(file, value) = rhs { return true } else { return false } case let .albumArt(file, value): if case .albumArt(file, value) = rhs { return true } else { return false } case let .roundVideo(file): if case .roundVideo(file) = rhs { return true } else { return false } } } } final class ListMessageFileItemNode: ListMessageNode { private let highlightedBackgroundNode: ASDisplayNode private let separatorNode: ASDisplayNode private var selectionNode: ItemListSelectableControlNode? private let titleNode: TextNode private let descriptionNode: TextNode private let descriptionProgressNode: ImmediateTextNode private let extensionIconNode: ASImageNode private let extensionIconText: TextNode private let iconImageNode: TransformImageNode private let statusButtonNode: HighlightTrackingButtonNode private let statusNode: RadialStatusNode private var waveformNode: AudioWaveformNode? private var waveformForegroundNode: AudioWaveformNode? private var waveformScrubbingNode: MediaPlayerScrubbingNode? private var currentIconImage: FileIconImage? private var currentMedia: Media? private let statusDisposable = MetaDisposable() private let fetchControls = Atomic(value: nil) private var fetchStatus: MediaResourceStatus? private var resourceStatus: FileMediaResourceMediaStatus? private let fetchDisposable = MetaDisposable() private let playbackStatusDisposable = MetaDisposable() private let playbackStatus = Promise() private var downloadStatusIconNode: ASImageNode private var linearProgressNode: ASDisplayNode private let progressNode: RadialProgressNode private var playbackOverlayNode: ListMessagePlaybackOverlayNode? private var context: AccountContext? private (set) var message: Message? private var appliedItem: ListMessageItem? private var layoutParams: ListViewItemLayoutParams? private var contentSizeValue: CGSize? private var currentLeftOffset: CGFloat = 0.0 override var canBeLongTapped: Bool { return true } public required init() { self.separatorNode = ASDisplayNode() self.separatorNode.displaysAsynchronously = false self.separatorNode.isLayerBacked = true self.highlightedBackgroundNode = ASDisplayNode() self.highlightedBackgroundNode.isLayerBacked = true self.titleNode = TextNode() self.titleNode.isUserInteractionEnabled = false self.descriptionNode = TextNode() self.descriptionNode.isUserInteractionEnabled = false self.descriptionProgressNode = ImmediateTextNode() self.descriptionProgressNode.isUserInteractionEnabled = false self.descriptionProgressNode.maximumNumberOfLines = 1 self.extensionIconNode = ASImageNode() self.extensionIconNode.isLayerBacked = true self.extensionIconNode.displaysAsynchronously = false self.extensionIconNode.displayWithoutProcessing = true self.extensionIconText = TextNode() self.extensionIconText.isUserInteractionEnabled = false self.iconImageNode = TransformImageNode() self.iconImageNode.displaysAsynchronously = false self.iconImageNode.contentAnimations = .subsequentUpdates self.statusButtonNode = HighlightTrackingButtonNode() self.statusNode = RadialStatusNode(backgroundNodeColor: .clear) self.statusNode.isUserInteractionEnabled = false self.downloadStatusIconNode = ASImageNode() self.downloadStatusIconNode.isLayerBacked = true self.downloadStatusIconNode.displaysAsynchronously = false self.downloadStatusIconNode.displayWithoutProcessing = true self.progressNode = RadialProgressNode(theme: RadialProgressTheme(backgroundColor: .black, foregroundColor: .white, icon: nil)) //self.progressNode.isLayerBacked = true self.linearProgressNode = ASDisplayNode() self.linearProgressNode.isLayerBacked = true super.init() self.addSubnode(self.separatorNode) self.addSubnode(self.titleNode) self.addSubnode(self.progressNode) self.addSubnode(self.descriptionNode) self.addSubnode(self.descriptionProgressNode) self.addSubnode(self.extensionIconNode) self.addSubnode(self.extensionIconText) self.addSubnode(self.statusNode) self.addSubnode(self.statusButtonNode) self.statusButtonNode.highligthedChanged = { [weak self] highlighted in if let strongSelf = self { if highlighted { strongSelf.statusNode.layer.removeAnimation(forKey: "opacity") strongSelf.statusNode.alpha = 0.4 } else { strongSelf.statusNode.alpha = 1.0 strongSelf.statusNode.layer.animateAlpha(from: 0.4, to: 1.0, duration: 0.2) } } } self.statusButtonNode.addTarget(self, action: #selector(self.statusPressed), forControlEvents: .touchUpInside) } deinit { self.statusDisposable.dispose() self.fetchDisposable.dispose() } required public init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func setupItem(_ item: ListMessageItem) { self.item = item } override public func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) { if let item = item as? ListMessageItem { let doLayout = self.asyncLayout() let merged = (top: false, bottom: false, dateAtBottom: item.getDateAtBottom(top: previousItem, bottom: nextItem)) let (layout, apply) = doLayout(item, params, merged.top, merged.bottom, merged.dateAtBottom) self.contentSize = layout.contentSize self.insets = layout.insets apply(.None) } } override public func animateInsertion(_ currentTimestamp: Double, duration: Double, short: Bool) { super.animateInsertion(currentTimestamp, duration: duration, short: short) self.transitionOffset = self.bounds.size.height * 1.6 self.addTransitionOffsetAnimation(0.0, duration: duration, beginAt: currentTimestamp) } override func animateRemoved(_ currentTimestamp: Double, duration: Double) { self.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false) } override func asyncLayout() -> (_ item: ListMessageItem, _ params: ListViewItemLayoutParams, _ mergedTop: Bool, _ mergedBottom: Bool, _ dateHeaderAtBottom: Bool) -> (ListViewItemNodeLayout, (ListViewItemUpdateAnimation) -> Void) { let titleNodeMakeLayout = TextNode.asyncLayout(self.titleNode) let descriptionNodeMakeLayout = TextNode.asyncLayout(self.descriptionNode) let extensionIconTextMakeLayout = TextNode.asyncLayout(self.extensionIconText) let iconImageLayout = self.iconImageNode.asyncLayout() let currentMedia = self.currentMedia let currentMessage = self.message let currentIconImage = self.currentIconImage let currentItem = self.appliedItem let selectionNodeLayout = ItemListSelectableControlNode.asyncLayout(self.selectionNode) return { [weak self] item, params, _, _, dateHeaderAtBottom in var updatedTheme: PresentationTheme? if currentItem?.theme !== item.theme { updatedTheme = item.theme } let titleFont = Font.medium(floor(item.fontSize.baseDisplaySize * 16.0 / 17.0)) let audioTitleFont = Font.regular(floor(item.fontSize.baseDisplaySize * 16.0 / 17.0)) let descriptionFont = Font.regular(floor(item.fontSize.baseDisplaySize * 13.0 / 17.0)) var leftInset: CGFloat = 65.0 + params.leftInset let rightInset: CGFloat = 8.0 + params.rightInset var leftOffset: CGFloat = 0.0 var selectionNodeWidthAndApply: (CGFloat, (CGSize, Bool) -> ItemListSelectableControlNode)? if case let .selectable(selected) = item.selection { let (selectionWidth, selectionApply) = selectionNodeLayout(item.theme.list.itemCheckColors.strokeColor, item.theme.list.itemCheckColors.fillColor, item.theme.list.itemCheckColors.foregroundColor, selected, false) selectionNodeWidthAndApply = (selectionWidth, selectionApply) leftOffset += selectionWidth } var extensionIconImage: UIImage? var titleText: NSAttributedString? var descriptionText: NSAttributedString? var extensionText: NSAttributedString? var iconImage: FileIconImage? var updateIconImageSignal: Signal<(TransformImageArguments) -> DrawingContext?, NoError>? var updatedStatusSignal: Signal? var updatedPlaybackStatusSignal: Signal? var updatedFetchControls: FetchControls? var waveform: AudioWaveform? var isAudio = false var isVoice = false var isInstantVideo = false let message = item.message var selectedMedia: TelegramMediaFile? for media in message.media { if let file = media as? TelegramMediaFile { selectedMedia = file isInstantVideo = file.isInstantVideo for attribute in file.attributes { if case let .Audio(voice, _, title, performer, waveformValue) = attribute { isAudio = true isVoice = voice titleText = NSAttributedString(string: title ?? (file.fileName ?? "Unknown Track"), font: audioTitleFont, textColor: item.theme.list.itemPrimaryTextColor) let descriptionString: String if let performer = performer { descriptionString = performer } else if let size = file.size { descriptionString = dataSizeString(size, decimalSeparator: item.dateTimeFormat.decimalSeparator) } else { descriptionString = "" } descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.theme.list.itemSecondaryTextColor) if !voice { iconImage = .albumArt(file, SharedMediaPlaybackAlbumArt(thumbnailResource: ExternalMusicAlbumArtResource(title: title ?? "", performer: performer ?? "", isThumbnail: true), fullSizeResource: ExternalMusicAlbumArtResource(title: title ?? "", performer: performer ?? "", isThumbnail: false))) } else { titleText = NSAttributedString(string: " ", font: audioTitleFont, textColor: item.theme.list.itemPrimaryTextColor) descriptionText = NSAttributedString(string: item.message.author?.displayTitle(strings: item.strings, displayOrder: .firstLast) ?? " ", font: descriptionFont, textColor: item.theme.list.itemSecondaryTextColor) waveformValue?.withDataNoCopy { data in waveform = AudioWaveform(bitstream: data, bitsPerSample: 5) } } } } if isInstantVideo { titleText = NSAttributedString(string: item.strings.Message_VideoMessage, font: audioTitleFont, textColor: item.theme.list.itemPrimaryTextColor) descriptionText = NSAttributedString(string: item.message.author?.displayTitle(strings: item.strings, displayOrder: .firstLast) ?? " ", font: descriptionFont, textColor: item.theme.list.itemSecondaryTextColor) iconImage = .roundVideo(file) } else if !isAudio { let fileName: String = file.fileName ?? "" titleText = NSAttributedString(string: fileName, font: titleFont, textColor: item.theme.list.itemPrimaryTextColor) var fileExtension: String? if let range = fileName.range(of: ".", options: [.backwards]) { fileExtension = fileName[range.upperBound...].lowercased() } extensionIconImage = extensionImage(fileExtension: fileExtension) if let fileExtension = fileExtension { extensionText = NSAttributedString(string: fileExtension, font: extensionFont, textColor: UIColor.white) } if let representation = smallestImageRepresentation(file.previewRepresentations) { iconImage = .imageRepresentation(file, representation) } let dateString = stringForFullDate(timestamp: item.message.timestamp, strings: item.strings, dateTimeFormat: item.dateTimeFormat) let descriptionString: String if let size = file.size { descriptionString = "\(dataSizeString(size, decimalSeparator: item.dateTimeFormat.decimalSeparator)) • \(dateString)" } else { descriptionString = "\(dateString)" } descriptionText = NSAttributedString(string: descriptionString, font: descriptionFont, textColor: item.theme.list.itemSecondaryTextColor) } break } } if isAudio && !isVoice { leftInset += 14.0 } var mediaUpdated = false if let currentMedia = currentMedia { if let selectedMedia = selectedMedia { mediaUpdated = !selectedMedia.isEqual(to: currentMedia) } else { mediaUpdated = true } } else { mediaUpdated = selectedMedia != nil } var statusUpdated = mediaUpdated if currentMessage?.id != message.id || currentMessage?.flags != message.flags { statusUpdated = true } if let selectedMedia = selectedMedia { if mediaUpdated { let context = item.context updatedFetchControls = FetchControls(fetch: { [weak self] in if let strongSelf = self { strongSelf.fetchDisposable.set(messageMediaFileInteractiveFetched(context: context, message: message, file: selectedMedia, userInitiated: true).start()) } }, cancel: { messageMediaFileCancelInteractiveFetch(context: context, messageId: message.id, file: selectedMedia) }) } if statusUpdated { updatedStatusSignal = messageFileMediaResourceStatus(context: item.context, file: selectedMedia, message: message, isRecentActions: false, isSharedMedia: true) if isAudio || isInstantVideo { if let currentUpdatedStatusSignal = updatedStatusSignal { updatedStatusSignal = currentUpdatedStatusSignal |> map { status in switch status.mediaStatus { case .fetchStatus: return FileMediaResourceStatus(mediaStatus: .fetchStatus(.Local), fetchStatus: status.fetchStatus) case .playbackStatus: return status } } } } if isVoice { updatedPlaybackStatusSignal = messageFileMediaPlaybackStatus(context: item.context, file: selectedMedia, message: message, isRecentActions: false) } } } let (titleNodeLayout, titleNodeApply) = titleNodeMakeLayout(TextNodeLayoutArguments(attributedString: titleText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .middle, constrainedSize: CGSize(width: params.width - leftInset - rightInset - 40.0, height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) let (descriptionNodeLayout, descriptionNodeApply) = descriptionNodeMakeLayout(TextNodeLayoutArguments(attributedString: descriptionText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset - rightInset - 12.0 - 40.0, height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) let (extensionTextLayout, extensionTextApply) = extensionIconTextMakeLayout(TextNodeLayoutArguments(attributedString: extensionText, backgroundColor: nil, maximumNumberOfLines: 1, truncationType: .end, constrainedSize: CGSize(width: 38.0, height: CGFloat.infinity), alignment: .natural, cutout: nil, insets: UIEdgeInsets())) var iconImageApply: (() -> Void)? if let iconImage = iconImage { switch iconImage { case let .imageRepresentation(_, representation): let iconSize = CGSize(width: 42.0, height: 42.0) let imageCorners = ImageCorners(topLeft: .Corner(4.0), topRight: .Corner(4.0), bottomLeft: .Corner(4.0), bottomRight: .Corner(4.0)) let arguments = TransformImageArguments(corners: imageCorners, imageSize: representation.dimensions.cgSize.aspectFilled(iconSize), boundingSize: iconSize, intrinsicInsets: UIEdgeInsets(), emptyColor: item.theme.list.mediaPlaceholderColor) iconImageApply = iconImageLayout(arguments) case .albumArt: let iconSize = CGSize(width: 46.0, height: 46.0) let imageCorners = ImageCorners(topLeft: .Corner(4.0), topRight: .Corner(4.0), bottomLeft: .Corner(4.0), bottomRight: .Corner(4.0)) let arguments = TransformImageArguments(corners: imageCorners, imageSize: iconSize, boundingSize: iconSize, intrinsicInsets: UIEdgeInsets(), emptyColor: item.theme.list.mediaPlaceholderColor) iconImageApply = iconImageLayout(arguments) case let .roundVideo(file): let iconSize = CGSize(width: 42.0, height: 42.0) let imageCorners = ImageCorners(topLeft: .Corner(iconSize.width / 2.0), topRight: .Corner(iconSize.width / 2.0), bottomLeft: .Corner(iconSize.width / 2.0), bottomRight: .Corner(iconSize.width / 2.0)) let arguments = TransformImageArguments(corners: imageCorners, imageSize: (file.dimensions ?? PixelDimensions(width: 320, height: 320)).cgSize.aspectFilled(iconSize), boundingSize: iconSize, intrinsicInsets: UIEdgeInsets(), emptyColor: item.theme.list.mediaPlaceholderColor) iconImageApply = iconImageLayout(arguments) } } if currentIconImage != iconImage { if let iconImage = iconImage { switch iconImage { case let .imageRepresentation(file, representation): updateIconImageSignal = chatWebpageSnippetFile(account: item.context.account, fileReference: .message(message: MessageReference(message), media: file), representation: representation) case let .albumArt(file, albumArt): updateIconImageSignal = playerAlbumArt(postbox: item.context.account.postbox, fileReference: .message(message: MessageReference(message), media: file), albumArt: albumArt, thumbnail: true) case let .roundVideo(file): updateIconImageSignal = mediaGridMessageVideo(postbox: item.context.account.postbox, videoReference: FileMediaReference.message(message: MessageReference(message), media: file), autoFetchFullSizeThumbnail: true) } } else { updateIconImageSignal = .complete() } } var insets = UIEdgeInsets(top: 0.0, left: 0.0, bottom: 0.0, right: 0.0) if dateHeaderAtBottom, let header = item.header { insets.top += header.height } let nodeLayout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: 8.0 * 2.0 + titleNodeLayout.size.height + 3.0 + descriptionNodeLayout.size.height), insets: insets) return (nodeLayout, { animation in if let strongSelf = self { let transition: ContainedViewLayoutTransition if animation.isAnimated { transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) } else { transition = .immediate } strongSelf.currentMedia = selectedMedia strongSelf.message = message strongSelf.context = item.context strongSelf.appliedItem = item strongSelf.layoutParams = params strongSelf.contentSizeValue = nodeLayout.contentSize strongSelf.currentLeftOffset = leftOffset if let _ = updatedTheme { strongSelf.separatorNode.backgroundColor = item.theme.list.itemPlainSeparatorColor strongSelf.highlightedBackgroundNode.backgroundColor = item.theme.list.itemHighlightedBackgroundColor strongSelf.progressNode.updateTheme(RadialProgressTheme(backgroundColor: item.theme.list.itemAccentColor, foregroundColor: item.theme.list.plainBackgroundColor, icon: nil)) strongSelf.linearProgressNode.backgroundColor = item.theme.list.itemAccentColor } if let (selectionWidth, selectionApply) = selectionNodeWidthAndApply { let selectionFrame = CGRect(origin: CGPoint(x: params.leftInset, y: 0.0), size: CGSize(width: selectionWidth, height: nodeLayout.contentSize.height)) let selectionNode = selectionApply(selectionFrame.size, transition.isAnimated) if selectionNode !== strongSelf.selectionNode { strongSelf.selectionNode?.removeFromSupernode() strongSelf.selectionNode = selectionNode strongSelf.addSubnode(selectionNode) selectionNode.frame = selectionFrame transition.animatePosition(node: selectionNode, from: CGPoint(x: -selectionFrame.size.width / 2.0, y: selectionFrame.midY)) } else { transition.updateFrame(node: selectionNode, frame: selectionFrame) } } else if let selectionNode = strongSelf.selectionNode { strongSelf.selectionNode = nil let selectionFrame = selectionNode.frame transition.updatePosition(node: selectionNode, position: CGPoint(x: -selectionFrame.size.width / 2.0, y: selectionFrame.midY), completion: { [weak selectionNode] _ in selectionNode?.removeFromSupernode() }) } transition.updateFrame(node: strongSelf.separatorNode, frame: CGRect(origin: CGPoint(x: leftInset + leftOffset, y: nodeLayout.contentSize.height - UIScreenPixel), size: CGSize(width: params.width - leftInset - leftOffset, height: UIScreenPixel))) strongSelf.highlightedBackgroundNode.frame = CGRect(origin: CGPoint(x: 0.0, y: -UIScreenPixel - nodeLayout.insets.top), size: CGSize(width: params.width, height: nodeLayout.size.height + UIScreenPixel)) transition.updateFrame(node: strongSelf.titleNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset, y: 8.0), size: titleNodeLayout.size)) let _ = titleNodeApply() var descriptionOffset: CGFloat = 0.0 if let resourceStatus = strongSelf.resourceStatus { switch resourceStatus { case .playbackStatus: break case let .fetchStatus(fetchStatus): switch fetchStatus { case .Remote, .Fetching: descriptionOffset = 14.0 case .Local: break } } } transition.updateFrame(node: strongSelf.descriptionNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset + descriptionOffset, y: strongSelf.titleNode.frame.maxY + 3.0), size: descriptionNodeLayout.size)) let _ = descriptionNodeApply() let iconFrame: CGRect if isAudio { let iconSize = CGSize(width: 48.0, height: 48.0) iconFrame = CGRect(origin: CGPoint(x: params.leftInset + leftOffset + 12.0, y: 5.0), size: iconSize) } else { let iconSize = CGSize(width: 42.0, height: 42.0) iconFrame = CGRect(origin: CGPoint(x: params.leftInset + leftOffset + 12.0, y: 8.0), size: iconSize) } transition.updateFrame(node: strongSelf.extensionIconNode, frame: iconFrame) strongSelf.extensionIconNode.image = extensionIconImage transition.updateFrame(node: strongSelf.extensionIconText, frame: CGRect(origin: CGPoint(x: leftOffset + 12.0 + floor((42.0 - extensionTextLayout.size.width) / 2.0), y: 8.0 + floor((42.0 - extensionTextLayout.size.height) / 2.0)), size: extensionTextLayout.size)) let _ = extensionTextApply() strongSelf.currentIconImage = iconImage if isVoice { let waveformNode: AudioWaveformNode let waveformForegroundNode: AudioWaveformNode let waveformScrubbingNode: MediaPlayerScrubbingNode if let current = strongSelf.waveformNode { waveformNode = current } else { waveformNode = AudioWaveformNode() waveformNode.isLayerBacked = true strongSelf.waveformNode = waveformNode strongSelf.addSubnode(waveformNode) } if let current = strongSelf.waveformForegroundNode { waveformForegroundNode = current } else { waveformForegroundNode = AudioWaveformNode() waveformForegroundNode.isLayerBacked = true strongSelf.waveformForegroundNode = waveformForegroundNode strongSelf.addSubnode(waveformForegroundNode) } if let current = strongSelf.waveformScrubbingNode { waveformScrubbingNode = current } else { waveformScrubbingNode = MediaPlayerScrubbingNode(content: .custom(backgroundNode: waveformNode, foregroundContentNode: waveformForegroundNode)) waveformScrubbingNode.hitTestSlop = UIEdgeInsets(top: -10.0, left: 0.0, bottom: -10.0, right: 0.0) waveformScrubbingNode.seek = { timestamp in if let strongSelf = self, let context = strongSelf.context, let message = strongSelf.message, let type = peerMessageMediaPlayerType(message) { context.sharedContext.mediaManager.playlistControl(.seek(timestamp), type: type) } } waveformScrubbingNode.enableScrubbing = false waveformScrubbingNode.status = strongSelf.playbackStatus.get() strongSelf.waveformScrubbingNode = waveformScrubbingNode strongSelf.addSubnode(waveformScrubbingNode) } transition.updateFrame(node: waveformScrubbingNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset, y: 10.0), size: CGSize(width: params.width - leftInset - 16.0, height: 12.0))) waveformNode.setup(color: item.theme.list.controlSecondaryColor, waveform: waveform) waveformForegroundNode.setup(color: item.theme.list.itemAccentColor, waveform: waveform) } if let iconImageApply = iconImageApply { if let updateImageSignal = updateIconImageSignal { strongSelf.iconImageNode.setSignal(updateImageSignal) } transition.updateFrame(node: strongSelf.iconImageNode, frame: iconFrame) if strongSelf.iconImageNode.supernode == nil { strongSelf.addSubnode(strongSelf.iconImageNode) } iconImageApply() if strongSelf.extensionIconNode.supernode != nil { strongSelf.extensionIconNode.removeFromSupernode() } if strongSelf.extensionIconText.supernode != nil { strongSelf.extensionIconText.removeFromSupernode() } } else if strongSelf.iconImageNode.supernode != nil { strongSelf.iconImageNode.removeFromSupernode() if strongSelf.extensionIconNode.supernode == nil { strongSelf.addSubnode(strongSelf.extensionIconNode) } if strongSelf.extensionIconText.supernode == nil { strongSelf.addSubnode(strongSelf.extensionIconText) } } if let playbackOverlayNode = strongSelf.playbackOverlayNode { transition.updateFrame(node: playbackOverlayNode, frame: iconFrame) } let statusSize = CGSize(width: 28.0, height: 28.0) transition.updateFrame(node: strongSelf.statusNode, frame: CGRect(origin: CGPoint(x: params.width - params.rightInset - rightInset - statusSize.width + leftOffset, y: floor((nodeLayout.contentSize.height - statusSize.height) / 2.0)), size: statusSize)) strongSelf.statusButtonNode.frame = CGRect(origin: CGPoint(x: params.width - params.rightInset - rightInset - 40.0 + leftOffset, y: 0.0), size: CGSize(width: 40.0, height: nodeLayout.contentSize.height)) if let updatedStatusSignal = updatedStatusSignal { strongSelf.statusDisposable.set((updatedStatusSignal |> deliverOnMainQueue).start(next: { [weak strongSelf] fileStatus in if let strongSelf = strongSelf { strongSelf.fetchStatus = fileStatus.fetchStatus strongSelf.resourceStatus = fileStatus.mediaStatus strongSelf.updateStatus(transition: .immediate) } })) } transition.updateFrame(node: strongSelf.downloadStatusIconNode, frame: CGRect(origin: CGPoint(x: leftOffset + leftInset, y: strongSelf.descriptionNode.frame.minY + floor((strongSelf.descriptionNode.frame.height - 11.0) / 2.0)), size: CGSize(width: 11.0, height: 11.0))) let progressSize: CGFloat = 40.0 transition.updateFrame(node: strongSelf.progressNode, frame: CGRect(origin: CGPoint(x: leftOffset + params.leftInset + floor((leftInset - params.leftInset - progressSize) / 2.0), y: floor((nodeLayout.contentSize.height - progressSize) / 2.0)), size: CGSize(width: progressSize, height: progressSize))) if let updatedFetchControls = updatedFetchControls { let _ = strongSelf.fetchControls.swap(updatedFetchControls) } if let updatedPlaybackStatusSignal = updatedPlaybackStatusSignal { strongSelf.playbackStatus.set(updatedPlaybackStatusSignal) /*strongSelf.playbackStatusDisposable.set((updatedPlaybackStatusSignal |> deliverOnMainQueue).start(next: { [weak strongSelf] status in displayLinkDispatcher.dispatch { if let strongSelf = strongSelf { strongSelf.playerStatus = status } } }))*/ } strongSelf.updateStatus(transition: transition) } }) } } private func updateStatus(transition: ContainedViewLayoutTransition) { guard let item = self.item, let media = self.currentMedia, let fetchStatus = self.fetchStatus, let status = self.resourceStatus, let layoutParams = self.layoutParams, let contentSize = self.contentSizeValue else { return } var isAudio = false var isVoice = false var isInstantVideo = false if let file = media as? TelegramMediaFile { isAudio = file.isMusic || file.isVoice isVoice = file.isVoice isInstantVideo = file.isInstantVideo } self.progressNode.isHidden = !isVoice var enableScrubbing = false var musicIsPlaying: Bool? var statusState: RadialStatusNodeState = .none if !isAudio && !isInstantVideo { self.updateProgressFrame(size: contentSize, leftInset: layoutParams.leftInset, rightInset: layoutParams.rightInset, transition: .immediate) } else { if !isVoice && !isInstantVideo { switch fetchStatus { case let .Fetching(_, progress): let adjustedProgress = max(progress, 0.027) statusState = .cloudProgress(color: item.theme.list.itemAccentColor, strokeBackgroundColor: item.theme.list.itemAccentColor.withAlphaComponent(0.5), lineWidth: 2.0, value: CGFloat(adjustedProgress)) case .Local: break case .Remote: if let image = PresentationResourcesItemList.cloudFetchIcon(item.theme) { statusState = .customIcon(image) } } } self.statusNode.transitionToState(statusState, completion: {}) self.statusButtonNode.isUserInteractionEnabled = statusState != .none switch status { case let .fetchStatus(fetchStatus): switch fetchStatus { case let .Fetching(_, progress): let adjustedProgress = max(progress, 0.027) self.progressNode.state = .Fetching(progress: adjustedProgress) case .Local: if isAudio { self.progressNode.state = .Play } else { self.progressNode.state = .Icon } case .Remote: if isAudio { self.progressNode.state = .Play } else { self.progressNode.state = .Remote } } case let .playbackStatus(playbackStatus): enableScrubbing = true switch playbackStatus { case .playing: musicIsPlaying = true self.progressNode.state = .Pause case .paused: musicIsPlaying = false self.progressNode.state = .Play } } } self.waveformScrubbingNode?.enableScrubbing = enableScrubbing if let musicIsPlaying = musicIsPlaying, !isVoice, !isInstantVideo { if self.playbackOverlayNode == nil { let playbackOverlayNode = ListMessagePlaybackOverlayNode() playbackOverlayNode.frame = self.iconImageNode.frame self.playbackOverlayNode = playbackOverlayNode self.addSubnode(playbackOverlayNode) } self.playbackOverlayNode?.isPlaying = musicIsPlaying } else if let playbackOverlayNode = self.playbackOverlayNode { self.playbackOverlayNode = nil playbackOverlayNode.removeFromSupernode() } } override func setHighlighted(_ highlighted: Bool, at point: CGPoint, animated: Bool) { super.setHighlighted(highlighted, at: point, animated: animated) if highlighted, let item = self.item, case .none = item.selection { self.highlightedBackgroundNode.alpha = 1.0 if self.highlightedBackgroundNode.supernode == nil { self.insertSubnode(self.highlightedBackgroundNode, aboveSubnode: self.separatorNode) } } else { if self.highlightedBackgroundNode.supernode != nil { if animated { self.highlightedBackgroundNode.layer.animateAlpha(from: self.highlightedBackgroundNode.alpha, to: 0.0, duration: 0.4, completion: { [weak self] completed in if let strongSelf = self { if completed { strongSelf.highlightedBackgroundNode.removeFromSupernode() } } }) self.highlightedBackgroundNode.alpha = 0.0 } else { self.highlightedBackgroundNode.removeFromSupernode() } } } } override func transitionNode(id: MessageId, media: Media) -> (ASDisplayNode, CGRect, () -> (UIView?, UIView?))? { if let item = self.item, item.message.id == id, self.iconImageNode.supernode != nil { let iconImageNode = self.iconImageNode return (self.iconImageNode, self.iconImageNode.bounds, { [weak iconImageNode] in return (iconImageNode?.view.snapshotContentTree(unhide: true), nil) }) } return nil } override func updateHiddenMedia() { if let controllerInteraction = self.controllerInteraction, let item = self.item, controllerInteraction.hiddenMedia[item.message.id] != nil { self.iconImageNode.isHidden = true } else { self.iconImageNode.isHidden = false } } override func updateSelectionState(animated: Bool) { } private func updateProgressFrame(size: CGSize, leftInset: CGFloat, rightInset: CGFloat, transition: ContainedViewLayoutTransition) { guard let item = self.appliedItem else { return } var descriptionOffset: CGFloat = 0.0 var downloadingString: String? if let resourceStatus = self.resourceStatus { var maybeFetchStatus: MediaResourceStatus = .Local switch resourceStatus { case .playbackStatus: break case let .fetchStatus(fetchStatus): maybeFetchStatus = fetchStatus switch fetchStatus { case let .Fetching(_, progress): if let file = self.currentMedia as? TelegramMediaFile, let size = file.size { downloadingString = "\(dataSizeString(Int(Float(size) * progress), forceDecimal: true, decimalSeparator: item.dateTimeFormat.decimalSeparator)) / \(dataSizeString(size, forceDecimal: true, decimalSeparator: item.dateTimeFormat.decimalSeparator))" } descriptionOffset = 14.0 case .Remote: descriptionOffset = 14.0 case .Local: break } } switch maybeFetchStatus { case let .Fetching(_, progress): let progressFrame = CGRect(x: self.currentLeftOffset + leftInset + 65.0, y: size.height - 2.0, width: floor((size.width - 65.0 - leftInset - rightInset) * CGFloat(progress)), height: 2.0) if self.linearProgressNode.supernode == nil { self.addSubnode(self.linearProgressNode) } transition.updateFrame(node: self.linearProgressNode, frame: progressFrame) if self.downloadStatusIconNode.supernode == nil { self.addSubnode(self.downloadStatusIconNode) } self.downloadStatusIconNode.image = PresentationResourcesChat.sharedMediaFileDownloadPauseIcon(item.theme) case .Local: if self.linearProgressNode.supernode != nil { self.linearProgressNode.removeFromSupernode() } if self.downloadStatusIconNode.supernode != nil { self.downloadStatusIconNode.removeFromSupernode() } self.downloadStatusIconNode.image = nil case .Remote: if self.linearProgressNode.supernode != nil { self.linearProgressNode.removeFromSupernode() } if self.downloadStatusIconNode.supernode == nil { self.addSubnode(self.downloadStatusIconNode) } self.downloadStatusIconNode.image = PresentationResourcesChat.sharedMediaFileDownloadStartIcon(item.theme) } } else { if self.linearProgressNode.supernode != nil { self.linearProgressNode.removeFromSupernode() } if self.downloadStatusIconNode.supernode != nil { self.downloadStatusIconNode.removeFromSupernode() } } var descriptionFrame = self.descriptionNode.frame let originX = self.titleNode.frame.minX + descriptionOffset if !descriptionFrame.origin.x.isEqual(to: originX) { descriptionFrame.origin.x = originX transition.updateFrame(node: self.descriptionNode, frame: descriptionFrame) } if downloadingString != nil { self.descriptionProgressNode.isHidden = false self.descriptionNode.isHidden = true } else { self.descriptionProgressNode.isHidden = true self.descriptionNode.isHidden = false } let descriptionFont = Font.regular(floor(item.fontSize.baseDisplaySize * 13.0 / 17.0)) self.descriptionProgressNode.attributedText = NSAttributedString(string: downloadingString ?? "", font: descriptionFont, textColor: item.theme.list.itemSecondaryTextColor) let descriptionSize = self.descriptionProgressNode.updateLayout(CGSize(width: size.width - 14.0, height: size.height)) transition.updateFrame(node: self.descriptionProgressNode, frame: CGRect(origin: self.descriptionNode.frame.origin, size: descriptionSize)) } func activateMedia() { self.progressPressed() } func progressPressed() { if let resourceStatus = self.resourceStatus { switch resourceStatus { case let .fetchStatus(fetchStatus): switch fetchStatus { case .Fetching: if let cancel = self.fetchControls.with({ return $0?.cancel }) { cancel() } case .Remote: if let fetch = self.fetchControls.with({ return $0?.fetch }) { fetch() } case .Local: if let item = self.item, let controllerInteraction = self.controllerInteraction { let _ = controllerInteraction.openMessage(item.message, .default) } } case .playbackStatus: if let context = self.context { context.sharedContext.mediaManager.playlistControl(.playback(.togglePlayPause), type: nil) } } } } override func header() -> ListViewItemHeader? { return self.item?.header } override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { if let item = self.item, case .selectable = item.selection { if self.bounds.contains(point) { return self.view } } return super.hitTest(point, with: event) } override func longTapped() { if let item = self.item { item.controllerInteraction.openMessageContextMenu(item.message, false, self, self.bounds, nil) } } @objc private func statusPressed() { guard let _ = self.item, let fetchStatus = self.fetchStatus else { return } switch fetchStatus { case .Fetching: if let cancel = self.fetchControls.with({ return $0?.cancel }) { cancel() } case .Remote: if let fetch = self.fetchControls.with({ return $0?.fetch }) { fetch() } case .Local: break } } }