// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import SignalUI public class CVComponentAudioAttachment: CVComponentBase, CVComponent { public var componentKey: CVComponentKey { .audioAttachment } private let audioAttachment: AudioAttachment private let nextAudioAttachment: AudioAttachment? private var attachment: TSResource { audioAttachment.attachment } private var attachmentStream: TSResourceStream? { audioAttachment.attachmentStream?.attachmentStream } private let footerOverlay: CVComponent? init(itemModel: CVItemModel, audioAttachment: AudioAttachment, nextAudioAttachment: AudioAttachment?, footerOverlay: CVComponent?) { self.audioAttachment = audioAttachment self.nextAudioAttachment = nextAudioAttachment self.footerOverlay = footerOverlay super.init(itemModel: itemModel) databaseStorage.appendDatabaseChangeDelegate(self) } public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView { CVComponentViewAudioAttachment() } public func configureForRendering(componentView componentViewParam: CVComponentView, cellMeasurement: CVCellMeasurement, componentDelegate: CVComponentDelegate) { guard let componentView = componentViewParam as? CVComponentViewAudioAttachment else { owsFailDebug("Unexpected componentView.") componentViewParam.reset() return } let stackView = componentView.stackView let conversationStyle = self.conversationStyle if let footerOverlay = self.footerOverlay { let footerView: CVComponentView if let footerOverlayView = componentView.footerOverlayView { footerView = footerOverlayView } else { let footerOverlayView = CVComponentFooter.CVComponentViewFooter() componentView.footerOverlayView = footerOverlayView footerView = footerOverlayView } footerOverlay.configureForRendering(componentView: footerView, cellMeasurement: cellMeasurement, componentDelegate: componentDelegate) let footerRootView = footerView.rootView let footerSize = cellMeasurement.size(key: Self.measurementKey_footerSize) ?? .zero stackView.addSubview(footerRootView) { view in var footerFrame = view.bounds footerFrame.height = min(view.bounds.height, footerSize.height) footerFrame.y = view.bounds.height - footerSize.height footerRootView.frame = footerFrame } } owsAssertDebug(MimeTypeUtil.isSupportedAudioMimeType(attachment.mimeType)) let presentation = AudioMessagePresenter( isIncoming: isIncoming, audioAttachment: audioAttachment, threadUniqueId: itemModel.thread.uniqueId, playbackRate: AudioPlaybackRate(rawValue: itemModel.itemViewState.audioPlaybackRate)) let audioMessageView = AudioMessageView( presentation: presentation, audioMessageViewDelegate: componentDelegate, mediaCache: mediaCache ) if let incomingMessage = interaction as? TSIncomingMessage { audioMessageView.setViewed(incomingMessage.wasViewed, animated: false) } else if let outgoingMessage = interaction as? TSOutgoingMessage { audioMessageView.setViewed(!outgoingMessage.viewedRecipientAddresses().isEmpty, animated: false) } audioMessageView.configureForRendering( cellMeasurement: cellMeasurement, conversationStyle: conversationStyle ) componentView.audioMessageView = audioMessageView stackView.configure(config: stackViewConfig, cellMeasurement: cellMeasurement, measurementKey: Self.measurementKey_stackView, subviews: [ audioMessageView ]) // Listen for when our audio attachment finishes playing, so we can // start playing the next attachment. cvAudioPlayer.addListener(self) } private var stackViewConfig: CVStackViewConfig { CVStackViewConfig(axis: .vertical, alignment: .fill, spacing: 0, layoutMargins: .zero) } private static let measurementKey_stackView = "CVComponentAudioAttachment.measurementKey_stackView" private static let measurementKey_footerSize = "CVComponentAudioAttachment.measurementKey_footerSize" public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize { owsAssertDebug(maxWidth > 0) let maxWidth = min(maxWidth, conversationStyle.maxAudioMessageWidth) if let footerOverlay = self.footerOverlay { let maxFooterWidth = max(0, maxWidth - conversationStyle.textInsets.totalWidth) let footerSize = footerOverlay.measure(maxWidth: maxFooterWidth, measurementBuilder: measurementBuilder) measurementBuilder.setSize(key: Self.measurementKey_footerSize, size: footerSize) } let presentation = AudioMessagePresenter( isIncoming: false, audioAttachment: audioAttachment, threadUniqueId: itemModel.thread.uniqueId, playbackRate: AudioPlaybackRate(rawValue: itemModel.itemViewState.audioPlaybackRate)) let audioSize = AudioMessageView.measure( maxWidth: maxWidth, measurementBuilder: measurementBuilder, presentation: presentation).ceil let audioInfo = audioSize.asManualSubviewInfo let stackMeasurement = ManualStackView.measure(config: stackViewConfig, measurementBuilder: measurementBuilder, measurementKey: Self.measurementKey_stackView, subviewInfos: [ audioInfo ], maxWidth: maxWidth) var measuredSize = stackMeasurement.measuredSize measuredSize.width = maxWidth return measuredSize } /// Checks if the message still exists and stops playback if it does not. private func checkIfMessageStillExists() { let messageWasDeleted = databaseStorage.read { tx in TSInteraction.anyFetch(uniqueId: interaction.uniqueId, transaction: tx) == nil } if messageWasDeleted, cvAudioPlayer.audioPlaybackState(forAttachmentId: attachment.resourceId) == .playing { cvAudioPlayer.stopAll() } } // MARK: - Events public override func handleTap( sender: UIGestureRecognizer, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem ) -> Bool { if let audioMessageView = (componentView as? CVComponentViewAudioAttachment)?.audioMessageView, audioMessageView.handleTap(sender: sender, itemModel: renderItem.itemModel) { return true } if audioAttachment.isDownloaded { cvAudioPlayer.setPlaybackRate( renderItem.itemViewState.audioPlaybackRate, forThreadUniqueId: renderItem.itemModel.thread.uniqueId ) cvAudioPlayer.togglePlayState(forAudioAttachment: audioAttachment) return true } else if audioAttachment.isDownloading, let pointerId = audioAttachment.attachmentPointer?.attachment.resourceId { Logger.debug("Cancelling in-progress download because of user action: \(interaction.uniqueId):\(pointerId)") self.databaseStorage.write { tx in DependenciesBridge.shared.tsResourceDownloadManager.cancelDownload( for: pointerId, tx: tx.asV2Write ) } return true } else if let message = interaction as? TSMessage { Logger.debug("Retrying download for message: \(message.uniqueId)") componentDelegate.didTapFailedOrPendingDownloads(message) return true } else { owsFailDebug("Unexpected message type") return false } } // MARK: - Scrub Audio With Pan public override func findPanHandler(sender: UIPanGestureRecognizer, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem, messageSwipeActionState: CVMessageSwipeActionState) -> CVPanHandler? { AssertIsOnMainThread() guard let componentView = componentView as? CVComponentViewAudioAttachment else { owsFailDebug("Unexpected componentView.") return nil } let itemViewModel = CVItemViewModelImpl(renderItem: renderItem) guard componentDelegate.shouldAllowReplyForItem(itemViewModel) else { return nil } guard nil != attachmentStream else { return nil } guard let audioMessageView = componentView.audioMessageView else { owsFailDebug("Missing audioMessageView.") return nil } let location = sender.location(in: audioMessageView) guard audioMessageView.isPointInScrubbableRegion(location) else { return nil } return CVPanHandler(delegate: componentDelegate, panType: .scrubAudio, renderItem: renderItem) } public override func startPanGesture(sender: UIPanGestureRecognizer, panHandler: CVPanHandler, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem, messageSwipeActionState: CVMessageSwipeActionState) { AssertIsOnMainThread() } public override func handlePanGesture(sender: UIPanGestureRecognizer, panHandler: CVPanHandler, componentDelegate: CVComponentDelegate, componentView: CVComponentView, renderItem: CVRenderItem, messageSwipeActionState: CVMessageSwipeActionState) { AssertIsOnMainThread() guard let componentView = componentView as? CVComponentViewAudioAttachment else { owsFailDebug("Unexpected componentView.") return } guard let audioMessageView = componentView.audioMessageView else { owsFailDebug("Missing audioMessageView.") return } let location = sender.location(in: audioMessageView) guard let attachmentStream = attachmentStream else { return } switch sender.state { case .changed: let progress = audioMessageView.progressForLocation(location) audioMessageView.setOverrideProgress(progress, animated: false) case .ended: // Only update the actual playback position when the user finishes scrubbing, // we still call `scrubToLocation` above in order to update the slider. audioMessageView.clearOverrideProgress(animated: false) let scrubbedTime = audioMessageView.scrubToLocation(location) cvAudioPlayer.setPlaybackProgress(progress: scrubbedTime, forAttachmentStream: attachmentStream) case .possible, .began, .failed, .cancelled: audioMessageView.clearOverrideProgress(animated: false) @unknown default: owsFailDebug("Invalid state.") audioMessageView.clearOverrideProgress(animated: false) } } // MARK: - // Used for rendering some portion of an Conversation View item. // It could be the entire item or some part thereof. public class CVComponentViewAudioAttachment: NSObject, CVComponentView { fileprivate let stackView = ManualStackView(name: "CVComponentViewAudioAttachment.stackView") fileprivate var audioMessageView: AudioMessageView? fileprivate var footerOverlayView: CVComponentView? public var isDedicatedCellView = false public var rootView: UIView { stackView } public func setIsCellVisible(_ isCellVisible: Bool) {} public func reset() { stackView.reset() audioMessageView?.removeFromSuperview() audioMessageView = nil footerOverlayView?.reset() footerOverlayView = nil } } } // MARK: - CVAudioPlayerListener extension CVComponentAudioAttachment: CVAudioPlayerListener { func audioPlayerStateDidChange(attachmentId: TSResourceId) {} func audioPlayerDidFinish(attachmentId: TSResourceId) { guard attachmentId == audioAttachment.attachment.resourceId else { return } cvAudioPlayer.autoplayNextAudioAttachmentIfNeeded(nextAudioAttachment) } func audioPlayerDidMarkViewed(attachmentId: TSResourceId) {} } // MARK: - DatabaseChangeDelegate extension CVComponentAudioAttachment: DatabaseChangeDelegate { public func databaseChangesDidUpdate(databaseChanges: SignalServiceKit.DatabaseChanges) { guard databaseChanges.didUpdate(interaction: self.interaction) else { return } checkIfMessageStillExists() } public func databaseChangesDidUpdateExternally() { checkIfMessageStillExists() } public func databaseChangesDidReset() { checkIfMessageStillExists() } } // MARK: - extension CVComponentAudioAttachment: CVAccessibilityComponent { public var accessibilityDescription: String { if audioAttachment.isVoiceMessage { if audioAttachment.durationSeconds > 0 { let format = OWSLocalizedString("ACCESSIBILITY_LABEL_VOICE_MEMO_%d", tableName: "PluralAware", comment: "Accessibility label for a voice memo. Embeds: {{ the duration of the voice message }}.") return String.localizedStringWithFormat(format, Int(audioAttachment.durationSeconds)) } else { return OWSLocalizedString("ACCESSIBILITY_LABEL_VOICE_MEMO", comment: "Accessibility label for a voice memo.") } } else { // TODO: We could include information about the attachment format. return OWSLocalizedString("ACCESSIBILITY_LABEL_AUDIO", comment: "Accessibility label for audio.") } } }