From ff29e58d4bf6bcaf57747661956a134c74e49d02 Mon Sep 17 00:00:00 2001 From: Ilya Laktyushin Date: Sat, 24 May 2025 16:38:39 +0200 Subject: [PATCH] Various improvements --- .../Sources/AttachmentPanel.swift | 2 +- .../ChatPanelInterfaceInteraction.swift | 8 +- .../Sources/ContactsPeerItem.swift | 8 +- .../OpusBinding/TGOggOpusWriter.h | 3 +- .../OpusBinding/Sources/opusenc/opusenc.m | 170 ++++++++++++++++++ .../Sources/ChatQrCodeScreen.swift | 61 +++++-- .../Sources/ChatRecentActionsController.swift | 2 +- .../Sources/ChatTimerScreen.swift | 6 + .../Sources/GiftStoreScreen.swift | 10 +- .../Sources/PeerInfoScreen.swift | 2 +- .../Sources/PeerSelectionControllerNode.swift | 2 +- .../Chat/ChatControllerLoadDisplayNode.swift | 9 +- .../Chat/ChatControllerMediaRecording.swift | 84 ++++++--- .../ChatControllerOpenPhoneContextMenu.swift | 9 +- .../ChatRecordingPreviewInputPanelNode.swift | 14 +- .../Sources/ManagedAudioRecorder.swift | 34 +++- 16 files changed, 359 insertions(+), 65 deletions(-) diff --git a/submodules/AttachmentUI/Sources/AttachmentPanel.swift b/submodules/AttachmentUI/Sources/AttachmentPanel.swift index 9f1f540f89..21c65ebcdd 100644 --- a/submodules/AttachmentUI/Sources/AttachmentPanel.swift +++ b/submodules/AttachmentUI/Sources/AttachmentPanel.swift @@ -1264,7 +1264,7 @@ final class AttachmentPanel: ASDisplayNode, ASScrollViewDelegate { }, openStarsPurchase: { _ in }, openMessagePayment: { }, openBoostToUnrestrict: { - }, updateVideoTrimRange: { _, _, _, _ in + }, updateRecordingTrimRange: { _, _, _, _ in }, updateHistoryFilter: { _ in }, updateChatLocationThread: { _, _ in }, toggleChatSidebarMode: { diff --git a/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift b/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift index b99fe2b19b..48ebb5d6b6 100644 --- a/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift +++ b/submodules/ChatPresentationInterfaceState/Sources/ChatPanelInterfaceInteraction.swift @@ -175,7 +175,7 @@ public final class ChatPanelInterfaceInteraction { public let toggleChatSidebarMode: () -> Void public let updateDisplayHistoryFilterAsList: (Bool) -> Void public let openBoostToUnrestrict: () -> Void - public let updateVideoTrimRange: (Double, Double, Bool, Bool) -> Void + public let updateRecordingTrimRange: (Double, Double, Bool, Bool) -> Void public let requestLayout: (ContainedViewLayoutTransition) -> Void public let chatController: () -> ViewController? public let statuses: ChatPanelInterfaceInteractionStatuses? @@ -290,7 +290,7 @@ public final class ChatPanelInterfaceInteraction { openStarsPurchase: @escaping (Int64?) -> Void, openMessagePayment: @escaping () -> Void, openBoostToUnrestrict: @escaping () -> Void, - updateVideoTrimRange: @escaping (Double, Double, Bool, Bool) -> Void, + updateRecordingTrimRange: @escaping (Double, Double, Bool, Bool) -> Void, updateHistoryFilter: @escaping ((ChatPresentationInterfaceState.HistoryFilter?) -> ChatPresentationInterfaceState.HistoryFilter?) -> Void, updateChatLocationThread: @escaping (Int64?, ChatControllerAnimateInnerChatSwitchDirection?) -> Void, toggleChatSidebarMode: @escaping () -> Void, @@ -408,7 +408,7 @@ public final class ChatPanelInterfaceInteraction { self.openStarsPurchase = openStarsPurchase self.openMessagePayment = openMessagePayment self.openBoostToUnrestrict = openBoostToUnrestrict - self.updateVideoTrimRange = updateVideoTrimRange + self.updateRecordingTrimRange = updateRecordingTrimRange self.updateHistoryFilter = updateHistoryFilter self.updateChatLocationThread = updateChatLocationThread self.toggleChatSidebarMode = toggleChatSidebarMode @@ -535,7 +535,7 @@ public final class ChatPanelInterfaceInteraction { }, openStarsPurchase: { _ in }, openMessagePayment: { }, openBoostToUnrestrict: { - }, updateVideoTrimRange: { _, _, _, _ in + }, updateRecordingTrimRange: { _, _, _, _ in }, updateHistoryFilter: { _ in }, updateChatLocationThread: { _, _ in }, toggleChatSidebarMode: { diff --git a/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift b/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift index 80c49a033a..ddc2f0a963 100644 --- a/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift +++ b/submodules/ContactsPeerItem/Sources/ContactsPeerItem.swift @@ -804,6 +804,12 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode { rightInset -= 6.0 + rightLabelTextLayoutAndApplyValue.0.size.width } + var searchAdIcon: UIImage? + if item.isAd, let icon = PresentationResourcesChatList.searchAdIcon(item.presentationData.theme, strings: item.presentationData.strings) { + searchAdIcon = icon + rightInset += icon.size.width + 12.0 + } + let premiumConfiguration = PremiumConfiguration.with(appConfiguration: item.context.currentAppConfiguration.with { $0 }) var credibilityIcon: EmojiStatusComponent.Content? @@ -1824,7 +1830,7 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode { adButton.addTarget(strongSelf, action: #selector(strongSelf.adButtonPressed), forControlEvents: .touchUpInside) } if updatedTheme != nil || adButton.image(for: .normal) == nil { - adButton.setImage(PresentationResourcesChatList.searchAdIcon(item.presentationData.theme, strings: item.presentationData.strings), for: .normal) + adButton.setImage(searchAdIcon, for: .normal) } if let icon = adButton.image(for: .normal) { adButton.frame = CGRect(origin: CGPoint(x: params.width - 20.0 - icon.size.width - 13.0, y: 11.0), size: icon.size).insetBy(dx: -11.0, dy: -11.0) diff --git a/submodules/OpusBinding/PublicHeaders/OpusBinding/TGOggOpusWriter.h b/submodules/OpusBinding/PublicHeaders/OpusBinding/TGOggOpusWriter.h index d8c13f7733..0f89f6114b 100644 --- a/submodules/OpusBinding/PublicHeaders/OpusBinding/TGOggOpusWriter.h +++ b/submodules/OpusBinding/PublicHeaders/OpusBinding/TGOggOpusWriter.h @@ -7,6 +7,8 @@ NS_ASSUME_NONNULL_BEGIN @interface TGOggOpusWriter : NSObject - (bool)beginWithDataItem:(TGDataItem *)dataItem; +- (bool)beginAppendWithDataItem:(TGDataItem *)dataItem; + - (bool)writeFrame:(uint8_t * _Nullable)framePcmBytes frameByteCount:(NSUInteger)frameByteCount; - (NSUInteger)encodedBytes; - (NSTimeInterval)encodedDuration; @@ -14,7 +16,6 @@ NS_ASSUME_NONNULL_BEGIN - (NSDictionary *)pause; - (bool)resumeWithDataItem:(TGDataItem *)dataItem encoderState:(NSDictionary *)state; - @end NS_ASSUME_NONNULL_END diff --git a/submodules/OpusBinding/Sources/opusenc/opusenc.m b/submodules/OpusBinding/Sources/opusenc/opusenc.m index f52e89b8bc..15bad5b382 100644 --- a/submodules/OpusBinding/Sources/opusenc/opusenc.m +++ b/submodules/OpusBinding/Sources/opusenc/opusenc.m @@ -110,6 +110,8 @@ static inline int writeOggPage(ogg_page *page, TGDataItem *fileItem) opus_int32 lookahead; } +@property (nonatomic) ogg_sync_state syncState; + @end @implementation TGOggOpusWriter @@ -343,6 +345,174 @@ static inline int writeOggPage(ogg_page *page, TGDataItem *fileItem) return true; } +- (bool)parseExistingOpusFile:(NSData *)data +{ + ogg_sync_init(&_syncState); + + char *buffer = ogg_sync_buffer(&_syncState, (long)data.length); + memcpy(buffer, data.bytes, data.length); + ogg_sync_wrote(&_syncState, (long)data.length); + + ogg_stream_state tempStream; + ogg_page page; + ogg_packet packet; + bool headerParsed = false; + bool foundStream = false; + ogg_int64_t finalGranulePos = 0; + + while (ogg_sync_pageout(&_syncState, &page) == 1) { + if (!foundStream) { + serialno = ogg_page_serialno(&page); + if (ogg_stream_init(&tempStream, serialno) != 0) { + ogg_sync_clear(&_syncState); + return false; + } + foundStream = true; + } + + if (ogg_page_serialno(&page) == serialno) { + ogg_stream_pagein(&tempStream, &page); + + if (ogg_page_granulepos(&page) != -1) { + finalGranulePos = ogg_page_granulepos(&page); + } + + while (ogg_stream_packetout(&tempStream, &packet) == 1) { + if (!headerParsed && packet.packetno == 0) { + if (![self parseOpusHeader:packet.packet length:packet.bytes]) { + ogg_stream_clear(&tempStream); + ogg_sync_clear(&_syncState); + return false; + } + headerParsed = true; + } + + _packetId = (ogg_int32_t)packet.packetno; + if (packet.granulepos != -1) { + enc_granulepos = packet.granulepos; + last_granulepos = packet.granulepos; + finalGranulePos = packet.granulepos; + } + } + } + } + + if (finalGranulePos > header.preskip) { + opus_int64 samples = finalGranulePos - header.preskip; + total_samples = (samples * rate) / 48000; + } else { + total_samples = 0; + } + + ogg_stream_clear(&tempStream); + ogg_sync_clear(&_syncState); + + if (!headerParsed) { + return false; + } + + return true; +} + +- (bool)parseOpusHeader:(unsigned char *)data length:(long)length +{ + if (length < 19) { + NSLog(@"Opus header too short"); + return false; + } + + if (memcmp(data, "OpusHead", 8) != 0) { + NSLog(@"Invalid Opus header signature"); + return false; + } + + header.channels = data[9]; + header.preskip = data[10] | (data[11] << 8); + header.input_sample_rate = data[12] | (data[13] << 8) | (data[14] << 16) | (data[15] << 24); + header.gain = (signed short)(data[16] | (data[17] << 8)); + header.channel_mapping = data[18]; + + if (header.channels == 0) { + return false; + } + + rate = header.input_sample_rate; + coding_rate = rate; + + if (rate > 24000) + coding_rate = 48000; + else if (rate > 16000) + coding_rate = 24000; + else if (rate > 12000) + coding_rate = 16000; + else if (rate > 8000) + coding_rate = 12000; + else + coding_rate = 8000; + + header.nb_streams = 1; + + return true; +} + +- (bool)initializeEncoderForAppend +{ + bytes_written = _dataItem.data.length; + + inopt.channels = header.channels; + inopt.rate = rate; + inopt.gain = header.gain; + inopt.samplesize = 16; + inopt.endianness = 0; + inopt.rawmode = 0; + inopt.ignorelength = 0; + inopt.copy_comments = 0; + + int result = OPUS_OK; + _encoder = opus_encoder_create(coding_rate, header.channels, OPUS_APPLICATION_AUDIO, &result); + if (result != OPUS_OK) { + NSLog(@"Error cannot create encoder: %s", opus_strerror(result)); + return false; + } + + bitrate = 30 * 1024; + frame_size = 960; + + opus_encoder_ctl(_encoder, OPUS_SET_BITRATE(bitrate)); + +#ifdef OPUS_SET_LSB_DEPTH + opus_encoder_ctl(_encoder, OPUS_SET_LSB_DEPTH(16)); +#endif + + opus_encoder_ctl(_encoder, OPUS_GET_LOOKAHEAD(&lookahead)); + + if (ogg_stream_init(&os, serialno) == -1) { + NSLog(@"Error: stream init failed"); + return false; + } + + max_frame_bytes = (1275 * 3 + 7) * header.nb_streams; + _packet = malloc(max_frame_bytes); + + return true; +} + + +- (bool)beginAppendWithDataItem:(TGDataItem *)dataItem +{ + if (dataItem.data.length == 0) { + return [self beginWithDataItem:dataItem]; + } + + _dataItem = dataItem; + + if (![self parseExistingOpusFile:_dataItem.data]) { + return false; + } + + return [self initializeEncoderForAppend]; +} + - (bool)writeFrame:(uint8_t *)framePcmBytes frameByteCount:(NSUInteger)frameByteCount { // Main encoding loop (one frame per iteration) diff --git a/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift b/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift index 59c9905105..e4e97d3119 100644 --- a/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift +++ b/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift @@ -765,6 +765,7 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg private let contentNode: ContentNode private let wrappingScrollNode: ASScrollNode + private let scrollNodeContentNode: ASDisplayNode private let contentContainerNode: ASDisplayNode private let topContentContainerNode: SparseNode private let shadowNode: ASImageNode @@ -824,6 +825,9 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg self.wrappingScrollNode.view.delaysContentTouches = false self.wrappingScrollNode.view.canCancelContentTouches = true + self.scrollNodeContentNode = ASDisplayNode() + self.scrollNodeContentNode.clipsToBounds = true + switch controller.subject { case let .peer(peer, threadId, temporary): self.contentNode = QrContentNode(context: context, peer: peer, threadId: threadId, isStatic: false, temporary: temporary) @@ -909,12 +913,14 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg self.addSubnode(self.wrappingScrollNode) - self.wrappingScrollNode.addSubnode(self.contentNode) + self.wrappingScrollNode.addSubnode(self.scrollNodeContentNode) - self.wrappingScrollNode.addSubnode(self.shadowNode) - self.wrappingScrollNode.addSubnode(self.backgroundNode) - self.wrappingScrollNode.addSubnode(self.contentContainerNode) - self.wrappingScrollNode.addSubnode(self.topContentContainerNode) + self.scrollNodeContentNode.addSubnode(self.contentNode) + + self.scrollNodeContentNode.addSubnode(self.shadowNode) + self.scrollNodeContentNode.addSubnode(self.backgroundNode) + self.scrollNodeContentNode.addSubnode(self.contentContainerNode) + self.scrollNodeContentNode.addSubnode(self.topContentContainerNode) self.backgroundNode.addSubnode(self.effectNode) self.backgroundNode.addSubnode(self.contentBackgroundNode) @@ -1401,32 +1407,60 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg public func animateIn() { let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY + if let (layout, _) = self.containerLayout { + self.scrollNodeContentNode.cornerRadius = layout.deviceMetrics.screenCornerRadius + } + let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) let targetBounds = self.bounds self.bounds = self.bounds.offsetBy(dx: 0.0, dy: -offset) transition.animateView({ self.bounds = targetBounds + }, completion: { _ in + self.scrollNodeContentNode.cornerRadius = 0.0 }) } - public func animateOut(completion: (() -> Void)? = nil) { + public func animateOut(velocity: Double? = nil, completion: (() -> Void)? = nil) { self.animatedOut = true - let offset = self.bounds.size.height - self.contentBackgroundNode.frame.minY - self.wrappingScrollNode.layer.animateBoundsOriginYAdditive(from: 0.0, to: -offset, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { _ in - completion?() - }) + self.wrappingScrollNode.view.isScrollEnabled = false + + let distance = self.bounds.size.height - self.contentBackgroundNode.frame.minY + if let velocity { + let initialVelocity: CGFloat = distance.isZero ? 0.0 : abs(velocity / distance) + self.wrappingScrollNode.layer.animateSpring(from: 0.0 as NSNumber, to: -distance as NSNumber, keyPath: "bounds.origin.y", duration: 0.45, delay: 0.0, initialVelocity: initialVelocity, damping: 124.0, removeOnCompletion: false, additive: true, completion: { _ in + completion?() + }) + } else { + self.wrappingScrollNode.layer.animateBoundsOriginYAdditive(from: 0.0, to: -distance, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue, removeOnCompletion: false, completion: { _ in + completion?() + }) + } } - - public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { + + func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { let contentOffset = scrollView.contentOffset let additionalTopHeight = max(0.0, -contentOffset.y) if additionalTopHeight >= 30.0 { - self.cancelButtonPressed() + self.animateOut(velocity: velocity.y, completion: { + self.controller?.dismiss(animated: false) + }) } } + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + guard let (layout, _) = self.containerLayout else { + return + } + self.scrollNodeContentNode.cornerRadius = layout.deviceMetrics.screenCornerRadius + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + self.scrollNodeContentNode.cornerRadius = 0.0 + } + public func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) { self.containerLayout = (layout, navigationBarHeight) @@ -1455,6 +1489,7 @@ private class ChatQrCodeScreenNode: ViewControllerTracingNode, ASScrollViewDeleg transition.updateFrame(node: self.effectNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size)) transition.updateFrame(node: self.contentBackgroundNode, frame: CGRect(origin: CGPoint(), size: backgroundFrame.size)) transition.updateFrame(node: self.wrappingScrollNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + transition.updateFrame(node: self.scrollNodeContentNode, frame: CGRect(origin: CGPoint(), size: CGSize(width: layout.size.width, height: layout.size.height + 2000.0))) let titleSize = self.titleNode.measure(CGSize(width: width - 90.0, height: titleHeight)) let titleFrame = CGRect(origin: CGPoint(x: floor((contentFrame.width - titleSize.width) / 2.0), y: 19.0 + UIScreenPixel), size: titleSize) diff --git a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift index 9982a6d4cb..be16736728 100644 --- a/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift +++ b/submodules/TelegramUI/Components/Chat/ChatRecentActionsController/Sources/ChatRecentActionsController.swift @@ -168,7 +168,7 @@ public final class ChatRecentActionsController: TelegramBaseController { }, openStarsPurchase: { _ in }, openMessagePayment: { }, openBoostToUnrestrict: { - }, updateVideoTrimRange: { _, _, _, _ in + }, updateRecordingTrimRange: { _, _, _, _ in }, updateHistoryFilter: { _ in }, updateChatLocationThread: { _, _ in }, toggleChatSidebarMode: { diff --git a/submodules/TelegramUI/Components/ChatTimerScreen/Sources/ChatTimerScreen.swift b/submodules/TelegramUI/Components/ChatTimerScreen/Sources/ChatTimerScreen.swift index 94cc90f6ff..24b85cc119 100644 --- a/submodules/TelegramUI/Components/ChatTimerScreen/Sources/ChatTimerScreen.swift +++ b/submodules/TelegramUI/Components/ChatTimerScreen/Sources/ChatTimerScreen.swift @@ -186,6 +186,9 @@ private class TimerDatePickerView: UIDatePicker, TimerPickerView { } } +private let digitsCharacterSet = CharacterSet(charactersIn: "0123456789") +private let nondigitsCharacterSet = CharacterSet(charactersIn: "0123456789").inverted + private class TimerPickerItemView: UIView { let valueLabel = UILabel() let unitLabel = UILabel() @@ -207,6 +210,9 @@ private class TimerPickerItemView: UIView { } else if components.count > 1 { self.valueLabel.text = components[0] self.unitLabel.text = components[1] + } else { + self.valueLabel.text = string.trimmingCharacters(in: nondigitsCharacterSet) + self.unitLabel.text = string.trimmingCharacters(in: digitsCharacterSet) } } diff --git a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift index 1e243f7ba3..46e6b728a1 100644 --- a/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift +++ b/submodules/TelegramUI/Components/Gifts/GiftStoreScreen/Sources/GiftStoreScreen.swift @@ -1070,11 +1070,10 @@ final class GiftStoreScreenComponent: Component { var modelTitle = environment.strings.Gift_Store_Filter_Model var backdropTitle = environment.strings.Gift_Store_Filter_Backdrop var symbolTitle = environment.strings.Gift_Store_Filter_Symbol + var modelCount: Int32 = 0 + var backdropCount: Int32 = 0 + var symbolCount: Int32 = 0 if let filterAttributes = self.state?.starGiftsState?.filterAttributes { - var modelCount: Int32 = 0 - var backdropCount: Int32 = 0 - var symbolCount: Int32 = 0 - for attribute in filterAttributes { switch attribute { case .model: @@ -1099,6 +1098,7 @@ final class GiftStoreScreenComponent: Component { filterItems.append(FilterSelectorComponent.Item( id: AnyHashable(FilterItemId.model), + index: Int(modelCount), title: modelTitle, action: { [weak self] view in if let self { @@ -1110,6 +1110,7 @@ final class GiftStoreScreenComponent: Component { )) filterItems.append(FilterSelectorComponent.Item( id: AnyHashable(FilterItemId.backdrop), + index: Int(backdropCount), title: backdropTitle, action: { [weak self] view in if let self { @@ -1121,6 +1122,7 @@ final class GiftStoreScreenComponent: Component { )) filterItems.append(FilterSelectorComponent.Item( id: AnyHashable(FilterItemId.symbol), + index: Int(symbolCount), title: symbolTitle, action: { [weak self] view in if let self { diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift index 5dfcc7fda1..39a5a90b55 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoScreen.swift @@ -433,7 +433,7 @@ final class PeerInfoSelectionPanelNode: ASDisplayNode { }, openStarsPurchase: { _ in }, openMessagePayment: { }, openBoostToUnrestrict: { - }, updateVideoTrimRange: { _, _, _, _ in + }, updateRecordingTrimRange: { _, _, _, _ in }, updateHistoryFilter: { _ in }, updateChatLocationThread: { _, _ in }, toggleChatSidebarMode: { diff --git a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift index 1c9c9817a2..73b007a191 100644 --- a/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift +++ b/submodules/TelegramUI/Components/PeerSelectionController/Sources/PeerSelectionControllerNode.swift @@ -782,7 +782,7 @@ final class PeerSelectionControllerNode: ASDisplayNode { }, openStarsPurchase: { _ in }, openMessagePayment: { }, openBoostToUnrestrict: { - }, updateVideoTrimRange: { _, _, _, _ in + }, updateRecordingTrimRange: { _, _, _, _ in }, updateHistoryFilter: { _ in }, updateChatLocationThread: { _, _ in }, toggleChatSidebarMode: { diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift index d2d303b741..3bd0a344cf 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerLoadDisplayNode.swift @@ -4107,12 +4107,11 @@ extension ChatControllerImpl { ) self.push(boostController) }) - }, updateVideoTrimRange: { [weak self] start, end, updatedEnd, apply in - if let videoRecorder = self?.videoRecorderValue { - videoRecorder.updateTrimRange(start: start, end: end, updatedEnd: updatedEnd, apply: apply) - } else if let audioRecorder = self?.audioRecorderValue { - audioRecorder.updateTrimRange(start: start, end: end, updatedEnd: updatedEnd, apply: apply) + }, updateRecordingTrimRange: { [weak self] start, end, updatedEnd, apply in + guard let self else { + return } + self.updateTrimRange(start: start, end: end, updatedEnd: updatedEnd, apply: apply) }, updateHistoryFilter: { [weak self] update in guard let self else { return diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift index 969563f98a..87a5746086 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerMediaRecording.swift @@ -309,6 +309,13 @@ extension ChatControllerImpl { strongSelf.context.account.postbox.mediaBox.storeResourceData(resource!.id, data: data.compressedData) } + let audioWaveform: AudioWaveform + if let recordedMediaPreview = strongSelf.presentationInterfaceState.interfaceState.mediaDraftState, case let .audio(audio) = recordedMediaPreview { + audioWaveform = audio.waveform + } else { + audioWaveform = AudioWaveform(bitstream: waveform, bitsPerSample: 5) + } + strongSelf.updateChatPresentationInterfaceState(animated: true, interactive: true, { $0.updatedInterfaceState { $0.withUpdatedMediaDraftState(.audio( @@ -316,7 +323,7 @@ extension ChatControllerImpl { resource: resource!, fileSize: Int32(data.compressedData.count), duration: Int32(data.duration), - waveform: AudioWaveform(bitstream: waveform, bitsPerSample: 5), + waveform: audioWaveform, trimRange: data.trimRange, resumeData: data.resumeData ) @@ -465,29 +472,9 @@ extension ChatControllerImpl { } func resumeMediaRecorder() { - self.context.sharedContext.mediaManager.playlistControl(.playback(.pause), type: nil) + self.recorderDataDisposable.set(nil) - if let audioRecorderValue = self.audioRecorderValue { - audioRecorderValue.resume() - - self.updateChatPresentationInterfaceState(animated: true, interactive: true, { - $0.updatedInputTextPanelState { panelState in - return panelState.withUpdatedMediaRecordingState(.audio(recorder: audioRecorderValue, isLocked: true)) - }.updatedInterfaceState { $0.withUpdatedMediaDraftState(nil) } - }) - } else if let recordedMediaPreview = self.presentationInterfaceState.interfaceState.mediaDraftState, case let .audio(audio) = recordedMediaPreview { - self.requestAudioRecorder(beginWithTone: false, existingDraft: audio) - - if let audioRecorderValue = self.audioRecorderValue { - audioRecorderValue.resume() - - self.updateChatPresentationInterfaceState(animated: true, interactive: true, { - $0.updatedInputTextPanelState { panelState in - return panelState.withUpdatedMediaRecordingState(.audio(recorder: audioRecorderValue, isLocked: true)) - }.updatedInterfaceState { $0.withUpdatedMediaDraftState(nil) } - }) - } - } + self.context.sharedContext.mediaManager.playlistControl(.playback(.pause), type: nil) if let videoRecorderValue = self.videoRecorderValue { self.updateChatPresentationInterfaceState(animated: true, interactive: true, { @@ -496,6 +483,36 @@ extension ChatControllerImpl { return panelState.withUpdatedMediaRecordingState(.video(status: .recording(InstantVideoControllerRecordingStatus(micLevel: recordingStatus.micLevel, duration: recordingStatus.duration)), isLocked: true)) }.updatedInterfaceState { $0.withUpdatedMediaDraftState(nil) } }) + } else { + let proceed = { + self.withAudioRecorder({ audioRecorder in + audioRecorder.resume() + + self.updateChatPresentationInterfaceState(animated: true, interactive: true, { + $0.updatedInputTextPanelState { panelState in + return panelState.withUpdatedMediaRecordingState(.audio(recorder: audioRecorder, isLocked: true)) + }.updatedInterfaceState { $0.withUpdatedMediaDraftState(nil) } + }) + }) + } + + if let recordedMediaPreview = self.presentationInterfaceState.interfaceState.mediaDraftState, case let .audio(audio) = recordedMediaPreview, let _ = audio.trimRange { + self.present( + textAlertController( + context: self.context, + title: "Trim to selected range?", + text: "Audio outside that range will be discarded, and recording will start immediately.", + actions: [ + TextAlertAction(type: .genericAction, title: "Cancel", action: {}), + TextAlertAction(type: .defaultAction, title: "Proceed", action: { + proceed() + }) + ] + ), in: .window(.root) + ) + } else { + proceed() + } } } @@ -526,6 +543,27 @@ extension ChatControllerImpl { self.updateDownButtonVisibility() } + private func withAudioRecorder(_ f: (ManagedAudioRecorder) -> Void) { + if let audioRecorder = self.audioRecorderValue { + f(audioRecorder) + } else if let recordedMediaPreview = self.presentationInterfaceState.interfaceState.mediaDraftState, case let .audio(audio) = recordedMediaPreview { + self.requestAudioRecorder(beginWithTone: false, existingDraft: audio) + if let audioRecorder = self.audioRecorderValue { + f(audioRecorder) + } + } + } + + func updateTrimRange(start: Double, end: Double, updatedEnd: Bool, apply: Bool) { + if let videoRecorder = self.videoRecorderValue { + videoRecorder.updateTrimRange(start: start, end: end, updatedEnd: updatedEnd, apply: apply) + } else { + self.withAudioRecorder({ audioRecorder in + audioRecorder.updateTrimRange(start: start, end: end, updatedEnd: updatedEnd, apply: apply) + }) + } + } + func sendMediaRecording( silentPosting: Bool? = nil, scheduleTime: Int32? = nil, diff --git a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPhoneContextMenu.swift b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPhoneContextMenu.swift index 2506707179..b9d6aae48c 100644 --- a/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPhoneContextMenu.swift +++ b/submodules/TelegramUI/Sources/Chat/ChatControllerOpenPhoneContextMenu.swift @@ -54,6 +54,8 @@ extension ChatControllerImpl: MFMessageComposeViewControllerDelegate { } params.progress?.set(.single(false)) + var firstName = "" + var lastName = "" let phoneNumber: String if let peer, case let .user(user) = peer, let phone = user.phone { phoneNumber = "+\(phone)" @@ -61,13 +63,18 @@ extension ChatControllerImpl: MFMessageComposeViewControllerDelegate { phoneNumber = number } + if case let .user(user) = peer { + firstName = user.firstName ?? "" + lastName = user.lastName ?? "" + } + var items: [ContextMenuItem] = [] items.append( .action(ContextMenuActionItem(text: self.presentationData.strings.Chat_Context_Phone_AddToContacts, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/AddUser"), color: theme.contextMenu.primaryColor) }, action: { [weak self] c, _ in guard let self, let c else { return } - let basicData = DeviceContactBasicData(firstName: "", lastName: "", phoneNumbers: [ + let basicData = DeviceContactBasicData(firstName: firstName, lastName: lastName, phoneNumbers: [ DeviceContactPhoneNumberData(label: "", value: phoneNumber) ]) let contactData = DeviceContactExtendedData(basicData: basicData, middleName: "", prefix: "", suffix: "", organization: "", jobTitle: "", department: "", emailAddresses: [], urls: [], addresses: [], birthdayDate: nil, socialProfiles: [], instantMessagingProfiles: [], note: "") diff --git a/submodules/TelegramUI/Sources/ChatRecordingPreviewInputPanelNode.swift b/submodules/TelegramUI/Sources/ChatRecordingPreviewInputPanelNode.swift index 7b6b78c58e..15e13c963b 100644 --- a/submodules/TelegramUI/Sources/ChatRecordingPreviewInputPanelNode.swift +++ b/submodules/TelegramUI/Sources/ChatRecordingPreviewInputPanelNode.swift @@ -525,7 +525,7 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { self.trimView.trimUpdated = { [weak self] start, end, updatedEnd, apply in if let self { self.mediaPlayer?.pause() - self.interfaceInteraction?.updateVideoTrimRange(start, end, updatedEnd, apply) + self.interfaceInteraction?.updateRecordingTrimRange(start, end, updatedEnd, apply) if apply { if !updatedEnd { self.mediaPlayer?.seek(timestamp: start, play: true) @@ -548,7 +548,7 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { } self.trimView.frame = waveformBackgroundFrame - let playButtonSize = CGSize(width: rightHandleFrame.minX - leftHandleFrame.maxX, height: waveformBackgroundFrame.height) + let playButtonSize = CGSize(width: max(0.0, rightHandleFrame.minX - leftHandleFrame.maxX), height: waveformBackgroundFrame.height) self.playButtonNode.update(size: playButtonSize, transition: transition) transition.updateFrame(node: self.playButtonNode, frame: CGRect(origin: CGPoint(x: waveformBackgroundFrame.minX + leftHandleFrame.maxX, y: waveformBackgroundFrame.minY), size: playButtonSize)) case let .video(video): @@ -584,7 +584,7 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { positionUpdated: { _, _ in }, trackTrimUpdated: { [weak self] _, start, end, updatedEnd, apply in if let self { - self.interfaceInteraction?.updateVideoTrimRange(start, end, updatedEnd, apply) + self.interfaceInteraction?.updateRecordingTrimRange(start, end, updatedEnd, apply) } }, trackOffsetUpdated: { _, _, _ in }, @@ -825,14 +825,16 @@ final class ChatRecordingPreviewInputPanelNode: ChatInputPanelNode { let _ = (mediaPlayer.status |> take(1) |> deliverOnMainQueue).start(next: { [weak self] status in - guard let self else { + guard let self, let mediaPlayer = self.mediaPlayer else { return } if case .playing = status.status { - self.mediaPlayer?.pause() + mediaPlayer.pause() } else if status.timestamp <= trimRange.lowerBound { - self.mediaPlayer?.seek(timestamp: trimRange.lowerBound, play: true) + mediaPlayer.seek(timestamp: trimRange.lowerBound, play: true) + } else { + mediaPlayer.play() } }) } else { diff --git a/submodules/TelegramUI/Sources/ManagedAudioRecorder.swift b/submodules/TelegramUI/Sources/ManagedAudioRecorder.swift index 2b882c267c..feca4e411e 100644 --- a/submodules/TelegramUI/Sources/ManagedAudioRecorder.swift +++ b/submodules/TelegramUI/Sources/ManagedAudioRecorder.swift @@ -9,6 +9,7 @@ import AccountContext import OpusBinding import ChatPresentationInterfaceState import AudioWaveform +import FFMpegBinding private let kOutputBus: UInt32 = 0 private let kInputBus: UInt32 = 1 @@ -147,6 +148,8 @@ final class ManagedAudioRecorderContext { private let id: Int32 private let micLevel: ValuePromise private let recordingState: ValuePromise + private let previewState: ValuePromise + private let beginWithTone: Bool private let beganWithTone: (Bool) -> Void @@ -156,8 +159,8 @@ final class ManagedAudioRecorderContext { private let queue: Queue private let mediaManager: MediaManager - private let oggWriter: TGOggOpusWriter - private let dataItem: TGDataItem + private var oggWriter: TGOggOpusWriter + private var dataItem: TGDataItem private var audioBuffer = Data() private let audioUnit = Atomic(value: nil) @@ -193,6 +196,7 @@ final class ManagedAudioRecorderContext { pushIdleTimerExtension: @escaping () -> Disposable, micLevel: ValuePromise, recordingState: ValuePromise, + previewState: ValuePromise, beginWithTone: Bool, beganWithTone: @escaping (Bool) -> Void ) { @@ -204,6 +208,7 @@ final class ManagedAudioRecorderContext { self.beganWithTone = beganWithTone self.recordingState = recordingState + self.previewState = previewState self.queue = queue self.mediaManager = mediaManager @@ -487,6 +492,29 @@ final class ManagedAudioRecorderContext { func resume() { assert(self.queue.isCurrent()) + if let trimRange = self.trimRange, trimRange.upperBound < self.oggWriter.encodedDuration() { + if self.oggWriter.writeFrame(nil, frameByteCount: 0), let data = self.dataItem.data() { + let tempSourceFile = EngineTempBox.shared.tempFile(fileName: "audio.ogg") + let tempDestinationFile = EngineTempBox.shared.tempFile(fileName: "audio.ogg") + + try? data.write(to: URL(fileURLWithPath: tempSourceFile.path)) + + FFMpegOpusTrimmer.trim(tempSourceFile.path, to: tempDestinationFile.path, start: trimRange.lowerBound, end: trimRange.upperBound) + + if let trimmedData = try? Data(contentsOf: URL(fileURLWithPath: tempDestinationFile.path), options: []) { + self.dataItem = TGDataItem(data: trimmedData) + self.oggWriter = TGOggOpusWriter() + self.oggWriter.beginAppend(with: self.dataItem) + } + + EngineTempBox.shared.dispose(tempSourceFile) + EngineTempBox.shared.dispose(tempDestinationFile) + + self.trimRange = nil + self.previewState.set(AudioPreviewState(trimRange: self.trimRange)) + } + } + self.start() } @@ -755,7 +783,7 @@ final class ManagedAudioRecorderImpl: ManagedAudioRecorder { ) { self.beginWithTone = beginWithTone self.queue.async { - let context = ManagedAudioRecorderContext(queue: self.queue, mediaManager: mediaManager, resumeData: resumeData, pushIdleTimerExtension: pushIdleTimerExtension, micLevel: self.micLevelValue, recordingState: self.recordingStateValue, beginWithTone: beginWithTone, beganWithTone: beganWithTone) + let context = ManagedAudioRecorderContext(queue: self.queue, mediaManager: mediaManager, resumeData: resumeData, pushIdleTimerExtension: pushIdleTimerExtension, micLevel: self.micLevelValue, recordingState: self.recordingStateValue, previewState: self.previewStateValue, beginWithTone: beginWithTone, beganWithTone: beganWithTone) self.contextRef = Unmanaged.passRetained(context) } }