mirror of
https://github.com/TelegramMessenger/Telegram-iOS.git
synced 2026-07-06 03:33:41 +02:00
Merge branch 'master' of gitlab.com:peter-iakovlev/telegram-ios
This commit is contained in:
commit
3a41864868
302 changed files with 7127 additions and 2476 deletions
|
|
@ -73,7 +73,9 @@ experimental_i:
|
|||
except:
|
||||
- tags
|
||||
script:
|
||||
- python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="$TELEGRAM_PRIVATE_DATA_PATH/build-configurations/enterprise-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=enterprise --configuration=release_arm64
|
||||
- export PATH=/opt/homebrew/opt/ruby/bin:$PATH
|
||||
- export PATH=`gem environment gemdir`/bin:$PATH
|
||||
- python3 -u build-system/Make/Make.py remote-build --darwinContainers="$DARWIN_CONTAINERS" --darwinContainersHost="$DARWIN_CONTAINERS_HOST" --cacheHost="$TELEGRAM_BAZEL_CACHE_HOST" --configurationPath="build-system/appstore-configuration.json" --gitCodesigningRepository="$TELEGRAM_GIT_CODESIGNING_REPOSITORY" --gitCodesigningType=adhoc --configuration=release_arm64
|
||||
- python3 -u build-system/Make/DeployToAppCenter.py --configuration="$TELEGRAM_PRIVATE_DATA_PATH/appcenter-configurations/appcenter-experimental.json" --ipa="build/artifacts/Telegram.ipa" --dsyms="build/artifacts/Telegram.DSYMs.zip"
|
||||
environment:
|
||||
name: experimental
|
||||
|
|
|
|||
|
|
@ -593,8 +593,10 @@ private struct NotificationContent: CustomStringConvertible {
|
|||
if !self.userInfo.isEmpty {
|
||||
content.userInfo = self.userInfo
|
||||
}
|
||||
if !self.attachments.isEmpty {
|
||||
content.attachments = self.attachments
|
||||
if self.isLockedMessage == nil {
|
||||
if !self.attachments.isEmpty {
|
||||
content.attachments = self.attachments
|
||||
}
|
||||
}
|
||||
|
||||
if #available(iOS 15.0, *) {
|
||||
|
|
|
|||
|
|
@ -13534,13 +13534,26 @@ Sorry for the inconvenience.";
|
|||
|
||||
"Gift.View.UpgradeForFree" = "Upgrade for Free";
|
||||
"Gift.View.KeepUpgradeOrConvertDescription" = "You can keep this gift, upgrade it, or sell it for %@. [More About Stars >]()";
|
||||
"Gift.View.KeepOrUpgradeDescription" = "You can keep this gift or upgrade it.";
|
||||
|
||||
"PeerInfo.VerificationInfo.Bot" = "This bot is verified as official by the representatives of Telegram.";
|
||||
"PeerInfo.VerificationInfo.Channel" = "This channel is verified as official by the representatives of Telegram.";
|
||||
"PeerInfo.VerificationInfo.Group" = "This group is verified as official by the representatives of Telegram.";
|
||||
"PeerInfo.VerificationInfo.URL" = "https://telegram.org/verify";
|
||||
|
||||
"ChatList.ToastFolderMutedV2" = "All chats in {folder} are now muted";
|
||||
"ChatList.ToastFolderUnmutedV2" = "All chats in {folder} are now unmuted";
|
||||
"ChatList.AddedToFolderTooltipV2" = "{chat} has been added to folder {folder}";
|
||||
"ChatList.RemovedFromFolderTooltipV2" = "{chat} has been removed from folder {folder}";
|
||||
|
||||
"FolderLinkScreen.TitleDescriptionDeselectedV2" = "Anyone with this link can add {folder} folder and the chats selected below.";
|
||||
"FolderLinkScreen.TitleDescriptionSelectedV2" = "Anyone with this link can add {folder} folder and {chats} selected below.";
|
||||
"Chat.NextChannelFolderSwipeProgressV2" = "Swipe up to go to the {folder} folder";
|
||||
"Chat.NextChannelFolderSwipeActionV2" = "Release to go to the {folder} folder";
|
||||
"FolderLinkPreview.TextAddChatsV2" = "Do you want to add {chats} to the\nfolder {folder}?";
|
||||
"FolderLinkPreview.ToastLeftTitleV2" = "Folder {folder} deleted";
|
||||
"FolderLinkPreview.ToastChatsAddedTitleV2" = "Folder {folder} Updated";
|
||||
"FolderLinkPreview.ToastFolderAddedTitleV2" = "Folder {folder} Added";
|
||||
|
||||
"Stars.Purchase.UpgradeStarGiftInfo" = "Buy Stars to upgrade yort gift into a unique collectible.";
|
||||
|
||||
"Gift.Send.Upgrade" = "Make Unique for %@";
|
||||
|
|
@ -13563,7 +13576,14 @@ Sorry for the inconvenience.";
|
|||
"Notification.StarGift.Subtitle.Refunded" = "This gift cannot be converted to Stars because the payment related to it was refunded.";
|
||||
"Notification.StarGift.Subtitle.Downgraded" = "This gift was downgraded because a request to refund the payment related to this gift was made, and the money was returned.";
|
||||
|
||||
"Notification.StarGift.Subtitle.Other" = "%1$@ can keep this gift or upgrade it.";
|
||||
"Notification.StarGift.Subtitle.Other" = "%1$@ can turn this gift to a unique collectible.";
|
||||
|
||||
"Stars.Transaction.GiftFrom" = "Gift From";
|
||||
"Stars.Transaction.GiftUpgrade" = "Gift Upgrade";
|
||||
|
||||
"Gift.View.KeepOrUpgradeDescription" = "Tap \"Unpack\" to turn this gift to a unique collectible.";
|
||||
|
||||
"VideoChat.IncomingVideoQuality.AudioOnly" = "Audio Only";
|
||||
"VideoChat.IncomingVideoQuality.Title" = "Receive Video Quality";
|
||||
|
||||
"ChatList.EmptyResult.SearchInAll" = "Search in All Messages";
|
||||
|
|
|
|||
|
|
@ -413,7 +413,7 @@ public enum ChatTextInputStateTextAttributeType: Codable, Equatable {
|
|||
case monospace
|
||||
case textMention(EnginePeer.Id)
|
||||
case textUrl(String)
|
||||
case customEmoji(stickerPack: StickerPackReference?, fileId: Int64)
|
||||
case customEmoji(stickerPack: StickerPackReference?, fileId: Int64, enableAnimation: Bool)
|
||||
case strikethrough
|
||||
case underline
|
||||
case spoiler
|
||||
|
|
@ -440,7 +440,8 @@ public enum ChatTextInputStateTextAttributeType: Codable, Equatable {
|
|||
case 5:
|
||||
let stickerPack = try container.decodeIfPresent(StickerPackReference.self, forKey: "s")
|
||||
let fileId = try container.decode(Int64.self, forKey: "f")
|
||||
self = .customEmoji(stickerPack: stickerPack, fileId: fileId)
|
||||
let enableAnimation = try container.decodeIfPresent(Bool.self, forKey: "ea") ?? true
|
||||
self = .customEmoji(stickerPack: stickerPack, fileId: fileId, enableAnimation: enableAnimation)
|
||||
case 6:
|
||||
self = .strikethrough
|
||||
case 7:
|
||||
|
|
@ -474,10 +475,11 @@ public enum ChatTextInputStateTextAttributeType: Codable, Equatable {
|
|||
case let .textUrl(url):
|
||||
try container.encode(4 as Int32, forKey: "t")
|
||||
try container.encode(url, forKey: "url")
|
||||
case let .customEmoji(stickerPack, fileId):
|
||||
case let .customEmoji(stickerPack, fileId, enableAnimation):
|
||||
try container.encode(5 as Int32, forKey: "t")
|
||||
try container.encodeIfPresent(stickerPack, forKey: "s")
|
||||
try container.encode(fileId, forKey: "f")
|
||||
try container.encode(enableAnimation, forKey: "ea")
|
||||
case .strikethrough:
|
||||
try container.encode(6 as Int32, forKey: "t")
|
||||
case .underline:
|
||||
|
|
@ -560,7 +562,7 @@ public struct ChatTextInputStateText: Codable, Equatable {
|
|||
} else if key == ChatTextInputAttributes.textUrl, let value = value as? ChatTextInputTextUrlAttribute {
|
||||
parsedAttributes.append(ChatTextInputStateTextAttribute(type: .textUrl(value.url), range: range.location ..< (range.location + range.length)))
|
||||
} else if key == ChatTextInputAttributes.customEmoji, let value = value as? ChatTextInputTextCustomEmojiAttribute {
|
||||
parsedAttributes.append(ChatTextInputStateTextAttribute(type: .customEmoji(stickerPack: nil, fileId: value.fileId), range: range.location ..< (range.location + range.length)))
|
||||
parsedAttributes.append(ChatTextInputStateTextAttribute(type: .customEmoji(stickerPack: nil, fileId: value.fileId, enableAnimation: value.enableAnimation), range: range.location ..< (range.location + range.length)))
|
||||
} else if key == ChatTextInputAttributes.strikethrough {
|
||||
parsedAttributes.append(ChatTextInputStateTextAttribute(type: .strikethrough, range: range.location ..< (range.location + range.length)))
|
||||
} else if key == ChatTextInputAttributes.underline {
|
||||
|
|
@ -618,8 +620,8 @@ public struct ChatTextInputStateText: Codable, Equatable {
|
|||
result.addAttribute(ChatTextInputAttributes.textMention, value: ChatTextInputTextMentionAttribute(peerId: id), range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count))
|
||||
case let .textUrl(url):
|
||||
result.addAttribute(ChatTextInputAttributes.textUrl, value: ChatTextInputTextUrlAttribute(url: url), range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count))
|
||||
case let .customEmoji(_, fileId):
|
||||
result.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: nil), range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count))
|
||||
case let .customEmoji(_, fileId, enableAnimation):
|
||||
result.addAttribute(ChatTextInputAttributes.customEmoji, value: ChatTextInputTextCustomEmojiAttribute(interactivelySelectedFromPackId: nil, fileId: fileId, file: nil, enableAnimation: enableAnimation), range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count))
|
||||
case .strikethrough:
|
||||
result.addAttribute(ChatTextInputAttributes.strikethrough, value: true as NSNumber, range: NSRange(location: attribute.range.lowerBound, length: attribute.range.count))
|
||||
case .underline:
|
||||
|
|
@ -1204,3 +1206,40 @@ public protocol ChatHistoryListNode: ListView {
|
|||
|
||||
var contentPositionChanged: (ListViewVisibleContentOffset) -> Void { get set }
|
||||
}
|
||||
|
||||
public extension ChatFolderTitle {
|
||||
init(attributedString: NSAttributedString, enableAnimations: Bool) {
|
||||
let inputStateText = ChatTextInputStateText(attributedText: attributedString)
|
||||
self.init(text: inputStateText.text, entities: inputStateText.attributes.compactMap { attribute -> MessageTextEntity? in
|
||||
if case let .customEmoji(_, fileId, _) = attribute.type {
|
||||
return MessageTextEntity(range: attribute.range, type: .CustomEmoji(stickerPack: nil, fileId: fileId))
|
||||
}
|
||||
return nil
|
||||
}, enableAnimations: enableAnimations)
|
||||
}
|
||||
|
||||
var rawAttributedString: NSAttributedString {
|
||||
let inputStateText = ChatTextInputStateText(text: self.text, attributes: self.entities.compactMap { entity -> ChatTextInputStateTextAttribute? in
|
||||
if case let .CustomEmoji(_, fileId) = entity.type {
|
||||
return ChatTextInputStateTextAttribute(type: .customEmoji(stickerPack: nil, fileId: fileId, enableAnimation: self.enableAnimations), range: entity.range)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return inputStateText.attributedText()
|
||||
}
|
||||
|
||||
func attributedString(attributes: [NSAttributedString.Key: Any]) -> NSAttributedString {
|
||||
let result = NSMutableAttributedString(attributedString: self.rawAttributedString)
|
||||
result.addAttributes(attributes, range: NSRange(location: 0, length: result.length))
|
||||
return result
|
||||
}
|
||||
|
||||
func attributedString(font: UIFont, textColor: UIColor) -> NSAttributedString {
|
||||
let result = NSMutableAttributedString(attributedString: self.rawAttributedString)
|
||||
result.addAttributes([
|
||||
.font: font,
|
||||
.foregroundColor: textColor
|
||||
], range: NSRange(location: 0, length: result.length))
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ public protocol MediaManager: AnyObject {
|
|||
var musicMediaPlayerState: Signal<(Account, SharedMediaPlayerItemPlaybackStateOrLoading, MediaManagerPlayerType)?, NoError> { get }
|
||||
var activeGlobalMediaPlayerAccountId: Signal<(AccountRecordId, Bool)?, NoError> { get }
|
||||
|
||||
func setPlaylist(_ playlist: (Account, SharedMediaPlaylist)?, type: MediaManagerPlayerType, control: SharedMediaPlayerControlAction)
|
||||
func setPlaylist(_ playlist: (AccountContext, SharedMediaPlaylist)?, type: MediaManagerPlayerType, control: SharedMediaPlayerControlAction)
|
||||
func playlistControl(_ control: SharedMediaPlayerControlAction, type: MediaManagerPlayerType?)
|
||||
|
||||
func filteredPlaylistState(accountId: AccountRecordId, playlistId: SharedMediaPlaylistId, itemId: SharedMediaPlaylistItemId, type: MediaManagerPlayerType) -> Signal<SharedMediaPlayerItemPlaybackState?, NoError>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ public protocol UniversalVideoContent {
|
|||
var dimensions: CGSize { get }
|
||||
var duration: Double { get }
|
||||
|
||||
func makeContentNode(accountId: AccountRecordId, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode
|
||||
func makeContentNode(context: AccountContext, postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode
|
||||
|
||||
func isEqual(to other: UniversalVideoContent) -> Bool
|
||||
}
|
||||
|
|
@ -95,7 +95,7 @@ public enum UniversalVideoNodeFetchControl {
|
|||
}
|
||||
|
||||
public final class UniversalVideoNode: ASDisplayNode {
|
||||
private let accountId: AccountRecordId
|
||||
private let context: AccountContext
|
||||
private let postbox: Postbox
|
||||
private let audioSession: ManagedAudioSession
|
||||
private let manager: UniversalVideoManager
|
||||
|
|
@ -146,12 +146,12 @@ public final class UniversalVideoNode: ASDisplayNode {
|
|||
if self.canAttachContent {
|
||||
assert(self.contentRequestIndex == nil)
|
||||
|
||||
let accountId = self.accountId
|
||||
let context = self.context
|
||||
let content = self.content
|
||||
let postbox = self.postbox
|
||||
let audioSession = self.audioSession
|
||||
self.contentRequestIndex = self.manager.attachUniversalVideoContent(content: self.content, priority: self.priority, create: {
|
||||
return content.makeContentNode(accountId: accountId, postbox: postbox, audioSession: audioSession)
|
||||
return content.makeContentNode(context: context, postbox: postbox, audioSession: audioSession)
|
||||
}, update: { [weak self] contentNodeAndFlags in
|
||||
if let strongSelf = self {
|
||||
strongSelf.updateContentNode(contentNodeAndFlags)
|
||||
|
|
@ -172,8 +172,8 @@ public final class UniversalVideoNode: ASDisplayNode {
|
|||
return self.contentNode != nil
|
||||
}
|
||||
|
||||
public init(accountId: AccountRecordId, postbox: Postbox, audioSession: ManagedAudioSession, manager: UniversalVideoManager, decoration: UniversalVideoDecoration, content: UniversalVideoContent, priority: UniversalVideoPriority, autoplay: Bool = false, snapshotContentWhenGone: Bool = false) {
|
||||
self.accountId = accountId
|
||||
public init(context: AccountContext, postbox: Postbox, audioSession: ManagedAudioSession, manager: UniversalVideoManager, decoration: UniversalVideoDecoration, content: UniversalVideoContent, priority: UniversalVideoPriority, autoplay: Bool = false, snapshotContentWhenGone: Bool = false) {
|
||||
self.context = context
|
||||
self.postbox = postbox
|
||||
self.audioSession = audioSession
|
||||
self.manager = manager
|
||||
|
|
|
|||
|
|
@ -234,7 +234,7 @@ public final class AvatarVideoNode: ASDisplayNode {
|
|||
if self.videoNode == nil {
|
||||
let context = self.context
|
||||
let mediaManager = context.sharedContext.mediaManager
|
||||
let videoNode = UniversalVideoNode(accountId: context.account.id, postbox: context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: VideoDecoration(), content: videoContent, priority: .embedded)
|
||||
let videoNode = UniversalVideoNode(context: context, postbox: context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: VideoDecoration(), content: videoContent, priority: .embedded)
|
||||
videoNode.clipsToBounds = true
|
||||
videoNode.isUserInteractionEnabled = false
|
||||
videoNode.isHidden = true
|
||||
|
|
|
|||
|
|
@ -1076,7 +1076,7 @@ final class BrowserInstantPageContent: UIView, BrowserContent, UIScrollViewDeleg
|
|||
}
|
||||
}
|
||||
}
|
||||
self.context.sharedContext.mediaManager.setPlaylist((self.context.account, InstantPageMediaPlaylist(webPage: webPage, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
|
||||
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(webPage: webPage, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -464,7 +464,7 @@ public final class ChatImportActivityScreen: ViewController {
|
|||
|
||||
let videoContent = NativeVideoContent(id: .message(1, EngineMedia.Id(namespace: 0, id: 1)), userLocation: .other, fileReference: .standalone(media: dummyFile), streamVideo: .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .black, storeAfterDownload: nil)
|
||||
|
||||
let videoNode = UniversalVideoNode(accountId: context.account.id, postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: decoration, content: videoContent, priority: .embedded)
|
||||
let videoNode = UniversalVideoNode(context: context, postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: decoration, content: videoContent, priority: .embedded)
|
||||
videoNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 2.0, height: 2.0))
|
||||
videoNode.alpha = 0.01
|
||||
self.videoNode = videoNode
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ swift_library(
|
|||
"//submodules/TelegramUI/Components/Settings/PeerNameColorItem",
|
||||
"//submodules/TelegramUI/Components/Settings/BirthdayPickerScreen",
|
||||
"//submodules/Components/MultilineTextComponent",
|
||||
"//submodules/Components/MultilineTextWithEntitiesComponent",
|
||||
"//submodules/TelegramUI/Components/Stories/StoryStealthModeSheetScreen",
|
||||
"//submodules/TelegramUI/Components/PeerManagement/OldChannelsController",
|
||||
"//submodules/TelegramUI/Components/TextFieldComponent",
|
||||
|
|
@ -110,6 +111,7 @@ swift_library(
|
|||
"//submodules/ComposePollUI",
|
||||
"//submodules/ChatPresentationInterfaceState",
|
||||
"//submodules/ShimmerEffect:ShimmerEffect",
|
||||
"//submodules/TelegramUI/Components/LottieComponent",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
|
|
|||
|
|
@ -223,8 +223,7 @@ func chatContextMenuItems(context: AccountContext, peerId: PeerId, promoInfo: Ch
|
|||
}
|
||||
|> deliverOnMainQueue).startStandalone(completed: {
|
||||
c?.dismiss(completion: {
|
||||
//TODO:release
|
||||
chatListController?.present(UndoOverlayController(presentationData: presentationData, content: .chatRemovedFromFolder(chatTitle: peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), folderTitle: title.text), elevatedLayout: false, animateInAsReplacement: true, action: { _ in
|
||||
chatListController?.present(UndoOverlayController(presentationData: presentationData, content: .chatRemovedFromFolder(context: context, chatTitle: peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), folderTitle: title.rawAttributedString), elevatedLayout: false, animateInAsReplacement: true, action: { _ in
|
||||
return false
|
||||
}), in: .current)
|
||||
})
|
||||
|
|
@ -274,8 +273,7 @@ func chatContextMenuItems(context: AccountContext, peerId: PeerId, promoInfo: Ch
|
|||
}
|
||||
|
||||
let filterType = chatListFilterType(data)
|
||||
//TODO:release
|
||||
updatedItems.append(.action(ContextMenuActionItem(text: title.text, icon: { theme in
|
||||
updatedItems.append(.action(ContextMenuActionItem(text: title.text, entities: title.entities, enableEntityAnimations: title.enableAnimations, icon: { theme in
|
||||
let imageName: String
|
||||
switch filterType {
|
||||
case .generic:
|
||||
|
|
@ -339,8 +337,7 @@ func chatContextMenuItems(context: AccountContext, peerId: PeerId, promoInfo: Ch
|
|||
}
|
||||
return filters
|
||||
}).startStandalone()
|
||||
//TODO:release
|
||||
chatListController?.present(UndoOverlayController(presentationData: presentationData, content: .chatAddedToFolder(chatTitle: peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), folderTitle: title.text), elevatedLayout: false, animateInAsReplacement: true, action: { _ in
|
||||
chatListController?.present(UndoOverlayController( presentationData: presentationData, content: .chatAddedToFolder(context: context, chatTitle: peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), folderTitle: title.rawAttributedString), elevatedLayout: false, animateInAsReplacement: true, action: { _ in
|
||||
return false
|
||||
}), in: .current)
|
||||
})
|
||||
|
|
@ -348,7 +345,7 @@ func chatContextMenuItems(context: AccountContext, peerId: PeerId, promoInfo: Ch
|
|||
}
|
||||
}
|
||||
|
||||
c?.setItems(.single(ContextController.Items(content: .list(updatedItems))), minHeight: nil, animated: true)
|
||||
c?.setItems(.single(ContextController.Items(content: .list(updatedItems), context: context)), minHeight: nil, animated: true)
|
||||
})))
|
||||
items.append(.separator)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import PeerInfoStoryGridScreen
|
|||
import ArchiveInfoScreen
|
||||
import BirthdayPickerScreen
|
||||
import OldChannelsController
|
||||
import TextFormat
|
||||
|
||||
private final class ContextControllerContentSourceImpl: ContextControllerContentSource {
|
||||
let controller: ViewController
|
||||
|
|
@ -1453,7 +1454,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController
|
|||
source = .controller(ContextControllerContentSourceImpl(controller: chatController, sourceNode: node, navigationController: strongSelf.navigationController as? NavigationController))
|
||||
}
|
||||
|
||||
let contextController = ContextController(presentationData: strongSelf.presentationData, source: source, items: chatContextMenuItems(context: strongSelf.context, peerId: peer.peerId, promoInfo: promoInfo, source: .chatList(filter: strongSelf.chatListDisplayNode.mainContainerNode.currentItemNode.chatListFilter), chatListController: strongSelf, joined: joined) |> map { ContextController.Items(content: .list($0)) }, gesture: gesture)
|
||||
let contextController = ContextController(context: strongSelf.context, presentationData: strongSelf.presentationData, source: source, items: chatContextMenuItems(context: strongSelf.context, peerId: peer.peerId, promoInfo: promoInfo, source: .chatList(filter: strongSelf.chatListDisplayNode.mainContainerNode.currentItemNode.chatListFilter), chatListController: strongSelf, joined: joined) |> map { ContextController.Items(content: .list($0)) }, gesture: gesture)
|
||||
strongSelf.presentInGlobalOverlay(contextController)
|
||||
|
||||
dismissPreviewingImpl = { [weak contextController] in
|
||||
|
|
@ -1526,7 +1527,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController
|
|||
contextContentSource = .controller(ContextControllerContentSourceImpl(controller: chatController, sourceNode: node, navigationController: strongSelf.navigationController as? NavigationController))
|
||||
}
|
||||
|
||||
let contextController = ContextController(presentationData: strongSelf.presentationData, source: contextContentSource, items: chatContextMenuItems(context: strongSelf.context, peerId: peer.id, promoInfo: nil, source: .search(source), chatListController: strongSelf, joined: false) |> map { ContextController.Items(content: .list($0)) }, gesture: gesture)
|
||||
let contextController = ContextController(context: strongSelf.context, presentationData: strongSelf.presentationData, source: contextContentSource, items: chatContextMenuItems(context: strongSelf.context, peerId: peer.id, promoInfo: nil, source: .search(source), chatListController: strongSelf, joined: false) |> map { ContextController.Items(content: .list($0)) }, gesture: gesture)
|
||||
strongSelf.presentInGlobalOverlay(contextController)
|
||||
}
|
||||
}
|
||||
|
|
@ -1811,28 +1812,42 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController
|
|||
return
|
||||
}
|
||||
|
||||
//TODO:release
|
||||
let iconColor: UIColor = .white
|
||||
let overlayController: UndoOverlayController
|
||||
if !filterPeersAreMuted.areMuted {
|
||||
let text = strongSelf.presentationData.strings.ChatList_ToastFolderMuted(title.text).string
|
||||
overlayController = UndoOverlayController(presentationData: strongSelf.presentationData, content: .universal(animation: "anim_profilemute", scale: 0.075, colors: [
|
||||
let text = NSMutableAttributedString(string: strongSelf.presentationData.strings.ChatList_ToastFolderMutedV2)
|
||||
let folderNameRange = (text.string as NSString).range(of: "{folder}")
|
||||
if folderNameRange.location != NSNotFound {
|
||||
text.replaceCharacters(in: folderNameRange, with: "")
|
||||
text.insert(title.attributedString(attributes: [
|
||||
ChatTextInputAttributes.bold: true
|
||||
]), at: folderNameRange.location)
|
||||
}
|
||||
|
||||
overlayController = UndoOverlayController(presentationData: strongSelf.presentationData, content: .universalWithEntities(context: strongSelf.context, animation: "anim_profilemute", scale: 0.075, colors: [
|
||||
"Middle.Group 1.Fill 1": iconColor,
|
||||
"Top.Group 1.Fill 1": iconColor,
|
||||
"Bottom.Group 1.Fill 1": iconColor,
|
||||
"EXAMPLE.Group 1.Fill 1": iconColor,
|
||||
"Line.Group 1.Stroke 1": iconColor
|
||||
], title: nil, text: text, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false })
|
||||
], title: nil, text: text, animateEntities: title.enableAnimations, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false })
|
||||
} else {
|
||||
//TODO:release
|
||||
let text = strongSelf.presentationData.strings.ChatList_ToastFolderUnmuted(title.text).string
|
||||
overlayController = UndoOverlayController(presentationData: strongSelf.presentationData, content: .universal(animation: "anim_profileunmute", scale: 0.075, colors: [
|
||||
let text = NSMutableAttributedString(string: strongSelf.presentationData.strings.ChatList_ToastFolderUnmutedV2)
|
||||
let folderNameRange = (text.string as NSString).range(of: "{folder}")
|
||||
if folderNameRange.location != NSNotFound {
|
||||
text.replaceCharacters(in: folderNameRange, with: "")
|
||||
text.insert(title.attributedString(attributes: [
|
||||
ChatTextInputAttributes.bold: true
|
||||
]), at: folderNameRange.location)
|
||||
}
|
||||
|
||||
overlayController = UndoOverlayController(presentationData: strongSelf.presentationData, content: .universalWithEntities(context: strongSelf.context, animation: "anim_profileunmute", scale: 0.075, colors: [
|
||||
"Middle.Group 1.Fill 1": iconColor,
|
||||
"Top.Group 1.Fill 1": iconColor,
|
||||
"Bottom.Group 1.Fill 1": iconColor,
|
||||
"EXAMPLE.Group 1.Fill 1": iconColor,
|
||||
"Line.Group 1.Stroke 1": iconColor
|
||||
], title: nil, text: text, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false })
|
||||
], title: nil, text: text, animateEntities: title.enableAnimations, customUndoText: nil, timeout: nil), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false })
|
||||
}
|
||||
strongSelf.present(overlayController, in: .current)
|
||||
})
|
||||
|
|
@ -4736,7 +4751,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController
|
|||
|
||||
let text = strongSelf.presentationData.strings.ChatList_DeletedThreads(Int32(threadIds.count))
|
||||
|
||||
strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(title: text, text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { value in
|
||||
strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: strongSelf.context, title: NSAttributedString(string: text), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { value in
|
||||
guard let strongSelf = self else {
|
||||
return false
|
||||
}
|
||||
|
|
@ -4842,7 +4857,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController
|
|||
|
||||
let text = strongSelf.presentationData.strings.ChatList_DeletedChats(Int32(peerIds.count))
|
||||
|
||||
strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(title: text, text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { value in
|
||||
strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: strongSelf.context, title: NSAttributedString(string: text), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { value in
|
||||
guard let strongSelf = self else {
|
||||
return false
|
||||
}
|
||||
|
|
@ -4914,7 +4929,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController
|
|||
|
||||
let text = strongSelf.presentationData.strings.ChatList_DeletedChats(Int32(peerIds.count))
|
||||
|
||||
strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(title: text, text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { value in
|
||||
strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: strongSelf.context, title: NSAttributedString(string: text), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { value in
|
||||
guard let strongSelf = self else {
|
||||
return false
|
||||
}
|
||||
|
|
@ -5274,7 +5289,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController
|
|||
return true
|
||||
})
|
||||
|
||||
strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(title: strongSelf.presentationData.strings.Undo_ChatCleared, text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { value in
|
||||
strongSelf.present(UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: strongSelf.context, title: NSAttributedString(string: strongSelf.presentationData.strings.Undo_ChatCleared), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { value in
|
||||
guard let strongSelf = self else {
|
||||
return false
|
||||
}
|
||||
|
|
@ -5496,7 +5511,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController
|
|||
|
||||
let statusText = self.presentationData.strings.Undo_DeletedTopic
|
||||
|
||||
self.present(UndoOverlayController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(title: statusText, text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in
|
||||
self.present(UndoOverlayController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: self.context, title: NSAttributedString(string: statusText), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in
|
||||
guard let self else {
|
||||
return false
|
||||
}
|
||||
|
|
@ -5793,7 +5808,7 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController
|
|||
return true
|
||||
})
|
||||
|
||||
self.present(UndoOverlayController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(title: statusText, text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in
|
||||
self.present(UndoOverlayController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: self.context, title: NSAttributedString(string: statusText), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in
|
||||
guard let strongSelf = self else {
|
||||
return false
|
||||
}
|
||||
|
|
@ -5933,7 +5948,6 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController
|
|||
badge = ContextMenuActionBadge(value: "\(item.1)", color: item.2 ? .accent : .inactive)
|
||||
}
|
||||
}
|
||||
//TODO:release
|
||||
items.append(.action(ContextMenuActionItem(text: title.text, entities: title.entities, enableEntityAnimations: title.enableAnimations, badge: badge, icon: { theme in
|
||||
let imageName: String
|
||||
if isDisabled {
|
||||
|
|
@ -6347,9 +6361,9 @@ private final class ChatListLocationContext {
|
|||
let peerView = Promise<PeerView>()
|
||||
peerView.set(context.account.viewTracker.peerView(peerId))
|
||||
|
||||
var onlineMemberCount: Signal<Int32?, NoError> = .single(nil)
|
||||
var onlineMemberCount: Signal<(total: Int32?, recent: Int32?), NoError> = .single((nil, nil))
|
||||
|
||||
let recentOnlineSignal: Signal<Int32?, NoError> = peerView.get()
|
||||
let recentOnlineSignal: Signal<(total: Int32?, recent: Int32?), NoError> = peerView.get()
|
||||
|> map { view -> Bool? in
|
||||
if let cachedData = view.cachedData as? CachedChannelData, let peer = peerViewMainPeer(view) as? TelegramChannel {
|
||||
if case .broadcast = peer.info {
|
||||
|
|
@ -6364,17 +6378,21 @@ private final class ChatListLocationContext {
|
|||
}
|
||||
}
|
||||
|> distinctUntilChanged
|
||||
|> mapToSignal { isLarge -> Signal<Int32?, NoError> in
|
||||
|> mapToSignal { isLarge -> Signal<(total: Int32?, recent: Int32?), NoError> in
|
||||
if let isLarge = isLarge {
|
||||
if isLarge {
|
||||
return context.peerChannelMemberCategoriesContextsManager.recentOnline(account: context.account, accountPeerId: context.account.peerId, peerId: peerId)
|
||||
|> map(Optional.init)
|
||||
|> map { value -> (total: Int32?, recent: Int32?) in
|
||||
return (nil, value)
|
||||
}
|
||||
} else {
|
||||
return context.peerChannelMemberCategoriesContextsManager.recentOnlineSmall(engine: context.engine, postbox: context.account.postbox, network: context.account.network, accountPeerId: context.account.peerId, peerId: peerId)
|
||||
|> map(Optional.init)
|
||||
|> map { value -> (total: Int32?, recent: Int32?) in
|
||||
return (value.total, value.recent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return .single(nil)
|
||||
return .single((nil, nil))
|
||||
}
|
||||
}
|
||||
onlineMemberCount = recentOnlineSignal
|
||||
|
|
@ -6783,7 +6801,7 @@ private final class ChatListLocationContext {
|
|||
private func updateForum(
|
||||
peerId: EnginePeer.Id,
|
||||
peerView: PeerView,
|
||||
onlineMemberCount: Int32?,
|
||||
onlineMemberCount: (total: Int32?, recent: Int32?),
|
||||
stateAndFilterId: (state: ChatListNodeState, filterId: Int32?),
|
||||
presentationData: PresentationData
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ private final class ChatListFilterPresetControllerArguments {
|
|||
let context: AccountContext
|
||||
let updateState: ((ChatListFilterPresetControllerState) -> ChatListFilterPresetControllerState) -> Void
|
||||
let updateName: (ChatFolderTitle) -> Void
|
||||
let toggleNameInputMode: () -> Void
|
||||
let toggleNameAnimations: () -> Void
|
||||
let openAddIncludePeer: () -> Void
|
||||
let openAddExcludePeer: () -> Void
|
||||
|
|
@ -58,6 +59,7 @@ private final class ChatListFilterPresetControllerArguments {
|
|||
context: AccountContext,
|
||||
updateState: @escaping ((ChatListFilterPresetControllerState) -> ChatListFilterPresetControllerState) -> Void,
|
||||
updateName: @escaping (ChatFolderTitle) -> Void,
|
||||
toggleNameInputMode: @escaping () -> Void,
|
||||
toggleNameAnimations: @escaping () -> Void,
|
||||
openAddIncludePeer: @escaping () -> Void,
|
||||
openAddExcludePeer: @escaping () -> Void,
|
||||
|
|
@ -80,6 +82,7 @@ private final class ChatListFilterPresetControllerArguments {
|
|||
self.context = context
|
||||
self.updateState = updateState
|
||||
self.updateName = updateName
|
||||
self.toggleNameInputMode = toggleNameInputMode
|
||||
self.toggleNameAnimations = toggleNameAnimations
|
||||
self.openAddIncludePeer = openAddIncludePeer
|
||||
self.openAddExcludePeer = openAddExcludePeer
|
||||
|
|
@ -228,7 +231,7 @@ private enum ChatListFilterRevealedItemId: Equatable {
|
|||
|
||||
private enum ChatListFilterPresetEntry: ItemListNodeEntry {
|
||||
case screenHeader
|
||||
case nameHeader(title: String, enableAnimations: Bool)
|
||||
case nameHeader(title: String, enableAnimations: Bool?)
|
||||
case name(placeholder: String, value: NSAttributedString, inputMode: ListComposePollOptionComponent.InputMode?, enableAnimations: Bool)
|
||||
case includePeersHeader(String)
|
||||
case addIncludePeer(title: String)
|
||||
|
|
@ -376,7 +379,11 @@ private enum ChatListFilterPresetEntry: ItemListNodeEntry {
|
|||
return ChatListFilterSettingsHeaderItem(context: arguments.context, theme: presentationData.theme, text: "", animation: .newFolder, sectionId: self.section)
|
||||
case let .nameHeader(title, enableAnimations):
|
||||
//TODO:localize
|
||||
return ItemListSectionHeaderItem(presentationData: presentationData, text: title, actionText: enableAnimations ? "Disable Animations" : "Enable Animations", action: {
|
||||
var actionText: String?
|
||||
if let enableAnimations {
|
||||
actionText = enableAnimations ? "Disable Animations" : "Enable Animations"
|
||||
}
|
||||
return ItemListSectionHeaderItem(presentationData: presentationData, text: title, actionText: actionText, action: {
|
||||
arguments.toggleNameAnimations()
|
||||
}, sectionId: self.section)
|
||||
case let .name(placeholder, value, inputMode, enableAnimations):
|
||||
|
|
@ -393,15 +400,7 @@ private enum ChatListFilterPresetEntry: ItemListNodeEntry {
|
|||
arguments.updateName(ChatFolderTitle(attributedString: value, enableAnimations: true))
|
||||
},
|
||||
toggleInputMode: {
|
||||
arguments.updateState { current in
|
||||
var state = current
|
||||
if state.nameInputMode == .emoji {
|
||||
state.nameInputMode = .keyboard
|
||||
} else {
|
||||
state.nameInputMode = .emoji
|
||||
}
|
||||
return state
|
||||
}
|
||||
arguments.toggleNameInputMode()
|
||||
}
|
||||
)
|
||||
case .includePeersHeader(let text), .excludePeersHeader(let text):
|
||||
|
|
@ -545,37 +544,6 @@ private enum ChatListFilterPresetEntry: ItemListNodeEntry {
|
|||
}
|
||||
}
|
||||
|
||||
extension ChatFolderTitle {
|
||||
init(attributedString: NSAttributedString, enableAnimations: Bool) {
|
||||
let inputStateText = ChatTextInputStateText(attributedText: attributedString)
|
||||
self.init(text: inputStateText.text, entities: inputStateText.attributes.compactMap { attribute -> MessageTextEntity? in
|
||||
if case let .customEmoji(_, fileId) = attribute.type {
|
||||
return MessageTextEntity(range: attribute.range, type: .CustomEmoji(stickerPack: nil, fileId: fileId))
|
||||
}
|
||||
return nil
|
||||
}, enableAnimations: enableAnimations)
|
||||
}
|
||||
|
||||
var rawAttributedString: NSAttributedString {
|
||||
let inputStateText = ChatTextInputStateText(text: self.text, attributes: self.entities.compactMap { entity -> ChatTextInputStateTextAttribute? in
|
||||
if case let .CustomEmoji(_, fileId) = entity.type {
|
||||
return ChatTextInputStateTextAttribute(type: .customEmoji(stickerPack: nil, fileId: fileId), range: entity.range)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return inputStateText.attributedText()
|
||||
}
|
||||
|
||||
func attributedString(font: UIFont, textColor: UIColor) -> NSAttributedString {
|
||||
let result = NSMutableAttributedString(attributedString: self.rawAttributedString)
|
||||
result.addAttributes([
|
||||
.font: font,
|
||||
.foregroundColor: textColor
|
||||
], range: NSRange(location: 0, length: result.length))
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
private struct ChatListFilterPresetControllerState: Equatable {
|
||||
var name: ChatFolderTitle
|
||||
var changedName: Bool
|
||||
|
|
@ -624,7 +592,7 @@ private func chatListFilterPresetControllerEntries(context: AccountContext, pres
|
|||
entries.append(.screenHeader)
|
||||
}
|
||||
|
||||
entries.append(.nameHeader(title: presentationData.strings.ChatListFolder_NameSectionHeader, enableAnimations: state.name.enableAnimations))
|
||||
entries.append(.nameHeader(title: presentationData.strings.ChatListFolder_NameSectionHeader, enableAnimations: state.name.entities.isEmpty ? nil : state.name.enableAnimations))
|
||||
entries.append(.name(placeholder: presentationData.strings.ChatListFolder_NamePlaceholder, value: state.name.rawAttributedString, inputMode: state.nameInputMode, enableAnimations: state.name.enableAnimations))
|
||||
|
||||
entries.append(.includePeersHeader(presentationData.strings.ChatListFolder_IncludedSectionHeader))
|
||||
|
|
@ -1584,6 +1552,18 @@ func chatListFilterPresetController(context: AccountContext, currentPreset initi
|
|||
}
|
||||
}
|
||||
},
|
||||
toggleNameInputMode: {
|
||||
updateState { current in
|
||||
var state = current
|
||||
if state.nameInputMode == .emoji {
|
||||
state.nameInputMode = .keyboard
|
||||
} else {
|
||||
state.nameInputMode = .emoji
|
||||
}
|
||||
return state
|
||||
}
|
||||
focusOnNameImpl?()
|
||||
},
|
||||
toggleNameAnimations: {
|
||||
updateState { current in
|
||||
var name = current.name
|
||||
|
|
@ -2145,7 +2125,7 @@ func chatListFilterPresetController(context: AccountContext, currentPreset initi
|
|||
return
|
||||
}
|
||||
controller.forEachItemNode { itemNode in
|
||||
if let itemNode = itemNode as? ItemListSingleLineInputItemNode {
|
||||
if let itemNode = itemNode as? ItemListFilterTitleInputItemNode {
|
||||
itemNode.focus()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import ChatFolderLinkPreviewScreen
|
|||
private final class ChatListFilterPresetListControllerArguments {
|
||||
let context: AccountContext
|
||||
|
||||
let addSuggestedPressed: (String, ChatListFilterData) -> Void
|
||||
let addSuggestedPressed: (ChatFolderTitle, ChatListFilterData) -> Void
|
||||
let openPreset: (ChatListFilter) -> Void
|
||||
let addNew: () -> Void
|
||||
let setItemWithRevealedOptions: (Int32?, Int32?) -> Void
|
||||
|
|
@ -24,7 +24,7 @@ private final class ChatListFilterPresetListControllerArguments {
|
|||
let updateDisplayTags: (Bool) -> Void
|
||||
let updateDisplayTagsLocked: () -> Void
|
||||
|
||||
init(context: AccountContext, addSuggestedPressed: @escaping (String, ChatListFilterData) -> Void, openPreset: @escaping (ChatListFilter) -> Void, addNew: @escaping () -> Void, setItemWithRevealedOptions: @escaping (Int32?, Int32?) -> Void, removePreset: @escaping (Int32) -> Void, updateDisplayTags: @escaping (Bool) -> Void, updateDisplayTagsLocked: @escaping () -> Void) {
|
||||
init(context: AccountContext, addSuggestedPressed: @escaping (ChatFolderTitle, ChatListFilterData) -> Void, openPreset: @escaping (ChatListFilter) -> Void, addNew: @escaping () -> Void, setItemWithRevealedOptions: @escaping (Int32?, Int32?) -> Void, removePreset: @escaping (Int32) -> Void, updateDisplayTags: @escaping (Bool) -> Void, updateDisplayTagsLocked: @escaping () -> Void) {
|
||||
self.context = context
|
||||
self.addSuggestedPressed = addSuggestedPressed
|
||||
self.openPreset = openPreset
|
||||
|
|
@ -92,10 +92,10 @@ private struct PresetIndex: Equatable {
|
|||
private enum ChatListFilterPresetListEntry: ItemListNodeEntry {
|
||||
case screenHeader(String)
|
||||
case suggestedListHeader(String)
|
||||
case suggestedPreset(index: PresetIndex, title: String, label: String, preset: ChatListFilterData)
|
||||
case suggestedPreset(index: PresetIndex, title: ChatFolderTitle, label: String, preset: ChatListFilterData)
|
||||
case suggestedAddCustom(String)
|
||||
case listHeader(String)
|
||||
case preset(index: PresetIndex, title: String, label: String, preset: ChatListFilter, canBeReordered: Bool, canBeDeleted: Bool, isEditing: Bool, isAllChats: Bool, isDisabled: Bool, displayTags: Bool)
|
||||
case preset(index: PresetIndex, title: ChatFolderTitle, label: String, preset: ChatListFilter, canBeReordered: Bool, canBeDeleted: Bool, isEditing: Bool, isAllChats: Bool, isDisabled: Bool, displayTags: Bool)
|
||||
case addItem(text: String, isEditing: Bool)
|
||||
case listFooter(String)
|
||||
case displayTags(Bool?)
|
||||
|
|
@ -176,7 +176,7 @@ private enum ChatListFilterPresetListEntry: ItemListNodeEntry {
|
|||
case let .suggestedListHeader(text):
|
||||
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, multiline: true, sectionId: self.section)
|
||||
case let .suggestedPreset(_, title, label, preset):
|
||||
return ChatListFilterPresetListSuggestedItem(presentationData: presentationData, title: title, label: label, sectionId: self.section, style: .blocks, installAction: {
|
||||
return ChatListFilterPresetListSuggestedItem(presentationData: presentationData, title: title.text, label: label, sectionId: self.section, style: .blocks, installAction: {
|
||||
arguments.addSuggestedPressed(title, preset)
|
||||
}, tag: nil)
|
||||
case let .suggestedAddCustom(text):
|
||||
|
|
@ -194,7 +194,7 @@ private enum ChatListFilterPresetListEntry: ItemListNodeEntry {
|
|||
}
|
||||
}
|
||||
|
||||
return ChatListFilterPresetListItem(presentationData: presentationData, preset: preset, title: title, label: label, tagColor: resolvedColor, editing: ChatListFilterPresetListItemEditing(editable: true, editing: isEditing, revealed: false), canBeReordered: canBeReordered, canBeDeleted: canBeDeleted, isAllChats: isAllChats, isDisabled: isDisabled, sectionId: self.section, action: {
|
||||
return ChatListFilterPresetListItem(context: arguments.context, presentationData: presentationData, preset: preset, title: title, label: label, tagColor: resolvedColor, editing: ChatListFilterPresetListItemEditing(editable: true, editing: isEditing, revealed: false), canBeReordered: canBeReordered, canBeDeleted: canBeDeleted, isAllChats: isAllChats, isDisabled: isDisabled, sectionId: self.section, action: {
|
||||
if isDisabled {
|
||||
arguments.addNew()
|
||||
} else {
|
||||
|
|
@ -285,12 +285,11 @@ private func chatListFilterPresetListControllerEntries(presentationData: Present
|
|||
var folderCount = 0
|
||||
for (filter, chatCount) in filtersWithAppliedOrder(filters: filters, order: updatedFilterOrder) {
|
||||
if case .allChats = filter {
|
||||
entries.append(.preset(index: PresetIndex(value: entries.count), title: "", label: "", preset: filter, canBeReordered: filters.count > 1, canBeDeleted: false, isEditing: state.isEditing, isAllChats: true, isDisabled: false, displayTags: effectiveDisplayTags == true))
|
||||
entries.append(.preset(index: PresetIndex(value: entries.count), title: ChatFolderTitle(text: "", entities: [], enableAnimations: true), label: "", preset: filter, canBeReordered: filters.count > 1, canBeDeleted: false, isEditing: state.isEditing, isAllChats: true, isDisabled: false, displayTags: effectiveDisplayTags == true))
|
||||
}
|
||||
if case let .filter(_, title, _, _) = filter {
|
||||
folderCount += 1
|
||||
//TODO:release
|
||||
entries.append(.preset(index: PresetIndex(value: entries.count), title: title.text, label: chatCount == 0 ? "" : "\(chatCount)", preset: filter, canBeReordered: filters.count > 1, canBeDeleted: true, isEditing: state.isEditing, isAllChats: false, isDisabled: !isPremium && folderCount > limits.maxFoldersCount, displayTags: effectiveDisplayTags == true))
|
||||
entries.append(.preset(index: PresetIndex(value: entries.count), title: title, label: chatCount == 0 ? "" : "\(chatCount)", preset: filter, canBeReordered: filters.count > 1, canBeDeleted: true, isEditing: state.isEditing, isAllChats: false, isDisabled: !isPremium && folderCount > limits.maxFoldersCount, displayTags: effectiveDisplayTags == true))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -300,8 +299,7 @@ private func chatListFilterPresetListControllerEntries(presentationData: Present
|
|||
if !filteredSuggestedFilters.isEmpty && actualFilters.count < limits.maxFoldersCount {
|
||||
entries.append(.suggestedListHeader(presentationData.strings.ChatListFolderSettings_RecommendedFoldersSection))
|
||||
for filter in filteredSuggestedFilters {
|
||||
//TODO:release
|
||||
entries.append(.suggestedPreset(index: PresetIndex(value: entries.count), title: filter.title.text, label: filter.description, preset: filter.data))
|
||||
entries.append(.suggestedPreset(index: PresetIndex(value: entries.count), title: filter.title, label: filter.description, preset: filter.data))
|
||||
}
|
||||
if filters.isEmpty {
|
||||
entries.append(.suggestedAddCustom(presentationData.strings.ChatListFolderSettings_RecommendedNewFolder))
|
||||
|
|
@ -389,8 +387,7 @@ public func chatListFilterPresetListController(context: AccountContext, mode: Ch
|
|||
let _ = (context.engine.peers.updateChatListFiltersInteractively { filters in
|
||||
var filters = filters
|
||||
let id = context.engine.peers.generateNewChatListFilterId(filters: filters)
|
||||
//TODO:release
|
||||
filters.append(.filter(id: id, title: ChatFolderTitle(text: title, entities: [], enableAnimations: true), emoticon: nil, data: data))
|
||||
filters.append(.filter(id: id, title: title, emoticon: nil, data: data))
|
||||
return filters
|
||||
}
|
||||
|> deliverOnMainQueue).start(next: { _ in
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import TelegramCore
|
|||
import TelegramPresentationData
|
||||
import ItemListUI
|
||||
import TelegramUIPreferences
|
||||
import AccountContext
|
||||
import TextNodeWithEntities
|
||||
|
||||
struct ChatListFilterPresetListItemEditing: Equatable {
|
||||
let editable: Bool
|
||||
|
|
@ -15,9 +17,10 @@ struct ChatListFilterPresetListItemEditing: Equatable {
|
|||
}
|
||||
|
||||
final class ChatListFilterPresetListItem: ListViewItem, ItemListItem {
|
||||
let context: AccountContext
|
||||
let presentationData: ItemListPresentationData
|
||||
let preset: ChatListFilter
|
||||
let title: String
|
||||
let title: ChatFolderTitle
|
||||
let label: String
|
||||
let tagColor: UIColor?
|
||||
let editing: ChatListFilterPresetListItemEditing
|
||||
|
|
@ -31,9 +34,10 @@ final class ChatListFilterPresetListItem: ListViewItem, ItemListItem {
|
|||
let remove: () -> Void
|
||||
|
||||
init(
|
||||
context: AccountContext,
|
||||
presentationData: ItemListPresentationData,
|
||||
preset: ChatListFilter,
|
||||
title: String,
|
||||
title: ChatFolderTitle,
|
||||
label: String,
|
||||
tagColor: UIColor?,
|
||||
editing: ChatListFilterPresetListItemEditing,
|
||||
|
|
@ -46,6 +50,7 @@ final class ChatListFilterPresetListItem: ListViewItem, ItemListItem {
|
|||
setItemWithRevealedOptions: @escaping (Int32?, Int32?) -> Void,
|
||||
remove: @escaping () -> Void
|
||||
) {
|
||||
self.context = context
|
||||
self.presentationData = presentationData
|
||||
self.preset = preset
|
||||
self.title = title
|
||||
|
|
@ -124,7 +129,7 @@ final class ChatListFilterPresetListItemNode: ItemListRevealOptionsItemNode {
|
|||
return self.containerNode
|
||||
}
|
||||
|
||||
private let titleNode: TextNode
|
||||
private let titleNode: TextNodeWithEntities
|
||||
private let labelNode: TextNode
|
||||
private let arrowNode: ASImageNode
|
||||
private let sharedIconNode: ASImageNode
|
||||
|
|
@ -145,6 +150,15 @@ final class ChatListFilterPresetListItemNode: ItemListRevealOptionsItemNode {
|
|||
return true
|
||||
}
|
||||
|
||||
override var visibility: ListViewItemNodeVisibility {
|
||||
didSet {
|
||||
if self.visibility != oldValue {
|
||||
let enableAnimations = self.item?.title.enableAnimations ?? true
|
||||
self.titleNode.visibilityRect = (self.visibility == ListViewItemNodeVisibility.none || !enableAnimations) ? CGRect.zero : CGRect.infinite
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
self.backgroundNode = ASDisplayNode()
|
||||
self.backgroundNode.isLayerBacked = true
|
||||
|
|
@ -160,10 +174,11 @@ final class ChatListFilterPresetListItemNode: ItemListRevealOptionsItemNode {
|
|||
self.maskNode = ASImageNode()
|
||||
self.maskNode.isUserInteractionEnabled = false
|
||||
|
||||
self.titleNode = TextNode()
|
||||
self.titleNode.isUserInteractionEnabled = false
|
||||
self.titleNode.contentMode = .left
|
||||
self.titleNode.contentsScale = UIScreen.main.scale
|
||||
self.titleNode = TextNodeWithEntities()
|
||||
self.titleNode.textNode.isUserInteractionEnabled = false
|
||||
self.titleNode.textNode.contentMode = .left
|
||||
self.titleNode.textNode.contentsScale = UIScreen.main.scale
|
||||
self.titleNode.resetEmojiToFirstFrameAutomatically = true
|
||||
|
||||
self.labelNode = TextNode()
|
||||
self.labelNode.isUserInteractionEnabled = false
|
||||
|
|
@ -186,7 +201,7 @@ final class ChatListFilterPresetListItemNode: ItemListRevealOptionsItemNode {
|
|||
super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false)
|
||||
|
||||
self.addSubnode(self.containerNode)
|
||||
self.containerNode.addSubnode(self.titleNode)
|
||||
self.containerNode.addSubnode(self.titleNode.textNode)
|
||||
self.containerNode.addSubnode(self.labelNode)
|
||||
self.containerNode.addSubnode(self.arrowNode)
|
||||
self.containerNode.addSubnode(self.sharedIconNode)
|
||||
|
|
@ -199,7 +214,7 @@ final class ChatListFilterPresetListItemNode: ItemListRevealOptionsItemNode {
|
|||
}
|
||||
|
||||
func asyncLayout() -> (_ item: ChatListFilterPresetListItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, (Bool) -> Void) {
|
||||
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
|
||||
let makeTitleLayout = TextNodeWithEntities.asyncLayout(self.titleNode)
|
||||
let makeLabelLayout = TextNode.asyncLayout(self.labelNode)
|
||||
let editableControlLayout = ItemListEditableControlNode.asyncLayout(self.editableControlNode)
|
||||
let reorderControlLayout = ItemListEditableReorderControlNode.asyncLayout(self.reorderControlNode)
|
||||
|
|
@ -230,7 +245,11 @@ final class ChatListFilterPresetListItemNode: ItemListRevealOptionsItemNode {
|
|||
}
|
||||
|
||||
let titleAttributedString = NSMutableAttributedString()
|
||||
titleAttributedString.append(NSAttributedString(string: item.isAllChats ? item.presentationData.strings.ChatList_FolderAllChats : item.title, font: titleFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor))
|
||||
if item.isAllChats {
|
||||
titleAttributedString.append(NSAttributedString(string: item.presentationData.strings.ChatList_FolderAllChats, font: titleFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor))
|
||||
} else {
|
||||
titleAttributedString.append(item.title.attributedString(font: titleFont, textColor: item.presentationData.theme.list.itemPrimaryTextColor))
|
||||
}
|
||||
|
||||
var editableControlSizeAndApply: (CGFloat, (CGFloat) -> ItemListEditableControlNode)?
|
||||
var reorderControlSizeAndApply: (CGFloat, (CGFloat, Bool, ContainedViewLayoutTransition) -> ItemListEditableReorderControlNode)?
|
||||
|
|
@ -337,9 +356,18 @@ final class ChatListFilterPresetListItemNode: ItemListRevealOptionsItemNode {
|
|||
}
|
||||
strongSelf.editableControlNode?.isHidden = !item.canBeDeleted
|
||||
|
||||
let _ = titleApply()
|
||||
let _ = titleApply(TextNodeWithEntities.Arguments(
|
||||
context: item.context,
|
||||
cache: item.context.animationCache,
|
||||
renderer: item.context.animationRenderer,
|
||||
placeholderColor: item.presentationData.theme.list.mediaPlaceholderColor,
|
||||
attemptSynchronous: true
|
||||
))
|
||||
let _ = labelApply()
|
||||
|
||||
let enableAnimations = item.title.enableAnimations
|
||||
strongSelf.titleNode.visibilityRect = (strongSelf.visibility == ListViewItemNodeVisibility.none || !enableAnimations) ? CGRect.zero : CGRect.infinite
|
||||
|
||||
if strongSelf.backgroundNode.supernode == nil {
|
||||
strongSelf.insertSubnode(strongSelf.backgroundNode, at: 0)
|
||||
}
|
||||
|
|
@ -385,7 +413,7 @@ final class ChatListFilterPresetListItemNode: ItemListRevealOptionsItemNode {
|
|||
transition.updateFrame(node: strongSelf.topStripeNode, frame: CGRect(origin: CGPoint(x: 0.0, y: -min(insets.top, separatorHeight)), size: CGSize(width: layoutSize.width, height: separatorHeight)))
|
||||
transition.updateFrame(node: strongSelf.bottomStripeNode, frame: CGRect(origin: CGPoint(x: bottomStripeInset, y: contentSize.height + bottomStripeOffset), size: CGSize(width: layoutSize.width - bottomStripeInset, height: separatorHeight)))
|
||||
|
||||
transition.updateFrame(node: strongSelf.titleNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: 11.0), size: titleLayout.size))
|
||||
transition.updateFrame(node: strongSelf.titleNode.textNode, frame: CGRect(origin: CGPoint(x: leftInset + revealOffset + editingOffset, y: 11.0), size: titleLayout.size))
|
||||
|
||||
let labelFrame = CGRect(origin: CGPoint(x: params.width - rightArrowInset - labelLayout.size.width + revealOffset, y: 11.0), size: labelLayout.size)
|
||||
strongSelf.labelNode.frame = labelFrame
|
||||
|
|
@ -542,7 +570,7 @@ final class ChatListFilterPresetListItemNode: ItemListRevealOptionsItemNode {
|
|||
editingOffset = 0.0
|
||||
}
|
||||
|
||||
transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: leftInset + offset + editingOffset, y: self.titleNode.frame.minY), size: self.titleNode.bounds.size))
|
||||
transition.updateFrame(node: self.titleNode.textNode, frame: CGRect(origin: CGPoint(x: leftInset + offset + editingOffset, y: self.titleNode.textNode.frame.minY), size: self.titleNode.textNode.bounds.size))
|
||||
|
||||
var labelFrame = self.labelNode.frame
|
||||
labelFrame.origin.x = params.width - rightArrowInset - labelFrame.width + revealOffset
|
||||
|
|
|
|||
|
|
@ -109,11 +109,13 @@ private final class ItemNode: ASDisplayNode {
|
|||
self.titleNode = ImmediateTextNodeWithEntities()
|
||||
self.titleNode.displaysAsynchronously = false
|
||||
self.titleNode.insets = UIEdgeInsets(top: titleInset, left: 0.0, bottom: titleInset, right: 0.0)
|
||||
self.titleNode.resetEmojiToFirstFrameAutomatically = true
|
||||
|
||||
self.titleActiveNode = ImmediateTextNodeWithEntities()
|
||||
self.titleActiveNode.displaysAsynchronously = false
|
||||
self.titleActiveNode.insets = UIEdgeInsets(top: titleInset, left: 0.0, bottom: titleInset, right: 0.0)
|
||||
self.titleActiveNode.alpha = 0.0
|
||||
self.titleActiveNode.resetEmojiToFirstFrameAutomatically = true
|
||||
|
||||
self.shortTitleContainer = ASDisplayNode()
|
||||
|
||||
|
|
@ -121,12 +123,14 @@ private final class ItemNode: ASDisplayNode {
|
|||
self.shortTitleNode.displaysAsynchronously = false
|
||||
self.shortTitleNode.alpha = 0.0
|
||||
self.shortTitleNode.insets = UIEdgeInsets(top: titleInset, left: 0.0, bottom: titleInset, right: 0.0)
|
||||
self.shortTitleNode.resetEmojiToFirstFrameAutomatically = true
|
||||
|
||||
self.shortTitleActiveNode = ImmediateTextNodeWithEntities()
|
||||
self.shortTitleActiveNode.displaysAsynchronously = false
|
||||
self.shortTitleActiveNode.alpha = 0.0
|
||||
self.shortTitleActiveNode.insets = UIEdgeInsets(top: titleInset, left: 0.0, bottom: titleInset, right: 0.0)
|
||||
self.shortTitleActiveNode.alpha = 0.0
|
||||
self.shortTitleActiveNode.resetEmojiToFirstFrameAutomatically = true
|
||||
|
||||
self.badgeContainerNode = ASDisplayNode()
|
||||
|
||||
|
|
|
|||
|
|
@ -239,7 +239,8 @@ public class ChatListFilterTagSectionHeaderItemNode: ListViewItemNode {
|
|||
cache: item.context.animationCache,
|
||||
renderer: item.context.animationRenderer,
|
||||
placeholderColor: item.presentationData.theme.list.mediaPlaceholderColor,
|
||||
attemptSynchronous: true
|
||||
attemptSynchronous: true,
|
||||
emojiOffset: CGPoint(x: 0.0, y: -1.0)
|
||||
))
|
||||
let badgeSideInset: CGFloat = 4.0
|
||||
let badgeBackgroundSize: CGSize
|
||||
|
|
@ -248,7 +249,7 @@ public class ChatListFilterTagSectionHeaderItemNode: ListViewItemNode {
|
|||
} else {
|
||||
badgeBackgroundSize = CGSize(width: badgeSideInset * 2.0 + badgeLayoutAndApply.0.size.width, height: badgeLayoutAndApply.0.size.height + 3.0)
|
||||
}
|
||||
let badgeBackgroundFrame = CGRect(origin: CGPoint(x: strongSelf.titleNode.frame.maxX + badgeSpacing, y: strongSelf.titleNode.frame.minY - UIScreenPixel + floorToScreenPixels((strongSelf.titleNode.bounds.height - badgeBackgroundSize.height) * 0.5)), size: badgeBackgroundSize)
|
||||
let badgeBackgroundFrame = CGRect(origin: CGPoint(x: strongSelf.titleNode.frame.maxX + badgeSpacing, y: strongSelf.titleNode.frame.minY + floorToScreenPixels((strongSelf.titleNode.bounds.height - badgeBackgroundSize.height) * 0.5)), size: badgeBackgroundSize)
|
||||
|
||||
let badgeBackgroundLayer: SimpleLayer
|
||||
if let current = strongSelf.badgeBackgroundLayer {
|
||||
|
|
@ -262,6 +263,7 @@ public class ChatListFilterTagSectionHeaderItemNode: ListViewItemNode {
|
|||
if strongSelf.badgeTextNode !== badgeTextNode {
|
||||
strongSelf.badgeTextNode?.textNode.removeFromSupernode()
|
||||
strongSelf.badgeTextNode = badgeTextNode
|
||||
badgeTextNode.resetEmojiToFirstFrameAutomatically = true
|
||||
strongSelf.addSubnode(badgeTextNode.textNode)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -197,12 +197,12 @@ private enum ChatListRecentEntry: Comparable, Identifiable {
|
|||
} else if case let .user(user) = primaryPeer {
|
||||
let servicePeer = isServicePeer(primaryPeer._asPeer())
|
||||
if user.flags.contains(.isSupport) && !servicePeer {
|
||||
status = .custom(string: strings.Bot_GenericSupportStatus, multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: strings.Bot_GenericSupportStatus), multiline: false, isActive: false, icon: nil)
|
||||
} else if let _ = user.botInfo {
|
||||
if let subscriberCount = user.subscriberCount {
|
||||
status = .custom(string: strings.Conversation_StatusBotSubscribers(subscriberCount), multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: strings.Conversation_StatusBotSubscribers(subscriberCount)), multiline: false, isActive: false, icon: nil)
|
||||
} else {
|
||||
status = .custom(string: strings.Bot_GenericBotStatus, multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: strings.Bot_GenericBotStatus), multiline: false, isActive: false, icon: nil)
|
||||
}
|
||||
} else if user.id != context.account.peerId && !servicePeer {
|
||||
let presence = peer.presence ?? TelegramUserPresence(status: .none, lastActivity: 0)
|
||||
|
|
@ -211,19 +211,19 @@ private enum ChatListRecentEntry: Comparable, Identifiable {
|
|||
status = .none
|
||||
}
|
||||
} else if case let .legacyGroup(group) = primaryPeer {
|
||||
status = .custom(string: strings.GroupInfo_ParticipantCount(Int32(group.participantCount)), multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: strings.GroupInfo_ParticipantCount(Int32(group.participantCount))), multiline: false, isActive: false, icon: nil)
|
||||
} else if case let .channel(channel) = primaryPeer {
|
||||
if case .group = channel.info {
|
||||
if let count = peer.subpeerSummary?.count, count > 0 {
|
||||
status = .custom(string: strings.GroupInfo_ParticipantCount(Int32(count)), multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: strings.GroupInfo_ParticipantCount(Int32(count))), multiline: false, isActive: false, icon: nil)
|
||||
} else {
|
||||
status = .custom(string: strings.Group_Status, multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: strings.Group_Status), multiline: false, isActive: false, icon: nil)
|
||||
}
|
||||
} else {
|
||||
if let count = peer.subpeerSummary?.count, count > 0 {
|
||||
status = .custom(string: strings.Conversation_StatusSubscribers(Int32(count)), multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: strings.Conversation_StatusSubscribers(Int32(count))), multiline: false, isActive: false, icon: nil)
|
||||
} else {
|
||||
status = .custom(string: strings.Channel_Status, multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: strings.Channel_Status), multiline: false, isActive: false, icon: nil)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -380,6 +380,7 @@ public enum ChatListSearchEntryStableId: Hashable {
|
|||
case globalPeerId(EnginePeer.Id)
|
||||
case messageId(EngineMessage.Id, ChatListSearchEntry.MessageSection)
|
||||
case messagePlaceholder(Int32)
|
||||
case emptyMessagesFooter
|
||||
case addContact
|
||||
}
|
||||
|
||||
|
|
@ -442,6 +443,7 @@ public enum ChatListSearchEntry: Comparable, Identifiable {
|
|||
case globalPeer(FoundPeer, (Int32, Bool)?, Int, PresentationTheme, PresentationStrings, PresentationPersonNameOrder, PresentationPersonNameOrder, ChatListSearchSectionExpandType, PeerStoryStats?, Bool, String?)
|
||||
case message(EngineMessage, EngineRenderedPeer, EnginePeerReadCounters?, EngineMessageHistoryThread.Info?, ChatListPresentationData, Int32, Bool?, Bool, MessageOrderingKey, (id: String, size: Int64, isFirstInList: Bool)?, MessageSection, Bool, PeerStoryStats?, Bool, TelegramSearchPeersScope)
|
||||
case messagePlaceholder(Int32, ChatListPresentationData, TelegramSearchPeersScope)
|
||||
case emptyMessagesFooter(ChatListPresentationData, TelegramSearchPeersScope, String?)
|
||||
case addContact(String, PresentationTheme, PresentationStrings)
|
||||
|
||||
public var stableId: ChatListSearchEntryStableId {
|
||||
|
|
@ -458,6 +460,8 @@ public enum ChatListSearchEntry: Comparable, Identifiable {
|
|||
return .messageId(message.id, section)
|
||||
case let .messagePlaceholder(index, _, _):
|
||||
return .messagePlaceholder(index)
|
||||
case .emptyMessagesFooter:
|
||||
return .emptyMessagesFooter
|
||||
case .addContact:
|
||||
return .addContact
|
||||
}
|
||||
|
|
@ -561,6 +565,21 @@ public enum ChatListSearchEntry: Comparable, Identifiable {
|
|||
} else {
|
||||
return false
|
||||
}
|
||||
case let .emptyMessagesFooter(lhsPresentationData, lhsSearchScope, lhsQuery):
|
||||
if case let .emptyMessagesFooter(rhsPresentationData, rhsSearchScope, rhsQuery) = rhs {
|
||||
if lhsPresentationData !== rhsPresentationData {
|
||||
return false
|
||||
}
|
||||
if lhsSearchScope != rhsSearchScope {
|
||||
return false
|
||||
}
|
||||
if lhsQuery != rhsQuery {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
case let .addContact(lhsPhoneNumber, lhsTheme, lhsStrings):
|
||||
if case let .addContact(rhsPhoneNumber, rhsTheme, rhsStrings) = rhs {
|
||||
if lhsPhoneNumber != rhsPhoneNumber {
|
||||
|
|
@ -601,7 +620,7 @@ public enum ChatListSearchEntry: Comparable, Identifiable {
|
|||
return false
|
||||
case let .localPeer(_, _, _, rhsIndex, _, _, _, _, _, _, _):
|
||||
return lhsIndex <= rhsIndex
|
||||
case .globalPeer, .message, .messagePlaceholder, .addContact:
|
||||
case .globalPeer, .message, .messagePlaceholder, .emptyMessagesFooter, .addContact:
|
||||
return true
|
||||
}
|
||||
case let .globalPeer(_, _, lhsIndex, _, _, _, _, _, _, _, _):
|
||||
|
|
@ -610,7 +629,7 @@ public enum ChatListSearchEntry: Comparable, Identifiable {
|
|||
return false
|
||||
case let .globalPeer(_, _, rhsIndex, _, _, _, _, _, _, _, _):
|
||||
return lhsIndex <= rhsIndex
|
||||
case .message, .messagePlaceholder, .addContact:
|
||||
case .message, .messagePlaceholder, .emptyMessagesFooter, .addContact:
|
||||
return true
|
||||
}
|
||||
case let .message(_, _, _, _, _, _, _, _, lhsKey, _, _, _, _, _, _):
|
||||
|
|
@ -618,6 +637,8 @@ public enum ChatListSearchEntry: Comparable, Identifiable {
|
|||
return lhsKey < rhsKey
|
||||
} else if case .messagePlaceholder = rhs {
|
||||
return true
|
||||
} else if case .emptyMessagesFooter = rhs {
|
||||
return true
|
||||
} else if case .addContact = rhs {
|
||||
return true
|
||||
} else {
|
||||
|
|
@ -626,11 +647,19 @@ public enum ChatListSearchEntry: Comparable, Identifiable {
|
|||
case let .messagePlaceholder(lhsIndex, _, _):
|
||||
if case let .messagePlaceholder(rhsIndex, _, _) = rhs {
|
||||
return lhsIndex < rhsIndex
|
||||
} else if case .emptyMessagesFooter = rhs {
|
||||
return true
|
||||
} else if case .addContact = rhs {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
case .emptyMessagesFooter:
|
||||
if case .addContact = rhs {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
case .addContact:
|
||||
return false
|
||||
}
|
||||
|
|
@ -658,7 +687,8 @@ public enum ChatListSearchEntry: Comparable, Identifiable {
|
|||
toggleAllPaused: @escaping () -> Void,
|
||||
openStories: @escaping (EnginePeer.Id, AvatarNode) -> Void,
|
||||
openPublicPosts: @escaping () -> Void,
|
||||
openMessagesFilter: @escaping (ASDisplayNode) -> Void
|
||||
openMessagesFilter: @escaping (ASDisplayNode) -> Void,
|
||||
switchMessagesFilter: @escaping (TelegramSearchPeersScope) -> Void
|
||||
) -> ListViewItem {
|
||||
switch self {
|
||||
case let .topic(peer, threadInfo, _, theme, strings, expandType):
|
||||
|
|
@ -870,9 +900,9 @@ public enum ChatListSearchEntry: Comparable, Identifiable {
|
|||
var status: ContactsPeerItemStatus = .none
|
||||
if case let .user(user) = primaryPeer, let _ = user.botInfo, !primaryPeer.id.isVerificationCodes {
|
||||
if let subscriberCount = user.subscriberCount {
|
||||
status = .custom(string: presentationData.strings.Conversation_StatusBotSubscribers(subscriberCount), multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: presentationData.strings.Conversation_StatusBotSubscribers(subscriberCount)), multiline: false, isActive: false, icon: nil)
|
||||
} else {
|
||||
status = .custom(string: presentationData.strings.Bot_GenericBotStatus, multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: presentationData.strings.Bot_GenericBotStatus), multiline: false, isActive: false, icon: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1128,6 +1158,33 @@ public enum ChatListSearchEntry: Comparable, Identifiable {
|
|||
openMessagesFilter(sourceNode)
|
||||
})
|
||||
return ChatListItem(presentationData: presentationData, context: context, chatListLocation: location, filterData: nil, index: EngineChatList.Item.Index.chatList(ChatListIndex(pinningIndex: nil, messageIndex: MessageIndex(id: MessageId(peerId: PeerId(0), namespace: Namespaces.Message.Cloud, id: 0), timestamp: 0))), content: .loading, editing: false, hasActiveRevealControls: false, selected: false, header: header, enableContextActions: false, hiddenOffset: false, interaction: interaction)
|
||||
case let .emptyMessagesFooter(presentationData, searchScope, searchQuery):
|
||||
var actionTitle: String?
|
||||
let filterTitle: String
|
||||
switch searchScope {
|
||||
case .everywhere:
|
||||
filterTitle = presentationData.strings.ChatList_Search_Messages_AllChats
|
||||
case .channels:
|
||||
filterTitle = presentationData.strings.ChatList_Search_Messages_Channels
|
||||
case .groups:
|
||||
filterTitle = presentationData.strings.ChatList_Search_Messages_GroupChats
|
||||
case .privateChats:
|
||||
filterTitle = presentationData.strings.ChatList_Search_Messages_PrivateChats
|
||||
}
|
||||
actionTitle = "\(filterTitle) <"
|
||||
|
||||
let header = ChatListSearchItemHeader(type: .messages(location: nil), theme: presentationData.theme, strings: presentationData.strings, actionTitle: actionTitle, action: { sourceNode in
|
||||
openMessagesFilter(sourceNode)
|
||||
})
|
||||
return ChatListSearchEmptyFooterItem(
|
||||
theme: presentationData.theme,
|
||||
strings: presentationData.strings,
|
||||
header: header,
|
||||
searchQuery: searchQuery,
|
||||
searchAllMessages: searchScope == .everywhere ? nil : {
|
||||
switchMessagesFilter(.everywhere)
|
||||
}
|
||||
)
|
||||
case let .addContact(phoneNumber, theme, strings):
|
||||
return ContactsAddItem(context: context, theme: theme, strings: strings, phoneNumber: phoneNumber, header: ChatListSearchItemHeader(type: .phoneNumber, theme: theme, strings: strings, actionTitle: nil, action: nil), action: {
|
||||
interaction.addContact(phoneNumber)
|
||||
|
|
@ -1201,12 +1258,42 @@ private func chatListSearchContainerPreparedRecentTransition(
|
|||
return ChatListSearchContainerRecentTransition(deletions: deletions, insertions: insertions, updates: updates, isEmpty: isEmpty)
|
||||
}
|
||||
|
||||
public func chatListSearchContainerPreparedTransition(from fromEntries: [ChatListSearchEntry], to toEntries: [ChatListSearchEntry], displayingResults: Bool, isEmpty: Bool, isLoading: Bool, animated: Bool, context: AccountContext, presentationData: PresentationData, enableHeaders: Bool, filter: ChatListNodePeersFilter, requestPeerType: [ReplyMarkupButtonRequestPeerType]?, location: ChatListControllerLocation, key: ChatListSearchPaneKey, tagMask: EngineMessage.Tags?, interaction: ChatListNodeInteraction, listInteraction: ListMessageItemInteraction, peerContextAction: ((EnginePeer, ChatListSearchContextActionSource, ASDisplayNode, ContextGesture?, CGPoint?) -> Void)?, toggleExpandLocalResults: @escaping () -> Void, toggleExpandGlobalResults: @escaping () -> Void, searchPeer: @escaping (EnginePeer) -> Void, searchQuery: String?, searchOptions: ChatListSearchOptions?, messageContextAction: ((EngineMessage, ASDisplayNode?, CGRect?, UIGestureRecognizer?, ChatListSearchPaneKey, (id: String, size: Int64, isFirstInList: Bool)?) -> Void)?, openClearRecentlyDownloaded: @escaping () -> Void, toggleAllPaused: @escaping () -> Void, openStories: @escaping (EnginePeer.Id, AvatarNode) -> Void, openPublicPosts: @escaping () -> Void, openMessagesFilter: @escaping (ASDisplayNode) -> Void) -> ChatListSearchContainerTransition {
|
||||
public func chatListSearchContainerPreparedTransition(
|
||||
from fromEntries: [ChatListSearchEntry],
|
||||
to toEntries: [ChatListSearchEntry],
|
||||
displayingResults: Bool,
|
||||
isEmpty: Bool,
|
||||
isLoading: Bool,
|
||||
animated: Bool,
|
||||
context: AccountContext,
|
||||
presentationData: PresentationData,
|
||||
enableHeaders: Bool,
|
||||
filter: ChatListNodePeersFilter,
|
||||
requestPeerType: [ReplyMarkupButtonRequestPeerType]?,
|
||||
location: ChatListControllerLocation,
|
||||
key: ChatListSearchPaneKey,
|
||||
tagMask: EngineMessage.Tags?,
|
||||
interaction: ChatListNodeInteraction,
|
||||
listInteraction: ListMessageItemInteraction,
|
||||
peerContextAction: ((EnginePeer, ChatListSearchContextActionSource, ASDisplayNode, ContextGesture?, CGPoint?) -> Void)?,
|
||||
toggleExpandLocalResults: @escaping () -> Void,
|
||||
toggleExpandGlobalResults: @escaping () -> Void,
|
||||
searchPeer: @escaping (EnginePeer) -> Void,
|
||||
searchQuery: String?,
|
||||
searchOptions: ChatListSearchOptions?,
|
||||
messageContextAction: ((EngineMessage, ASDisplayNode?, CGRect?, UIGestureRecognizer?, ChatListSearchPaneKey, (id: String, size: Int64, isFirstInList: Bool)?) -> Void)?,
|
||||
openClearRecentlyDownloaded: @escaping () -> Void,
|
||||
toggleAllPaused: @escaping () -> Void,
|
||||
openStories: @escaping (EnginePeer.Id, AvatarNode) -> Void,
|
||||
openPublicPosts: @escaping () -> Void,
|
||||
openMessagesFilter: @escaping (ASDisplayNode) -> Void,
|
||||
switchMessagesFilter: @escaping (TelegramSearchPeersScope) -> Void
|
||||
) -> ChatListSearchContainerTransition {
|
||||
let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries)
|
||||
|
||||
let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) }
|
||||
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enableHeaders: enableHeaders, filter: filter, requestPeerType: requestPeerType, location: location, key: key, tagMask: tagMask, interaction: interaction, listInteraction: listInteraction, peerContextAction: peerContextAction, toggleExpandLocalResults: toggleExpandLocalResults, toggleExpandGlobalResults: toggleExpandGlobalResults, searchPeer: searchPeer, searchQuery: searchQuery, searchOptions: searchOptions, messageContextAction: messageContextAction, openClearRecentlyDownloaded: openClearRecentlyDownloaded, toggleAllPaused: toggleAllPaused, openStories: openStories, openPublicPosts: openPublicPosts, openMessagesFilter: openMessagesFilter), directionHint: nil) }
|
||||
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enableHeaders: enableHeaders, filter: filter, requestPeerType: requestPeerType, location: location, key: key, tagMask: tagMask, interaction: interaction, listInteraction: listInteraction, peerContextAction: peerContextAction, toggleExpandLocalResults: toggleExpandLocalResults, toggleExpandGlobalResults: toggleExpandGlobalResults, searchPeer: searchPeer, searchQuery: searchQuery, searchOptions: searchOptions, messageContextAction: messageContextAction, openClearRecentlyDownloaded: openClearRecentlyDownloaded, toggleAllPaused: toggleAllPaused, openStories: openStories, openPublicPosts: openPublicPosts, openMessagesFilter: openMessagesFilter), directionHint: nil) }
|
||||
let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enableHeaders: enableHeaders, filter: filter, requestPeerType: requestPeerType, location: location, key: key, tagMask: tagMask, interaction: interaction, listInteraction: listInteraction, peerContextAction: peerContextAction, toggleExpandLocalResults: toggleExpandLocalResults, toggleExpandGlobalResults: toggleExpandGlobalResults, searchPeer: searchPeer, searchQuery: searchQuery, searchOptions: searchOptions, messageContextAction: messageContextAction, openClearRecentlyDownloaded: openClearRecentlyDownloaded, toggleAllPaused: toggleAllPaused, openStories: openStories, openPublicPosts: openPublicPosts, openMessagesFilter: openMessagesFilter, switchMessagesFilter: switchMessagesFilter), directionHint: nil) }
|
||||
let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, enableHeaders: enableHeaders, filter: filter, requestPeerType: requestPeerType, location: location, key: key, tagMask: tagMask, interaction: interaction, listInteraction: listInteraction, peerContextAction: peerContextAction, toggleExpandLocalResults: toggleExpandLocalResults, toggleExpandGlobalResults: toggleExpandGlobalResults, searchPeer: searchPeer, searchQuery: searchQuery, searchOptions: searchOptions, messageContextAction: messageContextAction, openClearRecentlyDownloaded: openClearRecentlyDownloaded, toggleAllPaused: toggleAllPaused, openStories: openStories, openPublicPosts: openPublicPosts, openMessagesFilter: openMessagesFilter, switchMessagesFilter: switchMessagesFilter), directionHint: nil) }
|
||||
|
||||
return ChatListSearchContainerTransition(deletions: deletions, insertions: insertions, updates: updates, displayingResults: displayingResults, isEmpty: isEmpty, isLoading: isLoading, query: searchQuery, animated: animated)
|
||||
}
|
||||
|
|
@ -2912,6 +2999,7 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode {
|
|||
index += 1
|
||||
}
|
||||
} else {
|
||||
var hasAnyMessages = false
|
||||
for foundRemoteMessageSet in foundRemoteMessages.0 {
|
||||
for message in foundRemoteMessageSet.messages {
|
||||
if existingMessageIds.contains(message.id) {
|
||||
|
|
@ -2936,10 +3024,22 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode {
|
|||
}
|
||||
|
||||
//TODO:requiresPremiumForMessaging
|
||||
hasAnyMessages = true
|
||||
entries.append(.message(message, peer, foundRemoteMessageSet.readCounters[message.id.peerId], foundRemoteMessageSet.threadsData[message.id]?.info, presentationData, foundRemoteMessageSet.totalCount, selectionState?.contains(message.id), headerId == firstHeaderId, .index(message.index), nil, .generic, false, nil, false, searchScope))
|
||||
index += 1
|
||||
}
|
||||
}
|
||||
if !hasAnyMessages {
|
||||
switch searchScope {
|
||||
case .everywhere:
|
||||
break
|
||||
default:
|
||||
if let data = context.currentAppConfiguration.with({ $0 }).data, data["ios_killswitch_empty_search_footer"] != nil {
|
||||
} else {
|
||||
entries.append(.emptyMessagesFooter(presentationData, searchScope, query))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3444,6 +3544,8 @@ final class ChatListSearchListPaneNode: ASDisplayNode, ChatListSearchPaneNode {
|
|||
strongSelf.interaction.switchToFilter(.publicPosts)
|
||||
}, openMessagesFilter: { sourceNode in
|
||||
strongSelf.openMessagesFilter(sourceNode: sourceNode)
|
||||
}, switchMessagesFilter: { filter in
|
||||
strongSelf.searchScopePromise.set(.everywhere)
|
||||
})
|
||||
strongSelf.currentEntries = newEntries
|
||||
if strongSelf.key == .downloads {
|
||||
|
|
|
|||
|
|
@ -238,6 +238,7 @@ public class ItemListFilterTitleInputItemNode: ListViewItemNode, UITextFieldDele
|
|||
backspaceKeyAction: nil,
|
||||
selection: nil,
|
||||
inputMode: item.inputMode,
|
||||
alwaysDisplayInputModeSelector: true,
|
||||
toggleInputMode: { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
|
|
@ -268,6 +269,9 @@ public class ItemListFilterTitleInputItemNode: ListViewItemNode, UITextFieldDele
|
|||
}
|
||||
|
||||
public func focus() {
|
||||
if let textFieldView = self.textField.view as? ListComposePollOptionComponent.View {
|
||||
textFieldView.activateInput()
|
||||
}
|
||||
}
|
||||
|
||||
public func selectAll() {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import AsyncDisplayKit
|
|||
import Display
|
||||
import SwiftSignalKit
|
||||
import TelegramPresentationData
|
||||
import ComponentFlow
|
||||
import LottieComponent
|
||||
|
||||
class ChatListHoleItem: ListViewItem {
|
||||
let theme: PresentationTheme
|
||||
|
|
@ -78,3 +80,252 @@ class ChatListHoleItemNode: ListViewItemNode {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ChatListSearchEmptyFooterItem: ListViewItem {
|
||||
let theme: PresentationTheme
|
||||
let strings: PresentationStrings
|
||||
let searchQuery: String?
|
||||
let searchAllMessages: (() -> Void)?
|
||||
|
||||
let header: ListViewItemHeader?
|
||||
let selectable: Bool = false
|
||||
|
||||
init(theme: PresentationTheme, strings: PresentationStrings, header: ListViewItemHeader?, searchQuery: String?, searchAllMessages: (() -> Void)?) {
|
||||
self.theme = theme
|
||||
self.strings = strings
|
||||
self.header = header
|
||||
self.searchQuery = searchQuery
|
||||
self.searchAllMessages = searchAllMessages
|
||||
}
|
||||
|
||||
func nodeConfiguredForParams(async: @escaping (@escaping () -> Void) -> Void, params: ListViewItemLayoutParams, synchronousLoads: Bool, previousItem: ListViewItem?, nextItem: ListViewItem?, completion: @escaping (ListViewItemNode, @escaping () -> (Signal<Void, NoError>?, (ListViewItemApply) -> Void)) -> Void) {
|
||||
async {
|
||||
let node = ChatListSearchEmptyFooterItemNode()
|
||||
let (layout, apply) = node.asyncLayout()(self, params)
|
||||
|
||||
node.contentSize = layout.contentSize
|
||||
node.insets = layout.insets
|
||||
|
||||
Queue.mainQueue().async {
|
||||
completion(node, {
|
||||
return (nil, { _ in apply() })
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func updateNode(async: @escaping (@escaping () -> Void) -> Void, node: @escaping () -> ListViewItemNode, params: ListViewItemLayoutParams, previousItem: ListViewItem?, nextItem: ListViewItem?, animation: ListViewItemUpdateAnimation, completion: @escaping (ListViewItemNodeLayout, @escaping (ListViewItemApply) -> Void) -> Void) {
|
||||
Queue.mainQueue().async {
|
||||
assert(node() is ChatListSearchEmptyFooterItemNode)
|
||||
if let nodeValue = node() as? ChatListSearchEmptyFooterItemNode {
|
||||
|
||||
let layout = nodeValue.asyncLayout()
|
||||
async {
|
||||
let (nodeLayout, apply) = layout(self, params)
|
||||
Queue.mainQueue().async {
|
||||
completion(nodeLayout, { _ in
|
||||
apply()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ChatListSearchEmptyFooterItemNode: ListViewItemNode {
|
||||
private let contentNode: ASDisplayNode
|
||||
private let titleNode: TextNode
|
||||
private let textNode: TextNode
|
||||
private let searchAllMessagesButton: HighlightableButtonNode
|
||||
private let searchAllMessagesTitle: TextNode
|
||||
|
||||
private let icon = ComponentView<Empty>()
|
||||
|
||||
private var item: ChatListSearchEmptyFooterItem?
|
||||
|
||||
required init() {
|
||||
self.contentNode = ASDisplayNode()
|
||||
self.titleNode = TextNode()
|
||||
self.textNode = TextNode()
|
||||
|
||||
self.searchAllMessagesButton = HighlightableButtonNode()
|
||||
self.searchAllMessagesTitle = TextNode()
|
||||
self.searchAllMessagesTitle.isUserInteractionEnabled = false
|
||||
|
||||
super.init(layerBacked: false, dynamicBounce: false)
|
||||
|
||||
self.addSubnode(self.contentNode)
|
||||
self.contentNode.addSubnode(self.titleNode)
|
||||
self.contentNode.addSubnode(self.textNode)
|
||||
|
||||
self.contentNode.addSubnode(self.searchAllMessagesButton)
|
||||
self.searchAllMessagesButton.addSubnode(self.searchAllMessagesTitle)
|
||||
|
||||
self.searchAllMessagesButton.addTarget(self, action: #selector(self.searchAllMessagesButtonPressed), forControlEvents: .touchUpInside)
|
||||
|
||||
self.wantsTrailingItemSpaceUpdates = true
|
||||
}
|
||||
|
||||
@objc private func searchAllMessagesButtonPressed() {
|
||||
self.item?.searchAllMessages?()
|
||||
}
|
||||
|
||||
override func layoutForParams(_ params: ListViewItemLayoutParams, item: ListViewItem, previousItem: ListViewItem?, nextItem: ListViewItem?) {
|
||||
let layout = self.asyncLayout()
|
||||
let (_, apply) = layout(item as! ChatListSearchEmptyFooterItem, params)
|
||||
apply()
|
||||
}
|
||||
|
||||
override func headers() -> [ListViewItemHeader]? {
|
||||
if let item = self.item {
|
||||
return item.header.flatMap { [$0] }
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
override func updateTrailingItemSpace(_ trailingItemSpace: CGFloat, transition: ContainedViewLayoutTransition) {
|
||||
var contentFrame = self.contentNode.frame
|
||||
contentFrame.origin.y = max(0.0, floor(trailingItemSpace * 0.5))
|
||||
self.contentNode.frame = contentFrame
|
||||
}
|
||||
|
||||
func asyncLayout() -> (_ item: ChatListSearchEmptyFooterItem, _ params: ListViewItemLayoutParams) -> (ListViewItemNodeLayout, () -> Void) {
|
||||
let makeTitleNodeLayout = TextNode.asyncLayout(self.titleNode)
|
||||
let makeTextNodeLayout = TextNode.asyncLayout(self.textNode)
|
||||
let makeSearchAllMessagesTitleLayout = TextNode.asyncLayout(self.searchAllMessagesTitle)
|
||||
|
||||
return { [weak self] item, params in
|
||||
let titleLayout = makeTitleNodeLayout(TextNodeLayoutArguments(
|
||||
attributedString: NSAttributedString(string: item.strings.ChatList_Search_NoResults, font: Font.semibold(17.0), textColor: item.theme.list.freeTextColor),
|
||||
maximumNumberOfLines: 1,
|
||||
truncationType: .end,
|
||||
constrainedSize: CGSize(width: params.width - params.leftInset * 2.0 - 12.0 * 2.0, height: 1000.0)
|
||||
))
|
||||
|
||||
let textValue: String
|
||||
if let searchQuery = item.searchQuery {
|
||||
textValue = item.strings.ChatList_Search_NoResultsQueryDescription(searchQuery).string
|
||||
} else {
|
||||
textValue = item.strings.ChatList_Search_NoResults
|
||||
}
|
||||
|
||||
let textLayout = makeTextNodeLayout(TextNodeLayoutArguments(
|
||||
attributedString: NSAttributedString(string: textValue, font: Font.regular(16.0), textColor: item.theme.list.freeTextColor),
|
||||
maximumNumberOfLines: 0,
|
||||
truncationType: .end,
|
||||
constrainedSize: CGSize(width: params.width - params.leftInset * 2.0 - 12.0 * 2.0, height: 1000.0),
|
||||
alignment: .center,
|
||||
lineSpacing: 0.1
|
||||
))
|
||||
|
||||
let searchAllMessagesTitleLayout = makeSearchAllMessagesTitleLayout(TextNodeLayoutArguments(
|
||||
attributedString: NSAttributedString(string: item.strings.ChatList_EmptyResult_SearchInAll, font: Font.regular(17.0), textColor: item.theme.list.itemAccentColor),
|
||||
maximumNumberOfLines: 1,
|
||||
truncationType: .end,
|
||||
constrainedSize: CGSize(width: params.width - params.leftInset * 2.0 - 12.0 * 2.0, height: 1000.0)
|
||||
))
|
||||
|
||||
var contentHeight: CGFloat = 0.0
|
||||
|
||||
let topInset: CGFloat = 40.0
|
||||
let bottomInset: CGFloat = 10.0
|
||||
let iconSpacing: CGFloat = 20.0
|
||||
let titleSpacing: CGFloat = 6.0
|
||||
|
||||
let buttonSpacing: CGFloat = 14.0
|
||||
let buttonInset: CGFloat = 11.0
|
||||
|
||||
let iconSize = CGSize(width: 128.0, height: 128.0)
|
||||
|
||||
contentHeight += topInset
|
||||
contentHeight += iconSize.height
|
||||
contentHeight += iconSpacing
|
||||
contentHeight += titleLayout.0.size.height
|
||||
contentHeight += titleSpacing
|
||||
contentHeight += textLayout.0.size.height
|
||||
|
||||
if item.searchAllMessages != nil {
|
||||
contentHeight += buttonSpacing
|
||||
contentHeight += buttonInset
|
||||
contentHeight += searchAllMessagesTitleLayout.0.size.height
|
||||
contentHeight += buttonInset
|
||||
}
|
||||
|
||||
contentHeight += bottomInset
|
||||
|
||||
let layout = ListViewItemNodeLayout(contentSize: CGSize(width: params.width, height: contentHeight), insets: UIEdgeInsets())
|
||||
|
||||
return (layout, { [weak self] in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.item = item
|
||||
self.contentSize = layout.contentSize
|
||||
self.insets = layout.insets
|
||||
|
||||
let _ = titleLayout.1()
|
||||
let _ = textLayout.1()
|
||||
let _ = searchAllMessagesTitleLayout.1()
|
||||
|
||||
var contentY: CGFloat = 0.0
|
||||
contentY += topInset
|
||||
|
||||
let _ = self.icon.update(
|
||||
transition: .immediate,
|
||||
component: AnyComponent(LottieComponent(
|
||||
content: LottieComponent.AppBundleContent(
|
||||
name: "ChatListNoResults"
|
||||
),
|
||||
color: nil,
|
||||
placeholderColor: nil,
|
||||
startingPosition: .begin,
|
||||
size: iconSize,
|
||||
renderingScale: nil,
|
||||
loop: false,
|
||||
playOnce: nil
|
||||
)),
|
||||
environment: {}, containerSize: iconSize
|
||||
)
|
||||
let iconFrame = CGRect(origin: CGPoint(x: floor((params.width - iconSize.width) * 0.5), y: contentY), size: iconSize)
|
||||
if let iconView = self.icon.view {
|
||||
if iconView.superview == nil {
|
||||
self.contentNode.view.addSubview(iconView)
|
||||
}
|
||||
iconView.frame = iconFrame
|
||||
}
|
||||
|
||||
contentY += iconSize.height
|
||||
contentY += iconSpacing
|
||||
|
||||
let titleFrame = CGRect(origin: CGPoint(x: floor((params.width - titleLayout.0.size.width) * 0.5), y: contentY), size: titleLayout.0.size)
|
||||
self.titleNode.frame = titleFrame
|
||||
contentY += titleLayout.0.size.height
|
||||
contentY += titleSpacing
|
||||
|
||||
let textFrame = CGRect(origin: CGPoint(x: floor((params.width - textLayout.0.size.width) * 0.5), y: contentY), size: textLayout.0.size)
|
||||
self.textNode.frame = textFrame
|
||||
contentY += textLayout.0.size.height
|
||||
|
||||
if item.searchAllMessages != nil {
|
||||
contentY += buttonSpacing
|
||||
let searchAllMessagesButtonFrame = CGRect(origin: CGPoint(x: floor((params.width - searchAllMessagesTitleLayout.0.size.width) * 0.5), y: contentY), size: CGSize(width: searchAllMessagesTitleLayout.0.size.width, height: searchAllMessagesTitleLayout.0.size.height + buttonInset * 2.0))
|
||||
contentY += searchAllMessagesTitleLayout.0.size.height + buttonInset * 2.0
|
||||
|
||||
self.searchAllMessagesButton.frame = searchAllMessagesButtonFrame
|
||||
self.searchAllMessagesTitle.frame = CGRect(origin: CGPoint(x: 0.0, y: buttonInset), size: searchAllMessagesTitleLayout.0.size)
|
||||
contentY += buttonInset
|
||||
contentY += searchAllMessagesTitleLayout.0.size.height
|
||||
contentY += buttonInset
|
||||
}
|
||||
|
||||
contentY += bottomInset
|
||||
|
||||
let contentFrame = CGRect(origin: CGPoint(x: 0.0, y: self.contentNode.frame.minY), size: CGSize(width: params.width, height: contentHeight))
|
||||
self.contentNode.frame = contentFrame
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import EmojiStatusComponent
|
|||
import AvatarVideoNode
|
||||
import AppBundle
|
||||
import MultilineTextComponent
|
||||
import MultilineTextWithEntitiesComponent
|
||||
import ShimmerEffect
|
||||
|
||||
public enum ChatListItemContent {
|
||||
|
|
@ -82,10 +83,10 @@ public enum ChatListItemContent {
|
|||
|
||||
public struct Tag: Equatable {
|
||||
public var id: Int32
|
||||
public var title: String
|
||||
public var title: ChatFolderTitle
|
||||
public var colorId: Int32
|
||||
|
||||
public init(id: Int32, title: String, colorId: Int32) {
|
||||
public init(id: Int32, title: ChatFolderTitle, colorId: Int32) {
|
||||
self.id = id
|
||||
self.title = title
|
||||
self.colorId = colorId
|
||||
|
|
@ -269,6 +270,8 @@ private final class ChatListItemTagListComponent: Component {
|
|||
let backgroundView: UIImageView
|
||||
let title = ComponentView<Empty>()
|
||||
|
||||
private var currentTitle: ChatFolderTitle?
|
||||
|
||||
override init(frame: CGRect) {
|
||||
self.backgroundView = UIImageView(image: tagBackgroundImage)
|
||||
|
||||
|
|
@ -281,11 +284,20 @@ private final class ChatListItemTagListComponent: Component {
|
|||
preconditionFailure()
|
||||
}
|
||||
|
||||
func update(context: AccountContext, title: String, backgroundColor: UIColor, foregroundColor: UIColor, sizeFactor: CGFloat) -> CGSize {
|
||||
func update(context: AccountContext, title: ChatFolderTitle, backgroundColor: UIColor, foregroundColor: UIColor, sizeFactor: CGFloat) -> CGSize {
|
||||
self.currentTitle = title
|
||||
|
||||
let titleValue = ChatFolderTitle(text: title.text.isEmpty ? " " : title.text, entities: title.entities, enableAnimations: title.enableAnimations)
|
||||
let titleSize = self.title.update(
|
||||
transition: .immediate,
|
||||
component: AnyComponent(MultilineTextComponent(
|
||||
text: .plain(NSAttributedString(string: title.isEmpty ? " " : title, font: Font.semibold(floor(11.0 * sizeFactor)), textColor: foregroundColor))
|
||||
component: AnyComponent(MultilineTextWithEntitiesComponent(
|
||||
context: context,
|
||||
animationCache: context.animationCache,
|
||||
animationRenderer: context.animationRenderer,
|
||||
placeholderColor: foregroundColor.withMultipliedAlpha(0.1),
|
||||
text: .plain(titleValue.attributedString(font: Font.semibold(floor(11.0 * sizeFactor)), textColor: foregroundColor)),
|
||||
manualVisibilityControl: true,
|
||||
resetAnimationsOnVisibilityChange: true
|
||||
)),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: 100.0, height: 100.0)
|
||||
|
|
@ -309,11 +321,30 @@ private final class ChatListItemTagListComponent: Component {
|
|||
|
||||
return backgroundSize
|
||||
}
|
||||
|
||||
func updateVisibility(_ isVisible: Bool) {
|
||||
guard let currentTitle = self.currentTitle else {
|
||||
return
|
||||
}
|
||||
if let titleView = self.title.view as? MultilineTextWithEntitiesComponent.View {
|
||||
titleView.updateVisibility(isVisible && currentTitle.enableAnimations)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class View: UIView {
|
||||
private var itemViews: [Int32: ItemView] = [:]
|
||||
|
||||
var isVisible: Bool = false {
|
||||
didSet {
|
||||
if self.isVisible != oldValue {
|
||||
for (_, itemView) in self.itemViews {
|
||||
itemView.updateVisibility(self.isVisible)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override init(frame: CGRect) {
|
||||
super.init(frame: frame)
|
||||
}
|
||||
|
|
@ -332,13 +363,13 @@ private final class ChatListItemTagListComponent: Component {
|
|||
}
|
||||
|
||||
let itemId: Int32
|
||||
let itemTitle: String
|
||||
let itemTitle: ChatFolderTitle
|
||||
let itemBackgroundColor: UIColor
|
||||
let itemForegroundColor: UIColor
|
||||
|
||||
if validIds.count >= 3 {
|
||||
itemId = Int32.max
|
||||
itemTitle = "+\(component.tags.count - validIds.count)"
|
||||
itemTitle = ChatFolderTitle(text: "+\(component.tags.count - validIds.count)", entities: [], enableAnimations: true)
|
||||
itemForegroundColor = component.theme.chatList.dateTextColor
|
||||
itemBackgroundColor = itemForegroundColor.withMultipliedAlpha(0.1)
|
||||
} else {
|
||||
|
|
@ -347,7 +378,7 @@ private final class ChatListItemTagListComponent: Component {
|
|||
let tagColor = PeerNameColor(rawValue: tag.colorId)
|
||||
let resolvedColor = component.context.peerNameColors.getChatFolderTag(tagColor, dark: component.theme.overallDarkAppearance)
|
||||
|
||||
itemTitle = tag.title.uppercased()
|
||||
itemTitle = ChatFolderTitle(text: tag.title.text.uppercased(), entities: tag.title.entities, enableAnimations: tag.title.enableAnimations)
|
||||
itemBackgroundColor = resolvedColor.main.withMultipliedAlpha(0.1)
|
||||
itemForegroundColor = resolvedColor.main
|
||||
}
|
||||
|
|
@ -364,6 +395,7 @@ private final class ChatListItemTagListComponent: Component {
|
|||
let itemSize = itemView.update(context: component.context, title: itemTitle, backgroundColor: itemBackgroundColor, foregroundColor: itemForegroundColor, sizeFactor: component.sizeFactor)
|
||||
let itemFrame = CGRect(origin: CGPoint(x: nextX, y: 0.0), size: itemSize)
|
||||
itemView.frame = itemFrame
|
||||
itemView.updateVisibility(self.isVisible)
|
||||
|
||||
validIds.append(itemId)
|
||||
nextX += itemSize.width
|
||||
|
|
@ -1451,6 +1483,10 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode {
|
|||
)
|
||||
}
|
||||
self.authorNode.visibilityStatus = self.visibilityStatus
|
||||
|
||||
if let itemTagListView = self.itemTagList?.view as? ChatListItemTagListComponent.View {
|
||||
itemTagListView.isVisible = self.visibilityStatus
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -4239,13 +4275,14 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode {
|
|||
environment: {},
|
||||
containerSize: itemTagListFrame.size
|
||||
)
|
||||
if let itemTagListView = itemTagList.view {
|
||||
if let itemTagListView = itemTagList.view as? ChatListItemTagListComponent.View {
|
||||
if itemTagListView.superview == nil {
|
||||
itemTagListView.isUserInteractionEnabled = false
|
||||
strongSelf.mainContentContainerNode.view.addSubview(itemTagListView)
|
||||
}
|
||||
|
||||
itemTagListTransition.updateFrame(view: itemTagListView, frame: itemTagListFrame)
|
||||
itemTagListView.isVisible = strongSelf.visibilityStatus && item.context.sharedContext.energyUsageSettings.loopEmoji
|
||||
}
|
||||
} else {
|
||||
if let itemTagList = strongSelf.itemTagList {
|
||||
|
|
|
|||
|
|
@ -4276,33 +4276,32 @@ public final class ChatListNode: ListView {
|
|||
}
|
||||
}
|
||||
|
||||
private func statusStringForPeerType(accountPeerId: EnginePeer.Id, strings: PresentationStrings, peer: EnginePeer, isMuted: Bool, isUnread: Bool, isContact: Bool, hasUnseenMentions: Bool, chatListFilters: [ChatListFilter]?, displayAutoremoveTimeout: Bool, autoremoveTimeout: Int32?) -> (String, Bool, Bool, ContactsPeerItemStatus.Icon?)? {
|
||||
private func statusStringForPeerType(accountPeerId: EnginePeer.Id, strings: PresentationStrings, peer: EnginePeer, isMuted: Bool, isUnread: Bool, isContact: Bool, hasUnseenMentions: Bool, chatListFilters: [ChatListFilter]?, displayAutoremoveTimeout: Bool, autoremoveTimeout: Int32?) -> (NSAttributedString, Bool, Bool, ContactsPeerItemStatus.Icon?)? {
|
||||
if accountPeerId == peer.id {
|
||||
return nil
|
||||
}
|
||||
|
||||
if displayAutoremoveTimeout {
|
||||
if let autoremoveTimeout = autoremoveTimeout {
|
||||
return (strings.ChatList_LabelAutodeleteAfter(timeIntervalString(strings: strings, value: autoremoveTimeout, usage: .afterTime)).string, false, true, .autoremove)
|
||||
return (NSAttributedString(string: strings.ChatList_LabelAutodeleteAfter(timeIntervalString(strings: strings, value: autoremoveTimeout, usage: .afterTime)).string), false, true, .autoremove)
|
||||
} else {
|
||||
return (strings.ChatList_LabelAutodeleteDisabled, false, false, .autoremove)
|
||||
return (NSAttributedString(string: strings.ChatList_LabelAutodeleteDisabled), false, false, .autoremove)
|
||||
}
|
||||
}
|
||||
|
||||
if let chatListFilters = chatListFilters {
|
||||
var result = ""
|
||||
let result = NSMutableAttributedString(string: "")
|
||||
for case let .filter(_, title, _, data) in chatListFilters {
|
||||
let predicate = chatListFilterPredicate(filter: data, accountPeerId: accountPeerId)
|
||||
if predicate.includes(peer: peer._asPeer(), groupId: .root, isRemovedFromTotalUnreadCount: isMuted, isUnread: isUnread, isContact: isContact, messageTagSummaryResult: hasUnseenMentions) {
|
||||
if !result.isEmpty {
|
||||
result.append(", ")
|
||||
if result.length != 0 {
|
||||
result.append(NSAttributedString(string: ", "))
|
||||
}
|
||||
//TODO:release
|
||||
result.append(title.text)
|
||||
result.append(title.rawAttributedString)
|
||||
}
|
||||
}
|
||||
|
||||
if result.isEmpty {
|
||||
if result.length == 0 {
|
||||
return nil
|
||||
} else {
|
||||
return (result, true, false, nil)
|
||||
|
|
@ -4313,30 +4312,30 @@ private func statusStringForPeerType(accountPeerId: EnginePeer.Id, strings: Pres
|
|||
return nil
|
||||
} else if case let .user(user) = peer {
|
||||
if user.botInfo != nil || user.flags.contains(.isSupport) {
|
||||
return (strings.ChatList_PeerTypeBot, false, false, nil)
|
||||
return (NSAttributedString(string: strings.ChatList_PeerTypeBot), false, false, nil)
|
||||
} else {
|
||||
if isContact {
|
||||
return (strings.ChatList_PeerTypeContact, false, false, nil)
|
||||
return (NSAttributedString(string: strings.ChatList_PeerTypeContact), false, false, nil)
|
||||
} else {
|
||||
return (strings.ChatList_PeerTypeNonContactUser, false, false, nil)
|
||||
return (NSAttributedString(string: strings.ChatList_PeerTypeNonContactUser), false, false, nil)
|
||||
}
|
||||
}
|
||||
} else if case .secretChat = peer {
|
||||
if isContact {
|
||||
return (strings.ChatList_PeerTypeContact, false, false, nil)
|
||||
return (NSAttributedString(string: strings.ChatList_PeerTypeContact), false, false, nil)
|
||||
} else {
|
||||
return (strings.ChatList_PeerTypeNonContactUser, false, false, nil)
|
||||
return (NSAttributedString(string: strings.ChatList_PeerTypeNonContactUser), false, false, nil)
|
||||
}
|
||||
} else if case .legacyGroup = peer {
|
||||
return (strings.ChatList_PeerTypeGroup, false, false, nil)
|
||||
return (NSAttributedString(string: strings.ChatList_PeerTypeGroup), false, false, nil)
|
||||
} else if case let .channel(channel) = peer {
|
||||
if case .group = channel.info {
|
||||
return (strings.ChatList_PeerTypeGroup, false, false, nil)
|
||||
return (NSAttributedString(string: strings.ChatList_PeerTypeGroup), false, false, nil)
|
||||
} else {
|
||||
return (strings.ChatList_PeerTypeChannel, false, false, nil)
|
||||
return (NSAttributedString(string: strings.ChatList_PeerTypeChannel), false, false, nil)
|
||||
}
|
||||
}
|
||||
return (strings.ChatList_PeerTypeNonContactUser, false, false, nil)
|
||||
return (NSAttributedString(string: strings.ChatList_PeerTypeNonContactUser), false, false, nil)
|
||||
}
|
||||
|
||||
public class ChatHistoryListSelectionRecognizer: UIPanGestureRecognizer {
|
||||
|
|
@ -4425,10 +4424,9 @@ func chatListItemTags(location: ChatListControllerLocation, accountPeerId: Engin
|
|||
if data.color != nil {
|
||||
let predicate = chatListFilterPredicate(filter: data, accountPeerId: accountPeerId)
|
||||
if predicate.pinnedPeerIds.contains(peer.id) || predicate.includes(peer: peer._asPeer(), groupId: .root, isRemovedFromTotalUnreadCount: isMuted, isUnread: isUnread, isContact: isContact, messageTagSummaryResult: hasUnseenMentions) {
|
||||
//TODO:release
|
||||
result.append(ChatListItemContent.Tag(
|
||||
id: id,
|
||||
title: title.text,
|
||||
title: title,
|
||||
colorId: data.color?.rawValue ?? PeerNameColor.blue.rawValue
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,8 @@ public final class MultilineTextWithEntitiesComponent: Component {
|
|||
public let textStroke: (UIColor, CGFloat)?
|
||||
public let highlightColor: UIColor?
|
||||
public let handleSpoilers: Bool
|
||||
public let manualVisibilityControl: Bool
|
||||
public let resetAnimationsOnVisibilityChange: Bool
|
||||
public let highlightAction: (([NSAttributedString.Key: Any]) -> NSAttributedString.Key?)?
|
||||
public let tapAction: (([NSAttributedString.Key: Any], Int) -> Void)?
|
||||
public let longTapAction: (([NSAttributedString.Key: Any], Int) -> Void)?
|
||||
|
|
@ -52,6 +54,8 @@ public final class MultilineTextWithEntitiesComponent: Component {
|
|||
textStroke: (UIColor, CGFloat)? = nil,
|
||||
highlightColor: UIColor? = nil,
|
||||
handleSpoilers: Bool = false,
|
||||
manualVisibilityControl: Bool = false,
|
||||
resetAnimationsOnVisibilityChange: Bool = false,
|
||||
highlightAction: (([NSAttributedString.Key: Any]) -> NSAttributedString.Key?)? = nil,
|
||||
tapAction: (([NSAttributedString.Key: Any], Int) -> Void)? = nil,
|
||||
longTapAction: (([NSAttributedString.Key: Any], Int) -> Void)? = nil
|
||||
|
|
@ -73,6 +77,8 @@ public final class MultilineTextWithEntitiesComponent: Component {
|
|||
self.highlightColor = highlightColor
|
||||
self.highlightAction = highlightAction
|
||||
self.handleSpoilers = handleSpoilers
|
||||
self.manualVisibilityControl = manualVisibilityControl
|
||||
self.resetAnimationsOnVisibilityChange = resetAnimationsOnVisibilityChange
|
||||
self.tapAction = tapAction
|
||||
self.longTapAction = longTapAction
|
||||
}
|
||||
|
|
@ -105,6 +111,12 @@ public final class MultilineTextWithEntitiesComponent: Component {
|
|||
if lhs.handleSpoilers != rhs.handleSpoilers {
|
||||
return false
|
||||
}
|
||||
if lhs.manualVisibilityControl != rhs.manualVisibilityControl {
|
||||
return false
|
||||
}
|
||||
if lhs.resetAnimationsOnVisibilityChange != rhs.resetAnimationsOnVisibilityChange {
|
||||
return false
|
||||
}
|
||||
if let lhsTextShadowColor = lhs.textShadowColor, let rhsTextShadowColor = rhs.textShadowColor {
|
||||
if !lhsTextShadowColor.isEqual(rhsTextShadowColor) {
|
||||
return false
|
||||
|
|
@ -151,6 +163,10 @@ public final class MultilineTextWithEntitiesComponent: Component {
|
|||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
public func updateVisibility(_ isVisible: Bool) {
|
||||
self.textNode.visibility = isVisible
|
||||
}
|
||||
|
||||
public func update(component: MultilineTextWithEntitiesComponent, availableSize: CGSize, transition: ComponentTransition) -> CGSize {
|
||||
let attributedString: NSAttributedString
|
||||
switch component.text {
|
||||
|
|
@ -176,6 +192,8 @@ public final class MultilineTextWithEntitiesComponent: Component {
|
|||
self.textNode.highlightAttributeAction = component.highlightAction
|
||||
self.textNode.tapAttributeAction = component.tapAction
|
||||
self.textNode.longTapAttributeAction = component.longTapAction
|
||||
|
||||
self.textNode.resetEmojiToFirstFrameAutomatically = component.resetAnimationsOnVisibilityChange
|
||||
|
||||
if case let .curve(duration, _) = transition.animation, let previousText = previousText, previousText != attributedString.string {
|
||||
if let snapshotView = self.snapshotContentTree() {
|
||||
|
|
@ -189,7 +207,9 @@ public final class MultilineTextWithEntitiesComponent: Component {
|
|||
}
|
||||
}
|
||||
|
||||
self.textNode.visibility = true
|
||||
if !component.manualVisibilityControl {
|
||||
self.textNode.visibility = true
|
||||
}
|
||||
if let context = component.context, let animationCache = component.animationCache, let animationRenderer = component.animationRenderer, let placeholderColor = component.placeholderColor {
|
||||
self.textNode.arguments = TextNodeWithEntities.Arguments(
|
||||
context: context,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ public final class ListComposePollOptionComponent: Component {
|
|||
public let backspaceKeyAction: (() -> Void)?
|
||||
public let selection: Selection?
|
||||
public let inputMode: InputMode?
|
||||
public let alwaysDisplayInputModeSelector: Bool
|
||||
public let toggleInputMode: (() -> Void)?
|
||||
public let tag: AnyObject?
|
||||
|
||||
|
|
@ -100,6 +101,7 @@ public final class ListComposePollOptionComponent: Component {
|
|||
backspaceKeyAction: (() -> Void)?,
|
||||
selection: Selection?,
|
||||
inputMode: InputMode?,
|
||||
alwaysDisplayInputModeSelector: Bool = false,
|
||||
toggleInputMode: (() -> Void)?,
|
||||
tag: AnyObject? = nil
|
||||
) {
|
||||
|
|
@ -117,6 +119,7 @@ public final class ListComposePollOptionComponent: Component {
|
|||
self.backspaceKeyAction = backspaceKeyAction
|
||||
self.selection = selection
|
||||
self.inputMode = inputMode
|
||||
self.alwaysDisplayInputModeSelector = alwaysDisplayInputModeSelector
|
||||
self.toggleInputMode = toggleInputMode
|
||||
self.tag = tag
|
||||
}
|
||||
|
|
@ -158,6 +161,9 @@ public final class ListComposePollOptionComponent: Component {
|
|||
if lhs.inputMode != rhs.inputMode {
|
||||
return false
|
||||
}
|
||||
if lhs.alwaysDisplayInputModeSelector != rhs.alwaysDisplayInputModeSelector {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
|
@ -490,15 +496,17 @@ public final class ListComposePollOptionComponent: Component {
|
|||
ComponentTransition.immediate.setScale(view: modeSelectorView, scale: 0.001)
|
||||
}
|
||||
|
||||
if playAnimation, let animationView = modeSelectorView.contentView as? LottieComponent.View {
|
||||
animationView.playOnce()
|
||||
if let animationView = modeSelectorView.contentView as? LottieComponent.View {
|
||||
if playAnimation {
|
||||
animationView.playOnce()
|
||||
}
|
||||
}
|
||||
|
||||
modeSelectorTransition.setPosition(view: modeSelectorView, position: modeSelectorFrame.center)
|
||||
modeSelectorTransition.setBounds(view: modeSelectorView, bounds: CGRect(origin: CGPoint(), size: modeSelectorFrame.size))
|
||||
|
||||
if let externalState = component.externalState {
|
||||
let displaySelector = externalState.isEditing
|
||||
let displaySelector = externalState.isEditing || component.alwaysDisplayInputModeSelector
|
||||
|
||||
alphaTransition.setAlpha(view: modeSelectorView, alpha: displaySelector ? 1.0 : 0.0)
|
||||
alphaTransition.setScale(view: modeSelectorView, scale: displaySelector ? 1.0 : 0.001)
|
||||
|
|
|
|||
|
|
@ -165,19 +165,19 @@ private enum ContactListNodeEntry: Comparable, Identifiable {
|
|||
if let _ = peer as? TelegramUser {
|
||||
status = .presence(presence ?? EnginePeer.Presence(status: .longTimeAgo, lastActivity: 0), dateTimeFormat)
|
||||
} else if let group = peer as? TelegramGroup {
|
||||
status = .custom(string: strings.Conversation_StatusMembers(Int32(group.participantCount)), multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: strings.Conversation_StatusMembers(Int32(group.participantCount))), multiline: false, isActive: false, icon: nil)
|
||||
} else if let channel = peer as? TelegramChannel {
|
||||
if case .group = channel.info {
|
||||
if let participantCount = participantCount, participantCount != 0 {
|
||||
status = .custom(string: strings.Conversation_StatusMembers(participantCount), multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: strings.Conversation_StatusMembers(participantCount)), multiline: false, isActive: false, icon: nil)
|
||||
} else {
|
||||
status = .custom(string: strings.Group_Status, multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: strings.Group_Status), multiline: false, isActive: false, icon: nil)
|
||||
}
|
||||
} else {
|
||||
if let participantCount = participantCount, participantCount != 0 {
|
||||
status = .custom(string: strings.Conversation_StatusSubscribers(participantCount), multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: strings.Conversation_StatusSubscribers(participantCount)), multiline: false, isActive: false, icon: nil)
|
||||
} else {
|
||||
status = .custom(string: strings.Channel_Status, multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: strings.Channel_Status), multiline: false, isActive: false, icon: nil)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
|
@ -227,7 +227,7 @@ private enum ContactListNodeEntry: Comparable, Identifiable {
|
|||
|
||||
let text: String
|
||||
text = presentationData.strings.ChatList_ArchiveStoryCount(Int32(storyData.count))
|
||||
status = .custom(string: text, multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: text), multiline: false, isActive: false, icon: nil)
|
||||
}
|
||||
|
||||
return ContactsPeerItem(presentationData: ItemListPresentationData(presentationData), sortOrder: nameSortOrder, displayOrder: nameDisplayOrder, context: context, peerMode: isSearch ? .generalSearch(isSavedMessages: false) : .peer, peer: itemPeer, status: status, requiresPremiumForMessaging: requiresPremiumForMessaging, enabled: enabled, selection: selection, selectionPosition: .left, editing: ContactsPeerItemEditing(editable: false, editing: false, revealed: false), additionalActions: additionalActions, index: nil, header: header, action: { _ in
|
||||
|
|
|
|||
|
|
@ -674,7 +674,7 @@ public class ContactsController: ViewController {
|
|||
|
||||
let text = self.presentationData.strings.ContactList_DeletedContacts(Int32(peerIds.count))
|
||||
|
||||
self.present(UndoOverlayController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(title: text, text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in
|
||||
self.present(UndoOverlayController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: self.context, title: NSAttributedString(string: text), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in
|
||||
guard let self else {
|
||||
return false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ private enum InviteContactsEntry: Comparable, Identifiable {
|
|||
case let .peer(_, id, contact, count, selection, theme, strings, nameSortOrder, nameDisplayOrder):
|
||||
let status: ContactsPeerItemStatus
|
||||
if count != 0 {
|
||||
status = .custom(string: strings.Contacts_ImportersCount(count), multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: strings.Contacts_ImportersCount(count)), multiline: false, isActive: false, icon: nil)
|
||||
} else {
|
||||
status = .none
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,8 @@ swift_library(
|
|||
"//submodules/TelegramUI/Components/EmojiStatusComponent",
|
||||
"//submodules/TelegramUI/Components/Stories/AvatarStoryIndicatorComponent",
|
||||
"//submodules/MoreButtonNode",
|
||||
"//submodules/TextFormat",
|
||||
"//submodules/TelegramUI/Components/TextNodeWithEntities",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ import AnimationCache
|
|||
import MultiAnimationRenderer
|
||||
import EmojiStatusComponent
|
||||
import MoreButtonNode
|
||||
import TextFormat
|
||||
import TextNodeWithEntities
|
||||
|
||||
public final class ContactItemHighlighting {
|
||||
public var chatLocation: ChatLocation?
|
||||
|
|
@ -39,7 +41,7 @@ public enum ContactsPeerItemStatus {
|
|||
case none
|
||||
case presence(EnginePeer.Presence, PresentationDateTimeFormat)
|
||||
case addressName(String)
|
||||
case custom(string: String, multiline: Bool, isActive: Bool, icon: Icon?)
|
||||
case custom(string: NSAttributedString, multiline: Bool, isActive: Bool, icon: Icon?)
|
||||
}
|
||||
|
||||
public enum ContactsPeerItemSelection: Equatable {
|
||||
|
|
@ -437,7 +439,7 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode {
|
|||
private var credibilityIconComponent: EmojiStatusComponent?
|
||||
private var verifiedIconView: ComponentHostView<Empty>?
|
||||
private var verifiedIconComponent: EmojiStatusComponent?
|
||||
public let statusNode: TextNode
|
||||
public let statusNode: TextNodeWithEntities
|
||||
private var statusIconNode: ASImageNode?
|
||||
private var badgeBackgroundNode: ASImageNode?
|
||||
private var badgeTextNode: TextNode?
|
||||
|
|
@ -519,6 +521,7 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode {
|
|||
containerSize: avatarIconView.bounds.size
|
||||
)
|
||||
}
|
||||
self.statusNode.visibilityRect = self.visibilityStatus == false ? CGRect.zero : CGRect.infinite
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -554,7 +557,7 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode {
|
|||
self.avatarNode.isLayerBacked = false
|
||||
|
||||
self.titleNode = TextNode()
|
||||
self.statusNode = TextNode()
|
||||
self.statusNode = TextNodeWithEntities()
|
||||
|
||||
super.init(layerBacked: false, dynamicBounce: false, rotated: false, seeThrough: false)
|
||||
|
||||
|
|
@ -575,7 +578,7 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode {
|
|||
self.avatarNodeContainer.addSubnode(self.avatarNode)
|
||||
self.offsetContainerNode.addSubnode(self.avatarNodeContainer)
|
||||
self.offsetContainerNode.addSubnode(self.titleNode)
|
||||
self.offsetContainerNode.addSubnode(self.statusNode)
|
||||
self.offsetContainerNode.addSubnode(self.statusNode.textNode)
|
||||
|
||||
self.addSubnode(self.maskNode)
|
||||
|
||||
|
|
@ -708,7 +711,7 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode {
|
|||
|
||||
public func asyncLayout() -> (_ item: ContactsPeerItem, _ params: ListViewItemLayoutParams, _ first: Bool, _ last: Bool, _ firstWithHeader: Bool, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> (Signal<Void, NoError>?, (Bool, Bool) -> Void)) {
|
||||
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
|
||||
let makeStatusLayout = TextNode.asyncLayout(self.statusNode)
|
||||
let makeStatusLayout = TextNodeWithEntities.asyncLayout(self.statusNode)
|
||||
let currentSelectionNode = self.selectionNode
|
||||
|
||||
let makeBadgeTextLayout = TextNode.asyncLayout(self.badgeTextNode)
|
||||
|
|
@ -939,7 +942,24 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode {
|
|||
statusAttributedString = NSAttributedString(string: suffix, font: statusFont, textColor: item.presentationData.theme.list.itemSecondaryTextColor)
|
||||
}
|
||||
case let .custom(text, multiline, isActive, icon):
|
||||
statusAttributedString = NSAttributedString(string: text, font: statusFont, textColor: isActive ? item.presentationData.theme.list.itemAccentColor : item.presentationData.theme.list.itemSecondaryTextColor)
|
||||
let statusAttributedStringValue = NSMutableAttributedString(string: text.string)
|
||||
statusAttributedStringValue.addAttribute(.font, value: statusFont, range: NSRange(location: 0, length: statusAttributedStringValue.length))
|
||||
statusAttributedStringValue.addAttribute(.foregroundColor, value: isActive ? item.presentationData.theme.list.itemAccentColor : item.presentationData.theme.list.itemSecondaryTextColor, range: NSRange(location: 0, length: statusAttributedStringValue.length))
|
||||
text.enumerateAttributes(in: NSRange(location: 0, length: text.length), using: { attributes, range, _ in
|
||||
for (key, value) in attributes {
|
||||
if key == ChatTextInputAttributes.bold {
|
||||
statusAttributedStringValue.addAttribute(.font, value: Font.semibold(14.0), range: range)
|
||||
} else if key == ChatTextInputAttributes.italic {
|
||||
statusAttributedStringValue.addAttribute(.font, value: Font.italic(14.0), range: range)
|
||||
} else if key == ChatTextInputAttributes.monospace {
|
||||
statusAttributedStringValue.addAttribute(.font, value: Font.monospace(14.0), range: range)
|
||||
} else {
|
||||
statusAttributedStringValue.addAttribute(key, value: value, range: range)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
statusAttributedString = statusAttributedStringValue
|
||||
statusIcon = icon
|
||||
statusIsActive = isActive
|
||||
multilineStatus = multiline
|
||||
|
|
@ -964,7 +984,23 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode {
|
|||
|
||||
switch item.status {
|
||||
case let .custom(text, multiline, isActive, icon):
|
||||
statusAttributedString = NSAttributedString(string: text, font: statusFont, textColor: isActive ? item.presentationData.theme.list.itemAccentColor : item.presentationData.theme.list.itemSecondaryTextColor)
|
||||
let statusAttributedStringValue = NSMutableAttributedString(string: "")
|
||||
statusAttributedStringValue.addAttribute(.font, value: statusFont, range: NSRange(location: 0, length: text.length))
|
||||
statusAttributedStringValue.addAttribute(.foregroundColor, value: isActive ? item.presentationData.theme.list.itemAccentColor : item.presentationData.theme.list.itemSecondaryTextColor, range: NSRange(location: 0, length: text.length))
|
||||
text.enumerateAttributes(in: NSRange(location: 0, length: text.length), using: { attributes, range, _ in
|
||||
for (key, value) in attributes {
|
||||
if key == ChatTextInputAttributes.bold {
|
||||
statusAttributedStringValue.addAttribute(.font, value: Font.semibold(14.0), range: range)
|
||||
} else if key == ChatTextInputAttributes.italic {
|
||||
statusAttributedStringValue.addAttribute(.font, value: Font.italic(14.0), range: range)
|
||||
} else if key == ChatTextInputAttributes.monospace {
|
||||
statusAttributedStringValue.addAttribute(.font, value: Font.monospace(14.0), range: range)
|
||||
} else {
|
||||
statusAttributedStringValue.addAttribute(key, value: value, range: range)
|
||||
}
|
||||
}
|
||||
})
|
||||
statusAttributedString = statusAttributedStringValue
|
||||
multilineStatus = multiline
|
||||
statusIsActive = isActive
|
||||
statusIcon = icon
|
||||
|
|
@ -1409,17 +1445,24 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode {
|
|||
transition.updateFrame(node: strongSelf.titleNode, frame: titleFrame)
|
||||
|
||||
strongSelf.titleNode.alpha = item.enabled ? 1.0 : 0.4
|
||||
strongSelf.statusNode.alpha = item.enabled ? 1.0 : 1.0
|
||||
strongSelf.statusNode.textNode.alpha = item.enabled ? 1.0 : 1.0
|
||||
|
||||
let _ = statusApply()
|
||||
strongSelf.statusNode.visibilityRect = strongSelf.visibilityStatus == false ? CGRect.zero : CGRect.infinite
|
||||
let _ = statusApply(TextNodeWithEntities.Arguments(
|
||||
context: item.context,
|
||||
cache: item.context.animationCache,
|
||||
renderer: item.context.animationRenderer,
|
||||
placeholderColor: item.presentationData.theme.list.mediaPlaceholderColor,
|
||||
attemptSynchronous: false
|
||||
))
|
||||
var statusFrame = CGRect(origin: CGPoint(x: revealOffset + leftInset, y: strongSelf.titleNode.frame.maxY - 1.0), size: statusLayout.size)
|
||||
if let statusIconImage {
|
||||
statusFrame.origin.x += statusIconImage.size.width + 1.0
|
||||
}
|
||||
let previousStatusFrame = strongSelf.statusNode.frame
|
||||
let previousStatusFrame = strongSelf.statusNode.textNode.frame
|
||||
|
||||
strongSelf.statusNode.frame = statusFrame
|
||||
transition.animatePositionAdditive(node: strongSelf.statusNode, offset: CGPoint(x: previousStatusFrame.minX - statusFrame.minX, y: 0))
|
||||
strongSelf.statusNode.textNode.frame = statusFrame
|
||||
transition.animatePositionAdditive(node: strongSelf.statusNode.textNode, offset: CGPoint(x: previousStatusFrame.minX - statusFrame.minX, y: 0))
|
||||
|
||||
if let statusIconImage {
|
||||
let statusIconNode: ASImageNode
|
||||
|
|
@ -1427,7 +1470,7 @@ public class ContactsPeerItemNode: ItemListRevealOptionsItemNode {
|
|||
statusIconNode = current
|
||||
} else {
|
||||
statusIconNode = ASImageNode()
|
||||
strongSelf.statusNode.addSubnode(statusIconNode)
|
||||
strongSelf.statusNode.textNode.addSubnode(statusIconNode)
|
||||
}
|
||||
statusIconNode.image = statusIconImage
|
||||
statusIconNode.frame = CGRect(origin: CGPoint(x: -statusIconImage.size.width - 1.0, y: floor((statusFrame.height - statusIconImage.size.height) / 2.0) + 1.0), size: statusIconImage.size)
|
||||
|
|
|
|||
|
|
@ -317,7 +317,7 @@ public final class ContextControllerActionsListActionItemNode: HighlightTracking
|
|||
if !self.item.entities.isEmpty {
|
||||
let inputStateText = ChatTextInputStateText(text: self.item.text, attributes: self.item.entities.compactMap { entity -> ChatTextInputStateTextAttribute? in
|
||||
if case let .CustomEmoji(_, fileId) = entity.type {
|
||||
return ChatTextInputStateTextAttribute(type: .customEmoji(stickerPack: nil, fileId: fileId), range: entity.range)
|
||||
return ChatTextInputStateTextAttribute(type: .customEmoji(stickerPack: nil, fileId: fileId, enableAnimation: true), range: entity.range)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
|
|
|||
|
|
@ -106,6 +106,7 @@ private enum DebugControllerEntry: ItemListNodeEntry {
|
|||
case disableCallV2(Bool)
|
||||
case experimentalCallMute(Bool)
|
||||
case conferenceCalls(Bool)
|
||||
case playerV2(Bool)
|
||||
case benchmarkReflectors
|
||||
case enableLocalTranslation(Bool)
|
||||
case preferredVideoCodec(Int, String, String?, Bool)
|
||||
|
|
@ -132,7 +133,7 @@ private enum DebugControllerEntry: ItemListNodeEntry {
|
|||
return DebugControllerSection.web.rawValue
|
||||
case .keepChatNavigationStack, .skipReadHistory, .dustEffect, .crashOnSlowQueries, .crashOnMemoryPressure:
|
||||
return DebugControllerSection.experiments.rawValue
|
||||
case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .storiesExperiment, .storiesJpegExperiment, .playlistPlayback, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .rippleEffect, .browserExperiment, .localTranscription, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .disableCallV2, .experimentalCallMute, .conferenceCalls, .benchmarkReflectors, .enableLocalTranslation:
|
||||
case .clearTips, .resetNotifications, .crash, .fillLocalSavedMessageCache, .resetDatabase, .resetDatabaseAndCache, .resetHoles, .resetTagHoles, .reindexUnread, .resetCacheIndex, .reindexCache, .resetBiometricsData, .optimizeDatabase, .photoPreview, .knockoutWallpaper, .storiesExperiment, .storiesJpegExperiment, .playlistPlayback, .enableQuickReactionSwitch, .experimentalCompatibility, .enableDebugDataDisplay, .rippleEffect, .browserExperiment, .localTranscription, .enableReactionOverrides, .restorePurchases, .disableReloginTokens, .disableCallV2, .experimentalCallMute, .conferenceCalls, .playerV2, .benchmarkReflectors, .enableLocalTranslation:
|
||||
return DebugControllerSection.experiments.rawValue
|
||||
case .logTranslationRecognition, .resetTranslationStates:
|
||||
return DebugControllerSection.translation.rawValue
|
||||
|
|
@ -251,12 +252,14 @@ private enum DebugControllerEntry: ItemListNodeEntry {
|
|||
return 52
|
||||
case .conferenceCalls:
|
||||
return 53
|
||||
case .benchmarkReflectors:
|
||||
case .playerV2:
|
||||
return 54
|
||||
case .enableLocalTranslation:
|
||||
case .benchmarkReflectors:
|
||||
return 55
|
||||
case .enableLocalTranslation:
|
||||
return 56
|
||||
case let .preferredVideoCodec(index, _, _, _):
|
||||
return 56 + index
|
||||
return 57 + index
|
||||
case .disableVideoAspectScaling:
|
||||
return 100
|
||||
case .enableNetworkFramework:
|
||||
|
|
@ -1355,6 +1358,16 @@ private enum DebugControllerEntry: ItemListNodeEntry {
|
|||
})
|
||||
}).start()
|
||||
})
|
||||
case let .playerV2(value):
|
||||
return ItemListSwitchItem(presentationData: presentationData, title: "PlayerV2", value: value, sectionId: self.section, style: .blocks, updated: { value in
|
||||
let _ = arguments.sharedContext.accountManager.transaction ({ transaction in
|
||||
transaction.updateSharedData(ApplicationSpecificSharedDataKeys.experimentalUISettings, { settings in
|
||||
var settings = settings?.get(ExperimentalUISettings.self) ?? ExperimentalUISettings.defaultSettings
|
||||
settings.playerV2 = value
|
||||
return PreferencesEntry(settings)
|
||||
})
|
||||
}).start()
|
||||
})
|
||||
case .benchmarkReflectors:
|
||||
return ItemListActionItem(presentationData: presentationData, title: "Benchmark Reflectors", kind: .generic, alignment: .natural, sectionId: self.section, style: .blocks, action: {
|
||||
guard let context = arguments.context else {
|
||||
|
|
@ -1578,6 +1591,7 @@ private func debugControllerEntries(sharedContext: SharedAccountContext, present
|
|||
entries.append(.experimentalCallMute(experimentalSettings.experimentalCallMute))
|
||||
|
||||
entries.append(.conferenceCalls(experimentalSettings.conferenceCalls))
|
||||
entries.append(.playerV2(experimentalSettings.playerV2))
|
||||
|
||||
entries.append(.benchmarkReflectors)
|
||||
entries.append(.enableLocalTranslation(experimentalSettings.enableLocalTranslation))
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ public class DrawingStickerEntityView: DrawingEntityView {
|
|||
|
||||
private func setupWithVideo(_ file: TelegramMediaFile) {
|
||||
let videoNode = UniversalVideoNode(
|
||||
accountId: self.context.account.id,
|
||||
context: self.context,
|
||||
postbox: self.context.account.postbox,
|
||||
audioSession: self.context.sharedContext.mediaManager.audioSession,
|
||||
manager: self.context.sharedContext.mediaManager.universalVideoManager,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ NS_ASSUME_NONNULL_BEGIN
|
|||
|
||||
@interface FFMpegAVCodec : NSObject
|
||||
|
||||
+ (FFMpegAVCodec * _Nullable)findForId:(int)codecId;
|
||||
+ (FFMpegAVCodec * _Nullable)findForId:(int)codecId preferHardwareAccelerationCapable:(bool)preferHardwareAccelerationCapable;
|
||||
|
||||
- (void *)impl;
|
||||
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ typedef NS_ENUM(NSUInteger, FFMpegAVCodecContextReceiveResult)
|
|||
|
||||
- (bool)open;
|
||||
- (bool)sendEnd;
|
||||
- (void)setupHardwareAccelerationIfPossible;
|
||||
- (FFMpegAVCodecContextReceiveResult)receiveIntoFrame:(FFMpegAVFrame *)frame;
|
||||
- (void)flushBuffers;
|
||||
|
||||
|
|
|
|||
|
|
@ -12,6 +12,11 @@ typedef NS_ENUM(NSUInteger, FFMpegAVFramePixelFormat) {
|
|||
FFMpegAVFramePixelFormatYUVA
|
||||
};
|
||||
|
||||
typedef NS_ENUM(NSUInteger, FFMpegAVFrameNativePixelFormat) {
|
||||
FFMpegAVFrameNativePixelFormatUnknown,
|
||||
FFMpegAVFrameNativePixelFormatVideoToolbox
|
||||
};
|
||||
|
||||
@interface FFMpegAVFrame : NSObject
|
||||
|
||||
@property (nonatomic, readonly) int32_t width;
|
||||
|
|
@ -27,6 +32,7 @@ typedef NS_ENUM(NSUInteger, FFMpegAVFramePixelFormat) {
|
|||
- (instancetype)initWithPixelFormat:(FFMpegAVFramePixelFormat)pixelFormat width:(int32_t)width height:(int32_t)height;
|
||||
|
||||
- (void *)impl;
|
||||
- (FFMpegAVFrameNativePixelFormat)nativePixelFormat;
|
||||
|
||||
@end
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,26 @@
|
|||
return self;
|
||||
}
|
||||
|
||||
+ (FFMpegAVCodec * _Nullable)findForId:(int)codecId {
|
||||
+ (FFMpegAVCodec * _Nullable)findForId:(int)codecId preferHardwareAccelerationCapable:(_Bool)preferHardwareAccelerationCapable {
|
||||
if (preferHardwareAccelerationCapable && codecId == AV_CODEC_ID_AV1) {
|
||||
void *codecIterationState = nil;
|
||||
while (true) {
|
||||
AVCodec const *codec = av_codec_iterate(&codecIterationState);
|
||||
if (!codec) {
|
||||
break;
|
||||
}
|
||||
if (!av_codec_is_decoder(codec)) {
|
||||
continue;
|
||||
}
|
||||
if (codec->id != codecId) {
|
||||
continue;
|
||||
}
|
||||
if (strncmp(codec->name, "av1", 2) == 0) {
|
||||
return [[FFMpegAVCodec alloc] initWithImpl:codec];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AVCodec const *codec = avcodec_find_decoder(codecId);
|
||||
if (codec) {
|
||||
return [[FFMpegAVCodec alloc] initWithImpl:codec];
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
#import "libavformat/avformat.h"
|
||||
#import "libavcodec/avcodec.h"
|
||||
|
||||
static enum AVPixelFormat getPreferredPixelFormat(__unused AVCodecContext *ctx, __unused const enum AVPixelFormat *pix_fmts) {
|
||||
return AV_PIX_FMT_VIDEOTOOLBOX;
|
||||
}
|
||||
|
||||
@interface FFMpegAVCodecContext () {
|
||||
FFMpegAVCodec *_codec;
|
||||
AVCodecContext *_impl;
|
||||
|
|
@ -61,6 +65,11 @@
|
|||
return status == 0;
|
||||
}
|
||||
|
||||
- (void)setupHardwareAccelerationIfPossible {
|
||||
av_hwdevice_ctx_create(&_impl->hw_device_ctx, AV_HWDEVICE_TYPE_VIDEOTOOLBOX, nil, nil, 0);
|
||||
_impl->get_format = getPreferredPixelFormat;
|
||||
}
|
||||
|
||||
- (FFMpegAVCodecContextReceiveResult)receiveIntoFrame:(FFMpegAVFrame *)frame {
|
||||
int status = avcodec_receive_frame(_impl, (AVFrame *)[frame impl]);
|
||||
if (status == 0) {
|
||||
|
|
|
|||
|
|
@ -64,6 +64,17 @@
|
|||
return _impl->pts;
|
||||
}
|
||||
|
||||
- (FFMpegAVFrameNativePixelFormat)nativePixelFormat {
|
||||
switch (_impl->format) {
|
||||
case AV_PIX_FMT_VIDEOTOOLBOX: {
|
||||
return FFMpegAVFrameNativePixelFormatVideoToolbox;
|
||||
}
|
||||
default: {
|
||||
return FFMpegAVFrameNativePixelFormatUnknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
- (int64_t)duration {
|
||||
#if LIBAVFORMAT_VERSION_MAJOR >= 59
|
||||
return _impl->duration;
|
||||
|
|
|
|||
|
|
@ -259,7 +259,7 @@ public func galleryItemForEntry(
|
|||
}
|
||||
|
||||
if isHLS {
|
||||
content = HLSVideoContent(id: .message(message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), streamVideo: streamVideos, loopVideo: loopVideos, codecConfiguration: HLSCodecConfiguration(context: context))
|
||||
content = HLSVideoContent(id: .message(message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), streamVideo: streamVideos, loopVideo: loopVideos, autoFetchFullSizeThumbnail: true, codecConfiguration: HLSCodecConfiguration(context: context))
|
||||
} else {
|
||||
content = NativeVideoContent(id: .message(message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), imageReference: mediaImage.flatMap({ ImageMediaReference.message(message: MessageReference(message), media: $0) }), streamVideo: .conservative, loopVideo: loopVideos, tempFilePath: tempFilePath, captureProtected: captureProtected, storeAfterDownload: generateStoreAfterDownload?(message, file))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1762,7 +1762,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
|
|||
|
||||
let mediaManager = item.context.sharedContext.mediaManager
|
||||
|
||||
let videoNode = UniversalVideoNode(accountId: item.context.account.id, postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: item.content, priority: .gallery)
|
||||
let videoNode = UniversalVideoNode(context: item.context, postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: item.content, priority: .gallery)
|
||||
let videoScale: CGFloat
|
||||
if item.content is WebEmbedVideoContent {
|
||||
videoScale = 1.0
|
||||
|
|
@ -2849,7 +2849,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
|
|||
let baseNavigationController = self.baseNavigationController()
|
||||
let mediaManager = self.context.sharedContext.mediaManager
|
||||
var expandImpl: (() -> Void)?
|
||||
let overlayNode = OverlayUniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, content: item.content, expand: {
|
||||
let overlayNode = OverlayUniversalVideoNode(context: self.context, postbox: self.context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, content: item.content, expand: {
|
||||
expandImpl?()
|
||||
}, close: { [weak mediaManager] in
|
||||
mediaManager?.setOverlayVideoNode(nil)
|
||||
|
|
@ -3073,7 +3073,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
|
|||
|
||||
self.disablePictureInPicturePlaceholder = true
|
||||
|
||||
let overlayVideoNode = UniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: self.context.sharedContext.mediaManager.audioSession, manager: self.context.sharedContext.mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: item.content, priority: .overlay)
|
||||
let overlayVideoNode = UniversalVideoNode(context: self.context, postbox: self.context.account.postbox, audioSession: self.context.sharedContext.mediaManager.audioSession, manager: self.context.sharedContext.mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: item.content, priority: .overlay)
|
||||
let absoluteRect = videoNode.view.convert(videoNode.view.bounds, to: nil)
|
||||
overlayVideoNode.frame = absoluteRect
|
||||
overlayVideoNode.updateLayout(size: absoluteRect.size, transition: .immediate)
|
||||
|
|
@ -3156,7 +3156,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
|
|||
shouldBeDismissed = .single(false)
|
||||
}
|
||||
|
||||
let overlayNode = OverlayUniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, content: item.content, shouldBeDismissed: shouldBeDismissed, expand: {
|
||||
let overlayNode = OverlayUniversalVideoNode(context: self.context, postbox: self.context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, content: item.content, shouldBeDismissed: shouldBeDismissed, expand: {
|
||||
expandImpl?()
|
||||
}, close: { [weak mediaManager] in
|
||||
mediaManager?.setOverlayVideoNode(nil)
|
||||
|
|
|
|||
|
|
@ -1449,7 +1449,7 @@ final class InstantPageControllerNode: ASDisplayNode, ASScrollViewDelegate {
|
|||
}
|
||||
}
|
||||
}
|
||||
self.context.sharedContext.mediaManager.setPlaylist((self.context.account, InstantPageMediaPlaylist(webPage: webPage, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
|
||||
self.context.sharedContext.mediaManager.setPlaylist((self.context, InstantPageMediaPlaylist(webPage: webPage, items: medias, initialItemIndex: initialIndex)), type: file.isVoice ? .voice : .music, control: .playback(.play))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ final class InstantPagePlayableVideoNode: ASDisplayNode, InstantPageNode, Galler
|
|||
fileValue = file
|
||||
}
|
||||
|
||||
self.videoNode = UniversalVideoNode(accountId: context.account.id, postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: NativeVideoContent(id: .instantPage(webPage.webpageId, media.media.id!), userLocation: userLocation, fileReference: .webPage(webPage: WebpageReference(webPage), media: fileValue!), imageReference: imageReference, streamVideo: streamVideo ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, placeholderColor: theme.pageBackgroundColor, storeAfterDownload: nil), priority: .embedded, autoplay: true)
|
||||
self.videoNode = UniversalVideoNode(context: context, postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: NativeVideoContent(id: .instantPage(webPage.webpageId, media.media.id!), userLocation: userLocation, fileReference: .webPage(webPage: WebpageReference(webPage), media: fileValue!), imageReference: imageReference, streamVideo: streamVideo ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, placeholderColor: theme.pageBackgroundColor, storeAfterDownload: nil), priority: .embedded, autoplay: true)
|
||||
self.videoNode.isUserInteractionEnabled = false
|
||||
|
||||
self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.6))
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@ private enum InviteLinksListEntry: ItemListNodeEntry {
|
|||
case peer(EnginePeer.Id)
|
||||
}
|
||||
|
||||
case header(String)
|
||||
case header(NSAttributedString)
|
||||
|
||||
case mainLinkHeader(String)
|
||||
case mainLink(link: ExportedChatFolderLink?, isGenerating: Bool)
|
||||
|
|
@ -225,13 +225,13 @@ private enum InviteLinksListEntry: ItemListNodeEntry {
|
|||
private func folderInviteLinkListControllerEntries(
|
||||
presentationData: PresentationData,
|
||||
state: FolderInviteLinkListControllerState,
|
||||
title: String,
|
||||
title: ChatFolderTitle,
|
||||
allPeers: [EnginePeer]
|
||||
) -> [InviteLinksListEntry] {
|
||||
var entries: [InviteLinksListEntry] = []
|
||||
|
||||
var infoString: String?
|
||||
let chatCountString: String
|
||||
let chatCountString: NSAttributedString
|
||||
let peersHeaderString: String
|
||||
|
||||
let canShareChats = !allPeers.allSatisfy({ !canShareLinkToPeer(peer: $0) })
|
||||
|
|
@ -241,16 +241,36 @@ private func folderInviteLinkListControllerEntries(
|
|||
|
||||
if !canShareChats {
|
||||
infoString = presentationData.strings.FolderLinkScreen_TitleDescriptionUnavailable
|
||||
chatCountString = presentationData.strings.FolderLinkScreen_ChatCountHeaderUnavailable
|
||||
chatCountString = NSAttributedString(string: presentationData.strings.FolderLinkScreen_ChatCountHeaderUnavailable)
|
||||
peersHeaderString = presentationData.strings.FolderLinkScreen_ChatsSectionHeaderUnavailable
|
||||
} else if state.selectedPeerIds.isEmpty {
|
||||
chatCountString = presentationData.strings.FolderLinkScreen_TitleDescriptionDeselected(title).string
|
||||
let chatCountStringValue = NSMutableAttributedString(string: presentationData.strings.FolderLinkScreen_TitleDescriptionDeselectedV2)
|
||||
let folderRange = (chatCountStringValue.string as NSString).range(of: "{folder}")
|
||||
if folderRange.location != NSNotFound {
|
||||
chatCountStringValue.replaceCharacters(in: folderRange, with: "")
|
||||
chatCountStringValue.insert(title.rawAttributedString, at: folderRange.location)
|
||||
}
|
||||
|
||||
chatCountString = chatCountStringValue
|
||||
peersHeaderString = presentationData.strings.FolderLinkScreen_ChatsSectionHeader
|
||||
if allPeers.count > 1 {
|
||||
selectAllString = allSelected ? presentationData.strings.FolderLinkScreen_ChatsSectionHeaderActionDeselectAll : presentationData.strings.FolderLinkScreen_ChatsSectionHeaderActionSelectAll
|
||||
}
|
||||
} else {
|
||||
chatCountString = presentationData.strings.FolderLinkScreen_TitleDescriptionSelected(title, presentationData.strings.FolderLinkScreen_TitleDescriptionSelectedCount(Int32(state.selectedPeerIds.count))).string
|
||||
let chatCountStringValue = NSMutableAttributedString(string: presentationData.strings.FolderLinkScreen_TitleDescriptionSelectedV2)
|
||||
let folderRange = (chatCountStringValue.string as NSString).range(of: "{folder}")
|
||||
if folderRange.location != NSNotFound {
|
||||
chatCountStringValue.replaceCharacters(in: folderRange, with: "")
|
||||
chatCountStringValue.insert(title.rawAttributedString, at: folderRange.location)
|
||||
}
|
||||
let chatsRange = (chatCountStringValue.string as NSString).range(of: "{chats}")
|
||||
if chatsRange.location != NSNotFound {
|
||||
chatCountStringValue.replaceCharacters(in: chatsRange, with: "")
|
||||
let countValue = presentationData.strings.FolderLinkScreen_TitleDescriptionSelectedCount(Int32(state.selectedPeerIds.count))
|
||||
chatCountStringValue.insert(NSAttributedString(string: countValue), at: chatsRange.location)
|
||||
}
|
||||
|
||||
chatCountString = chatCountStringValue
|
||||
peersHeaderString = presentationData.strings.FolderLinkScreen_ChatsSectionHeaderSelected(Int32(state.selectedPeerIds.count))
|
||||
if allPeers.count > 1 {
|
||||
selectAllString = allSelected ? presentationData.strings.FolderLinkScreen_ChatsSectionHeaderActionDeselectAll : presentationData.strings.FolderLinkScreen_ChatsSectionHeaderActionSelectAll
|
||||
|
|
@ -699,11 +719,10 @@ public func folderInviteLinkListController(context: AccountContext, updatedPrese
|
|||
}
|
||||
|
||||
let controllerState = ItemListControllerState(presentationData: ItemListPresentationData(presentationData), title: title, leftNavigationButton: nil, rightNavigationButton: doneButton, backNavigationButton: ItemListBackButton(title: presentationData.strings.Common_Back), animateChanges: true)
|
||||
//TODO:release
|
||||
let listState = ItemListNodeState(presentationData: ItemListPresentationData(presentationData), entries: folderInviteLinkListControllerEntries(
|
||||
presentationData: presentationData,
|
||||
state: state,
|
||||
title: filterTitle.text,
|
||||
title: filterTitle,
|
||||
allPeers: allPeers
|
||||
), style: .blocks, emptyStateItem: nil, crossfadeState: crossfade, animateChanges: animateChanges)
|
||||
|
||||
|
|
|
|||
|
|
@ -11,18 +11,19 @@ import TelegramAnimatedStickerNode
|
|||
import AccountContext
|
||||
import Markdown
|
||||
import TextFormat
|
||||
import TextNodeWithEntities
|
||||
|
||||
public class InviteLinkHeaderItem: ListViewItem, ItemListItem {
|
||||
public let context: AccountContext
|
||||
public let theme: PresentationTheme
|
||||
public let title: String?
|
||||
public let text: String
|
||||
public let text: NSAttributedString
|
||||
public let animationName: String
|
||||
public let hideOnSmallScreens: Bool
|
||||
public let sectionId: ItemListSectionId
|
||||
public let linkAction: ((ItemListTextItemLinkAction) -> Void)?
|
||||
|
||||
public init(context: AccountContext, theme: PresentationTheme, title: String? = nil, text: String, animationName: String, hideOnSmallScreens: Bool = false, sectionId: ItemListSectionId, linkAction: ((ItemListTextItemLinkAction) -> Void)? = nil) {
|
||||
public init(context: AccountContext, theme: PresentationTheme, title: String? = nil, text: NSAttributedString, animationName: String, hideOnSmallScreens: Bool = false, sectionId: ItemListSectionId, linkAction: ((ItemListTextItemLinkAction) -> Void)? = nil) {
|
||||
self.context = context
|
||||
self.theme = theme
|
||||
self.title = title
|
||||
|
|
@ -75,7 +76,7 @@ private let textFont = Font.regular(14.0)
|
|||
|
||||
class InviteLinkHeaderItemNode: ListViewItemNode {
|
||||
private let titleNode: TextNode
|
||||
private let textNode: TextNode
|
||||
private let textNode: TextNodeWithEntities
|
||||
private var animationNode: AnimatedStickerNode
|
||||
|
||||
private var item: InviteLinkHeaderItem?
|
||||
|
|
@ -86,17 +87,17 @@ class InviteLinkHeaderItemNode: ListViewItemNode {
|
|||
self.titleNode.contentMode = .left
|
||||
self.titleNode.contentsScale = UIScreen.main.scale
|
||||
|
||||
self.textNode = TextNode()
|
||||
self.textNode.isUserInteractionEnabled = false
|
||||
self.textNode.contentMode = .left
|
||||
self.textNode.contentsScale = UIScreen.main.scale
|
||||
self.textNode = TextNodeWithEntities()
|
||||
self.textNode.textNode.isUserInteractionEnabled = false
|
||||
self.textNode.textNode.contentMode = .left
|
||||
self.textNode.textNode.contentsScale = UIScreen.main.scale
|
||||
|
||||
self.animationNode = DefaultAnimatedStickerNodeImpl()
|
||||
|
||||
super.init(layerBacked: false, dynamicBounce: false)
|
||||
|
||||
self.addSubnode(self.titleNode)
|
||||
self.addSubnode(self.textNode)
|
||||
self.addSubnode(self.textNode.textNode)
|
||||
self.addSubnode(self.animationNode)
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +113,7 @@ class InviteLinkHeaderItemNode: ListViewItemNode {
|
|||
|
||||
func asyncLayout() -> (_ item: InviteLinkHeaderItem, _ params: ListViewItemLayoutParams, _ neighbors: ItemListNeighbors) -> (ListViewItemNodeLayout, () -> Void) {
|
||||
let makeTitleLayout = TextNode.asyncLayout(self.titleNode)
|
||||
let makeTextLayout = TextNode.asyncLayout(self.textNode)
|
||||
let makeTextLayout = TextNodeWithEntities.asyncLayout(self.textNode)
|
||||
|
||||
return { item, params, neighbors in
|
||||
let leftInset: CGFloat = 24.0 + params.leftInset
|
||||
|
|
@ -131,9 +132,22 @@ class InviteLinkHeaderItemNode: ListViewItemNode {
|
|||
|
||||
let attributedTitle = NSAttributedString(string: item.title ?? "", font: titleFont, textColor: item.theme.list.itemPrimaryTextColor, paragraphAlignment: .center)
|
||||
|
||||
let attributedText = parseMarkdownIntoAttributedString(item.text, attributes: MarkdownAttributes(body: MarkdownAttributeSet(font: textFont, textColor: item.theme.list.freeTextColor), bold: MarkdownAttributeSet(font: Font.semibold(14.0), textColor: item.theme.list.freeTextColor), link: MarkdownAttributeSet(font: textFont, textColor: item.theme.list.itemAccentColor), linkAttribute: { contents in
|
||||
return (TelegramTextAttributes.URL, contents)
|
||||
}))
|
||||
let attributedText = NSMutableAttributedString(string: item.text.string)
|
||||
attributedText.addAttribute(.font, value: Font.regular(14.0), range: NSRange(location: 0, length: attributedText.length))
|
||||
attributedText.addAttribute(.foregroundColor, value: item.theme.list.freeTextColor, range: NSRange(location: 0, length: attributedText.length))
|
||||
item.text.enumerateAttributes(in: NSRange(location: 0, length: item.text.length), using: { attributes, range, _ in
|
||||
for (key, value) in attributes {
|
||||
if key == ChatTextInputAttributes.bold {
|
||||
attributedText.addAttribute(.font, value: Font.semibold(14.0), range: range)
|
||||
} else if key == ChatTextInputAttributes.italic {
|
||||
attributedText.addAttribute(.font, value: Font.italic(14.0), range: range)
|
||||
} else if key == ChatTextInputAttributes.monospace {
|
||||
attributedText.addAttribute(.font, value: Font.monospace(14.0), range: range)
|
||||
} else {
|
||||
attributedText.addAttribute(key, value: value, range: range)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let (titleLayout, titleApply) = makeTitleLayout(TextNodeLayoutArguments(attributedString: attributedTitle, backgroundColor: nil, maximumNumberOfLines: 0, truncationType: .end, constrainedSize: CGSize(width: params.width - leftInset * 2.0, height: CGFloat.greatestFiniteMagnitude), alignment: .center, cutout: nil, insets: UIEdgeInsets()))
|
||||
|
||||
|
|
@ -168,8 +182,15 @@ class InviteLinkHeaderItemNode: ListViewItemNode {
|
|||
origin += titleLayout.size.height + spacing
|
||||
}
|
||||
|
||||
let _ = textApply()
|
||||
strongSelf.textNode.frame = CGRect(origin: CGPoint(x: floor((layout.size.width - textLayout.size.width) / 2.0), y: origin), size: textLayout.size)
|
||||
let _ = textApply(TextNodeWithEntities.Arguments(
|
||||
context: item.context,
|
||||
cache: item.context.animationCache,
|
||||
renderer: item.context.animationRenderer,
|
||||
placeholderColor: item.theme.list.mediaPlaceholderColor,
|
||||
attemptSynchronous: true
|
||||
))
|
||||
strongSelf.textNode.textNode.frame = CGRect(origin: CGPoint(x: floor((layout.size.width - textLayout.size.width) / 2.0), y: origin), size: textLayout.size)
|
||||
strongSelf.textNode.visibilityRect = .infinite
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -189,9 +210,9 @@ class InviteLinkHeaderItemNode: ListViewItemNode {
|
|||
if let (gesture, location) = recognizer.lastRecognizedGestureAndLocation {
|
||||
switch gesture {
|
||||
case .tap:
|
||||
let textFrame = self.textNode.frame
|
||||
let textFrame = self.textNode.textNode.frame
|
||||
if let item = self.item, textFrame.contains(location) {
|
||||
if let (_, attributes) = self.textNode.attributesAtPoint(CGPoint(x: location.x - textFrame.minX, y: location.y - textFrame.minY)) {
|
||||
if let (_, attributes) = self.textNode.textNode.attributesAtPoint(CGPoint(x: location.x - textFrame.minX, y: location.y - textFrame.minY)) {
|
||||
if let url = attributes[NSAttributedString.Key(rawValue: TelegramTextAttributes.URL)] as? String {
|
||||
item.linkAction?(.tap(url))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,7 +56,7 @@ private enum InviteLinksListSection: Int32 {
|
|||
}
|
||||
|
||||
private enum InviteLinksListEntry: ItemListNodeEntry {
|
||||
case header(PresentationTheme, String)
|
||||
case header(PresentationTheme, NSAttributedString)
|
||||
|
||||
case mainLinkHeader(PresentationTheme, String)
|
||||
case mainLink(PresentationTheme, ExportedInvitation?, [EnginePeer], Int32, Bool)
|
||||
|
|
@ -278,7 +278,7 @@ private func inviteLinkListControllerEntries(presentationData: PresentationData,
|
|||
} else {
|
||||
helpText = presentationData.strings.InviteLink_CreatePrivateLinkHelp
|
||||
}
|
||||
entries.append(.header(presentationData.theme, helpText))
|
||||
entries.append(.header(presentationData.theme, NSAttributedString(string: helpText)))
|
||||
}
|
||||
|
||||
let mainInvite: ExportedInvitation?
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ private enum InviteRequestsEntry: ItemListNodeEntry {
|
|||
let arguments = arguments as! InviteRequestsControllerArguments
|
||||
switch self {
|
||||
case let .header(theme, text):
|
||||
return InviteLinkHeaderItem(context: arguments.context, theme: theme, text: text, animationName: "Requests", sectionId: self.section, linkAction: { _ in
|
||||
return InviteLinkHeaderItem(context: arguments.context, theme: theme, text: NSAttributedString(string: text), animationName: "Requests", sectionId: self.section, linkAction: { _ in
|
||||
arguments.openLinks()
|
||||
})
|
||||
case let .requestsHeader(_, text):
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ public func convertOpusToAAC(sourcePath: String, allocateTempFile: @escaping ()
|
|||
|
||||
queue.async {
|
||||
do {
|
||||
let audioSource = SoftwareAudioSource(path: sourcePath, focusedPart: nil)
|
||||
let audioSource = SoftwareAudioSource(path: sourcePath)
|
||||
|
||||
let outputPath = allocateTempFile()
|
||||
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ swift_library(
|
|||
"//submodules/YuvConversion:YuvConversion",
|
||||
"//submodules/Utils/RangeSet:RangeSet",
|
||||
"//submodules/TextFormat:TextFormat",
|
||||
"//submodules/ManagedFile",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import TelegramAudio
|
|||
public final class ChunkMediaPlayerPart {
|
||||
public enum Id: Hashable {
|
||||
case tempFile(path: String)
|
||||
case directFile(path: String, audio: DirectStream?, video: DirectStream?)
|
||||
case directStream
|
||||
}
|
||||
|
||||
public struct DirectStream: Hashable {
|
||||
|
|
@ -26,51 +26,29 @@ public final class ChunkMediaPlayerPart {
|
|||
}
|
||||
}
|
||||
|
||||
public enum Content {
|
||||
public final class TempFile {
|
||||
public let file: TempBoxFile
|
||||
|
||||
public init(file: TempBoxFile) {
|
||||
self.file = file
|
||||
}
|
||||
|
||||
deinit {
|
||||
TempBox.shared.dispose(self.file)
|
||||
}
|
||||
public final class TempFile {
|
||||
public let file: TempBoxFile
|
||||
|
||||
public init(file: TempBoxFile) {
|
||||
self.file = file
|
||||
}
|
||||
|
||||
public final class FFMpegDirectFile {
|
||||
public let path: String
|
||||
public let audio: DirectStream?
|
||||
public let video: DirectStream?
|
||||
|
||||
public init(path: String, audio: DirectStream?, video: DirectStream?) {
|
||||
self.path = path
|
||||
self.audio = audio
|
||||
self.video = video
|
||||
}
|
||||
deinit {
|
||||
TempBox.shared.dispose(self.file)
|
||||
}
|
||||
|
||||
case tempFile(TempFile)
|
||||
case directFile(FFMpegDirectFile)
|
||||
}
|
||||
|
||||
public let startTime: Double
|
||||
public let endTime: Double
|
||||
public let content: Content
|
||||
public let content: TempFile
|
||||
public let clippedStartTime: Double?
|
||||
public let codecName: String?
|
||||
|
||||
public var id: Id {
|
||||
switch self.content {
|
||||
case let .tempFile(tempFile):
|
||||
return .tempFile(path: tempFile.file.path)
|
||||
case let .directFile(directFile):
|
||||
return .directFile(path: directFile.path, audio: directFile.audio, video: directFile.video)
|
||||
}
|
||||
return .tempFile(path: self.content.file.path)
|
||||
}
|
||||
|
||||
public init(startTime: Double, clippedStartTime: Double? = nil, endTime: Double, content: Content, codecName: String?) {
|
||||
public init(startTime: Double, clippedStartTime: Double? = nil, endTime: Double, content: TempFile, codecName: String?) {
|
||||
self.startTime = startTime
|
||||
self.clippedStartTime = clippedStartTime
|
||||
self.endTime = endTime
|
||||
|
|
@ -80,12 +58,63 @@ public final class ChunkMediaPlayerPart {
|
|||
}
|
||||
|
||||
public final class ChunkMediaPlayerPartsState {
|
||||
public let duration: Double?
|
||||
public let parts: [ChunkMediaPlayerPart]
|
||||
public final class DirectReader {
|
||||
public struct Stream {
|
||||
public let mediaBox: MediaBox
|
||||
public let resource: MediaResource
|
||||
public let size: Int64
|
||||
public let index: Int
|
||||
public let seek: (streamIndex: Int, pts: Int64)
|
||||
public let maxReadablePts: (streamIndex: Int, pts: Int64, isEnded: Bool)?
|
||||
public let codecName: String?
|
||||
|
||||
public init(mediaBox: MediaBox, resource: MediaResource, size: Int64, index: Int, seek: (streamIndex: Int, pts: Int64), maxReadablePts: (streamIndex: Int, pts: Int64, isEnded: Bool)?, codecName: String?) {
|
||||
self.mediaBox = mediaBox
|
||||
self.resource = resource
|
||||
self.size = size
|
||||
self.index = index
|
||||
self.seek = seek
|
||||
self.maxReadablePts = maxReadablePts
|
||||
self.codecName = codecName
|
||||
}
|
||||
}
|
||||
|
||||
public final class Impl {
|
||||
public let video: Stream?
|
||||
public let audio: Stream?
|
||||
|
||||
public init(video: Stream?, audio: Stream?) {
|
||||
self.video = video
|
||||
self.audio = audio
|
||||
}
|
||||
}
|
||||
|
||||
public let id: Int
|
||||
public let seekPosition: Double
|
||||
public let availableUntilPosition: Double
|
||||
public let bufferedUntilEnd: Bool
|
||||
public let impl: Impl?
|
||||
|
||||
public init(id: Int, seekPosition: Double, availableUntilPosition: Double, bufferedUntilEnd: Bool, impl: Impl?) {
|
||||
self.id = id
|
||||
self.seekPosition = seekPosition
|
||||
self.availableUntilPosition = availableUntilPosition
|
||||
self.bufferedUntilEnd = bufferedUntilEnd
|
||||
self.impl = impl
|
||||
}
|
||||
}
|
||||
|
||||
public init(duration: Double?, parts: [ChunkMediaPlayerPart]) {
|
||||
public enum Content {
|
||||
case parts([ChunkMediaPlayerPart])
|
||||
case directReader(DirectReader)
|
||||
}
|
||||
|
||||
public let duration: Double?
|
||||
public let content: Content
|
||||
|
||||
public init(duration: Double?, content: Content) {
|
||||
self.duration = duration
|
||||
self.parts = parts
|
||||
self.content = content
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,390 +6,454 @@ import TelegramCore
|
|||
import FFMpegBinding
|
||||
import RangeSet
|
||||
|
||||
private final class FFMpegMediaFrameExtractContext {
|
||||
let fd: Int32
|
||||
var readPosition: Int = 0
|
||||
let size: Int
|
||||
private func FFMpegLookaheadReader_readPacketCallback(userData: UnsafeMutableRawPointer?, buffer: UnsafeMutablePointer<UInt8>?, bufferSize: Int32) -> Int32 {
|
||||
let context = Unmanaged<FFMpegLookaheadReader>.fromOpaque(userData!).takeUnretainedValue()
|
||||
|
||||
var accessedRanges = RangeSet<Int>()
|
||||
var maskRanges: RangeSet<Int>?
|
||||
var recordAccessedRanges = false
|
||||
let readCount = min(256 * 1024, Int64(bufferSize))
|
||||
let requestRange: Range<Int64> = context.readingOffset ..< (context.readingOffset + readCount)
|
||||
|
||||
init(fd: Int32, size: Int) {
|
||||
self.fd = fd
|
||||
self.size = size
|
||||
}
|
||||
}
|
||||
|
||||
private func FFMpegMediaFrameExtractContextReadPacketCallback(userData: UnsafeMutableRawPointer?, buffer: UnsafeMutablePointer<UInt8>?, bufferSize: Int32) -> Int32 {
|
||||
let context = Unmanaged<FFMpegMediaFrameExtractContext>.fromOpaque(userData!).takeUnretainedValue()
|
||||
if context.recordAccessedRanges {
|
||||
context.accessedRanges.insert(contentsOf: context.readPosition ..< (context.readPosition + Int(bufferSize)))
|
||||
var fetchedData: Data?
|
||||
let fetchDisposable = MetaDisposable()
|
||||
|
||||
let semaphore = DispatchSemaphore(value: 0)
|
||||
let disposable = context.params.getDataInRange(requestRange, { data in
|
||||
if let data {
|
||||
fetchedData = data
|
||||
semaphore.signal()
|
||||
}
|
||||
})
|
||||
var isCancelled = false
|
||||
let cancelDisposable = context.params.cancel.start(next: { _ in
|
||||
isCancelled = true
|
||||
semaphore.signal()
|
||||
})
|
||||
semaphore.wait()
|
||||
|
||||
if isCancelled {
|
||||
context.isCancelled = true
|
||||
}
|
||||
|
||||
let result: Int
|
||||
if let maskRanges = context.maskRanges {
|
||||
let readRange = context.readPosition ..< (context.readPosition + Int(bufferSize))
|
||||
let _ = maskRanges
|
||||
let _ = readRange
|
||||
result = read(context.fd, buffer, Int(bufferSize))
|
||||
disposable.dispose()
|
||||
cancelDisposable.dispose()
|
||||
fetchDisposable.dispose()
|
||||
|
||||
if let fetchedData = fetchedData {
|
||||
fetchedData.withUnsafeBytes { byteBuffer -> Void in
|
||||
guard let bytes = byteBuffer.baseAddress?.assumingMemoryBound(to: UInt8.self) else {
|
||||
return
|
||||
}
|
||||
memcpy(buffer, bytes, fetchedData.count)
|
||||
}
|
||||
let fetchedCount = Int32(fetchedData.count)
|
||||
context.setReadingOffset(offset: context.readingOffset + Int64(fetchedCount))
|
||||
if fetchedCount == 0 {
|
||||
return FFMPEG_CONSTANT_AVERROR_EOF
|
||||
}
|
||||
return fetchedCount
|
||||
} else {
|
||||
result = read(context.fd, buffer, Int(bufferSize))
|
||||
}
|
||||
context.readPosition += Int(bufferSize)
|
||||
if result == 0 {
|
||||
return FFMPEG_CONSTANT_AVERROR_EOF
|
||||
}
|
||||
return Int32(result)
|
||||
}
|
||||
|
||||
private func FFMpegMediaFrameExtractContextSeekCallback(userData: UnsafeMutableRawPointer?, offset: Int64, whence: Int32) -> Int64 {
|
||||
let context = Unmanaged<FFMpegMediaFrameExtractContext>.fromOpaque(userData!).takeUnretainedValue()
|
||||
private func FFMpegLookaheadReader_seekCallback(userData: UnsafeMutableRawPointer?, offset: Int64, whence: Int32) -> Int64 {
|
||||
let context = Unmanaged<FFMpegLookaheadReader>.fromOpaque(userData!).takeUnretainedValue()
|
||||
if (whence & FFMPEG_AVSEEK_SIZE) != 0 {
|
||||
return Int64(context.size)
|
||||
return context.params.size
|
||||
} else {
|
||||
context.readPosition = Int(offset)
|
||||
lseek(context.fd, off_t(offset), SEEK_SET)
|
||||
context.setReadingOffset(offset: offset)
|
||||
|
||||
return offset
|
||||
}
|
||||
}
|
||||
|
||||
private struct FFMpegFrameSegment {
|
||||
struct Stream {
|
||||
let index: Int
|
||||
let startPts: CMTime
|
||||
let startPosition: Int64
|
||||
var endPts: CMTime
|
||||
var endPosition: Int64
|
||||
var duration: Double
|
||||
}
|
||||
|
||||
var audio: Stream?
|
||||
var video: Stream?
|
||||
|
||||
init() {
|
||||
}
|
||||
|
||||
mutating func addFrame(isVideo: Bool, index: Int, pts: CMTime, duration: Double, position: Int64, size: Int64) {
|
||||
if var stream = isVideo ? self.video : self.audio {
|
||||
stream.endPts = pts
|
||||
stream.duration += duration
|
||||
stream.endPosition = max(stream.endPosition, position + size)
|
||||
if isVideo {
|
||||
self.video = stream
|
||||
} else {
|
||||
self.audio = stream
|
||||
}
|
||||
} else {
|
||||
let stream = Stream(index: index, startPts: pts, startPosition: position, endPts: pts, endPosition: position + size, duration: duration)
|
||||
if isVideo {
|
||||
self.video = stream
|
||||
} else {
|
||||
self.audio = stream
|
||||
}
|
||||
}
|
||||
}
|
||||
private func range(_ outer: Range<Int64>, fullyContains inner: Range<Int64>) -> Bool {
|
||||
return inner.lowerBound >= outer.lowerBound && inner.upperBound <= outer.upperBound
|
||||
}
|
||||
|
||||
private final class FFMpegFrameSegmentInfo {
|
||||
let headerAccessRanges: RangeSet<Int>
|
||||
let segments: [FFMpegFrameSegment]
|
||||
private final class FFMpegLookaheadReader {
|
||||
let params: FFMpegLookaheadThread.Params
|
||||
|
||||
init(headerAccessRanges: RangeSet<Int>, segments: [FFMpegFrameSegment]) {
|
||||
self.headerAccessRanges = headerAccessRanges
|
||||
self.segments = segments
|
||||
}
|
||||
}
|
||||
|
||||
private func extractFFMpegFrameSegmentInfo(path: String) -> FFMpegFrameSegmentInfo? {
|
||||
let _ = FFMpegMediaFrameSourceContextHelpers.registerFFMpegGlobals
|
||||
var avIoContext: FFMpegAVIOContext?
|
||||
var avFormatContext: FFMpegAVFormatContext?
|
||||
|
||||
var s = stat()
|
||||
stat(path, &s)
|
||||
let size = Int32(s.st_size)
|
||||
var audioStream: FFMpegFileReader.StreamInfo?
|
||||
var videoStream: FFMpegFileReader.StreamInfo?
|
||||
|
||||
let fd = open(path, O_RDONLY, S_IRUSR)
|
||||
if fd < 0 {
|
||||
return nil
|
||||
}
|
||||
defer {
|
||||
close(fd)
|
||||
}
|
||||
var seekInfo: FFMpegLookaheadThread.State.Seek?
|
||||
var maxReadPts: FFMpegLookaheadThread.State.Seek?
|
||||
var audioStreamState: FFMpegLookaheadThread.StreamState?
|
||||
var videoStreamState: FFMpegLookaheadThread.StreamState?
|
||||
|
||||
let avFormatContext = FFMpegAVFormatContext()
|
||||
let ioBufferSize = 32 * 1024
|
||||
var reportedState: FFMpegLookaheadThread.State?
|
||||
|
||||
let context = FFMpegMediaFrameExtractContext(fd: fd, size: Int(size))
|
||||
context.recordAccessedRanges = true
|
||||
var readingOffset: Int64 = 0
|
||||
var isCancelled: Bool = false
|
||||
var isEnded: Bool = false
|
||||
|
||||
guard let avIoContext = FFMpegAVIOContext(bufferSize: Int32(ioBufferSize), opaqueContext: Unmanaged.passUnretained(context).toOpaque(), readPacket: FFMpegMediaFrameExtractContextReadPacketCallback, writePacket: nil, seek: FFMpegMediaFrameExtractContextSeekCallback, isSeekable: true) else {
|
||||
return nil
|
||||
}
|
||||
private var currentFetchRange: Range<Int64>?
|
||||
private var currentFetchDisposable: Disposable?
|
||||
|
||||
avFormatContext.setIO(avIoContext)
|
||||
var currentTimestamp: Double?
|
||||
|
||||
if !avFormatContext.openInput(withDirectFilePath: nil) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !avFormatContext.findStreamInfo() {
|
||||
return nil
|
||||
}
|
||||
|
||||
var audioStream: FFMpegMediaInfo.Info?
|
||||
var videoStream: FFMpegMediaInfo.Info?
|
||||
|
||||
for typeIndex in 0 ..< 2 {
|
||||
let isVideo = typeIndex == 0
|
||||
init?(params: FFMpegLookaheadThread.Params) {
|
||||
self.params = params
|
||||
|
||||
for streamIndexNumber in avFormatContext.streamIndices(for: isVideo ? FFMpegAVFormatStreamTypeVideo : FFMpegAVFormatStreamTypeAudio) {
|
||||
let streamIndex = streamIndexNumber.int32Value
|
||||
if avFormatContext.isAttachedPic(atStreamIndex: streamIndex) {
|
||||
continue
|
||||
}
|
||||
|
||||
let fpsAndTimebase = avFormatContext.fpsAndTimebase(forStreamIndex: streamIndex, defaultTimeBase: CMTimeMake(value: 1, timescale: 40000))
|
||||
let (fps, timebase) = (fpsAndTimebase.fps, fpsAndTimebase.timebase)
|
||||
|
||||
let startTime: CMTime
|
||||
let rawStartTime = avFormatContext.startTime(atStreamIndex: streamIndex)
|
||||
if rawStartTime == Int64(bitPattern: 0x8000000000000000 as UInt64) {
|
||||
startTime = CMTime(value: 0, timescale: timebase.timescale)
|
||||
} else {
|
||||
startTime = CMTimeMake(value: rawStartTime, timescale: timebase.timescale)
|
||||
}
|
||||
var duration = CMTimeMake(value: avFormatContext.duration(atStreamIndex: streamIndex), timescale: timebase.timescale)
|
||||
duration = CMTimeMaximum(CMTime(value: 0, timescale: duration.timescale), CMTimeSubtract(duration, startTime))
|
||||
|
||||
var codecName: String?
|
||||
let codecId = avFormatContext.codecId(atStreamIndex: streamIndex)
|
||||
if codecId == FFMpegCodecIdMPEG4 {
|
||||
codecName = "mpeg4"
|
||||
} else if codecId == FFMpegCodecIdH264 {
|
||||
codecName = "h264"
|
||||
} else if codecId == FFMpegCodecIdHEVC {
|
||||
codecName = "hevc"
|
||||
} else if codecId == FFMpegCodecIdAV1 {
|
||||
codecName = "av1"
|
||||
} else if codecId == FFMpegCodecIdVP9 {
|
||||
codecName = "vp9"
|
||||
} else if codecId == FFMpegCodecIdVP8 {
|
||||
codecName = "vp8"
|
||||
}
|
||||
|
||||
let info = FFMpegMediaInfo.Info(
|
||||
index: Int(streamIndex),
|
||||
timescale: timebase.timescale,
|
||||
startTime: startTime,
|
||||
duration: duration,
|
||||
fps: fps,
|
||||
codecName: codecName
|
||||
)
|
||||
|
||||
if isVideo {
|
||||
videoStream = info
|
||||
} else {
|
||||
audioStream = info
|
||||
}
|
||||
let ioBufferSize = 64 * 1024
|
||||
|
||||
guard let avIoContext = FFMpegAVIOContext(bufferSize: Int32(ioBufferSize), opaqueContext: Unmanaged.passUnretained(self).toOpaque(), readPacket: FFMpegLookaheadReader_readPacketCallback, writePacket: nil, seek: FFMpegLookaheadReader_seekCallback, isSeekable: true) else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var segments: [FFMpegFrameSegment] = []
|
||||
let maxSegmentDuration: Double = 5.0
|
||||
|
||||
if let videoStream {
|
||||
let indexEntryCount = avFormatContext.numberOfIndexEntries(atStreamIndex: Int32(videoStream.index))
|
||||
self.avIoContext = avIoContext
|
||||
|
||||
if indexEntryCount > 0 {
|
||||
let frameDuration = 1.0 / videoStream.fps.seconds
|
||||
|
||||
var indexEntry = FFMpegAVIndexEntry()
|
||||
for i in 0 ..< indexEntryCount {
|
||||
if !avFormatContext.fillIndexEntry(atStreamIndex: Int32(videoStream.index), entryIndex: Int32(i), outEntry: &indexEntry) {
|
||||
let avFormatContext = FFMpegAVFormatContext()
|
||||
avFormatContext.setIO(avIoContext)
|
||||
|
||||
if !avFormatContext.openInput(withDirectFilePath: nil) {
|
||||
return nil
|
||||
}
|
||||
if !avFormatContext.findStreamInfo() {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.avFormatContext = avFormatContext
|
||||
|
||||
var audioStream: FFMpegFileReader.StreamInfo?
|
||||
var videoStream: FFMpegFileReader.StreamInfo?
|
||||
|
||||
for streamType in 0 ..< 2 {
|
||||
let isVideo = streamType == 0
|
||||
for streamIndexNumber in avFormatContext.streamIndices(for: isVideo ? FFMpegAVFormatStreamTypeVideo : FFMpegAVFormatStreamTypeAudio) {
|
||||
let streamIndex = streamIndexNumber.int32Value
|
||||
if avFormatContext.isAttachedPic(atStreamIndex: streamIndex) {
|
||||
continue
|
||||
}
|
||||
|
||||
let packetPts = CMTime(value: indexEntry.timestamp, timescale: videoStream.timescale)
|
||||
//print("index: \(packetPts.seconds), isKeyframe: \(indexEntry.isKeyframe), position: \(indexEntry.pos), size: \(indexEntry.size)")
|
||||
let codecId = avFormatContext.codecId(atStreamIndex: streamIndex)
|
||||
|
||||
var startNewSegment = segments.isEmpty
|
||||
if indexEntry.isKeyframe {
|
||||
if segments.isEmpty {
|
||||
startNewSegment = true
|
||||
} else if let video = segments[segments.count - 1].video {
|
||||
if packetPts.seconds - video.startPts.seconds > maxSegmentDuration {
|
||||
startNewSegment = true
|
||||
}
|
||||
}
|
||||
}
|
||||
let fpsAndTimebase = avFormatContext.fpsAndTimebase(forStreamIndex: streamIndex, defaultTimeBase: CMTimeMake(value: 1, timescale: 40000))
|
||||
let (fps, timebase) = (fpsAndTimebase.fps, fpsAndTimebase.timebase)
|
||||
|
||||
if startNewSegment {
|
||||
segments.append(FFMpegFrameSegment())
|
||||
let startTime: CMTime
|
||||
let rawStartTime = avFormatContext.startTime(atStreamIndex: streamIndex)
|
||||
if rawStartTime == Int64(bitPattern: 0x8000000000000000 as UInt64) {
|
||||
startTime = CMTime(value: 0, timescale: timebase.timescale)
|
||||
} else {
|
||||
startTime = CMTimeMake(value: rawStartTime, timescale: timebase.timescale)
|
||||
}
|
||||
segments[segments.count - 1].addFrame(isVideo: true, index: videoStream.index, pts: packetPts, duration: frameDuration, position: indexEntry.pos, size: Int64(indexEntry.size))
|
||||
}
|
||||
if !segments.isEmpty, let video = segments[segments.count - 1].video {
|
||||
if video.endPts.seconds + 1.0 / videoStream.fps.seconds + 0.001 < videoStream.duration.seconds {
|
||||
segments[segments.count - 1].video?.duration = videoStream.duration.seconds - video.startPts.seconds
|
||||
segments[segments.count - 1].video?.endPts = videoStream.duration
|
||||
var duration = CMTimeMake(value: avFormatContext.duration(atStreamIndex: streamIndex), timescale: timebase.timescale)
|
||||
duration = CMTimeMaximum(CMTime(value: 0, timescale: duration.timescale), CMTimeSubtract(duration, startTime))
|
||||
|
||||
//let metrics = avFormatContext.metricsForStream(at: streamIndex)
|
||||
//let rotationAngle: Double = metrics.rotationAngle
|
||||
//let aspect = Double(metrics.width) / Double(metrics.height)
|
||||
|
||||
let stream = FFMpegFileReader.StreamInfo(
|
||||
index: streamIndexNumber.intValue,
|
||||
codecId: codecId,
|
||||
startTime: startTime,
|
||||
duration: duration,
|
||||
timeBase: timebase.value,
|
||||
timeScale: timebase.timescale,
|
||||
fps: fps
|
||||
)
|
||||
|
||||
if isVideo {
|
||||
videoStream = stream
|
||||
} else {
|
||||
audioStream = stream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.audioStream = audioStream
|
||||
self.videoStream = videoStream
|
||||
|
||||
if let preferredStream = self.videoStream ?? self.audioStream {
|
||||
let pts = CMTimeMakeWithSeconds(params.seekToTimestamp, preferredTimescale: preferredStream.timeScale)
|
||||
self.seekInfo = FFMpegLookaheadThread.State.Seek(streamIndex: preferredStream.index, pts: pts.value)
|
||||
avFormatContext.seekFrame(forStreamIndex: Int32(preferredStream.index), pts: pts.value, positionOnKeyframe: true)
|
||||
}
|
||||
|
||||
self.updateCurrentTimestamp()
|
||||
}
|
||||
if let audioStream {
|
||||
let indexEntryCount = avFormatContext.numberOfIndexEntries(atStreamIndex: Int32(audioStream.index))
|
||||
if indexEntryCount > 0 {
|
||||
var minSegmentIndex = 0
|
||||
var minSegmentStartTime: Double = -100000.0
|
||||
|
||||
let frameDuration = 1.0 / audioStream.fps.seconds
|
||||
|
||||
var indexEntry = FFMpegAVIndexEntry()
|
||||
for i in 0 ..< indexEntryCount {
|
||||
if !avFormatContext.fillIndexEntry(atStreamIndex: Int32(audioStream.index), entryIndex: Int32(i), outEntry: &indexEntry) {
|
||||
continue
|
||||
}
|
||||
|
||||
let packetPts = CMTime(value: indexEntry.timestamp, timescale: audioStream.timescale)
|
||||
//print("index: \(packetPts.value), timestamp: \(packetPts.seconds), isKeyframe: \(indexEntry.isKeyframe), position: \(indexEntry.pos), size: \(indexEntry.size)")
|
||||
|
||||
if videoStream != nil {
|
||||
for i in minSegmentIndex ..< segments.count {
|
||||
if let video = segments[i].video {
|
||||
if minSegmentStartTime <= packetPts.seconds && video.endPts.seconds >= packetPts.seconds {
|
||||
segments[i].addFrame(isVideo: false, index: audioStream.index, pts: packetPts, duration: frameDuration, position: indexEntry.pos, size: Int64(indexEntry.size))
|
||||
if minSegmentIndex != i {
|
||||
minSegmentIndex = i
|
||||
minSegmentStartTime = video.startPts.seconds
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.currentFetchDisposable?.dispose()
|
||||
}
|
||||
|
||||
func setReadingOffset(offset: Int64) {
|
||||
self.readingOffset = offset
|
||||
|
||||
let readRange: Range<Int64> = offset ..< (offset + 512 * 1024)
|
||||
if !self.params.isDataCachedInRange(readRange) {
|
||||
if let currentFetchRange = self.currentFetchRange {
|
||||
if currentFetchRange.overlaps(readRange) {
|
||||
if !range(currentFetchRange, fullyContains: readRange) {
|
||||
self.setFetchRange(range: currentFetchRange.lowerBound ..< max(currentFetchRange.upperBound, readRange.upperBound + 2 * 1024 * 1024))
|
||||
}
|
||||
} else {
|
||||
if segments.isEmpty {
|
||||
segments.append(FFMpegFrameSegment())
|
||||
}
|
||||
segments[segments.count - 1].addFrame(isVideo: false, index: audioStream.index, pts: packetPts, duration: frameDuration, position: indexEntry.pos, size: Int64(indexEntry.size))
|
||||
self.setFetchRange(range: offset ..< (offset + 2 * 1024 * 1024))
|
||||
}
|
||||
}
|
||||
}
|
||||
if !segments.isEmpty, let audio = segments[segments.count - 1].audio {
|
||||
if audio.endPts.seconds + 0.001 < audioStream.duration.seconds {
|
||||
segments[segments.count - 1].audio?.duration = audioStream.duration.seconds - audio.startPts.seconds
|
||||
segments[segments.count - 1].audio?.endPts = audioStream.duration
|
||||
} else {
|
||||
self.setFetchRange(range: offset ..< (offset + 2 * 1024 * 1024))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let headerAccessRanges = context.accessedRanges
|
||||
private func setFetchRange(range: Range<Int64>) {
|
||||
if self.currentFetchRange != range {
|
||||
self.currentFetchRange = range
|
||||
|
||||
self.currentFetchDisposable?.dispose()
|
||||
self.currentFetchDisposable = self.params.fetchInRange(range)
|
||||
}
|
||||
}
|
||||
|
||||
for i in 1 ..< segments.count {
|
||||
let segment = segments[i]
|
||||
func updateCurrentTimestamp() {
|
||||
self.currentTimestamp = self.params.currentTimestamp.with({ $0 })
|
||||
|
||||
if let video = segment.video {
|
||||
context.maskRanges = headerAccessRanges
|
||||
context.maskRanges?.insert(contentsOf: Int(video.startPosition) ..< Int(video.endPosition))
|
||||
self.updateReadIfNeeded()
|
||||
}
|
||||
|
||||
private func updateReadIfNeeded() {
|
||||
guard let avFormatContext = self.avFormatContext else {
|
||||
return
|
||||
}
|
||||
guard let currentTimestamp = self.currentTimestamp else {
|
||||
return
|
||||
}
|
||||
|
||||
let maxPtsSeconds = max(self.params.seekToTimestamp, currentTimestamp) + 10.0
|
||||
|
||||
var currentAudioPtsSecondsAdvanced: Double = 0.0
|
||||
var currentVideoPtsSecondsAdvanced: Double = 0.0
|
||||
|
||||
let packet = FFMpegPacket()
|
||||
while !self.isCancelled && !self.isEnded {
|
||||
var audioAlreadyRead: Bool = false
|
||||
var videoAlreadyRead: Bool = false
|
||||
|
||||
context.accessedRanges = RangeSet()
|
||||
context.recordAccessedRanges = true
|
||||
|
||||
avFormatContext.seekFrame(forStreamIndex: Int32(video.index), byteOffset: video.startPosition)
|
||||
|
||||
let packet = FFMpegPacket()
|
||||
while true {
|
||||
if !avFormatContext.readFrame(into: packet) {
|
||||
break
|
||||
}
|
||||
|
||||
if Int(packet.streamIndex) == video.index {
|
||||
let packetPts = CMTime(value: packet.pts, timescale: video.startPts.timescale)
|
||||
if packetPts.value >= video.endPts.value {
|
||||
break
|
||||
}
|
||||
if let audioStreamState = self.audioStreamState {
|
||||
if audioStreamState.readableToTime.seconds >= maxPtsSeconds {
|
||||
audioAlreadyRead = true
|
||||
}
|
||||
} else if self.audioStream == nil {
|
||||
audioAlreadyRead = true
|
||||
}
|
||||
|
||||
print("Segment \(i): \(video.startPosition) ..< \(video.endPosition) accessed \(context.accessedRanges.ranges)")
|
||||
if let videoStreamState = self.videoStreamState {
|
||||
if videoStreamState.readableToTime.seconds >= maxPtsSeconds {
|
||||
videoAlreadyRead = true
|
||||
}
|
||||
} else if self.videoStream == nil {
|
||||
videoAlreadyRead = true
|
||||
}
|
||||
|
||||
if audioAlreadyRead && videoAlreadyRead {
|
||||
break
|
||||
}
|
||||
|
||||
if !avFormatContext.readFrame(into: packet) {
|
||||
self.isEnded = true
|
||||
break
|
||||
}
|
||||
|
||||
self.maxReadPts = FFMpegLookaheadThread.State.Seek(streamIndex: Int(packet.streamIndex), pts: packet.pts)
|
||||
|
||||
if let audioStream = self.audioStream, Int(packet.streamIndex) == audioStream.index {
|
||||
let pts = CMTimeMake(value: packet.pts, timescale: audioStream.timeScale)
|
||||
if let audioStreamState = self.audioStreamState {
|
||||
currentAudioPtsSecondsAdvanced += pts.seconds - audioStreamState.readableToTime.seconds
|
||||
}
|
||||
self.audioStreamState = FFMpegLookaheadThread.StreamState(
|
||||
info: audioStream,
|
||||
readableToTime: pts
|
||||
)
|
||||
} else if let videoStream = self.videoStream, Int(packet.streamIndex) == videoStream.index {
|
||||
let pts = CMTimeMake(value: packet.pts, timescale: videoStream.timeScale)
|
||||
if let videoStreamState = self.videoStreamState {
|
||||
currentVideoPtsSecondsAdvanced += pts.seconds - videoStreamState.readableToTime.seconds
|
||||
}
|
||||
self.videoStreamState = FFMpegLookaheadThread.StreamState(
|
||||
info: videoStream,
|
||||
readableToTime: pts
|
||||
)
|
||||
}
|
||||
|
||||
if min(currentAudioPtsSecondsAdvanced, currentVideoPtsSecondsAdvanced) >= 0.1 {
|
||||
self.reportStateIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
self.reportStateIfNeeded()
|
||||
}
|
||||
|
||||
private func reportStateIfNeeded() {
|
||||
guard let seekInfo = self.seekInfo else {
|
||||
return
|
||||
}
|
||||
var stateIsFullyInitialised = true
|
||||
if self.audioStream != nil && self.audioStreamState == nil {
|
||||
stateIsFullyInitialised = false
|
||||
}
|
||||
if self.videoStream != nil && self.videoStreamState == nil {
|
||||
stateIsFullyInitialised = false
|
||||
}
|
||||
|
||||
let state = FFMpegLookaheadThread.State(
|
||||
seek: seekInfo,
|
||||
maxReadablePts: self.maxReadPts,
|
||||
audio: (stateIsFullyInitialised && self.maxReadPts != nil) ? self.audioStreamState : nil,
|
||||
video: (stateIsFullyInitialised && self.maxReadPts != nil) ? self.videoStreamState : nil,
|
||||
isEnded: self.isEnded
|
||||
)
|
||||
if self.reportedState != state {
|
||||
self.reportedState = state
|
||||
self.params.updateState(state)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class FFMpegLookaheadThread: NSObject {
|
||||
struct StreamState: Equatable {
|
||||
let info: FFMpegFileReader.StreamInfo
|
||||
let readableToTime: CMTime
|
||||
|
||||
init(info: FFMpegFileReader.StreamInfo, readableToTime: CMTime) {
|
||||
self.info = info
|
||||
self.readableToTime = readableToTime
|
||||
}
|
||||
}
|
||||
|
||||
/*{
|
||||
if let videoStream {
|
||||
avFormatContext.seekFrame(forStreamIndex: Int32(videoStream.index), pts: 0, positionOnKeyframe: true)
|
||||
struct State: Equatable {
|
||||
struct Seek: Equatable {
|
||||
var streamIndex: Int
|
||||
var pts: Int64
|
||||
|
||||
let packet = FFMpegPacket()
|
||||
while true {
|
||||
if !avFormatContext.readFrame(into: packet) {
|
||||
break
|
||||
}
|
||||
|
||||
if Int(packet.streamIndex) == videoStream.index {
|
||||
let packetPts = CMTime(value: packet.pts, timescale: videoStream.timescale)
|
||||
let packetDuration = CMTime(value: packet.duration, timescale: videoStream.timescale)
|
||||
|
||||
var startNewSegment = segments.isEmpty
|
||||
if packet.isKeyframe {
|
||||
if segments.isEmpty {
|
||||
startNewSegment = true
|
||||
} else if let video = segments[segments.count - 1].video {
|
||||
if packetPts.seconds - video.startPts.seconds > maxSegmentDuration {
|
||||
startNewSegment = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if startNewSegment {
|
||||
segments.append(FFMpegFrameSegment())
|
||||
}
|
||||
segments[segments.count - 1].addFrame(isVideo: true, index: Int(packet.streamIndex), pts: packetPts, duration: packetDuration.seconds)
|
||||
}
|
||||
init(streamIndex: Int, pts: Int64) {
|
||||
self.streamIndex = streamIndex
|
||||
self.pts = pts
|
||||
}
|
||||
}
|
||||
if let audioStream {
|
||||
avFormatContext.seekFrame(forStreamIndex: Int32(audioStream.index), pts: 0, positionOnKeyframe: true)
|
||||
|
||||
var minSegmentIndex = 0
|
||||
|
||||
let packet = FFMpegPacket()
|
||||
while true {
|
||||
if !avFormatContext.readFrame(into: packet) {
|
||||
break
|
||||
}
|
||||
|
||||
if Int(packet.streamIndex) == audioStream.index {
|
||||
let packetPts = CMTime(value: packet.pts, timescale: audioStream.timescale)
|
||||
let packetDuration = CMTime(value: packet.duration, timescale: audioStream.timescale)
|
||||
|
||||
if videoStream != nil {
|
||||
for i in minSegmentIndex ..< segments.count {
|
||||
if let video = segments[i].video {
|
||||
if video.startPts.seconds <= packetPts.seconds && video.endPts.seconds >= packetPts.seconds {
|
||||
segments[i].addFrame(isVideo: false, index: Int(audioStream.index), pts: packetPts, duration: packetDuration.seconds)
|
||||
minSegmentIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if segments.isEmpty {
|
||||
segments.append(FFMpegFrameSegment())
|
||||
}
|
||||
segments[segments.count - 1].addFrame(isVideo: false, index: Int(packet.streamIndex), pts: packetPts, duration: packetDuration.seconds)
|
||||
}
|
||||
}
|
||||
|
||||
let seek: Seek
|
||||
let maxReadablePts: Seek?
|
||||
let audio: StreamState?
|
||||
let video: StreamState?
|
||||
let isEnded: Bool
|
||||
|
||||
init(seek: Seek, maxReadablePts: Seek?, audio: StreamState?, video: StreamState?, isEnded: Bool) {
|
||||
self.seek = seek
|
||||
self.maxReadablePts = maxReadablePts
|
||||
self.audio = audio
|
||||
self.video = video
|
||||
self.isEnded = isEnded
|
||||
}
|
||||
}
|
||||
|
||||
final class Params: NSObject {
|
||||
let seekToTimestamp: Double
|
||||
let updateState: (State) -> Void
|
||||
let fetchInRange: (Range<Int64>) -> Disposable
|
||||
let getDataInRange: (Range<Int64>, @escaping (Data?) -> Void) -> Disposable
|
||||
let isDataCachedInRange: (Range<Int64>) -> Bool
|
||||
let size: Int64
|
||||
let cancel: Signal<Void, NoError>
|
||||
let currentTimestamp: Atomic<Double?>
|
||||
|
||||
init(
|
||||
seekToTimestamp: Double,
|
||||
updateState: @escaping (State) -> Void,
|
||||
fetchInRange: @escaping (Range<Int64>) -> Disposable,
|
||||
getDataInRange: @escaping (Range<Int64>, @escaping (Data?) -> Void) -> Disposable,
|
||||
isDataCachedInRange: @escaping (Range<Int64>) -> Bool,
|
||||
size: Int64,
|
||||
cancel: Signal<Void, NoError>,
|
||||
currentTimestamp: Atomic<Double?>
|
||||
) {
|
||||
self.seekToTimestamp = seekToTimestamp
|
||||
self.updateState = updateState
|
||||
self.fetchInRange = fetchInRange
|
||||
self.getDataInRange = getDataInRange
|
||||
self.isDataCachedInRange = isDataCachedInRange
|
||||
self.size = size
|
||||
self.cancel = cancel
|
||||
self.currentTimestamp = currentTimestamp
|
||||
}
|
||||
}
|
||||
|
||||
@objc static func entryPoint(_ params: Params) {
|
||||
let runLoop = RunLoop.current
|
||||
|
||||
let timer = Timer(fireAt: .distantFuture, interval: 0.0, target: FFMpegLookaheadThread.self, selector: #selector(FFMpegLookaheadThread.none), userInfo: nil, repeats: false)
|
||||
runLoop.add(timer, forMode: .common)
|
||||
|
||||
Thread.current.threadDictionary["FFMpegLookaheadThread_reader"] = FFMpegLookaheadReader(params: params)
|
||||
|
||||
while true {
|
||||
runLoop.run(mode: .default, before: .distantFuture)
|
||||
if Thread.current.threadDictionary["FFMpegLookaheadThread_stop"] != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
}*/
|
||||
|
||||
Thread.current.threadDictionary.removeObject(forKey: "FFMpegLookaheadThread_params")
|
||||
}
|
||||
|
||||
/*for i in 0 ..< segments.count {
|
||||
print("Segment \(i):\n video \(segments[i].video?.startPts.seconds ?? -1.0) ... \(segments[i].video?.endPts.seconds ?? -1.0)\n audio \(segments[i].audio?.startPts.seconds ?? -1.0) ... \(segments[i].audio?.endPts.seconds ?? -1.0)")
|
||||
}*/
|
||||
@objc static func none() {
|
||||
}
|
||||
|
||||
return FFMpegFrameSegmentInfo(
|
||||
headerAccessRanges: context.accessedRanges,
|
||||
segments: segments
|
||||
)
|
||||
@objc static func stop() {
|
||||
Thread.current.threadDictionary["FFMpegLookaheadThread_stop"] = "true"
|
||||
}
|
||||
|
||||
@objc static func updateCurrentTimestamp() {
|
||||
if let reader = Thread.current.threadDictionary["FFMpegLookaheadThread_reader"] as? FFMpegLookaheadReader {
|
||||
reader.updateCurrentTimestamp()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class FFMpegLookahead {
|
||||
private let cancel = Promise<Void>()
|
||||
private let currentTimestamp = Atomic<Double?>(value: nil)
|
||||
private let thread: Thread
|
||||
|
||||
init(
|
||||
seekToTimestamp: Double,
|
||||
updateState: @escaping (FFMpegLookaheadThread.State) -> Void,
|
||||
fetchInRange: @escaping (Range<Int64>) -> Disposable,
|
||||
getDataInRange: @escaping (Range<Int64>, @escaping (Data?) -> Void) -> Disposable,
|
||||
isDataCachedInRange: @escaping (Range<Int64>) -> Bool,
|
||||
size: Int64
|
||||
) {
|
||||
self.thread = Thread(
|
||||
target: FFMpegLookaheadThread.self,
|
||||
selector: #selector(FFMpegLookaheadThread.entryPoint(_:)),
|
||||
object: FFMpegLookaheadThread.Params(
|
||||
seekToTimestamp: seekToTimestamp,
|
||||
updateState: updateState,
|
||||
fetchInRange: fetchInRange,
|
||||
getDataInRange: getDataInRange,
|
||||
isDataCachedInRange: isDataCachedInRange,
|
||||
size: size,
|
||||
cancel: self.cancel.get(),
|
||||
currentTimestamp: self.currentTimestamp
|
||||
)
|
||||
)
|
||||
self.thread.name = "FFMpegLookahead"
|
||||
self.thread.start()
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.cancel.set(.single(Void()))
|
||||
FFMpegLookaheadThread.self.perform(#selector(FFMpegLookaheadThread.stop), on: self.thread, with: nil, waitUntilDone: false)
|
||||
}
|
||||
|
||||
func updateCurrentTimestamp(timestamp: Double) {
|
||||
let _ = self.currentTimestamp.swap(timestamp)
|
||||
FFMpegLookaheadThread.self.perform(#selector(FFMpegLookaheadThread.updateCurrentTimestamp), on: self.thread, with: timestamp as NSNumber, waitUntilDone: false)
|
||||
}
|
||||
}
|
||||
|
||||
final class ChunkMediaPlayerDirectFetchSourceImpl: ChunkMediaPlayerSourceImpl {
|
||||
|
|
@ -401,7 +465,10 @@ final class ChunkMediaPlayerDirectFetchSourceImpl: ChunkMediaPlayerSourceImpl {
|
|||
}
|
||||
|
||||
private var completeFetchDisposable: Disposable?
|
||||
private var dataDisposable: Disposable?
|
||||
|
||||
private var seekTimestamp: Double?
|
||||
private var currentLookaheadId: Int = 0
|
||||
private var lookahead: FFMpegLookahead?
|
||||
|
||||
init(resource: ChunkMediaPlayerV2.SourceDescription.ResourceDescription) {
|
||||
self.resource = resource
|
||||
|
|
@ -416,71 +483,134 @@ final class ChunkMediaPlayerDirectFetchSourceImpl: ChunkMediaPlayerSourceImpl {
|
|||
preferBackgroundReferenceRevalidation: true
|
||||
).startStrict()
|
||||
}
|
||||
|
||||
self.dataDisposable = (resource.postbox.mediaBox.resourceData(resource.reference.resource)
|
||||
|> deliverOnMainQueue).startStrict(next: { [weak self] data in
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
if data.complete {
|
||||
if let mediaInfo = extractFFMpegMediaInfo(path: data.path), let mainTrack = mediaInfo.audio ?? mediaInfo.video, let segmentInfo = extractFFMpegFrameSegmentInfo(path: data.path) {
|
||||
var parts: [ChunkMediaPlayerPart] = []
|
||||
for segment in segmentInfo.segments {
|
||||
guard let mainStream = segment.video ?? segment.audio else {
|
||||
assertionFailure()
|
||||
continue
|
||||
}
|
||||
parts.append(ChunkMediaPlayerPart(
|
||||
startTime: mainStream.startPts.seconds,
|
||||
endTime: mainStream.startPts.seconds + mainStream.duration,
|
||||
content: .directFile(ChunkMediaPlayerPart.Content.FFMpegDirectFile(
|
||||
path: data.path,
|
||||
audio: segment.audio.flatMap { stream in
|
||||
return ChunkMediaPlayerPart.DirectStream(
|
||||
index: stream.index,
|
||||
startPts: stream.startPts,
|
||||
endPts: stream.endPts,
|
||||
duration: stream.duration
|
||||
)
|
||||
},
|
||||
video: segment.video.flatMap { stream in
|
||||
return ChunkMediaPlayerPart.DirectStream(
|
||||
index: stream.index,
|
||||
startPts: stream.startPts,
|
||||
endPts: stream.endPts,
|
||||
duration: stream.duration
|
||||
)
|
||||
}
|
||||
)),
|
||||
codecName: mediaInfo.video?.codecName
|
||||
))
|
||||
}
|
||||
|
||||
self.partsStateValue.set(.single(ChunkMediaPlayerPartsState(
|
||||
duration: mainTrack.duration.seconds,
|
||||
parts: parts
|
||||
)))
|
||||
} else {
|
||||
self.partsStateValue.set(.single(ChunkMediaPlayerPartsState(
|
||||
duration: nil,
|
||||
parts: []
|
||||
)))
|
||||
}
|
||||
} else {
|
||||
self.partsStateValue.set(.single(ChunkMediaPlayerPartsState(
|
||||
duration: nil,
|
||||
parts: []
|
||||
)))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
deinit {
|
||||
self.completeFetchDisposable?.dispose()
|
||||
self.dataDisposable?.dispose()
|
||||
}
|
||||
|
||||
func updatePlaybackState(position: Double, isPlaying: Bool) {
|
||||
func seek(id: Int, position: Double) {
|
||||
self.seekTimestamp = position
|
||||
|
||||
self.currentLookaheadId += 1
|
||||
let lookaheadId = self.currentLookaheadId
|
||||
|
||||
let resource = self.resource
|
||||
let updateState: (FFMpegLookaheadThread.State) -> Void = { [weak self] state in
|
||||
Queue.mainQueue().async {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
if self.currentLookaheadId != lookaheadId {
|
||||
return
|
||||
}
|
||||
guard let mainTrack = state.video ?? state.audio else {
|
||||
self.partsStateValue.set(.single(ChunkMediaPlayerPartsState(
|
||||
duration: nil,
|
||||
content: .directReader(ChunkMediaPlayerPartsState.DirectReader(
|
||||
id: id,
|
||||
seekPosition: position,
|
||||
availableUntilPosition: position,
|
||||
bufferedUntilEnd: true,
|
||||
impl: nil
|
||||
))
|
||||
)))
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
var minAvailableUntilPosition: Double?
|
||||
if let audio = state.audio {
|
||||
if let minAvailableUntilPositionValue = minAvailableUntilPosition {
|
||||
minAvailableUntilPosition = min(minAvailableUntilPositionValue, audio.readableToTime.seconds)
|
||||
} else {
|
||||
minAvailableUntilPosition = audio.readableToTime.seconds
|
||||
}
|
||||
}
|
||||
if let video = state.video {
|
||||
if let minAvailableUntilPositionValue = minAvailableUntilPosition {
|
||||
minAvailableUntilPosition = min(minAvailableUntilPositionValue, video.readableToTime.seconds)
|
||||
} else {
|
||||
minAvailableUntilPosition = video.readableToTime.seconds
|
||||
}
|
||||
}
|
||||
|
||||
self.partsStateValue.set(.single(ChunkMediaPlayerPartsState(
|
||||
duration: mainTrack.info.duration.seconds,
|
||||
content: .directReader(ChunkMediaPlayerPartsState.DirectReader(
|
||||
id: id,
|
||||
seekPosition: position,
|
||||
availableUntilPosition: minAvailableUntilPosition ?? position,
|
||||
bufferedUntilEnd: state.isEnded,
|
||||
impl: ChunkMediaPlayerPartsState.DirectReader.Impl(
|
||||
video: state.video.flatMap { media -> ChunkMediaPlayerPartsState.DirectReader.Stream? in
|
||||
guard let maxReadablePts = state.maxReadablePts else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ChunkMediaPlayerPartsState.DirectReader.Stream(
|
||||
mediaBox: resource.postbox.mediaBox,
|
||||
resource: resource.reference.resource,
|
||||
size: resource.size,
|
||||
index: media.info.index,
|
||||
seek: (streamIndex: state.seek.streamIndex, pts: state.seek.pts),
|
||||
maxReadablePts: (streamIndex: maxReadablePts.streamIndex, pts: maxReadablePts.pts, isEnded: state.isEnded),
|
||||
codecName: resolveFFMpegCodecName(id: media.info.codecId)
|
||||
)
|
||||
},
|
||||
audio: state.audio.flatMap { media -> ChunkMediaPlayerPartsState.DirectReader.Stream? in
|
||||
guard let maxReadablePts = state.maxReadablePts else {
|
||||
return nil
|
||||
}
|
||||
return ChunkMediaPlayerPartsState.DirectReader.Stream(
|
||||
mediaBox: resource.postbox.mediaBox,
|
||||
resource: resource.reference.resource,
|
||||
size: resource.size,
|
||||
index: media.info.index,
|
||||
seek: (streamIndex: state.seek.streamIndex, pts: state.seek.pts),
|
||||
maxReadablePts: (streamIndex: maxReadablePts.streamIndex, pts: maxReadablePts.pts, isEnded: state.isEnded),
|
||||
codecName: resolveFFMpegCodecName(id: media.info.codecId)
|
||||
)
|
||||
}
|
||||
)
|
||||
))
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
self.lookahead = FFMpegLookahead(
|
||||
seekToTimestamp: position,
|
||||
updateState: updateState,
|
||||
fetchInRange: { range in
|
||||
return fetchedMediaResource(
|
||||
mediaBox: resource.postbox.mediaBox,
|
||||
userLocation: resource.userLocation,
|
||||
userContentType: resource.userContentType,
|
||||
reference: resource.reference,
|
||||
range: (range, .elevated),
|
||||
statsCategory: resource.statsCategory,
|
||||
preferBackgroundReferenceRevalidation: true
|
||||
).startStrict()
|
||||
},
|
||||
getDataInRange: { range, completion in
|
||||
return resource.postbox.mediaBox.resourceData(resource.reference.resource, size: resource.size, in: range, mode: .complete).start(next: { result, isComplete in
|
||||
completion(isComplete ? result : nil)
|
||||
})
|
||||
},
|
||||
isDataCachedInRange: { range in
|
||||
return resource.postbox.mediaBox.internal_resourceDataIsCached(
|
||||
id: resource.reference.resource.id,
|
||||
size: resource.size,
|
||||
in: range
|
||||
)
|
||||
},
|
||||
size: self.resource.size
|
||||
)
|
||||
}
|
||||
|
||||
func updatePlaybackState(seekTimestamp: Double, position: Double, isPlaying: Bool) {
|
||||
if self.seekTimestamp == seekTimestamp {
|
||||
self.lookahead?.updateCurrentTimestamp(timestamp: position)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ public let internal_isHardwareAv1Supported: Bool = {
|
|||
protocol ChunkMediaPlayerSourceImpl: AnyObject {
|
||||
var partsState: Signal<ChunkMediaPlayerPartsState, NoError> { get }
|
||||
|
||||
func updatePlaybackState(position: Double, isPlaying: Bool)
|
||||
func seek(id: Int, position: Double)
|
||||
func updatePlaybackState(seekTimestamp: Double, position: Double, isPlaying: Bool)
|
||||
}
|
||||
|
||||
private final class ChunkMediaPlayerExternalSourceImpl: ChunkMediaPlayerSourceImpl {
|
||||
|
|
@ -24,7 +25,10 @@ private final class ChunkMediaPlayerExternalSourceImpl: ChunkMediaPlayerSourceIm
|
|||
self.partsState = partsState
|
||||
}
|
||||
|
||||
func updatePlaybackState(position: Double, isPlaying: Bool) {
|
||||
func seek(id: Int, position: Double) {
|
||||
}
|
||||
|
||||
func updatePlaybackState(seekTimestamp: Double, position: Double, isPlaying: Bool) {
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -32,14 +36,16 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
public enum SourceDescription {
|
||||
public final class ResourceDescription {
|
||||
public let postbox: Postbox
|
||||
public let size: Int64
|
||||
public let reference: MediaResourceReference
|
||||
public let userLocation: MediaResourceUserLocation
|
||||
public let userContentType: MediaResourceUserContentType
|
||||
public let statsCategory: MediaResourceStatsCategory
|
||||
public let fetchAutomatically: Bool
|
||||
|
||||
public init(postbox: Postbox, reference: MediaResourceReference, userLocation: MediaResourceUserLocation, userContentType: MediaResourceUserContentType, statsCategory: MediaResourceStatsCategory, fetchAutomatically: Bool) {
|
||||
public init(postbox: Postbox, size: Int64, reference: MediaResourceReference, userLocation: MediaResourceUserLocation, userContentType: MediaResourceUserContentType, statsCategory: MediaResourceStatsCategory, fetchAutomatically: Bool) {
|
||||
self.postbox = postbox
|
||||
self.size = size
|
||||
self.reference = reference
|
||||
self.userLocation = userLocation
|
||||
self.userContentType = userContentType
|
||||
|
|
@ -52,10 +58,23 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
case directFetch(ResourceDescription)
|
||||
}
|
||||
|
||||
public struct MediaDataReaderParams {
|
||||
public var useV2Reader: Bool
|
||||
|
||||
public init(useV2Reader: Bool) {
|
||||
self.useV2Reader = useV2Reader
|
||||
}
|
||||
}
|
||||
|
||||
private final class LoadedPart {
|
||||
enum Content {
|
||||
case tempFile(ChunkMediaPlayerPart.TempFile)
|
||||
case directStream(ChunkMediaPlayerPartsState.DirectReader.Stream)
|
||||
}
|
||||
|
||||
final class Media {
|
||||
let queue: Queue
|
||||
let content: ChunkMediaPlayerPart.Content
|
||||
let content: Content
|
||||
let mediaType: AVMediaType
|
||||
let codecName: String?
|
||||
|
||||
|
|
@ -64,7 +83,7 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
var didBeginReading: Bool = false
|
||||
var isFinished: Bool = false
|
||||
|
||||
init(queue: Queue, content: ChunkMediaPlayerPart.Content, mediaType: AVMediaType, codecName: String?) {
|
||||
init(queue: Queue, content: Content, mediaType: AVMediaType, codecName: String?) {
|
||||
assert(queue.isCurrent())
|
||||
|
||||
self.queue = queue
|
||||
|
|
@ -77,12 +96,21 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
assert(self.queue.isCurrent())
|
||||
}
|
||||
|
||||
func load() {
|
||||
func load(params: MediaDataReaderParams) {
|
||||
let reader: MediaDataReader
|
||||
if case let .tempFile(tempFile) = self.content, self.mediaType == .video, (self.codecName == "av1" || self.codecName == "av01"), internal_isHardwareAv1Supported {
|
||||
reader = AVAssetVideoDataReader(filePath: tempFile.file.path, isVideo: self.mediaType == .video)
|
||||
} else {
|
||||
reader = FFMpegMediaDataReader(content: self.content, isVideo: self.mediaType == .video, codecName: self.codecName)
|
||||
switch self.content {
|
||||
case let .tempFile(tempFile):
|
||||
if self.mediaType == .video, (self.codecName == "av1" || self.codecName == "av01"), internal_isHardwareAv1Supported {
|
||||
reader = AVAssetVideoDataReader(filePath: tempFile.file.path, isVideo: self.mediaType == .video)
|
||||
} else {
|
||||
if params.useV2Reader {
|
||||
reader = FFMpegMediaDataReaderV2(content: .tempFile(tempFile), isVideo: self.mediaType == .video, codecName: self.codecName)
|
||||
} else {
|
||||
reader = FFMpegMediaDataReaderV1(filePath: tempFile.file.path, isVideo: self.mediaType == .video, codecName: self.codecName)
|
||||
}
|
||||
}
|
||||
case let .directStream(directStream):
|
||||
reader = FFMpegMediaDataReaderV2(content: .directStream(directStream), isVideo: self.mediaType == .video, codecName: self.codecName)
|
||||
}
|
||||
if self.mediaType == .video {
|
||||
if reader.hasVideo {
|
||||
|
|
@ -94,15 +122,23 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func update(content: Content) {
|
||||
if let reader = self.reader {
|
||||
if let reader = reader as? FFMpegMediaDataReaderV2, case let .directStream(directStream) = content {
|
||||
reader.update(content: .directStream(directStream))
|
||||
} else {
|
||||
assertionFailure()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final class MediaData {
|
||||
let part: ChunkMediaPlayerPart
|
||||
let video: Media?
|
||||
let audio: Media?
|
||||
|
||||
init(part: ChunkMediaPlayerPart, video: Media?, audio: Media?) {
|
||||
self.part = part
|
||||
init(video: Media?, audio: Media?) {
|
||||
self.video = video
|
||||
self.audio = audio
|
||||
}
|
||||
|
|
@ -118,6 +154,8 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
private final class LoadedPartsMediaData {
|
||||
var ids: [ChunkMediaPlayerPart.Id] = []
|
||||
var parts: [ChunkMediaPlayerPart.Id: LoadedPart.MediaData] = [:]
|
||||
var directMediaData: LoadedPart.MediaData?
|
||||
var directReaderId: Double?
|
||||
var notifiedHasSound: Bool = false
|
||||
var seekFromMinTimestamp: Double?
|
||||
}
|
||||
|
|
@ -125,6 +163,7 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
private static let sharedDataQueue = Queue(name: "ChunkMediaPlayerV2-DataQueue")
|
||||
private let dataQueue: Queue
|
||||
|
||||
private let mediaDataReaderParams: MediaDataReaderParams
|
||||
private let audioSessionManager: ManagedAudioSession
|
||||
private let onSeeked: (() -> Void)?
|
||||
|
||||
|
|
@ -132,7 +171,7 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
private var videoRenderer: AVSampleBufferDisplayLayer
|
||||
private var audioRenderer: AVSampleBufferAudioRenderer?
|
||||
|
||||
private var partsState = ChunkMediaPlayerPartsState(duration: nil, parts: [])
|
||||
private var partsState = ChunkMediaPlayerPartsState(duration: nil, content: .parts([]))
|
||||
private var loadedParts: [LoadedPart] = []
|
||||
private var loadedPartsMediaData: QueueLocalObject<LoadedPartsMediaData>
|
||||
private var hasSound: Bool = false
|
||||
|
|
@ -155,12 +194,14 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
|
||||
public var actionAtEnd: MediaPlayerActionAtEnd = .stop
|
||||
|
||||
private var didSeekOnce: Bool = false
|
||||
private var isPlaying: Bool = false
|
||||
private var baseRate: Double = 1.0
|
||||
private var isSoundEnabled: Bool
|
||||
private var isMuted: Bool
|
||||
|
||||
private var seekId: Int = 0
|
||||
private var seekTimestamp: Double = 0.0
|
||||
private var pendingSeekTimestamp: Double?
|
||||
private var pendingContinuePlaybackAfterSeekToTimestamp: Double?
|
||||
private var shouldNotifySeeked: Bool = false
|
||||
|
|
@ -171,6 +212,7 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
private var audioIsRequestingMediaData: Bool = false
|
||||
|
||||
private let source: ChunkMediaPlayerSourceImpl
|
||||
private var didSetSourceSeek: Bool = false
|
||||
private var partsStateDisposable: Disposable?
|
||||
private var updateTimer: Foundation.Timer?
|
||||
|
||||
|
|
@ -178,6 +220,7 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
private var hasAudioSession: Bool = false
|
||||
|
||||
public init(
|
||||
params: MediaDataReaderParams,
|
||||
audioSessionManager: ManagedAudioSession,
|
||||
source: SourceDescription,
|
||||
video: Bool,
|
||||
|
|
@ -196,6 +239,7 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
) {
|
||||
self.dataQueue = ChunkMediaPlayerV2.sharedDataQueue
|
||||
|
||||
self.mediaDataReaderParams = params
|
||||
self.audioSessionManager = audioSessionManager
|
||||
self.onSeeked = onSeeked
|
||||
|
||||
|
|
@ -328,6 +372,12 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
}
|
||||
}
|
||||
|
||||
if !self.didSeekOnce {
|
||||
self.didSeekOnce = true
|
||||
self.seek(timestamp: 0.0, play: nil)
|
||||
return
|
||||
}
|
||||
|
||||
let timestamp: CMTime
|
||||
if let pendingSeekTimestamp = self.pendingSeekTimestamp {
|
||||
timestamp = CMTimeMakeWithSeconds(pendingSeekTimestamp, preferredTimescale: 44000)
|
||||
|
|
@ -337,6 +387,7 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
let timestampSeconds = timestamp.seconds
|
||||
|
||||
self.source.updatePlaybackState(
|
||||
seekTimestamp: self.seekTimestamp,
|
||||
position: timestampSeconds,
|
||||
isPlaying: self.isPlaying
|
||||
)
|
||||
|
|
@ -346,38 +397,16 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
duration = partsStateDuration
|
||||
}
|
||||
|
||||
var validParts: [ChunkMediaPlayerPart] = []
|
||||
let isBuffering: Bool
|
||||
|
||||
var minStartTime: Double = 0.0
|
||||
for i in 0 ..< self.partsState.parts.count {
|
||||
let part = self.partsState.parts[i]
|
||||
|
||||
let partStartTime = max(minStartTime, part.startTime)
|
||||
let partEndTime = max(partStartTime, part.endTime)
|
||||
if partStartTime >= partEndTime {
|
||||
continue
|
||||
}
|
||||
|
||||
var partMatches = false
|
||||
if timestampSeconds >= partStartTime - 0.5 && timestampSeconds < partEndTime + 0.5 {
|
||||
partMatches = true
|
||||
}
|
||||
|
||||
if partMatches {
|
||||
validParts.append(ChunkMediaPlayerPart(
|
||||
startTime: part.startTime,
|
||||
clippedStartTime: partStartTime == part.startTime ? nil : partStartTime,
|
||||
endTime: part.endTime,
|
||||
content: part.content,
|
||||
codecName: part.codecName
|
||||
))
|
||||
minStartTime = max(minStartTime, partEndTime)
|
||||
}
|
||||
}
|
||||
let mediaDataReaderParams = self.mediaDataReaderParams
|
||||
|
||||
if let lastValidPart = validParts.last {
|
||||
for i in 0 ..< self.partsState.parts.count {
|
||||
let part = self.partsState.parts[i]
|
||||
switch self.partsState.content {
|
||||
case let .parts(partsStateParts):
|
||||
var validParts: [ChunkMediaPlayerPart] = []
|
||||
var minStartTime: Double = 0.0
|
||||
for i in 0 ..< partsStateParts.count {
|
||||
let part = partsStateParts[i]
|
||||
|
||||
let partStartTime = max(minStartTime, part.startTime)
|
||||
let partEndTime = max(partStartTime, part.endTime)
|
||||
|
|
@ -385,7 +414,12 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
continue
|
||||
}
|
||||
|
||||
if lastValidPart !== part && partStartTime > (lastValidPart.clippedStartTime ?? lastValidPart.startTime) && partStartTime <= lastValidPart.endTime + 0.5 {
|
||||
var partMatches = false
|
||||
if timestampSeconds >= partStartTime - 0.5 && timestampSeconds < partEndTime + 0.5 {
|
||||
partMatches = true
|
||||
}
|
||||
|
||||
if partMatches {
|
||||
validParts.append(ChunkMediaPlayerPart(
|
||||
startTime: part.startTime,
|
||||
clippedStartTime: partStartTime == part.startTime ? nil : partStartTime,
|
||||
|
|
@ -394,141 +428,247 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
codecName: part.codecName
|
||||
))
|
||||
minStartTime = max(minStartTime, partEndTime)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if validParts.isEmpty, let pendingContinuePlaybackAfterSeekToTimestamp = self.pendingContinuePlaybackAfterSeekToTimestamp {
|
||||
for part in self.partsState.parts {
|
||||
if pendingContinuePlaybackAfterSeekToTimestamp >= part.startTime - 0.2 && pendingContinuePlaybackAfterSeekToTimestamp < part.endTime {
|
||||
self.renderSynchronizer.setRate(Float(self.renderSynchronizerRate), time: CMTimeMakeWithSeconds(part.startTime, preferredTimescale: 44000))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.loadedParts.removeAll(where: { partState in
|
||||
if !validParts.contains(where: { $0.id == partState.part.id }) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
for part in validParts {
|
||||
if !self.loadedParts.contains(where: { $0.part.id == part.id }) {
|
||||
self.loadedParts.append(LoadedPart(part: part))
|
||||
self.loadedParts.sort(by: { $0.part.startTime < $1.part.startTime })
|
||||
}
|
||||
}
|
||||
|
||||
if self.pendingSeekTimestamp != nil {
|
||||
return
|
||||
}
|
||||
|
||||
let loadedParts = self.loadedParts
|
||||
let dataQueue = self.dataQueue
|
||||
let isSoundEnabled = self.isSoundEnabled
|
||||
self.loadedPartsMediaData.with { [weak self] loadedPartsMediaData in
|
||||
loadedPartsMediaData.ids = loadedParts.map(\.part.id)
|
||||
|
||||
for part in loadedParts {
|
||||
if let loadedPart = loadedPartsMediaData.parts[part.part.id] {
|
||||
if let audio = loadedPart.audio, audio.didBeginReading, !isSoundEnabled {
|
||||
let cleanAudio = LoadedPart.Media(
|
||||
if let lastValidPart = validParts.last {
|
||||
for i in 0 ..< partsStateParts.count {
|
||||
let part = partsStateParts[i]
|
||||
|
||||
let partStartTime = max(minStartTime, part.startTime)
|
||||
let partEndTime = max(partStartTime, part.endTime)
|
||||
if partStartTime >= partEndTime {
|
||||
continue
|
||||
}
|
||||
|
||||
if lastValidPart !== part && partStartTime > (lastValidPart.clippedStartTime ?? lastValidPart.startTime) && partStartTime <= lastValidPart.endTime + 0.5 {
|
||||
validParts.append(ChunkMediaPlayerPart(
|
||||
startTime: part.startTime,
|
||||
clippedStartTime: partStartTime == part.startTime ? nil : partStartTime,
|
||||
endTime: part.endTime,
|
||||
content: part.content,
|
||||
codecName: part.codecName
|
||||
))
|
||||
minStartTime = max(minStartTime, partEndTime)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if validParts.isEmpty, let pendingContinuePlaybackAfterSeekToTimestamp = self.pendingContinuePlaybackAfterSeekToTimestamp {
|
||||
for part in partsStateParts {
|
||||
if pendingContinuePlaybackAfterSeekToTimestamp >= part.startTime - 0.2 && pendingContinuePlaybackAfterSeekToTimestamp < part.endTime {
|
||||
self.renderSynchronizer.setRate(Float(self.renderSynchronizerRate), time: CMTimeMakeWithSeconds(part.startTime, preferredTimescale: 44000))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.loadedParts.removeAll(where: { partState in
|
||||
if !validParts.contains(where: { $0.id == partState.part.id }) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
for part in validParts {
|
||||
if !self.loadedParts.contains(where: { $0.part.id == part.id }) {
|
||||
self.loadedParts.append(LoadedPart(part: part))
|
||||
self.loadedParts.sort(by: { $0.part.startTime < $1.part.startTime })
|
||||
}
|
||||
}
|
||||
|
||||
var playableDuration: Double = 0.0
|
||||
var previousValidPartEndTime: Double?
|
||||
|
||||
for part in partsStateParts {
|
||||
if let previousValidPartEndTime {
|
||||
if part.startTime > previousValidPartEndTime + 0.5 {
|
||||
break
|
||||
}
|
||||
} else if !validParts.contains(where: { $0.id == part.id }) {
|
||||
continue
|
||||
}
|
||||
|
||||
let partDuration: Double
|
||||
if part.startTime - 0.5 <= timestampSeconds && part.endTime + 0.5 > timestampSeconds {
|
||||
partDuration = part.endTime - timestampSeconds
|
||||
} else if part.startTime - 0.5 > timestampSeconds {
|
||||
partDuration = part.endTime - part.startTime
|
||||
} else {
|
||||
partDuration = 0.0
|
||||
}
|
||||
playableDuration += partDuration
|
||||
previousValidPartEndTime = part.endTime
|
||||
}
|
||||
|
||||
if self.pendingSeekTimestamp != nil {
|
||||
return
|
||||
}
|
||||
|
||||
let loadedParts = self.loadedParts
|
||||
let dataQueue = self.dataQueue
|
||||
let isSoundEnabled = self.isSoundEnabled
|
||||
self.loadedPartsMediaData.with { [weak self] loadedPartsMediaData in
|
||||
loadedPartsMediaData.ids = loadedParts.map(\.part.id)
|
||||
|
||||
for part in loadedParts {
|
||||
if let loadedPart = loadedPartsMediaData.parts[part.part.id] {
|
||||
if let audio = loadedPart.audio, audio.didBeginReading, !isSoundEnabled {
|
||||
let cleanAudio = LoadedPart.Media(
|
||||
queue: dataQueue,
|
||||
content: .tempFile(part.part.content),
|
||||
mediaType: .audio,
|
||||
codecName: part.part.codecName
|
||||
)
|
||||
cleanAudio.load(params: mediaDataReaderParams)
|
||||
|
||||
loadedPartsMediaData.parts[part.part.id] = LoadedPart.MediaData(
|
||||
video: loadedPart.video,
|
||||
audio: cleanAudio.reader != nil ? cleanAudio : nil
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let video = LoadedPart.Media(
|
||||
queue: dataQueue,
|
||||
content: part.part.content,
|
||||
content: .tempFile(part.part.content),
|
||||
mediaType: .video,
|
||||
codecName: part.part.codecName
|
||||
)
|
||||
video.load(params: mediaDataReaderParams)
|
||||
|
||||
let audio = LoadedPart.Media(
|
||||
queue: dataQueue,
|
||||
content: .tempFile(part.part.content),
|
||||
mediaType: .audio,
|
||||
codecName: part.part.codecName
|
||||
)
|
||||
cleanAudio.load()
|
||||
audio.load(params: mediaDataReaderParams)
|
||||
|
||||
loadedPartsMediaData.parts[part.part.id] = LoadedPart.MediaData(
|
||||
part: part.part,
|
||||
video: loadedPart.video,
|
||||
audio: cleanAudio.reader != nil ? cleanAudio : nil
|
||||
video: video,
|
||||
audio: audio.reader != nil ? audio : nil
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let video = LoadedPart.Media(
|
||||
queue: dataQueue,
|
||||
content: part.part.content,
|
||||
mediaType: .video,
|
||||
codecName: part.part.codecName
|
||||
)
|
||||
video.load()
|
||||
|
||||
let audio = LoadedPart.Media(
|
||||
queue: dataQueue,
|
||||
content: part.part.content,
|
||||
mediaType: .audio,
|
||||
codecName: part.part.codecName
|
||||
)
|
||||
audio.load()
|
||||
|
||||
loadedPartsMediaData.parts[part.part.id] = LoadedPart.MediaData(
|
||||
part: part.part,
|
||||
video: video,
|
||||
audio: audio.reader != nil ? audio : nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var removedKeys: [ChunkMediaPlayerPart.Id] = []
|
||||
for (id, _) in loadedPartsMediaData.parts {
|
||||
if !loadedPartsMediaData.ids.contains(id) {
|
||||
removedKeys.append(id)
|
||||
}
|
||||
}
|
||||
for id in removedKeys {
|
||||
loadedPartsMediaData.parts.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
if !loadedPartsMediaData.notifiedHasSound, let part = loadedPartsMediaData.parts.values.first {
|
||||
loadedPartsMediaData.notifiedHasSound = true
|
||||
let hasSound = part.audio?.reader != nil
|
||||
Queue.mainQueue().async {
|
||||
guard let self else {
|
||||
return
|
||||
|
||||
var removedKeys: [ChunkMediaPlayerPart.Id] = []
|
||||
for (id, _) in loadedPartsMediaData.parts {
|
||||
if !loadedPartsMediaData.ids.contains(id) {
|
||||
removedKeys.append(id)
|
||||
}
|
||||
if self.hasSound != hasSound {
|
||||
self.hasSound = hasSound
|
||||
self.updateInternalState()
|
||||
}
|
||||
for id in removedKeys {
|
||||
loadedPartsMediaData.parts.removeValue(forKey: id)
|
||||
}
|
||||
|
||||
if !loadedPartsMediaData.notifiedHasSound, let part = loadedPartsMediaData.parts.values.first {
|
||||
loadedPartsMediaData.notifiedHasSound = true
|
||||
let hasSound = part.audio?.reader != nil
|
||||
Queue.mainQueue().async {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
if self.hasSound != hasSound {
|
||||
self.hasSound = hasSound
|
||||
self.updateInternalState()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var playableDuration: Double = 0.0
|
||||
var previousValidPartEndTime: Double?
|
||||
for part in self.partsState.parts {
|
||||
if let previousValidPartEndTime {
|
||||
if part.startTime > previousValidPartEndTime + 0.5 {
|
||||
break
|
||||
}
|
||||
} else if !validParts.contains(where: { $0.id == part.id }) {
|
||||
continue
|
||||
}
|
||||
|
||||
let partDuration: Double
|
||||
if part.startTime - 0.5 <= timestampSeconds && part.endTime + 0.5 > timestampSeconds {
|
||||
partDuration = part.endTime - timestampSeconds
|
||||
} else if part.startTime - 0.5 > timestampSeconds {
|
||||
partDuration = part.endTime - part.startTime
|
||||
if let previousValidPartEndTime, previousValidPartEndTime >= duration - 0.5 {
|
||||
isBuffering = false
|
||||
} else {
|
||||
partDuration = 0.0
|
||||
isBuffering = playableDuration < 1.0
|
||||
}
|
||||
case let .directReader(directReader):
|
||||
var readerImpl: ChunkMediaPlayerPartsState.DirectReader.Impl?
|
||||
var playableDuration: Double = 0.0
|
||||
let directReaderSeekPosition = directReader.seekPosition
|
||||
if directReader.id == self.seekId {
|
||||
readerImpl = directReader.impl
|
||||
playableDuration = max(0.0, directReader.availableUntilPosition - timestampSeconds)
|
||||
if directReader.bufferedUntilEnd {
|
||||
isBuffering = false
|
||||
} else {
|
||||
isBuffering = playableDuration < 1.0
|
||||
}
|
||||
} else {
|
||||
playableDuration = 0.0
|
||||
isBuffering = true
|
||||
}
|
||||
|
||||
let dataQueue = self.dataQueue
|
||||
self.loadedPartsMediaData.with { [weak self] loadedPartsMediaData in
|
||||
if !loadedPartsMediaData.ids.isEmpty {
|
||||
loadedPartsMediaData.ids = []
|
||||
}
|
||||
if !loadedPartsMediaData.parts.isEmpty {
|
||||
loadedPartsMediaData.parts.removeAll()
|
||||
}
|
||||
|
||||
if let readerImpl {
|
||||
if let currentDirectMediaData = loadedPartsMediaData.directMediaData, let currentDirectReaderId = loadedPartsMediaData.directReaderId, currentDirectReaderId == directReaderSeekPosition {
|
||||
if let video = currentDirectMediaData.video, let videoStream = readerImpl.video {
|
||||
video.update(content: .directStream(videoStream))
|
||||
}
|
||||
if let audio = currentDirectMediaData.audio, let audioStream = readerImpl.audio {
|
||||
audio.update(content: .directStream(audioStream))
|
||||
}
|
||||
} else {
|
||||
let video = readerImpl.video.flatMap { media in
|
||||
return LoadedPart.Media(
|
||||
queue: dataQueue,
|
||||
content: .directStream(media),
|
||||
mediaType: .video,
|
||||
codecName: media.codecName
|
||||
)
|
||||
}
|
||||
video?.load(params: mediaDataReaderParams)
|
||||
|
||||
let audio = readerImpl.audio.flatMap { media in
|
||||
return LoadedPart.Media(
|
||||
queue: dataQueue,
|
||||
content: .directStream(media),
|
||||
mediaType: .audio,
|
||||
codecName: media.codecName
|
||||
)
|
||||
}
|
||||
audio?.load(params: mediaDataReaderParams)
|
||||
|
||||
loadedPartsMediaData.directMediaData = LoadedPart.MediaData(
|
||||
video: video,
|
||||
audio: audio
|
||||
)
|
||||
}
|
||||
loadedPartsMediaData.directReaderId = directReaderSeekPosition
|
||||
|
||||
if !loadedPartsMediaData.notifiedHasSound {
|
||||
loadedPartsMediaData.notifiedHasSound = true
|
||||
let hasSound = readerImpl.audio != nil
|
||||
Queue.mainQueue().async {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
if self.hasSound != hasSound {
|
||||
self.hasSound = hasSound
|
||||
self.updateInternalState()
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
loadedPartsMediaData.directMediaData = nil
|
||||
loadedPartsMediaData.directReaderId = nil
|
||||
}
|
||||
}
|
||||
|
||||
if self.pendingSeekTimestamp != nil {
|
||||
return
|
||||
}
|
||||
playableDuration += partDuration
|
||||
previousValidPartEndTime = part.endTime
|
||||
}
|
||||
|
||||
var effectiveRate: Double = 0.0
|
||||
let isBuffering: Bool
|
||||
if let previousValidPartEndTime, previousValidPartEndTime >= duration - 0.5 {
|
||||
isBuffering = false
|
||||
} else {
|
||||
isBuffering = playableDuration < 1.0
|
||||
}
|
||||
if self.isPlaying {
|
||||
if !isBuffering {
|
||||
effectiveRate = self.baseRate
|
||||
|
|
@ -671,7 +811,30 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
}
|
||||
|
||||
private func seek(timestamp: Double, play: Bool?, notify: Bool) {
|
||||
let currentTimestamp: CMTime
|
||||
if let pendingSeekTimestamp = self.pendingSeekTimestamp {
|
||||
currentTimestamp = CMTimeMakeWithSeconds(pendingSeekTimestamp, preferredTimescale: 44000)
|
||||
} else {
|
||||
currentTimestamp = self.renderSynchronizer.currentTime()
|
||||
}
|
||||
let currentTimestampSeconds = currentTimestamp.seconds
|
||||
if currentTimestampSeconds == timestamp {
|
||||
if let play {
|
||||
self.isPlaying = play
|
||||
}
|
||||
if notify {
|
||||
self.shouldNotifySeeked = true
|
||||
}
|
||||
if !self.didSetSourceSeek {
|
||||
self.didSetSourceSeek = true
|
||||
self.source.seek(id: self.seekId, position: timestamp)
|
||||
}
|
||||
self.updateInternalState()
|
||||
return
|
||||
}
|
||||
|
||||
self.seekId += 1
|
||||
self.seekTimestamp = timestamp
|
||||
let seekId = self.seekId
|
||||
self.pendingSeekTimestamp = timestamp
|
||||
self.pendingContinuePlaybackAfterSeekToTimestamp = timestamp
|
||||
|
|
@ -699,6 +862,9 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
audioRenderer.stopRequestingMediaData()
|
||||
}
|
||||
|
||||
self.didSetSourceSeek = true
|
||||
self.source.seek(id: self.seekId, position: timestamp)
|
||||
|
||||
self.loadedPartsMediaData.with { [weak self] loadedPartsMediaData in
|
||||
loadedPartsMediaData.parts.removeAll()
|
||||
loadedPartsMediaData.seekFromMinTimestamp = timestamp
|
||||
|
|
@ -733,7 +899,7 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
private func triggerRequestMediaData() {
|
||||
let loadedPartsMediaData = self.loadedPartsMediaData
|
||||
|
||||
if !self.videoIsRequestingMediaData && "".isEmpty {
|
||||
if !self.videoIsRequestingMediaData {
|
||||
self.videoIsRequestingMediaData = true
|
||||
|
||||
let videoTarget: AVQueuedSampleBufferRendering
|
||||
|
|
@ -804,7 +970,9 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
continue
|
||||
}
|
||||
media.didBeginReading = true
|
||||
if var sampleBuffer = reader.readSampleBuffer() {
|
||||
switch reader.readSampleBuffer() {
|
||||
case let .frame(sampleBuffer):
|
||||
var sampleBuffer = sampleBuffer
|
||||
if let seekFromMinTimestamp = loadedPartsMediaData.seekFromMinTimestamp, CMSampleBufferGetPresentationTimeStamp(sampleBuffer).seconds < seekFromMinTimestamp {
|
||||
if isVideo {
|
||||
var updatedSampleBuffer: CMSampleBuffer?
|
||||
|
|
@ -829,7 +997,54 @@ public final class ChunkMediaPlayerV2: ChunkMediaPlayer {
|
|||
bufferTarget.enqueue(sampleBuffer)
|
||||
hasData = true
|
||||
continue outer
|
||||
} else {
|
||||
case .waitingForMoreData, .endOfStream, .error:
|
||||
media.isFinished = true
|
||||
}
|
||||
}
|
||||
outerDirect: while true {
|
||||
guard let directMediaData = loadedPartsMediaData.directMediaData else {
|
||||
break outer
|
||||
}
|
||||
guard let media = isVideo ? directMediaData.video : directMediaData.audio else {
|
||||
break outer
|
||||
}
|
||||
if media.isFinished {
|
||||
break outer
|
||||
}
|
||||
guard let reader = media.reader else {
|
||||
break outer
|
||||
}
|
||||
media.didBeginReading = true
|
||||
switch reader.readSampleBuffer() {
|
||||
case let .frame(sampleBuffer):
|
||||
var sampleBuffer = sampleBuffer
|
||||
if let seekFromMinTimestamp = loadedPartsMediaData.seekFromMinTimestamp, CMSampleBufferGetPresentationTimeStamp(sampleBuffer).seconds < seekFromMinTimestamp {
|
||||
if isVideo {
|
||||
var updatedSampleBuffer: CMSampleBuffer?
|
||||
CMSampleBufferCreateCopy(allocator: nil, sampleBuffer: sampleBuffer, sampleBufferOut: &updatedSampleBuffer)
|
||||
if let updatedSampleBuffer {
|
||||
if let attachments = CMSampleBufferGetSampleAttachmentsArray(updatedSampleBuffer, createIfNecessary: true) {
|
||||
let attachments = attachments as NSArray
|
||||
let dict = attachments[0] as! NSMutableDictionary
|
||||
|
||||
dict.setValue(kCFBooleanTrue as AnyObject, forKey: kCMSampleAttachmentKey_DoNotDisplay as NSString as String)
|
||||
|
||||
sampleBuffer = updatedSampleBuffer
|
||||
}
|
||||
}
|
||||
} else {
|
||||
continue outer
|
||||
}
|
||||
}
|
||||
/*if !isVideo {
|
||||
print("Enqueue audio \(CMSampleBufferGetPresentationTimeStamp(sampleBuffer).value) next: \(CMSampleBufferGetPresentationTimeStamp(sampleBuffer).value + 1024)")
|
||||
}*/
|
||||
bufferTarget.enqueue(sampleBuffer)
|
||||
hasData = true
|
||||
continue outer
|
||||
case .waitingForMoreData:
|
||||
break outer
|
||||
case .endOfStream, .error:
|
||||
media.isFinished = true
|
||||
}
|
||||
}
|
||||
|
|
|
|||
473
submodules/MediaPlayer/Sources/FFMpegFileReader.swift
Normal file
473
submodules/MediaPlayer/Sources/FFMpegFileReader.swift
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
import Foundation
|
||||
#if !os(macOS)
|
||||
import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
import TGUIKit
|
||||
#endif
|
||||
import CoreMedia
|
||||
import SwiftSignalKit
|
||||
import FFMpegBinding
|
||||
import Postbox
|
||||
import ManagedFile
|
||||
|
||||
private func FFMpegFileReader_readPacketCallback(userData: UnsafeMutableRawPointer?, buffer: UnsafeMutablePointer<UInt8>?, bufferSize: Int32) -> Int32 {
|
||||
guard let buffer else {
|
||||
return FFMPEG_CONSTANT_AVERROR_EOF
|
||||
}
|
||||
let context = Unmanaged<FFMpegFileReader>.fromOpaque(userData!).takeUnretainedValue()
|
||||
|
||||
switch context.source {
|
||||
case let .file(file):
|
||||
let result = file.read(buffer, Int(bufferSize))
|
||||
if result == 0 {
|
||||
return FFMPEG_CONSTANT_AVERROR_EOF
|
||||
}
|
||||
return Int32(result)
|
||||
case let .resource(resource):
|
||||
let readCount = min(256 * 1024, Int64(bufferSize))
|
||||
let requestRange: Range<Int64> = resource.readingPosition ..< (resource.readingPosition + readCount)
|
||||
|
||||
//TODO:improve thread safe read if incomplete
|
||||
if let (file, readSize) = resource.mediaBox.internal_resourceData(id: resource.resource.id, size: resource.size, in: requestRange) {
|
||||
let result = file.read(buffer, readSize)
|
||||
if result == 0 {
|
||||
return FFMPEG_CONSTANT_AVERROR_EOF
|
||||
}
|
||||
resource.readingPosition += Int64(result)
|
||||
return Int32(result)
|
||||
} else {
|
||||
return FFMPEG_CONSTANT_AVERROR_EOF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func FFMpegFileReader_seekCallback(userData: UnsafeMutableRawPointer?, offset: Int64, whence: Int32) -> Int64 {
|
||||
let context = Unmanaged<FFMpegFileReader>.fromOpaque(userData!).takeUnretainedValue()
|
||||
if (whence & FFMPEG_AVSEEK_SIZE) != 0 {
|
||||
switch context.source {
|
||||
case let .file(file):
|
||||
return file.getSize() ?? 0
|
||||
case let .resource(resource):
|
||||
return resource.size
|
||||
}
|
||||
} else {
|
||||
switch context.source {
|
||||
case let .file(file):
|
||||
let _ = file.seek(position: offset)
|
||||
case let .resource(resource):
|
||||
resource.readingPosition = offset
|
||||
}
|
||||
return offset
|
||||
}
|
||||
}
|
||||
|
||||
final class FFMpegFileReader {
|
||||
enum SourceDescription {
|
||||
case file(String)
|
||||
case resource(mediaBox: MediaBox, resource: MediaResource, size: Int64)
|
||||
}
|
||||
|
||||
final class StreamInfo: Equatable {
|
||||
let index: Int
|
||||
let codecId: Int32
|
||||
let startTime: CMTime
|
||||
let duration: CMTime
|
||||
let timeBase: CMTimeValue
|
||||
let timeScale: CMTimeScale
|
||||
let fps: CMTime
|
||||
|
||||
init(index: Int, codecId: Int32, startTime: CMTime, duration: CMTime, timeBase: CMTimeValue, timeScale: CMTimeScale, fps: CMTime) {
|
||||
self.index = index
|
||||
self.codecId = codecId
|
||||
self.startTime = startTime
|
||||
self.duration = duration
|
||||
self.timeBase = timeBase
|
||||
self.timeScale = timeScale
|
||||
self.fps = fps
|
||||
}
|
||||
|
||||
static func ==(lhs: StreamInfo, rhs: StreamInfo) -> Bool {
|
||||
if lhs.index != rhs.index {
|
||||
return false
|
||||
}
|
||||
if lhs.codecId != rhs.codecId {
|
||||
return false
|
||||
}
|
||||
if lhs.startTime != rhs.startTime {
|
||||
return false
|
||||
}
|
||||
if lhs.duration != rhs.duration {
|
||||
return false
|
||||
}
|
||||
if lhs.timeBase != rhs.timeBase {
|
||||
return false
|
||||
}
|
||||
if lhs.timeScale != rhs.timeScale {
|
||||
return false
|
||||
}
|
||||
if lhs.fps != rhs.fps {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate enum Source {
|
||||
final class Resource {
|
||||
let mediaBox: MediaBox
|
||||
let resource: MediaResource
|
||||
let size: Int64
|
||||
var readingPosition: Int64 = 0
|
||||
|
||||
init(mediaBox: MediaBox, resource: MediaResource, size: Int64) {
|
||||
self.mediaBox = mediaBox
|
||||
self.resource = resource
|
||||
self.size = size
|
||||
}
|
||||
}
|
||||
|
||||
case file(ManagedFile)
|
||||
case resource(Resource)
|
||||
}
|
||||
|
||||
private enum Decoder {
|
||||
case videoPassthrough(FFMpegMediaPassthroughVideoFrameDecoder)
|
||||
case video(FFMpegMediaVideoFrameDecoder)
|
||||
case audio(FFMpegAudioFrameDecoder)
|
||||
|
||||
func send(frame: MediaTrackDecodableFrame) -> Bool {
|
||||
switch self {
|
||||
case let .videoPassthrough(decoder):
|
||||
decoder.send(frame: frame)
|
||||
case let .video(decoder):
|
||||
decoder.send(frame: frame)
|
||||
case let .audio(decoder):
|
||||
decoder.send(frame: frame)
|
||||
}
|
||||
}
|
||||
|
||||
func sendEnd() -> Bool {
|
||||
switch self {
|
||||
case let .videoPassthrough(decoder):
|
||||
return decoder.sendEndToDecoder()
|
||||
case let .video(decoder):
|
||||
return decoder.sendEndToDecoder()
|
||||
case let .audio(decoder):
|
||||
return decoder.sendEndToDecoder()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private final class Stream {
|
||||
let info: StreamInfo
|
||||
let decoder: Decoder
|
||||
|
||||
init(info: StreamInfo, decoder: Decoder) {
|
||||
self.info = info
|
||||
self.decoder = decoder
|
||||
}
|
||||
}
|
||||
|
||||
enum SelectedStream {
|
||||
enum MediaType {
|
||||
case audio
|
||||
case video
|
||||
}
|
||||
|
||||
case mediaType(MediaType)
|
||||
case index(Int)
|
||||
}
|
||||
|
||||
enum ReadFrameResult {
|
||||
case frame(MediaTrackFrame)
|
||||
case waitingForMoreData
|
||||
case endOfStream
|
||||
case error
|
||||
}
|
||||
|
||||
private(set) var readingError = false
|
||||
private var stream: Stream?
|
||||
private var avIoContext: FFMpegAVIOContext?
|
||||
private var avFormatContext: FFMpegAVFormatContext?
|
||||
|
||||
fileprivate let source: Source
|
||||
|
||||
private var didSendEndToDecoder: Bool = false
|
||||
private var hasReadToEnd: Bool = false
|
||||
|
||||
private var maxReadablePts: (streamIndex: Int, pts: Int64, isEnded: Bool)?
|
||||
private var lastReadPts: (streamIndex: Int, pts: Int64)?
|
||||
private var isWaitingForMoreData: Bool = false
|
||||
|
||||
public init?(source: SourceDescription, passthroughDecoder: Bool = false, useHardwareAcceleration: Bool, selectedStream: SelectedStream, seek: (streamIndex: Int, pts: Int64)?, maxReadablePts: (streamIndex: Int, pts: Int64, isEnded: Bool)?) {
|
||||
let _ = FFMpegMediaFrameSourceContextHelpers.registerFFMpegGlobals
|
||||
|
||||
switch source {
|
||||
case let .file(path):
|
||||
guard let file = ManagedFile(queue: nil, path: path, mode: .read) else {
|
||||
return nil
|
||||
}
|
||||
self.source = .file(file)
|
||||
case let .resource(mediaBox, resource, size):
|
||||
self.source = .resource(Source.Resource(mediaBox: mediaBox, resource: resource, size: size))
|
||||
}
|
||||
|
||||
self.maxReadablePts = maxReadablePts
|
||||
|
||||
let avFormatContext = FFMpegAVFormatContext()
|
||||
/*if hintVP9 {
|
||||
avFormatContext.forceVideoCodecId(FFMpegCodecIdVP9)
|
||||
}*/
|
||||
let ioBufferSize = 64 * 1024
|
||||
|
||||
let avIoContext = FFMpegAVIOContext(bufferSize: Int32(ioBufferSize), opaqueContext: Unmanaged.passUnretained(self).toOpaque(), readPacket: FFMpegFileReader_readPacketCallback, writePacket: nil, seek: FFMpegFileReader_seekCallback, isSeekable: true)
|
||||
self.avIoContext = avIoContext
|
||||
|
||||
avFormatContext.setIO(self.avIoContext!)
|
||||
|
||||
if !avFormatContext.openInput(withDirectFilePath: nil) {
|
||||
self.readingError = true
|
||||
return nil
|
||||
}
|
||||
|
||||
if !avFormatContext.findStreamInfo() {
|
||||
self.readingError = true
|
||||
return nil
|
||||
}
|
||||
|
||||
self.avFormatContext = avFormatContext
|
||||
|
||||
var stream: Stream?
|
||||
outer: for mediaType in [.audio, .video] as [SelectedStream.MediaType] {
|
||||
streamSearch: for streamIndexNumber in avFormatContext.streamIndices(for: mediaType == .video ? FFMpegAVFormatStreamTypeVideo : FFMpegAVFormatStreamTypeAudio) {
|
||||
let streamIndex = Int(streamIndexNumber.int32Value)
|
||||
if avFormatContext.isAttachedPic(atStreamIndex: Int32(streamIndex)) {
|
||||
continue
|
||||
}
|
||||
|
||||
switch selectedStream {
|
||||
case let .mediaType(selectedMediaType):
|
||||
if mediaType != selectedMediaType {
|
||||
continue streamSearch
|
||||
}
|
||||
case let .index(index):
|
||||
if streamIndex != index {
|
||||
continue streamSearch
|
||||
}
|
||||
}
|
||||
|
||||
let codecId = avFormatContext.codecId(atStreamIndex: Int32(streamIndex))
|
||||
|
||||
let fpsAndTimebase = avFormatContext.fpsAndTimebase(forStreamIndex: Int32(streamIndex), defaultTimeBase: CMTimeMake(value: 1, timescale: 40000))
|
||||
let (fps, timebase) = (fpsAndTimebase.fps, fpsAndTimebase.timebase)
|
||||
|
||||
let startTime: CMTime
|
||||
let rawStartTime = avFormatContext.startTime(atStreamIndex: Int32(streamIndex))
|
||||
if rawStartTime == Int64(bitPattern: 0x8000000000000000 as UInt64) {
|
||||
startTime = CMTime(value: 0, timescale: timebase.timescale)
|
||||
} else {
|
||||
startTime = CMTimeMake(value: rawStartTime, timescale: timebase.timescale)
|
||||
}
|
||||
let duration = CMTimeMake(value: avFormatContext.duration(atStreamIndex: Int32(streamIndex)), timescale: timebase.timescale)
|
||||
|
||||
let metrics = avFormatContext.metricsForStream(at: Int32(streamIndex))
|
||||
|
||||
let rotationAngle: Double = metrics.rotationAngle
|
||||
//let aspect = Double(metrics.width) / Double(metrics.height)
|
||||
|
||||
let info = StreamInfo(
|
||||
index: streamIndex,
|
||||
codecId: codecId,
|
||||
startTime: startTime,
|
||||
duration: duration,
|
||||
timeBase: timebase.value,
|
||||
timeScale: timebase.timescale,
|
||||
fps: fps
|
||||
)
|
||||
|
||||
switch mediaType {
|
||||
case .video:
|
||||
if passthroughDecoder {
|
||||
var videoFormatData: FFMpegMediaPassthroughVideoFrameDecoder.VideoFormatData?
|
||||
if codecId == FFMpegCodecIdMPEG4 {
|
||||
videoFormatData = FFMpegMediaPassthroughVideoFrameDecoder.VideoFormatData(codecType: kCMVideoCodecType_MPEG4Video, width: metrics.width, height: metrics.height, extraData: Data(bytes: metrics.extradata, count: Int(metrics.extradataSize)))
|
||||
} else if codecId == FFMpegCodecIdH264 {
|
||||
videoFormatData = FFMpegMediaPassthroughVideoFrameDecoder.VideoFormatData(codecType: kCMVideoCodecType_H264, width: metrics.width, height: metrics.height, extraData: Data(bytes: metrics.extradata, count: Int(metrics.extradataSize)))
|
||||
} else if codecId == FFMpegCodecIdHEVC {
|
||||
videoFormatData = FFMpegMediaPassthroughVideoFrameDecoder.VideoFormatData(codecType: kCMVideoCodecType_HEVC, width: metrics.width, height: metrics.height, extraData: Data(bytes: metrics.extradata, count: Int(metrics.extradataSize)))
|
||||
} else if codecId == FFMpegCodecIdAV1 {
|
||||
videoFormatData = FFMpegMediaPassthroughVideoFrameDecoder.VideoFormatData(codecType: kCMVideoCodecType_AV1, width: metrics.width, height: metrics.height, extraData: Data(bytes: metrics.extradata, count: Int(metrics.extradataSize)))
|
||||
}
|
||||
|
||||
if let videoFormatData {
|
||||
stream = Stream(
|
||||
info: info,
|
||||
decoder: .videoPassthrough(FFMpegMediaPassthroughVideoFrameDecoder(videoFormatData: videoFormatData, rotationAngle: rotationAngle))
|
||||
)
|
||||
break outer
|
||||
}
|
||||
} else {
|
||||
if let codec = FFMpegAVCodec.find(forId: codecId, preferHardwareAccelerationCapable: useHardwareAcceleration) {
|
||||
let codecContext = FFMpegAVCodecContext(codec: codec)
|
||||
if avFormatContext.codecParams(atStreamIndex: Int32(streamIndex), to: codecContext) {
|
||||
if useHardwareAcceleration {
|
||||
codecContext.setupHardwareAccelerationIfPossible()
|
||||
}
|
||||
|
||||
if codecContext.open() {
|
||||
stream = Stream(
|
||||
info: info,
|
||||
decoder: .video(FFMpegMediaVideoFrameDecoder(codecContext: codecContext))
|
||||
)
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
case .audio:
|
||||
if let codec = FFMpegAVCodec.find(forId: codecId, preferHardwareAccelerationCapable: false) {
|
||||
let codecContext = FFMpegAVCodecContext(codec: codec)
|
||||
if avFormatContext.codecParams(atStreamIndex: Int32(streamIndex), to: codecContext) {
|
||||
if codecContext.open() {
|
||||
stream = Stream(
|
||||
info: info,
|
||||
decoder: .audio(FFMpegAudioFrameDecoder(codecContext: codecContext, sampleRate: 48000, channelCount: 1))
|
||||
)
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
guard let stream else {
|
||||
self.readingError = true
|
||||
return nil
|
||||
}
|
||||
|
||||
self.stream = stream
|
||||
|
||||
if let seek {
|
||||
avFormatContext.seekFrame(forStreamIndex: Int32(seek.streamIndex), pts: seek.pts, positionOnKeyframe: true)
|
||||
} else {
|
||||
avFormatContext.seekFrame(forStreamIndex: Int32(stream.info.index), pts: 0, positionOnKeyframe: true)
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
}
|
||||
|
||||
private func readPacketInternal() -> FFMpegPacket? {
|
||||
guard let avFormatContext = self.avFormatContext else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if let maxReadablePts = self.maxReadablePts, !maxReadablePts.isEnded, let lastReadPts = self.lastReadPts, lastReadPts.streamIndex == maxReadablePts.streamIndex, lastReadPts.pts == maxReadablePts.pts {
|
||||
self.isWaitingForMoreData = true
|
||||
return nil
|
||||
}
|
||||
|
||||
let packet = FFMpegPacket()
|
||||
if avFormatContext.readFrame(into: packet) {
|
||||
self.lastReadPts = (Int(packet.streamIndex), packet.pts)
|
||||
return packet
|
||||
} else {
|
||||
self.hasReadToEnd = true
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func readDecodableFrame() -> MediaTrackDecodableFrame? {
|
||||
while !self.readingError && !self.hasReadToEnd && !self.isWaitingForMoreData {
|
||||
if let packet = self.readPacketInternal() {
|
||||
if let stream = self.stream, Int(packet.streamIndex) == stream.info.index {
|
||||
let packetPts = packet.pts
|
||||
|
||||
/*if let focusedPart = self.focusedPart, packetPts >= focusedPart.endPts.value {
|
||||
self.hasReadToEnd = true
|
||||
}*/
|
||||
|
||||
let pts = CMTimeMake(value: packetPts, timescale: stream.info.timeScale)
|
||||
let dts = CMTimeMake(value: packet.dts, timescale: stream.info.timeScale)
|
||||
|
||||
let duration: CMTime
|
||||
|
||||
let frameDuration = packet.duration
|
||||
if frameDuration != 0 {
|
||||
duration = CMTimeMake(value: frameDuration * stream.info.timeBase, timescale: stream.info.timeScale)
|
||||
} else {
|
||||
duration = stream.info.fps
|
||||
}
|
||||
|
||||
let frame = MediaTrackDecodableFrame(type: .video, packet: packet, pts: pts, dts: dts, duration: duration)
|
||||
return frame
|
||||
}
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
public func readFrame() -> ReadFrameResult {
|
||||
guard let stream = self.stream else {
|
||||
return .error
|
||||
}
|
||||
|
||||
while true {
|
||||
var result: MediaTrackFrame?
|
||||
switch stream.decoder {
|
||||
case let .video(decoder):
|
||||
result = decoder.decode(ptsOffset: nil, forceARGB: false, unpremultiplyAlpha: false, displayImmediately: false)
|
||||
case let .videoPassthrough(decoder):
|
||||
result = decoder.decode()
|
||||
case let .audio(decoder):
|
||||
result = decoder.decode()
|
||||
}
|
||||
if let result {
|
||||
if self.didSendEndToDecoder {
|
||||
assert(true)
|
||||
}
|
||||
return .frame(result)
|
||||
}
|
||||
|
||||
if !self.isWaitingForMoreData && !self.readingError && !self.hasReadToEnd {
|
||||
if let decodableFrame = self.readDecodableFrame() {
|
||||
let _ = stream.decoder.send(frame: decodableFrame)
|
||||
}
|
||||
} else if self.hasReadToEnd && !self.didSendEndToDecoder {
|
||||
self.didSendEndToDecoder = true
|
||||
let _ = stream.decoder.sendEnd()
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if self.isWaitingForMoreData {
|
||||
return .waitingForMoreData
|
||||
} else {
|
||||
return .endOfStream
|
||||
}
|
||||
}
|
||||
|
||||
public func updateMaxReadablePts(pts: (streamIndex: Int, pts: Int64, isEnded: Bool)?) {
|
||||
if self.maxReadablePts?.streamIndex != pts?.streamIndex || self.maxReadablePts?.pts != pts?.pts {
|
||||
self.maxReadablePts = pts
|
||||
|
||||
if let pts {
|
||||
if pts.isEnded {
|
||||
self.isWaitingForMoreData = false
|
||||
} else {
|
||||
if self.lastReadPts?.streamIndex != pts.streamIndex || self.lastReadPts?.pts != pts.pts {
|
||||
self.isWaitingForMoreData = false
|
||||
}
|
||||
}
|
||||
} else {
|
||||
self.isWaitingForMoreData = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -490,7 +490,7 @@ final class FFMpegMediaFrameSourceContext: NSObject {
|
|||
let aspect = Double(metrics.width) / Double(metrics.height)
|
||||
|
||||
if self.preferSoftwareDecoding {
|
||||
if let codec = FFMpegAVCodec.find(forId: codecId) {
|
||||
if let codec = FFMpegAVCodec.find(forId: codecId, preferHardwareAccelerationCapable: false) {
|
||||
let codecContext = FFMpegAVCodecContext(codec: codec)
|
||||
if avFormatContext.codecParams(atStreamIndex: streamIndex, to: codecContext) {
|
||||
if codecContext.open() {
|
||||
|
|
@ -523,7 +523,7 @@ final class FFMpegMediaFrameSourceContext: NSObject {
|
|||
var codec: FFMpegAVCodec?
|
||||
|
||||
if codec == nil {
|
||||
codec = FFMpegAVCodec.find(forId: codecId)
|
||||
codec = FFMpegAVCodec.find(forId: codecId, preferHardwareAccelerationCapable: false)
|
||||
}
|
||||
|
||||
if let codec = codec {
|
||||
|
|
|
|||
|
|
@ -277,6 +277,43 @@ public final class FFMpegMediaVideoFrameDecoder: MediaTrackFrameDecoder {
|
|||
}
|
||||
|
||||
private func convertVideoFrame(_ frame: FFMpegAVFrame, pts: CMTime, dts: CMTime, duration: CMTime, forceARGB: Bool = false, unpremultiplyAlpha: Bool = true, displayImmediately: Bool = true) -> MediaTrackFrame? {
|
||||
if frame.nativePixelFormat() == FFMpegAVFrameNativePixelFormat.videoToolbox {
|
||||
guard let pixelBufferRef = frame.data[3] else {
|
||||
return nil
|
||||
}
|
||||
let unmanagedPixelBuffer = Unmanaged<CVPixelBuffer>.fromOpaque(UnsafeRawPointer(pixelBufferRef))
|
||||
let pixelBuffer = unmanagedPixelBuffer.takeUnretainedValue()
|
||||
|
||||
var formatRef: CMVideoFormatDescription?
|
||||
let formatStatus = CMVideoFormatDescriptionCreateForImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescriptionOut: &formatRef)
|
||||
|
||||
guard let format = formatRef, formatStatus == 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
var timingInfo = CMSampleTimingInfo(duration: duration, presentationTimeStamp: pts, decodeTimeStamp: pts)
|
||||
var sampleBuffer: CMSampleBuffer?
|
||||
|
||||
guard CMSampleBufferCreateReadyWithImageBuffer(allocator: kCFAllocatorDefault, imageBuffer: pixelBuffer, formatDescription: format, sampleTiming: &timingInfo, sampleBufferOut: &sampleBuffer) == noErr else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let attachments = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer!, createIfNecessary: true)! as NSArray
|
||||
let dict = attachments[0] as! NSMutableDictionary
|
||||
|
||||
let resetDecoder = self.resetDecoderOnNextFrame
|
||||
if self.resetDecoderOnNextFrame {
|
||||
self.resetDecoderOnNextFrame = false
|
||||
//dict.setValue(kCFBooleanTrue as AnyObject, forKey: kCMSampleBufferAttachmentKey_ResetDecoderBeforeDecoding as NSString as String)
|
||||
}
|
||||
|
||||
if displayImmediately {
|
||||
dict.setValue(kCFBooleanTrue as AnyObject, forKey: kCMSampleAttachmentKey_DisplayImmediately as NSString as String)
|
||||
}
|
||||
|
||||
return MediaTrackFrame(type: .video, sampleBuffer: sampleBuffer!, resetDecoder: resetDecoder, decoded: true)
|
||||
}
|
||||
|
||||
if frame.data[0] == nil {
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import AVFoundation
|
|||
import CoreMedia
|
||||
import FFMpegBinding
|
||||
import VideoToolbox
|
||||
import Postbox
|
||||
|
||||
#if os(macOS)
|
||||
private let internal_isHardwareAv1Supported: Bool = {
|
||||
|
|
@ -11,15 +12,131 @@ private let internal_isHardwareAv1Supported: Bool = {
|
|||
}()
|
||||
#endif
|
||||
|
||||
public enum MediaDataReaderReadSampleBufferResult {
|
||||
case frame(CMSampleBuffer)
|
||||
case waitingForMoreData
|
||||
case endOfStream
|
||||
case error
|
||||
}
|
||||
|
||||
public protocol MediaDataReader: AnyObject {
|
||||
var hasVideo: Bool { get }
|
||||
var hasAudio: Bool { get }
|
||||
|
||||
func readSampleBuffer() -> CMSampleBuffer?
|
||||
func readSampleBuffer() -> MediaDataReaderReadSampleBufferResult
|
||||
}
|
||||
|
||||
public final class FFMpegMediaDataReader: MediaDataReader {
|
||||
private let content: ChunkMediaPlayerPart.Content
|
||||
public final class FFMpegMediaDataReaderV2: MediaDataReader {
|
||||
public enum Content {
|
||||
case tempFile(ChunkMediaPlayerPart.TempFile)
|
||||
case directStream(ChunkMediaPlayerPartsState.DirectReader.Stream)
|
||||
}
|
||||
|
||||
private let content: Content
|
||||
private let isVideo: Bool
|
||||
private let videoSource: FFMpegFileReader?
|
||||
private let audioSource: FFMpegFileReader?
|
||||
|
||||
public var hasVideo: Bool {
|
||||
return self.videoSource != nil
|
||||
}
|
||||
|
||||
public var hasAudio: Bool {
|
||||
return self.audioSource != nil
|
||||
}
|
||||
|
||||
public init(content: Content, isVideo: Bool, codecName: String?) {
|
||||
self.content = content
|
||||
self.isVideo = isVideo
|
||||
|
||||
let source: FFMpegFileReader.SourceDescription
|
||||
var seek: (streamIndex: Int, pts: Int64)?
|
||||
var maxReadablePts: (streamIndex: Int, pts: Int64, isEnded: Bool)?
|
||||
switch content {
|
||||
case let .tempFile(tempFile):
|
||||
source = .file(tempFile.file.path)
|
||||
case let .directStream(directStream):
|
||||
source = .resource(mediaBox: directStream.mediaBox, resource: directStream.resource, size: directStream.size)
|
||||
seek = (directStream.seek.streamIndex, directStream.seek.pts)
|
||||
maxReadablePts = directStream.maxReadablePts
|
||||
}
|
||||
|
||||
if self.isVideo {
|
||||
var passthroughDecoder = true
|
||||
var useHardwareAcceleration = false
|
||||
|
||||
if (codecName == "av1" || codecName == "av01") {
|
||||
passthroughDecoder = false
|
||||
useHardwareAcceleration = internal_isHardwareAv1Supported
|
||||
}
|
||||
if codecName == "vp9" || codecName == "vp8" {
|
||||
passthroughDecoder = false
|
||||
}
|
||||
|
||||
/*#if DEBUG
|
||||
if codecName == "h264" {
|
||||
passthroughDecoder = false
|
||||
useHardwareAcceleration = true
|
||||
}
|
||||
#endif*/
|
||||
|
||||
if let videoSource = FFMpegFileReader(source: source, passthroughDecoder: passthroughDecoder, useHardwareAcceleration: useHardwareAcceleration, selectedStream: .mediaType(.video), seek: seek, maxReadablePts: maxReadablePts) {
|
||||
self.videoSource = videoSource
|
||||
} else {
|
||||
self.videoSource = nil
|
||||
}
|
||||
self.audioSource = nil
|
||||
} else {
|
||||
if let audioSource = FFMpegFileReader(source: source, passthroughDecoder: false, useHardwareAcceleration: false, selectedStream: .mediaType(.audio), seek: seek, maxReadablePts: maxReadablePts) {
|
||||
self.audioSource = audioSource
|
||||
} else {
|
||||
self.audioSource = nil
|
||||
}
|
||||
self.videoSource = nil
|
||||
}
|
||||
}
|
||||
|
||||
public func update(content: Content) {
|
||||
guard case let .directStream(directStream) = content else {
|
||||
return
|
||||
}
|
||||
if let audioSource = self.audioSource {
|
||||
audioSource.updateMaxReadablePts(pts: directStream.maxReadablePts)
|
||||
} else if let videoSource = self.videoSource {
|
||||
videoSource.updateMaxReadablePts(pts: directStream.maxReadablePts)
|
||||
}
|
||||
}
|
||||
|
||||
public func readSampleBuffer() -> MediaDataReaderReadSampleBufferResult {
|
||||
if let videoSource {
|
||||
switch videoSource.readFrame() {
|
||||
case let .frame(frame):
|
||||
return .frame(frame.sampleBuffer)
|
||||
case .waitingForMoreData:
|
||||
return .waitingForMoreData
|
||||
case .endOfStream:
|
||||
return .endOfStream
|
||||
case .error:
|
||||
return .error
|
||||
}
|
||||
} else if let audioSource {
|
||||
switch audioSource.readFrame() {
|
||||
case let .frame(frame):
|
||||
return .frame(frame.sampleBuffer)
|
||||
case .waitingForMoreData:
|
||||
return .waitingForMoreData
|
||||
case .endOfStream:
|
||||
return .endOfStream
|
||||
case .error:
|
||||
return .error
|
||||
}
|
||||
} else {
|
||||
return .endOfStream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public final class FFMpegMediaDataReaderV1: MediaDataReader {
|
||||
private let isVideo: Bool
|
||||
private let videoSource: SoftwareVideoReader?
|
||||
private let audioSource: SoftwareAudioSource?
|
||||
|
|
@ -32,42 +149,15 @@ public final class FFMpegMediaDataReader: MediaDataReader {
|
|||
return self.audioSource != nil
|
||||
}
|
||||
|
||||
public init(content: ChunkMediaPlayerPart.Content, isVideo: Bool, codecName: String?) {
|
||||
self.content = content
|
||||
public init(filePath: String, isVideo: Bool, codecName: String?) {
|
||||
self.isVideo = isVideo
|
||||
|
||||
let filePath: String
|
||||
var focusedPart: MediaStreamFocusedPart?
|
||||
switch content {
|
||||
case let .tempFile(tempFile):
|
||||
filePath = tempFile.file.path
|
||||
case let .directFile(directFile):
|
||||
filePath = directFile.path
|
||||
|
||||
let stream = isVideo ? directFile.video : directFile.audio
|
||||
guard let stream else {
|
||||
self.videoSource = nil
|
||||
self.audioSource = nil
|
||||
return
|
||||
}
|
||||
|
||||
focusedPart = MediaStreamFocusedPart(
|
||||
seekStreamIndex: stream.index,
|
||||
startPts: stream.startPts,
|
||||
endPts: stream.endPts
|
||||
)
|
||||
}
|
||||
|
||||
if self.isVideo {
|
||||
var passthroughDecoder = true
|
||||
if (codecName == "av1" || codecName == "av01") && !internal_isHardwareAv1Supported {
|
||||
passthroughDecoder = false
|
||||
}
|
||||
if codecName == "vp9" || codecName == "vp8" {
|
||||
passthroughDecoder = false
|
||||
}
|
||||
|
||||
let videoSource = SoftwareVideoReader(path: filePath, hintVP9: false, passthroughDecoder: passthroughDecoder, focusedPart: focusedPart)
|
||||
let videoSource = SoftwareVideoReader(path: filePath, hintVP9: false, passthroughDecoder: passthroughDecoder)
|
||||
if videoSource.hasStream {
|
||||
self.videoSource = videoSource
|
||||
} else {
|
||||
|
|
@ -75,7 +165,7 @@ public final class FFMpegMediaDataReader: MediaDataReader {
|
|||
}
|
||||
self.audioSource = nil
|
||||
} else {
|
||||
let audioSource = SoftwareAudioSource(path: filePath, focusedPart: focusedPart)
|
||||
let audioSource = SoftwareAudioSource(path: filePath)
|
||||
if audioSource.hasStream {
|
||||
self.audioSource = audioSource
|
||||
} else {
|
||||
|
|
@ -85,19 +175,23 @@ public final class FFMpegMediaDataReader: MediaDataReader {
|
|||
}
|
||||
}
|
||||
|
||||
public func readSampleBuffer() -> CMSampleBuffer? {
|
||||
public func readSampleBuffer() -> MediaDataReaderReadSampleBufferResult {
|
||||
if let videoSource {
|
||||
let frame = videoSource.readFrame()
|
||||
if let frame {
|
||||
return frame.sampleBuffer
|
||||
return .frame(frame.sampleBuffer)
|
||||
} else {
|
||||
return nil
|
||||
return .endOfStream
|
||||
}
|
||||
} else if let audioSource {
|
||||
return audioSource.readSampleBuffer()
|
||||
if let sampleBuffer = audioSource.readSampleBuffer() {
|
||||
return .frame(sampleBuffer)
|
||||
} else {
|
||||
return .endOfStream
|
||||
}
|
||||
} else {
|
||||
return .endOfStream
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -140,14 +234,18 @@ public final class AVAssetVideoDataReader: MediaDataReader {
|
|||
}
|
||||
}
|
||||
|
||||
public func readSampleBuffer() -> CMSampleBuffer? {
|
||||
public func readSampleBuffer() -> MediaDataReaderReadSampleBufferResult {
|
||||
guard let mediaInfo = self.mediaInfo, let assetReader = self.assetReader, let assetOutput = self.assetOutput else {
|
||||
return nil
|
||||
return .endOfStream
|
||||
}
|
||||
var retryCount = 0
|
||||
while true {
|
||||
if let sampleBuffer = assetOutput.copyNextSampleBuffer() {
|
||||
return createSampleBuffer(fromSampleBuffer: sampleBuffer, withTimeOffset: mediaInfo.startTime, duration: nil)
|
||||
if let convertedSampleBuffer = createSampleBuffer(fromSampleBuffer: sampleBuffer, withTimeOffset: mediaInfo.startTime, duration: nil) {
|
||||
return .frame(convertedSampleBuffer)
|
||||
} else {
|
||||
return .endOfStream
|
||||
}
|
||||
} else if assetReader.status == .reading && retryCount < 100 {
|
||||
Thread.sleep(forTimeInterval: 1.0 / 60.0)
|
||||
retryCount += 1
|
||||
|
|
@ -156,7 +254,7 @@ public final class AVAssetVideoDataReader: MediaDataReader {
|
|||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return .endOfStream
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ public final class SoftwareVideoSource {
|
|||
break
|
||||
}
|
||||
} else {
|
||||
if let codec = FFMpegAVCodec.find(forId: codecId) {
|
||||
if let codec = FFMpegAVCodec.find(forId: codecId, preferHardwareAccelerationCapable: false) {
|
||||
let codecContext = FFMpegAVCodecContext(codec: codec)
|
||||
if avFormatContext.codecParams(atStreamIndex: streamIndex, to: codecContext) {
|
||||
if codecContext.open() {
|
||||
|
|
@ -382,7 +382,6 @@ private func SoftwareAudioSource_seekCallback(userData: UnsafeMutableRawPointer?
|
|||
}
|
||||
|
||||
public final class SoftwareAudioSource {
|
||||
private let focusedPart: MediaStreamFocusedPart?
|
||||
private var readingError = false
|
||||
private var audioStream: SoftwareAudioStream?
|
||||
private var avIoContext: FFMpegAVIOContext?
|
||||
|
|
@ -397,11 +396,9 @@ public final class SoftwareAudioSource {
|
|||
return self.audioStream != nil
|
||||
}
|
||||
|
||||
public init(path: String, focusedPart: MediaStreamFocusedPart?) {
|
||||
public init(path: String) {
|
||||
let _ = FFMpegMediaFrameSourceContextHelpers.registerFFMpegGlobals
|
||||
|
||||
self.focusedPart = focusedPart
|
||||
|
||||
var s = stat()
|
||||
stat(path, &s)
|
||||
self.size = Int32(s.st_size)
|
||||
|
|
@ -451,7 +448,7 @@ public final class SoftwareAudioSource {
|
|||
|
||||
let duration = CMTimeMake(value: avFormatContext.duration(atStreamIndex: streamIndex), timescale: timebase.timescale)
|
||||
|
||||
let codec = FFMpegAVCodec.find(forId: codecId)
|
||||
let codec = FFMpegAVCodec.find(forId: codecId, preferHardwareAccelerationCapable: false)
|
||||
|
||||
if let codec = codec {
|
||||
let codecContext = FFMpegAVCodecContext(codec: codec)
|
||||
|
|
@ -466,12 +463,8 @@ public final class SoftwareAudioSource {
|
|||
|
||||
self.audioStream = audioStream
|
||||
|
||||
if let focusedPart = self.focusedPart {
|
||||
avFormatContext.seekFrame(forStreamIndex: Int32(focusedPart.seekStreamIndex), pts: focusedPart.startPts.value, positionOnKeyframe: true)
|
||||
} else {
|
||||
if let audioStream = self.audioStream {
|
||||
avFormatContext.seekFrame(forStreamIndex: Int32(audioStream.index), pts: 0, positionOnKeyframe: false)
|
||||
}
|
||||
if let audioStream = self.audioStream {
|
||||
avFormatContext.seekFrame(forStreamIndex: Int32(audioStream.index), pts: 0, positionOnKeyframe: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -502,10 +495,6 @@ public final class SoftwareAudioSource {
|
|||
if let audioStream = self.audioStream, Int(packet.streamIndex) == audioStream.index {
|
||||
let packetPts = packet.pts
|
||||
|
||||
if let focusedPart = self.focusedPart, packetPts >= focusedPart.endPts.value {
|
||||
self.hasReadToEnd = true
|
||||
}
|
||||
|
||||
let pts = CMTimeMake(value: packetPts, timescale: audioStream.timebase.timescale)
|
||||
let dts = CMTimeMake(value: packet.dts, timescale: audioStream.timebase.timescale)
|
||||
|
||||
|
|
@ -579,18 +568,6 @@ public final class SoftwareAudioSource {
|
|||
}
|
||||
}
|
||||
|
||||
public struct MediaStreamFocusedPart {
|
||||
public let seekStreamIndex: Int
|
||||
public let startPts: CMTime
|
||||
public let endPts: CMTime
|
||||
|
||||
public init(seekStreamIndex: Int, startPts: CMTime, endPts: CMTime) {
|
||||
self.seekStreamIndex = seekStreamIndex
|
||||
self.startPts = startPts
|
||||
self.endPts = endPts
|
||||
}
|
||||
}
|
||||
|
||||
private func SoftwareVideoReader_readPacketCallback(userData: UnsafeMutableRawPointer?, buffer: UnsafeMutablePointer<UInt8>?, bufferSize: Int32) -> Int32 {
|
||||
let context = Unmanaged<SoftwareVideoReader>.fromOpaque(userData!).takeUnretainedValue()
|
||||
if let fd = context.fd {
|
||||
|
|
@ -617,7 +594,6 @@ private func SoftwareVideoReader_seekCallback(userData: UnsafeMutableRawPointer?
|
|||
}
|
||||
|
||||
final class SoftwareVideoReader {
|
||||
private let focusedPart: MediaStreamFocusedPart?
|
||||
private var readingError = false
|
||||
private var videoStream: SoftwareVideoStream?
|
||||
private var avIoContext: FFMpegAVIOContext?
|
||||
|
|
@ -636,11 +612,9 @@ final class SoftwareVideoReader {
|
|||
return self.videoStream != nil
|
||||
}
|
||||
|
||||
public init(path: String, hintVP9: Bool, passthroughDecoder: Bool = false, focusedPart: MediaStreamFocusedPart?) {
|
||||
public init(path: String, hintVP9: Bool, passthroughDecoder: Bool = false) {
|
||||
let _ = FFMpegMediaFrameSourceContextHelpers.registerFFMpegGlobals
|
||||
|
||||
self.focusedPart = focusedPart
|
||||
|
||||
var s = stat()
|
||||
stat(path, &s)
|
||||
self.size = Int32(s.st_size)
|
||||
|
|
@ -721,7 +695,7 @@ final class SoftwareVideoReader {
|
|||
break
|
||||
}
|
||||
} else {
|
||||
if let codec = FFMpegAVCodec.find(forId: codecId) {
|
||||
if let codec = FFMpegAVCodec.find(forId: codecId, preferHardwareAccelerationCapable: false) {
|
||||
let codecContext = FFMpegAVCodecContext(codec: codec)
|
||||
if avFormatContext.codecParams(atStreamIndex: streamIndex, to: codecContext) {
|
||||
if codecContext.open() {
|
||||
|
|
@ -737,12 +711,8 @@ final class SoftwareVideoReader {
|
|||
|
||||
self.videoStream = videoStream
|
||||
|
||||
if let focusedPart = self.focusedPart {
|
||||
avFormatContext.seekFrame(forStreamIndex: Int32(focusedPart.seekStreamIndex), pts: focusedPart.startPts.value, positionOnKeyframe: true)
|
||||
} else {
|
||||
if let videoStream = self.videoStream {
|
||||
avFormatContext.seekFrame(forStreamIndex: Int32(videoStream.index), pts: 0, positionOnKeyframe: true)
|
||||
}
|
||||
if let videoStream = self.videoStream {
|
||||
avFormatContext.seekFrame(forStreamIndex: Int32(videoStream.index), pts: 0, positionOnKeyframe: true)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -775,10 +745,6 @@ final class SoftwareVideoReader {
|
|||
if let videoStream = self.videoStream, Int(packet.streamIndex) == videoStream.index {
|
||||
let packetPts = packet.pts
|
||||
|
||||
if let focusedPart = self.focusedPart, packetPts >= focusedPart.endPts.value {
|
||||
self.hasReadToEnd = true
|
||||
}
|
||||
|
||||
let pts = CMTimeMake(value: packetPts, timescale: videoStream.timebase.timescale)
|
||||
let dts = CMTimeMake(value: packet.dts, timescale: videoStream.timebase.timescale)
|
||||
|
||||
|
|
@ -958,21 +924,8 @@ public func extractFFMpegMediaInfo(path: String) -> FFMpegMediaInfo? {
|
|||
var duration = CMTimeMake(value: avFormatContext.duration(atStreamIndex: streamIndex), timescale: timebase.timescale)
|
||||
duration = CMTimeMaximum(CMTime(value: 0, timescale: duration.timescale), CMTimeSubtract(duration, startTime))
|
||||
|
||||
var codecName: String?
|
||||
let codecId = avFormatContext.codecId(atStreamIndex: streamIndex)
|
||||
if codecId == FFMpegCodecIdMPEG4 {
|
||||
codecName = "mpeg4"
|
||||
} else if codecId == FFMpegCodecIdH264 {
|
||||
codecName = "h264"
|
||||
} else if codecId == FFMpegCodecIdHEVC {
|
||||
codecName = "hevc"
|
||||
} else if codecId == FFMpegCodecIdAV1 {
|
||||
codecName = "av1"
|
||||
} else if codecId == FFMpegCodecIdVP9 {
|
||||
codecName = "vp9"
|
||||
} else if codecId == FFMpegCodecIdVP8 {
|
||||
codecName = "vp8"
|
||||
}
|
||||
let codecName = resolveFFMpegCodecName(id: codecId)
|
||||
|
||||
streamInfos.append((isVideo: isVideo, info: FFMpegMediaInfo.Info(
|
||||
index: Int(streamIndex),
|
||||
|
|
@ -987,3 +940,21 @@ public func extractFFMpegMediaInfo(path: String) -> FFMpegMediaInfo? {
|
|||
|
||||
return FFMpegMediaInfo(audio: streamInfos.first(where: { !$0.isVideo })?.info, video: streamInfos.first(where: { $0.isVideo })?.info)
|
||||
}
|
||||
|
||||
public func resolveFFMpegCodecName(id: Int32) -> String? {
|
||||
if id == FFMpegCodecIdMPEG4 {
|
||||
return "mpeg4"
|
||||
} else if id == FFMpegCodecIdH264 {
|
||||
return "h264"
|
||||
} else if id == FFMpegCodecIdHEVC {
|
||||
return "hevc"
|
||||
} else if id == FFMpegCodecIdAV1 {
|
||||
return "av1"
|
||||
} else if id == FFMpegCodecIdVP9 {
|
||||
return "vp9"
|
||||
} else if id == FFMpegCodecIdVP8 {
|
||||
return "vp8"
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -146,7 +146,6 @@ private final class UniversalSoftwareVideoSourceImpl {
|
|||
self.size = sizeValue
|
||||
}
|
||||
|
||||
|
||||
self.mediaBox = mediaBox
|
||||
self.source = source
|
||||
self.automaticallyFetchHeader = automaticallyFetchHeader
|
||||
|
|
@ -210,7 +209,7 @@ private final class UniversalSoftwareVideoSourceImpl {
|
|||
let rotationAngle: Double = metrics.rotationAngle
|
||||
let aspect = Double(metrics.width) / Double(metrics.height)
|
||||
|
||||
if let codec = FFMpegAVCodec.find(forId: codecId) {
|
||||
if let codec = FFMpegAVCodec.find(forId: codecId, preferHardwareAccelerationCapable: false) {
|
||||
let codecContext = FFMpegAVCodecContext(codec: codec)
|
||||
if avFormatContext.codecParams(atStreamIndex: streamIndex, to: codecContext) {
|
||||
if codecContext.open() {
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@ private enum InviteLinksListEntry: ItemListNodeEntry {
|
|||
let arguments = arguments as! PaymentMethodListScreenArguments
|
||||
switch self {
|
||||
case let .header(text):
|
||||
return InviteLinkHeaderItem(context: arguments.context, theme: presentationData.theme, text: text, animationName: "Invite", sectionId: self.section)
|
||||
return InviteLinkHeaderItem(context: arguments.context, theme: presentationData.theme, text: NSAttributedString(string: text), animationName: "Invite", sectionId: self.section)
|
||||
case let .methodsHeader(text):
|
||||
return ItemListSectionHeaderItem(presentationData: presentationData, text: text, sectionId: self.section)
|
||||
case let .addMethod(text):
|
||||
|
|
|
|||
|
|
@ -281,7 +281,7 @@ final class PeerAvatarImageGalleryItemNode: ZoomableContentGalleryItemNode {
|
|||
let mediaManager = self.context.sharedContext.mediaManager
|
||||
let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.representation.resource, previewRepresentations: representations.map { $0.representation }, videoThumbnails: [], immediateThumbnailData: entry.immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.representation.dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: []))
|
||||
let videoContent = NativeVideoContent(id: .profileVideo(id, category), userLocation: .other, fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.representation.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: true, useLargeThumbnail: true, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear, storeAfterDownload: nil)
|
||||
let videoNode = UniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .overlay)
|
||||
let videoNode = UniversalVideoNode(context: self.context, postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .overlay)
|
||||
videoNode.isUserInteractionEnabled = false
|
||||
videoNode.isHidden = true
|
||||
self.videoStartTimestamp = video.representation.startTimestamp
|
||||
|
|
|
|||
|
|
@ -366,7 +366,7 @@ public final class PeerInfoAvatarListItemNode: ASDisplayNode {
|
|||
}
|
||||
|
||||
let mediaManager = self.context.sharedContext.mediaManager
|
||||
let videoNode = UniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .secondaryOverlay)
|
||||
let videoNode = UniversalVideoNode(context: self.context, postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .secondaryOverlay)
|
||||
videoNode.isUserInteractionEnabled = false
|
||||
videoNode.canAttachContent = true
|
||||
videoNode.isHidden = true
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ private final class ChannelMembersSearchEntry: Comparable, Identifiable {
|
|||
case let .participant(participant, label, revealActions, revealed, enabled):
|
||||
let status: ContactsPeerItemStatus
|
||||
if let label = label {
|
||||
status = .custom(string: label, multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: label), multiline: false, isActive: false, icon: nil)
|
||||
} else if let presence = participant.presences[participant.peer.id], self.addIcon {
|
||||
status = .presence(EnginePeer.Presence(presence), dateTimeFormat)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ private enum ChannelMembersSearchEntry: Comparable, Identifiable {
|
|||
case let .peer(_, participant, editing, label, enabled, isChannel, isContact):
|
||||
let status: ContactsPeerItemStatus
|
||||
if let label = label {
|
||||
status = .custom(string: label, multiline: false, isActive: false, icon: nil)
|
||||
status = .custom(string: NSAttributedString(string: label), multiline: false, isActive: false, icon: nil)
|
||||
} else if participant.peer.id != context.account.peerId {
|
||||
let presence = participant.presences[participant.peer.id] ?? TelegramUserPresence(status: .none, lastActivity: 0)
|
||||
status = .presence(EnginePeer.Presence(presence), presentationData.dateTimeFormat)
|
||||
|
|
|
|||
|
|
@ -705,6 +705,17 @@ public final class MediaBox {
|
|||
}
|
||||
}
|
||||
|
||||
public func internal_resourceDataIsCached(id: MediaResourceId, size: Int64, in range: Range<Int64>) -> Bool {
|
||||
let paths = self.storePathsForId(id)
|
||||
|
||||
if let _ = fileSize(paths.complete) {
|
||||
return true
|
||||
} else {
|
||||
let tempManager = MediaBoxFileManager(queue: nil)
|
||||
return MediaBoxPartialFile.internal_isPartialDataCached(manager: tempManager, path: paths.partial, metaPath: paths.partial + ".meta", range: range)
|
||||
}
|
||||
}
|
||||
|
||||
public func resourceData(id: MediaResourceId, size: Int64, in range: Range<Int64>, mode: ResourceDataRangeMode = .complete, notifyAboutIncomplete: Bool = false, attemptSynchronously: Bool = false) -> Signal<(Data, Bool), NoError> {
|
||||
return Signal { subscriber in
|
||||
let disposable = MetaDisposable()
|
||||
|
|
|
|||
|
|
@ -106,6 +106,16 @@ final class MediaBoxPartialFile {
|
|||
return (fd, Int(clippedRange.upperBound - clippedRange.lowerBound))
|
||||
}
|
||||
|
||||
static func internal_isPartialDataCached(manager: MediaBoxFileManager, path: String, metaPath: String, range: Range<Int64>) -> Bool {
|
||||
guard let fileMap = try? MediaBoxFileMap.read(manager: manager, path: metaPath) else {
|
||||
return false
|
||||
}
|
||||
guard let _ = fileMap.contains(range) else {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
var storedSize: Int64 {
|
||||
assert(self.queue.isCurrent())
|
||||
return self.fileMap.sum
|
||||
|
|
|
|||
|
|
@ -513,7 +513,11 @@ private final class TimeBasedCleanupImpl {
|
|||
}
|
||||
|
||||
func touch(paths: [String]) {
|
||||
self.scheduledTouches.append(contentsOf: paths)
|
||||
for path in paths {
|
||||
if !self.scheduledTouches.contains(path) {
|
||||
self.scheduledTouches.append(path)
|
||||
}
|
||||
}
|
||||
self.scheduleTouches()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ private final class PhoneView: UIView {
|
|||
hintDimensions: CGSize(width: 1170, height: 1754),
|
||||
storeAfterDownload: nil
|
||||
)
|
||||
let videoNode = UniversalVideoNode(accountId: context.account.id, postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: VideoDecoration(), content: videoContent, priority: .embedded)
|
||||
let videoNode = UniversalVideoNode(context: context, postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: VideoDecoration(), content: videoContent, priority: .embedded)
|
||||
videoNode.canAttachContent = true
|
||||
self.videoNode = videoNode
|
||||
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ private enum DeleteAccountDataEntry: ItemListNodeEntry, Equatable {
|
|||
let arguments = arguments as! DeleteAccountDataArguments
|
||||
switch self {
|
||||
case let .header(theme, animation, title, text, hideOnSmallScreens):
|
||||
return InviteLinkHeaderItem(context: arguments.context, theme: theme, title: title, text: text, animationName: animation, hideOnSmallScreens: hideOnSmallScreens, sectionId: self.section, linkAction: nil)
|
||||
return InviteLinkHeaderItem(context: arguments.context, theme: theme, title: title, text: NSAttributedString(string: text), animationName: animation, hideOnSmallScreens: hideOnSmallScreens, sectionId: self.section, linkAction: nil)
|
||||
case let .peers(_, peers):
|
||||
return DeleteAccountPeersItem(context: arguments.context, theme: presentationData.theme, strings: presentationData.strings, peers: peers, sectionId: self.section)
|
||||
case let .info(_, text):
|
||||
|
|
|
|||
|
|
@ -277,20 +277,25 @@ public final class ShareProlongedLoadingContainerNode: ASDisplayNode, ShareConte
|
|||
}))
|
||||
|
||||
if let postbox, let mediaManager = environment.mediaManager, let path = getAppBundle().path(forResource: "BlankVideo", ofType: "m4v"), let size = fileSize(path) {
|
||||
let _ = postbox
|
||||
let _ = mediaManager
|
||||
|
||||
let decoration = ChatBubbleVideoDecoration(corners: ImageCorners(), nativeSize: CGSize(width: 100.0, height: 100.0), contentMode: .aspectFit, backgroundColor: .black)
|
||||
let _ = decoration
|
||||
|
||||
let dummyFile = TelegramMediaFile(fileId: EngineMedia.Id(namespace: 0, id: 1), partialReference: nil, resource: LocalFileReferenceMediaResource(localFilePath: path, randomId: 12345), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: size, attributes: [.Video(duration: 1, size: PixelDimensions(width: 100, height: 100), flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: [])
|
||||
|
||||
let videoContent = NativeVideoContent(id: .message(1, EngineMedia.Id(namespace: 0, id: 1)), userLocation: .other, fileReference: .standalone(media: dummyFile), streamVideo: .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .black, storeAfterDownload: nil)
|
||||
let _ = videoContent
|
||||
|
||||
let videoNode = UniversalVideoNode(accountId: AccountRecordId(rawValue: 0), postbox: postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: decoration, content: videoContent, priority: .embedded)
|
||||
/*let videoNode = UniversalVideoNode(accountId: AccountRecordId(rawValue: 0), postbox: postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: decoration, content: videoContent, priority: .embedded)
|
||||
videoNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 2.0, height: 2.0))
|
||||
videoNode.alpha = 0.01
|
||||
self.videoNode = videoNode
|
||||
|
||||
self.addSubnode(videoNode)
|
||||
videoNode.canAttachContent = true
|
||||
videoNode.play()
|
||||
videoNode.play()*/
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -234,8 +234,8 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = {
|
|||
dict[1135897376] = { return Api.DefaultHistoryTTL.parse_defaultHistoryTTL($0) }
|
||||
dict[-712374074] = { return Api.Dialog.parse_dialog($0) }
|
||||
dict[1908216652] = { return Api.Dialog.parse_dialogFolder($0) }
|
||||
dict[1605718587] = { return Api.DialogFilter.parse_dialogFilter($0) }
|
||||
dict[-1612542300] = { return Api.DialogFilter.parse_dialogFilterChatlist($0) }
|
||||
dict[-1438177711] = { return Api.DialogFilter.parse_dialogFilter($0) }
|
||||
dict[-1772913705] = { return Api.DialogFilter.parse_dialogFilterChatlist($0) }
|
||||
dict[909284270] = { return Api.DialogFilter.parse_dialogFilterDefault($0) }
|
||||
dict[2004110666] = { return Api.DialogFilterSuggested.parse_dialogFilterSuggested($0) }
|
||||
dict[-445792507] = { return Api.DialogPeer.parse_dialogPeer($0) }
|
||||
|
|
@ -1226,7 +1226,7 @@ fileprivate let parsers: [Int32 : (BufferReader) -> Any?] = {
|
|||
dict[1044107055] = { return Api.channels.SponsoredMessageReportResult.parse_sponsoredMessageReportResultAdsHidden($0) }
|
||||
dict[-2073059774] = { return Api.channels.SponsoredMessageReportResult.parse_sponsoredMessageReportResultChooseOption($0) }
|
||||
dict[-1384544183] = { return Api.channels.SponsoredMessageReportResult.parse_sponsoredMessageReportResultReported($0) }
|
||||
dict[500007837] = { return Api.chatlists.ChatlistInvite.parse_chatlistInvite($0) }
|
||||
dict[-250687953] = { return Api.chatlists.ChatlistInvite.parse_chatlistInvite($0) }
|
||||
dict[-91752871] = { return Api.chatlists.ChatlistInvite.parse_chatlistInviteAlready($0) }
|
||||
dict[-1816295539] = { return Api.chatlists.ChatlistUpdates.parse_chatlistUpdates($0) }
|
||||
dict[283567014] = { return Api.chatlists.ExportedChatlistInvite.parse_exportedChatlistInvite($0) }
|
||||
|
|
|
|||
|
|
@ -476,17 +476,17 @@ public extension Api.channels {
|
|||
}
|
||||
public extension Api.chatlists {
|
||||
enum ChatlistInvite: TypeConstructorDescription {
|
||||
case chatlistInvite(flags: Int32, title: String, emoticon: String?, peers: [Api.Peer], chats: [Api.Chat], users: [Api.User])
|
||||
case chatlistInvite(flags: Int32, title: Api.TextWithEntities, emoticon: String?, peers: [Api.Peer], chats: [Api.Chat], users: [Api.User])
|
||||
case chatlistInviteAlready(filterId: Int32, missingPeers: [Api.Peer], alreadyPeers: [Api.Peer], chats: [Api.Chat], users: [Api.User])
|
||||
|
||||
public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) {
|
||||
switch self {
|
||||
case .chatlistInvite(let flags, let title, let emoticon, let peers, let chats, let users):
|
||||
if boxed {
|
||||
buffer.appendInt32(500007837)
|
||||
buffer.appendInt32(-250687953)
|
||||
}
|
||||
serializeInt32(flags, buffer: buffer, boxed: false)
|
||||
serializeString(title, buffer: buffer, boxed: false)
|
||||
title.serialize(buffer, true)
|
||||
if Int(flags) & Int(1 << 0) != 0 {serializeString(emoticon!, buffer: buffer, boxed: false)}
|
||||
buffer.appendInt32(481674261)
|
||||
buffer.appendInt32(Int32(peers.count))
|
||||
|
|
@ -545,8 +545,10 @@ public extension Api.chatlists {
|
|||
public static func parse_chatlistInvite(_ reader: BufferReader) -> ChatlistInvite? {
|
||||
var _1: Int32?
|
||||
_1 = reader.readInt32()
|
||||
var _2: String?
|
||||
_2 = parseString(reader)
|
||||
var _2: Api.TextWithEntities?
|
||||
if let signature = reader.readInt32() {
|
||||
_2 = Api.parse(reader, signature: signature) as? Api.TextWithEntities
|
||||
}
|
||||
var _3: String?
|
||||
if Int(_1!) & Int(1 << 0) != 0 {_3 = parseString(reader) }
|
||||
var _4: [Api.Peer]?
|
||||
|
|
|
|||
|
|
@ -1166,19 +1166,19 @@ public extension Api {
|
|||
}
|
||||
public extension Api {
|
||||
enum DialogFilter: TypeConstructorDescription {
|
||||
case dialogFilter(flags: Int32, id: Int32, title: String, emoticon: String?, color: Int32?, pinnedPeers: [Api.InputPeer], includePeers: [Api.InputPeer], excludePeers: [Api.InputPeer])
|
||||
case dialogFilterChatlist(flags: Int32, id: Int32, title: String, emoticon: String?, color: Int32?, pinnedPeers: [Api.InputPeer], includePeers: [Api.InputPeer])
|
||||
case dialogFilter(flags: Int32, id: Int32, title: Api.TextWithEntities, emoticon: String?, color: Int32?, pinnedPeers: [Api.InputPeer], includePeers: [Api.InputPeer], excludePeers: [Api.InputPeer])
|
||||
case dialogFilterChatlist(flags: Int32, id: Int32, title: Api.TextWithEntities, emoticon: String?, color: Int32?, pinnedPeers: [Api.InputPeer], includePeers: [Api.InputPeer])
|
||||
case dialogFilterDefault
|
||||
|
||||
public func serialize(_ buffer: Buffer, _ boxed: Swift.Bool) {
|
||||
switch self {
|
||||
case .dialogFilter(let flags, let id, let title, let emoticon, let color, let pinnedPeers, let includePeers, let excludePeers):
|
||||
if boxed {
|
||||
buffer.appendInt32(1605718587)
|
||||
buffer.appendInt32(-1438177711)
|
||||
}
|
||||
serializeInt32(flags, buffer: buffer, boxed: false)
|
||||
serializeInt32(id, buffer: buffer, boxed: false)
|
||||
serializeString(title, buffer: buffer, boxed: false)
|
||||
title.serialize(buffer, true)
|
||||
if Int(flags) & Int(1 << 25) != 0 {serializeString(emoticon!, buffer: buffer, boxed: false)}
|
||||
if Int(flags) & Int(1 << 27) != 0 {serializeInt32(color!, buffer: buffer, boxed: false)}
|
||||
buffer.appendInt32(481674261)
|
||||
|
|
@ -1199,11 +1199,11 @@ public extension Api {
|
|||
break
|
||||
case .dialogFilterChatlist(let flags, let id, let title, let emoticon, let color, let pinnedPeers, let includePeers):
|
||||
if boxed {
|
||||
buffer.appendInt32(-1612542300)
|
||||
buffer.appendInt32(-1772913705)
|
||||
}
|
||||
serializeInt32(flags, buffer: buffer, boxed: false)
|
||||
serializeInt32(id, buffer: buffer, boxed: false)
|
||||
serializeString(title, buffer: buffer, boxed: false)
|
||||
title.serialize(buffer, true)
|
||||
if Int(flags) & Int(1 << 25) != 0 {serializeString(emoticon!, buffer: buffer, boxed: false)}
|
||||
if Int(flags) & Int(1 << 27) != 0 {serializeInt32(color!, buffer: buffer, boxed: false)}
|
||||
buffer.appendInt32(481674261)
|
||||
|
|
@ -1242,8 +1242,10 @@ public extension Api {
|
|||
_1 = reader.readInt32()
|
||||
var _2: Int32?
|
||||
_2 = reader.readInt32()
|
||||
var _3: String?
|
||||
_3 = parseString(reader)
|
||||
var _3: Api.TextWithEntities?
|
||||
if let signature = reader.readInt32() {
|
||||
_3 = Api.parse(reader, signature: signature) as? Api.TextWithEntities
|
||||
}
|
||||
var _4: String?
|
||||
if Int(_1!) & Int(1 << 25) != 0 {_4 = parseString(reader) }
|
||||
var _5: Int32?
|
||||
|
|
@ -1280,8 +1282,10 @@ public extension Api {
|
|||
_1 = reader.readInt32()
|
||||
var _2: Int32?
|
||||
_2 = reader.readInt32()
|
||||
var _3: String?
|
||||
_3 = parseString(reader)
|
||||
var _3: Api.TextWithEntities?
|
||||
if let signature = reader.readInt32() {
|
||||
_3 = Api.parse(reader, signature: signature) as? Api.TextWithEntities
|
||||
}
|
||||
var _4: String?
|
||||
if Int(_1!) & Int(1 << 25) != 0 {_4 = parseString(reader) }
|
||||
var _5: Int32?
|
||||
|
|
|
|||
|
|
@ -36,8 +36,6 @@ public final class PresentationCallImpl: PresentationCall {
|
|||
public let preferredVideoCodec: String?
|
||||
public let peer: EnginePeer?
|
||||
|
||||
private let isExpectedToBeConference: Bool
|
||||
|
||||
private let serializedData: String?
|
||||
private let dataSaving: VoiceCallDataSaving
|
||||
private let proxyServer: ProxyServerSettings?
|
||||
|
|
@ -213,8 +211,6 @@ public final class PresentationCallImpl: PresentationCall {
|
|||
self.currentNetworkType = currentNetworkType
|
||||
self.updatedNetworkType = updatedNetworkType
|
||||
|
||||
self.isExpectedToBeConference = self.context.sharedContext.immediateExperimentalUISettings.conferenceCalls
|
||||
|
||||
var didReceiveAudioOutputs = false
|
||||
|
||||
var callSessionState: Signal<CallSession, NoError> = .complete()
|
||||
|
|
@ -326,18 +322,6 @@ public final class PresentationCallImpl: PresentationCall {
|
|||
self.proximityManagerIndex = DeviceProximityManager.shared().add { _ in
|
||||
}
|
||||
}
|
||||
|
||||
if self.isExpectedToBeConference {
|
||||
self.conferenceSignalingDataDisposable = self.context.account.callSessionManager.beginReceivingCallSignalingData(internalId: self.internalId, { [weak self] dataList in
|
||||
Queue.mainQueue().async {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.processConferenceSignalingData(dataList: dataList)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
|
|
@ -554,6 +538,14 @@ public final class PresentationCallImpl: PresentationCall {
|
|||
presentationState = PresentationCallState(state: .requesting(ringing), videoState: mappedVideoState, remoteVideoState: mappedRemoteVideoState, remoteAudioState: mappedRemoteAudioState, remoteBatteryLevel: mappedRemoteBatteryLevel)
|
||||
case let .active(_, _, keyVisualHash, _, _, _, _, _, _), let .switchedToConference(_, keyVisualHash, _):
|
||||
self.callWasActive = true
|
||||
|
||||
var isConference = false
|
||||
if case let .active(_, _, _, _, _, version, _, _, _) = sessionState.state {
|
||||
isConference = version == "13.0.0"
|
||||
} else if case .switchedToConference = sessionState.state {
|
||||
isConference = true
|
||||
}
|
||||
|
||||
if let callContextState = callContextState {
|
||||
switch callContextState.state {
|
||||
case .initializing:
|
||||
|
|
@ -580,7 +572,7 @@ public final class PresentationCallImpl: PresentationCall {
|
|||
}
|
||||
presentationState = PresentationCallState(state: .reconnecting(timestamp, reception, keyVisualHash), videoState: mappedVideoState, remoteVideoState: mappedRemoteVideoState, remoteAudioState: mappedRemoteAudioState, remoteBatteryLevel: mappedRemoteBatteryLevel)
|
||||
}
|
||||
} else if !self.isExpectedToBeConference {
|
||||
} else if !isConference {
|
||||
presentationState = PresentationCallState(state: .connecting(keyVisualHash), videoState: mappedVideoState, remoteVideoState: mappedRemoteVideoState, remoteAudioState: mappedRemoteAudioState, remoteBatteryLevel: mappedRemoteBatteryLevel)
|
||||
}
|
||||
}
|
||||
|
|
@ -668,8 +660,7 @@ public final class PresentationCallImpl: PresentationCall {
|
|||
conferenceCall.hasActiveIncomingData
|
||||
)
|
||||
|> map { remoteConferenceIsConnected, hasActiveIncomingData -> Bool in
|
||||
//return remoteConferenceIsConnected || hasActiveIncomingData
|
||||
return true
|
||||
return remoteConferenceIsConnected || hasActiveIncomingData
|
||||
}
|
||||
|> distinctUntilChanged
|
||||
|
||||
|
|
@ -764,7 +755,7 @@ public final class PresentationCallImpl: PresentationCall {
|
|||
}
|
||||
if !self.didDropCall {
|
||||
self.didDropCall = true
|
||||
self.callSessionManager.drop(internalId: self.internalId, reason: .disconnect, debugLog: .single(nil))
|
||||
self.callSessionManager.drop(internalId: self.internalId, reason: .hangUp, debugLog: .single(nil))
|
||||
}
|
||||
})
|
||||
|
||||
|
|
@ -795,6 +786,28 @@ public final class PresentationCallImpl: PresentationCall {
|
|||
strongSelf.audioLevelPromise.set(level)
|
||||
}
|
||||
})
|
||||
|
||||
let localIsConnected = conferenceCall.state
|
||||
|> map { state -> Bool in
|
||||
switch state.networkState {
|
||||
case .connected:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|> distinctUntilChanged
|
||||
|
||||
let bothLocalAndRemoteConnected = combineLatest(queue: .mainQueue(),
|
||||
localIsConnected,
|
||||
remoteIsConnectedAggregated
|
||||
)
|
||||
|> map { localIsConnected, remoteIsConnectedAggregated -> Bool in
|
||||
return localIsConnected && remoteIsConnectedAggregated
|
||||
}
|
||||
|> distinctUntilChanged
|
||||
|
||||
conferenceCall.internal_isRemoteConnected.set(bothLocalAndRemoteConnected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -805,13 +818,25 @@ public final class PresentationCallImpl: PresentationCall {
|
|||
self.audioSessionShouldBeActive.set(true)
|
||||
}
|
||||
case let .active(id, key, _, connections, maxLayer, version, customParameters, allowsP2P, conferenceCall):
|
||||
if conferenceCall == nil, self.isExpectedToBeConference {
|
||||
if conferenceCall == nil, version == "13.0.0" {
|
||||
self.createConferenceIfPossible()
|
||||
}
|
||||
|
||||
self.audioSessionShouldBeActive.set(true)
|
||||
|
||||
if self.isExpectedToBeConference || conferenceCallData != nil {
|
||||
if version == "13.0.0" && self.conferenceSignalingDataDisposable == nil {
|
||||
self.conferenceSignalingDataDisposable = self.context.account.callSessionManager.beginReceivingCallSignalingData(internalId: self.internalId, { [weak self] dataList in
|
||||
Queue.mainQueue().async {
|
||||
guard let self else {
|
||||
return
|
||||
}
|
||||
|
||||
self.processConferenceSignalingData(dataList: dataList)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if version == "13.0.0" || conferenceCallData != nil {
|
||||
if sessionState.isOutgoing {
|
||||
self.callKitIntegration?.reportOutgoingCallConnected(uuid: sessionState.id, at: Date())
|
||||
}
|
||||
|
|
@ -1112,9 +1137,9 @@ public final class PresentationCallImpl: PresentationCall {
|
|||
}
|
||||
|
||||
private func sendConferenceSignalingMessage(dict: [String: Any]) {
|
||||
/*if let data = try? JSONSerialization.data(withJSONObject: dict) {
|
||||
if let data = try? JSONSerialization.data(withJSONObject: dict) {
|
||||
self.context.account.callSessionManager.sendSignalingData(internalId: self.internalId, data: data)
|
||||
}*/
|
||||
}
|
||||
}
|
||||
|
||||
private func updateIsAudioSessionActive(_ value: Bool) {
|
||||
|
|
|
|||
|
|
@ -871,6 +871,9 @@ public final class PresentationGroupCallImpl: PresentationGroupCall {
|
|||
private let conferenceFromCallId: CallId?
|
||||
private let isConference: Bool
|
||||
|
||||
var internal_isRemoteConnected = Promise<Bool>()
|
||||
private var internal_isRemoteConnectedDisposable: Disposable?
|
||||
|
||||
public var onMutedSpeechActivityDetected: ((Bool) -> Void)?
|
||||
|
||||
init(
|
||||
|
|
@ -1228,6 +1231,8 @@ public final class PresentationGroupCallImpl: PresentationGroupCall {
|
|||
self.screencastFramesDisposable?.dispose()
|
||||
self.screencastAudioDataDisposable?.dispose()
|
||||
self.screencastStateDisposable?.dispose()
|
||||
|
||||
self.internal_isRemoteConnectedDisposable?.dispose()
|
||||
}
|
||||
|
||||
private func switchToTemporaryParticipantsContext(sourceContext: GroupCallParticipantsContext?, oldMyPeerId: PeerId) {
|
||||
|
|
@ -1712,9 +1717,6 @@ public final class PresentationGroupCallImpl: PresentationGroupCall {
|
|||
|
||||
var encryptionKey: Data?
|
||||
encryptionKey = self.encryptionKey?.key
|
||||
if "".isEmpty {
|
||||
encryptionKey = nil
|
||||
}
|
||||
|
||||
genericCallContext = .call(OngoingGroupCallContext(audioSessionActive: self.audioSessionActive.get(), video: self.videoCapturer, requestMediaChannelDescriptions: { [weak self] ssrcs, completion in
|
||||
let disposable = MetaDisposable()
|
||||
|
|
@ -1786,6 +1788,15 @@ public final class PresentationGroupCallImpl: PresentationGroupCall {
|
|||
})
|
||||
|
||||
self.signalBarsPromise.set(callContext.signalBars)
|
||||
|
||||
self.internal_isRemoteConnectedDisposable = (self.internal_isRemoteConnected.get()
|
||||
|> distinctUntilChanged
|
||||
|> deliverOnMainQueue).startStrict(next: { [weak callContext] isRemoteConnected in
|
||||
guard let callContext else {
|
||||
return
|
||||
}
|
||||
callContext.addRemoteConnectedEvent(isRemoteConntected: isRemoteConnected)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -2365,22 +2376,28 @@ public final class PresentationGroupCallImpl: PresentationGroupCall {
|
|||
|
||||
if let muteState = filteredMuteState {
|
||||
if muteState.canUnmute {
|
||||
switch strongSelf.isMutedValue {
|
||||
case let .muted(isPushToTalkActive):
|
||||
if !isPushToTalkActive {
|
||||
strongSelf.genericCallContext?.setIsMuted(true)
|
||||
}
|
||||
case .unmuted:
|
||||
if let currentMuteState = strongSelf.stateValue.muteState, !currentMuteState.canUnmute {
|
||||
strongSelf.isMutedValue = .muted(isPushToTalkActive: false)
|
||||
strongSelf.isMutedPromise.set(strongSelf.isMutedValue)
|
||||
strongSelf.stateValue.muteState = GroupCallParticipantsContext.Participant.MuteState(canUnmute: true, mutedByYou: false)
|
||||
strongSelf.genericCallContext?.setIsMuted(true)
|
||||
} else {
|
||||
switch strongSelf.isMutedValue {
|
||||
case .muted:
|
||||
break
|
||||
case .unmuted:
|
||||
let _ = strongSelf.updateMuteState(peerId: strongSelf.joinAsPeerId, isMuted: false)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
strongSelf.isMutedValue = .muted(isPushToTalkActive: false)
|
||||
strongSelf.isMutedPromise.set(strongSelf.isMutedValue)
|
||||
strongSelf.genericCallContext?.setIsMuted(true)
|
||||
strongSelf.stateValue.muteState = muteState
|
||||
}
|
||||
strongSelf.stateValue.muteState = muteState
|
||||
} else if let currentMuteState = strongSelf.stateValue.muteState, !currentMuteState.canUnmute {
|
||||
strongSelf.isMutedValue = .muted(isPushToTalkActive: false)
|
||||
strongSelf.isMutedPromise.set(strongSelf.isMutedValue)
|
||||
strongSelf.stateValue.muteState = GroupCallParticipantsContext.Participant.MuteState(canUnmute: true, mutedByYou: false)
|
||||
strongSelf.genericCallContext?.setIsMuted(true)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -419,7 +419,12 @@ final class VideoChatParticipantVideoComponent: Component {
|
|||
alphaTransition.setAlpha(view: titleView, alpha: controlsAlpha)
|
||||
}
|
||||
|
||||
let videoDescription: GroupCallParticipantsContext.Participant.VideoDescription? = component.maxVideoQuality == 0 ? nil : (component.isPresentation ? component.participant.presentationDescription : component.participant.videoDescription)
|
||||
let videoDescription: GroupCallParticipantsContext.Participant.VideoDescription?
|
||||
if component.isMyPeer && component.isPresentation {
|
||||
videoDescription = nil
|
||||
} else {
|
||||
videoDescription = component.maxVideoQuality == 0 ? nil : (component.isPresentation ? component.participant.presentationDescription : component.participant.videoDescription)
|
||||
}
|
||||
|
||||
var isEffectivelyPaused = false
|
||||
if let videoDescription, videoDescription.isPaused {
|
||||
|
|
|
|||
|
|
@ -152,16 +152,15 @@ extension VideoChatScreenComponent.View {
|
|||
}
|
||||
}
|
||||
|
||||
//TODO:localize
|
||||
let qualityList: [(Int, String)] = [
|
||||
(0, "Audio Only"),
|
||||
(0, environment.strings.VideoChat_IncomingVideoQuality_AudioOnly),
|
||||
(180, "180p"),
|
||||
(360, "360p"),
|
||||
(Int.max, "720p")
|
||||
]
|
||||
|
||||
let videoQualityTitle = qualityList.first(where: { $0.0 == self.maxVideoQuality })?.1 ?? ""
|
||||
items.append(.action(ContextMenuActionItem(text: "Receive Video Quality", textColor: .primary, textLayout: .secondLineWithValue(videoQualityTitle), icon: { _ in
|
||||
items.append(.action(ContextMenuActionItem(text: environment.strings.VideoChat_IncomingVideoQuality_Title, textColor: .primary, textLayout: .secondLineWithValue(videoQualityTitle), icon: { _ in
|
||||
return nil
|
||||
}, action: { [weak self] c, _ in
|
||||
guard let self else {
|
||||
|
|
|
|||
|
|
@ -342,9 +342,17 @@ extension ChatListFilter {
|
|||
case .dialogFilterDefault:
|
||||
self = .allChats
|
||||
case let .dialogFilter(flags, id, title, emoticon, color, pinnedPeers, includePeers, excludePeers):
|
||||
let titleText: String
|
||||
let titleEntities: [MessageTextEntity]
|
||||
switch title {
|
||||
case let .textWithEntities(text, entities):
|
||||
titleText = text
|
||||
titleEntities = messageTextEntitiesFromApiEntities(entities)
|
||||
}
|
||||
let disableTitleAnimations = (flags & (1 << 28)) != 0
|
||||
self = .filter(
|
||||
id: id,
|
||||
title: ChatFolderTitle(text: title, entities: [], enableAnimations: true),
|
||||
title: ChatFolderTitle(text: titleText, entities: titleEntities, enableAnimations: !disableTitleAnimations),
|
||||
emoticon: emoticon,
|
||||
data: ChatListFilterData(
|
||||
isShared: false,
|
||||
|
|
@ -392,9 +400,18 @@ extension ChatListFilter {
|
|||
)
|
||||
)
|
||||
case let .dialogFilterChatlist(flags, id, title, emoticon, color, pinnedPeers, includePeers):
|
||||
let titleText: String
|
||||
let titleEntities: [MessageTextEntity]
|
||||
switch title {
|
||||
case let .textWithEntities(text, entities):
|
||||
titleText = text
|
||||
titleEntities = messageTextEntitiesFromApiEntities(entities)
|
||||
}
|
||||
let disableTitleAnimations = (flags & (1 << 28)) != 0
|
||||
|
||||
self = .filter(
|
||||
id: id,
|
||||
title: ChatFolderTitle(text: title, entities: [], enableAnimations: true),
|
||||
title: ChatFolderTitle(text: titleText, entities: titleEntities, enableAnimations: !disableTitleAnimations),
|
||||
emoticon: emoticon,
|
||||
data: ChatListFilterData(
|
||||
isShared: true,
|
||||
|
|
@ -446,7 +463,10 @@ extension ChatListFilter {
|
|||
if data.color != nil {
|
||||
flags |= 1 << 27
|
||||
}
|
||||
return .dialogFilterChatlist(flags: flags, id: id, title: title.text, emoticon: emoticon, color: data.color?.rawValue, pinnedPeers: data.includePeers.pinnedPeers.compactMap { peerId -> Api.InputPeer? in
|
||||
if !title.enableAnimations {
|
||||
flags |= 1 << 28
|
||||
}
|
||||
return .dialogFilterChatlist(flags: flags, id: id, title: .textWithEntities(text: title.text, entities: apiEntitiesFromMessageTextEntities(title.entities, associatedPeers: SimpleDictionary())), emoticon: emoticon, color: data.color?.rawValue, pinnedPeers: data.includePeers.pinnedPeers.compactMap { peerId -> Api.InputPeer? in
|
||||
return transaction.getPeer(peerId).flatMap(apiInputPeer)
|
||||
}, includePeers: data.includePeers.peers.compactMap { peerId -> Api.InputPeer? in
|
||||
if data.includePeers.pinnedPeers.contains(peerId) {
|
||||
|
|
@ -472,7 +492,10 @@ extension ChatListFilter {
|
|||
if data.color != nil {
|
||||
flags |= 1 << 27
|
||||
}
|
||||
return .dialogFilter(flags: flags, id: id, title: title.text, emoticon: emoticon, color: data.color?.rawValue, pinnedPeers: data.includePeers.pinnedPeers.compactMap { peerId -> Api.InputPeer? in
|
||||
if !title.enableAnimations {
|
||||
flags |= 1 << 28
|
||||
}
|
||||
return .dialogFilter(flags: flags, id: id, title: .textWithEntities(text: title.text, entities: apiEntitiesFromMessageTextEntities(title.entities, associatedPeers: SimpleDictionary())), emoticon: emoticon, color: data.color?.rawValue, pinnedPeers: data.includePeers.pinnedPeers.compactMap { peerId -> Api.InputPeer? in
|
||||
return transaction.getPeer(peerId).flatMap(apiInputPeer)
|
||||
}, includePeers: data.includePeers.peers.compactMap { peerId -> Api.InputPeer? in
|
||||
if data.includePeers.pinnedPeers.contains(peerId) {
|
||||
|
|
|
|||
|
|
@ -273,9 +273,11 @@ func _internal_checkChatFolderLink(account: Account, slug: String) -> Signal<Cha
|
|||
|> mapToSignal { result -> Signal<ChatFolderLinkContents, CheckChatFolderLinkError> in
|
||||
return account.postbox.transaction { transaction -> ChatFolderLinkContents in
|
||||
switch result {
|
||||
case let .chatlistInvite(_, title, emoticon, peers, chats, users):
|
||||
case let .chatlistInvite(flags, title, emoticon, peers, chats, users):
|
||||
let _ = emoticon
|
||||
|
||||
let disableTitleAnimation = (flags & (1 << 1)) != 0
|
||||
|
||||
let parsedPeers = AccumulatedPeers(transaction: transaction, chats: chats, users: users)
|
||||
var memberCounts: [PeerId: Int] = [:]
|
||||
|
||||
|
|
@ -301,7 +303,15 @@ func _internal_checkChatFolderLink(account: Account, slug: String) -> Signal<Cha
|
|||
}
|
||||
}
|
||||
|
||||
return ChatFolderLinkContents(localFilterId: nil, title: ChatFolderTitle(text: title, entities: [], enableAnimations: true), peers: resultPeers, alreadyMemberPeerIds: alreadyMemberPeerIds, memberCounts: memberCounts)
|
||||
let titleText: String
|
||||
let titleEntities: [MessageTextEntity]
|
||||
switch title {
|
||||
case let .textWithEntities(text, entities):
|
||||
titleText = text
|
||||
titleEntities = messageTextEntitiesFromApiEntities(entities)
|
||||
}
|
||||
|
||||
return ChatFolderLinkContents(localFilterId: nil, title: ChatFolderTitle(text: titleText, entities: titleEntities, enableAnimations: !disableTitleAnimation), peers: resultPeers, alreadyMemberPeerIds: alreadyMemberPeerIds, memberCounts: memberCounts)
|
||||
case let .chatlistInviteAlready(filterId, missingPeers, alreadyPeers, chats, users):
|
||||
let parsedPeers = AccumulatedPeers(transaction: transaction, chats: chats, users: users)
|
||||
var memberCounts: [PeerId: Int] = [:]
|
||||
|
|
|
|||
|
|
@ -151,7 +151,7 @@ public final class ChatBotInfoItemNode: ListViewItemNode {
|
|||
continuePlayingWithoutSoundOnLostAudioSession: false,
|
||||
storeAfterDownload: nil
|
||||
)
|
||||
let videoNode = UniversalVideoNode(accountId: context.account.id, postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: VideoDecoration(), content: videoContent, priority: .embedded)
|
||||
let videoNode = UniversalVideoNode(context: context, postbox: context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, decoration: VideoDecoration(), content: videoContent, priority: .embedded)
|
||||
videoNode.canAttachContent = true
|
||||
self.videoNode = videoNode
|
||||
|
||||
|
|
|
|||
|
|
@ -277,7 +277,7 @@ public class ChatMessageActionBubbleContentNode: ChatMessageBubbleContentNode {
|
|||
let videoContent = NativeVideoContent(id: .profileVideo(id, "action"), userLocation: .peer(item.message.id.peerId), fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, useLargeThumbnail: true, autoFetchFullSizeThumbnail: true, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear, storeAfterDownload: nil)
|
||||
if videoContent.id != strongSelf.videoContent?.id {
|
||||
let mediaManager = item.context.sharedContext.mediaManager
|
||||
let videoNode = UniversalVideoNode(accountId: item.context.account.id, postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .secondaryOverlay)
|
||||
let videoNode = UniversalVideoNode(context: item.context, postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .secondaryOverlay)
|
||||
videoNode.isUserInteractionEnabled = false
|
||||
videoNode.ownsContentNodeUpdated = { [weak self] owns in
|
||||
if let strongSelf = self {
|
||||
|
|
|
|||
|
|
@ -763,7 +763,7 @@ public class ChatMessageInteractiveInstantVideoNode: ASDisplayNode {
|
|||
})
|
||||
}
|
||||
let mediaManager = item.context.sharedContext.mediaManager
|
||||
let videoNode = UniversalVideoNode(accountId: item.context.account.id, postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: ChatBubbleInstantVideoDecoration(inset: 2.0, backgroundImage: instantVideoBackgroundImage, tapped: {
|
||||
let videoNode = UniversalVideoNode(context: item.context, postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: ChatBubbleInstantVideoDecoration(inset: 2.0, backgroundImage: instantVideoBackgroundImage, tapped: {
|
||||
if let strongSelf = self {
|
||||
if let item = strongSelf.item {
|
||||
if strongSelf.infoBackgroundNode.alpha.isZero {
|
||||
|
|
|
|||
|
|
@ -1830,7 +1830,7 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr
|
|||
}
|
||||
)
|
||||
}
|
||||
let videoNode = UniversalVideoNode(accountId: context.account.id, postbox: context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: decoration, content: videoContent, priority: .embedded)
|
||||
let videoNode = UniversalVideoNode(context: context, postbox: context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: decoration, content: videoContent, priority: .embedded)
|
||||
videoNode.isUserInteractionEnabled = false
|
||||
videoNode.ownsContentNodeUpdated = { [weak self] owns in
|
||||
if let strongSelf = self {
|
||||
|
|
|
|||
|
|
@ -222,7 +222,7 @@ public class ChatMessageProfilePhotoSuggestionContentNode: ChatMessageBubbleCont
|
|||
let videoContent = NativeVideoContent(id: .profileVideo(id, "action"), userLocation: .peer(item.message.id.peerId), fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, useLargeThumbnail: true, autoFetchFullSizeThumbnail: true, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear, storeAfterDownload: nil)
|
||||
if videoContent.id != strongSelf.videoContent?.id {
|
||||
let mediaManager = item.context.sharedContext.mediaManager
|
||||
let videoNode = UniversalVideoNode(accountId: item.context.account.id, postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .secondaryOverlay)
|
||||
let videoNode = UniversalVideoNode(context: item.context, postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .secondaryOverlay)
|
||||
videoNode.isUserInteractionEnabled = false
|
||||
videoNode.ownsContentNodeUpdated = { [weak self] owns in
|
||||
if let strongSelf = self {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ swift_library(
|
|||
"//submodules/WallpaperBackgroundNode",
|
||||
"//submodules/TelegramPresentationData",
|
||||
"//submodules/TelegramUI/Components/EmojiStatusComponent",
|
||||
"//submodules/TelegramUI/Components/TextNodeWithEntities",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import Markdown
|
|||
import WallpaperBackgroundNode
|
||||
import EmojiStatusComponent
|
||||
import TelegramPresentationData
|
||||
import TextNodeWithEntities
|
||||
|
||||
final class BlurredRoundedRectangle: Component {
|
||||
let color: UIColor
|
||||
|
|
@ -1210,21 +1211,42 @@ public final class ChatOverscrollControl: CombinedComponent {
|
|||
}
|
||||
|
||||
public final class ChatInputPanelOverscrollNode: ASDisplayNode {
|
||||
public let text: (String, [(Int, NSRange)])
|
||||
public let text: NSAttributedString
|
||||
public let priority: Int
|
||||
private let titleNode: ImmediateTextNode
|
||||
private let titleNode: ImmediateTextNodeWithEntities
|
||||
|
||||
public init(text: (String, [(Int, NSRange)]), color: UIColor, priority: Int) {
|
||||
public init(context: AccountContext, text: NSAttributedString, color: UIColor, priority: Int) {
|
||||
self.text = text
|
||||
self.priority = priority
|
||||
self.titleNode = ImmediateTextNode()
|
||||
self.titleNode = ImmediateTextNodeWithEntities()
|
||||
|
||||
super.init()
|
||||
|
||||
let body = MarkdownAttributeSet(font: Font.regular(14.0), textColor: color)
|
||||
let bold = MarkdownAttributeSet(font: Font.bold(14.0), textColor: color)
|
||||
|
||||
self.titleNode.attributedText = addAttributesToStringWithRanges(text, body: body, argumentAttributes: [0: bold])
|
||||
let attributedText = NSMutableAttributedString(string: text.string)
|
||||
attributedText.addAttribute(.font, value: Font.regular(14.0), range: NSRange(location: 0, length: text.length))
|
||||
attributedText.addAttribute(.foregroundColor, value: color, range: NSRange(location: 0, length: text.length))
|
||||
text.enumerateAttributes(in: NSRange(location: 0, length: text.length), using: { attributes, range, _ in
|
||||
for (key, value) in attributes {
|
||||
if key == ChatTextInputAttributes.bold {
|
||||
attributedText.addAttribute(.font, value: Font.bold(14.0), range: range)
|
||||
} else if key == ChatTextInputAttributes.italic {
|
||||
attributedText.addAttribute(.font, value: Font.italic(14.0), range: range)
|
||||
} else if key == ChatTextInputAttributes.monospace {
|
||||
attributedText.addAttribute(.font, value: Font.monospace(14.0), range: range)
|
||||
} else {
|
||||
attributedText.addAttribute(key, value: value, range: range)
|
||||
}
|
||||
}
|
||||
})
|
||||
self.titleNode.attributedText = attributedText
|
||||
self.titleNode.visibility = true
|
||||
self.titleNode.arguments = TextNodeWithEntities.Arguments(
|
||||
context: context,
|
||||
cache: context.animationCache,
|
||||
renderer: context.animationRenderer,
|
||||
placeholderColor: color.withMultipliedAlpha(0.1),
|
||||
attemptSynchronous: true
|
||||
)
|
||||
|
||||
self.addSubnode(self.titleNode)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2286,7 +2286,7 @@ private class MessageContentNode: ASDisplayNode, ContentNode {
|
|||
}
|
||||
} else {
|
||||
let videoContent = NativeVideoContent(id: .message(message.stableId, video.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: video), streamVideo: .conservative, loopVideo: true, enableSound: false, fetchAutomatically: false, onlyFullSizeThumbnail: self.isStatic, continuePlayingWithoutSoundOnLostAudioSession: true, placeholderColor: .clear, captureProtected: false, storeAfterDownload: nil)
|
||||
let videoNode = UniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: self.context.sharedContext.mediaManager.audioSession, manager: self.context.sharedContext.mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .overlay, autoplay: !self.isStatic)
|
||||
let videoNode = UniversalVideoNode(context: self.context, postbox: self.context.account.postbox, audioSession: self.context.sharedContext.mediaManager.audioSession, manager: self.context.sharedContext.mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .overlay, autoplay: !self.isStatic)
|
||||
|
||||
self.videoStatusDisposable.set((videoNode.status
|
||||
|> deliverOnMainQueue).startStrict(next: { [weak self] status in
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ swift_library(
|
|||
"//submodules/PremiumUI",
|
||||
"//submodules/QrCodeUI",
|
||||
"//submodules/InviteLinksUI",
|
||||
"//submodules/Components/MultilineTextWithEntitiesComponent",
|
||||
],
|
||||
visibility = [
|
||||
"//visibility:public",
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import AccountContext
|
|||
import MultilineTextComponent
|
||||
import TelegramPresentationData
|
||||
import TelegramCore
|
||||
import MultilineTextWithEntitiesComponent
|
||||
|
||||
final class BadgeComponent: Component {
|
||||
let fillColor: UIColor
|
||||
|
|
@ -84,17 +85,20 @@ final class BadgeComponent: Component {
|
|||
}
|
||||
|
||||
final class ChatFolderLinkHeaderComponent: Component {
|
||||
let context: AccountContext
|
||||
let theme: PresentationTheme
|
||||
let strings: PresentationStrings
|
||||
let title: ChatFolderTitle
|
||||
let badge: String?
|
||||
|
||||
init(
|
||||
context: AccountContext,
|
||||
theme: PresentationTheme,
|
||||
strings: PresentationStrings,
|
||||
title: ChatFolderTitle,
|
||||
badge: String?
|
||||
) {
|
||||
self.context = context
|
||||
self.theme = theme
|
||||
self.strings = strings
|
||||
self.title = title
|
||||
|
|
@ -213,10 +217,16 @@ final class ChatFolderLinkHeaderComponent: Component {
|
|||
}
|
||||
contentWidth += spacing
|
||||
|
||||
//TODO:release
|
||||
let titleSize = self.title.update(
|
||||
transition: .immediate,
|
||||
component: AnyComponent(Text(text: component.title.text, font: Font.semibold(17.0), color: component.theme.list.itemAccentColor)),
|
||||
component: AnyComponent(MultilineTextWithEntitiesComponent(
|
||||
context: component.context,
|
||||
animationCache: component.context.animationCache,
|
||||
animationRenderer: component.context.animationRenderer,
|
||||
placeholderColor: component.theme.list.itemAccentColor.withMultipliedAlpha(0.1),
|
||||
text: .plain(component.title.attributedString(font: Font.semibold(17.0), textColor: component.theme.list.itemAccentColor)),
|
||||
manualVisibilityControl: false
|
||||
)),
|
||||
environment: {},
|
||||
containerSize: CGSize(width: 200.0, height: 100.0)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import TelegramPresentationData
|
|||
import AccountContext
|
||||
import TelegramCore
|
||||
import MultilineTextComponent
|
||||
import MultilineTextWithEntitiesComponent
|
||||
import SolidRoundedButtonComponent
|
||||
import PresentationDataUtils
|
||||
import Markdown
|
||||
|
|
@ -437,6 +438,7 @@ private final class ChatFolderLinkPreviewScreenComponent: Component {
|
|||
let topIconSize = self.topIcon.update(
|
||||
transition: contentTransition,
|
||||
component: AnyComponent(ChatFolderLinkHeaderComponent(
|
||||
context: component.context,
|
||||
theme: environment.theme,
|
||||
strings: environment.strings,
|
||||
title: component.linkContents?.title ?? ChatFolderTitle(text: "Folder", entities: [], enableAnimations: true),
|
||||
|
|
@ -457,37 +459,53 @@ private final class ChatFolderLinkPreviewScreenComponent: Component {
|
|||
contentHeight += topIconSize.height
|
||||
contentHeight += 20.0
|
||||
|
||||
let text: String
|
||||
let text: NSAttributedString
|
||||
if case .linkList = component.subject {
|
||||
text = environment.strings.FolderLinkPreview_TextLinkList
|
||||
text = NSAttributedString(string: environment.strings.FolderLinkPreview_TextLinkList)
|
||||
} else if let linkContents = component.linkContents {
|
||||
if case .remove = component.subject {
|
||||
text = environment.strings.FolderLinkPreview_TextRemoveFolder
|
||||
text = NSAttributedString(string: environment.strings.FolderLinkPreview_TextRemoveFolder, font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor)
|
||||
} else if allChatsAdded {
|
||||
text = environment.strings.FolderLinkPreview_TextAllAdded
|
||||
text = NSAttributedString(string: environment.strings.FolderLinkPreview_TextAllAdded, font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor)
|
||||
} else if linkContents.localFilterId == nil {
|
||||
text = environment.strings.FolderLinkPreview_TextAddFolder
|
||||
} else {
|
||||
text = NSAttributedString(string: environment.strings.FolderLinkPreview_TextAddFolder, font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor)
|
||||
} else if let title = linkContents.title {
|
||||
let chatCountString: String = environment.strings.FolderLinkPreview_TextAddChatsCount(Int32(canAddChatCount))
|
||||
//TODO:release
|
||||
text = environment.strings.FolderLinkPreview_TextAddChats(chatCountString, linkContents.title?.text ?? "").string
|
||||
|
||||
let textValue = NSMutableAttributedString(string: environment.strings.FolderLinkPreview_TextAddChatsV2)
|
||||
textValue.addAttributes([
|
||||
.font: Font.regular(15.0),
|
||||
.foregroundColor: environment.theme.list.freeTextColor
|
||||
], range: NSRange(location: 0, length: textValue.length))
|
||||
|
||||
let folderRange = (textValue.string as NSString).range(of: "{folder}")
|
||||
if folderRange.location != NSNotFound {
|
||||
textValue.replaceCharacters(in: folderRange, with: "")
|
||||
textValue.insert(title.attributedString(font: Font.semibold(15.0), textColor: environment.theme.list.freeTextColor), at: folderRange.location)
|
||||
}
|
||||
|
||||
let chatsRange = (textValue.string as NSString).range(of: "{chats}")
|
||||
if chatsRange.location != NSNotFound {
|
||||
textValue.replaceCharacters(in: chatsRange, with: "")
|
||||
textValue.insert(NSAttributedString(string: chatCountString, font: Font.semibold(15.0), textColor: environment.theme.list.freeTextColor), at: chatsRange.location)
|
||||
}
|
||||
|
||||
text = textValue
|
||||
} else {
|
||||
text = NSAttributedString(string: " ", font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor)
|
||||
}
|
||||
} else {
|
||||
text = " "
|
||||
text = NSAttributedString(string: " ")
|
||||
}
|
||||
|
||||
let body = MarkdownAttributeSet(font: Font.regular(15.0), textColor: environment.theme.list.freeTextColor)
|
||||
let bold = MarkdownAttributeSet(font: Font.semibold(15.0), textColor: environment.theme.list.freeTextColor)
|
||||
|
||||
let descriptionTextSize = self.descriptionText.update(
|
||||
transition: .immediate,
|
||||
component: AnyComponent(MultilineTextComponent(
|
||||
text: .markdown(text: text, attributes: MarkdownAttributes(
|
||||
body: body,
|
||||
bold: bold,
|
||||
link: body,
|
||||
linkAttribute: { _ in nil }
|
||||
)),
|
||||
component: AnyComponent(MultilineTextWithEntitiesComponent(
|
||||
context: component.context,
|
||||
animationCache: component.context.animationCache,
|
||||
animationRenderer: component.context.animationRenderer,
|
||||
placeholderColor: environment.theme.list.freeTextColor.withMultipliedAlpha(0.1),
|
||||
text: .plain(text),
|
||||
horizontalAlignment: .center,
|
||||
maximumNumberOfLines: 0
|
||||
)),
|
||||
|
|
@ -982,8 +1000,12 @@ private final class ChatFolderLinkPreviewScreenComponent: Component {
|
|||
disposable.add(component.context.account.postbox.addHiddenChatIds(peerIds: Array(self.selectedItems)))
|
||||
disposable.add(component.context.account.viewTracker.addHiddenChatListFilterIds([folderId]))
|
||||
|
||||
//TODO:release
|
||||
let folderTitle = linkContents.title?.text ?? ""
|
||||
let folderTitle: ChatFolderTitle
|
||||
if let title = linkContents.title {
|
||||
folderTitle = title
|
||||
} else {
|
||||
folderTitle = ChatFolderTitle(text: "", entities: [], enableAnimations: true)
|
||||
}
|
||||
|
||||
let presentationData = component.context.sharedContext.currentPresentationData.with({ $0 })
|
||||
|
||||
|
|
@ -1015,11 +1037,18 @@ private final class ChatFolderLinkPreviewScreenComponent: Component {
|
|||
}
|
||||
}
|
||||
|
||||
let undoText = NSMutableAttributedString(string: presentationData.strings.FolderLinkPreview_ToastLeftTitleV2)
|
||||
let folderRange = (undoText.string as NSString).range(of: "{folder}")
|
||||
if folderRange.location != NSNotFound {
|
||||
undoText.replaceCharacters(in: folderRange, with: "")
|
||||
undoText.insert(folderTitle.rawAttributedString, at: folderRange.location)
|
||||
}
|
||||
|
||||
let context = component.context
|
||||
let selectedItems = self.selectedItems
|
||||
let undoOverlayController = UndoOverlayController(
|
||||
presentationData: presentationData,
|
||||
content: .removedChat(title: presentationData.strings.FolderLinkPreview_ToastLeftTitle(folderTitle).string, text: additionalText),
|
||||
content: .removedChat(context: component.context, title: undoText, text: additionalText),
|
||||
elevatedLayout: false,
|
||||
action: { value in
|
||||
if case .commit = value {
|
||||
|
|
@ -1112,8 +1141,14 @@ private final class ChatFolderLinkPreviewScreenComponent: Component {
|
|||
}
|
||||
|
||||
if isUpdates {
|
||||
//TODO:release
|
||||
chatListController.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_add_to_folder", scale: 0.1, colors: ["__allcolors__": UIColor.white], title: presentationData.strings.FolderLinkPreview_ToastChatsAddedTitle(result.title.text).string, text: presentationData.strings.FolderLinkPreview_ToastChatsAddedText(Int32(result.newChatCount)), customUndoText: nil, timeout: 5), elevatedLayout: false, action: { _ in true }), in: .current)
|
||||
let titleString = NSMutableAttributedString(string: presentationData.strings.FolderLinkPreview_ToastChatsAddedTitleV2)
|
||||
let folderRange = (titleString.string as NSString).range(of: "{folder}")
|
||||
if folderRange.location != NSNotFound {
|
||||
titleString.replaceCharacters(in: folderRange, with: "")
|
||||
titleString.insert(result.title.rawAttributedString, at: folderRange.location)
|
||||
}
|
||||
|
||||
chatListController.present(UndoOverlayController(presentationData: presentationData, content: .universalWithEntities(context: component.context, animation: "anim_add_to_folder", scale: 0.1, colors: ["__allcolors__": UIColor.white], title: titleString, text: NSAttributedString(string: presentationData.strings.FolderLinkPreview_ToastChatsAddedText(Int32(result.newChatCount))), animateEntities: true, customUndoText: nil, timeout: 5), elevatedLayout: false, action: { _ in true }), in: .current)
|
||||
} else if result.newChatCount != 0 {
|
||||
let animationBackgroundColor: UIColor
|
||||
if presentationData.theme.overallDarkAppearance {
|
||||
|
|
@ -1121,8 +1156,15 @@ private final class ChatFolderLinkPreviewScreenComponent: Component {
|
|||
} else {
|
||||
animationBackgroundColor = UIColor(rgb: 0x474747)
|
||||
}
|
||||
//TODO:release
|
||||
chatListController.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_success", scale: 1.0, colors: ["info1.info1.stroke": animationBackgroundColor, "info2.info2.Fill": animationBackgroundColor], title: presentationData.strings.FolderLinkPreview_ToastFolderAddedTitle(result.title.text).string, text: presentationData.strings.FolderLinkPreview_ToastFolderAddedText(Int32(result.newChatCount)), customUndoText: nil, timeout: 5), elevatedLayout: false, action: { _ in true }), in: .current)
|
||||
|
||||
let titleString = NSMutableAttributedString(string: presentationData.strings.FolderLinkPreview_ToastChatsAddedTitleV2)
|
||||
let folderRange = (titleString.string as NSString).range(of: "{folder}")
|
||||
if folderRange.location != NSNotFound {
|
||||
titleString.replaceCharacters(in: folderRange, with: "")
|
||||
titleString.insert(result.title.rawAttributedString, at: folderRange.location)
|
||||
}
|
||||
|
||||
chatListController.present(UndoOverlayController(presentationData: presentationData, content: .universalWithEntities(context: component.context, animation: "anim_success", scale: 1.0, colors: ["info1.info1.stroke": animationBackgroundColor, "info2.info2.Fill": animationBackgroundColor], title: titleString, text: NSAttributedString(string: presentationData.strings.FolderLinkPreview_ToastFolderAddedText(Int32(result.newChatCount))), animateEntities: true, customUndoText: nil, timeout: 5), elevatedLayout: false, action: { _ in true }), in: .current)
|
||||
} else {
|
||||
let animationBackgroundColor: UIColor
|
||||
if presentationData.theme.overallDarkAppearance {
|
||||
|
|
@ -1130,8 +1172,15 @@ private final class ChatFolderLinkPreviewScreenComponent: Component {
|
|||
} else {
|
||||
animationBackgroundColor = UIColor(rgb: 0x474747)
|
||||
}
|
||||
//TODO:release
|
||||
chatListController.present(UndoOverlayController(presentationData: presentationData, content: .universal(animation: "anim_success", scale: 1.0, colors: ["info1.info1.stroke": animationBackgroundColor, "info2.info2.Fill": animationBackgroundColor], title: presentationData.strings.FolderLinkPreview_ToastFolderAddedTitle(result.title.text).string, text: "", customUndoText: nil, timeout: 5), elevatedLayout: false, action: { _ in true }), in: .current)
|
||||
|
||||
let titleString = NSMutableAttributedString(string: presentationData.strings.FolderLinkPreview_ToastFolderAddedTitleV2)
|
||||
let folderRange = (titleString.string as NSString).range(of: "{folder}")
|
||||
if folderRange.location != NSNotFound {
|
||||
titleString.replaceCharacters(in: folderRange, with: "")
|
||||
titleString.insert(result.title.rawAttributedString, at: folderRange.location)
|
||||
}
|
||||
|
||||
chatListController.present(UndoOverlayController(presentationData: presentationData, content: .universalWithEntities(context: component.context, animation: "anim_success", scale: 1.0, colors: ["info1.info1.stroke": animationBackgroundColor, "info2.info2.Fill": animationBackgroundColor], title: titleString, text: NSAttributedString(string: ""), animateEntities: true, customUndoText: nil, timeout: 5), elevatedLayout: false, action: { _ in true }), in: .current)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -1310,7 +1359,6 @@ private final class ChatFolderLinkPreviewScreenComponent: Component {
|
|||
})
|
||||
|
||||
let navigationController = controller.navigationController
|
||||
//TODO:release
|
||||
controller.push(folderInviteLinkListController(context: component.context, filterId: folderId, title: title, allPeerIds: peers.map(\.id), currentInvitation: link, linkUpdated: { _ in }, presentController: { [weak navigationController] c in
|
||||
(navigationController?.topViewController as? ViewController)?.present(c, in: .window(.root))
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ public enum ChatTitleContent: Equatable {
|
|||
case replies
|
||||
}
|
||||
|
||||
case peer(peerView: PeerData, customTitle: String?, onlineMemberCount: Int32?, isScheduledMessages: Bool, isMuted: Bool?, customMessageCount: Int?, isEnabled: Bool)
|
||||
case peer(peerView: PeerData, customTitle: String?, onlineMemberCount: (total: Int32?, recent: Int32?), isScheduledMessages: Bool, isMuted: Bool?, customMessageCount: Int?, isEnabled: Bool)
|
||||
case replyThread(type: ReplyThreadType, count: Int)
|
||||
case custom(String, String?, Bool)
|
||||
|
||||
|
|
@ -104,7 +104,7 @@ public enum ChatTitleContent: Equatable {
|
|||
if customTitle != rhsCustomTitle {
|
||||
return false
|
||||
}
|
||||
if onlineMemberCount != rhsOnlineMemberCount {
|
||||
if onlineMemberCount.0 != rhsOnlineMemberCount.0 || onlineMemberCount.1 != rhsOnlineMemberCount.1 {
|
||||
return false
|
||||
}
|
||||
if isScheduledMessages != rhsIsScheduledMessages {
|
||||
|
|
@ -616,7 +616,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView {
|
|||
if channel.flags.contains(.isForum), customTitle != nil {
|
||||
let string = NSAttributedString(string: EnginePeer(peer).displayTitle(strings: self.strings, displayOrder: self.nameDisplayOrder), font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor)
|
||||
state = .info(string, .generic)
|
||||
} else if let cachedChannelData = peerView.cachedData as? CachedChannelData, let memberCount = cachedChannelData.participantsSummary.memberCount {
|
||||
} else if let cachedChannelData = peerView.cachedData as? CachedChannelData, let memberCount = onlineMemberCount.total ?? cachedChannelData.participantsSummary.memberCount {
|
||||
if memberCount == 0 {
|
||||
let string: NSAttributedString
|
||||
if case .group = channel.info {
|
||||
|
|
@ -626,7 +626,7 @@ public final class ChatTitleView: UIView, NavigationBarTitleView {
|
|||
}
|
||||
state = .info(string, .generic)
|
||||
} else {
|
||||
if case .group = channel.info, let onlineMemberCount = onlineMemberCount, onlineMemberCount > 1 {
|
||||
if case .group = channel.info, let onlineMemberCount = onlineMemberCount.recent, onlineMemberCount > 1 {
|
||||
let string = NSMutableAttributedString()
|
||||
|
||||
string.append(NSAttributedString(string: "\(strings.Conversation_StatusMembers(Int32(memberCount))), ", font: subtitleFont, textColor: titleTheme.rootController.navigationBar.secondaryTextColor))
|
||||
|
|
|
|||
|
|
@ -382,6 +382,8 @@ public final class InlineStickerItemLayer: MultiAnimationRenderTarget {
|
|||
}
|
||||
}
|
||||
|
||||
public var enableAnimation: Bool = true
|
||||
|
||||
public weak var mirrorLayer: CALayer? {
|
||||
didSet {
|
||||
if let mirrorLayer = self.mirrorLayer {
|
||||
|
|
@ -742,6 +744,7 @@ public final class InlineStickerItemLayer: MultiAnimationRenderTarget {
|
|||
self.loadAnimation()
|
||||
} else {
|
||||
let isTemplate = file.isCustomTemplateEmoji
|
||||
self.updateTintColor()
|
||||
|
||||
let pointSize = arguments.pointSize
|
||||
let placeholderColor = arguments.placeholderColor
|
||||
|
|
|
|||
|
|
@ -76,6 +76,12 @@ public final class GiftItemComponent: Component {
|
|||
case anonymous
|
||||
}
|
||||
|
||||
public enum Mode: Equatable {
|
||||
case generic
|
||||
case profile
|
||||
case thumbnail
|
||||
}
|
||||
|
||||
let context: AccountContext
|
||||
let theme: PresentationTheme
|
||||
let peer: GiftItemComponent.Peer?
|
||||
|
|
@ -86,7 +92,7 @@ public final class GiftItemComponent: Component {
|
|||
let isLoading: Bool
|
||||
let isHidden: Bool
|
||||
let isSoldOut: Bool
|
||||
let isSquare: Bool
|
||||
let mode: Mode
|
||||
|
||||
public init(
|
||||
context: AccountContext,
|
||||
|
|
@ -99,7 +105,7 @@ public final class GiftItemComponent: Component {
|
|||
isLoading: Bool = false,
|
||||
isHidden: Bool = false,
|
||||
isSoldOut: Bool = false,
|
||||
isSquare: Bool = false
|
||||
mode: Mode = .generic
|
||||
) {
|
||||
self.context = context
|
||||
self.theme = theme
|
||||
|
|
@ -111,7 +117,7 @@ public final class GiftItemComponent: Component {
|
|||
self.isLoading = isLoading
|
||||
self.isHidden = isHidden
|
||||
self.isSoldOut = isSoldOut
|
||||
self.isSquare = isSquare
|
||||
self.mode = mode
|
||||
}
|
||||
|
||||
public static func ==(lhs: GiftItemComponent, rhs: GiftItemComponent) -> Bool {
|
||||
|
|
@ -145,7 +151,7 @@ public final class GiftItemComponent: Component {
|
|||
if lhs.isSoldOut != rhs.isSoldOut {
|
||||
return false
|
||||
}
|
||||
if lhs.isSquare != rhs.isSquare {
|
||||
if lhs.mode != rhs.mode {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
|
|
@ -208,15 +214,21 @@ public final class GiftItemComponent: Component {
|
|||
let size: CGSize
|
||||
let iconSize: CGSize
|
||||
let cornerRadius: CGFloat
|
||||
if component.isSquare {
|
||||
size = CGSize(width: 60.0, height: 60.0)
|
||||
iconSize = CGSize(width: 42.0, height: 42.0)
|
||||
cornerRadius = 12.0
|
||||
} else {
|
||||
switch component.mode {
|
||||
case .generic:
|
||||
size = CGSize(width: availableSize.width, height: component.title != nil ? 178.0 : 154.0)
|
||||
iconSize = CGSize(width: 88.0, height: 88.0)
|
||||
cornerRadius = 10.0
|
||||
case .profile:
|
||||
size = CGSize(width: availableSize.width, height: availableSize.width)
|
||||
iconSize = CGSize(width: 88.0, height: 88.0)
|
||||
cornerRadius = 10.0
|
||||
case .thumbnail:
|
||||
size = CGSize(width: 60.0, height: 60.0)
|
||||
iconSize = CGSize(width: 42.0, height: 42.0)
|
||||
cornerRadius = 12.0
|
||||
}
|
||||
|
||||
self.backgroundLayer.cornerRadius = cornerRadius
|
||||
|
||||
if component.isLoading {
|
||||
|
|
@ -326,7 +338,7 @@ public final class GiftItemComponent: Component {
|
|||
self.layer.addSublayer(animationLayer)
|
||||
}
|
||||
|
||||
let animationFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - iconSize.width) / 2.0), y: component.isSquare ? floorToScreenPixels((size.height - iconSize.height) / 2.0) : animationOffset), size: iconSize)
|
||||
let animationFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - iconSize.width) / 2.0), y: component.mode == .generic ? animationOffset : floorToScreenPixels((size.height - iconSize.height) / 2.0)), size: iconSize)
|
||||
if let animationLayer = self.animationLayer {
|
||||
transition.setFrame(layer: animationLayer, frame: animationFrame)
|
||||
}
|
||||
|
|
@ -361,7 +373,7 @@ public final class GiftItemComponent: Component {
|
|||
}
|
||||
}
|
||||
|
||||
if !component.isSquare {
|
||||
if case .generic = component.mode {
|
||||
if let title = component.title {
|
||||
let titleSize = self.title.update(
|
||||
transition: transition,
|
||||
|
|
@ -503,7 +515,7 @@ public final class GiftItemComponent: Component {
|
|||
avatarNode.setPeer(context: component.context, theme: component.theme, peer: nil, overrideImage: .anonymousSavedMessagesIcon(isColored: true))
|
||||
}
|
||||
|
||||
avatarNode.frame = CGRect(origin: CGPoint(x: 2.0, y: 2.0), size: CGSize(width: 20.0, height: 20.0))
|
||||
avatarNode.frame = CGRect(origin: CGPoint(x: 5.0, y: 5.0), size: CGSize(width: 20.0, height: 20.0))
|
||||
}
|
||||
|
||||
if let backgroundColor, let _ = secondBackgroundColor {
|
||||
|
|
|
|||
|
|
@ -152,7 +152,7 @@ private final class GiftTransferAlertContentNode: AlertContentNode {
|
|||
theme: self.presentationTheme,
|
||||
peer: nil,
|
||||
subject: .uniqueGift(gift: self.gift),
|
||||
isSquare: true
|
||||
mode: .thumbnail
|
||||
)
|
||||
),
|
||||
environment: {},
|
||||
|
|
|
|||
|
|
@ -330,7 +330,7 @@ public final class PeerInfoChatListPaneNode: ASDisplayNode, PeerInfoPaneNode, AS
|
|||
}
|
||||
|
||||
let context = self.context
|
||||
let undoController = UndoOverlayController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(title: self.presentationData.strings.SavedMessages_SubChatDeleted, text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in
|
||||
let undoController = UndoOverlayController(presentationData: self.context.sharedContext.currentPresentationData.with { $0 }, content: .removedChat(context: self.context, title: NSAttributedString(string: self.presentationData.strings.SavedMessages_SubChatDeleted), text: nil), elevatedLayout: false, animateInAsReplacement: true, action: { [weak self] value in
|
||||
if value == .commit {
|
||||
let _ = context.engine.messages.clearHistoryInteractively(peerId: context.account.peerId, threadId: peer.id.toInt64(), type: .forLocalPeer).startStandalone(completed: {
|
||||
guard let self else {
|
||||
|
|
|
|||
|
|
@ -333,7 +333,7 @@ final class PeerInfoAvatarTransformContainerNode: ASDisplayNode {
|
|||
self.videoNode?.removeFromSupernode()
|
||||
|
||||
let mediaManager = self.context.sharedContext.mediaManager
|
||||
let videoNode = UniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .embedded)
|
||||
let videoNode = UniversalVideoNode(context: self.context, postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .embedded)
|
||||
videoNode.isUserInteractionEnabled = false
|
||||
videoNode.isHidden = true
|
||||
|
||||
|
|
|
|||
|
|
@ -1659,7 +1659,7 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen
|
|||
)
|
||||
}
|
||||
case let .group(groupId):
|
||||
var onlineMemberCount: Signal<Int32?, NoError> = .single(nil)
|
||||
var onlineMemberCount: Signal<(total: Int32?, recent: Int32?), NoError> = .single((nil, nil))
|
||||
if peerId.namespace == Namespaces.Peer.CloudChannel {
|
||||
onlineMemberCount = context.account.viewTracker.peerView(groupId, updateData: false)
|
||||
|> map { view -> Bool? in
|
||||
|
|
@ -1676,17 +1676,21 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen
|
|||
}
|
||||
}
|
||||
|> distinctUntilChanged
|
||||
|> mapToSignal { isLarge -> Signal<Int32?, NoError> in
|
||||
|> mapToSignal { isLarge -> Signal<(total: Int32?, recent: Int32?), NoError> in
|
||||
if let isLarge = isLarge {
|
||||
if isLarge {
|
||||
return context.peerChannelMemberCategoriesContextsManager.recentOnline(account: context.account, accountPeerId: context.account.peerId, peerId: peerId)
|
||||
|> map(Optional.init)
|
||||
|> map { value -> (total: Int32?, recent: Int32?) in
|
||||
return (nil, value)
|
||||
}
|
||||
} else {
|
||||
return context.peerChannelMemberCategoriesContextsManager.recentOnlineSmall(engine: context.engine, postbox: context.account.postbox, network: context.account.network, accountPeerId: context.account.peerId, peerId: peerId)
|
||||
|> map(Optional.init)
|
||||
|> map { value -> (total: Int32?, recent: Int32?) in
|
||||
return (value.total, value.recent)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return .single(nil)
|
||||
return .single((nil, nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1695,9 +1699,11 @@ func peerInfoScreenData(context: AccountContext, peerId: PeerId, strings: Presen
|
|||
context.account.viewTracker.peerView(groupId, updateData: false),
|
||||
onlineMemberCount
|
||||
)
|
||||
|> map { peerView, onlineMemberCount -> PeerInfoStatusData? in
|
||||
if let cachedChannelData = peerView.cachedData as? CachedChannelData, let memberCount = cachedChannelData.participantsSummary.memberCount {
|
||||
if let onlineMemberCount = onlineMemberCount, onlineMemberCount > 1 {
|
||||
|> map { peerView, memberCountData -> PeerInfoStatusData? in
|
||||
let (preciseTotalMemberCount, onlineMemberCount) = memberCountData
|
||||
|
||||
if let cachedChannelData = peerView.cachedData as? CachedChannelData, let memberCount = preciseTotalMemberCount ?? cachedChannelData.participantsSummary.memberCount {
|
||||
if let onlineMemberCount, onlineMemberCount > 1 {
|
||||
var string = ""
|
||||
|
||||
string.append("\(strings.Conversation_StatusMembers(Int32(memberCount))), ")
|
||||
|
|
|
|||
|
|
@ -168,7 +168,7 @@ final class PeerInfoEditingAvatarNode: ASDisplayNode {
|
|||
self.videoNode?.removeFromSupernode()
|
||||
|
||||
let mediaManager = self.context.sharedContext.mediaManager
|
||||
let videoNode = UniversalVideoNode(accountId: self.context.account.id, postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .gallery)
|
||||
let videoNode = UniversalVideoNode(context: self.context, postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .gallery)
|
||||
videoNode.isUserInteractionEnabled = false
|
||||
self.videoStartTimestamp = video.representation.startTimestamp
|
||||
self.videoContent = videoContent
|
||||
|
|
|
|||
|
|
@ -133,7 +133,7 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr
|
|||
let itemsInRow = min(starsProducts.count, 3)
|
||||
let optionWidth = (params.size.width - sideInset * 2.0 - optionSpacing * CGFloat(itemsInRow - 1)) / CGFloat(itemsInRow)
|
||||
|
||||
let starsOptionSize = CGSize(width: optionWidth, height: 154.0)
|
||||
let starsOptionSize = CGSize(width: optionWidth, height: optionWidth)
|
||||
|
||||
let visibleBounds = self.scrollNode.bounds.insetBy(dx: 0.0, dy: -10.0)
|
||||
|
||||
|
|
@ -212,7 +212,8 @@ public final class PeerInfoGiftsPaneNode: ASDisplayNode, PeerInfoPaneNode, UIScr
|
|||
peer: peer,
|
||||
subject: subject,
|
||||
ribbon: ribbonText.flatMap { GiftItemComponent.Ribbon(text: $0, color: ribbonColor) },
|
||||
isHidden: !product.savedToProfile
|
||||
isHidden: !product.savedToProfile,
|
||||
mode: .profile
|
||||
)
|
||||
),
|
||||
effectAlignment: .center,
|
||||
|
|
|
|||
|
|
@ -172,7 +172,7 @@ private enum OldChannelsEntry: ItemListNodeEntry {
|
|||
case let .peersHeader(title):
|
||||
return ItemListSectionHeaderItem(presentationData: presentationData, text: title, sectionId: self.section)
|
||||
case let .peer(_, peer, selected):
|
||||
return ContactsPeerItem(presentationData: presentationData, style: .blocks, sectionId: self.section, sortOrder: .firstLast, displayOrder: .firstLast, context: arguments.context, peerMode: .peer, peer: .peer(peer: EnginePeer(peer.peer), chatPeer: EnginePeer(peer.peer)), status: .custom(string: localizedOldChannelDate(peer: peer, strings: presentationData.strings), multiline: false, isActive: false, icon: nil), badge: nil, enabled: true, selection: ContactsPeerItemSelection.selectable(selected: selected), editing: ContactsPeerItemEditing(editable: false, editing: false, revealed: false), options: [], actionIcon: .none, index: nil, header: nil, action: { _ in
|
||||
return ContactsPeerItem(presentationData: presentationData, style: .blocks, sectionId: self.section, sortOrder: .firstLast, displayOrder: .firstLast, context: arguments.context, peerMode: .peer, peer: .peer(peer: EnginePeer(peer.peer), chatPeer: EnginePeer(peer.peer)), status: .custom(string: NSAttributedString(string: localizedOldChannelDate(peer: peer, strings: presentationData.strings)), multiline: false, isActive: false, icon: nil), badge: nil, enabled: true, selection: ContactsPeerItemSelection.selectable(selected: selected), editing: ContactsPeerItemEditing(editable: false, editing: false, revealed: false), options: [], actionIcon: .none, index: nil, header: nil, action: { _ in
|
||||
arguments.togglePeer(peer.peer.id, true)
|
||||
}, setPeerIdWithRevealedOptions: nil, deletePeer: nil, itemHighlighting: nil, contextAction: nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ private enum OldChannelsSearchEntry: Comparable, Identifiable {
|
|||
func item(context: AccountContext, presentationData: ItemListPresentationData, interaction: OldChannelsSearchInteraction) -> ListViewItem {
|
||||
switch self {
|
||||
case let .peer(_, peer, selected):
|
||||
return ContactsPeerItem(presentationData: presentationData, style: .plain, sortOrder: .firstLast, displayOrder: .firstLast, context: context, peerMode: .peer, peer: .peer(peer: EnginePeer(peer.peer), chatPeer: EnginePeer(peer.peer)), status: .custom(string: localizedOldChannelDate(peer: peer, strings: presentationData.strings), multiline: false, isActive: false, icon: nil), badge: nil, enabled: true, selection: ContactsPeerItemSelection.selectable(selected: selected), editing: ContactsPeerItemEditing(editable: false, editing: false, revealed: false), options: [], actionIcon: .none, index: nil, header: nil, action: { _ in
|
||||
return ContactsPeerItem(presentationData: presentationData, style: .plain, sortOrder: .firstLast, displayOrder: .firstLast, context: context, peerMode: .peer, peer: .peer(peer: EnginePeer(peer.peer), chatPeer: EnginePeer(peer.peer)), status: .custom(string: NSAttributedString(string: localizedOldChannelDate(peer: peer, strings: presentationData.strings)), multiline: false, isActive: false, icon: nil), badge: nil, enabled: true, selection: ContactsPeerItemSelection.selectable(selected: selected), editing: ContactsPeerItemEditing(editable: false, editing: false, revealed: false), options: [], actionIcon: .none, index: nil, header: nil, action: { _ in
|
||||
interaction.togglePeer(peer.peer.id)
|
||||
}, setPeerIdWithRevealedOptions: nil, deletePeer: nil, itemHighlighting: nil, contextAction: nil)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -171,7 +171,7 @@ final class PeerSelectionLoadingView: UIView {
|
|||
context: context,
|
||||
peerMode: .peer,
|
||||
peer: .peer(peer: peer1, chatPeer: peer1),
|
||||
status: .custom(string: "status", multiline: false, isActive: false, icon: nil),
|
||||
status: .custom(string: NSAttributedString(string: "status"), multiline: false, isActive: false, icon: nil),
|
||||
badge: nil,
|
||||
requiresPremiumForMessaging: false,
|
||||
enabled: true,
|
||||
|
|
@ -242,7 +242,7 @@ final class PeerSelectionLoadingView: UIView {
|
|||
let titleFrame = itemNodes[sampleIndex].titleNode.frame.offsetBy(dx: 0.0, dy: currentY)
|
||||
fillLabelPlaceholderRect(origin: CGPoint(x: titleFrame.minX, y: floor(titleFrame.midY - fakeLabelPlaceholderHeight / 2.0)), width: 100.0)
|
||||
|
||||
let textFrame = itemNodes[sampleIndex].statusNode.frame.offsetBy(dx: 0.0, dy: currentY)
|
||||
let textFrame = itemNodes[sampleIndex].statusNode.textNode.frame.offsetBy(dx: 0.0, dy: currentY)
|
||||
fillLabelPlaceholderRect(origin: CGPoint(x: textFrame.minX, y: currentY + itemHeight - floor(itemNodes[sampleIndex].titleNode.frame.midY - fakeLabelPlaceholderHeight / 2.0) - fakeLabelPlaceholderHeight), width: 40.0)
|
||||
|
||||
context.setBlendMode(.normal)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue