[WIP] View-once audio messages

This commit is contained in:
Ilya Laktyushin
2023-12-29 04:50:22 +04:00
parent 6aa462ba8b
commit f11c070862
62 changed files with 1351 additions and 131 deletions

View File

@@ -15,10 +15,48 @@ import ChatPresentationInterfaceState
import ChatSendButtonRadialStatusNode
import AudioWaveformNode
import ChatInputPanelNode
import TooltipUI
import TelegramNotices
extension AudioWaveformNode: CustomMediaPlayerScrubbingForegroundNode {
}
final class ChatRecordingPreviewViewForOverlayContent: UIView, ChatInputPanelViewForOverlayContent {
let ignoreHit: (UIView, CGPoint) -> Bool
init(ignoreHit: @escaping (UIView, CGPoint) -> Bool) {
self.ignoreHit = ignoreHit
super.init(frame: CGRect())
}
required init(coder: NSCoder) {
preconditionFailure()
}
func maybeDismissContent(point: CGPoint) {
for subview in self.subviews.reversed() {
if let _ = subview.hitTest(self.convert(point, to: subview), with: nil) {
return
}
}
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
for subview in self.subviews.reversed() {
if let result = subview.hitTest(self.convert(point, to: subview), with: event) {
return result
}
}
if event == nil || self.ignoreHit(self, point) {
return nil
}
return nil
}
}
final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode {
let deleteButton: HighlightableButtonNode
let binNode: AnimationNode
@@ -29,6 +67,9 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode {
private let waveformButton: ASButtonNode
let waveformBackgroundNode: ASImageNode
private var viewOnce = false
let viewOnceButton: ChatRecordingViewOnceButtonNode
private let waveformNode: AudioWaveformNode
private let waveformForegroundNode: AudioWaveformNode
let waveformScubberNode: MediaPlayerScrubbingNode
@@ -63,6 +104,8 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode {
self.sendButton.displaysAsynchronously = false
self.sendButton.setImage(PresentationResourcesChat.chatInputPanelSendButtonImage(theme), for: [])
self.viewOnceButton = ChatRecordingViewOnceButtonNode()
self.waveformBackgroundNode = ASImageNode()
self.waveformBackgroundNode.isLayerBacked = true
self.waveformBackgroundNode.displaysAsynchronously = false
@@ -92,6 +135,21 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode {
super.init()
self.viewForOverlayContent = ChatRecordingPreviewViewForOverlayContent(
ignoreHit: { [weak self] view, point in
guard let strongSelf = self else {
return false
}
if strongSelf.view.hitTest(view.convert(point, to: strongSelf.view), with: nil) != nil {
return true
}
if view.convert(point, to: strongSelf.view).y > strongSelf.view.bounds.maxY {
return true
}
return false
}
)
self.addSubnode(self.deleteButton)
self.deleteButton.addSubnode(self.binNode)
self.addSubnode(self.waveformBackgroundNode)
@@ -111,9 +169,10 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode {
}
}
}
self.deleteButton.addTarget(self, action: #selector(self.deletePressed), forControlEvents: [.touchUpInside])
self.sendButton.addTarget(self, action: #selector(self.sendPressed), forControlEvents: [.touchUpInside])
self.viewOnceButton.addTarget(self, action: #selector(self.viewOncePressed), forControlEvents: [.touchUpInside])
self.waveformButton.addTarget(self, action: #selector(self.waveformPressed), forControlEvents: .touchUpInside)
}
@@ -135,9 +194,38 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode {
}
strongSelf.interfaceInteraction?.displaySendMessageOptions(strongSelf.sendButton, gesture)
}
if let viewForOverlayContent = self.viewForOverlayContent {
viewForOverlayContent.addSubnode(self.viewOnceButton)
}
}
private func maybePresentViewOnceTooltip() {
guard let context = self.context else {
return
}
let _ = (ApplicationSpecificNotice.getVoiceMessagesPlayOnceSuggestion(accountManager: context.sharedContext.accountManager)
|> deliverOnMainQueue).startStandalone(next: { [weak self] counter in
guard let self, let interfaceState = self.presentationInterfaceState else {
return
}
if counter >= 3 {
return
}
Queue.mainQueue().after(0.3) {
self.displayViewOnceTooltip(text: interfaceState.strings.Chat_TapToPlayVoiceMessageOnceTooltip, hasIcon: true)
}
let _ = ApplicationSpecificNotice.incrementVoiceMessagesPlayOnceSuggestion(accountManager: context.sharedContext.accountManager).startStandalone()
})
}
override func updateLayout(width: CGFloat, leftInset: CGFloat, rightInset: CGFloat, bottomInset: CGFloat, additionalSideInsets: UIEdgeInsets, maxHeight: CGFloat, isSecondary: Bool, transition: ContainedViewLayoutTransition, interfaceState: ChatPresentationInterfaceState, metrics: LayoutMetrics, isMediaInputExpanded: Bool) -> CGFloat {
var isFirstTime = false
if self.presentationInterfaceState == nil {
isFirstTime = true
}
if self.presentationInterfaceState != interfaceState {
var updateWaveform = false
if self.presentationInterfaceState?.recordedMediaPreview != interfaceState.recordedMediaPreview {
@@ -148,8 +236,9 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode {
self.sendButton.accessibilityLabel = interfaceState.strings.VoiceOver_MessageContextSend
self.waveformButton.accessibilityLabel = interfaceState.strings.VoiceOver_Chat_RecordPreviewVoiceMessage
}
self.presentationInterfaceState = interfaceState
self.presentationInterfaceState = interfaceState
if let recordedMediaPreview = interfaceState.recordedMediaPreview, updateWaveform {
self.waveformNode.setup(color: interfaceState.theme.chat.inputPanel.actionControlForegroundColor.withAlphaComponent(0.5), gravity: .center, waveform: recordedMediaPreview.waveform)
self.waveformForegroundNode.setup(color: interfaceState.theme.chat.inputPanel.actionControlForegroundColor, gravity: .center, waveform: recordedMediaPreview.waveform)
@@ -182,12 +271,20 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode {
}
}
let panelHeight = defaultHeight(metrics: metrics)
if isFirstTime {
self.maybePresentViewOnceTooltip()
}
let panelHeight = defaultHeight(metrics: metrics)
transition.updateFrame(node: self.deleteButton, frame: CGRect(origin: CGPoint(x: leftInset + 2.0 - UIScreenPixel, y: 1), size: CGSize(width: 40.0, height: 40)))
transition.updateFrame(node: self.sendButton, frame: CGRect(origin: CGPoint(x: width - rightInset - 43.0 - UIScreenPixel, y: 2 - UIScreenPixel), size: CGSize(width: 44.0, height: 44)))
self.binNode.frame = self.deleteButton.bounds
let viewOnceSize = self.viewOnceButton.update(theme: interfaceState.theme)
let viewOnceButtonFrame = CGRect(origin: CGPoint(x: width - rightInset - 44.0 - UIScreenPixel, y: -64.0), size: viewOnceSize)
transition.updateFrame(node: self.viewOnceButton, frame: viewOnceButtonFrame)
var isScheduledMessages = false
if case .scheduledMessages = interfaceState.subject {
isScheduledMessages = true
@@ -229,6 +326,16 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode {
if let prevTextInputPanelNode = self.prevInputPanelNode as? ChatTextInputPanelNode {
self.prevInputPanelNode = nil
self.viewOnce = prevTextInputPanelNode.viewOnce
self.viewOnceButton.update(isSelected: self.viewOnce, animated: false)
prevTextInputPanelNode.viewOnceButton.isHidden = true
prevTextInputPanelNode.viewOnce = false
self.viewOnceButton.layer.animatePosition(from: prevTextInputPanelNode.viewOnceButton.position, to: self.viewOnceButton.position, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in
prevTextInputPanelNode.viewOnceButton.isHidden = false
prevTextInputPanelNode.viewOnceButton.update(isSelected: false, animated: false)
})
if let audioRecordingDotNode = prevTextInputPanelNode.audioRecordingDotNode {
let startAlpha = CGFloat(audioRecordingDotNode.layer.presentation()?.opacity ?? 1.0)
audioRecordingDotNode.layer.removeAllAnimations()
@@ -283,12 +390,63 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode {
}
@objc func deletePressed() {
self.tooltipController?.dismiss()
self.mediaPlayer?.pause()
self.interfaceInteraction?.deleteRecordedMedia()
}
@objc func sendPressed() {
self.interfaceInteraction?.sendRecordedMedia(false)
self.tooltipController?.dismiss()
self.interfaceInteraction?.sendRecordedMedia(false, self.viewOnce)
}
private weak var tooltipController: TooltipScreen?
@objc private func viewOncePressed() {
guard let context = self.context, let interfaceState = self.presentationInterfaceState else {
return
}
self.viewOnce = !self.viewOnce
self.viewOnceButton.update(isSelected: self.viewOnce, animated: true)
self.tooltipController?.dismiss()
if self.viewOnce {
self.displayViewOnceTooltip(text: interfaceState.strings.Chat_PlayVoiceMessageOnceTooltip, hasIcon: true)
let _ = ApplicationSpecificNotice.incrementVoiceMessagesPlayOnceSuggestion(accountManager: context.sharedContext.accountManager, count: 3).startStandalone()
}
}
private func displayViewOnceTooltip(text: String, hasIcon: Bool) {
guard let context = self.context, let parentController = self.interfaceInteraction?.chatController() else {
return
}
let absoluteFrame = self.viewOnceButton.view.convert(self.viewOnceButton.bounds, to: parentController.view)
let location = CGRect(origin: CGPoint(x: absoluteFrame.midX - 20.0, y: absoluteFrame.midY), size: CGSize())
let tooltipController = TooltipScreen(
account: context.account,
sharedContext: context.sharedContext,
text: .markdown(text: text),
balancedTextLayout: true,
constrainWidth: 240.0,
style: .customBlur(UIColor(rgb: 0x18181a), 0.0),
arrowStyle: .small,
icon: hasIcon ? .animation(name: "anim_autoremove_on", delay: 0.1, tintColor: nil) : nil,
location: .point(location, .right),
displayDuration: .default,
inset: 8.0,
cornerRadius: 8.0,
shouldDismissOnTouch: { _, _ in
return .ignore
}
)
self.tooltipController = tooltipController
parentController.present(tooltipController, in: .window(.root))
}
@objc func waveformPressed() {
@@ -353,3 +511,105 @@ private final class PlayPauseIconNode: ManagedAnimationNode {
}
}
}
final class ChatRecordingViewOnceButtonNode: HighlightTrackingButtonNode {
private let backgroundNode: NavigationBackgroundNode
private let borderNode: ASImageNode
private let iconNode: ASImageNode
private var theme: PresentationTheme?
override init(pointerStyle: PointerStyle? = nil) {
self.backgroundNode = NavigationBackgroundNode(color: .clear)
self.backgroundNode.isUserInteractionEnabled = false
self.borderNode = ASImageNode()
self.borderNode.isUserInteractionEnabled = false
self.iconNode = ASImageNode()
self.iconNode.isUserInteractionEnabled = false
super.init(pointerStyle: pointerStyle)
self.addSubnode(self.backgroundNode)
self.addSubnode(self.borderNode)
self.addSubnode(self.iconNode)
self.highligthedChanged = { [weak self] highlighted in
if let self, self.bounds.width > 0.0 {
let topScale: CGFloat = (self.bounds.width - 8.0) / self.bounds.width
let maxScale: CGFloat = (self.bounds.width + 2.0) / self.bounds.width
if highlighted {
self.layer.removeAnimation(forKey: "sublayerTransform")
let transition = ContainedViewLayoutTransition.animated(duration: 0.2, curve: .easeInOut)
transition.updateTransformScale(node: self, scale: topScale)
} else {
let transition = ContainedViewLayoutTransition.immediate
transition.updateTransformScale(node: self, scale: 1.0)
self.layer.animateScale(from: topScale, to: maxScale, duration: 0.13, timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, removeOnCompletion: false, completion: { [weak self] _ in
guard let self else {
return
}
self.layer.animateScale(from: maxScale, to: 1.0, duration: 0.1, timingFunction: CAMediaTimingFunctionName.easeIn.rawValue)
})
}
}
}
}
private var innerIsSelected = false
func update(isSelected: Bool, animated: Bool = false) {
guard let theme = self.theme else {
return
}
let updated = self.iconNode.image == nil || self.innerIsSelected != isSelected
self.innerIsSelected = isSelected
if animated, updated && self.iconNode.image != nil, let snapshot = self.iconNode.view.snapshotContentTree() {
self.view.addSubview(snapshot)
snapshot.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { _ in
snapshot.removeFromSuperview()
})
self.iconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
}
if updated {
self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: self.innerIsSelected ? "Media Gallery/ViewOnceOn" : "Media Gallery/ViewOnce"), color: theme.chat.inputPanel.panelControlAccentColor)
}
}
func update(theme: PresentationTheme) -> CGSize {
let size = CGSize(width: 44.0, height: 44.0)
let innerSize = CGSize(width: 40.0, height: 40.0)
if self.theme !== theme {
self.theme = theme
self.backgroundNode.updateColor(color: theme.chat.inputPanel.panelBackgroundColor, transition: .immediate)
self.borderNode.image = generateCircleImage(diameter: innerSize.width, lineWidth: 0.5, color: theme.chat.historyNavigation.strokeColor, backgroundColor: nil)
self.iconNode.image = generateTintedImage(image: UIImage(bundleImageName: self.innerIsSelected ? "Media Gallery/ViewOnceOn" : "Media Gallery/ViewOnce"), color: theme.chat.inputPanel.panelControlAccentColor)
}
let backgroundFrame = CGRect(origin: CGPoint(x: floorToScreenPixels(size.width / 2.0 - innerSize.width / 2.0), y: floorToScreenPixels(size.height / 2.0 - innerSize.height / 2.0)), size: innerSize)
self.backgroundNode.update(size: innerSize, cornerRadius: innerSize.width / 2.0, transition: .immediate, beginWithCurrentState: false)
self.backgroundNode.frame = backgroundFrame
if let borderImage = self.borderNode.image {
let borderFrame = CGRect(origin: CGPoint(x: floorToScreenPixels(size.width / 2.0 - borderImage.size.width / 2.0), y: floorToScreenPixels(size.height / 2.0 - borderImage.size.height / 2.0)), size: borderImage.size)
self.borderNode.frame = borderFrame
}
if let iconImage = self.iconNode.image {
let iconFrame = CGRect(origin: CGPoint(x: floorToScreenPixels(size.width / 2.0 - iconImage.size.width / 2.0), y: floorToScreenPixels(size.height / 2.0 - iconImage.size.height / 2.0)), size: iconImage.size)
self.iconNode.frame = iconFrame
}
return size
}
}