diff --git a/.gitmodules b/.gitmodules index 1a1053bf07..7b06b63b02 100644 --- a/.gitmodules +++ b/.gitmodules @@ -14,9 +14,6 @@ url=https://github.com/bazelbuild/rules_swift.git [submodule "submodules/TgVoip/libtgvoip"] path = submodules/TgVoip/libtgvoip url = https://github.com/telegramdesktop/libtgvoip.git -[submodule "build-system/tulsi"] - path = build-system/tulsi -url=https://github.com/bazelbuild/tulsi.git [submodule "submodules/TgVoipWebrtc/tgcalls"] path = submodules/TgVoipWebrtc/tgcalls url=../tgcalls.git diff --git a/Telegram/NotificationService/Sources/NotificationService.swift b/Telegram/NotificationService/Sources/NotificationService.swift index 3ffcd997c6..74adf27ecc 100644 --- a/Telegram/NotificationService/Sources/NotificationService.swift +++ b/Telegram/NotificationService/Sources/NotificationService.swift @@ -1707,7 +1707,7 @@ private final class NotificationServiceHandler { } else if let file = media as? TelegramMediaFile { resource = file.resource for attribute in file.attributes { - if case let .Video(_, _, _, preloadSize, _) = attribute { + if case let .Video(_, _, _, preloadSize, _, _) = attribute { fetchSize = preloadSize.flatMap(Int64.init) } } diff --git a/WORKSPACE b/WORKSPACE index 03291663a6..15eb608a33 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -2,9 +2,9 @@ load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_file" http_archive( name = "bazel_features", - sha256 = "0f23d75c7623d6dba1fd30513a94860447de87c8824570521fcc966eda3151c2", - strip_prefix = "bazel_features-1.4.1", - url = "https://github.com/bazel-contrib/bazel_features/releases/download/v1.4.1/bazel_features-v1.4.1.tar.gz", + sha256 = "bdc12fcbe6076180d835c9dd5b3685d509966191760a0eb10b276025fcb76158", + strip_prefix = "bazel_features-1.17.0", + url = "https://github.com/bazel-contrib/bazel_features/releases/download/v1.17.0/bazel_features-v1.17.0.tar.gz", ) local_repository( diff --git a/build-system/Make/Make.py b/build-system/Make/Make.py index 94fd2921ec..971c8c2d80 100644 --- a/build-system/Make/Make.py +++ b/build-system/Make/Make.py @@ -100,13 +100,14 @@ class BazelCommandLine: # https://github.com/bazelbuild/rules_swift # Use -Osize instead of -O when building swift modules. - #'--features=swift.opt_uses_osize', + '--features=swift.opt_uses_osize', # --num-threads 0 forces swiftc to generate one object file per module; it: # 1. resolves issues with the linker caused by the swift-objc mixing. # 2. makes the resulting binaries significantly smaller (up to 9% for this project). - '--swiftcopt=-num-threads', '--swiftcopt=1', + #'--swiftcopt=-num-threads', '--swiftcopt=1', '--swiftcopt=-j1', + '--features=swift._num_threads_0_in_swiftcopts', # Strip unsused code. '--features=dead_strip', diff --git a/build-system/bazel-rules/apple_support b/build-system/bazel-rules/apple_support index cf271a330b..07dd08dc40 160000 --- a/build-system/bazel-rules/apple_support +++ b/build-system/bazel-rules/apple_support @@ -1 +1 @@ -Subproject commit cf271a330b08a3bbd8ad61241d03787683d5a1c5 +Subproject commit 07dd08dc40470dcf8c9c9e0f36ca100d99535722 diff --git a/build-system/bazel-rules/rules_apple b/build-system/bazel-rules/rules_apple index 345b71fc22..78c2f00932 160000 --- a/build-system/bazel-rules/rules_apple +++ b/build-system/bazel-rules/rules_apple @@ -1 +1 @@ -Subproject commit 345b71fc226d79abfe180b27b7f8d711aa398bbd +Subproject commit 78c2f00932884edb9fd34a4d2f73ab1830f28976 diff --git a/build-system/bazel-rules/rules_swift b/build-system/bazel-rules/rules_swift index 1aec64c218..86dc0f0462 160000 --- a/build-system/bazel-rules/rules_swift +++ b/build-system/bazel-rules/rules_swift @@ -1 +1 @@ -Subproject commit 1aec64c218fc057c2a836e67bd55bc514e0ef8bb +Subproject commit 86dc0f046269b3001f6f20cec38342c03120a209 diff --git a/build-system/bazel-rules/rules_xcodeproj b/build-system/bazel-rules/rules_xcodeproj index db0ce201aa..44b6f046d9 160000 --- a/build-system/bazel-rules/rules_xcodeproj +++ b/build-system/bazel-rules/rules_xcodeproj @@ -1 +1 @@ -Subproject commit db0ce201aa4f2099559d6e4b4373f7de83b81eff +Subproject commit 44b6f046d95b84933c1149fbf7f9d81fd4e32020 diff --git a/build-system/tulsi b/build-system/tulsi deleted file mode 160000 index a0bf60e164..0000000000 --- a/build-system/tulsi +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a0bf60e1645869c6452c9f3b128362d433764f19 diff --git a/submodules/AccountContext/Sources/FetchMediaUtils.swift b/submodules/AccountContext/Sources/FetchMediaUtils.swift index 08c3069e65..d23937221a 100644 --- a/submodules/AccountContext/Sources/FetchMediaUtils.swift +++ b/submodules/AccountContext/Sources/FetchMediaUtils.swift @@ -20,8 +20,8 @@ public func freeMediaFileResourceInteractiveFetched(account: Account, userLocati return fetchedMediaResource(mediaBox: account.postbox.mediaBox, userLocation: userLocation, userContentType: MediaResourceUserContentType(file: fileReference.media), reference: fileReference.resourceReference(resource)) } -public func freeMediaFileResourceInteractiveFetched(postbox: Postbox, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, resource: MediaResource) -> Signal { - return fetchedMediaResource(mediaBox: postbox.mediaBox, userLocation: userLocation, userContentType: MediaResourceUserContentType(file: fileReference.media), reference: fileReference.resourceReference(resource)) +public func freeMediaFileResourceInteractiveFetched(postbox: Postbox, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, resource: MediaResource, range: (Range, MediaBoxFetchPriority)? = nil) -> Signal { + return fetchedMediaResource(mediaBox: postbox.mediaBox, userLocation: userLocation, userContentType: MediaResourceUserContentType(file: fileReference.media), reference: fileReference.resourceReference(resource), range: range) } public func cancelFreeMediaFileInteractiveFetch(account: Account, file: TelegramMediaFile) { diff --git a/submodules/AccountContext/Sources/IsMediaStreamable.swift b/submodules/AccountContext/Sources/IsMediaStreamable.swift index 650911e79a..5eb69dcf94 100644 --- a/submodules/AccountContext/Sources/IsMediaStreamable.swift +++ b/submodules/AccountContext/Sources/IsMediaStreamable.swift @@ -18,7 +18,7 @@ public func isMediaStreamable(message: Message, media: TelegramMediaFile) -> Boo return false } for attribute in media.attributes { - if case let .Video(_, _, flags, _, _) = attribute { + if case let .Video(_, _, flags, _, _, _) = attribute { if flags.contains(.supportsStreaming) { return true } @@ -41,7 +41,7 @@ public func isMediaStreamable(media: TelegramMediaFile) -> Bool { return false } for attribute in media.attributes { - if case let .Video(_, _, flags, _, _) = attribute { + if case let .Video(_, _, flags, _, _, _) = attribute { if flags.contains(.supportsStreaming) { return true } diff --git a/submodules/AccountContext/Sources/PresentationCallManager.swift b/submodules/AccountContext/Sources/PresentationCallManager.swift index d3e44953ea..310c6846ce 100644 --- a/submodules/AccountContext/Sources/PresentationCallManager.swift +++ b/submodules/AccountContext/Sources/PresentationCallManager.swift @@ -413,6 +413,7 @@ public protocol PresentationGroupCall: AnyObject { var members: Signal { get } var audioLevels: Signal<[(EnginePeer.Id, UInt32, Float, Bool)], NoError> { get } var myAudioLevel: Signal { get } + var myAudioLevelAndSpeaking: Signal<(Float, Bool), NoError> { get } var isMuted: Signal { get } var isNoiseSuppressionEnabled: Signal { get } diff --git a/submodules/AccountContext/Sources/UniversalVideoNode.swift b/submodules/AccountContext/Sources/UniversalVideoNode.swift index c9333755ff..d224b6aa78 100644 --- a/submodules/AccountContext/Sources/UniversalVideoNode.swift +++ b/submodules/AccountContext/Sources/UniversalVideoNode.swift @@ -10,6 +10,11 @@ import UniversalMediaPlayer import AVFoundation import RangeSet +public enum UniversalVideoContentVideoQuality: Equatable { + case auto + case quality(Int) +} + public protocol UniversalVideoContentNode: AnyObject { var ready: Signal { get } var status: Signal { get } @@ -29,6 +34,8 @@ public protocol UniversalVideoContentNode: AnyObject { func continuePlayingWithoutSound(actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) func setContinuePlayingWithoutSoundOnLostAudioSession(_ value: Bool) func setBaseRate(_ baseRate: Double) + func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) + func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? func addPlaybackCompleted(_ f: @escaping () -> Void) -> Int func removePlaybackCompleted(_ index: Int) func fetchControl(_ control: UniversalVideoNodeFetchControl) @@ -329,6 +336,24 @@ public final class UniversalVideoNode: ASDisplayNode { }) } + public func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) { + self.manager.withUniversalVideoContent(id: self.content.id, { contentNode in + if let contentNode = contentNode { + contentNode.setVideoQuality(videoQuality) + } + }) + } + + public func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? { + var result: (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? + self.manager.withUniversalVideoContent(id: self.content.id, { contentNode in + if let contentNode { + result = contentNode.videoQualityState() + } + }) + return result + } + public func continuePlayingWithoutSound(actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd = .loopDisablingSound) { self.manager.withUniversalVideoContent(id: self.content.id, { contentNode in if let contentNode = contentNode { diff --git a/submodules/AvatarVideoNode/Sources/AvatarVideoNode.swift b/submodules/AvatarVideoNode/Sources/AvatarVideoNode.swift index 15fc596bea..6cc94e44f2 100644 --- a/submodules/AvatarVideoNode/Sources/AvatarVideoNode.swift +++ b/submodules/AvatarVideoNode/Sources/AvatarVideoNode.swift @@ -206,7 +206,7 @@ public final class AvatarVideoNode: ASDisplayNode { self.backgroundNode.image = nil let videoId = photo.id?.id ?? peer.id.id._internalGetInt64Value() - let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.resource, previewRepresentations: photo.representations, videoThumbnails: [], immediateThumbnailData: photo.immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.dimensions, flags: [], preloadSize: nil, coverTime: nil)])) + let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.resource, previewRepresentations: photo.representations, videoThumbnails: [], immediateThumbnailData: photo.immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: [])) let videoContent = NativeVideoContent(id: .profileVideo(videoId, nil), userLocation: .other, fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, useLargeThumbnail: true, autoFetchFullSizeThumbnail: true, startTimestamp: video.startTimestamp, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear, captureProtected: false, storeAfterDownload: nil) if videoContent.id != self.videoContent?.id { self.videoNode?.removeFromSupernode() diff --git a/submodules/BotPaymentsUI/Sources/BotCheckoutWebInteractionControllerNode.swift b/submodules/BotPaymentsUI/Sources/BotCheckoutWebInteractionControllerNode.swift index d24ef2092d..a7ffa753b9 100644 --- a/submodules/BotPaymentsUI/Sources/BotCheckoutWebInteractionControllerNode.swift +++ b/submodules/BotPaymentsUI/Sources/BotCheckoutWebInteractionControllerNode.swift @@ -2,7 +2,7 @@ import Foundation import UIKit import Display import AsyncDisplayKit -import WebKit +@preconcurrency import WebKit import TelegramPresentationData import AccountContext diff --git a/submodules/BrowserUI/Sources/BrowserDocumentContent.swift b/submodules/BrowserUI/Sources/BrowserDocumentContent.swift index 1f72255144..5932fd64de 100644 --- a/submodules/BrowserUI/Sources/BrowserDocumentContent.swift +++ b/submodules/BrowserUI/Sources/BrowserDocumentContent.swift @@ -9,7 +9,7 @@ import TelegramPresentationData import TelegramUIPreferences import PresentationDataUtils import AccountContext -import WebKit +@preconcurrency import WebKit import AppBundle import PromptUI import SafariServices diff --git a/submodules/BrowserUI/Sources/BrowserWebContent.swift b/submodules/BrowserUI/Sources/BrowserWebContent.swift index 3229d42863..46311dd2ed 100644 --- a/submodules/BrowserUI/Sources/BrowserWebContent.swift +++ b/submodules/BrowserUI/Sources/BrowserWebContent.swift @@ -9,7 +9,7 @@ import TelegramPresentationData import TelegramUIPreferences import PresentationDataUtils import AccountContext -import WebKit +@preconcurrency import WebKit import AppBundle import PromptUI import SafariServices diff --git a/submodules/Camera/Sources/VideoRecorder.swift b/submodules/Camera/Sources/VideoRecorder.swift index 09dd80a039..a2d3a7cb83 100644 --- a/submodules/Camera/Sources/VideoRecorder.swift +++ b/submodules/Camera/Sources/VideoRecorder.swift @@ -123,6 +123,10 @@ private final class VideoRecorderImpl { private var previousAppendTime: Double? public func appendVideoSampleBuffer(_ sampleBuffer: CMSampleBuffer) { + #if compiler(>=6.0) // Xcode 16 + nonisolated(unsafe) let sampleBuffer = sampleBuffer + #endif + self.queue.async { guard self.hasError() == nil && !self.stopped else { return @@ -246,6 +250,10 @@ private final class VideoRecorderImpl { } public func appendAudioSampleBuffer(_ sampleBuffer: CMSampleBuffer) { + #if compiler(>=6.0) // Xcode 16 + nonisolated(unsafe) let sampleBuffer = sampleBuffer + #endif + self.queue.async { guard self.hasError() == nil && !self.stopped else { return diff --git a/submodules/ChatImportUI/Sources/ChatImportActivityScreen.swift b/submodules/ChatImportUI/Sources/ChatImportActivityScreen.swift index 68e3aa5abd..fcc1047125 100644 --- a/submodules/ChatImportUI/Sources/ChatImportActivityScreen.swift +++ b/submodules/ChatImportUI/Sources/ChatImportActivityScreen.swift @@ -460,7 +460,7 @@ public final class ChatImportActivityScreen: ViewController { if let path = getAppBundle().path(forResource: "BlankVideo", ofType: "m4v"), let size = fileSize(path) { let decoration = ChatBubbleVideoDecoration(corners: ImageCorners(), nativeSize: CGSize(width: 100.0, height: 100.0), contentMode: .aspectFit, backgroundColor: .black) - 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)]) + 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) diff --git a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift index 5f4b035ec6..cd0e1be846 100644 --- a/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift +++ b/submodules/ChatListUI/Sources/ChatListSearchListPaneNode.swift @@ -4749,7 +4749,7 @@ public final class ChatListSearchShimmerNode: ASDisplayNode { return ListMessageItem(presentationData: ChatPresentationData(presentationData: presentationData), context: context, chatLocation: .peer(id: peer1.id), interaction: ListMessageItemInteraction.default, message: message._asMessage(), selection: hasSelection ? .selectable(selected: false) : .none, displayHeader: false, customHeader: nil, hintIsLink: true, isGlobalSearchResult: true) case .files: var media: [EngineMedia] = [] - media.append(.file(TelegramMediaFile(fileId: EngineMedia.Id(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: 0, attributes: [.FileName(fileName: "Text.txt")]))) + media.append(.file(TelegramMediaFile(fileId: EngineMedia.Id(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: 0, attributes: [.FileName(fileName: "Text.txt")], alternativeRepresentations: []))) let message = EngineMessage( stableId: 0, stableVersion: 0, @@ -4780,7 +4780,7 @@ public final class ChatListSearchShimmerNode: ASDisplayNode { return ListMessageItem(presentationData: ChatPresentationData(presentationData: presentationData), context: context, chatLocation: .peer(id: peer1.id), interaction: ListMessageItemInteraction.default, message: message._asMessage(), selection: hasSelection ? .selectable(selected: false) : .none, displayHeader: false, customHeader: nil, hintIsLink: false, isGlobalSearchResult: true) case .music: var media: [EngineMedia] = [] - media.append(.file(TelegramMediaFile(fileId: EngineMedia.Id(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: [.Audio(isVoice: false, duration: 0, title: nil, performer: nil, waveform: Data())]))) + media.append(.file(TelegramMediaFile(fileId: EngineMedia.Id(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: [.Audio(isVoice: false, duration: 0, title: nil, performer: nil, waveform: Data())], alternativeRepresentations: []))) let message = EngineMessage( stableId: 0, stableVersion: 0, @@ -4811,7 +4811,7 @@ public final class ChatListSearchShimmerNode: ASDisplayNode { return ListMessageItem(presentationData: ChatPresentationData(presentationData: presentationData), context: context, chatLocation: .peer(id: peer1.id), interaction: ListMessageItemInteraction.default, message: message._asMessage(), selection: hasSelection ? .selectable(selected: false) : .none, displayHeader: false, customHeader: nil, hintIsLink: false, isGlobalSearchResult: true) case .voice: var media: [EngineMedia] = [] - media.append(.file(TelegramMediaFile(fileId: EngineMedia.Id(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: [.Audio(isVoice: true, duration: 0, title: nil, performer: nil, waveform: Data())]))) + media.append(.file(TelegramMediaFile(fileId: EngineMedia.Id(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: [.Audio(isVoice: true, duration: 0, title: nil, performer: nil, waveform: Data())], alternativeRepresentations: []))) let message = EngineMessage( stableId: 0, stableVersion: 0, diff --git a/submodules/ChatListUI/Sources/Node/ChatListItem.swift b/submodules/ChatListUI/Sources/Node/ChatListItem.swift index 7d071155d1..0f3ea9d162 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItem.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItem.swift @@ -2584,7 +2584,7 @@ public class ChatListItemNode: ItemListRevealOptionsItemNode { case let .preview(dimensions, immediateThumbnailData, videoDuration): if let immediateThumbnailData { if let videoDuration { - let thumbnailMedia = TelegramMediaFile(fileId: MediaId(namespace: 0, id: index), partialReference: nil, resource: EmptyMediaResource(), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Video(duration: Double(videoDuration), size: dimensions ?? PixelDimensions(width: 1, height: 1), flags: [], preloadSize: nil, coverTime: nil)]) + let thumbnailMedia = TelegramMediaFile(fileId: MediaId(namespace: 0, id: index), partialReference: nil, resource: EmptyMediaResource(), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Video(duration: Double(videoDuration), size: dimensions ?? PixelDimensions(width: 1, height: 1), flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: []) contentImageSpecs.append(ContentImageSpec(message: message, media: .file(thumbnailMedia), size: fitSize)) } else { let thumbnailMedia = TelegramMediaImage(imageId: MediaId(namespace: 0, id: index), representations: [], immediateThumbnailData: immediateThumbnailData, reference: nil, partialReference: nil, flags: []) diff --git a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift index 4dc4de1c29..0882e448cd 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListItemStrings.swift @@ -246,7 +246,7 @@ public func chatListItemStrings(strings: PresentationStrings, nameDisplayOrder: processed = true break inner } - case let .Video(_, _, flags, _, _): + case let .Video(_, _, flags, _, _, _): if flags.contains(.instantRoundVideo) { messageText = strings.Message_VideoMessage processed = true diff --git a/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift b/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift index f58c2993ad..188e0dcb82 100644 --- a/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift +++ b/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift @@ -795,6 +795,7 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo if case .animateOut = stateTransition { contentRect.origin.y = self.contentRectDebugNode.frame.maxY - contentRect.size.height } + //contentRect.size.height = 200.0 } else { return } diff --git a/submodules/DebugSettingsUI/Sources/DebugController.swift b/submodules/DebugSettingsUI/Sources/DebugController.swift index b42a95bdb1..db37d2bed4 100644 --- a/submodules/DebugSettingsUI/Sources/DebugController.swift +++ b/submodules/DebugSettingsUI/Sources/DebugController.swift @@ -105,6 +105,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { case disableCallV2(Bool) case experimentalCallMute(Bool) case liveStreamV2(Bool) + case dynamicStreaming(Bool) case preferredVideoCodec(Int, String, String?, Bool) case disableVideoAspectScaling(Bool) case enableNetworkFramework(Bool) @@ -129,7 +130,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, .liveStreamV2: + 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, .liveStreamV2, .dynamicStreaming: return DebugControllerSection.experiments.rawValue case .logTranslationRecognition, .resetTranslationStates: return DebugControllerSection.translation.rawValue @@ -248,8 +249,10 @@ private enum DebugControllerEntry: ItemListNodeEntry { return 52 case .liveStreamV2: return 53 + case .dynamicStreaming: + return 54 case let .preferredVideoCodec(index, _, _, _): - return 54 + index + return 55 + index case .disableVideoAspectScaling: return 100 case .enableNetworkFramework: @@ -338,7 +341,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { let fileResource = LocalFileMediaResource(fileId: id, size: Int64(gzippedData.count), isSecretRelated: false) context.account.postbox.mediaBox.storeResourceData(fileResource.id, data: gzippedData) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: Int64(gzippedData.count), attributes: [.FileName(fileName: "Log-iOS-Full.txt.zip")]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: Int64(gzippedData.count), attributes: [.FileName(fileName: "Log-iOS-Full.txt.zip")], alternativeRepresentations: []) let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [message]).start() @@ -418,7 +421,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { let fileResource = LocalFileMediaResource(fileId: id, size: Int64(logData.count), isSecretRelated: false) context.account.postbox.mediaBox.storeResourceData(fileResource.id, data: logData) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: Int64(logData.count), attributes: [.FileName(fileName: "Log-iOS-Short.txt")]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: Int64(logData.count), attributes: [.FileName(fileName: "Log-iOS-Short.txt")], alternativeRepresentations: []) let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [message]).start() @@ -504,7 +507,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { let fileResource = LocalFileMediaResource(fileId: id, size: Int64(gzippedData.count), isSecretRelated: false) context.account.postbox.mediaBox.storeResourceData(fileResource.id, data: gzippedData) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: Int64(gzippedData.count), attributes: [.FileName(fileName: "Log-iOS-Full.txt.zip")]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: Int64(gzippedData.count), attributes: [.FileName(fileName: "Log-iOS-Full.txt.zip")], alternativeRepresentations: []) let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [message]).start() @@ -588,7 +591,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { let fileResource = LocalFileMediaResource(fileId: id, size: Int64(gzippedData.count), isSecretRelated: false) context.account.postbox.mediaBox.storeResourceData(fileResource.id, data: gzippedData) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: Int64(gzippedData.count), attributes: [.FileName(fileName: "Log-iOS-Full.txt.zip")]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: Int64(gzippedData.count), attributes: [.FileName(fileName: "Log-iOS-Full.txt.zip")], alternativeRepresentations: []) let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [message]).start() @@ -673,7 +676,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { let fileResource = LocalFileMediaResource(fileId: id, size: Int64(gzippedData.count), isSecretRelated: false) context.account.postbox.mediaBox.storeResourceData(fileResource.id, data: gzippedData) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: Int64(gzippedData.count), attributes: [.FileName(fileName: "Log-iOS-Full.txt.zip")]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: Int64(gzippedData.count), attributes: [.FileName(fileName: "Log-iOS-Full.txt.zip")], alternativeRepresentations: []) let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [message]).start() @@ -726,7 +729,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { let messages = logs.map { (name, path) -> EnqueueMessage in let id = Int64.random(in: Int64.min ... Int64.max) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: LocalFileReferenceMediaResource(localFilePath: path, randomId: id), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: nil, attributes: [.FileName(fileName: name)]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: LocalFileReferenceMediaResource(localFilePath: path, randomId: id), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: nil, attributes: [.FileName(fileName: name)], alternativeRepresentations: []) return .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) } let _ = enqueueMessages(account: context.account, peerId: peerId, messages: messages).start() @@ -835,7 +838,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { let fileResource = LocalFileMediaResource(fileId: id, size: Int64(gzippedData.count), isSecretRelated: false) context.account.postbox.mediaBox.storeResourceData(fileResource.id, data: gzippedData) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/zip", size: Int64(gzippedData.count), attributes: [.FileName(fileName: "Log-iOS-All.txt.zip")]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/zip", size: Int64(gzippedData.count), attributes: [.FileName(fileName: "Log-iOS-All.txt.zip")], alternativeRepresentations: []) let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [message]).start() @@ -890,7 +893,7 @@ private enum DebugControllerEntry: ItemListNodeEntry { let fileResource = LocalFileMediaResource(fileId: id, size: Int64(allStatsData.count), isSecretRelated: false) context.account.postbox.mediaBox.storeResourceData(fileResource.id, data: allStatsData) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/zip", size: Int64(allStatsData.count), attributes: [.FileName(fileName: "StorageReport.txt")]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/zip", size: Int64(allStatsData.count), attributes: [.FileName(fileName: "StorageReport.txt")], alternativeRepresentations: []) let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [message]).start() @@ -1348,6 +1351,16 @@ private enum DebugControllerEntry: ItemListNodeEntry { }) }).start() }) + case let .dynamicStreaming(value): + return ItemListSwitchItem(presentationData: presentationData, title: "Dynamic Streaming", 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.dynamicStreaming = value + return PreferencesEntry(settings) + }) + }).start() + }) case let .preferredVideoCodec(_, title, value, isSelected): return ItemListCheckboxItem(presentationData: presentationData, title: title, style: .right, checked: isSelected, zeroSeparatorInsets: false, sectionId: self.section, action: { let _ = arguments.sharedContext.accountManager.transaction ({ transaction in @@ -1505,6 +1518,7 @@ private func debugControllerEntries(sharedContext: SharedAccountContext, present entries.append(.disableCallV2(experimentalSettings.disableCallV2)) entries.append(.experimentalCallMute(experimentalSettings.experimentalCallMute)) entries.append(.liveStreamV2(experimentalSettings.liveStreamV2)) + entries.append(.dynamicStreaming(experimentalSettings.dynamicStreaming)) } /*let codecs: [(String, String?)] = [ @@ -1673,7 +1687,7 @@ public func triggerDebugSendLogsUI(context: AccountContext, additionalInfo: Stri let fileResource = LocalFileMediaResource(fileId: id, size: Int64(gzippedData.count), isSecretRelated: false) context.account.postbox.mediaBox.storeResourceData(fileResource.id, data: gzippedData) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: Int64(gzippedData.count), attributes: [.FileName(fileName: "Log-iOS-Full.txt.zip")]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: Int64(gzippedData.count), attributes: [.FileName(fileName: "Log-iOS-Full.txt.zip")], alternativeRepresentations: []) let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [message]).start() diff --git a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift index 127cce67fa..d47d42bd3f 100644 --- a/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift +++ b/submodules/GalleryUI/Sources/ChatItemGalleryFooterContentNode.swift @@ -859,7 +859,7 @@ final class ChatItemGalleryFooterContentNode: GalleryFooterContentNode, ASScroll } else if let media = media as? TelegramMediaFile, !media.isAnimated { for attribute in media.attributes { switch attribute { - case let .Video(_, dimensions, _, _, _): + case let .Video(_, dimensions, _, _, _, _): isVideo = true if dimensions.height > 0 { if CGFloat(dimensions.width) / CGFloat(dimensions.height) > 1.33 { diff --git a/submodules/GalleryUI/Sources/GalleryController.swift b/submodules/GalleryUI/Sources/GalleryController.swift index 5c65e2a77d..c5e76739a1 100644 --- a/submodules/GalleryUI/Sources/GalleryController.swift +++ b/submodules/GalleryUI/Sources/GalleryController.swift @@ -245,7 +245,11 @@ public func galleryItemForEntry( 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) }), loopVideo: true, enableSound: false, tempFilePath: tempFilePath, captureProtected: captureProtected, storeAfterDownload: generateStoreAfterDownload?(message, file)) } else { if true || (file.mimeType == "video/mpeg4" || file.mimeType == "video/mov" || file.mimeType == "video/mp4") { - 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)) + if NativeVideoContent.isHLSVideo(file: file), context.sharedContext.immediateExperimentalUISettings.dynamicStreaming { + content = HLSVideoContent(id: .message(message.id, message.stableId, file.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: file), streamVideo: streamVideos, loopVideo: loopVideos) + } 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)) + } } else { content = PlatformVideoContent(id: .message(message.id, message.stableId, file.fileId), userLocation: .peer(message.id.peerId), content: .file(.message(message: MessageReference(message), media: file)), streamVideo: streamVideos, loopVideo: loopVideos) } diff --git a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift index 074f443cda..fd7be6047b 100644 --- a/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift +++ b/submodules/GalleryUI/Sources/Items/UniversalVideoGalleryItem.swift @@ -1096,6 +1096,8 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { var hasLinkedStickers = false if let content = item.content as? NativeVideoContent { hasLinkedStickers = content.fileReference.media.hasLinkedStickers + } else if let content = item.content as? HLSVideoContent { + hasLinkedStickers = content.fileReference.media.hasLinkedStickers } var disablePictureInPicture = false @@ -1241,7 +1243,7 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } if let file = file { for attribute in file.attributes { - if case let .Video(duration, _, _, _, _) = attribute, duration >= 30 { + if case let .Video(duration, _, _, _, _, _) = attribute, duration >= 30 { hintSeekable = true break } @@ -1532,6 +1534,8 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { if let _ = item.content as? NativeVideoContent { self.playbackRate = item.playbackRate() + } else if let _ = item.content as? HLSVideoContent { + self.playbackRate = item.playbackRate() } else if let _ = item.content as? WebEmbedVideoContent { self.playbackRate = item.playbackRate() } @@ -1602,6 +1606,8 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { if isLocal || isStreamable { return true } + } else if let item = self.item, let _ = item.content as? HLSVideoContent { + return true } else if let item = self.item, let _ = item.content as? PlatformVideoContent { return true } @@ -1619,6 +1625,8 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { var isAnimated = false if let item = self.item, let content = item.content as? NativeVideoContent { isAnimated = content.fileReference.media.isAnimated + } else if let item = self.item, let content = item.content as? HLSVideoContent { + isAnimated = content.fileReference.media.isAnimated } self.hideStatusNodeUntilCentrality = false @@ -1712,6 +1720,11 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { if let time = item.timecode { seek = .timecode(time) } + } else if let content = item.content as? HLSVideoContent { + isAnimated = content.fileReference.media.isAnimated + if let time = item.timecode { + seek = .timecode(time) + } } else if let _ = item.content as? WebEmbedVideoContent { if let time = item.timecode { seek = .timecode(time) @@ -1743,6 +1756,9 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { if !item.isSecret, let content = item.content as? NativeVideoContent, content.duration <= 30 { return .loop } + if !item.isSecret, let content = item.content as? HLSVideoContent, content.duration <= 30 { + return .loop + } } return .stop } @@ -2700,6 +2716,35 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { items.append(.separator) + if let videoQualityState = strongSelf.videoNode?.videoQualityState(), !videoQualityState.available.isEmpty { + //TODO:localize + + let qualityText: String + switch videoQualityState.preferred { + case .auto: + if videoQualityState.current != 0 { + qualityText = "Auto (\(videoQualityState.current)p)" + } else { + qualityText = "Auto" + } + case let .quality(value): + qualityText = "\(value)p" + } + + items.append(.action(ContextMenuActionItem(text: "Video Quality", textLayout: .secondLineWithValue(qualityText), icon: { _ in + return nil + }, action: { c, _ in + guard let strongSelf = self else { + c?.dismiss(completion: nil) + return + } + + c?.setItems(.single(ContextController.Items(content: .list(strongSelf.contextMenuVideoQualityItems(dismiss: dismiss)))), minHeight: nil, animated: true) + }))) + + items.append(.separator) + } + if let (message, _, _) = strongSelf.contentInfo() { let context = strongSelf.context items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.SharedMedia_ViewInChat, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/GoToMessage"), color: theme.contextMenu.primaryColor)}, action: { [weak self] _, f in @@ -2881,6 +2926,80 @@ final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode { } } + private func contextMenuVideoQualityItems(dismiss: @escaping () -> Void) -> [ContextMenuItem] { + guard let videoNode = self.videoNode else { + return [] + } + guard let qualityState = videoNode.videoQualityState(), !qualityState.available.isEmpty else { + return [] + } + + var items: [ContextMenuItem] = [] + + items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Common_Back, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) + }, iconPosition: .left, action: { [weak self] c, _ in + guard let self else { + c?.dismiss(completion: nil) + return + } + c?.setItems(self.contextMenuMainItems(dismiss: dismiss) |> map { ContextController.Items(content: .list($0)) }, minHeight: nil, animated: true) + }))) + + do { + let isSelected = qualityState.preferred == .auto + let qualityText: String + if qualityState.current != 0 { + qualityText = "Auto (\(qualityState.current)p)" + } else { + qualityText = "Auto" + } + items.append(.action(ContextMenuActionItem(text: qualityText, icon: { _ in + if isSelected { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: .white) + } else { + return nil + } + }, action: { [weak self] _, f in + f(.default) + + guard let self, let videoNode = self.videoNode else { + return + } + videoNode.setVideoQuality(.auto) + + /*if let controller = strongSelf.galleryController() as? GalleryController { + controller.updateSharedPlaybackRate(rate) + }*/ + }))) + } + + for quality in qualityState.available { + //TODO:release + let isSelected = qualityState.preferred == .quality(quality) + items.append(.action(ContextMenuActionItem(text: "\(quality)p", icon: { _ in + if isSelected { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: .white) + } else { + return nil + } + }, action: { [weak self] _, f in + f(.default) + + guard let self, let videoNode = self.videoNode else { + return + } + videoNode.setVideoQuality(.quality(quality)) + + /*if let controller = strongSelf.galleryController() as? GalleryController { + controller.updateSharedPlaybackRate(rate) + }*/ + }))) + } + + return items + } + private var isAirPlayActive = false private var externalVideoPlayer: ExternalVideoPlayer? func beginAirPlaySetup() { diff --git a/submodules/ICloudResources/Sources/ICloudResources.swift b/submodules/ICloudResources/Sources/ICloudResources.swift index c24cb17a46..2ef67e5e9d 100644 --- a/submodules/ICloudResources/Sources/ICloudResources.swift +++ b/submodules/ICloudResources/Sources/ICloudResources.swift @@ -132,20 +132,24 @@ public func iCloudFileDescription(_ url: URL) -> Signal Signal 0 && (timer <= 60 || timer == viewOnceTimeout) { @@ -651,7 +651,7 @@ public func legacyAssetPickerEnqueueMessages(context: AccountContext, account: A var randomId: Int64 = 0 arc4random_buf(&randomId, 8) let resource = LocalFileReferenceMediaResource(localFilePath: path, randomId: randomId) - let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: mimeType, size: nil, attributes: [.FileName(fileName: name)]) + let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: mimeType, size: nil, attributes: [.FileName(fileName: name)], alternativeRepresentations: []) var attributes: [MessageAttribute] = [] let text = trimChatInputText(convertMarkdownToAttributes(caption ?? NSAttributedString())) @@ -704,7 +704,7 @@ public func legacyAssetPickerEnqueueMessages(context: AccountContext, account: A var randomId: Int64 = 0 arc4random_buf(&randomId, 8) let resource = PhotoLibraryMediaResource(localIdentifier: asset.localIdentifier, uniqueId: Int64.random(in: Int64.min ... Int64.max)) - let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: mimeType, size: nil, attributes: [.FileName(fileName: name)]) + let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: mimeType, size: nil, attributes: [.FileName(fileName: name)], alternativeRepresentations: []) var attributes: [MessageAttribute] = [] let text = trimChatInputText(convertMarkdownToAttributes(caption ?? NSAttributedString())) @@ -857,7 +857,7 @@ public func legacyAssetPickerEnqueueMessages(context: AccountContext, account: A fileAttributes.append(.Animated) } if !asFile { - fileAttributes.append(.Video(duration: finalDuration, size: PixelDimensions(finalDimensions), flags: [.supportsStreaming], preloadSize: nil, coverTime: nil)) + fileAttributes.append(.Video(duration: finalDuration, size: PixelDimensions(finalDimensions), flags: [.supportsStreaming], preloadSize: nil, coverTime: nil, videoCodec: nil)) if let adjustments = adjustments { if adjustments.sendAsGif { fileAttributes.append(.Animated) @@ -891,7 +891,7 @@ public func legacyAssetPickerEnqueueMessages(context: AccountContext, account: A fileAttributes.append(.HasLinkedStickers) } - let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: fileAttributes) + let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: fileAttributes, alternativeRepresentations: []) if let timer = item.timer, timer > 0 && (timer <= 60 || timer == viewOnceTimeout) { attributes.append(AutoremoveTimeoutMessageAttribute(timeout: Int32(timer), countdownBeginTime: nil)) diff --git a/submodules/MtProtoKit/Sources/PingFoundation.h b/submodules/MtProtoKit/Sources/PingFoundation.h index ffb54db275..9c6d5a0234 100644 --- a/submodules/MtProtoKit/Sources/PingFoundation.h +++ b/submodules/MtProtoKit/Sources/PingFoundation.h @@ -8,6 +8,8 @@ #include +#import + #pragma mark * PingFoundation @protocol PingFoundationDelegate; @@ -59,7 +61,7 @@ typedef NS_ENUM(NSInteger, PingFoundationAddressStyle) { /*! The address family for `hostAddress`, or `AF_UNSPEC` if that's nil. */ -@property (nonatomic, assign, readonly) sa_family_t hostAddressFamily; +@property (nonatomic, assign, readonly) __uint8_t hostAddressFamily; /*! The identifier used by pings by this object. * \details When you create an instance of this object it generates a random identifier diff --git a/submodules/PeerAvatarGalleryUI/Sources/PeerAvatarImageGalleryItem.swift b/submodules/PeerAvatarGalleryUI/Sources/PeerAvatarImageGalleryItem.swift index 37b5bc76e6..5d15ce66df 100644 --- a/submodules/PeerAvatarGalleryUI/Sources/PeerAvatarImageGalleryItem.swift +++ b/submodules/PeerAvatarGalleryUI/Sources/PeerAvatarImageGalleryItem.swift @@ -187,7 +187,7 @@ final class PeerAvatarImageGalleryItemNode: ZoomableContentGalleryItemNode { let subject: ShareControllerSubject var actionCompletionText: String? if let video = entry.videoRepresentations.last, let peerReference = PeerReference(peer._asPeer()) { - let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.representation.resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.representation.dimensions, flags: [], preloadSize: nil, coverTime: nil)])) + let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.representation.resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.representation.dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: [])) subject = .media(videoFileReference.abstract) actionCompletionText = strongSelf.presentationData.strings.Gallery_VideoSaved } else { @@ -279,7 +279,7 @@ final class PeerAvatarImageGalleryItemNode: ZoomableContentGalleryItemNode { if let video = entry.videoRepresentations.last, let peerReference = PeerReference(self.peer._asPeer()) { if video != previousVideoRepresentations?.last { 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)])) + 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(postbox: self.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: videoContent, priority: .overlay) videoNode.isUserInteractionEnabled = false diff --git a/submodules/PeerInfoAvatarListNode/Sources/PeerInfoAvatarListNode.swift b/submodules/PeerInfoAvatarListNode/Sources/PeerInfoAvatarListNode.swift index a3ba818d23..b9f3a37afc 100644 --- a/submodules/PeerInfoAvatarListNode/Sources/PeerInfoAvatarListNode.swift +++ b/submodules/PeerInfoAvatarListNode/Sources/PeerInfoAvatarListNode.swift @@ -515,7 +515,7 @@ public final class PeerInfoAvatarListItemNode: ASDisplayNode { self.isReady.set(.single(true)) } } else if let video = videoRepresentations.last, let peerReference = PeerReference(self.peer._asPeer()) { - 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: immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.representation.dimensions, flags: [], preloadSize: nil, coverTime: nil)])) + 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: 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, nil), userLocation: .other, fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.representation.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: fullSizeOnly, useLargeThumbnail: true, autoFetchFullSizeThumbnail: true, startTimestamp: video.representation.startTimestamp, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear, storeAfterDownload: nil) if videoContent.id != self.videoContent?.id { diff --git a/submodules/Postbox/Sources/Media.swift b/submodules/Postbox/Sources/Media.swift index 066afb81ec..4c0e3e28eb 100644 --- a/submodules/Postbox/Sources/Media.swift +++ b/submodules/Postbox/Sources/Media.swift @@ -101,6 +101,18 @@ public func areMediaArraysEqual(_ lhs: [Media], _ rhs: [Media]) -> Bool { return true } +public func areMediaArraysSemanticallyEqual(_ lhs: [Media], _ rhs: [Media]) -> Bool { + if lhs.count != rhs.count { + return false + } + for i in 0 ..< lhs.count { + if !lhs[i].isSemanticallyEqual(to: rhs[i]) { + return false + } + } + return true +} + public func areMediaDictionariesEqual(_ lhs: [MediaId: Media], _ rhs: [MediaId: Media]) -> Bool { if lhs.count != rhs.count { return false diff --git a/submodules/Postbox/Sources/Utils/Encoder/AdaptedPostboxUnkeyedEncodingContainer.swift b/submodules/Postbox/Sources/Utils/Encoder/AdaptedPostboxUnkeyedEncodingContainer.swift index 365aa0e83d..b97d6453df 100644 --- a/submodules/Postbox/Sources/Utils/Encoder/AdaptedPostboxUnkeyedEncodingContainer.swift +++ b/submodules/Postbox/Sources/Utils/Encoder/AdaptedPostboxUnkeyedEncodingContainer.swift @@ -159,10 +159,6 @@ extension _AdaptedPostboxEncoder.UnkeyedContainer: UnkeyedEncodingContainer { func encode(_ value: String) throws { self.items.append(.string(value)) } - - func encode(_ value: Data) throws { - self.items.append(.data(value)) - } func nestedContainer(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer where NestedKey : CodingKey { preconditionFailure() @@ -177,6 +173,12 @@ extension _AdaptedPostboxEncoder.UnkeyedContainer: UnkeyedEncodingContainer { } } +private extension _AdaptedPostboxEncoder.UnkeyedContainer { + func encode(_ value: Data) throws { + self.items.append(.data(value)) + } +} + extension _AdaptedPostboxEncoder.UnkeyedContainer: AdaptedPostboxEncodingContainer { func makeData() -> Data { preconditionFailure() diff --git a/submodules/PremiumUI/Sources/PremiumDemoScreen.swift b/submodules/PremiumUI/Sources/PremiumDemoScreen.swift index fc876e6e81..51a2d5e99b 100644 --- a/submodules/PremiumUI/Sources/PremiumDemoScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumDemoScreen.swift @@ -645,7 +645,8 @@ private final class DemoSheetContent: CombinedComponent { immediateThumbnailData: file.immediateThumbnailData, mimeType: file.mimeType, size: file.size, - attributes: file.attributes + attributes: file.attributes, + alternativeRepresentations: file.alternativeRepresentations ) } default: diff --git a/submodules/PremiumUI/Sources/PremiumLimitsListScreen.swift b/submodules/PremiumUI/Sources/PremiumLimitsListScreen.swift index 3f1fa037fd..77609c80f6 100644 --- a/submodules/PremiumUI/Sources/PremiumLimitsListScreen.swift +++ b/submodules/PremiumUI/Sources/PremiumLimitsListScreen.swift @@ -158,7 +158,8 @@ public class PremiumLimitsListScreen: ViewController { immediateThumbnailData: file.immediateThumbnailData, mimeType: file.mimeType, size: file.size, - attributes: file.attributes + attributes: file.attributes, + alternativeRepresentations: file.alternativeRepresentations ) } default: diff --git a/submodules/SettingsUI/Sources/BubbleSettings/BubbleSettingsController.swift b/submodules/SettingsUI/Sources/BubbleSettings/BubbleSettingsController.swift index 312ebb57a8..c039babd7d 100644 --- a/submodules/SettingsUI/Sources/BubbleSettings/BubbleSettingsController.swift +++ b/submodules/SettingsUI/Sources/BubbleSettings/BubbleSettingsController.swift @@ -177,7 +177,7 @@ private final class BubbleSettingsControllerNode: ASDisplayNode, ASScrollViewDel let waveformBase64 = "DAAOAAkACQAGAAwADwAMABAADQAPABsAGAALAA0AGAAfABoAHgATABgAGQAYABQADAAVABEAHwANAA0ACQAWABkACQAOAAwACQAfAAAAGQAVAAAAEwATAAAACAAfAAAAHAAAABwAHwAAABcAGQAAABQADgAAABQAHwAAAB8AHwAAAAwADwAAAB8AEwAAABoAFwAAAB8AFAAAAAAAHwAAAAAAHgAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAAAA=" let voiceAttributes: [TelegramMediaFileAttribute] = [.Audio(isVoice: true, duration: 23, title: nil, performer: nil, waveform: Data(base64Encoded: waveformBase64)!)] - let voiceMedia = TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: voiceAttributes) + let voiceMedia = TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: voiceAttributes, alternativeRepresentations: []) let message3 = Message(stableId: 1, stableVersion: 0, id: MessageId(peerId: peerId, namespace: 0, id: 1), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 66001, flags: [.Incoming], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: peers[peerId], text: "", attributes: [], media: [voiceMedia], peers: peers, associatedMessages: messages, associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) items.append(self.context.sharedContext.makeChatMessagePreviewItem(context: self.context, messages: [message3], theme: self.presentationData.theme, strings: self.presentationData.strings, wallpaper: self.presentationData.chatWallpaper, fontSize: self.presentationData.chatFontSize, chatBubbleCorners: self.presentationData.chatBubbleCorners, dateTimeFormat: self.presentationData.dateTimeFormat, nameOrder: self.presentationData.nameDisplayOrder, forcedResourceStatus: FileMediaResourceStatus(mediaStatus: .playbackStatus(.paused), fetchStatus: .Local), tapMessage: nil, clickThroughMessage: nil, backgroundNode: self.chatBackgroundNode, availableReactions: nil, accountPeer: nil, isCentered: false, isPreview: true, isStandalone: false)) diff --git a/submodules/SettingsUI/Sources/Text Size/TextSizeSelectionController.swift b/submodules/SettingsUI/Sources/Text Size/TextSizeSelectionController.swift index 347f115d99..0ea046eeaa 100644 --- a/submodules/SettingsUI/Sources/Text Size/TextSizeSelectionController.swift +++ b/submodules/SettingsUI/Sources/Text Size/TextSizeSelectionController.swift @@ -450,7 +450,7 @@ private final class TextSizeSelectionControllerNode: ASDisplayNode, ASScrollView let waveformBase64 = "DAAOAAkACQAGAAwADwAMABAADQAPABsAGAALAA0AGAAfABoAHgATABgAGQAYABQADAAVABEAHwANAA0ACQAWABkACQAOAAwACQAfAAAAGQAVAAAAEwATAAAACAAfAAAAHAAAABwAHwAAABcAGQAAABQADgAAABQAHwAAAB8AHwAAAAwADwAAAB8AEwAAABoAFwAAAB8AFAAAAAAAHwAAAAAAHgAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAAAA=" let voiceAttributes: [TelegramMediaFileAttribute] = [.Audio(isVoice: true, duration: 23, title: nil, performer: nil, waveform: Data(base64Encoded: waveformBase64)!)] - let voiceMedia = TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: voiceAttributes) + let voiceMedia = TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: voiceAttributes, alternativeRepresentations: []) let message3 = Message(stableId: 1, stableVersion: 0, id: MessageId(peerId: peerId, namespace: 0, id: 1), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 66001, flags: [.Incoming], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: peers[peerId], text: "", attributes: [], media: [voiceMedia], peers: peers, associatedMessages: messages, associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) items.append(self.context.sharedContext.makeChatMessagePreviewItem(context: self.context, messages: [message3], theme: self.presentationData.theme, strings: self.presentationData.strings, wallpaper: self.presentationData.chatWallpaper, fontSize: self.presentationData.chatFontSize, chatBubbleCorners: self.presentationData.chatBubbleCorners, dateTimeFormat: self.presentationData.dateTimeFormat, nameOrder: self.presentationData.nameDisplayOrder, forcedResourceStatus: FileMediaResourceStatus(mediaStatus: .playbackStatus(.paused), fetchStatus: .Local), tapMessage: nil, clickThroughMessage: nil, backgroundNode: self.chatBackgroundNode, availableReactions: nil, accountPeer: nil, isCentered: false, isPreview: true, isStandalone: false)) diff --git a/submodules/SettingsUI/Sources/Themes/EditThemeController.swift b/submodules/SettingsUI/Sources/Themes/EditThemeController.swift index c8030edd2e..0b009def43 100644 --- a/submodules/SettingsUI/Sources/Themes/EditThemeController.swift +++ b/submodules/SettingsUI/Sources/Themes/EditThemeController.swift @@ -538,7 +538,7 @@ public func editThemeController(context: AccountContext, mode: EditThemeControll let _ = (combineLatest(queue: Queue.mainQueue(), previewThemePromise.get(), settingsPromise.get()) |> take(1)).start(next: { previewTheme, settings in let saveThemeTemplateFile: (String, LocalFileMediaResource, @escaping () -> Void) -> Void = { title, resource, completion in - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: resource.fileId), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/x-tgtheme-ios", size: nil, attributes: [.FileName(fileName: "\(title).tgios-theme")]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: resource.fileId), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/x-tgtheme-ios", size: nil, attributes: [.FileName(fileName: "\(title).tgios-theme")], alternativeRepresentations: []) let message = EnqueueMessage.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) let _ = enqueueMessages(account: context.account, peerId: context.account.peerId, messages: [message]).start() diff --git a/submodules/SettingsUI/Sources/Themes/ThemePreviewControllerNode.swift b/submodules/SettingsUI/Sources/Themes/ThemePreviewControllerNode.swift index 974bf7976f..61fdf2c69f 100644 --- a/submodules/SettingsUI/Sources/Themes/ThemePreviewControllerNode.swift +++ b/submodules/SettingsUI/Sources/Themes/ThemePreviewControllerNode.swift @@ -615,7 +615,7 @@ final class ThemePreviewControllerNode: ASDisplayNode, ASScrollViewDelegate { let waveformBase64 = "DAAOAAkACQAGAAwADwAMABAADQAPABsAGAALAA0AGAAfABoAHgATABgAGQAYABQADAAVABEAHwANAA0ACQAWABkACQAOAAwACQAfAAAAGQAVAAAAEwATAAAACAAfAAAAHAAAABwAHwAAABcAGQAAABQADgAAABQAHwAAAB8AHwAAAAwADwAAAB8AEwAAABoAFwAAAB8AFAAAAAAAHwAAAAAAHgAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAAAA=" let voiceAttributes: [TelegramMediaFileAttribute] = [.Audio(isVoice: true, duration: 23, title: nil, performer: nil, waveform: Data(base64Encoded: waveformBase64)!)] - let voiceMedia = TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: voiceAttributes) + let voiceMedia = TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: voiceAttributes, alternativeRepresentations: []) let message6 = Message(stableId: 6, stableVersion: 0, id: MessageId(peerId: peerId, namespace: 0, id: 6), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 66005, flags: [.Incoming], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: peers[peerId], text: "", attributes: [], media: [voiceMedia], peers: peers, associatedMessages: messages, associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) sampleMessages.append(message6) diff --git a/submodules/ShareController/Sources/ShareLoadingContainerNode.swift b/submodules/ShareController/Sources/ShareLoadingContainerNode.swift index 292cc01841..ebf18f121b 100644 --- a/submodules/ShareController/Sources/ShareLoadingContainerNode.swift +++ b/submodules/ShareController/Sources/ShareLoadingContainerNode.swift @@ -279,7 +279,7 @@ 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 decoration = ChatBubbleVideoDecoration(corners: ImageCorners(), nativeSize: CGSize(width: 100.0, height: 100.0), contentMode: .aspectFit, backgroundColor: .black) - 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)]) + 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) diff --git a/submodules/ShareItems/Sources/ShareItems.swift b/submodules/ShareItems/Sources/ShareItems.swift index 71ae6be423..84c74e819c 100644 --- a/submodules/ShareItems/Sources/ShareItems.swift +++ b/submodules/ShareItems/Sources/ShareItems.swift @@ -144,7 +144,7 @@ private func preparedShareItem(postbox: Postbox, network: Network, to peerId: Pe let estimatedSize = TGMediaVideoConverter.estimatedSize(for: preset, duration: finalDuration, hasAudio: true) let resource = LocalFileVideoMediaResource(randomId: Int64.random(in: Int64.min ... Int64.max), path: asset.url.path, adjustments: resourceAdjustments) - return standaloneUploadedFile(postbox: postbox, network: network, peerId: peerId, text: "", source: .resource(.standalone(resource: resource)), mimeType: "video/mp4", attributes: [.Video(duration: finalDuration, size: PixelDimensions(width: Int32(finalDimensions.width), height: Int32(finalDimensions.height)), flags: flags, preloadSize: nil, coverTime: nil)], hintFileIsLarge: estimatedSize > 10 * 1024 * 1024) + return standaloneUploadedFile(postbox: postbox, network: network, peerId: peerId, text: "", source: .resource(.standalone(resource: resource)), mimeType: "video/mp4", attributes: [.Video(duration: finalDuration, size: PixelDimensions(width: Int32(finalDimensions.width), height: Int32(finalDimensions.height)), flags: flags, preloadSize: nil, coverTime: nil, videoCodec: nil)], hintFileIsLarge: estimatedSize > 10 * 1024 * 1024) |> mapError { _ -> PreparedShareItemError in return .generic } @@ -210,7 +210,7 @@ private func preparedShareItem(postbox: Postbox, network: Network, to peerId: Pe let mimeType: String if converted { mimeType = "video/mp4" - attributes = [.Video(duration: duration, size: PixelDimensions(width: Int32(dimensions.width), height: Int32(dimensions.height)), flags: [.supportsStreaming], preloadSize: nil, coverTime: nil), .Animated, .FileName(fileName: "animation.mp4")] + attributes = [.Video(duration: duration, size: PixelDimensions(width: Int32(dimensions.width), height: Int32(dimensions.height)), flags: [.supportsStreaming], preloadSize: nil, coverTime: nil, videoCodec: nil), .Animated, .FileName(fileName: "animation.mp4")] } else { mimeType = "animation/gif" attributes = [.ImageSize(size: PixelDimensions(width: Int32(dimensions.width), height: Int32(dimensions.height))), .Animated, .FileName(fileName: fileName ?? "animation.gif")] diff --git a/submodules/StatisticsUI/Sources/ChannelStatsController.swift b/submodules/StatisticsUI/Sources/ChannelStatsController.swift index 177019657a..39a97e73d5 100644 --- a/submodules/StatisticsUI/Sources/ChannelStatsController.swift +++ b/submodules/StatisticsUI/Sources/ChannelStatsController.swift @@ -2500,7 +2500,11 @@ public func channelStatsController(context: AccountContext, updatedPresentationD })) } var tooltipScreen: UndoOverlayController? + #if compiler(>=6.0) // Xcode 16 + nonisolated(unsafe) var timer: Foundation.Timer? + #else var timer: Foundation.Timer? + #endif showTimeoutTooltipImpl = { cooldownUntilTimestamp in let remainingCooldownSeconds = cooldownUntilTimestamp - Int32(Date().timeIntervalSince1970) diff --git a/submodules/TelegramCallsUI/Sources/CallRatingController.swift b/submodules/TelegramCallsUI/Sources/CallRatingController.swift index d9c9d9b892..fa86baa08a 100644 --- a/submodules/TelegramCallsUI/Sources/CallRatingController.swift +++ b/submodules/TelegramCallsUI/Sources/CallRatingController.swift @@ -291,7 +291,7 @@ func rateCallAndSendLogs(engine: TelegramEngine, callId: CallId, starsCount: Int let id = Int64.random(in: Int64.min ... Int64.max) let name = "\(callId.id)_\(callId.accessHash).log.json" let path = callLogsPath(account: engine.account) + "/" + name - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: LocalFileReferenceMediaResource(localFilePath: path, randomId: id), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: nil, attributes: [.FileName(fileName: name)]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: LocalFileReferenceMediaResource(localFilePath: path, randomId: id), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: nil, attributes: [.FileName(fileName: name)], alternativeRepresentations: []) let message = EnqueueMessage.message(text: comment, attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) return rate |> then(enqueueMessages(account: engine.account, peerId: peerId, messages: [message]) diff --git a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift index f270f3ab29..4bcace59a3 100644 --- a/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift +++ b/submodules/TelegramCallsUI/Sources/PresentationGroupCall.swift @@ -730,6 +730,10 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { public var myAudioLevel: Signal { return self.myAudioLevelPipe.signal() } + private let myAudioLevelAndSpeakingPipe = ValuePipe<(Float, Bool)>() + public var myAudioLevelAndSpeaking: Signal<(Float, Bool), NoError> { + return self.myAudioLevelAndSpeakingPipe.signal() + } private var myAudioLevelDisposable = MetaDisposable() private var audioSessionControl: ManagedAudioSessionControl? @@ -1957,6 +1961,7 @@ public final class PresentationGroupCallImpl: PresentationGroupCall { let mappedLevel = myLevel * 1.5 strongSelf.myAudioLevelPipe.putNext(mappedLevel) + strongSelf.myAudioLevelAndSpeakingPipe.putNext((mappedLevel, myLevelHasVoice)) strongSelf.processMyAudioLevel(level: mappedLevel, hasVoice: myLevelHasVoice) strongSelf.isSpeakingPromise.set(orignalMyLevelHasVoice) diff --git a/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift index 63ff380319..13c69b2cf2 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatActionButtonComponent.swift @@ -5,19 +5,54 @@ import ComponentFlow import MultilineTextComponent import TelegramPresentationData import AppBundle +import TelegramAudio final class VideoChatActionButtonComponent: Component { enum Content: Equatable { - fileprivate enum IconType { + enum BluetoothType: Equatable { + case generic + case airpods + case airpodsPro + case airpodsMax + } + + enum Audio: Equatable { + case none + case builtin + case speaker + case headphones + case bluetooth(BluetoothType) + } + + fileprivate enum IconType: Equatable { + enum Audio: Equatable { + case speaker + case headphones + case bluetooth(BluetoothType) + } + + case audio(audio: Audio) case video case leave } + case audio(audio: Audio) case video(isActive: Bool) case leave fileprivate var iconType: IconType { switch self { + case let .audio(audio): + let mappedAudio: IconType.Audio + switch audio { + case .none, .builtin, .speaker: + mappedAudio = .speaker + case .headphones: + mappedAudio = .headphones + case let .bluetooth(type): + mappedAudio = .bluetooth(type) + } + return .audio(audio: mappedAudio) case .video: return .video case .leave: @@ -30,23 +65,30 @@ final class VideoChatActionButtonComponent: Component { case connecting case muted case unmuted + case raiseHand } + let strings: PresentationStrings let content: Content let microphoneState: MicrophoneState let isCollapsed: Bool init( + strings: PresentationStrings, content: Content, microphoneState: MicrophoneState, isCollapsed: Bool ) { + self.strings = strings self.content = content self.microphoneState = microphoneState self.isCollapsed = isCollapsed } static func ==(lhs: VideoChatActionButtonComponent, rhs: VideoChatActionButtonComponent) -> Bool { + if lhs.strings !== rhs.strings { + return false + } if lhs.content != rhs.content { return false } @@ -94,6 +136,30 @@ final class VideoChatActionButtonComponent: Component { let backgroundColor: UIColor let iconDiameter: CGFloat switch component.content { + case let .audio(audio): + var isActive = false + switch audio { + case .none, .builtin: + titleText = component.strings.Call_Speaker + case .speaker: + isActive = true + titleText = component.strings.Call_Speaker + case .headphones: + titleText = component.strings.Call_Audio + case .bluetooth: + titleText = component.strings.Call_Audio + } + switch component.microphoneState { + case .connecting: + backgroundColor = UIColor(white: 0.1, alpha: 1.0) + case .muted: + backgroundColor = !isActive ? UIColor(rgb: 0x002E5D) : UIColor(rgb: 0x027FFF) + case .unmuted: + backgroundColor = !isActive ? UIColor(rgb: 0x124B21) : UIColor(rgb: 0x34C659) + case .raiseHand: + backgroundColor = UIColor(rgb: 0x3252EF) + } + iconDiameter = 60.0 case let .video(isActive): titleText = "video" switch component.microphoneState { @@ -103,6 +169,8 @@ final class VideoChatActionButtonComponent: Component { backgroundColor = !isActive ? UIColor(rgb: 0x002E5D) : UIColor(rgb: 0x027FFF) case .unmuted: backgroundColor = !isActive ? UIColor(rgb: 0x124B21) : UIColor(rgb: 0x34C659) + case .raiseHand: + backgroundColor = UIColor(rgb: 0x3252EF) } iconDiameter = 60.0 case .leave: @@ -113,6 +181,26 @@ final class VideoChatActionButtonComponent: Component { if self.contentImage == nil || previousComponent?.content.iconType != component.content.iconType { switch component.content.iconType { + case let .audio(audio): + let iconName: String + switch audio { + case .speaker: + iconName = "Call/CallSpeakerButton" + case .headphones: + iconName = "Call/CallHeadphonesButton" + case let .bluetooth(type): + switch type { + case .generic: + iconName = "Call/CallBluetoothButton" + case .airpods: + iconName = "Call/CallAirpodsButton" + case .airpodsPro: + iconName = "Call/CallAirpodsProButton" + case .airpodsMax: + iconName = "Call/CallAirpodsMaxButton" + } + } + self.contentImage = UIImage(bundleImageName: iconName)?.precomposed().withRenderingMode(.alwaysTemplate) case .video: self.contentImage = UIImage(bundleImageName: "Call/CallCameraButton")?.precomposed().withRenderingMode(.alwaysTemplate) case .leave: diff --git a/submodules/TelegramCallsUI/Sources/VideoChatExpandedParticipantThumbnailsComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatExpandedParticipantThumbnailsComponent.swift index 716bfd8076..355c75d06f 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatExpandedParticipantThumbnailsComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatExpandedParticipantThumbnailsComponent.swift @@ -19,6 +19,7 @@ final class VideoChatParticipantThumbnailComponent: Component { let isPresentation: Bool let isSelected: Bool let isSpeaking: Bool + let interfaceOrientation: UIInterfaceOrientation let action: (() -> Void)? init( @@ -28,6 +29,7 @@ final class VideoChatParticipantThumbnailComponent: Component { isPresentation: Bool, isSelected: Bool, isSpeaking: Bool, + interfaceOrientation: UIInterfaceOrientation, action: (() -> Void)? ) { self.call = call @@ -36,6 +38,7 @@ final class VideoChatParticipantThumbnailComponent: Component { self.isPresentation = isPresentation self.isSelected = isSelected self.isSpeaking = isSpeaking + self.interfaceOrientation = interfaceOrientation self.action = action } @@ -58,16 +61,21 @@ final class VideoChatParticipantThumbnailComponent: Component { if lhs.isSpeaking != rhs.isSpeaking { return false } + if lhs.interfaceOrientation != rhs.interfaceOrientation { + return false + } return true } private struct VideoSpec: Equatable { var resolution: CGSize var rotationAngle: Float + var followsDeviceOrientation: Bool - init(resolution: CGSize, rotationAngle: Float) { + init(resolution: CGSize, rotationAngle: Float, followsDeviceOrientation: Bool) { self.resolution = resolution self.rotationAngle = rotationAngle + self.followsDeviceOrientation = followsDeviceOrientation } } @@ -193,7 +201,7 @@ final class VideoChatParticipantThumbnailComponent: Component { text: .plain(NSAttributedString(string: EnginePeer(component.participant.peer).compactDisplayTitle, font: Font.semibold(13.0), textColor: .white)) )), environment: {}, - containerSize: CGSize(width: availableSize.width - 6.0 * 2.0 - 8.0, height: 100.0) + containerSize: CGSize(width: availableSize.width - 6.0 * 2.0 - 12.0, height: 100.0) ) let titleFrame = CGRect(origin: CGPoint(x: 6.0, y: availableSize.height - 6.0 - titleSize.height), size: titleSize) if let titleView = self.title.view { @@ -243,7 +251,7 @@ final class VideoChatParticipantThumbnailComponent: Component { videoLayer.video = videoOutput if let videoOutput { - let videoSpec = VideoSpec(resolution: videoOutput.resolution, rotationAngle: videoOutput.rotationAngle) + let videoSpec = VideoSpec(resolution: videoOutput.resolution, rotationAngle: videoOutput.rotationAngle, followsDeviceOrientation: videoOutput.followsDeviceOrientation) if self.videoSpec != videoSpec { self.videoSpec = videoSpec if !self.isUpdating { @@ -269,9 +277,11 @@ final class VideoChatParticipantThumbnailComponent: Component { videoLayer.blurredLayer.isHidden = component.isSelected videoLayer.isHidden = component.isSelected + let rotationAngle = resolveCallVideoRotationAngle(angle: videoSpec.rotationAngle, followsDeviceOrientation: videoSpec.followsDeviceOrientation, interfaceOrientation: component.interfaceOrientation) + var rotatedResolution = videoSpec.resolution var videoIsRotated = false - if abs(videoSpec.rotationAngle - Float.pi * 0.5) < .ulpOfOne || abs(videoSpec.rotationAngle - Float.pi * 3.0 / 2.0) < .ulpOfOne { + if abs(rotationAngle - Float.pi * 0.5) < .ulpOfOne || abs(rotationAngle - Float.pi * 3.0 / 2.0) < .ulpOfOne { videoIsRotated = true } if videoIsRotated { @@ -303,12 +313,12 @@ final class VideoChatParticipantThumbnailComponent: Component { transition.setPosition(layer: videoLayer, position: rotatedVideoFrame.center) transition.setBounds(layer: videoLayer, bounds: CGRect(origin: CGPoint(), size: rotatedVideoBoundsSize)) - transition.setTransform(layer: videoLayer, transform: CATransform3DMakeRotation(CGFloat(videoSpec.rotationAngle), 0.0, 0.0, 1.0)) + transition.setTransform(layer: videoLayer, transform: CATransform3DMakeRotation(CGFloat(rotationAngle), 0.0, 0.0, 1.0)) videoLayer.renderSpec = RenderLayerSpec(size: RenderSize(width: Int(rotatedVideoResolution.width), height: Int(rotatedVideoResolution.height)), edgeInset: 2) transition.setPosition(layer: videoLayer.blurredLayer, position: rotatedBlurredVideoFrame.center) transition.setBounds(layer: videoLayer.blurredLayer, bounds: CGRect(origin: CGPoint(), size: rotatedBlurredVideoBoundsSize)) - transition.setTransform(layer: videoLayer.blurredLayer, transform: CATransform3DMakeRotation(CGFloat(videoSpec.rotationAngle), 0.0, 0.0, 1.0)) + transition.setTransform(layer: videoLayer.blurredLayer, transform: CATransform3DMakeRotation(CGFloat(rotationAngle), 0.0, 0.0, 1.0)) } } else { if let videoBackgroundLayer = self.videoBackgroundLayer { @@ -426,6 +436,7 @@ final class VideoChatExpandedParticipantThumbnailsComponent: Component { let participants: [Participant] let selectedParticipant: Participant.Key? let speakingParticipants: Set + let interfaceOrientation: UIInterfaceOrientation let updateSelectedParticipant: (Participant.Key) -> Void init( @@ -434,6 +445,7 @@ final class VideoChatExpandedParticipantThumbnailsComponent: Component { participants: [Participant], selectedParticipant: Participant.Key?, speakingParticipants: Set, + interfaceOrientation: UIInterfaceOrientation, updateSelectedParticipant: @escaping (Participant.Key) -> Void ) { self.call = call @@ -441,6 +453,7 @@ final class VideoChatExpandedParticipantThumbnailsComponent: Component { self.participants = participants self.selectedParticipant = selectedParticipant self.speakingParticipants = speakingParticipants + self.interfaceOrientation = interfaceOrientation self.updateSelectedParticipant = updateSelectedParticipant } @@ -460,6 +473,9 @@ final class VideoChatExpandedParticipantThumbnailsComponent: Component { if lhs.speakingParticipants != rhs.speakingParticipants { return false } + if lhs.interfaceOrientation != rhs.interfaceOrientation { + return false + } return true } @@ -595,6 +611,7 @@ final class VideoChatExpandedParticipantThumbnailsComponent: Component { isPresentation: participant.isPresentation, isSelected: component.selectedParticipant == participant.key, isSpeaking: component.speakingParticipants.contains(participant.participant.peer.id), + interfaceOrientation: component.interfaceOrientation, action: { [weak self] in guard let self, let component = self.component else { return diff --git a/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift index 564c11822f..9e7c87b5bb 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatMicButtonComponent.swift @@ -179,23 +179,27 @@ final class VideoChatMicButtonComponent: Component { case connecting case muted case unmuted(pushToTalk: Bool) + case raiseHand } let call: PresentationGroupCall let content: Content let isCollapsed: Bool let updateUnmutedStateIsPushToTalk: (Bool?) -> Void + let raiseHand: () -> Void init( call: PresentationGroupCall, content: Content, isCollapsed: Bool, - updateUnmutedStateIsPushToTalk: @escaping (Bool?) -> Void + updateUnmutedStateIsPushToTalk: @escaping (Bool?) -> Void, + raiseHand: @escaping () -> Void ) { self.call = call self.content = content self.isCollapsed = isCollapsed self.updateUnmutedStateIsPushToTalk = updateUnmutedStateIsPushToTalk + self.raiseHand = raiseHand } static func ==(lhs: VideoChatMicButtonComponent, rhs: VideoChatMicButtonComponent) -> Bool { @@ -241,13 +245,11 @@ final class VideoChatMicButtonComponent: Component { self.beginTrackingTimestamp = CFAbsoluteTimeGetCurrent() if let component = self.component { switch component.content { - case .connecting: + case .connecting, .unmuted, .raiseHand: self.beginTrackingWasPushToTalk = false case .muted: self.beginTrackingWasPushToTalk = true component.updateUnmutedStateIsPushToTalk(true) - case .unmuted: - self.beginTrackingWasPushToTalk = false } } @@ -285,6 +287,10 @@ final class VideoChatMicButtonComponent: Component { } else { component.updateUnmutedStateIsPushToTalk(nil) } + case .raiseHand: + self.icon.playRandomAnimation() + + component.raiseHand() } } } @@ -314,6 +320,8 @@ final class VideoChatMicButtonComponent: Component { titleText = "Unmute" case let .unmuted(isPushToTalk): titleText = isPushToTalk ? "You are Live" : "Tap to Mute" + case .raiseHand: + titleText = "Raise Hand" } self.isEnabled = isEnabled @@ -382,10 +390,12 @@ final class VideoChatMicButtonComponent: Component { case .connecting: context.setFillColor(UIColor(white: 0.1, alpha: 1.0).cgColor) context.fill(CGRect(origin: CGPoint(), size: size)) - case .muted, .unmuted: + case .muted, .unmuted, .raiseHand: let colors: [UIColor] if case .muted = component.content { colors = [UIColor(rgb: 0x0080FF), UIColor(rgb: 0x00A1FE)] + } else if case .raiseHand = component.content { + colors = [UIColor(rgb: 0x3252EF), UIColor(rgb: 0xC64688)] } else { colors = [UIColor(rgb: 0x33C659), UIColor(rgb: 0x0BA8A5)] } @@ -465,10 +475,12 @@ final class VideoChatMicButtonComponent: Component { self.icon.enqueueState(.mute) case .unmuted: self.icon.enqueueState(.unmute) + case .raiseHand: + self.icon.enqueueState(.hand) } switch component.content { - case .muted, .unmuted: + case .muted, .unmuted, .raiseHand: let blobSize = CGRect(origin: CGPoint(), size: CGSize(width: 116.0, height: 116.0)).insetBy(dx: -40.0, dy: -40.0).size let blobTintTransition: ComponentTransition @@ -495,7 +507,15 @@ final class VideoChatMicButtonComponent: Component { transition.setPosition(view: blobView, position: CGPoint(x: availableSize.width * 0.5, y: availableSize.height * 0.5)) transition.setScale(view: blobView, scale: availableSize.width / 116.0) - blobTintTransition.setTintColor(layer: blobView.blobsLayer, color: component.content == .muted ? UIColor(rgb: 0x0086FF) : UIColor(rgb: 0x33C758)) + let blobsColor: UIColor + if case .muted = component.content { + blobsColor = UIColor(rgb: 0x0086FF) + } else if case .raiseHand = component.content { + blobsColor = UIColor(rgb: 0x914BAD) + } else { + blobsColor = UIColor(rgb: 0x33C758) + } + blobTintTransition.setTintColor(layer: blobView.blobsLayer, color: blobsColor) switch component.content { case .unmuted: @@ -508,7 +528,7 @@ final class VideoChatMicButtonComponent: Component { blobView.updateLevel(CGFloat(value), immediately: false) }) } - case .connecting, .muted: + case .connecting, .muted, .raiseHand: if let audioLevelDisposable = self.audioLevelDisposable { self.audioLevelDisposable = nil audioLevelDisposable.dispose() @@ -536,7 +556,14 @@ final class VideoChatMicButtonComponent: Component { glowView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) } - let glowColor: UIColor = component.content == .muted ? UIColor(rgb: 0x0086FF) : UIColor(rgb: 0x33C758) + let glowColor: UIColor + if case .muted = component.content { + glowColor = UIColor(rgb: 0x0086FF) + } else if case .raiseHand = component.content { + glowColor = UIColor(rgb: 0x3252EF) + } else { + glowColor = UIColor(rgb: 0x33C758) + } glowView.update(size: glowFrame.size, color: glowColor.withMultipliedAlpha(component.isCollapsed ? 0.5 : 0.7), transition: transition, colorTransition: blobTintTransition) transition.setFrame(view: glowView, frame: glowFrame) default: diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift index bc631b0f84..2369a08d5c 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantAvatarComponent.swift @@ -97,20 +97,17 @@ private final class BlobView: UIView { } private func updateAudioLevel() { - let additionalAvatarScale = CGFloat(max(0.0, min(self.presentationAudioLevel * 18.0, 5.0)) * 0.05) - let blobAmplificationFactor: CGFloat = 2.0 - let blobScale = 1.0 + additionalAvatarScale * blobAmplificationFactor + let additionalAvatarScale = CGFloat(max(0.0, min(self.presentationAudioLevel * 0.3, 1.0)) * 1.0) + let blobScale = 1.28 + additionalAvatarScale self.blobsLayer.transform = CATransform3DMakeScale(blobScale, blobScale, 1.0) - self.scaleUpdated?(blobScale) + self.scaleUpdated?(additionalAvatarScale) } public func startAnimating() { guard !self.isAnimating else { return } self.isAnimating = true - self.updateBlobsState() - self.displayLinkAnimator?.isPaused = false } @@ -122,51 +119,35 @@ private final class BlobView: UIView { guard isAnimating else { return } self.isAnimating = false - self.updateBlobsState() - self.displayLinkAnimator?.isPaused = true } - private func updateBlobsState() { - /*if self.isAnimating { - if self.mediumBlob.frame.size != .zero { - self.mediumBlob.startAnimating() - self.bigBlob.startAnimating() - } - } else { - self.mediumBlob.stopAnimating() - self.bigBlob.stopAnimating() - }*/ - } - - override public func layoutSubviews() { + func update(size: CGSize) { super.layoutSubviews() - //self.mediumBlob.frame = bounds - //self.bigBlob.frame = bounds - - let blobsFrame = bounds.insetBy(dx: floor(bounds.width * 0.12), dy: floor(bounds.height * 0.12)) + let blobsFrame = CGRect(origin: CGPoint(), size: size) self.blobsLayer.position = blobsFrame.center self.blobsLayer.bounds = CGRect(origin: CGPoint(), size: blobsFrame.size) - - self.updateBlobsState() } } final class VideoChatParticipantAvatarComponent: Component { let call: PresentationGroupCall let peer: EnginePeer + let myPeerId: EnginePeer.Id let isSpeaking: Bool let theme: PresentationTheme init( call: PresentationGroupCall, peer: EnginePeer, + myPeerId: EnginePeer.Id, isSpeaking: Bool, theme: PresentationTheme ) { self.call = call self.peer = peer + self.myPeerId = myPeerId self.isSpeaking = isSpeaking self.theme = theme } @@ -181,6 +162,9 @@ final class VideoChatParticipantAvatarComponent: Component { if lhs.isSpeaking != rhs.isSpeaking { return false } + if lhs.myPeerId != rhs.myPeerId { + return false + } if lhs.theme !== rhs.theme { return false } @@ -197,6 +181,7 @@ final class VideoChatParticipantAvatarComponent: Component { private var wasSpeaking: Bool? private var noAudioTimer: Foundation.Timer? + private var lastAudioLevelTimestamp: Double = 0.0 override init(frame: CGRect) { super.init(frame: frame) @@ -211,6 +196,31 @@ final class VideoChatParticipantAvatarComponent: Component { self.noAudioTimer?.invalidate() } + private func checkNoAudio() { + let timestamp = CFAbsoluteTimeGetCurrent() + if self.lastAudioLevelTimestamp + 1.0 < timestamp { + self.noAudioTimer?.invalidate() + self.noAudioTimer = nil + + if let blobView = self.blobView { + let transition: ComponentTransition = .easeInOut(duration: 0.3) + transition.setAlpha(view: blobView, alpha: 0.0, completion: { [weak self, weak blobView] completed in + guard let self, let blobView, completed else { + return + } + if self.blobView === blobView { + self.blobView = nil + } + blobView.removeFromSuperview() + }) + transition.setScale(layer: blobView.layer, scale: 0.5) + if let avatarNode = self.avatarNode { + transition.setScale(view: avatarNode.view, scale: 1.0) + } + } + } + } + func update(component: VideoChatParticipantAvatarComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { @@ -268,39 +278,60 @@ final class VideoChatParticipantAvatarComponent: Component { avatarNode.setPeer(context: component.call.accountContext, theme: component.theme, peer: component.peer, clipStyle: clipStyle, synchronousLoad: false, displayDimensions: avatarSize) } - transition.setFrame(view: avatarNode.view, frame: CGRect(origin: CGPoint(), size: avatarSize)) + let avatarFrame = CGRect(origin: CGPoint(), size: avatarSize) + transition.setPosition(view: avatarNode.view, position: avatarFrame.center) + transition.setBounds(view: avatarNode.view, bounds: CGRect(origin: CGPoint(), size: avatarFrame.size)) avatarNode.updateSize(size: avatarSize) + let blobScale: CGFloat = 1.5 + if self.audioLevelDisposable == nil { - let peerId = component.peer.id struct Level { var value: Float var isSpeaking: Bool } - self.audioLevelDisposable = (component.call.audioLevels - |> map { levels -> Level? in - for level in levels { - if level.0 == peerId { - return Level(value: level.2, isSpeaking: level.3) + + let peerId = component.peer.id + let levelSignal: Signal + if peerId == component.myPeerId { + levelSignal = component.call.myAudioLevelAndSpeaking + |> map { value, isSpeaking -> Level? in + if value == 0.0 { + return nil + } else { + return Level(value: value, isSpeaking: isSpeaking) } } - return nil + } else { + levelSignal = component.call.audioLevels + |> map { levels -> Level? in + for level in levels { + if level.0 == peerId { + return Level(value: level.2, isSpeaking: level.3) + } + } + return nil + } } + + self.audioLevelDisposable = (levelSignal |> distinctUntilChanged(isEqual: { lhs, rhs in if (lhs == nil) != (rhs == nil) { return false } if lhs != nil { - return true - } else { return false + } else { + return true } }) |> deliverOnMainQueue).startStrict(next: { [weak self] level in guard let self, let component = self.component, let avatarNode = self.avatarNode else { return } - if let level { + if let level, level.value >= 0.1 { + self.lastAudioLevelTimestamp = CFAbsoluteTimeGetCurrent() + let blobView: BlobView if let current = self.blobView { blobView = current @@ -314,14 +345,31 @@ final class VideoChatParticipantAvatarComponent: Component { bigBlobRange: (0.71, 1.0) ) self.blobView = blobView - blobView.frame = avatarNode.frame + let blobSize = floor(avatarNode.bounds.width * blobScale) + blobView.center = avatarNode.frame.center + blobView.bounds = CGRect(origin: CGPoint(), size: CGSize(width: blobSize, height: blobSize)) + blobView.layer.transform = CATransform3DMakeScale(1.0 / blobScale, 1.0 / blobScale, 1.0) + + blobView.update(size: blobView.bounds.size) self.insertSubview(blobView, belowSubview: avatarNode.view) - blobView.layer.animateScale(from: 0.001, to: 1.0, duration: 0.2) + blobView.layer.animateScale(from: 0.5, to: 1.0 / blobScale, duration: 0.2) + + blobView.scaleUpdated = { [weak self] additionalScale in + guard let self, let avatarNode = self.avatarNode else { + return + } + avatarNode.layer.transform = CATransform3DMakeScale(1.0 + additionalScale, 1.0 + additionalScale, 1.0) + } ComponentTransition.immediate.setTintColor(layer: blobView.blobsLayer, color: component.isSpeaking ? UIColor(rgb: 0x33C758) : component.theme.list.itemAccentColor) } + if blobView.alpha == 0.0 { + let transition: ComponentTransition = .easeInOut(duration: 0.3) + transition.setAlpha(view: blobView, alpha: 1.0) + transition.setScale(view: blobView, scale: 1.0 / blobScale) + } blobView.updateLevel(CGFloat(level.value), immediately: false) if let noAudioTimer = self.noAudioTimer { @@ -329,24 +377,19 @@ final class VideoChatParticipantAvatarComponent: Component { noAudioTimer.invalidate() } } else { - if self.noAudioTimer == nil { - self.noAudioTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.4, repeats: false, block: { [weak self] _ in - guard let self else { - return - } - self.noAudioTimer?.invalidate() - self.noAudioTimer = nil - - if let blobView = self.blobView { - self.blobView = nil - blobView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak blobView] _ in - blobView?.removeFromSuperview() - }) - blobView.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, removeOnCompletion: false) - } - }) + if let blobView = self.blobView { + blobView.updateLevel(0.0, immediately: false) } } + + if self.noAudioTimer == nil { + self.noAudioTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 0.4, repeats: true, block: { [weak self] _ in + guard let self else { + return + } + self.checkNoAudio() + }) + } }) } diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift index c33ba085ce..5b480bac46 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantStatusComponent.swift @@ -4,18 +4,22 @@ import Display import ComponentFlow import TelegramPresentationData import TelegramCore +import LottieComponent final class VideoChatParticipantStatusComponent: Component { let muteState: GroupCallParticipantsContext.Participant.MuteState? + let hasRaiseHand: Bool let isSpeaking: Bool let theme: PresentationTheme init( muteState: GroupCallParticipantsContext.Participant.MuteState?, + hasRaiseHand: Bool, isSpeaking: Bool, theme: PresentationTheme ) { self.muteState = muteState + self.hasRaiseHand = hasRaiseHand self.isSpeaking = isSpeaking self.theme = theme } @@ -24,6 +28,9 @@ final class VideoChatParticipantStatusComponent: Component { if lhs.muteState != rhs.muteState { return false } + if lhs.hasRaiseHand != rhs.hasRaiseHand { + return false + } if lhs.isSpeaking != rhs.isSpeaking { return false } @@ -34,7 +41,8 @@ final class VideoChatParticipantStatusComponent: Component { } final class View: UIView { - private let muteStatus = ComponentView() + private var muteStatus: ComponentView? + private var raiseHandStatus: ComponentView? private var component: VideoChatParticipantStatusComponent? private var isUpdating: Bool = false @@ -56,47 +64,149 @@ final class VideoChatParticipantStatusComponent: Component { self.isUpdating = false } + let alphaTransition: ComponentTransition + if !transition.animation.isImmediate { + alphaTransition = .easeInOut(duration: 0.2) + } else { + alphaTransition = .immediate + } + let size = CGSize(width: 44.0, height: 44.0) - let muteStatusSize = self.muteStatus.update( - transition: transition, - component: AnyComponent(VideoChatMuteIconComponent( - color: .white, - content: .mute(isFilled: false, isMuted: component.muteState != nil && !component.isSpeaking) - )), - environment: {}, - containerSize: CGSize(width: 36.0, height: 36.0) - ) - let muteStatusFrame = CGRect(origin: CGPoint(x: floor((size.width - muteStatusSize.width) * 0.5), y: floor((size.height - muteStatusSize.height) * 0.5)), size: muteStatusSize) - if let muteStatusView = self.muteStatus.view as? VideoChatMuteIconComponent.View { - if muteStatusView.superview == nil { - self.addSubview(muteStatusView) - } - transition.setFrame(view: muteStatusView, frame: muteStatusFrame) - - let tintTransition: ComponentTransition - if !transition.animation.isImmediate { - tintTransition = .easeInOut(duration: 0.2) + let isRaiseHand: Bool + if let muteState = component.muteState { + if muteState.canUnmute { + isRaiseHand = false } else { - tintTransition = .immediate + isRaiseHand = component.hasRaiseHand } - if let iconView = muteStatusView.iconView { - let iconTintColor: UIColor - if component.isSpeaking { - iconTintColor = UIColor(rgb: 0x33C758) + } else { + isRaiseHand = false + } + + if !isRaiseHand { + let muteStatus: ComponentView + var muteStatusTransition = transition + if let current = self.muteStatus { + muteStatus = current + } else { + muteStatusTransition = muteStatusTransition.withAnimation(.none) + muteStatus = ComponentView() + self.muteStatus = muteStatus + } + + let muteStatusSize = muteStatus.update( + transition: muteStatusTransition, + component: AnyComponent(VideoChatMuteIconComponent( + color: .white, + content: .mute(isFilled: false, isMuted: component.muteState != nil && !component.isSpeaking) + )), + environment: {}, + containerSize: CGSize(width: 36.0, height: 36.0) + ) + let muteStatusFrame = CGRect(origin: CGPoint(x: floor((size.width - muteStatusSize.width) * 0.5), y: floor((size.height - muteStatusSize.height) * 0.5)), size: muteStatusSize) + if let muteStatusView = muteStatus.view as? VideoChatMuteIconComponent.View { + var animateIn = false + if muteStatusView.superview == nil { + animateIn = true + self.addSubview(muteStatusView) + } + muteStatusTransition.setFrame(view: muteStatusView, frame: muteStatusFrame) + + let tintTransition: ComponentTransition + if !muteStatusTransition.animation.isImmediate { + tintTransition = .easeInOut(duration: 0.2) } else { - if let muteState = component.muteState { - if muteState.canUnmute { - iconTintColor = UIColor(white: 1.0, alpha: 0.4) - } else { - iconTintColor = UIColor(rgb: 0xFF3B30) - } + tintTransition = .immediate + } + if let iconView = muteStatusView.iconView { + let iconTintColor: UIColor + if component.isSpeaking { + iconTintColor = UIColor(rgb: 0x33C758) } else { - iconTintColor = UIColor(white: 1.0, alpha: 0.4) + if let muteState = component.muteState { + if muteState.canUnmute { + iconTintColor = UIColor(white: 1.0, alpha: 0.4) + } else { + iconTintColor = UIColor(rgb: 0xFF3B30) + } + } else { + iconTintColor = UIColor(white: 1.0, alpha: 0.4) + } } + + tintTransition.setTintColor(layer: iconView.layer, color: iconTintColor) } - tintTransition.setTintColor(layer: iconView.layer, color: iconTintColor) + if animateIn, !transition.animation.isImmediate { + transition.animateScale(view: muteStatusView, from: 0.001, to: 1.0) + alphaTransition.animateAlpha(view: muteStatusView, from: 0.0, to: 1.0) + } + } + } else if let muteStatus = self.muteStatus { + self.muteStatus = nil + + if let muteStatusView = muteStatus.view { + if !transition.animation.isImmediate { + transition.setScale(view: muteStatusView, scale: 0.001) + alphaTransition.setAlpha(view: muteStatusView, alpha: 0.0, completion: { [weak muteStatusView] _ in + muteStatusView?.removeFromSuperview() + }) + } else { + muteStatusView.removeFromSuperview() + } + } + } + + if isRaiseHand { + let raiseHandStatus: ComponentView + var raiseHandStatusTransition = transition + if let current = self.raiseHandStatus { + raiseHandStatus = current + } else { + raiseHandStatusTransition = raiseHandStatusTransition.withAnimation(.none) + raiseHandStatus = ComponentView() + self.raiseHandStatus = raiseHandStatus + } + + let raiseHandStatusSize = raiseHandStatus.update( + transition: raiseHandStatusTransition, + component: AnyComponent(LottieComponent( + content: LottieComponent.AppBundleContent( + name: "anim_hand1" + ), + color: component.theme.list.itemAccentColor, + size: CGSize(width: 48.0, height: 48.0) + )), + environment: {}, + containerSize: CGSize(width: 48.0, height: 48.0) + ) + let raiseHandStatusFrame = CGRect(origin: CGPoint(x: floor((size.width - raiseHandStatusSize.width) * 0.5) - 2.0, y: floor((size.height - raiseHandStatusSize.height) * 0.5)), size: raiseHandStatusSize) + if let raiseHandStatusView = raiseHandStatus.view { + var animateIn = false + if raiseHandStatusView.superview == nil { + animateIn = true + self.addSubview(raiseHandStatusView) + } + raiseHandStatusTransition.setFrame(view: raiseHandStatusView, frame: raiseHandStatusFrame) + + if animateIn, !transition.animation.isImmediate { + transition.animateScale(view: raiseHandStatusView, from: 0.001, to: 1.0) + alphaTransition.animateAlpha(view: raiseHandStatusView, from: 0.0, to: 1.0) + } + } + } else if let raiseHandStatus = self.raiseHandStatus { + self.raiseHandStatus = nil + + if let raiseHandStatusView = raiseHandStatus.view { + if !transition.animation.isImmediate { + transition.setScale(view: raiseHandStatusView, scale: 0.001) + alphaTransition.setAlpha(view: raiseHandStatusView, alpha: 0.0, completion: { [weak raiseHandStatusView] _ in + raiseHandStatusView?.removeFromSuperview() + }) + } else { + raiseHandStatusView.removeFromSuperview() + } } } diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift index cde0ff9ad9..d295c40a7f 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoComponent.swift @@ -35,8 +35,10 @@ private let activityBorderImage: UIImage = { }() final class VideoChatParticipantVideoComponent: Component { + let strings: PresentationStrings let call: PresentationGroupCall let participant: GroupCallParticipantsContext.Participant + let isMyPeer: Bool let isPresentation: Bool let isSpeaking: Bool let isExpanded: Bool @@ -44,12 +46,13 @@ final class VideoChatParticipantVideoComponent: Component { let contentInsets: UIEdgeInsets let controlInsets: UIEdgeInsets let interfaceOrientation: UIInterfaceOrientation - weak var rootVideoLoadingEffectView: VideoChatVideoLoadingEffectView? let action: (() -> Void)? init( + strings: PresentationStrings, call: PresentationGroupCall, participant: GroupCallParticipantsContext.Participant, + isMyPeer: Bool, isPresentation: Bool, isSpeaking: Bool, isExpanded: Bool, @@ -57,11 +60,12 @@ final class VideoChatParticipantVideoComponent: Component { contentInsets: UIEdgeInsets, controlInsets: UIEdgeInsets, interfaceOrientation: UIInterfaceOrientation, - rootVideoLoadingEffectView: VideoChatVideoLoadingEffectView?, action: (() -> Void)? ) { + self.strings = strings self.call = call self.participant = participant + self.isMyPeer = isMyPeer self.isPresentation = isPresentation self.isSpeaking = isSpeaking self.isExpanded = isExpanded @@ -69,7 +73,6 @@ final class VideoChatParticipantVideoComponent: Component { self.contentInsets = contentInsets self.controlInsets = controlInsets self.interfaceOrientation = interfaceOrientation - self.rootVideoLoadingEffectView = rootVideoLoadingEffectView self.action = action } @@ -77,6 +80,9 @@ final class VideoChatParticipantVideoComponent: Component { if lhs.participant != rhs.participant { return false } + if lhs.isMyPeer != rhs.isMyPeer { + return false + } if lhs.isPresentation != rhs.isPresentation { return false } @@ -116,12 +122,36 @@ final class VideoChatParticipantVideoComponent: Component { } } + private struct ReferenceLocation: Equatable { + var containerWidth: CGFloat + var positionX: CGFloat + + init(containerWidth: CGFloat, positionX: CGFloat) { + self.containerWidth = containerWidth + self.positionX = positionX + } + } + + private final class AnimationHint { + enum Kind { + case videoAvailabilityChanged + } + + let kind: Kind + + init(kind: Kind) { + self.kind = kind + } + } + final class View: HighlightTrackingButton { private var component: VideoChatParticipantVideoComponent? private weak var componentState: EmptyComponentState? private var isUpdating: Bool = false private var previousSize: CGSize? + private let backgroundGradientView: UIImageView + private let muteStatus = ComponentView() private let title = ComponentView() @@ -134,13 +164,20 @@ final class VideoChatParticipantVideoComponent: Component { private var videoLayer: PrivateCallVideoLayer? private var videoSpec: VideoSpec? + private var awaitingFirstVideoFrameForUnpause: Bool = false + private var videoStatus: ComponentView? private var activityBorderView: UIImageView? - private var loadingEffectView: PortalView? + private var referenceLocation: ReferenceLocation? + private var loadingEffectView: VideoChatVideoLoadingEffectView? override init(frame: CGRect) { + self.backgroundGradientView = UIImageView() + super.init(frame: frame) + self.addSubview(self.backgroundGradientView) + //TODO:release optimize self.clipsToBounds = true self.layer.cornerRadius = 10.0 @@ -170,9 +207,12 @@ final class VideoChatParticipantVideoComponent: Component { self.isUpdating = false } + let previousComponent = self.component self.component = component self.componentState = state + transition.setFrame(view: self.backgroundGradientView, frame: CGRect(origin: CGPoint(), size: availableSize)) + let alphaTransition: ComponentTransition if !transition.animation.isImmediate { alphaTransition = .easeInOut(duration: 0.2) @@ -180,11 +220,24 @@ final class VideoChatParticipantVideoComponent: Component { alphaTransition = .immediate } + let videoAlphaTransition: ComponentTransition + if let animationHint = transition.userData(AnimationHint.self), case .videoAvailabilityChanged = animationHint.kind { + videoAlphaTransition = .easeInOut(duration: 0.2) + } else { + videoAlphaTransition = alphaTransition + } + let controlsAlpha: CGFloat = component.isUIHidden ? 0.0 : 1.0 let nameColor = component.participant.peer.nameColor ?? .blue let nameColors = component.call.accountContext.peerNameColors.get(nameColor, dark: true) - self.backgroundColor = nameColors.main.withMultiplied(hue: 1.0, saturation: 1.0, brightness: 0.4) + + if previousComponent == nil { + self.backgroundGradientView.image = generateGradientImage(size: CGSize(width: 8.0, height: 32.0), colors: [ + nameColors.main.withMultiplied(hue: 1.0, saturation: 1.1, brightness: 1.3), + nameColors.main.withMultiplied(hue: 1.0, saturation: 1.2, brightness: 1.0) + ], locations: [0.0, 1.0], direction: .vertical) + } if let smallProfileImage = component.participant.peer.smallProfileImage { let blurredAvatarView: UIImageView @@ -196,7 +249,7 @@ final class VideoChatParticipantVideoComponent: Component { blurredAvatarView = UIImageView() blurredAvatarView.contentMode = .scaleAspectFill self.blurredAvatarView = blurredAvatarView - self.insertSubview(blurredAvatarView, at: 0) + self.insertSubview(blurredAvatarView, aboveSubview: self.backgroundGradientView) blurredAvatarView.frame = CGRect(origin: CGPoint(), size: availableSize) } @@ -254,6 +307,12 @@ final class VideoChatParticipantVideoComponent: Component { if muteStatusView.superview == nil { self.addSubview(muteStatusView) muteStatusView.alpha = controlsAlpha + + //TODO:release + muteStatusView.layer.shadowOpacity = 0.7 + muteStatusView.layer.shadowColor = UIColor(white: 0.0, alpha: 1.0).cgColor + muteStatusView.layer.shadowOffset = CGSize(width: 0.0, height: 1.0) + muteStatusView.layer.shadowRadius = 8.0 } transition.setPosition(view: muteStatusView, position: muteStatusFrame.center) transition.setBounds(view: muteStatusView, bounds: CGRect(origin: CGPoint(), size: muteStatusFrame.size)) @@ -267,7 +326,7 @@ final class VideoChatParticipantVideoComponent: Component { text: .plain(NSAttributedString(string: component.participant.peer.debugDisplayTitle, font: Font.semibold(16.0), textColor: .white)) )), environment: {}, - containerSize: CGSize(width: availableSize.width - 8.0 * 2.0, height: 100.0) + containerSize: CGSize(width: availableSize.width - 8.0 * 2.0 - 4.0, height: 100.0) ) let titleFrame: CGRect if component.isExpanded { @@ -280,6 +339,12 @@ final class VideoChatParticipantVideoComponent: Component { titleView.layer.anchorPoint = CGPoint() self.addSubview(titleView) titleView.alpha = controlsAlpha + + //TODO:release + titleView.layer.shadowOpacity = 0.7 + titleView.layer.shadowColor = UIColor(white: 0.0, alpha: 1.0).cgColor + titleView.layer.shadowOffset = CGSize(width: 0.0, height: 1.0) + titleView.layer.shadowRadius = 8.0 } transition.setPosition(view: titleView, position: titleFrame.origin) titleView.bounds = CGRect(origin: CGPoint(), size: titleFrame.size) @@ -287,18 +352,34 @@ final class VideoChatParticipantVideoComponent: Component { alphaTransition.setAlpha(view: titleView, alpha: controlsAlpha) } - if let videoDescription = component.isPresentation ? component.participant.presentationDescription : component.participant.videoDescription { + let videoDescription = component.isPresentation ? component.participant.presentationDescription : component.participant.videoDescription + + var isEffectivelyPaused = false + if let videoDescription, videoDescription.isPaused { + isEffectivelyPaused = true + } else if let previousComponent { + let previousVideoDescription = previousComponent.isPresentation ? previousComponent.participant.presentationDescription : previousComponent.participant.videoDescription + if let previousVideoDescription, previousVideoDescription.isPaused { + self.awaitingFirstVideoFrameForUnpause = true + } + if self.awaitingFirstVideoFrameForUnpause { + isEffectivelyPaused = true + } + } + + if let videoDescription { let videoBackgroundLayer: SimpleLayer if let current = self.videoBackgroundLayer { videoBackgroundLayer = current } else { videoBackgroundLayer = SimpleLayer() videoBackgroundLayer.backgroundColor = UIColor(white: 0.1, alpha: 1.0).cgColor + videoBackgroundLayer.opacity = 0.0 self.videoBackgroundLayer = videoBackgroundLayer if let blurredAvatarView = self.blurredAvatarView { self.layer.insertSublayer(videoBackgroundLayer, above: blurredAvatarView.layer) } else { - self.layer.insertSublayer(videoBackgroundLayer, at: 0) + self.layer.insertSublayer(videoBackgroundLayer, above: self.backgroundGradientView.layer) } videoBackgroundLayer.isHidden = true } @@ -309,10 +390,11 @@ final class VideoChatParticipantVideoComponent: Component { } else { videoLayer = PrivateCallVideoLayer() self.videoLayer = videoLayer + videoLayer.opacity = 0.0 self.layer.insertSublayer(videoLayer.blurredLayer, above: videoBackgroundLayer) self.layer.insertSublayer(videoLayer, above: videoLayer.blurredLayer) - videoLayer.blurredLayer.opacity = 0.25 + videoLayer.blurredLayer.opacity = 0.0 if let input = (component.call as! PresentationGroupCallImpl).video(endpointId: videoDescription.endpointId) { let videoSource = AdaptedCallVideoSource(videoStreamSignal: input) @@ -329,10 +411,12 @@ final class VideoChatParticipantVideoComponent: Component { if let videoOutput { let videoSpec = VideoSpec(resolution: videoOutput.resolution, rotationAngle: videoOutput.rotationAngle, followsDeviceOrientation: videoOutput.followsDeviceOrientation) - if self.videoSpec != videoSpec { + if self.videoSpec != videoSpec || self.awaitingFirstVideoFrameForUnpause { + self.awaitingFirstVideoFrameForUnpause = false + self.videoSpec = videoSpec if !self.isUpdating { - self.componentState?.updated(transition: .immediate, isLocal: true) + self.componentState?.updated(transition: ComponentTransition.immediate.withUserData(AnimationHint(kind: .videoAvailabilityChanged)), isLocal: true) } } } else { @@ -350,7 +434,19 @@ final class VideoChatParticipantVideoComponent: Component { transition.setFrame(layer: videoBackgroundLayer, frame: CGRect(origin: CGPoint(), size: availableSize)) if let videoSpec = self.videoSpec { - videoBackgroundLayer.isHidden = false + if videoBackgroundLayer.isHidden { + videoBackgroundLayer.isHidden = false + } + + videoAlphaTransition.setAlpha(layer: videoBackgroundLayer, alpha: 1.0) + + if isEffectivelyPaused { + videoAlphaTransition.setAlpha(layer: videoLayer, alpha: 0.0) + videoAlphaTransition.setAlpha(layer: videoLayer.blurredLayer, alpha: 0.9) + } else { + videoAlphaTransition.setAlpha(layer: videoLayer, alpha: 1.0) + videoAlphaTransition.setAlpha(layer: videoLayer.blurredLayer, alpha: 0.25) + } let rotationAngle = resolveCallVideoRotationAngle(angle: videoSpec.rotationAngle, followsDeviceOrientation: videoSpec.followsDeviceOrientation, interfaceOrientation: component.interfaceOrientation) @@ -410,17 +506,69 @@ final class VideoChatParticipantVideoComponent: Component { self.videoSpec = nil } - if self.loadingEffectView == nil, let rootVideoLoadingEffectView = component.rootVideoLoadingEffectView { - if let loadingEffectView = PortalView(matchPosition: true) { - self.loadingEffectView = loadingEffectView - self.addSubview(loadingEffectView.view) - rootVideoLoadingEffectView.portalSource.addPortal(view: loadingEffectView) - loadingEffectView.view.isUserInteractionEnabled = false - loadingEffectView.view.frame = CGRect(origin: CGPoint(), size: availableSize) + var statusKind: VideoChatParticipantVideoStatusComponent.Kind? + if component.isPresentation && component.isMyPeer { + statusKind = .ownScreenshare + } else if isEffectivelyPaused { + statusKind = .paused + } + + if let statusKind { + let videoStatus: ComponentView + var videoStatusTransition = transition + if let current = self.videoStatus { + videoStatus = current + } else { + videoStatusTransition = videoStatusTransition.withAnimation(.none) + videoStatus = ComponentView() + self.videoStatus = videoStatus + } + let _ = videoStatus.update( + transition: videoStatusTransition, + component: AnyComponent(VideoChatParticipantVideoStatusComponent( + strings: component.strings, + kind: statusKind, + isExpanded: component.isExpanded + )), + environment: {}, + containerSize: availableSize + ) + if let videoStatusView = videoStatus.view { + if videoStatusView.superview == nil { + videoStatusView.isUserInteractionEnabled = false + videoStatusView.alpha = 0.0 + self.addSubview(videoStatusView) + } + videoStatusTransition.setFrame(view: videoStatusView, frame: CGRect(origin: CGPoint(), size: availableSize)) + videoAlphaTransition.setAlpha(view: videoStatusView, alpha: 1.0) + } + } else if let videoStatus = self.videoStatus { + self.videoStatus = nil + if let videoStatusView = videoStatus.view { + videoAlphaTransition.setAlpha(view: videoStatusView, alpha: 0.0, completion: { [weak videoStatusView] _ in + videoStatusView?.removeFromSuperview() + }) } } - if let loadingEffectView = self.loadingEffectView { - transition.setFrame(view: loadingEffectView.view, frame: CGRect(origin: CGPoint(), size: availableSize)) + + if videoDescription != nil && self.videoSpec == nil && !isEffectivelyPaused { + if self.loadingEffectView == nil { + let loadingEffectView = VideoChatVideoLoadingEffectView(effectAlpha: 0.1, borderAlpha: 0.2, cornerRadius: 10.0, duration: 1.0) + self.loadingEffectView = loadingEffectView + loadingEffectView.alpha = 0.0 + loadingEffectView.isUserInteractionEnabled = false + self.addSubview(loadingEffectView) + if let referenceLocation = self.referenceLocation { + self.updateHorizontalReferenceLocation(containerWidth: referenceLocation.containerWidth, positionX: referenceLocation.positionX, transition: .immediate) + } + videoAlphaTransition.setAlpha(view: loadingEffectView, alpha: 1.0) + } + } else if let loadingEffectView = self.loadingEffectView { + self.loadingEffectView = nil + + videoAlphaTransition.setAlpha(view: loadingEffectView, alpha: 0.0, completion: { [weak loadingEffectView] _ in + loadingEffectView?.removeFromSuperview() + }) } if component.isSpeaking && !component.isExpanded { @@ -467,6 +615,15 @@ final class VideoChatParticipantVideoComponent: Component { return availableSize } + + func updateHorizontalReferenceLocation(containerWidth: CGFloat, positionX: CGFloat, transition: ComponentTransition) { + self.referenceLocation = ReferenceLocation(containerWidth: containerWidth, positionX: positionX) + + if let loadingEffectView = self.loadingEffectView, let size = self.previousSize { + transition.setFrame(view: loadingEffectView, frame: CGRect(origin: CGPoint(), size: size)) + loadingEffectView.update(size: size, containerWidth: containerWidth, offsetX: positionX, gradientWidth: floor(containerWidth * 0.8), transition: transition) + } + } } func makeView() -> View { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoStatusComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoStatusComponent.swift new file mode 100644 index 0000000000..f8f91cc9ee --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantVideoStatusComponent.swift @@ -0,0 +1,140 @@ +import Foundation +import UIKit +import Display +import ComponentFlow +import TelegramPresentationData +import BundleIconComponent +import MultilineTextComponent + +final class VideoChatParticipantVideoStatusComponent: Component { + enum Kind { + case ownScreenshare + case paused + } + + let strings: PresentationStrings + let kind: Kind + let isExpanded: Bool + + init( + strings: PresentationStrings, + kind: Kind, + isExpanded: Bool + ) { + self.strings = strings + self.kind = kind + self.isExpanded = isExpanded + } + + static func ==(lhs: VideoChatParticipantVideoStatusComponent, rhs: VideoChatParticipantVideoStatusComponent) -> Bool { + if lhs.strings !== rhs.strings { + return false + } + if lhs.kind != rhs.kind { + return false + } + if lhs.isExpanded != rhs.isExpanded { + return false + } + return true + } + + final class View: UIView { + private var icon = ComponentView() + private let title = ComponentView() + + private var component: VideoChatParticipantVideoStatusComponent? + private var isUpdating: Bool = false + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + } + + func update(component: VideoChatParticipantVideoStatusComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + self.isUpdating = true + defer { + self.isUpdating = false + } + + let previousComponent = self.component + self.component = component + + var iconTransition = transition + if let previousComponent, previousComponent.kind != component.kind { + self.icon.view?.removeFromSuperview() + self.icon = ComponentView() + iconTransition = iconTransition.withAnimation(.none) + } + + let iconName: String + let titleValue: String + switch component.kind { + case .ownScreenshare: + iconName = "Call/ScreenSharePhone" + titleValue = component.strings.VoiceChat_YouAreSharingScreen + case .paused: + iconName = "Call/Pause" + titleValue = component.strings.VoiceChat_VideoPaused + } + + let iconSize = self.icon.update( + transition: iconTransition, + component: AnyComponent(BundleIconComponent( + name: iconName, + tintColor: .white + )), + environment: {}, + containerSize: CGSize(width: 100.0, height: 100.0) + ) + + let titleSize = self.title.update( + transition: .immediate, + component: AnyComponent(MultilineTextComponent( + text: .plain(NSAttributedString(string: titleValue, font: Font.semibold(14.0), textColor: .white)) + )), + environment: {}, + containerSize: CGSize(width: availableSize.width - 8.0 * 2.0, height: 100.0) + ) + + let scale: CGFloat = component.isExpanded ? 1.0 : 0.825 + + let spacing: CGFloat = 18.0 + let contentHeight: CGFloat = iconSize.height + spacing + titleSize.height + + let iconFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - iconSize.width) * 0.5), y: floor((availableSize.height - contentHeight) * 0.5)), size: iconSize) + let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: iconFrame.maxY + spacing), size: titleSize) + + if let iconView = self.icon.view { + if iconView.superview == nil { + self.addSubview(iconView) + } + iconTransition.setFrame(view: iconView, frame: iconFrame) + } + if let titleView = self.title.view { + if titleView.superview == nil { + self.addSubview(titleView) + } + iconTransition.setFrame(view: titleView, frame: titleFrame) + } + + iconTransition.setSublayerTransform(view: self, transform: CATransform3DMakeScale(scale, scale, 1.0)) + + return availableSize + } + } + + func makeView() -> View { + return View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, environment: environment, transition: transition) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift index 092dd64cda..c5c197f6e2 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatParticipantsComponent.swift @@ -26,25 +26,34 @@ final class VideoChatParticipantsComponent: Component { var videoColumn: Column? var mainColumn: Column var columnSpacing: CGFloat + var isMainColumnHidden: Bool - init(videoColumn: Column?, mainColumn: Column, columnSpacing: CGFloat) { + init(videoColumn: Column?, mainColumn: Column, columnSpacing: CGFloat, isMainColumnHidden: Bool) { self.videoColumn = videoColumn self.mainColumn = mainColumn self.columnSpacing = columnSpacing + self.isMainColumnHidden = isMainColumnHidden } } final class Participants: Equatable { + enum InviteType { + case invite + case shareLink + } + let myPeerId: EnginePeer.Id let participants: [GroupCallParticipantsContext.Participant] let totalCount: Int let loadMoreToken: String? + let inviteType: InviteType? - init(myPeerId: EnginePeer.Id, participants: [GroupCallParticipantsContext.Participant], totalCount: Int, loadMoreToken: String?) { + init(myPeerId: EnginePeer.Id, participants: [GroupCallParticipantsContext.Participant], totalCount: Int, loadMoreToken: String?, inviteType: InviteType?) { self.myPeerId = myPeerId self.participants = participants self.totalCount = totalCount self.loadMoreToken = loadMoreToken + self.inviteType = inviteType } static func ==(lhs: Participants, rhs: Participants) -> Bool { @@ -63,6 +72,9 @@ final class VideoChatParticipantsComponent: Component { if lhs.loadMoreToken != rhs.loadMoreToken { return false } + if lhs.inviteType != rhs.inviteType { + return false + } return true } } @@ -116,7 +128,7 @@ final class VideoChatParticipantsComponent: Component { let safeInsets: UIEdgeInsets let interfaceOrientation: UIInterfaceOrientation let openParticipantContextMenu: (EnginePeer.Id, ContextExtractedContentContainingView, ContextGesture?) -> Void - let updateMainParticipant: (VideoParticipantKey?) -> Void + let updateMainParticipant: (VideoParticipantKey?, Bool?) -> Void let updateIsMainParticipantPinned: (Bool) -> Void let updateIsExpandedUIHidden: (Bool) -> Void let openInviteMembers: () -> Void @@ -133,7 +145,7 @@ final class VideoChatParticipantsComponent: Component { safeInsets: UIEdgeInsets, interfaceOrientation: UIInterfaceOrientation, openParticipantContextMenu: @escaping (EnginePeer.Id, ContextExtractedContentContainingView, ContextGesture?) -> Void, - updateMainParticipant: @escaping (VideoParticipantKey?) -> Void, + updateMainParticipant: @escaping (VideoParticipantKey?, Bool?) -> Void, updateIsMainParticipantPinned: @escaping (Bool) -> Void, updateIsExpandedUIHidden: @escaping (Bool) -> Void, openInviteMembers: @escaping () -> Void @@ -412,7 +424,11 @@ final class VideoChatParticipantsComponent: Component { let gridSideInset: CGFloat let gridContainerHeight: CGFloat if let videoColumn = layout.videoColumn { - gridWidth = videoColumn.width + if layout.isMainColumnHidden { + gridWidth = videoColumn.width + layout.columnSpacing + layout.mainColumn.width + } else { + gridWidth = videoColumn.width + } gridSideInset = videoColumn.insets.left gridContainerHeight = containerSize.height - videoColumn.insets.top - videoColumn.insets.bottom } else { @@ -425,7 +441,7 @@ final class VideoChatParticipantsComponent: Component { self.list = List(containerSize: CGSize(width: listWidth, height: containerSize.height), sideInset: layout.mainColumn.insets.left, itemCount: listItemCount, itemHeight: listItemHeight, trailingItemHeight: listTrailingItemHeight) self.spacing = 4.0 - if let videoColumn = layout.videoColumn, !isUIHidden { + if let videoColumn = layout.videoColumn, !isUIHidden && !layout.isMainColumnHidden { self.expandedGrid = ExpandedGrid(containerSize: CGSize(width: videoColumn.width + expandedInsets.left, height: containerSize.height), layout: layout, expandedInsets: UIEdgeInsets(top: expandedInsets.top, left: expandedInsets.left, bottom: expandedInsets.bottom, right: 0.0), isUIHidden: isUIHidden) } else { self.expandedGrid = ExpandedGrid(containerSize: containerSize, layout: layout, expandedInsets: expandedInsets, isUIHidden: isUIHidden) @@ -449,8 +465,8 @@ final class VideoChatParticipantsComponent: Component { var separateVideoGridFrame = CGRect(origin: CGPoint(x: floor((containerSize.width - columnsWidth) * 0.5), y: 0.0), size: CGSize(width: gridWidth, height: containerSize.height)) var listFrame = CGRect(origin: CGPoint(x: separateVideoGridFrame.maxX + layout.columnSpacing, y: 0.0), size: CGSize(width: listWidth, height: containerSize.height)) - if isUIHidden { - listFrame.origin.x += columnsSideInset + layout.mainColumn.width + if isUIHidden || layout.isMainColumnHidden { + listFrame.origin.x = containerSize.width + columnsSideInset separateVideoGridFrame = CGRect(origin: CGPoint(x: floor((containerSize.width - columnsWidth) * 0.5), y: 0.0), size: CGSize(width: columnsWidth, height: containerSize.height)) } @@ -581,20 +597,19 @@ final class VideoChatParticipantsComponent: Component { } final class View: UIView, UIScrollViewDelegate { - private var rootVideoLoadingEffectView: VideoChatVideoLoadingEffectView? - private let scrollViewClippingContainer: SolidRoundedCornersContainer private let scrollView: ScrollView private let separateVideoScrollViewClippingContainer: SolidRoundedCornersContainer private let separateVideoScrollView: ScrollView - private var component: VideoChatParticipantsComponent? + private(set) var component: VideoChatParticipantsComponent? private weak var state: EmptyComponentState? private var isUpdating: Bool = false private var ignoreScrolling: Bool = false + //TODO:release private var gridParticipants: [VideoParticipant] = [] private var listParticipants: [GroupCallParticipantsContext.Participant] = [] @@ -618,6 +633,8 @@ final class VideoChatParticipantsComponent: Component { private var appliedGridIsEmpty: Bool = true + private var currentLoadMoreToken: String? + override init(frame: CGRect) { self.scrollViewClippingContainer = SolidRoundedCornersContainer() self.scrollView = ScrollView() @@ -693,9 +710,19 @@ final class VideoChatParticipantsComponent: Component { if component.expandedVideoState != nil { if let result = self.expandedGridItemContainer.hitTest(self.convert(point, to: self.expandedGridItemContainer), with: event) { return result - } else { - return self } + + if component.layout.videoColumn != nil { + if let result = self.scrollViewClippingContainer.hitTest(self.convert(point, to: self.scrollViewClippingContainer), with: event) { + return result + } + } + + if !self.expandedGridItemContainer.bounds.contains(self.convert(point, to: self.expandedGridItemContainer)) && !self.scrollViewClippingContainer.bounds.contains(self.convert(point, to: self.scrollViewClippingContainer)) { + return nil + } + + return self } else { if let result = self.scrollViewClippingContainer.hitTest(self.convert(point, to: self.scrollViewClippingContainer), with: event) { return result @@ -727,7 +754,7 @@ final class VideoChatParticipantsComponent: Component { let velocity = recognizer.velocity(in: self) if abs(velocity.y) > 100.0 || abs(fraction) >= 0.5 { - component.updateMainParticipant(nil) + component.updateMainParticipant(nil, nil) } else { self.state?.updated(transition: .spring(duration: 0.4)) } @@ -875,6 +902,14 @@ final class VideoChatParticipantsComponent: Component { itemFrame = itemLayout.gridItemFrame(at: index) } + let itemReferenceX: CGFloat = itemFrame.minX + let itemContainerWidth: CGFloat + if isItemExpanded { + itemContainerWidth = expandedGridItemContainerFrame.width + } else { + itemContainerWidth = itemLayout.grid.containerSize.width + } + let itemContentInsets: UIEdgeInsets if isItemExpanded { itemContentInsets = itemLayout.expandedGrid.itemContainerInsets() @@ -902,8 +937,10 @@ final class VideoChatParticipantsComponent: Component { let _ = itemView.view.update( transition: itemTransition, component: AnyComponent(VideoChatParticipantVideoComponent( + strings: component.strings, call: component.call, participant: videoParticipant.participant, + isMyPeer: videoParticipant.participant.peer.id == component.participants?.myPeerId, isPresentation: videoParticipant.isPresentation, isSpeaking: component.speakingParticipants.contains(videoParticipant.participant.peer.id), isExpanded: isItemExpanded, @@ -911,22 +948,30 @@ final class VideoChatParticipantsComponent: Component { contentInsets: itemContentInsets, controlInsets: itemControlInsets, interfaceOrientation: component.interfaceOrientation, - rootVideoLoadingEffectView: self.rootVideoLoadingEffectView, action: { [weak self] in guard let self, let component = self.component else { return } - if let expandedVideoState = component.expandedVideoState, expandedVideoState.mainParticipant == videoParticipantKey { - component.updateIsExpandedUIHidden(!expandedVideoState.isUIHidden) + + if self.gridParticipants.count == 1, component.layout.videoColumn != nil { + if let expandedVideoState = component.expandedVideoState, expandedVideoState.mainParticipant == videoParticipantKey { + component.updateMainParticipant(nil, false) + } else { + component.updateMainParticipant(videoParticipantKey, true) + } } else { - component.updateMainParticipant(videoParticipantKey) + if let expandedVideoState = component.expandedVideoState, expandedVideoState.mainParticipant == videoParticipantKey { + component.updateIsExpandedUIHidden(!expandedVideoState.isUIHidden) + } else { + component.updateMainParticipant(videoParticipantKey, nil) + } } } )), environment: {}, containerSize: itemFrame.size ) - if let itemComponentView = itemView.view.view { + if let itemComponentView = itemView.view.view as? VideoChatParticipantVideoComponent.View { if itemComponentView.superview == nil { itemComponentView.layer.allowsGroupOpacity = true @@ -942,6 +987,7 @@ final class VideoChatParticipantsComponent: Component { itemComponentView.frame = itemFrame itemComponentView.alpha = itemAlpha + itemComponentView.updateHorizontalReferenceLocation(containerWidth: itemContainerWidth, positionX: itemReferenceX, transition: .immediate) if !resultingItemTransition.animation.isImmediate { resultingItemTransition.animateScale(view: itemComponentView, from: 0.001, to: 1.0) @@ -976,11 +1022,13 @@ final class VideoChatParticipantsComponent: Component { itemComponentView.center = targetLocalItemFrame.center itemComponentView.bounds = CGRect(origin: CGPoint(), size: targetLocalItemFrame.size) }) + itemComponentView.updateHorizontalReferenceLocation(containerWidth: itemLayout.containerSize.width, positionX: itemFrame.minX, transition: commonGridItemTransition) } } if !itemView.isCollapsing { resultingItemTransition.setPosition(view: itemComponentView, position: itemFrame.center) resultingItemTransition.setBounds(view: itemComponentView, bounds: CGRect(origin: CGPoint(), size: itemFrame.size)) + itemComponentView.updateHorizontalReferenceLocation(containerWidth: itemLayout.containerSize.width, positionX: itemFrame.minX, transition: resultingItemTransition) let resultingItemAlphaTransition: ComponentTransition if !resultingItemTransition.animation.isImmediate { @@ -1046,6 +1094,7 @@ final class VideoChatParticipantsComponent: Component { let rightAccessoryComponent: AnyComponent = AnyComponent(VideoChatParticipantStatusComponent( muteState: participant.muteState, + hasRaiseHand: participant.hasRaiseHand, isSpeaking: component.speakingParticipants.contains(participant.peer.id), theme: component.theme )) @@ -1062,6 +1111,7 @@ final class VideoChatParticipantsComponent: Component { avatarComponent: AnyComponent(VideoChatParticipantAvatarComponent( call: component.call, peer: EnginePeer(participant.peer), + myPeerId: component.participants?.myPeerId ?? component.call.accountContext.account.peerId, isSpeaking: component.speakingParticipants.contains(participant.peer.id), theme: component.theme )), @@ -1226,11 +1276,12 @@ final class VideoChatParticipantsComponent: Component { return VideoChatExpandedParticipantThumbnailsComponent.Participant.Key(id: expandedVideoState.mainParticipant.id, isPresentation: expandedVideoState.mainParticipant.isPresentation) }, speakingParticipants: component.speakingParticipants, + interfaceOrientation: component.interfaceOrientation, updateSelectedParticipant: { [weak self] key in guard let self, let component = self.component else { return } - component.updateMainParticipant(VideoParticipantKey(id: key.id, isPresentation: key.isPresentation)) + component.updateMainParticipant(VideoParticipantKey(id: key.id, isPresentation: key.isPresentation), nil) } )), environment: {}, @@ -1278,7 +1329,7 @@ final class VideoChatParticipantsComponent: Component { guard let self, let component = self.component else { return } - component.updateMainParticipant(nil) + component.updateMainParticipant(nil, nil) }, pinAction: { [weak self] in guard let self, let component = self.component else { @@ -1360,6 +1411,13 @@ final class VideoChatParticipantsComponent: Component { } } } + + if let participants = component.participants, let loadMoreToken = participants.loadMoreToken, visibleListItemRange.maxIndex >= self.listParticipants.count - 5 { + if self.currentLoadMoreToken != loadMoreToken { + self.currentLoadMoreToken = loadMoreToken + component.call.loadMoreMembers(token: loadMoreToken) + } + } } func update(component: VideoChatParticipantsComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { @@ -1393,10 +1451,21 @@ final class VideoChatParticipantsComponent: Component { containerSize: CGSize(width: availableSize.width, height: 1000.0) ) + let inviteText: String + if let participants = component.participants, let inviteType = participants.inviteType { + switch inviteType { + case .invite: + inviteText = "Invite Members" + case .shareLink: + inviteText = "Share Invite Link" + } + } else { + inviteText = "Invite Members" + } let inviteListItemSize = self.inviteListItemView.update( transition: transition, component: AnyComponent(VideoChatListInviteComponent( - title: "Invite Members", + title: inviteText, theme: component.theme, action: { [weak self] in guard let self, let component = self.component else { diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift index 3878d2077c..a193b72312 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreen.swift @@ -21,18 +21,9 @@ import UndoUI import ShareController import AvatarNode import TelegramAudio - -import PeerInfoUI - -import DeleteChatPeerActionSheetItem -import PeerListItemComponent import LegacyComponents -import LegacyUI -import WebSearchUI -import MapResourceToAvatarSizes -import LegacyMediaPickerUI -private final class VideoChatScreenComponent: Component { +final class VideoChatScreenComponent: Component { typealias EnvironmentType = ViewControllerComponentContainer.Environment let initialData: VideoChatScreenV2Impl.InitialData @@ -59,58 +50,60 @@ private final class VideoChatScreenComponent: Component { } final class View: UIView { - private let containerView: UIView + let containerView: UIView - private var component: VideoChatScreenComponent? - private var environment: ViewControllerComponentContainer.Environment? - private weak var state: EmptyComponentState? - private var isUpdating: Bool = false + var component: VideoChatScreenComponent? + var environment: ViewControllerComponentContainer.Environment? + weak var state: EmptyComponentState? + var isUpdating: Bool = false private var panGestureState: PanGestureState? - private var notifyDismissedInteractivelyOnPanGestureApply: Bool = false - private var completionOnPanGestureApply: (() -> Void)? + var notifyDismissedInteractivelyOnPanGestureApply: Bool = false + var completionOnPanGestureApply: (() -> Void)? - private let videoRenderingContext = VideoRenderingContext() + let videoRenderingContext = VideoRenderingContext() - private let title = ComponentView() - private let navigationLeftButton = ComponentView() - private let navigationRightButton = ComponentView() + let title = ComponentView() + let navigationLeftButton = ComponentView() + let navigationRightButton = ComponentView() + var navigationSidebarButton: ComponentView? - private let videoButton = ComponentView() - private let leaveButton = ComponentView() - private let microphoneButton = ComponentView() + let videoButton = ComponentView() + let leaveButton = ComponentView() + let microphoneButton = ComponentView() - private let participants = ComponentView() + let participants = ComponentView() - private var reconnectedAsEventsDisposable: Disposable? + var reconnectedAsEventsDisposable: Disposable? - private var peer: EnginePeer? - private var callState: PresentationGroupCallState? - private var stateDisposable: Disposable? + var peer: EnginePeer? + var callState: PresentationGroupCallState? + var stateDisposable: Disposable? - private var audioOutputState: ([AudioSessionOutput], AudioSessionOutput?)? - private var audioOutputStateDisposable: Disposable? + var audioOutputState: ([AudioSessionOutput], AudioSessionOutput?)? + var audioOutputStateDisposable: Disposable? - private var displayAsPeers: [FoundPeer]? - private var displayAsPeersDisposable: Disposable? + var displayAsPeers: [FoundPeer]? + var displayAsPeersDisposable: Disposable? - private var inviteLinks: GroupCallInviteLinks? - private var inviteLinksDisposable: Disposable? + var inviteLinks: GroupCallInviteLinks? + var inviteLinksDisposable: Disposable? - private var isPushToTalkActive: Bool = false + var isPushToTalkActive: Bool = false - private var members: PresentationGroupCallMembers? - private var membersDisposable: Disposable? + var members: PresentationGroupCallMembers? + var membersDisposable: Disposable? - private let isPresentedValue = ValuePromise(false, ignoreRepeated: true) - private var applicationStateDisposable: Disposable? + let isPresentedValue = ValuePromise(false, ignoreRepeated: true) + var applicationStateDisposable: Disposable? - private var expandedParticipantsVideoState: VideoChatParticipantsComponent.ExpandedVideoState? + var expandedParticipantsVideoState: VideoChatParticipantsComponent.ExpandedVideoState? + var isTwoColumnSidebarHidden: Bool = false - private let inviteDisposable = MetaDisposable() - private let currentAvatarMixin = Atomic(value: nil) - private let updateAvatarDisposable = MetaDisposable() - private var currentUpdatingAvatar: (TelegramMediaImageRepresentation, Float)? + let inviteDisposable = MetaDisposable() + let currentAvatarMixin = Atomic(value: nil) + let updateAvatarDisposable = MetaDisposable() + var currentUpdatingAvatar: (TelegramMediaImageRepresentation, Float)? override init(frame: CGRect) { self.containerView = UIView() @@ -189,1180 +182,7 @@ private final class VideoChatScreenComponent: Component { } } - private func openMoreMenu() { - guard let sourceView = self.navigationLeftButton.view else { - return - } - guard let component = self.component, let environment = self.environment, let controller = environment.controller() else { - return - } - guard let peer = self.peer else { - return - } - guard let callState = self.callState else { - return - } - - let canManageCall = callState.canManageCall - - var items: [ContextMenuItem] = [] - - if let displayAsPeers = self.displayAsPeers, displayAsPeers.count > 1 { - for peer in displayAsPeers { - if peer.peer.id == callState.myPeerId { - let avatarSize = CGSize(width: 28.0, height: 28.0) - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_DisplayAs, textLayout: .secondLineWithValue(EnginePeer(peer.peer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)), icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: peerAvatarCompleteImage(account: component.call.accountContext.account, peer: EnginePeer(peer.peer), size: avatarSize)), action: { [weak self] c, _ in - guard let self else { - return - } - c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuDisplayAsItems())))) - }))) - items.append(.separator) - break - } - } - } - - if let (availableOutputs, currentOutput) = self.audioOutputState, availableOutputs.count > 1 { - var currentOutputTitle = "" - for output in availableOutputs { - if output == currentOutput { - let title: String - switch output { - case .builtin: - title = UIDevice.current.model - case .speaker: - title = environment.strings.Call_AudioRouteSpeaker - case .headphones: - title = environment.strings.Call_AudioRouteHeadphones - case let .port(port): - title = port.name - } - currentOutputTitle = title - break - } - } - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_ContextAudio, textLayout: .secondLineWithValue(currentOutputTitle), icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Audio"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] c, _ in - guard let self else { - return - } - c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuAudioItems())))) - }))) - } - - if canManageCall { - let text: String - if case let .channel(channel) = peer, case .broadcast = channel.info { - text = environment.strings.LiveStream_EditTitle - } else { - text = environment.strings.VoiceChat_EditTitle - } - items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Pencil"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] _, f in - f(.default) - - guard let self else { - return - } - self.openTitleEditing() - }))) - - var hasPermissions = true - if case let .channel(chatPeer) = peer { - if case .broadcast = chatPeer.info { - hasPermissions = false - } else if chatPeer.flags.contains(.isGigagroup) { - hasPermissions = false - } - } - if hasPermissions { - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_EditPermissions, icon: { theme -> UIImage? in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Restrict"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] c, _ in - guard let self else { - return - } - c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuPermissionItems())))) - }))) - } - } - - if let inviteLinks = self.inviteLinks { - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_Share, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] _, f in - f(.default) - - guard let self else { - return - } - self.presentShare(inviteLinks) - }))) - } - - //let isScheduled = strongSelf.isScheduled - //TODO:release - let isScheduled: Bool = !"".isEmpty - - let canSpeak: Bool - if let muteState = callState.muteState { - canSpeak = muteState.canUnmute - } else { - canSpeak = true - } - - if !isScheduled && canSpeak { - if #available(iOS 15.0, *) { - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MicrophoneModes, textColor: .primary, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Noise"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in - f(.dismissWithoutContent) - AVCaptureDevice.showSystemUserInterface(.microphoneModes) - }))) - } - } - - if callState.isVideoEnabled && (callState.muteState?.canUnmute ?? true) { - if component.call.hasScreencast { - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_StopScreenSharing, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ShareScreen"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] _, f in - f(.default) - - guard let self, let component = self.component else { - return - } - component.call.disableScreencast() - }))) - } else { - items.append(.custom(VoiceChatShareScreenContextItem(context: component.call.accountContext, text: environment.strings.VoiceChat_ShareScreen, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ShareScreen"), color: theme.actionSheet.primaryTextColor) - }, action: { _, _ in }), false)) - } - } - - if canManageCall { - if let recordingStartTimestamp = callState.recordingStartTimestamp { - items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { [weak self] _, f in - f(.dismissWithoutContent) - - guard let self, let component = self.component, let environment = self.environment else { - return - } - - let alertController = textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: environment.strings.VoiceChat_StopRecordingTitle, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_StopRecordingStop, action: { [weak self] in - guard let self, let component = self.component, let environment = self.environment else { - return - } - component.call.setShouldBeRecording(false, title: nil, videoOrientation: nil) - - Queue.mainQueue().after(0.88) { - HapticFeedback().success() - } - - let text: String - if case let .channel(channel) = self.peer, case .broadcast = channel.info { - text = environment.strings.LiveStream_RecordingSaved - } else { - text = environment.strings.VideoChat_RecordingSaved - } - self.presentUndoOverlay(content: .forward(savedMessages: true, text: text), action: { [weak self] value in - if case .info = value, let self, let component = self.component, let environment = self.environment, let navigationController = environment.controller()?.navigationController as? NavigationController { - let context = component.call.accountContext - environment.controller()?.dismiss(completion: { [weak navigationController] in - Queue.mainQueue().justDispatch { - let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) - |> deliverOnMainQueue).start(next: { peer in - guard let peer, let navigationController else { - return - } - context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), keepStack: .always, purposefulAction: {}, peekData: nil)) - }) - } - }) - - return true - } - return false - }) - })]) - environment.controller()?.present(alertController, in: .window(.root)) - }), false)) - } else { - let text: String - if case let .channel(channel) = peer, case .broadcast = channel.info { - text = environment.strings.LiveStream_StartRecording - } else { - text = environment.strings.VoiceChat_StartRecording - } - if callState.scheduleTimestamp == nil { - items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in - return generateStartRecordingIcon(color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] _, f in - f(.dismissWithoutContent) - - guard let self, let component = self.component, let environment = self.environment, let peer = self.peer else { - return - } - - let controller = VoiceChatRecordingSetupController(context: component.call.accountContext, peer: peer, completion: { [weak self] videoOrientation in - guard let self, let component = self.component, let environment = self.environment, let peer = self.peer else { - return - } - let title: String - let text: String - let placeholder: String - if let _ = videoOrientation { - placeholder = environment.strings.VoiceChat_RecordingTitlePlaceholderVideo - } else { - placeholder = environment.strings.VoiceChat_RecordingTitlePlaceholder - } - if case let .channel(channel) = peer, case .broadcast = channel.info { - title = environment.strings.LiveStream_StartRecordingTitle - if let _ = videoOrientation { - text = environment.strings.LiveStream_StartRecordingTextVideo - } else { - text = environment.strings.LiveStream_StartRecordingText - } - } else { - title = environment.strings.VoiceChat_StartRecordingTitle - if let _ = videoOrientation { - text = environment.strings.VoiceChat_StartRecordingTextVideo - } else { - text = environment.strings.VoiceChat_StartRecordingText - } - } - - let controller = voiceChatTitleEditController(sharedContext: component.call.accountContext.sharedContext, account: component.call.account, forceTheme: environment.theme, title: title, text: text, placeholder: placeholder, value: nil, maxLength: 40, apply: { [weak self] title in - guard let self, let component = self.component, let environment = self.environment, let peer = self.peer, let title else { - return - } - - component.call.setShouldBeRecording(true, title: title, videoOrientation: videoOrientation) - - let text: String - if case let .channel(channel) = peer, case .broadcast = channel.info { - text = environment.strings.LiveStream_RecordingStarted - } else { - text = environment.strings.VoiceChat_RecordingStarted - } - - self.presentUndoOverlay(content: .voiceChatRecording(text: text), action: { _ in return false }) - component.call.playTone(.recordingStarted) - }) - environment.controller()?.present(controller, in: .window(.root)) - }) - environment.controller()?.present(controller, in: .window(.root)) - }))) - } - } - } - - if canManageCall { - let text: String - if case let .channel(channel) = peer, case .broadcast = channel.info { - text = isScheduled ? environment.strings.VoiceChat_CancelLiveStream : environment.strings.VoiceChat_EndLiveStream - } else { - text = isScheduled ? environment.strings.VoiceChat_CancelVoiceChat : environment.strings.VoiceChat_EndVoiceChat - } - items.append(.action(ContextMenuActionItem(text: text, textColor: .destructive, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor) - }, action: { [weak self] _, f in - f(.dismissWithoutContent) - - guard let self, let component = self.component, let environment = self.environment else { - return - } - - let action: () -> Void = { [weak self] in - guard let self, let component = self.component else { - return - } - - let _ = (component.call.leave(terminateIfPossible: true) - |> filter { $0 } - |> take(1) - |> deliverOnMainQueue).start(completed: { [weak self] in - guard let self, let environment = self.environment else { - return - } - environment.controller()?.dismiss() - }) - } - - let title: String - let text: String - if case let .channel(channel) = self.peer, case .broadcast = channel.info { - title = isScheduled ? environment.strings.LiveStream_CancelConfirmationTitle : environment.strings.LiveStream_EndConfirmationTitle - text = isScheduled ? environment.strings.LiveStream_CancelConfirmationText : environment.strings.LiveStream_EndConfirmationText - } else { - title = isScheduled ? environment.strings.VoiceChat_CancelConfirmationTitle : environment.strings.VoiceChat_EndConfirmationTitle - text = isScheduled ? environment.strings.VoiceChat_CancelConfirmationText : environment.strings.VoiceChat_EndConfirmationText - } - - let alertController = textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: isScheduled ? environment.strings.VoiceChat_CancelConfirmationEnd : environment.strings.VoiceChat_EndConfirmationEnd, action: { - action() - })]) - environment.controller()?.present(alertController, in: .window(.root)) - }))) - } else { - let leaveText: String - if case let .channel(channel) = peer, case .broadcast = channel.info { - leaveText = environment.strings.LiveStream_LeaveVoiceChat - } else { - leaveText = environment.strings.VoiceChat_LeaveVoiceChat - } - items.append(.action(ContextMenuActionItem(text: leaveText, textColor: .destructive, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor) - }, action: { [weak self] _, f in - f(.dismissWithoutContent) - - guard let self, let component = self.component else { - return - } - - let _ = (component.call.leave(terminateIfPossible: false) - |> filter { $0 } - |> take(1) - |> deliverOnMainQueue).start(completed: { [weak self] in - guard let self, let environment = self.environment else { - return - } - environment.controller()?.dismiss() - }) - }))) - } - - let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) - let contextController = ContextController(presentationData: presentationData, source: .reference(VoiceChatContextReferenceContentSource(controller: controller, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil) - controller.presentInGlobalOverlay(contextController) - } - - private func contextMenuDisplayAsItems() -> [ContextMenuItem] { - guard let component = self.component, let environment = self.environment else { - return [] - } - guard let callState = self.callState else { - return [] - } - let myPeerId = callState.myPeerId - - let avatarSize = CGSize(width: 28.0, height: 28.0) - - var items: [ContextMenuItem] = [] - - items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) - }, iconPosition: .left, action: { (c, _) in - c?.popItems() - }))) - items.append(.separator) - - var isGroup = false - if let displayAsPeers = self.displayAsPeers { - for peer in displayAsPeers { - if peer.peer is TelegramGroup { - isGroup = true - break - } else if let peer = peer.peer as? TelegramChannel, case .group = peer.info { - isGroup = true - break - } - } - } - - items.append(.custom(VoiceChatInfoContextItem(text: isGroup ? environment.strings.VoiceChat_DisplayAsInfoGroup : environment.strings.VoiceChat_DisplayAsInfo, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Accounts"), color: theme.actionSheet.primaryTextColor) - }), true)) - - if let displayAsPeers = self.displayAsPeers { - for peer in displayAsPeers { - var subtitle: String? - if peer.peer.id.namespace == Namespaces.Peer.CloudUser { - subtitle = environment.strings.VoiceChat_PersonalAccount - } else if let subscribers = peer.subscribers { - if let peer = peer.peer as? TelegramChannel, case .broadcast = peer.info { - subtitle = environment.strings.Conversation_StatusSubscribers(subscribers) - } else { - subtitle = environment.strings.Conversation_StatusMembers(subscribers) - } - } - - let isSelected = peer.peer.id == myPeerId - let extendedAvatarSize = CGSize(width: 35.0, height: 35.0) - let theme = environment.theme - let avatarSignal = peerAvatarCompleteImage(account: component.call.accountContext.account, peer: EnginePeer(peer.peer), size: avatarSize) - |> map { image -> UIImage? in - if isSelected, let image = image { - return generateImage(extendedAvatarSize, rotatedContext: { size, context in - let bounds = CGRect(origin: CGPoint(), size: size) - context.clear(bounds) - context.translateBy(x: size.width / 2.0, y: size.height / 2.0) - context.scaleBy(x: 1.0, y: -1.0) - context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) - context.draw(image.cgImage!, in: CGRect(x: (extendedAvatarSize.width - avatarSize.width) / 2.0, y: (extendedAvatarSize.height - avatarSize.height) / 2.0, width: avatarSize.width, height: avatarSize.height)) - - let lineWidth = 1.0 + UIScreenPixel - context.setLineWidth(lineWidth) - context.setStrokeColor(theme.actionSheet.controlAccentColor.cgColor) - context.strokeEllipse(in: bounds.insetBy(dx: lineWidth / 2.0, dy: lineWidth / 2.0)) - }) - } else { - return image - } - } - - items.append(.action(ContextMenuActionItem(text: EnginePeer(peer.peer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), textLayout: subtitle.flatMap { .secondLineWithValue($0) } ?? .singleLine, icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: isSelected ? extendedAvatarSize : avatarSize, signal: avatarSignal), action: { [weak self] _, f in - f(.default) - - guard let self, let component = self.component else { - return - } - - if peer.peer.id != myPeerId { - component.call.reconnect(as: peer.peer.id) - } - }))) - - if peer.peer.id.namespace == Namespaces.Peer.CloudUser { - items.append(.separator) - } - } - } - return items - } - - private func contextMenuAudioItems() -> [ContextMenuItem] { - guard let environment = self.environment else { - return [] - } - guard let (availableOutputs, currentOutput) = self.audioOutputState else { - return [] - } - - var items: [ContextMenuItem] = [] - - items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) - }, iconPosition: .left, action: { (c, _) in - c?.popItems() - }))) - items.append(.separator) - - for output in availableOutputs { - let title: String - switch output { - case .builtin: - title = UIDevice.current.model - case .speaker: - title = environment.strings.Call_AudioRouteSpeaker - case .headphones: - title = environment.strings.Call_AudioRouteHeadphones - case let .port(port): - title = port.name - } - items.append(.action(ContextMenuActionItem(text: title, icon: { theme in - if output == currentOutput { - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor) - } else { - return nil - } - }, action: { [weak self] _, f in - f(.default) - - guard let self, let component = self.component else { - return - } - - component.call.setCurrentAudioOutput(output) - }))) - } - - return items - } - - private func contextMenuPermissionItems() -> [ContextMenuItem] { - guard let environment = self.environment, let callState = self.callState else { - return [] - } - - var items: [ContextMenuItem] = [] - if callState.canManageCall, let defaultParticipantMuteState = callState.defaultParticipantMuteState { - let isMuted = defaultParticipantMuteState == .muted - - items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) - }, iconPosition: .left, action: { (c, _) in - c?.popItems() - }))) - items.append(.separator) - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_SpeakPermissionEveryone, icon: { theme in - if isMuted { - return nil - } else { - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor) - } - }, action: { [weak self] _, f in - f(.dismissWithoutContent) - - guard let self, let component = self.component else { - return - } - component.call.updateDefaultParticipantsAreMuted(isMuted: false) - }))) - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_SpeakPermissionAdmin, icon: { theme in - if !isMuted { - return nil - } else { - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor) - } - }, action: { [weak self] _, f in - f(.dismissWithoutContent) - - guard let self, let component = self.component else { - return - } - component.call.updateDefaultParticipantsAreMuted(isMuted: true) - }))) - } - return items - } - - private func openParticipantContextMenu(id: EnginePeer.Id, sourceView: ContextExtractedContentContainingView, gesture: ContextGesture?) { - guard let component = self.component, let environment = self.environment else { - return - } - guard let members = self.members, let participant = members.participants.first(where: { $0.peer.id == id }) else { - return - } - - let muteStatePromise = Promise(participant.muteState) - - let itemsForEntry: (GroupCallParticipantsContext.Participant.MuteState?) -> [ContextMenuItem] = { [weak self] muteState in - guard let self, let component = self.component, let environment = self.environment else { - return [] - } - guard let callState = self.callState else { - return [] - } - - var items: [ContextMenuItem] = [] - - var hasVolumeSlider = false - let peer = participant.peer - if let muteState = muteState, !muteState.canUnmute || muteState.mutedByYou { - } else { - if callState.canManageCall || callState.myPeerId != id { - hasVolumeSlider = true - - let minValue: CGFloat - if callState.canManageCall && callState.adminIds.contains(peer.id) && muteState != nil { - minValue = 0.01 - } else { - minValue = 0.0 - } - items.append(.custom(VoiceChatVolumeContextItem(minValue: minValue, value: participant.volume.flatMap { CGFloat($0) / 10000.0 } ?? 1.0, valueChanged: { [weak self] newValue, finished in - guard let self, let component = self.component else { - return - } - - if finished && newValue.isZero { - let updatedMuteState = component.call.updateMuteState(peerId: peer.id, isMuted: true) - muteStatePromise.set(.single(updatedMuteState)) - } else { - component.call.setVolume(peerId: peer.id, volume: Int32(newValue * 10000), sync: finished) - } - }), true)) - } - } - - if callState.myPeerId == id && !hasVolumeSlider && ((participant.about?.isEmpty ?? true) || participant.peer.smallProfileImage == nil) { - items.append(.custom(VoiceChatInfoContextItem(text: environment.strings.VoiceChat_ImproveYourProfileText, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Tip"), color: theme.actionSheet.primaryTextColor) - }), true)) - } - - if peer.id == callState.myPeerId { - if participant.hasRaiseHand { - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_CancelSpeakRequest, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/RevokeSpeak"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] _, f in - guard let self, let component = self.component else { - return - } - component.call.lowerHand() - - f(.default) - }))) - } - items.append(.action(ContextMenuActionItem(text: peer.smallProfileImage == nil ? environment.strings.VoiceChat_AddPhoto : environment.strings.VoiceChat_ChangePhoto, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Camera"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] _, f in - f(.default) - Queue.mainQueue().after(0.1) { - guard let self else { - return - } - - self.openAvatarForEditing(fromGallery: false, completion: {}) - } - }))) - - items.append(.action(ContextMenuActionItem(text: (participant.about?.isEmpty ?? true) ? environment.strings.VoiceChat_AddBio : environment.strings.VoiceChat_EditBio, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] _, f in - f(.default) - - Queue.mainQueue().after(0.1) { - guard let self, let component = self.component, let environment = self.environment else { - return - } - let maxBioLength: Int - if peer.id.namespace == Namespaces.Peer.CloudUser { - maxBioLength = 70 - } else { - maxBioLength = 100 - } - let controller = voiceChatTitleEditController(sharedContext: component.call.accountContext.sharedContext, account: component.call.accountContext.account, forceTheme: environment.theme, title: environment.strings.VoiceChat_EditBioTitle, text: environment.strings.VoiceChat_EditBioText, placeholder: environment.strings.VoiceChat_EditBioPlaceholder, doneButtonTitle: environment.strings.VoiceChat_EditBioSave, value: participant.about, maxLength: maxBioLength, apply: { [weak self] bio in - guard let self, let component = self.component, let environment = self.environment, let bio else { - return - } - if peer.id.namespace == Namespaces.Peer.CloudUser { - let _ = (component.call.accountContext.engine.accountData.updateAbout(about: bio) - |> `catch` { _ -> Signal in - return .complete() - }).start() - } else { - let _ = (component.call.accountContext.engine.peers.updatePeerDescription(peerId: peer.id, description: bio) - |> `catch` { _ -> Signal in - return .complete() - }).start() - } - - self.presentUndoOverlay(content: .info(title: nil, text: environment.strings.VoiceChat_EditBioSuccess, timeout: nil, customUndoText: nil), action: { _ in return false }) - }) - environment.controller()?.present(controller, in: .window(.root)) - } - }))) - - if let peer = peer as? TelegramUser { - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_ChangeName, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ChangeName"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] _, f in - f(.default) - - Queue.mainQueue().after(0.1) { - guard let self, let component = self.component, let environment = self.environment else { - return - } - let controller = voiceChatUserNameController(sharedContext: component.call.accountContext.sharedContext, account: component.call.accountContext.account, forceTheme: environment.theme, title: environment.strings.VoiceChat_ChangeNameTitle, firstNamePlaceholder: environment.strings.UserInfo_FirstNamePlaceholder, lastNamePlaceholder: environment.strings.UserInfo_LastNamePlaceholder, doneButtonTitle: environment.strings.VoiceChat_EditBioSave, firstName: peer.firstName, lastName: peer.lastName, maxLength: 128, apply: { [weak self] firstAndLastName in - guard let self, let component = self.component, let environment = self.environment, let (firstName, lastName) = firstAndLastName else { - return - } - let _ = component.call.accountContext.engine.accountData.updateAccountPeerName(firstName: firstName, lastName: lastName).startStandalone() - - self.presentUndoOverlay(content: .info(title: nil, text: environment.strings.VoiceChat_EditNameSuccess, timeout: nil, customUndoText: nil), action: { _ in return false }) - }) - environment.controller()?.present(controller, in: .window(.root)) - } - }))) - } - } else { - if (callState.canManageCall || callState.adminIds.contains(component.call.accountContext.account.peerId)) { - if callState.adminIds.contains(peer.id) { - if let _ = muteState { - } else { - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MutePeer, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] _, f in - guard let self, let component = self.component else { - return - } - - let _ = component.call.updateMuteState(peerId: peer.id, isMuted: true) - f(.default) - }))) - } - } else { - if let muteState = muteState, !muteState.canUnmute { - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_UnmutePeer, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: participant.hasRaiseHand ? "Call/Context Menu/AllowToSpeak" : "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] _, f in - guard let self, let component = self.component, let environment = self.environment else { - return - } - - let _ = component.call.updateMuteState(peerId: peer.id, isMuted: false) - f(.default) - - self.presentUndoOverlay(content: .voiceChatCanSpeak(text: environment.strings.VoiceChat_UserCanNowSpeak(EnginePeer(participant.peer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string), action: { _ in return true }) - }))) - } else { - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MutePeer, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] _, f in - guard let self, let component = self.component else { - return - } - - let _ = component.call.updateMuteState(peerId: peer.id, isMuted: true) - f(.default) - }))) - } - } - } else { - if let muteState = muteState, muteState.mutedByYou { - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_UnmuteForMe, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] _, f in - guard let self, let component = self.component else { - return - } - - let _ = component.call.updateMuteState(peerId: peer.id, isMuted: false) - f(.default) - }))) - } else { - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MuteForMe, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] _, f in - guard let self, let component = self.component else { - return - } - - let _ = component.call.updateMuteState(peerId: peer.id, isMuted: true) - f(.default) - }))) - } - } - - let openTitle: String - let openIcon: UIImage? - if [Namespaces.Peer.CloudChannel, Namespaces.Peer.CloudGroup].contains(peer.id.namespace) { - if let peer = peer as? TelegramChannel, case .broadcast = peer.info { - openTitle = environment.strings.VoiceChat_OpenChannel - openIcon = UIImage(bundleImageName: "Chat/Context Menu/Channels") - } else { - openTitle = environment.strings.VoiceChat_OpenGroup - openIcon = UIImage(bundleImageName: "Chat/Context Menu/Groups") - } - } else { - openTitle = environment.strings.Conversation_ContextMenuSendMessage - openIcon = UIImage(bundleImageName: "Chat/Context Menu/Message") - } - items.append(.action(ContextMenuActionItem(text: openTitle, icon: { theme in - return generateTintedImage(image: openIcon, color: theme.actionSheet.primaryTextColor) - }, action: { [weak self] _, f in - guard let self, let component = self.component, let environment = self.environment else { - return - } - - guard let controller = environment.controller() as? VideoChatScreenV2Impl, let navigationController = controller.parentNavigationController else { - return - } - - let context = component.call.accountContext - environment.controller()?.dismiss(completion: { [weak navigationController] in - Queue.mainQueue().after(0.3) { - guard let navigationController else { - return - } - context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(EnginePeer(peer)), keepStack: .always, purposefulAction: {}, peekData: nil)) - } - }) - - f(.dismissWithoutContent) - }))) - - if (callState.canManageCall && !callState.adminIds.contains(peer.id)), peer.id != component.call.peerId { - items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_RemovePeer, textColor: .destructive, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor) - }, action: { [weak self] c, _ in - c?.dismiss(completion: { - guard let self, let component = self.component else { - return - } - - let _ = (component.call.accountContext.account.postbox.loadedPeerWithId(component.call.peerId) - |> deliverOnMainQueue).start(next: { [weak self] chatPeer in - guard let self, let component = self.component, let environment = self.environment else { - return - } - - let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) - let actionSheet = ActionSheetController(presentationData: presentationData) - var items: [ActionSheetItem] = [] - - let nameDisplayOrder = presentationData.nameDisplayOrder - items.append(DeleteChatPeerActionSheetItem(context: component.call.accountContext, peer: EnginePeer(peer), chatPeer: EnginePeer(chatPeer), action: .removeFromGroup, strings: environment.strings, nameDisplayOrder: nameDisplayOrder)) - - items.append(ActionSheetButtonItem(title: environment.strings.VoiceChat_RemovePeerRemove, color: .destructive, action: { [weak self, weak actionSheet] in - actionSheet?.dismissAnimated() - - guard let self, let component = self.component, let environment = self.environment else { - return - } - - let _ = component.call.accountContext.peerChannelMemberCategoriesContextsManager.updateMemberBannedRights(engine: component.call.accountContext.engine, peerId: component.call.peerId, memberId: peer.id, bannedRights: TelegramChatBannedRights(flags: [.banReadMessages], untilDate: Int32.max)).start() - component.call.removedPeer(peer.id) - - self.presentUndoOverlay(content: .banned(text: environment.strings.VoiceChat_RemovedPeerText(EnginePeer(peer).displayTitle(strings: environment.strings, displayOrder: nameDisplayOrder)).string), action: { _ in return false }) - })) - - actionSheet.setItemGroups([ - ActionSheetItemGroup(items: items), - ActionSheetItemGroup(items: [ - ActionSheetButtonItem(title: environment.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in - actionSheet?.dismissAnimated() - }) - ]) - ]) - environment.controller()?.present(actionSheet, in: .window(.root)) - }) - }) - }))) - } - } - return items - } - - let items = muteStatePromise.get() - |> map { muteState -> [ContextMenuItem] in - return itemsForEntry(muteState) - } - - let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) - let contextController = ContextController( - presentationData: presentationData, - source: .extracted(ParticipantExtractedContentSource(contentView: sourceView)), - items: items |> map { items in - return ContextController.Items(content: .list(items)) - }, - recognizer: nil, - gesture: gesture - ) - - environment.controller()?.forEachController({ controller in - if let controller = controller as? UndoOverlayController { - controller.dismiss() - } - return true - }) - - environment.controller()?.presentInGlobalOverlay(contextController) - } - - private func openAvatarForEditing(fromGallery: Bool = false, completion: @escaping () -> Void = {}) { - guard let component = self.component else { - return - } - guard let callState = self.callState else { - return - } - let peerId = callState.myPeerId - - let _ = (component.call.accountContext.engine.data.get( - TelegramEngine.EngineData.Item.Peer.Peer(id: peerId), - TelegramEngine.EngineData.Item.Configuration.SearchBots() - ) - |> deliverOnMainQueue).start(next: { [weak self] peer, searchBotsConfiguration in - guard let self, let component = self.component, let environment = self.environment else { - return - } - guard let peer else { - return - } - - let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) - - let legacyController = LegacyController(presentation: .custom, theme: environment.theme) - legacyController.statusBar.statusBarStyle = .Ignore - - let emptyController = LegacyEmptyController(context: legacyController.context)! - let navigationController = makeLegacyNavigationController(rootController: emptyController) - navigationController.setNavigationBarHidden(true, animated: false) - navigationController.navigationBar.transform = CGAffineTransform(translationX: -1000.0, y: 0.0) - - legacyController.bind(controller: navigationController) - - self.endEditing(true) - environment.controller()?.present(legacyController, in: .window(.root)) - - var hasPhotos = false - if !peer.profileImageRepresentations.isEmpty { - hasPhotos = true - } - - let mixin = TGMediaAvatarMenuMixin(context: legacyController.context, parentController: emptyController, hasSearchButton: true, hasDeleteButton: hasPhotos && !fromGallery, hasViewButton: false, personalPhoto: peerId.namespace == Namespaces.Peer.CloudUser, isVideo: false, saveEditedPhotos: false, saveCapturedMedia: false, signup: false, forum: false, title: nil, isSuggesting: false)! - mixin.forceDark = true - mixin.stickersContext = LegacyPaintStickersContext(context: component.call.accountContext) - let _ = self.currentAvatarMixin.swap(mixin) - mixin.requestSearchController = { [weak self] assetsController in - guard let self, let component = self.component, let environment = self.environment else { - return - } - let controller = WebSearchController(context: component.call.accountContext, peer: peer, chatLocation: nil, configuration: searchBotsConfiguration, mode: .avatar(initialQuery: peer.id.namespace == Namespaces.Peer.CloudUser ? nil : peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder), completion: { [weak self] result in - assetsController?.dismiss() - - guard let self else { - return - } - self.updateProfilePhoto(result) - })) - controller.navigationPresentation = .modal - environment.controller()?.push(controller) - - if fromGallery { - completion() - } - } - mixin.didFinishWithImage = { [weak self] image in - if let image = image { - completion() - self?.updateProfilePhoto(image) - } - } - mixin.didFinishWithVideo = { [weak self] image, asset, adjustments in - if let image = image, let asset = asset { - completion() - self?.updateProfileVideo(image, asset: asset, adjustments: adjustments) - } - } - mixin.didFinishWithDelete = { [weak self] in - guard let self, let environment = self.environment else { - return - } - - let proceed = { [weak self] in - guard let self, let component = self.component else { - return - } - - let _ = self.currentAvatarMixin.swap(nil) - let postbox = component.call.accountContext.account.postbox - self.updateAvatarDisposable.set((component.call.accountContext.engine.peers.updatePeerPhoto(peerId: peerId, photo: nil, mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) - }) - |> deliverOnMainQueue).start()) - } - - let actionSheet = ActionSheetController(presentationData: presentationData) - let items: [ActionSheetItem] = [ - ActionSheetButtonItem(title: environment.strings.Settings_RemoveConfirmation, color: .destructive, action: { [weak actionSheet] in - actionSheet?.dismissAnimated() - proceed() - }) - ] - - actionSheet.setItemGroups([ - ActionSheetItemGroup(items: items), - ActionSheetItemGroup(items: [ - ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in - actionSheet?.dismissAnimated() - }) - ]) - ]) - environment.controller()?.present(actionSheet, in: .window(.root)) - } - mixin.didDismiss = { [weak self, weak legacyController] in - guard let self else { - return - } - let _ = self.currentAvatarMixin.swap(nil) - legacyController?.dismiss() - } - let menuController = mixin.present() - if let menuController = menuController { - menuController.customRemoveFromParentViewController = { [weak legacyController] in - legacyController?.dismiss() - } - } - }) - } - - private func updateProfilePhoto(_ image: UIImage) { - guard let component = self.component else { - return - } - guard let callState = self.callState else { - return - } - guard let data = image.jpegData(compressionQuality: 0.6) else { - return - } - - let peerId = callState.myPeerId - - let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) - component.call.account.postbox.mediaBox.storeResourceData(resource.id, data: data) - let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false) - - self.currentUpdatingAvatar = (representation, 0.0) - - let postbox = component.call.account.postbox - let signal = peerId.namespace == Namespaces.Peer.CloudUser ? component.call.accountContext.engine.accountData.updateAccountPhoto(resource: resource, videoResource: nil, videoStartTimestamp: nil, markup: nil, mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) - }) : component.call.accountContext.engine.peers.updatePeerPhoto(peerId: peerId, photo: component.call.accountContext.engine.peers.uploadedPeerPhoto(resource: resource), mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) - }) - - self.updateAvatarDisposable.set((signal - |> deliverOnMainQueue).start(next: { [weak self] result in - guard let self else { - return - } - switch result { - case .complete: - self.currentUpdatingAvatar = nil - self.state?.updated(transition: .spring(duration: 0.4)) - case let .progress(value): - self.currentUpdatingAvatar = (representation, value) - } - })) - - self.state?.updated(transition: .spring(duration: 0.4)) - } - - private func updateProfileVideo(_ image: UIImage, asset: Any?, adjustments: TGVideoEditAdjustments?) { - guard let component = self.component else { - return - } - guard let callState = self.callState else { - return - } - guard let data = image.jpegData(compressionQuality: 0.6) else { - return - } - let peerId = callState.myPeerId - - let photoResource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) - component.call.accountContext.account.postbox.mediaBox.storeResourceData(photoResource.id, data: data) - let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: photoResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false) - - self.currentUpdatingAvatar = (representation, 0.0) - - var videoStartTimestamp: Double? = nil - if let adjustments = adjustments, adjustments.videoStartValue > 0.0 { - videoStartTimestamp = adjustments.videoStartValue - adjustments.trimStartValue - } - - let context = component.call.accountContext - let account = context.account - let signal = Signal { [weak self] subscriber in - let entityRenderer: LegacyPaintEntityRenderer? = adjustments.flatMap { adjustments in - if let paintingData = adjustments.paintingData, paintingData.hasAnimation { - return LegacyPaintEntityRenderer(postbox: account.postbox, adjustments: adjustments) - } else { - return nil - } - } - - let tempFile = EngineTempBox.shared.tempFile(fileName: "video.mp4") - let uploadInterface = LegacyLiveUploadInterface(context: context) - let signal: SSignal - if let url = asset as? URL, url.absoluteString.hasSuffix(".jpg"), let data = try? Data(contentsOf: url, options: [.mappedRead]), let image = UIImage(data: data), let entityRenderer = entityRenderer { - let durationSignal: SSignal = SSignal(generator: { subscriber in - let disposable = (entityRenderer.duration()).start(next: { duration in - subscriber.putNext(duration) - subscriber.putCompletion() - }) - - return SBlockDisposable(block: { - disposable.dispose() - }) - }) - signal = durationSignal.map(toSignal: { duration -> SSignal in - if let duration = duration as? Double { - return TGMediaVideoConverter.renderUIImage(image, duration: duration, adjustments: adjustments, path: tempFile.path, watcher: nil, entityRenderer: entityRenderer)! - } else { - return SSignal.single(nil) - } - }) - - } else if let asset = asset as? AVAsset { - signal = TGMediaVideoConverter.convert(asset, adjustments: adjustments, path: tempFile.path, watcher: uploadInterface, entityRenderer: entityRenderer)! - } else { - signal = SSignal.complete() - } - - let signalDisposable = signal.start(next: { next in - if let result = next as? TGMediaVideoConversionResult { - if let image = result.coverImage, let data = image.jpegData(compressionQuality: 0.7) { - account.postbox.mediaBox.storeResourceData(photoResource.id, data: data) - } - - if let timestamp = videoStartTimestamp { - videoStartTimestamp = max(0.0, min(timestamp, result.duration - 0.05)) - } - - var value = stat() - if stat(result.fileURL.path, &value) == 0 { - if let data = try? Data(contentsOf: result.fileURL) { - let resource: TelegramMediaResource - if let liveUploadData = result.liveUploadData as? LegacyLiveUploadInterfaceResult { - resource = LocalFileMediaResource(fileId: liveUploadData.id) - } else { - resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) - } - account.postbox.mediaBox.storeResourceData(resource.id, data: data, synchronous: true) - subscriber.putNext(resource) - - EngineTempBox.shared.dispose(tempFile) - } - } - subscriber.putCompletion() - } else if let progress = next as? NSNumber { - Queue.mainQueue().async { [weak self] in - guard let self else { - return - } - self.currentUpdatingAvatar = (representation, Float(truncating: progress) * 0.25) - self.state?.updated(transition: .spring(duration: 0.4)) - } - } - }, error: { _ in - }, completed: nil) - - let disposable = ActionDisposable { - signalDisposable?.dispose() - } - - return ActionDisposable { - disposable.dispose() - } - } - - self.updateAvatarDisposable.set((signal - |> mapToSignal { videoResource -> Signal in - if peerId.namespace == Namespaces.Peer.CloudUser { - return context.engine.accountData.updateAccountPhoto(resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: nil, mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) - }) - } else { - return context.engine.peers.updatePeerPhoto(peerId: peerId, photo: context.engine.peers.uploadedPeerPhoto(resource: photoResource), video: context.engine.peers.uploadedPeerVideo(resource: videoResource) |> map(Optional.init), videoStartTimestamp: videoStartTimestamp, mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) - }) - } - } - |> deliverOnMainQueue).start(next: { [weak self] result in - guard let self else { - return - } - switch result { - case .complete: - self.currentUpdatingAvatar = nil - self.state?.updated(transition: .spring(duration: 0.4)) - case let .progress(value): - self.currentUpdatingAvatar = (representation, 0.25 + value * 0.75) - self.state?.updated(transition: .spring(duration: 0.4)) - } - })) - } - - private func openTitleEditing() { + func openTitleEditing() { guard let component = self.component else { return } @@ -1411,7 +231,7 @@ private final class VideoChatScreenComponent: Component { }) } - private func presentUndoOverlay(content: UndoOverlayContent, action: @escaping (UndoOverlayAction) -> Bool) { + func presentUndoOverlay(content: UndoOverlayContent, action: @escaping (UndoOverlayAction) -> Bool) { guard let component = self.component, let environment = self.environment else { return } @@ -1427,306 +247,7 @@ private final class VideoChatScreenComponent: Component { environment.controller()?.present(UndoOverlayController(presentationData: presentationData, content: content, elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: action), in: .current) } - private func openInviteMembers() { - guard let component = self.component else { - return - } - - let groupPeer = component.call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: component.call.peerId)) - let _ = (groupPeer - |> deliverOnMainQueue).start(next: { [weak self] groupPeer in - guard let self, let component = self.component, let environment = self.environment, let groupPeer else { - return - } - let inviteLinks = self.inviteLinks - - if case let .channel(groupPeer) = groupPeer { - var canInviteMembers = true - if case .broadcast = groupPeer.info, !(groupPeer.addressName?.isEmpty ?? true) { - canInviteMembers = false - } - if !canInviteMembers { - if let inviteLinks { - self.presentShare(inviteLinks) - } - return - } - } - - var filters: [ChannelMembersSearchFilter] = [] - if let members = self.members { - filters.append(.disable(Array(members.participants.map { $0.peer.id }))) - } - if case let .channel(groupPeer) = groupPeer { - if !groupPeer.hasPermission(.inviteMembers) && inviteLinks?.listenerLink == nil { - filters.append(.excludeNonMembers) - } - } else if case let .legacyGroup(groupPeer) = groupPeer { - if groupPeer.hasBannedPermission(.banAddMembers) { - filters.append(.excludeNonMembers) - } - } - filters.append(.excludeBots) - - var dismissController: (() -> Void)? - let controller = ChannelMembersSearchController(context: component.call.accountContext, peerId: groupPeer.id, forceTheme: environment.theme, mode: .inviteToCall, filters: filters, openPeer: { [weak self] peer, participant in - guard let self, let component = self.component, let environment = self.environment else { - dismissController?() - return - } - guard let callState = self.callState else { - return - } - - let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) - if peer.id == callState.myPeerId { - return - } - if let participant { - dismissController?() - - if component.call.invitePeer(participant.peer.id) { - let text: String - if case let .channel(channel) = self.peer, case .broadcast = channel.info { - text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string - } else { - text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string - } - self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: EnginePeer(participant.peer), title: nil, text: text, action: nil, duration: 3), action: { _ in return false }) - } - } else { - if case let .channel(groupPeer) = groupPeer, let listenerLink = inviteLinks?.listenerLink, !groupPeer.hasPermission(.inviteMembers) { - let text = environment.strings.VoiceChat_SendPublicLinkText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string - - environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_SendPublicLinkSend, action: { [weak self] in - dismissController?() - - guard let self, let component = self.component else { - return - } - - let _ = (enqueueMessages(account: component.call.accountContext.account, peerId: peer.id, messages: [.message(text: listenerLink, attributes: [], inlineStickers: [:], mediaReference: nil, threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]) - |> deliverOnMainQueue).start(next: { [weak self] _ in - guard let self, let environment = self.environment else { - return - } - self.presentUndoOverlay(content: .forward(savedMessages: false, text: environment.strings.UserInfo_LinkForwardTooltip_Chat_One(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string), action: { _ in return true }) - }) - })]), in: .window(.root)) - } else { - let text: String - if case let .channel(groupPeer) = groupPeer, case .broadcast = groupPeer.info { - text = environment.strings.VoiceChat_InviteMemberToChannelFirstText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string - } else { - text = environment.strings.VoiceChat_InviteMemberToGroupFirstText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), groupPeer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string - } - - environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_InviteMemberToGroupFirstAdd, action: { [weak self] in - guard let self, let component = self.component, let environment = self.environment else { - return - } - - if case let .channel(groupPeer) = groupPeer { - guard let selfController = environment.controller() else { - return - } - let inviteDisposable = self.inviteDisposable - var inviteSignal = component.call.accountContext.peerChannelMemberCategoriesContextsManager.addMembers(engine: component.call.accountContext.engine, peerId: groupPeer.id, memberIds: [peer.id]) - var cancelImpl: (() -> Void)? - let progressSignal = Signal { [weak selfController] subscriber in - let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { - cancelImpl?() - })) - selfController?.present(controller, in: .window(.root)) - return ActionDisposable { [weak controller] in - Queue.mainQueue().async() { - controller?.dismiss() - } - } - } - |> runOn(Queue.mainQueue()) - |> delay(0.15, queue: Queue.mainQueue()) - let progressDisposable = progressSignal.start() - - inviteSignal = inviteSignal - |> afterDisposed { - Queue.mainQueue().async { - progressDisposable.dispose() - } - } - cancelImpl = { - inviteDisposable.set(nil) - } - - inviteDisposable.set((inviteSignal |> deliverOnMainQueue).start(error: { [weak self] error in - dismissController?() - guard let self, let component = self.component, let environment = self.environment else { - return - } - - let text: String - switch error { - case .limitExceeded: - text = environment.strings.Channel_ErrorAddTooMuch - case .tooMuchJoined: - text = environment.strings.Invite_ChannelsTooMuch - case .generic: - text = environment.strings.Login_UnknownError - case .restricted: - text = environment.strings.Channel_ErrorAddBlocked - case .notMutualContact: - if case .broadcast = groupPeer.info { - text = environment.strings.Channel_AddUserLeftError - } else { - text = environment.strings.GroupInfo_AddUserLeftError - } - case .botDoesntSupportGroups: - text = environment.strings.Channel_BotDoesntSupportGroups - case .tooMuchBots: - text = environment.strings.Channel_TooMuchBots - case .bot: - text = environment.strings.Login_UnknownError - case .kicked: - text = environment.strings.Channel_AddUserKickedError - } - environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) - }, completed: { [weak self] in - guard let self, let component = self.component, let environment = self.environment else { - dismissController?() - return - } - dismissController?() - - if component.call.invitePeer(peer.id) { - let text: String - if case let .channel(channel) = self.peer, case .broadcast = channel.info { - text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string - } else { - text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string - } - self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: peer, title: nil, text: text, action: nil, duration: 3), action: { _ in return false }) - } - })) - } else if case let .legacyGroup(groupPeer) = groupPeer { - guard let selfController = environment.controller() else { - return - } - let inviteDisposable = self.inviteDisposable - var inviteSignal = component.call.accountContext.engine.peers.addGroupMember(peerId: groupPeer.id, memberId: peer.id) - var cancelImpl: (() -> Void)? - let progressSignal = Signal { [weak selfController] subscriber in - let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { - cancelImpl?() - })) - selfController?.present(controller, in: .window(.root)) - return ActionDisposable { [weak controller] in - Queue.mainQueue().async() { - controller?.dismiss() - } - } - } - |> runOn(Queue.mainQueue()) - |> delay(0.15, queue: Queue.mainQueue()) - let progressDisposable = progressSignal.start() - - inviteSignal = inviteSignal - |> afterDisposed { - Queue.mainQueue().async { - progressDisposable.dispose() - } - } - cancelImpl = { - inviteDisposable.set(nil) - } - - inviteDisposable.set((inviteSignal |> deliverOnMainQueue).start(error: { [weak self] error in - dismissController?() - guard let self, let component = self.component, let environment = self.environment else { - return - } - let context = component.call.accountContext - - switch error { - case .privacy: - let _ = (component.call.accountContext.account.postbox.loadedPeerWithId(peer.id) - |> deliverOnMainQueue).start(next: { [weak self] peer in - guard let self, let component = self.component, let environment = self.environment else { - return - } - environment.controller()?.present(textAlertController(context: component.call.accountContext, title: nil, text: environment.strings.Privacy_GroupsAndChannels_InviteToGroupError(EnginePeer(peer).compactDisplayTitle, EnginePeer(peer).compactDisplayTitle).string, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) - }) - case .notMutualContact: - environment.controller()?.present(textAlertController(context: context, title: nil, text: environment.strings.GroupInfo_AddUserLeftError, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) - case .tooManyChannels: - environment.controller()?.present(textAlertController(context: context, title: nil, text: environment.strings.Invite_ChannelsTooMuch, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) - case .groupFull, .generic: - environment.controller()?.present(textAlertController(context: context, forceTheme: environment.theme, title: nil, text: environment.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) - } - }, completed: { [weak self] in - guard let self, let component = self.component, let environment = self.environment else { - dismissController?() - return - } - dismissController?() - - if component.call.invitePeer(peer.id) { - let text: String - if case let .channel(channel) = self.peer, case .broadcast = channel.info { - text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string - } else { - text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string - } - self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: peer, title: nil, text: text, action: nil, duration: 3), action: { _ in return false }) - } - })) - } - })]), in: .window(.root)) - } - } - }) - controller.copyInviteLink = { [weak self] in - dismissController?() - - guard let self, let component = self.component else { - return - } - let callPeerId = component.call.peerId - - let _ = (component.call.accountContext.engine.data.get( - TelegramEngine.EngineData.Item.Peer.Peer(id: callPeerId), - TelegramEngine.EngineData.Item.Peer.ExportedInvitation(id: callPeerId) - ) - |> map { peer, exportedInvitation -> String? in - if let link = inviteLinks?.listenerLink { - return link - } else if let peer = peer, let addressName = peer.addressName, !addressName.isEmpty { - return "https://t.me/\(addressName)" - } else if let link = exportedInvitation?.link { - return link - } else { - return nil - } - } - |> deliverOnMainQueue).start(next: { [weak self] link in - guard let self, let environment = self.environment else { - return - } - - if let link { - UIPasteboard.general.string = link - - self.presentUndoOverlay(content: .linkCopied(text: environment.strings.VoiceChat_InviteLinkCopiedText), action: { _ in return false }) - } - }) - } - dismissController = { [weak controller] in - controller?.dismiss() - } - environment.controller()?.push(controller) - }) - } - - private func presentShare(_ inviteLinks: GroupCallInviteLinks) { + func presentShare(_ inviteLinks: GroupCallInviteLinks) { guard let component = self.component else { return } @@ -1871,6 +392,169 @@ private final class VideoChatScreenComponent: Component { } } + private func onAudioRoutePressed() { + guard let component = self.component, let environment = self.environment else { + return + } + + HapticFeedback().impact(.light) + + guard let (availableOutputs, currentOutput) = self.audioOutputState else { + return + } + guard availableOutputs.count >= 2 else { + return + } + + if availableOutputs.count == 2 { + for output in availableOutputs { + if output != currentOutput { + component.call.setCurrentAudioOutput(output) + break + } + } + } else { + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + let actionSheet = ActionSheetController(presentationData: presentationData) + var items: [ActionSheetItem] = [] + for output in availableOutputs { + let title: String + var icon: UIImage? + switch output { + case .builtin: + title = UIDevice.current.model + case .speaker: + title = environment.strings.Call_AudioRouteSpeaker + icon = generateScaledImage(image: UIImage(bundleImageName: "Call/CallSpeakerButton"), size: CGSize(width: 48.0, height: 48.0), opaque: false) + case .headphones: + title = environment.strings.Call_AudioRouteHeadphones + case let .port(port): + title = port.name + if port.type == .bluetooth { + var image = UIImage(bundleImageName: "Call/CallBluetoothButton") + let portName = port.name.lowercased() + if portName.contains("airpods max") { + image = UIImage(bundleImageName: "Call/CallAirpodsMaxButton") + } else if portName.contains("airpods pro") { + image = UIImage(bundleImageName: "Call/CallAirpodsProButton") + } else if portName.contains("airpods") { + image = UIImage(bundleImageName: "Call/CallAirpodsButton") + } + icon = generateScaledImage(image: image, size: CGSize(width: 48.0, height: 48.0), opaque: false) + } + } + items.append(CallRouteActionSheetItem(title: title, icon: icon, selected: output == currentOutput, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + + guard let self, let component = self.component else { + return + } + component.call.setCurrentAudioOutput(output) + })) + } + + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: environment.strings.Call_AudioRouteHide, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + environment.controller()?.present(actionSheet, in: .window(.root)) + } + } + + private func onLeavePressed() { + guard let component = self.component, let environment = self.environment else { + return + } + + //TODO:release + let isScheduled = !"".isEmpty + + let action: (Bool) -> Void = { [weak self] terminateIfPossible in + guard let self, let component = self.component else { + return + } + + let _ = component.call.leave(terminateIfPossible: terminateIfPossible).startStandalone() + + if let controller = self.environment?.controller() as? VideoChatScreenV2Impl { + controller.dismiss(closing: true, manual: false) + } + } + + if let callState = self.callState, callState.canManageCall { + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + let actionSheet = ActionSheetController(presentationData: presentationData) + var items: [ActionSheetItem] = [] + + let leaveTitle: String + let leaveAndCancelTitle: String + + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + leaveTitle = environment.strings.LiveStream_LeaveConfirmation + leaveAndCancelTitle = isScheduled ? environment.strings.LiveStream_LeaveAndCancelVoiceChat : environment.strings.LiveStream_LeaveAndEndVoiceChat + } else { + leaveTitle = environment.strings.VoiceChat_LeaveConfirmation + leaveAndCancelTitle = isScheduled ? environment.strings.VoiceChat_LeaveAndCancelVoiceChat : environment.strings.VoiceChat_LeaveAndEndVoiceChat + } + + items.append(ActionSheetTextItem(title: leaveTitle)) + items.append(ActionSheetButtonItem(title: leaveAndCancelTitle, color: .destructive, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + + guard let self, let component = self.component, let environment = self.environment else { + return + } + let title: String + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + title = isScheduled ? environment.strings.LiveStream_CancelConfirmationTitle : environment.strings.LiveStream_EndConfirmationTitle + text = isScheduled ? environment.strings.LiveStream_CancelConfirmationText : environment.strings.LiveStream_EndConfirmationText + } else { + title = isScheduled ? environment.strings.VoiceChat_CancelConfirmationTitle : environment.strings.VoiceChat_EndConfirmationTitle + text = isScheduled ? environment.strings.VoiceChat_CancelConfirmationText : environment.strings.VoiceChat_EndConfirmationText + } + + if let _ = self.members { + let alertController = textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: isScheduled ? environment.strings.VoiceChat_CancelConfirmationEnd : environment.strings.VoiceChat_EndConfirmationEnd, action: { + action(true) + })]) + environment.controller()?.present(alertController, in: .window(.root)) + } else { + action(true) + } + })) + + let leaveText: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + leaveText = environment.strings.LiveStream_LeaveVoiceChat + } else { + leaveText = environment.strings.VoiceChat_LeaveVoiceChat + } + + items.append(ActionSheetButtonItem(title: leaveText, color: .accent, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + + action(false) + })) + + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: environment.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + environment.controller()?.present(actionSheet, in: .window(.root)) + } else { + action(false) + } + } + func update(component: VideoChatScreenComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { @@ -1977,8 +661,28 @@ private final class VideoChatScreenComponent: Component { self.members = members + if let members, let _ = self.expandedParticipantsVideoState { + var videoCount = 0 + for participant in members.participants { + if participant.presentationDescription != nil { + videoCount += 1 + } + if participant.videoDescription != nil { + videoCount += 1 + } + } + if videoCount == 1, let participantsView = self.participants.view as? VideoChatParticipantsComponent.View, let participantsComponent = participantsView.component { + if participantsComponent.layout.videoColumn != nil { + self.expandedParticipantsVideoState = nil + } + } + } + if let expandedParticipantsVideoState = self.expandedParticipantsVideoState, let members { if !expandedParticipantsVideoState.isMainParticipantPinned, let participant = members.participants.first(where: { participant in + if let callState = self.callState, participant.peer.id == callState.myPeerId { + return false + } if participant.videoDescription != nil || participant.presentationDescription != nil { if members.speakingParticipants.contains(participant.peer.id) { return true @@ -2140,6 +844,51 @@ private final class VideoChatScreenComponent: Component { self.containerView.backgroundColor = .black } + var mappedParticipants: VideoChatParticipantsComponent.Participants? + if let members = self.members, let callState = self.callState { + var canInvite = true + var inviteIsLink = false + if case let .channel(peer) = self.peer { + if peer.flags.contains(.isGigagroup) { + if peer.flags.contains(.isCreator) || peer.adminRights != nil { + } else { + canInvite = false + } + } + if case .broadcast = peer.info, !(peer.addressName?.isEmpty ?? true) { + inviteIsLink = true + } + } + var inviteType: VideoChatParticipantsComponent.Participants.InviteType? + if canInvite { + if inviteIsLink { + inviteType = .shareLink + } else { + inviteType = .invite + } + } + + mappedParticipants = VideoChatParticipantsComponent.Participants( + myPeerId: callState.myPeerId, + participants: members.participants, + totalCount: members.totalCount, + loadMoreToken: members.loadMoreToken, + inviteType: inviteType + ) + } + + let maxSingleColumnWidth: CGFloat = 620.0 + let isTwoColumnLayout: Bool + if availableSize.width > maxSingleColumnWidth { + if let mappedParticipants, mappedParticipants.participants.contains(where: { $0.videoDescription != nil || $0.presentationDescription != nil }) { + isTwoColumnLayout = true + } else { + isTwoColumnLayout = false + } + } else { + isTwoColumnLayout = false + } + var containerOffset: CGFloat = 0.0 if let panGestureState = self.panGestureState { containerOffset = panGestureState.offsetFraction * availableSize.height @@ -2197,6 +946,7 @@ private final class VideoChatScreenComponent: Component { size: CGSize(width: navigationButtonDiameter, height: navigationButtonDiameter) )), effectAlignment: .center, + minSize: CGSize(width: navigationButtonDiameter, height: navigationButtonDiameter), action: { [weak self] in guard let self else { return @@ -2219,6 +969,7 @@ private final class VideoChatScreenComponent: Component { size: CGSize(width: navigationButtonDiameter, height: navigationButtonDiameter) )), effectAlignment: .center, + minSize: CGSize(width: navigationButtonDiameter, height: navigationButtonDiameter), action: { [weak self] in guard let self else { return @@ -2246,6 +997,66 @@ private final class VideoChatScreenComponent: Component { transition.setFrame(view: navigationRightButtonView, frame: navigationRightButtonFrame) } + if isTwoColumnLayout { + var navigationSidebarButtonTransition = transition + let navigationSidebarButton: ComponentView + if let current = self.navigationSidebarButton { + navigationSidebarButton = current + } else { + navigationSidebarButtonTransition = navigationSidebarButtonTransition.withAnimation(.none) + navigationSidebarButton = ComponentView() + self.navigationSidebarButton = navigationSidebarButton + } + let navigationSidebarButtonSize = navigationSidebarButton.update( + transition: .immediate, + component: AnyComponent(PlainButtonComponent( + content: AnyComponent(BundleIconComponent( + name: "Call/PanelIcon", + tintColor: .white + )), + background: AnyComponent(FilledRoundedRectangleComponent( + color: UIColor(white: 1.0, alpha: 0.1), + cornerRadius: navigationButtonDiameter * 0.5, + smoothCorners: false + )), + effectAlignment: .center, + minSize: CGSize(width: navigationButtonDiameter + 10.0, height: navigationButtonDiameter), + action: { [weak self] in + guard let self else { + return + } + self.isTwoColumnSidebarHidden = !self.isTwoColumnSidebarHidden + self.state?.updated(transition: .spring(duration: 0.4)) + } + )), + environment: {}, + containerSize: CGSize(width: navigationButtonDiameter, height: navigationButtonDiameter) + ) + let navigationSidebarButtonFrame = CGRect(origin: CGPoint(x: navigationRightButtonFrame.minX - 32.0 - navigationSidebarButtonSize.width, y: topInset + floor((navigationBarHeight - navigationSidebarButtonSize.height) * 0.5)), size: navigationSidebarButtonSize) + if let navigationSidebarButtonView = navigationSidebarButton.view { + var animateIn = false + if navigationSidebarButtonView.superview == nil { + animateIn = true + if let navigationRightButtonView = self.navigationRightButton.view { + self.containerView.insertSubview(navigationSidebarButtonView, aboveSubview: navigationRightButtonView) + } + } + navigationSidebarButtonTransition.setFrame(view: navigationSidebarButtonView, frame: navigationSidebarButtonFrame) + if animateIn { + transition.animateScale(view: navigationSidebarButtonView, from: 0.001, to: 1.0) + transition.animateAlpha(view: navigationSidebarButtonView, from: 0.0, to: 1.0) + } + } + } else if let navigationSidebarButton = self.navigationSidebarButton { + self.navigationSidebarButton = nil + if let navigationSidebarButtonView = navigationSidebarButton.view { + transition.setScale(view: navigationSidebarButtonView, scale: 0.001) + transition.setAlpha(view: navigationSidebarButtonView, alpha: 0.0, completion: { [weak navigationSidebarButtonView] _ in + navigationSidebarButtonView?.removeFromSuperview() + }) + } + } + let idleTitleStatusText: String if let callState = self.callState, callState.networkState == .connected, let members = self.members { idleTitleStatusText = environment.strings.VoiceChat_Panel_Members(Int32(max(1, members.totalCount))) @@ -2257,6 +1068,7 @@ private final class VideoChatScreenComponent: Component { component: AnyComponent(VideoChatTitleComponent( title: self.callState?.title ?? self.peer?.debugDisplayTitle ?? " ", status: idleTitleStatusText, + isRecording: self.callState?.recordingStartTimestamp != nil, strings: environment.strings )), environment: {}, @@ -2265,33 +1077,12 @@ private final class VideoChatScreenComponent: Component { let titleFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - titleSize.width) * 0.5), y: topInset + floor((navigationBarHeight - titleSize.height) * 0.5)), size: titleSize) if let titleView = self.title.view { if titleView.superview == nil { + titleView.isUserInteractionEnabled = false self.containerView.addSubview(titleView) } transition.setFrame(view: titleView, frame: titleFrame) } - var mappedParticipants: VideoChatParticipantsComponent.Participants? - if let members = self.members, let callState = self.callState { - mappedParticipants = VideoChatParticipantsComponent.Participants( - myPeerId: callState.myPeerId, - participants: members.participants, - totalCount: members.totalCount, - loadMoreToken: members.loadMoreToken - ) - } - - let maxSingleColumnWidth: CGFloat = 620.0 - let isTwoColumnLayout: Bool - if availableSize.width > maxSingleColumnWidth { - if let mappedParticipants, mappedParticipants.participants.contains(where: { $0.videoDescription != nil || $0.presentationDescription != nil }) { - isTwoColumnLayout = true - } else { - isTwoColumnLayout = false - } - } else { - isTwoColumnLayout = false - } - let areButtonsCollapsed: Bool let mainColumnWidth: CGFloat let mainColumnSideInset: CGFloat @@ -2344,8 +1135,18 @@ private final class VideoChatScreenComponent: Component { var collapsedMicrophoneButtonFrame: CGRect = CGRect(origin: CGPoint(x: floor((availableSize.width - collapsedMicrophoneButtonDiameter) * 0.5), y: availableSize.height - 48.0 - environment.safeInsets.bottom - collapsedMicrophoneButtonDiameter), size: CGSize(width: collapsedMicrophoneButtonDiameter, height: collapsedMicrophoneButtonDiameter)) var expandedMicrophoneButtonFrame: CGRect = CGRect(origin: CGPoint(x: floor((availableSize.width - expandedMicrophoneButtonDiameter) * 0.5), y: availableSize.height - environment.safeInsets.bottom - expandedMicrophoneButtonDiameter - 12.0), size: CGSize(width: expandedMicrophoneButtonDiameter, height: expandedMicrophoneButtonDiameter)) + + var isMainColumnHidden = false if isTwoColumnLayout { if let expandedParticipantsVideoState = self.expandedParticipantsVideoState, expandedParticipantsVideoState.isUIHidden { + isMainColumnHidden = true + } else if self.isTwoColumnSidebarHidden { + isMainColumnHidden = true + } + } + + if isTwoColumnLayout { + if isMainColumnHidden { collapsedMicrophoneButtonFrame.origin.x = availableSize.width - sideInset - mainColumnWidth + floor((mainColumnWidth - collapsedMicrophoneButtonDiameter) * 0.5) + sideInset + mainColumnWidth } else { collapsedMicrophoneButtonFrame.origin.x = availableSize.width - sideInset - mainColumnWidth + floor((mainColumnWidth - collapsedMicrophoneButtonDiameter) * 0.5) @@ -2397,7 +1198,8 @@ private final class VideoChatScreenComponent: Component { width: mainColumnWidth, insets: mainColumnInsets ), - columnSpacing: columnSpacing + columnSpacing: columnSpacing, + isMainColumnHidden: self.isTwoColumnSidebarHidden ) } else { let mainColumnInsets: UIEdgeInsets = UIEdgeInsets(top: navigationHeight, left: mainColumnSideInset, bottom: availableSize.height - collapsedParticipantsClippingY, right: mainColumnSideInset) @@ -2407,7 +1209,8 @@ private final class VideoChatScreenComponent: Component { width: mainColumnWidth, insets: mainColumnInsets ), - columnSpacing: columnSpacing + columnSpacing: columnSpacing, + isMainColumnHidden: false ) } @@ -2453,7 +1256,7 @@ private final class VideoChatScreenComponent: Component { } self.openParticipantContextMenu(id: id, sourceView: sourceView, gesture: gesture) }, - updateMainParticipant: { [weak self] key in + updateMainParticipant: { [weak self] key, alsoSetIsUIHidden in guard let self else { return } @@ -2461,7 +1264,13 @@ private final class VideoChatScreenComponent: Component { if let expandedParticipantsVideoState = self.expandedParticipantsVideoState, expandedParticipantsVideoState.mainParticipant == key { return } - self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: key, isMainParticipantPinned: false, isUIHidden: self.expandedParticipantsVideoState?.isUIHidden ?? false) + + var isUIHidden = self.expandedParticipantsVideoState?.isUIHidden ?? false + if let alsoSetIsUIHidden { + isUIHidden = alsoSetIsUIHidden + } + + self.expandedParticipantsVideoState = VideoChatParticipantsComponent.ExpandedVideoState(mainParticipant: key, isMainParticipantPinned: false, isUIHidden: isUIHidden) self.state?.updated(transition: .spring(duration: 0.4)) } else if self.expandedParticipantsVideoState != nil { self.expandedParticipantsVideoState = nil @@ -2528,13 +1337,18 @@ private final class VideoChatScreenComponent: Component { micButtonContent = .connecting actionButtonMicrophoneState = .connecting case .connected: - if let _ = callState.muteState { - if self.isPushToTalkActive { - micButtonContent = .unmuted(pushToTalk: self.isPushToTalkActive) - actionButtonMicrophoneState = .unmuted + if let callState = callState.muteState { + if callState.canUnmute { + if self.isPushToTalkActive { + micButtonContent = .unmuted(pushToTalk: self.isPushToTalkActive) + actionButtonMicrophoneState = .unmuted + } else { + micButtonContent = .muted + actionButtonMicrophoneState = .muted + } } else { - micButtonContent = .muted - actionButtonMicrophoneState = .muted + micButtonContent = .raiseHand + actionButtonMicrophoneState = .raiseHand } } else { micButtonContent = .unmuted(pushToTalk: false) @@ -2587,6 +1401,17 @@ private final class VideoChatScreenComponent: Component { self.isPushToTalkActive = false self.state?.updated(transition: .spring(duration: 0.5)) } + }, + raiseHand: { [weak self] in + guard let self, let component = self.component else { + return + } + guard let callState = self.callState else { + return + } + if !callState.raisedHand { + component.call.raiseHand() + } } )), environment: {}, @@ -2600,11 +1425,44 @@ private final class VideoChatScreenComponent: Component { transition.setBounds(view: microphoneButtonView, bounds: CGRect(origin: CGPoint(), size: microphoneButtonFrame.size)) } + let videoButtonContent: VideoChatActionButtonComponent.Content + if let callState = self.callState, let muteState = callState.muteState, !muteState.canUnmute { + var buttonAudio: VideoChatActionButtonComponent.Content.Audio = .speaker + if let (availableOutputs, maybeCurrentOutput) = self.audioOutputState, let currentOutput = maybeCurrentOutput { + switch currentOutput { + case .builtin: + buttonAudio = .builtin + case .speaker: + buttonAudio = .speaker + case .headphones: + buttonAudio = .headphones + case let .port(port): + var type: VideoChatActionButtonComponent.Content.BluetoothType = .generic + let portName = port.name.lowercased() + if portName.contains("airpods max") { + type = .airpodsMax + } else if portName.contains("airpods pro") { + type = .airpodsPro + } else if portName.contains("airpods") { + type = .airpods + } + buttonAudio = .bluetooth(type) + } + if availableOutputs.count <= 1 { + buttonAudio = .none + } + } + videoButtonContent = .audio(audio: buttonAudio) + } else { + //TODO:release + videoButtonContent = .video(isActive: false) + } let _ = self.videoButton.update( transition: transition, component: AnyComponent(PlainButtonComponent( content: AnyComponent(VideoChatActionButtonComponent( - content: .video(isActive: false), + strings: environment.strings, + content: videoButtonContent, microphoneState: actionButtonMicrophoneState, isCollapsed: areButtonsCollapsed )), @@ -2613,7 +1471,11 @@ private final class VideoChatScreenComponent: Component { guard let self else { return } - self.onCameraPressed() + if let callState = self.callState, let muteState = callState.muteState, !muteState.canUnmute { + self.onAudioRoutePressed() + } else { + self.onCameraPressed() + } }, animateAlpha: false )), @@ -2632,20 +1494,17 @@ private final class VideoChatScreenComponent: Component { transition: transition, component: AnyComponent(PlainButtonComponent( content: AnyComponent(VideoChatActionButtonComponent( + strings: environment.strings, content: .leave, microphoneState: actionButtonMicrophoneState, isCollapsed: areButtonsCollapsed )), effectAlignment: .center, action: { [weak self] in - guard let self, let component = self.component else { + guard let self else { return } - let _ = component.call.leave(terminateIfPossible: false).startStandalone() - - if let controller = self.environment?.controller() as? VideoChatScreenV2Impl { - controller.dismiss(closing: true, manual: false) - } + self.onLeavePressed() }, animateAlpha: false )), @@ -2826,25 +1685,3 @@ final class VideoChatScreenV2Impl: ViewControllerComponentContainer, VoiceChatCo } } } - -private final class ParticipantExtractedContentSource: ContextExtractedContentSource { - let keepInPlace: Bool = false - let ignoreContentTouches: Bool = false - let blurBackground: Bool = true - - //let actionsHorizontalAlignment: ContextActionsHorizontalAlignment = .center - - private let contentView: ContextExtractedContentContainingView - - init(contentView: ContextExtractedContentContainingView) { - self.contentView = contentView - } - - func takeView() -> ContextControllerTakeViewInfo? { - return ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: UIScreen.main.bounds) - } - - func putBack() -> ContextControllerPutBackViewInfo? { - return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) - } -} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreenInviteMembers.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreenInviteMembers.swift new file mode 100644 index 0000000000..70e52ce32e --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreenInviteMembers.swift @@ -0,0 +1,343 @@ +import Foundation +import UIKit +import Display +import TelegramCore +import SwiftSignalKit +import PeerInfoUI +import OverlayStatusController +import PresentationDataUtils + +extension VideoChatScreenComponent.View { + func openInviteMembers() { + guard let component = self.component else { + return + } + + var canInvite = true + var inviteIsLink = false + if case let .channel(peer) = self.peer { + if peer.flags.contains(.isGigagroup) { + if peer.flags.contains(.isCreator) || peer.adminRights != nil { + } else { + canInvite = false + } + } + if case .broadcast = peer.info, !(peer.addressName?.isEmpty ?? true) { + inviteIsLink = true + } + } + var inviteType: VideoChatParticipantsComponent.Participants.InviteType? + if canInvite { + if inviteIsLink { + inviteType = .shareLink + } else { + inviteType = .invite + } + } + + guard let inviteType else { + return + } + + switch inviteType { + case .invite: + let groupPeer = component.call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: component.call.peerId)) + let _ = (groupPeer + |> deliverOnMainQueue).start(next: { [weak self] groupPeer in + guard let self, let component = self.component, let environment = self.environment, let groupPeer else { + return + } + let inviteLinks = self.inviteLinks + + if case let .channel(groupPeer) = groupPeer { + var canInviteMembers = true + if case .broadcast = groupPeer.info, !(groupPeer.addressName?.isEmpty ?? true) { + canInviteMembers = false + } + if !canInviteMembers { + if let inviteLinks { + self.presentShare(inviteLinks) + } + return + } + } + + var filters: [ChannelMembersSearchFilter] = [] + if let members = self.members { + filters.append(.disable(Array(members.participants.map { $0.peer.id }))) + } + if case let .channel(groupPeer) = groupPeer { + if !groupPeer.hasPermission(.inviteMembers) && inviteLinks?.listenerLink == nil { + filters.append(.excludeNonMembers) + } + } else if case let .legacyGroup(groupPeer) = groupPeer { + if groupPeer.hasBannedPermission(.banAddMembers) { + filters.append(.excludeNonMembers) + } + } + filters.append(.excludeBots) + + var dismissController: (() -> Void)? + let controller = ChannelMembersSearchController(context: component.call.accountContext, peerId: groupPeer.id, forceTheme: environment.theme, mode: .inviteToCall, filters: filters, openPeer: { [weak self] peer, participant in + guard let self, let component = self.component, let environment = self.environment else { + dismissController?() + return + } + guard let callState = self.callState else { + return + } + + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + if peer.id == callState.myPeerId { + return + } + if let participant { + dismissController?() + + if component.call.invitePeer(participant.peer.id) { + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string + } else { + text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string + } + self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: EnginePeer(participant.peer), title: nil, text: text, action: nil, duration: 3), action: { _ in return false }) + } + } else { + if case let .channel(groupPeer) = groupPeer, let listenerLink = inviteLinks?.listenerLink, !groupPeer.hasPermission(.inviteMembers) { + let text = environment.strings.VoiceChat_SendPublicLinkText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string + + environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_SendPublicLinkSend, action: { [weak self] in + dismissController?() + + guard let self, let component = self.component else { + return + } + + let _ = (enqueueMessages(account: component.call.accountContext.account, peerId: peer.id, messages: [.message(text: listenerLink, attributes: [], inlineStickers: [:], mediaReference: nil, threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]) + |> deliverOnMainQueue).start(next: { [weak self] _ in + guard let self, let environment = self.environment else { + return + } + self.presentUndoOverlay(content: .forward(savedMessages: false, text: environment.strings.UserInfo_LinkForwardTooltip_Chat_One(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string), action: { _ in return true }) + }) + })]), in: .window(.root)) + } else { + let text: String + if case let .channel(groupPeer) = groupPeer, case .broadcast = groupPeer.info { + text = environment.strings.VoiceChat_InviteMemberToChannelFirstText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string + } else { + text = environment.strings.VoiceChat_InviteMemberToGroupFirstText(peer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), groupPeer.displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string + } + + environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_InviteMemberToGroupFirstAdd, action: { [weak self] in + guard let self, let component = self.component, let environment = self.environment else { + return + } + + if case let .channel(groupPeer) = groupPeer { + guard let selfController = environment.controller() else { + return + } + let inviteDisposable = self.inviteDisposable + var inviteSignal = component.call.accountContext.peerChannelMemberCategoriesContextsManager.addMembers(engine: component.call.accountContext.engine, peerId: groupPeer.id, memberIds: [peer.id]) + var cancelImpl: (() -> Void)? + let progressSignal = Signal { [weak selfController] subscriber in + let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { + cancelImpl?() + })) + selfController?.present(controller, in: .window(.root)) + return ActionDisposable { [weak controller] in + Queue.mainQueue().async() { + controller?.dismiss() + } + } + } + |> runOn(Queue.mainQueue()) + |> delay(0.15, queue: Queue.mainQueue()) + let progressDisposable = progressSignal.start() + + inviteSignal = inviteSignal + |> afterDisposed { + Queue.mainQueue().async { + progressDisposable.dispose() + } + } + cancelImpl = { + inviteDisposable.set(nil) + } + + inviteDisposable.set((inviteSignal |> deliverOnMainQueue).start(error: { [weak self] error in + dismissController?() + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let text: String + switch error { + case .limitExceeded: + text = environment.strings.Channel_ErrorAddTooMuch + case .tooMuchJoined: + text = environment.strings.Invite_ChannelsTooMuch + case .generic: + text = environment.strings.Login_UnknownError + case .restricted: + text = environment.strings.Channel_ErrorAddBlocked + case .notMutualContact: + if case .broadcast = groupPeer.info { + text = environment.strings.Channel_AddUserLeftError + } else { + text = environment.strings.GroupInfo_AddUserLeftError + } + case .botDoesntSupportGroups: + text = environment.strings.Channel_BotDoesntSupportGroups + case .tooMuchBots: + text = environment.strings.Channel_TooMuchBots + case .bot: + text = environment.strings.Login_UnknownError + case .kicked: + text = environment.strings.Channel_AddUserKickedError + } + environment.controller()?.present(textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) + }, completed: { [weak self] in + guard let self, let component = self.component, let environment = self.environment else { + dismissController?() + return + } + dismissController?() + + if component.call.invitePeer(peer.id) { + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string + } else { + text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string + } + self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: peer, title: nil, text: text, action: nil, duration: 3), action: { _ in return false }) + } + })) + } else if case let .legacyGroup(groupPeer) = groupPeer { + guard let selfController = environment.controller() else { + return + } + let inviteDisposable = self.inviteDisposable + var inviteSignal = component.call.accountContext.engine.peers.addGroupMember(peerId: groupPeer.id, memberId: peer.id) + var cancelImpl: (() -> Void)? + let progressSignal = Signal { [weak selfController] subscriber in + let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: { + cancelImpl?() + })) + selfController?.present(controller, in: .window(.root)) + return ActionDisposable { [weak controller] in + Queue.mainQueue().async() { + controller?.dismiss() + } + } + } + |> runOn(Queue.mainQueue()) + |> delay(0.15, queue: Queue.mainQueue()) + let progressDisposable = progressSignal.start() + + inviteSignal = inviteSignal + |> afterDisposed { + Queue.mainQueue().async { + progressDisposable.dispose() + } + } + cancelImpl = { + inviteDisposable.set(nil) + } + + inviteDisposable.set((inviteSignal |> deliverOnMainQueue).start(error: { [weak self] error in + dismissController?() + guard let self, let component = self.component, let environment = self.environment else { + return + } + let context = component.call.accountContext + + switch error { + case .privacy: + let _ = (component.call.accountContext.account.postbox.loadedPeerWithId(peer.id) + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let self, let component = self.component, let environment = self.environment else { + return + } + environment.controller()?.present(textAlertController(context: component.call.accountContext, title: nil, text: environment.strings.Privacy_GroupsAndChannels_InviteToGroupError(EnginePeer(peer).compactDisplayTitle, EnginePeer(peer).compactDisplayTitle).string, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) + }) + case .notMutualContact: + environment.controller()?.present(textAlertController(context: context, title: nil, text: environment.strings.GroupInfo_AddUserLeftError, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) + case .tooManyChannels: + environment.controller()?.present(textAlertController(context: context, title: nil, text: environment.strings.Invite_ChannelsTooMuch, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) + case .groupFull, .generic: + environment.controller()?.present(textAlertController(context: context, forceTheme: environment.theme, title: nil, text: environment.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_OK, action: {})]), in: .window(.root)) + } + }, completed: { [weak self] in + guard let self, let component = self.component, let environment = self.environment else { + dismissController?() + return + } + dismissController?() + + if component.call.invitePeer(peer.id) { + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string + } else { + text = environment.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder)).string + } + self.presentUndoOverlay(content: .invitedToVoiceChat(context: component.call.accountContext, peer: peer, title: nil, text: text, action: nil, duration: 3), action: { _ in return false }) + } + })) + } + })]), in: .window(.root)) + } + } + }) + controller.copyInviteLink = { [weak self] in + dismissController?() + + guard let self, let component = self.component else { + return + } + let callPeerId = component.call.peerId + + let _ = (component.call.accountContext.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: callPeerId), + TelegramEngine.EngineData.Item.Peer.ExportedInvitation(id: callPeerId) + ) + |> map { peer, exportedInvitation -> String? in + if let link = inviteLinks?.listenerLink { + return link + } else if let peer = peer, let addressName = peer.addressName, !addressName.isEmpty { + return "https://t.me/\(addressName)" + } else if let link = exportedInvitation?.link { + return link + } else { + return nil + } + } + |> deliverOnMainQueue).start(next: { [weak self] link in + guard let self, let environment = self.environment else { + return + } + + if let link { + UIPasteboard.general.string = link + + self.presentUndoOverlay(content: .linkCopied(text: environment.strings.VoiceChat_InviteLinkCopiedText), action: { _ in return false }) + } + }) + } + dismissController = { [weak controller] in + controller?.dismiss() + } + environment.controller()?.push(controller) + }) + case .shareLink: + guard let inviteLinks = self.inviteLinks else { + return + } + self.presentShare(inviteLinks) + } + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreenMoreMenu.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreenMoreMenu.swift new file mode 100644 index 0000000000..16ecfecd1b --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreenMoreMenu.swift @@ -0,0 +1,560 @@ +import Foundation +import UIKit +import Display +import ContextUI +import TelegramCore +import SwiftSignalKit +import DeleteChatPeerActionSheetItem +import PeerListItemComponent +import LegacyComponents +import LegacyUI +import WebSearchUI +import MapResourceToAvatarSizes +import LegacyMediaPickerUI +import AvatarNode +import PresentationDataUtils +import AccountContext + +extension VideoChatScreenComponent.View { + func openMoreMenu() { + guard let sourceView = self.navigationLeftButton.view else { + return + } + guard let component = self.component, let environment = self.environment, let controller = environment.controller() else { + return + } + guard let peer = self.peer else { + return + } + guard let callState = self.callState else { + return + } + + let canManageCall = callState.canManageCall + + var items: [ContextMenuItem] = [] + + if let displayAsPeers = self.displayAsPeers, displayAsPeers.count > 1 { + for peer in displayAsPeers { + if peer.peer.id == callState.myPeerId { + let avatarSize = CGSize(width: 28.0, height: 28.0) + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_DisplayAs, textLayout: .secondLineWithValue(EnginePeer(peer.peer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)), icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: peerAvatarCompleteImage(account: component.call.accountContext.account, peer: EnginePeer(peer.peer), size: avatarSize)), action: { [weak self] c, _ in + guard let self else { + return + } + c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuDisplayAsItems())))) + }))) + items.append(.separator) + break + } + } + } + + if let (availableOutputs, currentOutput) = self.audioOutputState, availableOutputs.count > 1 { + var currentOutputTitle = "" + for output in availableOutputs { + if output == currentOutput { + let title: String + switch output { + case .builtin: + title = UIDevice.current.model + case .speaker: + title = environment.strings.Call_AudioRouteSpeaker + case .headphones: + title = environment.strings.Call_AudioRouteHeadphones + case let .port(port): + title = port.name + } + currentOutputTitle = title + break + } + } + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_ContextAudio, textLayout: .secondLineWithValue(currentOutputTitle), icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Audio"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] c, _ in + guard let self else { + return + } + c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuAudioItems())))) + }))) + } + + if canManageCall { + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_EditTitle + } else { + text = environment.strings.VoiceChat_EditTitle + } + items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Pencil"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + + guard let self else { + return + } + self.openTitleEditing() + }))) + + var hasPermissions = true + if case let .channel(chatPeer) = peer { + if case .broadcast = chatPeer.info { + hasPermissions = false + } else if chatPeer.flags.contains(.isGigagroup) { + hasPermissions = false + } + } + if hasPermissions { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_EditPermissions, icon: { theme -> UIImage? in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Restrict"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] c, _ in + guard let self else { + return + } + c?.pushItems(items: .single(ContextController.Items(content: .list(self.contextMenuPermissionItems())))) + }))) + } + } + + if let inviteLinks = self.inviteLinks { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_Share, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + + guard let self else { + return + } + self.presentShare(inviteLinks) + }))) + } + + //let isScheduled = strongSelf.isScheduled + //TODO:release + let isScheduled: Bool = !"".isEmpty + + let canSpeak: Bool + if let muteState = callState.muteState { + canSpeak = muteState.canUnmute + } else { + canSpeak = true + } + + if !isScheduled && canSpeak { + if #available(iOS 15.0, *) { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MicrophoneModes, textColor: .primary, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Noise"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + f(.dismissWithoutContent) + AVCaptureDevice.showSystemUserInterface(.microphoneModes) + }))) + } + } + + if callState.isVideoEnabled && (callState.muteState?.canUnmute ?? true) { + if component.call.hasScreencast { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_StopScreenSharing, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ShareScreen"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + + guard let self, let component = self.component else { + return + } + component.call.disableScreencast() + }))) + } else { + items.append(.custom(VoiceChatShareScreenContextItem(context: component.call.accountContext, text: environment.strings.VoiceChat_ShareScreen, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ShareScreen"), color: theme.actionSheet.primaryTextColor) + }, action: { _, _ in }), false)) + } + } + + if canManageCall { + if let recordingStartTimestamp = callState.recordingStartTimestamp { + items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let alertController = textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: nil, text: environment.strings.VoiceChat_StopRecordingTitle, actions: [TextAlertAction(type: .genericAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: environment.strings.VoiceChat_StopRecordingStop, action: { [weak self] in + guard let self, let component = self.component, let environment = self.environment else { + return + } + component.call.setShouldBeRecording(false, title: nil, videoOrientation: nil) + + Queue.mainQueue().after(0.88) { + HapticFeedback().success() + } + + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_RecordingSaved + } else { + text = environment.strings.VideoChat_RecordingSaved + } + self.presentUndoOverlay(content: .forward(savedMessages: true, text: text), action: { [weak self] value in + if case .info = value, let self, let component = self.component, let environment = self.environment, let navigationController = environment.controller()?.navigationController as? NavigationController { + let context = component.call.accountContext + environment.controller()?.dismiss(completion: { [weak navigationController] in + Queue.mainQueue().justDispatch { + let _ = (context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: context.account.peerId)) + |> deliverOnMainQueue).start(next: { peer in + guard let peer, let navigationController else { + return + } + context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), keepStack: .always, purposefulAction: {}, peekData: nil)) + }) + } + }) + + return true + } + return false + }) + })]) + environment.controller()?.present(alertController, in: .window(.root)) + }), false)) + } else { + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_StartRecording + } else { + text = environment.strings.VoiceChat_StartRecording + } + if callState.scheduleTimestamp == nil { + items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in + return generateStartRecordingIcon(color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component, let environment = self.environment, let peer = self.peer else { + return + } + + let controller = VoiceChatRecordingSetupController(context: component.call.accountContext, peer: peer, completion: { [weak self] videoOrientation in + guard let self, let component = self.component, let environment = self.environment, let peer = self.peer else { + return + } + let title: String + let text: String + let placeholder: String + if let _ = videoOrientation { + placeholder = environment.strings.VoiceChat_RecordingTitlePlaceholderVideo + } else { + placeholder = environment.strings.VoiceChat_RecordingTitlePlaceholder + } + if case let .channel(channel) = peer, case .broadcast = channel.info { + title = environment.strings.LiveStream_StartRecordingTitle + if let _ = videoOrientation { + text = environment.strings.LiveStream_StartRecordingTextVideo + } else { + text = environment.strings.LiveStream_StartRecordingText + } + } else { + title = environment.strings.VoiceChat_StartRecordingTitle + if let _ = videoOrientation { + text = environment.strings.VoiceChat_StartRecordingTextVideo + } else { + text = environment.strings.VoiceChat_StartRecordingText + } + } + + let controller = voiceChatTitleEditController(sharedContext: component.call.accountContext.sharedContext, account: component.call.account, forceTheme: environment.theme, title: title, text: text, placeholder: placeholder, value: nil, maxLength: 40, apply: { [weak self] title in + guard let self, let component = self.component, let environment = self.environment, let peer = self.peer, let title else { + return + } + + component.call.setShouldBeRecording(true, title: title, videoOrientation: videoOrientation) + + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + text = environment.strings.LiveStream_RecordingStarted + } else { + text = environment.strings.VoiceChat_RecordingStarted + } + + self.presentUndoOverlay(content: .voiceChatRecording(text: text), action: { _ in return false }) + component.call.playTone(.recordingStarted) + }) + environment.controller()?.present(controller, in: .window(.root)) + }) + environment.controller()?.present(controller, in: .window(.root)) + }))) + } + } + } + + if canManageCall { + let text: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + text = isScheduled ? environment.strings.VoiceChat_CancelLiveStream : environment.strings.VoiceChat_EndLiveStream + } else { + text = isScheduled ? environment.strings.VoiceChat_CancelVoiceChat : environment.strings.VoiceChat_EndVoiceChat + } + items.append(.action(ContextMenuActionItem(text: text, textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor) + }, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let action: () -> Void = { [weak self] in + guard let self, let component = self.component else { + return + } + + let _ = (component.call.leave(terminateIfPossible: true) + |> filter { $0 } + |> take(1) + |> deliverOnMainQueue).start(completed: { [weak self] in + guard let self, let environment = self.environment else { + return + } + environment.controller()?.dismiss() + }) + } + + let title: String + let text: String + if case let .channel(channel) = self.peer, case .broadcast = channel.info { + title = isScheduled ? environment.strings.LiveStream_CancelConfirmationTitle : environment.strings.LiveStream_EndConfirmationTitle + text = isScheduled ? environment.strings.LiveStream_CancelConfirmationText : environment.strings.LiveStream_EndConfirmationText + } else { + title = isScheduled ? environment.strings.VoiceChat_CancelConfirmationTitle : environment.strings.VoiceChat_EndConfirmationTitle + text = isScheduled ? environment.strings.VoiceChat_CancelConfirmationText : environment.strings.VoiceChat_EndConfirmationText + } + + let alertController = textAlertController(context: component.call.accountContext, forceTheme: environment.theme, title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: environment.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: isScheduled ? environment.strings.VoiceChat_CancelConfirmationEnd : environment.strings.VoiceChat_EndConfirmationEnd, action: { + action() + })]) + environment.controller()?.present(alertController, in: .window(.root)) + }))) + } else { + let leaveText: String + if case let .channel(channel) = peer, case .broadcast = channel.info { + leaveText = environment.strings.LiveStream_LeaveVoiceChat + } else { + leaveText = environment.strings.VoiceChat_LeaveVoiceChat + } + items.append(.action(ContextMenuActionItem(text: leaveText, textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor) + }, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component else { + return + } + + let _ = (component.call.leave(terminateIfPossible: false) + |> filter { $0 } + |> take(1) + |> deliverOnMainQueue).start(completed: { [weak self] in + guard let self, let environment = self.environment else { + return + } + environment.controller()?.dismiss() + }) + }))) + } + + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + let contextController = ContextController(presentationData: presentationData, source: .reference(VoiceChatContextReferenceContentSource(controller: controller, sourceView: sourceView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil) + controller.presentInGlobalOverlay(contextController) + } + + private func contextMenuDisplayAsItems() -> [ContextMenuItem] { + guard let component = self.component, let environment = self.environment else { + return [] + } + guard let callState = self.callState else { + return [] + } + let myPeerId = callState.myPeerId + + let avatarSize = CGSize(width: 28.0, height: 28.0) + + var items: [ContextMenuItem] = [] + + items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) + }, iconPosition: .left, action: { (c, _) in + c?.popItems() + }))) + items.append(.separator) + + var isGroup = false + if let displayAsPeers = self.displayAsPeers { + for peer in displayAsPeers { + if peer.peer is TelegramGroup { + isGroup = true + break + } else if let peer = peer.peer as? TelegramChannel, case .group = peer.info { + isGroup = true + break + } + } + } + + items.append(.custom(VoiceChatInfoContextItem(text: isGroup ? environment.strings.VoiceChat_DisplayAsInfoGroup : environment.strings.VoiceChat_DisplayAsInfo, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Accounts"), color: theme.actionSheet.primaryTextColor) + }), true)) + + if let displayAsPeers = self.displayAsPeers { + for peer in displayAsPeers { + var subtitle: String? + if peer.peer.id.namespace == Namespaces.Peer.CloudUser { + subtitle = environment.strings.VoiceChat_PersonalAccount + } else if let subscribers = peer.subscribers { + if let peer = peer.peer as? TelegramChannel, case .broadcast = peer.info { + subtitle = environment.strings.Conversation_StatusSubscribers(subscribers) + } else { + subtitle = environment.strings.Conversation_StatusMembers(subscribers) + } + } + + let isSelected = peer.peer.id == myPeerId + let extendedAvatarSize = CGSize(width: 35.0, height: 35.0) + let theme = environment.theme + let avatarSignal = peerAvatarCompleteImage(account: component.call.accountContext.account, peer: EnginePeer(peer.peer), size: avatarSize) + |> map { image -> UIImage? in + if isSelected, let image = image { + return generateImage(extendedAvatarSize, rotatedContext: { size, context in + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + context.translateBy(x: size.width / 2.0, y: size.height / 2.0) + context.scaleBy(x: 1.0, y: -1.0) + context.translateBy(x: -size.width / 2.0, y: -size.height / 2.0) + context.draw(image.cgImage!, in: CGRect(x: (extendedAvatarSize.width - avatarSize.width) / 2.0, y: (extendedAvatarSize.height - avatarSize.height) / 2.0, width: avatarSize.width, height: avatarSize.height)) + + let lineWidth = 1.0 + UIScreenPixel + context.setLineWidth(lineWidth) + context.setStrokeColor(theme.actionSheet.controlAccentColor.cgColor) + context.strokeEllipse(in: bounds.insetBy(dx: lineWidth / 2.0, dy: lineWidth / 2.0)) + }) + } else { + return image + } + } + + items.append(.action(ContextMenuActionItem(text: EnginePeer(peer.peer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder), textLayout: subtitle.flatMap { .secondLineWithValue($0) } ?? .singleLine, icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: isSelected ? extendedAvatarSize : avatarSize, signal: avatarSignal), action: { [weak self] _, f in + f(.default) + + guard let self, let component = self.component else { + return + } + + if peer.peer.id != myPeerId { + component.call.reconnect(as: peer.peer.id) + } + }))) + + if peer.peer.id.namespace == Namespaces.Peer.CloudUser { + items.append(.separator) + } + } + } + return items + } + + private func contextMenuAudioItems() -> [ContextMenuItem] { + guard let environment = self.environment else { + return [] + } + guard let (availableOutputs, currentOutput) = self.audioOutputState else { + return [] + } + + var items: [ContextMenuItem] = [] + + items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) + }, iconPosition: .left, action: { (c, _) in + c?.popItems() + }))) + items.append(.separator) + + for output in availableOutputs { + let title: String + switch output { + case .builtin: + title = UIDevice.current.model + case .speaker: + title = environment.strings.Call_AudioRouteSpeaker + case .headphones: + title = environment.strings.Call_AudioRouteHeadphones + case let .port(port): + title = port.name + } + items.append(.action(ContextMenuActionItem(text: title, icon: { theme in + if output == currentOutput { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor) + } else { + return nil + } + }, action: { [weak self] _, f in + f(.default) + + guard let self, let component = self.component else { + return + } + + component.call.setCurrentAudioOutput(output) + }))) + } + + return items + } + + private func contextMenuPermissionItems() -> [ContextMenuItem] { + guard let environment = self.environment, let callState = self.callState else { + return [] + } + + var items: [ContextMenuItem] = [] + if callState.canManageCall, let defaultParticipantMuteState = callState.defaultParticipantMuteState { + let isMuted = defaultParticipantMuteState == .muted + + items.append(.action(ContextMenuActionItem(text: environment.strings.Common_Back, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) + }, iconPosition: .left, action: { (c, _) in + c?.popItems() + }))) + items.append(.separator) + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_SpeakPermissionEveryone, icon: { theme in + if isMuted { + return nil + } else { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor) + } + }, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component else { + return + } + component.call.updateDefaultParticipantsAreMuted(isMuted: false) + }))) + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_SpeakPermissionAdmin, icon: { theme in + if !isMuted { + return nil + } else { + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: theme.actionSheet.primaryTextColor) + } + }, action: { [weak self] _, f in + f(.dismissWithoutContent) + + guard let self, let component = self.component else { + return + } + component.call.updateDefaultParticipantsAreMuted(isMuted: true) + }))) + } + return items + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatScreenParticipantContextMenu.swift b/submodules/TelegramCallsUI/Sources/VideoChatScreenParticipantContextMenu.swift new file mode 100644 index 0000000000..6ee08d0a90 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/VideoChatScreenParticipantContextMenu.swift @@ -0,0 +1,667 @@ +import Foundation +import UIKit +import Display +import SwiftSignalKit +import AccountContext +import TelegramCore +import ContextUI +import DeleteChatPeerActionSheetItem +import UndoUI +import LegacyComponents +import WebSearchUI +import MapResourceToAvatarSizes +import LegacyUI +import LegacyMediaPickerUI + +extension VideoChatScreenComponent.View { + func openParticipantContextMenu(id: EnginePeer.Id, sourceView: ContextExtractedContentContainingView, gesture: ContextGesture?) { + guard let component = self.component, let environment = self.environment else { + return + } + guard let members = self.members, let participant = members.participants.first(where: { $0.peer.id == id }) else { + return + } + + let muteStatePromise = Promise(participant.muteState) + + let itemsForEntry: (GroupCallParticipantsContext.Participant.MuteState?) -> [ContextMenuItem] = { [weak self] muteState in + guard let self, let component = self.component, let environment = self.environment else { + return [] + } + guard let callState = self.callState else { + return [] + } + + var items: [ContextMenuItem] = [] + + var hasVolumeSlider = false + let peer = participant.peer + if let muteState = muteState, !muteState.canUnmute || muteState.mutedByYou { + } else { + if callState.canManageCall || callState.myPeerId != id { + hasVolumeSlider = true + + let minValue: CGFloat + if callState.canManageCall && callState.adminIds.contains(peer.id) && muteState != nil { + minValue = 0.01 + } else { + minValue = 0.0 + } + items.append(.custom(VoiceChatVolumeContextItem(minValue: minValue, value: participant.volume.flatMap { CGFloat($0) / 10000.0 } ?? 1.0, valueChanged: { [weak self] newValue, finished in + guard let self, let component = self.component else { + return + } + + if finished && newValue.isZero { + let updatedMuteState = component.call.updateMuteState(peerId: peer.id, isMuted: true) + muteStatePromise.set(.single(updatedMuteState)) + } else { + component.call.setVolume(peerId: peer.id, volume: Int32(newValue * 10000), sync: finished) + } + }), true)) + } + } + + if callState.myPeerId == id && !hasVolumeSlider && ((participant.about?.isEmpty ?? true) || participant.peer.smallProfileImage == nil) { + items.append(.custom(VoiceChatInfoContextItem(text: environment.strings.VoiceChat_ImproveYourProfileText, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Tip"), color: theme.actionSheet.primaryTextColor) + }), true)) + } + + if peer.id == callState.myPeerId { + if participant.hasRaiseHand { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_CancelSpeakRequest, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/RevokeSpeak"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + component.call.lowerHand() + + f(.default) + }))) + } + items.append(.action(ContextMenuActionItem(text: peer.smallProfileImage == nil ? environment.strings.VoiceChat_AddPhoto : environment.strings.VoiceChat_ChangePhoto, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Camera"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + Queue.mainQueue().after(0.1) { + guard let self else { + return + } + + self.openAvatarForEditing(fromGallery: false, completion: {}) + } + }))) + + items.append(.action(ContextMenuActionItem(text: (participant.about?.isEmpty ?? true) ? environment.strings.VoiceChat_AddBio : environment.strings.VoiceChat_EditBio, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + + Queue.mainQueue().after(0.1) { + guard let self, let component = self.component, let environment = self.environment else { + return + } + let maxBioLength: Int + if peer.id.namespace == Namespaces.Peer.CloudUser { + maxBioLength = 70 + } else { + maxBioLength = 100 + } + let controller = voiceChatTitleEditController(sharedContext: component.call.accountContext.sharedContext, account: component.call.accountContext.account, forceTheme: environment.theme, title: environment.strings.VoiceChat_EditBioTitle, text: environment.strings.VoiceChat_EditBioText, placeholder: environment.strings.VoiceChat_EditBioPlaceholder, doneButtonTitle: environment.strings.VoiceChat_EditBioSave, value: participant.about, maxLength: maxBioLength, apply: { [weak self] bio in + guard let self, let component = self.component, let environment = self.environment, let bio else { + return + } + if peer.id.namespace == Namespaces.Peer.CloudUser { + let _ = (component.call.accountContext.engine.accountData.updateAbout(about: bio) + |> `catch` { _ -> Signal in + return .complete() + }).start() + } else { + let _ = (component.call.accountContext.engine.peers.updatePeerDescription(peerId: peer.id, description: bio) + |> `catch` { _ -> Signal in + return .complete() + }).start() + } + + self.presentUndoOverlay(content: .info(title: nil, text: environment.strings.VoiceChat_EditBioSuccess, timeout: nil, customUndoText: nil), action: { _ in return false }) + }) + environment.controller()?.present(controller, in: .window(.root)) + } + }))) + + if let peer = peer as? TelegramUser { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_ChangeName, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ChangeName"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + f(.default) + + Queue.mainQueue().after(0.1) { + guard let self, let component = self.component, let environment = self.environment else { + return + } + let controller = voiceChatUserNameController(sharedContext: component.call.accountContext.sharedContext, account: component.call.accountContext.account, forceTheme: environment.theme, title: environment.strings.VoiceChat_ChangeNameTitle, firstNamePlaceholder: environment.strings.UserInfo_FirstNamePlaceholder, lastNamePlaceholder: environment.strings.UserInfo_LastNamePlaceholder, doneButtonTitle: environment.strings.VoiceChat_EditBioSave, firstName: peer.firstName, lastName: peer.lastName, maxLength: 128, apply: { [weak self] firstAndLastName in + guard let self, let component = self.component, let environment = self.environment, let (firstName, lastName) = firstAndLastName else { + return + } + let _ = component.call.accountContext.engine.accountData.updateAccountPeerName(firstName: firstName, lastName: lastName).startStandalone() + + self.presentUndoOverlay(content: .info(title: nil, text: environment.strings.VoiceChat_EditNameSuccess, timeout: nil, customUndoText: nil), action: { _ in return false }) + }) + environment.controller()?.present(controller, in: .window(.root)) + } + }))) + } + } else { + if (callState.canManageCall || callState.adminIds.contains(component.call.accountContext.account.peerId)) { + if callState.adminIds.contains(peer.id) { + if let _ = muteState { + } else { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MutePeer, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: true) + f(.default) + }))) + } + } else { + if let muteState = muteState, !muteState.canUnmute { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_UnmutePeer, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: participant.hasRaiseHand ? "Call/Context Menu/AllowToSpeak" : "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: false) + f(.default) + + self.presentUndoOverlay(content: .voiceChatCanSpeak(text: environment.strings.VoiceChat_UserCanNowSpeak(EnginePeer(participant.peer).displayTitle(strings: environment.strings, displayOrder: component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).nameDisplayOrder)).string), action: { _ in return true }) + }))) + } else { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MutePeer, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: true) + f(.default) + }))) + } + } + } else { + if let muteState = muteState, muteState.mutedByYou { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_UnmuteForMe, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: false) + f(.default) + }))) + } else { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_MuteForMe, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component else { + return + } + + let _ = component.call.updateMuteState(peerId: peer.id, isMuted: true) + f(.default) + }))) + } + } + + let openTitle: String + let openIcon: UIImage? + if [Namespaces.Peer.CloudChannel, Namespaces.Peer.CloudGroup].contains(peer.id.namespace) { + if let peer = peer as? TelegramChannel, case .broadcast = peer.info { + openTitle = environment.strings.VoiceChat_OpenChannel + openIcon = UIImage(bundleImageName: "Chat/Context Menu/Channels") + } else { + openTitle = environment.strings.VoiceChat_OpenGroup + openIcon = UIImage(bundleImageName: "Chat/Context Menu/Groups") + } + } else { + openTitle = environment.strings.Conversation_ContextMenuSendMessage + openIcon = UIImage(bundleImageName: "Chat/Context Menu/Message") + } + items.append(.action(ContextMenuActionItem(text: openTitle, icon: { theme in + return generateTintedImage(image: openIcon, color: theme.actionSheet.primaryTextColor) + }, action: { [weak self] _, f in + guard let self, let component = self.component, let environment = self.environment else { + return + } + + guard let controller = environment.controller() as? VideoChatScreenV2Impl, let navigationController = controller.parentNavigationController else { + return + } + + let context = component.call.accountContext + environment.controller()?.dismiss(completion: { [weak navigationController] in + Queue.mainQueue().after(0.3) { + guard let navigationController else { + return + } + context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(EnginePeer(peer)), keepStack: .always, purposefulAction: {}, peekData: nil)) + } + }) + + f(.dismissWithoutContent) + }))) + + if (callState.canManageCall && !callState.adminIds.contains(peer.id)), peer.id != component.call.peerId { + items.append(.action(ContextMenuActionItem(text: environment.strings.VoiceChat_RemovePeer, textColor: .destructive, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.destructiveActionTextColor) + }, action: { [weak self] c, _ in + c?.dismiss(completion: { + guard let self, let component = self.component else { + return + } + + let _ = (component.call.accountContext.account.postbox.loadedPeerWithId(component.call.peerId) + |> deliverOnMainQueue).start(next: { [weak self] chatPeer in + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + let actionSheet = ActionSheetController(presentationData: presentationData) + var items: [ActionSheetItem] = [] + + let nameDisplayOrder = presentationData.nameDisplayOrder + items.append(DeleteChatPeerActionSheetItem(context: component.call.accountContext, peer: EnginePeer(peer), chatPeer: EnginePeer(chatPeer), action: .removeFromGroup, strings: environment.strings, nameDisplayOrder: nameDisplayOrder)) + + items.append(ActionSheetButtonItem(title: environment.strings.VoiceChat_RemovePeerRemove, color: .destructive, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + + guard let self, let component = self.component, let environment = self.environment else { + return + } + + let _ = component.call.accountContext.peerChannelMemberCategoriesContextsManager.updateMemberBannedRights(engine: component.call.accountContext.engine, peerId: component.call.peerId, memberId: peer.id, bannedRights: TelegramChatBannedRights(flags: [.banReadMessages], untilDate: Int32.max)).start() + component.call.removedPeer(peer.id) + + self.presentUndoOverlay(content: .banned(text: environment.strings.VoiceChat_RemovedPeerText(EnginePeer(peer).displayTitle(strings: environment.strings, displayOrder: nameDisplayOrder)).string), action: { _ in return false }) + })) + + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: environment.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + environment.controller()?.present(actionSheet, in: .window(.root)) + }) + }) + }))) + } + } + return items + } + + let items = muteStatePromise.get() + |> map { muteState -> [ContextMenuItem] in + return itemsForEntry(muteState) + } + + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + let contextController = ContextController( + presentationData: presentationData, + source: .extracted(ParticipantExtractedContentSource(contentView: sourceView)), + items: items |> map { items in + return ContextController.Items(content: .list(items)) + }, + recognizer: nil, + gesture: gesture + ) + + environment.controller()?.forEachController({ controller in + if let controller = controller as? UndoOverlayController { + controller.dismiss() + } + return true + }) + + environment.controller()?.presentInGlobalOverlay(contextController) + } + + private func openAvatarForEditing(fromGallery: Bool = false, completion: @escaping () -> Void = {}) { + guard let component = self.component else { + return + } + guard let callState = self.callState else { + return + } + let peerId = callState.myPeerId + + let _ = (component.call.accountContext.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: peerId), + TelegramEngine.EngineData.Item.Configuration.SearchBots() + ) + |> deliverOnMainQueue).start(next: { [weak self] peer, searchBotsConfiguration in + guard let self, let component = self.component, let environment = self.environment else { + return + } + guard let peer else { + return + } + + let presentationData = component.call.accountContext.sharedContext.currentPresentationData.with({ $0 }).withUpdated(theme: environment.theme) + + let legacyController = LegacyController(presentation: .custom, theme: environment.theme) + legacyController.statusBar.statusBarStyle = .Ignore + + let emptyController = LegacyEmptyController(context: legacyController.context)! + let navigationController = makeLegacyNavigationController(rootController: emptyController) + navigationController.setNavigationBarHidden(true, animated: false) + navigationController.navigationBar.transform = CGAffineTransform(translationX: -1000.0, y: 0.0) + + legacyController.bind(controller: navigationController) + + self.endEditing(true) + environment.controller()?.present(legacyController, in: .window(.root)) + + var hasPhotos = false + if !peer.profileImageRepresentations.isEmpty { + hasPhotos = true + } + + let mixin = TGMediaAvatarMenuMixin(context: legacyController.context, parentController: emptyController, hasSearchButton: true, hasDeleteButton: hasPhotos && !fromGallery, hasViewButton: false, personalPhoto: peerId.namespace == Namespaces.Peer.CloudUser, isVideo: false, saveEditedPhotos: false, saveCapturedMedia: false, signup: false, forum: false, title: nil, isSuggesting: false)! + mixin.forceDark = true + mixin.stickersContext = LegacyPaintStickersContext(context: component.call.accountContext) + let _ = self.currentAvatarMixin.swap(mixin) + mixin.requestSearchController = { [weak self] assetsController in + guard let self, let component = self.component, let environment = self.environment else { + return + } + let controller = WebSearchController(context: component.call.accountContext, peer: peer, chatLocation: nil, configuration: searchBotsConfiguration, mode: .avatar(initialQuery: peer.id.namespace == Namespaces.Peer.CloudUser ? nil : peer.displayTitle(strings: environment.strings, displayOrder: presentationData.nameDisplayOrder), completion: { [weak self] result in + assetsController?.dismiss() + + guard let self else { + return + } + self.updateProfilePhoto(result) + })) + controller.navigationPresentation = .modal + environment.controller()?.push(controller) + + if fromGallery { + completion() + } + } + mixin.didFinishWithImage = { [weak self] image in + if let image = image { + completion() + self?.updateProfilePhoto(image) + } + } + mixin.didFinishWithVideo = { [weak self] image, asset, adjustments in + if let image = image, let asset = asset { + completion() + self?.updateProfileVideo(image, asset: asset, adjustments: adjustments) + } + } + mixin.didFinishWithDelete = { [weak self] in + guard let self, let environment = self.environment else { + return + } + + let proceed = { [weak self] in + guard let self, let component = self.component else { + return + } + + let _ = self.currentAvatarMixin.swap(nil) + let postbox = component.call.accountContext.account.postbox + self.updateAvatarDisposable.set((component.call.accountContext.engine.peers.updatePeerPhoto(peerId: peerId, photo: nil, mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) + }) + |> deliverOnMainQueue).start()) + } + + let actionSheet = ActionSheetController(presentationData: presentationData) + let items: [ActionSheetItem] = [ + ActionSheetButtonItem(title: environment.strings.Settings_RemoveConfirmation, color: .destructive, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + proceed() + }) + ] + + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + environment.controller()?.present(actionSheet, in: .window(.root)) + } + mixin.didDismiss = { [weak self, weak legacyController] in + guard let self else { + return + } + let _ = self.currentAvatarMixin.swap(nil) + legacyController?.dismiss() + } + let menuController = mixin.present() + if let menuController = menuController { + menuController.customRemoveFromParentViewController = { [weak legacyController] in + legacyController?.dismiss() + } + } + }) + } + + private func updateProfilePhoto(_ image: UIImage) { + guard let component = self.component else { + return + } + guard let callState = self.callState else { + return + } + guard let data = image.jpegData(compressionQuality: 0.6) else { + return + } + + let peerId = callState.myPeerId + + let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) + component.call.account.postbox.mediaBox.storeResourceData(resource.id, data: data) + let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: resource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false) + + self.currentUpdatingAvatar = (representation, 0.0) + + let postbox = component.call.account.postbox + let signal = peerId.namespace == Namespaces.Peer.CloudUser ? component.call.accountContext.engine.accountData.updateAccountPhoto(resource: resource, videoResource: nil, videoStartTimestamp: nil, markup: nil, mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) + }) : component.call.accountContext.engine.peers.updatePeerPhoto(peerId: peerId, photo: component.call.accountContext.engine.peers.uploadedPeerPhoto(resource: resource), mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) + }) + + self.updateAvatarDisposable.set((signal + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self else { + return + } + switch result { + case .complete: + self.currentUpdatingAvatar = nil + self.state?.updated(transition: .spring(duration: 0.4)) + case let .progress(value): + self.currentUpdatingAvatar = (representation, value) + } + })) + + self.state?.updated(transition: .spring(duration: 0.4)) + } + + private func updateProfileVideo(_ image: UIImage, asset: Any?, adjustments: TGVideoEditAdjustments?) { + guard let component = self.component else { + return + } + guard let callState = self.callState else { + return + } + guard let data = image.jpegData(compressionQuality: 0.6) else { + return + } + let peerId = callState.myPeerId + + let photoResource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) + component.call.accountContext.account.postbox.mediaBox.storeResourceData(photoResource.id, data: data) + let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: photoResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false) + + self.currentUpdatingAvatar = (representation, 0.0) + + var videoStartTimestamp: Double? = nil + if let adjustments = adjustments, adjustments.videoStartValue > 0.0 { + videoStartTimestamp = adjustments.videoStartValue - adjustments.trimStartValue + } + + let context = component.call.accountContext + let account = context.account + let signal = Signal { [weak self] subscriber in + let entityRenderer: LegacyPaintEntityRenderer? = adjustments.flatMap { adjustments in + if let paintingData = adjustments.paintingData, paintingData.hasAnimation { + return LegacyPaintEntityRenderer(postbox: account.postbox, adjustments: adjustments) + } else { + return nil + } + } + + let tempFile = EngineTempBox.shared.tempFile(fileName: "video.mp4") + let uploadInterface = LegacyLiveUploadInterface(context: context) + let signal: SSignal + if let url = asset as? URL, url.absoluteString.hasSuffix(".jpg"), let data = try? Data(contentsOf: url, options: [.mappedRead]), let image = UIImage(data: data), let entityRenderer = entityRenderer { + let durationSignal: SSignal = SSignal(generator: { subscriber in + let disposable = (entityRenderer.duration()).start(next: { duration in + subscriber.putNext(duration) + subscriber.putCompletion() + }) + + return SBlockDisposable(block: { + disposable.dispose() + }) + }) + signal = durationSignal.map(toSignal: { duration -> SSignal in + if let duration = duration as? Double { + return TGMediaVideoConverter.renderUIImage(image, duration: duration, adjustments: adjustments, path: tempFile.path, watcher: nil, entityRenderer: entityRenderer)! + } else { + return SSignal.single(nil) + } + }) + + } else if let asset = asset as? AVAsset { + signal = TGMediaVideoConverter.convert(asset, adjustments: adjustments, path: tempFile.path, watcher: uploadInterface, entityRenderer: entityRenderer)! + } else { + signal = SSignal.complete() + } + + let signalDisposable = signal.start(next: { next in + if let result = next as? TGMediaVideoConversionResult { + if let image = result.coverImage, let data = image.jpegData(compressionQuality: 0.7) { + account.postbox.mediaBox.storeResourceData(photoResource.id, data: data) + } + + if let timestamp = videoStartTimestamp { + videoStartTimestamp = max(0.0, min(timestamp, result.duration - 0.05)) + } + + var value = stat() + if stat(result.fileURL.path, &value) == 0 { + if let data = try? Data(contentsOf: result.fileURL) { + let resource: TelegramMediaResource + if let liveUploadData = result.liveUploadData as? LegacyLiveUploadInterfaceResult { + resource = LocalFileMediaResource(fileId: liveUploadData.id) + } else { + resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) + } + account.postbox.mediaBox.storeResourceData(resource.id, data: data, synchronous: true) + subscriber.putNext(resource) + + EngineTempBox.shared.dispose(tempFile) + } + } + subscriber.putCompletion() + } else if let progress = next as? NSNumber { + Queue.mainQueue().async { [weak self] in + guard let self else { + return + } + self.currentUpdatingAvatar = (representation, Float(truncating: progress) * 0.25) + self.state?.updated(transition: .spring(duration: 0.4)) + } + } + }, error: { _ in + }, completed: nil) + + let disposable = ActionDisposable { + signalDisposable?.dispose() + } + + return ActionDisposable { + disposable.dispose() + } + } + + self.updateAvatarDisposable.set((signal + |> mapToSignal { videoResource -> Signal in + if peerId.namespace == Namespaces.Peer.CloudUser { + return context.engine.accountData.updateAccountPhoto(resource: photoResource, videoResource: videoResource, videoStartTimestamp: videoStartTimestamp, markup: nil, mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) + }) + } else { + return context.engine.peers.updatePeerPhoto(peerId: peerId, photo: context.engine.peers.uploadedPeerPhoto(resource: photoResource), video: context.engine.peers.uploadedPeerVideo(resource: videoResource) |> map(Optional.init), videoStartTimestamp: videoStartTimestamp, mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: account.postbox, resource: resource, representations: representations) + }) + } + } + |> deliverOnMainQueue).start(next: { [weak self] result in + guard let self else { + return + } + switch result { + case .complete: + self.currentUpdatingAvatar = nil + self.state?.updated(transition: .spring(duration: 0.4)) + case let .progress(value): + self.currentUpdatingAvatar = (representation, 0.25 + value * 0.75) + self.state?.updated(transition: .spring(duration: 0.4)) + } + })) + } +} + +private final class ParticipantExtractedContentSource: ContextExtractedContentSource { + let keepInPlace: Bool = false + let ignoreContentTouches: Bool = false + let blurBackground: Bool = true + + private let contentView: ContextExtractedContentContainingView + + init(contentView: ContextExtractedContentContainingView) { + self.contentView = contentView + } + + func takeView() -> ContextControllerTakeViewInfo? { + return ContextControllerTakeViewInfo(containingItem: .view(self.contentView), contentAreaInScreenSpace: UIScreen.main.bounds) + } + + func putBack() -> ContextControllerPutBackViewInfo? { + return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds) + } +} diff --git a/submodules/TelegramCallsUI/Sources/VideoChatTitleComponent.swift b/submodules/TelegramCallsUI/Sources/VideoChatTitleComponent.swift index 93b9115a31..0f13e9d815 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatTitleComponent.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatTitleComponent.swift @@ -4,19 +4,23 @@ import Display import ComponentFlow import MultilineTextComponent import TelegramPresentationData +import HierarchyTrackingLayer final class VideoChatTitleComponent: Component { let title: String let status: String + let isRecording: Bool let strings: PresentationStrings init( title: String, status: String, + isRecording: Bool, strings: PresentationStrings ) { self.title = title self.status = status + self.isRecording = isRecording self.strings = strings } @@ -27,6 +31,9 @@ final class VideoChatTitleComponent: Component { if lhs.status != rhs.status { return false } + if lhs.isRecording != rhs.isRecording { + return false + } if lhs.strings !== rhs.strings { return false } @@ -34,20 +41,46 @@ final class VideoChatTitleComponent: Component { } final class View: UIView { + private let hierarchyTrackingLayer: HierarchyTrackingLayer private let title = ComponentView() private var status: ComponentView? + private var recordingImageView: UIImageView? private var component: VideoChatTitleComponent? private var isUpdating: Bool = false override init(frame: CGRect) { + self.hierarchyTrackingLayer = HierarchyTrackingLayer() + super.init(frame: frame) + + self.layer.addSublayer(self.hierarchyTrackingLayer) + self.hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in + guard let self else { + return + } + self.updateAnimations() + } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + private func updateAnimations() { + if let recordingImageView = self.recordingImageView { + if recordingImageView.layer.animation(forKey: "blink") == nil { + let animation = CAKeyframeAnimation(keyPath: "opacity") + animation.values = [1.0 as NSNumber, 1.0 as NSNumber, 0.55 as NSNumber] + animation.keyTimes = [0.0 as NSNumber, 0.4546 as NSNumber, 0.9091 as NSNumber, 1 as NSNumber] + animation.duration = 0.7 + animation.autoreverses = true + animation.repeatCount = Float.infinity + recordingImageView.layer.add(animation, forKey: "blink") + } + } + } + func update(component: VideoChatTitleComponent, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: ComponentTransition) -> CGSize { self.isUpdating = true defer { @@ -106,6 +139,32 @@ final class VideoChatTitleComponent: Component { statusView.bounds = CGRect(origin: CGPoint(), size: statusFrame.size) } + if component.isRecording { + var recordingImageTransition = transition + let recordingImageView: UIImageView + if let current = self.recordingImageView { + recordingImageView = current + } else { + recordingImageTransition = recordingImageTransition.withAnimation(.none) + recordingImageView = UIImageView() + recordingImageView.image = generateFilledCircleImage(diameter: 8.0, color: UIColor(rgb: 0xFF3B2F)) + self.recordingImageView = recordingImageView + self.addSubview(recordingImageView) + transition.animateScale(view: recordingImageView, from: 0.0001, to: 1.0) + } + let recordingImageFrame = CGRect(origin: CGPoint(x: titleFrame.maxX + 5.0, y: titleFrame.minY + floor(titleFrame.height - 8.0) * 0.5 + 1.0), size: CGSize(width: 8.0, height: 8.0)) + recordingImageTransition.setFrame(view: recordingImageView, frame: recordingImageFrame) + + self.updateAnimations() + } else { + if let recordingImageView = self.recordingImageView { + self.recordingImageView = nil + transition.setScale(view: recordingImageView, scale: 0.0001, completion: { [weak recordingImageView] _ in + recordingImageView?.removeFromSuperview() + }) + } + } + return size } } diff --git a/submodules/TelegramCallsUI/Sources/VideoChatVideoLoadingEffectView.swift b/submodules/TelegramCallsUI/Sources/VideoChatVideoLoadingEffectView.swift index 8b81268148..364289cff1 100644 --- a/submodules/TelegramCallsUI/Sources/VideoChatVideoLoadingEffectView.swift +++ b/submodules/TelegramCallsUI/Sources/VideoChatVideoLoadingEffectView.swift @@ -8,125 +8,196 @@ private let shadowImage: UIImage? = { UIImage(named: "Stories/PanelGradient") }() -final class VideoChatVideoLoadingEffectView: UIView { - private let duration: Double - private let hasCustomBorder: Bool - private let playOnce: Bool +private func generateGradient(baseAlpha: CGFloat) -> UIImage? { + return generateImage(CGSize(width: 200.0, height: 16.0), opaque: false, scale: 1.0, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + + let foregroundColor = UIColor(white: 1.0, alpha: min(1.0, baseAlpha * 4.0)) + + if let shadowImage { + UIGraphicsPushContext(context) + + for i in 0 ..< 2 { + let shadowFrame = CGRect(origin: CGPoint(x: CGFloat(i) * (size.width * 0.5), y: 0.0), size: CGSize(width: size.width * 0.5, height: size.height)) + + context.saveGState() + context.translateBy(x: shadowFrame.midX, y: shadowFrame.midY) + context.rotate(by: CGFloat(i == 0 ? 1.0 : -1.0) * CGFloat.pi * 0.5) + let adjustedRect = CGRect(origin: CGPoint(x: -shadowFrame.height * 0.5, y: -shadowFrame.width * 0.5), size: CGSize(width: shadowFrame.height, height: shadowFrame.width)) + + context.clip(to: adjustedRect, mask: shadowImage.cgImage!) + context.setFillColor(foregroundColor.cgColor) + context.fill(adjustedRect) + + context.restoreGState() + } + + UIGraphicsPopContext() + } + }) +} + +private final class AnimatedGradientView: UIView { + private struct Params: Equatable { + var size: CGSize + var containerWidth: CGFloat + var offsetX: CGFloat + var gradientWidth: CGFloat + + init(size: CGSize, containerWidth: CGFloat, offsetX: CGFloat, gradientWidth: CGFloat) { + self.size = size + self.containerWidth = containerWidth + self.offsetX = offsetX + self.gradientWidth = gradientWidth + } + } + private let duration: Double private let hierarchyTrackingLayer: HierarchyTrackingLayer - private let gradientWidth: CGFloat - - let portalSource: PortalSourceView - + private let backgroundContainerView: UIView + private let backgroundScaleView: UIView + private let backgroundOffsetView: UIView private let backgroundView: UIImageView - private let borderGradientView: UIImageView - private let borderContainerView: UIView - let borderMaskLayer: SimpleShapeLayer + private var params: Params? - private var didPlayOnce = false - - init(effectAlpha: CGFloat, borderAlpha: CGFloat, gradientWidth: CGFloat = 200.0, duration: Double, hasCustomBorder: Bool, playOnce: Bool) { - self.portalSource = PortalSourceView() - + init(effectAlpha: CGFloat, duration: Double) { self.hierarchyTrackingLayer = HierarchyTrackingLayer() self.duration = duration - self.hasCustomBorder = hasCustomBorder - self.playOnce = playOnce - self.gradientWidth = gradientWidth + self.backgroundContainerView = UIView() + self.backgroundContainerView.layer.anchorPoint = CGPoint() + + self.backgroundScaleView = UIView() + self.backgroundOffsetView = UIView() + self.backgroundView = UIImageView() - self.borderGradientView = UIImageView() - self.borderContainerView = UIView() - self.borderMaskLayer = SimpleShapeLayer() + super.init(frame: CGRect()) - super.init(frame: .zero) - - self.portalSource.backgroundColor = .red - - self.portalSource.layer.addSublayer(self.hierarchyTrackingLayer) + self.layer.addSublayer(self.hierarchyTrackingLayer) self.hierarchyTrackingLayer.didEnterHierarchy = { [weak self] in - guard let self, self.bounds.width != 0.0 else { + guard let self else { return } - self.updateAnimations(size: self.bounds.size) + self.updateAnimations() } - let generateGradient: (CGFloat) -> UIImage? = { baseAlpha in - return generateImage(CGSize(width: self.gradientWidth, height: 16.0), opaque: false, scale: 1.0, rotatedContext: { size, context in - context.clear(CGRect(origin: CGPoint(), size: size)) - - let foregroundColor = UIColor(white: 1.0, alpha: min(1.0, baseAlpha * 4.0)) - - if let shadowImage { - UIGraphicsPushContext(context) - - for i in 0 ..< 2 { - let shadowFrame = CGRect(origin: CGPoint(x: CGFloat(i) * (size.width * 0.5), y: 0.0), size: CGSize(width: size.width * 0.5, height: size.height)) - - context.saveGState() - context.translateBy(x: shadowFrame.midX, y: shadowFrame.midY) - context.rotate(by: CGFloat(i == 0 ? 1.0 : -1.0) * CGFloat.pi * 0.5) - let adjustedRect = CGRect(origin: CGPoint(x: -shadowFrame.height * 0.5, y: -shadowFrame.width * 0.5), size: CGSize(width: shadowFrame.height, height: shadowFrame.width)) - - context.clip(to: adjustedRect, mask: shadowImage.cgImage!) - context.setFillColor(foregroundColor.cgColor) - context.fill(adjustedRect) - - context.restoreGState() - } - - UIGraphicsPopContext() - } - }) - } - self.backgroundView.image = generateGradient(effectAlpha) - self.portalSource.addSubview(self.backgroundView) + self.backgroundView.image = generateGradient(baseAlpha: effectAlpha) - self.borderGradientView.image = generateGradient(borderAlpha) - self.borderContainerView.addSubview(self.borderGradientView) - self.portalSource.addSubview(self.borderContainerView) - self.borderContainerView.layer.mask = self.borderMaskLayer + self.backgroundOffsetView.addSubview(self.backgroundView) + self.backgroundScaleView.addSubview(self.backgroundOffsetView) + self.backgroundContainerView.addSubview(self.backgroundScaleView) + self.addSubview(self.backgroundContainerView) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - private func updateAnimations(size: CGSize) { - if self.backgroundView.layer.animation(forKey: "shimmer") != nil || (self.playOnce && self.didPlayOnce) { - return + private func updateAnimations() { + if self.backgroundView.layer.animation(forKey: "shimmer") == nil { + let animation = self.backgroundView.layer.makeAnimation(from: -1.0 as NSNumber, to: 1.0 as NSNumber, keyPath: "position.x", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: self.duration, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: true) + animation.repeatCount = Float.infinity + animation.beginTime = 1.0 + self.backgroundView.layer.add(animation, forKey: "shimmer") + } + if self.backgroundScaleView.layer.animation(forKey: "shimmer") == nil { + let animation = self.backgroundScaleView.layer.makeAnimation(from: 0.0 as NSNumber, to: 1.0 as NSNumber, keyPath: "position.x", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: self.duration, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: true) + animation.repeatCount = Float.infinity + animation.beginTime = 1.0 + self.backgroundScaleView.layer.add(animation, forKey: "shimmer") } - - let animation = self.backgroundView.layer.makeAnimation(from: 0.0 as NSNumber, to: (size.width + self.gradientWidth + size.width * 0.2) as NSNumber, keyPath: "position.x", timingFunction: CAMediaTimingFunctionName.easeOut.rawValue, duration: self.duration, delay: 0.0, mediaTimingFunction: nil, removeOnCompletion: true, additive: true) - animation.repeatCount = self.playOnce ? 1 : Float.infinity - self.backgroundView.layer.add(animation, forKey: "shimmer") - self.borderGradientView.layer.add(animation, forKey: "shimmer") - - self.didPlayOnce = true } - func update(size: CGSize, transition: ComponentTransition) { - if self.backgroundView.bounds.size != size { - self.backgroundView.layer.removeAllAnimations() - - if !self.hasCustomBorder { - self.borderMaskLayer.fillColor = nil - self.borderMaskLayer.strokeColor = UIColor.white.cgColor - let lineWidth: CGFloat = 3.0 - self.borderMaskLayer.lineWidth = lineWidth - self.borderMaskLayer.path = UIBezierPath(roundedRect: CGRect(origin: CGPoint(), size: size).insetBy(dx: lineWidth * 0.5, dy: lineWidth * 0.5), cornerRadius: 12.0).cgPath - } + func update(size: CGSize, containerWidth: CGFloat, offsetX: CGFloat, gradientWidth: CGFloat, transition: ComponentTransition) { + let params = Params(size: size, containerWidth: containerWidth, offsetX: offsetX, gradientWidth: gradientWidth) + if self.params == params { + return } + self.params = params - transition.setFrame(view: self.backgroundView, frame: CGRect(origin: CGPoint(x: -self.gradientWidth, y: 0.0), size: CGSize(width: self.gradientWidth, height: size.height))) + let backgroundFrame = CGRect(origin: CGPoint(), size: CGSize(width: 1.0, height: 1.0)) + transition.setPosition(view: self.backgroundView, position: backgroundFrame.center) + transition.setBounds(view: self.backgroundView, bounds: CGRect(origin: CGPoint(), size: backgroundFrame.size)) - transition.setFrame(view: self.borderContainerView, frame: CGRect(origin: CGPoint(), size: size)) - transition.setFrame(view: self.borderGradientView, frame: CGRect(origin: CGPoint(x: -self.gradientWidth, y: 0.0), size: CGSize(width: self.gradientWidth, height: size.height))) + transition.setPosition(view: self.backgroundOffsetView, position: backgroundFrame.center) + transition.setBounds(view: self.backgroundOffsetView, bounds: CGRect(origin: CGPoint(), size: backgroundFrame.size)) - self.updateAnimations(size: size) + transition.setTransform(view: self.backgroundOffsetView, transform: CATransform3DMakeScale(gradientWidth, 1.0, 1.0)) + + let backgroundContainerViewSubFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: size.height)) + transition.setPosition(view: self.backgroundContainerView, position: CGPoint()) + transition.setBounds(view: self.backgroundContainerView, bounds: backgroundContainerViewSubFrame) + var containerTransform = CATransform3DIdentity + containerTransform = CATransform3DTranslate(containerTransform, -offsetX, 0.0, 0.0) + containerTransform = CATransform3DScale(containerTransform, containerWidth, size.height, 1.0) + transition.setSublayerTransform(view: self.backgroundContainerView, transform: containerTransform) + + transition.setSublayerTransform(view: self.backgroundScaleView, transform: CATransform3DMakeScale(1.0 / containerWidth, 1.0, 1.0)) + + self.updateAnimations() + } +} + +final class VideoChatVideoLoadingEffectView: UIView { + private struct Params: Equatable { + var size: CGSize + var containerWidth: CGFloat + var offsetX: CGFloat + var gradientWidth: CGFloat + + init(size: CGSize, containerWidth: CGFloat, offsetX: CGFloat, gradientWidth: CGFloat) { + self.size = size + self.containerWidth = containerWidth + self.offsetX = offsetX + self.gradientWidth = gradientWidth + } + } + + private let duration: Double + private let cornerRadius: CGFloat + + private let backgroundView: AnimatedGradientView + + private let borderMaskView: UIImageView + private let borderBackgroundView: AnimatedGradientView + + private var params: Params? + + init(effectAlpha: CGFloat, borderAlpha: CGFloat, cornerRadius: CGFloat = 12.0, duration: Double) { + self.duration = duration + self.cornerRadius = cornerRadius + + self.backgroundView = AnimatedGradientView(effectAlpha: effectAlpha, duration: duration) + + self.borderMaskView = UIImageView() + self.borderMaskView.image = generateStretchableFilledCircleImage(diameter: cornerRadius * 2.0, color: nil, strokeColor: .white, strokeWidth: 2.0) + self.borderBackgroundView = AnimatedGradientView(effectAlpha: borderAlpha, duration: duration) + + super.init(frame: CGRect()) + + self.addSubview(self.backgroundView) + + self.borderBackgroundView.mask = self.borderMaskView + self.addSubview(self.borderBackgroundView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(size: CGSize, containerWidth: CGFloat, offsetX: CGFloat, gradientWidth: CGFloat, transition: ComponentTransition) { + let params = Params(size: size, containerWidth: containerWidth, offsetX: offsetX, gradientWidth: gradientWidth) + if self.params == params { + return + } + self.params = params + + self.backgroundView.update(size: size, containerWidth: containerWidth, offsetX: offsetX, gradientWidth: gradientWidth, transition: transition) + self.borderBackgroundView.update(size: size, containerWidth: containerWidth, offsetX: offsetX, gradientWidth: gradientWidth, transition: transition) + transition.setFrame(view: self.borderMaskView, frame: CGRect(origin: CGPoint(), size: size)) } } diff --git a/submodules/TelegramCore/Sources/ApiUtils/BotInfo.swift b/submodules/TelegramCore/Sources/ApiUtils/BotInfo.swift index 440f522cfe..92ee56bc26 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/BotInfo.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/BotInfo.swift @@ -18,7 +18,7 @@ extension BotInfo { switch apiBotInfo { case let .botInfo(_, _, description, descriptionPhoto, descriptionDocument, apiCommands, apiMenuButton, privacyPolicyUrl): let photo: TelegramMediaImage? = descriptionPhoto.flatMap(telegramMediaImageFromApiPhoto) - let video: TelegramMediaFile? = descriptionDocument.flatMap(telegramMediaFileFromApiDocument) + let video: TelegramMediaFile? = descriptionDocument.flatMap { telegramMediaFileFromApiDocument($0, altDocuments: []) } var commands: [BotCommand] = [] if let apiCommands = apiCommands { commands = apiCommands.map { command in diff --git a/submodules/TelegramCore/Sources/ApiUtils/ChatContextResult.swift b/submodules/TelegramCore/Sources/ApiUtils/ChatContextResult.swift index 65fa28f513..40db07ab3f 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/ChatContextResult.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/ChatContextResult.swift @@ -598,7 +598,7 @@ extension ChatContextResult { if let photo = photo, let parsedImage = telegramMediaImageFromApiPhoto(photo) { image = parsedImage } - if let document = document, let parsedFile = telegramMediaFileFromApiDocument(document) { + if let document = document, let parsedFile = telegramMediaFileFromApiDocument(document, altDocuments: []) { file = parsedFile } self = .internalReference(ChatContextResult.InternalReference(queryId: queryId, id: id, type: type, title: title, description: description, image: image, file: file, message: ChatContextResultMessage(apiMessage: sendMessage))) diff --git a/submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift b/submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift index 09360ff164..ea4bf38bc7 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/InstantPage.swift @@ -192,7 +192,7 @@ extension InstantPage { } } for file in files { - if let file = telegramMediaFileFromApiDocument(file), let id = file.id { + if let file = telegramMediaFileFromApiDocument(file, altDocuments: []), let id = file.id { media[id] = file } } diff --git a/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift b/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift index 84f95b35cf..302ec5da6b 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/StoreMessage_Telegram.swift @@ -50,7 +50,7 @@ public func tagsForStoreMessage(incoming: Bool, attributes: [MessageAttribute], var isAnimated = false inner: for attribute in file.attributes { switch attribute { - case let .Video(_, _, flags, _, _): + case let .Video(_, _, flags, _, _, _): if flags.contains(.instantRoundVideo) { refinedTag = .voiceOrInstantVideo } else { @@ -350,9 +350,9 @@ func textMediaAndExpirationTimerFromApiMedia(_ media: Api.MessageMedia?, _ peerI case let .messageMediaGeoLive(_, geo, heading, period, proximityNotificationRadius): let mediaMap = telegramMediaMapFromApiGeoPoint(geo, title: nil, address: nil, provider: nil, venueId: nil, venueType: nil, liveBroadcastingTimeout: period, liveProximityNotificationRadius: proximityNotificationRadius, heading: heading) return (mediaMap, nil, nil, nil, nil) - case let .messageMediaDocument(flags, document, _, ttlSeconds): + case let .messageMediaDocument(flags, document, altDocuments, ttlSeconds): if let document = document { - if let mediaFile = telegramMediaFileFromApiDocument(document) { + if let mediaFile = telegramMediaFileFromApiDocument(document, altDocuments: altDocuments) { return (mediaFile, ttlSeconds, (flags & (1 << 3)) != 0, (flags & (1 << 4)) != 0, nil) } } else { diff --git a/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaFile.swift b/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaFile.swift index eb13b6699d..76b2a46ea6 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaFile.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaFile.swift @@ -6,7 +6,7 @@ import TelegramApi func dimensionsForFileAttributes(_ attributes: [TelegramMediaFileAttribute]) -> PixelDimensions? { for attribute in attributes { switch attribute { - case let .Video(_, size, _, _, _): + case let .Video(_, size, _, _, _, _): return size case let .ImageSize(size): return size @@ -20,7 +20,7 @@ func dimensionsForFileAttributes(_ attributes: [TelegramMediaFileAttribute]) -> func durationForFileAttributes(_ attributes: [TelegramMediaFileAttribute]) -> Double? { for attribute in attributes { switch attribute { - case let .Video(duration, _, _, _, _): + case let .Video(duration, _, _, _, _, _): return duration case let .Audio(_, duration, _, _, _): return Double(duration) @@ -99,8 +99,7 @@ func telegramMediaFileAttributesFromApiAttributes(_ attributes: [Api.DocumentAtt result.append(.ImageSize(size: PixelDimensions(width: w, height: h))) case .documentAttributeAnimated: result.append(.Animated) - case let .documentAttributeVideo(flags, duration, w, h, preloadSize, videoStart, codec): - let _ = codec + case let .documentAttributeVideo(flags, duration, w, h, preloadSize, videoStart, videoCodec): var videoFlags = TelegramMediaVideoFlags() if (flags & (1 << 0)) != 0 { videoFlags.insert(.instantRoundVideo) @@ -111,7 +110,7 @@ func telegramMediaFileAttributesFromApiAttributes(_ attributes: [Api.DocumentAtt if (flags & (1 << 3)) != 0 { videoFlags.insert(.isSilent) } - result.append(.Video(duration: Double(duration), size: PixelDimensions(width: w, height: h), flags: videoFlags, preloadSize: preloadSize, coverTime: videoStart)) + result.append(.Video(duration: Double(duration), size: PixelDimensions(width: w, height: h), flags: videoFlags, preloadSize: preloadSize, coverTime: videoStart, videoCodec: videoCodec)) case let .documentAttributeAudio(flags, duration, title, performer, waveform): let isVoice = (flags & (1 << 10)) != 0 let waveformBuffer: Data? = waveform?.makeData() @@ -159,7 +158,7 @@ func telegramMediaFileThumbnailRepresentationsFromApiSizes(datacenterId: Int32, return (immediateThumbnailData, representations) } -func telegramMediaFileFromApiDocument(_ document: Api.Document) -> TelegramMediaFile? { +func telegramMediaFileFromApiDocument(_ document: Api.Document, altDocuments: [Api.Document]?) -> TelegramMediaFile? { switch document { case let .document(_, id, accessHash, fileReference, _, mimeType, size, thumbs, videoThumbs, dcId, attributes): var parsedAttributes = telegramMediaFileAttributesFromApiAttributes(attributes) @@ -183,8 +182,13 @@ func telegramMediaFileFromApiDocument(_ document: Api.Document) -> TelegramMedia } } } + + var alternativeRepresentations: [Media] = [] + if let altDocuments { + alternativeRepresentations = altDocuments.compactMap { telegramMediaFileFromApiDocument($0, altDocuments: []) } + } - return TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.CloudFile, id: id), partialReference: nil, resource: CloudDocumentMediaResource(datacenterId: Int(dcId), fileId: id, accessHash: accessHash, size: size, fileReference: fileReference.makeData(), fileName: fileNameFromFileAttributes(parsedAttributes)), previewRepresentations: previewRepresentations, videoThumbnails: videoThumbnails, immediateThumbnailData: immediateThumbnail, mimeType: mimeType, size: size, attributes: parsedAttributes) + return TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.CloudFile, id: id), partialReference: nil, resource: CloudDocumentMediaResource(datacenterId: Int(dcId), fileId: id, accessHash: accessHash, size: size, fileReference: fileReference.makeData(), fileName: fileNameFromFileAttributes(parsedAttributes)), previewRepresentations: previewRepresentations, videoThumbnails: videoThumbnails, immediateThumbnailData: immediateThumbnail, mimeType: mimeType, size: size, attributes: parsedAttributes, alternativeRepresentations: alternativeRepresentations) case .documentEmpty: return nil } diff --git a/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaGame.swift b/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaGame.swift index 31d988351b..e4365a2a0e 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaGame.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaGame.swift @@ -9,7 +9,7 @@ extension TelegramMediaGame { case let .game(_, id, accessHash, shortName, title, description, photo, document): var file: TelegramMediaFile? if let document = document { - file = telegramMediaFileFromApiDocument(document) + file = telegramMediaFileFromApiDocument(document, altDocuments: []) } self.init(gameId: id, accessHash: accessHash, name: shortName, title: title, description: description, image: telegramMediaImageFromApiPhoto(photo), file: file) } diff --git a/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaWebpage.swift b/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaWebpage.swift index b5c63bcf8b..a27d6773f8 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaWebpage.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/TelegramMediaWebpage.swift @@ -9,7 +9,7 @@ func telegramMediaWebpageAttributeFromApiWebpageAttribute(_ attribute: Api.WebPa case let .webPageAttributeTheme(_, documents, settings): var files: [TelegramMediaFile] = [] if let documents = documents { - files = documents.compactMap { telegramMediaFileFromApiDocument($0) } + files = documents.compactMap { telegramMediaFileFromApiDocument($0, altDocuments: []) } } return .theme(TelegraMediaWebpageThemeAttribute(files: files, settings: settings.flatMap { TelegramThemeSettings(apiThemeSettings: $0) })) case let .webPageAttributeStickerSet(apiFlags, stickers): @@ -21,7 +21,7 @@ func telegramMediaWebpageAttributeFromApiWebpageAttribute(_ attribute: Api.WebPa flags.insert(.isTemplate) } var files: [TelegramMediaFile] = [] - files = stickers.compactMap { telegramMediaFileFromApiDocument($0) } + files = stickers.compactMap { telegramMediaFileFromApiDocument($0, altDocuments: []) } return .stickerPack(TelegramMediaWebpageStickerPackAttribute(flags: flags, files: files)) case .webPageAttributeStory: return nil @@ -50,7 +50,7 @@ func telegramMediaWebpageFromApiWebpage(_ webpage: Api.WebPage) -> TelegramMedia } var file: TelegramMediaFile? if let document = document { - file = telegramMediaFileFromApiDocument(document) + file = telegramMediaFileFromApiDocument(document, altDocuments: []) } var story: TelegramMediaStory? var webpageAttributes: [TelegramMediaWebpageAttribute] = [] diff --git a/submodules/TelegramCore/Sources/ApiUtils/Theme.swift b/submodules/TelegramCore/Sources/ApiUtils/Theme.swift index 38f4fb1421..01feb7b6b1 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/Theme.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/Theme.swift @@ -8,7 +8,7 @@ extension TelegramTheme { convenience init(apiTheme: Api.Theme) { switch apiTheme { case let .theme(flags, id, accessHash, slug, title, document, settings, emoticon, installCount): - self.init(id: id, accessHash: accessHash, slug: slug, emoticon: emoticon, title: title, file: document.flatMap { telegramMediaFileFromApiDocument($0) }, settings: settings?.compactMap(TelegramThemeSettings.init(apiThemeSettings:)), isCreator: (flags & 1 << 0) != 0, isDefault: (flags & 1 << 1) != 0, installCount: installCount) + self.init(id: id, accessHash: accessHash, slug: slug, emoticon: emoticon, title: title, file: document.flatMap { telegramMediaFileFromApiDocument($0, altDocuments: []) }, settings: settings?.compactMap(TelegramThemeSettings.init(apiThemeSettings:)), isCreator: (flags & 1 << 0) != 0, isDefault: (flags & 1 << 1) != 0, installCount: installCount) } } } diff --git a/submodules/TelegramCore/Sources/ApiUtils/Wallpaper.swift b/submodules/TelegramCore/Sources/ApiUtils/Wallpaper.swift index 9bb1a7ae3f..a7eebe3809 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/Wallpaper.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/Wallpaper.swift @@ -67,7 +67,7 @@ extension TelegramWallpaper { init(apiWallpaper: Api.WallPaper) { switch apiWallpaper { case let .wallPaper(id, flags, accessHash, slug, document, settings): - if let file = telegramMediaFileFromApiDocument(document) { + if let file = telegramMediaFileFromApiDocument(document, altDocuments: []) { let wallpaperSettings: WallpaperSettings if let settings = settings { wallpaperSettings = WallpaperSettings(apiWallpaperSettings: settings) diff --git a/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift b/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift index e01f826118..113edc7caf 100644 --- a/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift +++ b/submodules/TelegramCore/Sources/Network/FetchedMediaResource.swift @@ -13,12 +13,12 @@ extension MediaResourceReference { } } -final class TelegramCloudMediaResourceFetchInfo: MediaResourceFetchInfo { - let reference: MediaResourceReference - let preferBackgroundReferenceRevalidation: Bool - let continueInBackground: Bool +public final class TelegramCloudMediaResourceFetchInfo: MediaResourceFetchInfo { + public let reference: MediaResourceReference + public let preferBackgroundReferenceRevalidation: Bool + public let continueInBackground: Bool - init(reference: MediaResourceReference, preferBackgroundReferenceRevalidation: Bool, continueInBackground: Bool) { + public init(reference: MediaResourceReference, preferBackgroundReferenceRevalidation: Bool, continueInBackground: Bool) { self.reference = reference self.preferBackgroundReferenceRevalidation = preferBackgroundReferenceRevalidation self.continueInBackground = continueInBackground @@ -493,7 +493,7 @@ final class MediaReferenceRevalidationContext { return .fail(.generic) } for document in result { - if let file = telegramMediaFileFromApiDocument(document) { + if let file = telegramMediaFileFromApiDocument(document, altDocuments: []) { return .single(file) } } @@ -956,9 +956,12 @@ func revalidateMediaResourceReference(accountPeerId: PeerId, postbox: Postbox, n } if let updatedResource = findUpdatedMediaResource(media: media, previousMedia: nil, resource: resource) { return .single(RevalidatedMediaResource(updatedResource: updatedResource, updatedReference: nil)) - } else if let alternativeMedia = item.alternativeMedia, let updatedResource = findUpdatedMediaResource(media: alternativeMedia, previousMedia: nil, resource: resource) { - return .single(RevalidatedMediaResource(updatedResource: updatedResource, updatedReference: nil)) } else { + for alternativeMediaValue in item.alternativeMediaList { + if let updatedResource = findUpdatedMediaResource(media: alternativeMediaValue, previousMedia: nil, resource: resource) { + return .single(RevalidatedMediaResource(updatedResource: updatedResource, updatedReference: nil)) + } + } return .fail(.generic) } } diff --git a/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift b/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift index 7cafce774d..a5ac46ba0b 100644 --- a/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift +++ b/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift @@ -205,7 +205,7 @@ func augmentMediaWithReference(_ mediaReference: AnyMediaReference) -> Media { private func convertForwardedMediaForSecretChat(_ media: Media) -> Media { if let file = media as? TelegramMediaFile { - return TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: file.partialReference, resource: file.resource, previewRepresentations: file.previewRepresentations, videoThumbnails: file.videoThumbnails, immediateThumbnailData: file.immediateThumbnailData, mimeType: file.mimeType, size: file.size, attributes: file.attributes) + return TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: file.partialReference, resource: file.resource, previewRepresentations: file.previewRepresentations, videoThumbnails: file.videoThumbnails, immediateThumbnailData: file.immediateThumbnailData, mimeType: file.mimeType, size: file.size, attributes: file.attributes, alternativeRepresentations: []) } else if let image = media as? TelegramMediaImage { return TelegramMediaImage(imageId: MediaId(namespace: Namespaces.Media.LocalImage, id: Int64.random(in: Int64.min ... Int64.max)), representations: image.representations, immediateThumbnailData: image.immediateThumbnailData, reference: image.reference, partialReference: image.partialReference, flags: []) } else { diff --git a/submodules/TelegramCore/Sources/PendingMessages/PendingMessageUploadedContent.swift b/submodules/TelegramCore/Sources/PendingMessages/PendingMessageUploadedContent.swift index 51bd80a51f..8346d55cb0 100644 --- a/submodules/TelegramCore/Sources/PendingMessages/PendingMessageUploadedContent.swift +++ b/submodules/TelegramCore/Sources/PendingMessages/PendingMessageUploadedContent.swift @@ -703,7 +703,7 @@ func inputDocumentAttributesFromFileAttributes(_ fileAttributes: [TelegramMediaF attributes.append(.documentAttributeSticker(flags: flags, alt: displayText, stickerset: stickerSet, maskCoords: inputMaskCoords)) case .HasLinkedStickers: attributes.append(.documentAttributeHasStickers) - case let .Video(duration, size, videoFlags, preloadSize, coverTime): + case let .Video(duration, size, videoFlags, preloadSize, coverTime, videoCodec): var flags: Int32 = 0 if videoFlags.contains(.instantRoundVideo) { flags |= (1 << 0) @@ -720,7 +720,10 @@ func inputDocumentAttributesFromFileAttributes(_ fileAttributes: [TelegramMediaF if let coverTime = coverTime, coverTime > 0.0 { flags |= (1 << 4) } - attributes.append(.documentAttributeVideo(flags: flags, duration: duration, w: Int32(size.width), h: Int32(size.height), preloadPrefixSize: preloadSize, videoStartTs: coverTime, videoCodec: nil)) + if videoCodec != nil { + flags |= (1 << 5) + } + attributes.append(.documentAttributeVideo(flags: flags, duration: duration, w: Int32(size.width), h: Int32(size.height), preloadPrefixSize: preloadSize, videoStartTs: coverTime, videoCodec: videoCodec)) case let .Audio(isVoice, duration, title, performer, waveform): var flags: Int32 = 0 if isVoice { @@ -790,7 +793,7 @@ public func statsCategoryForFileWithAttributes(_ attributes: [TelegramMediaFileA } else { return .audio } - case let .Video(_, _, flags, _, _): + case let .Video(_, _, flags, _, _, _): if flags.contains(TelegramMediaVideoFlags.instantRoundVideo) { return .voiceMessages } else { @@ -1065,8 +1068,8 @@ private func uploadedMediaFileContent(network: Network, postbox: Postbox, auxili |> mapError { _ -> PendingMessageUploadError in return .generic } |> mapToSignal { result -> Signal in switch result { - case let .messageMediaDocument(_, document, _, _): - if let document = document, let mediaFile = telegramMediaFileFromApiDocument(document), let resource = mediaFile.resource as? CloudDocumentMediaResource, let fileReference = resource.fileReference { + case let .messageMediaDocument(_, document, altDocuments, _): + if let document = document, let mediaFile = telegramMediaFileFromApiDocument(document, altDocuments: altDocuments), let resource = mediaFile.resource as? CloudDocumentMediaResource, let fileReference = resource.fileReference { var flags: Int32 = 0 var ttlSeconds: Int32? if let autoclearMessageAttribute = autoclearMessageAttribute { diff --git a/submodules/TelegramCore/Sources/PendingMessages/StandaloneUploadedMedia.swift b/submodules/TelegramCore/Sources/PendingMessages/StandaloneUploadedMedia.swift index 0479620b2d..2f99126412 100644 --- a/submodules/TelegramCore/Sources/PendingMessages/StandaloneUploadedMedia.swift +++ b/submodules/TelegramCore/Sources/PendingMessages/StandaloneUploadedMedia.swift @@ -162,9 +162,9 @@ public func standaloneUploadedFile(postbox: Postbox, network: Network, peerId: P |> mapError { _ -> StandaloneUploadMediaError in return .generic } |> mapToSignal { media -> Signal in switch media { - case let .messageMediaDocument(_, document, _, _): + case let .messageMediaDocument(_, document, altDocuments, _): if let document = document { - if let mediaFile = telegramMediaFileFromApiDocument(document) { + if let mediaFile = telegramMediaFileFromApiDocument(document, altDocuments: altDocuments) { return .single(.result(.media(.standalone(media: mediaFile)))) } } @@ -194,7 +194,7 @@ public func standaloneUploadedFile(postbox: Postbox, network: Network, peerId: P |> mapToSignal { result -> Signal in switch result { case let .encryptedFile(id, accessHash, size, dcId, _): - let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: SecretFileMediaResource(fileId: id, accessHash: accessHash, containerSize: size, decryptedSize: size, datacenterId: Int(dcId), key: key), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: mimeType, size: size, attributes: attributes) + let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: SecretFileMediaResource(fileId: id, accessHash: accessHash, containerSize: size, decryptedSize: size, datacenterId: Int(dcId), key: key), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: mimeType, size: size, attributes: attributes, alternativeRepresentations: []) return .single(.result(.media(.standalone(media: media)))) case .encryptedFileEmpty: diff --git a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift index e5b49e557c..ac6cdb6fc3 100644 --- a/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift +++ b/submodules/TelegramCore/Sources/State/AccountStateManagementUtils.swift @@ -4762,7 +4762,7 @@ func replayFinalState( timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: item.media, - alternativeMedia: item.alternativeMedia, + alternativeMediaList: item.alternativeMediaList, mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -4796,7 +4796,7 @@ func replayFinalState( timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: item.media, - alternativeMedia: item.alternativeMedia, + alternativeMediaList: item.alternativeMediaList, mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -4980,7 +4980,7 @@ func replayFinalState( } for apiDocument in documents { - if let file = telegramMediaFileFromApiDocument(apiDocument), let id = file.id { + if let file = telegramMediaFileFromApiDocument(apiDocument, altDocuments: []), let id = file.id { let fileIndexKeys: [MemoryBuffer] if let indexKeys = indexKeysByFile[id] { fileIndexKeys = indexKeys diff --git a/submodules/TelegramCore/Sources/State/AccountViewTracker.swift b/submodules/TelegramCore/Sources/State/AccountViewTracker.swift index 15d15fb579..9262048466 100644 --- a/submodules/TelegramCore/Sources/State/AccountViewTracker.swift +++ b/submodules/TelegramCore/Sources/State/AccountViewTracker.swift @@ -1196,7 +1196,7 @@ public final class AccountViewTracker { switch result { case let .stickerSet(_, _, _, documents)?: for document in documents { - if let file = telegramMediaFileFromApiDocument(document) { + if let file = telegramMediaFileFromApiDocument(document, altDocuments: []) { if transaction.getMedia(file.fileId) != nil { let _ = transaction.updateMedia(file.fileId, update: file) } diff --git a/submodules/TelegramCore/Sources/State/AvailableMessageEffects.swift b/submodules/TelegramCore/Sources/State/AvailableMessageEffects.swift index 5842b06a9b..8070509f5e 100644 --- a/submodules/TelegramCore/Sources/State/AvailableMessageEffects.swift +++ b/submodules/TelegramCore/Sources/State/AvailableMessageEffects.swift @@ -216,7 +216,7 @@ func managedSynchronizeAvailableMessageEffects(postbox: Postbox, network: Networ case let .availableEffects(hash, effects, documents): var files: [Int64: TelegramMediaFile] = [:] for document in documents { - if let file = telegramMediaFileFromApiDocument(document) { + if let file = telegramMediaFileFromApiDocument(document, altDocuments: []) { files[file.fileId.id] = file } } diff --git a/submodules/TelegramCore/Sources/State/AvailableReactions.swift b/submodules/TelegramCore/Sources/State/AvailableReactions.swift index d0a4f149a0..82e652ade4 100644 --- a/submodules/TelegramCore/Sources/State/AvailableReactions.swift +++ b/submodules/TelegramCore/Sources/State/AvailableReactions.swift @@ -22,7 +22,8 @@ private func generateStarsReactionFile(kind: Int, isAnimatedSticker: Bool) -> Te immediateThumbnailData: nil, mimeType: isAnimatedSticker ? "application/x-tgsticker" : "image/webp", size: nil, - attributes: attributes + attributes: attributes, + alternativeRepresentations: [] ) } @@ -261,23 +262,23 @@ private extension AvailableReactions.Reaction { convenience init?(apiReaction: Api.AvailableReaction) { switch apiReaction { case let .availableReaction(flags, reaction, title, staticIcon, appearAnimation, selectAnimation, activateAnimation, effectAnimation, aroundAnimation, centerIcon): - guard let staticIconFile = telegramMediaFileFromApiDocument(staticIcon) else { + guard let staticIconFile = telegramMediaFileFromApiDocument(staticIcon, altDocuments: []) else { return nil } - guard let appearAnimationFile = telegramMediaFileFromApiDocument(appearAnimation) else { + guard let appearAnimationFile = telegramMediaFileFromApiDocument(appearAnimation, altDocuments: []) else { return nil } - guard let selectAnimationFile = telegramMediaFileFromApiDocument(selectAnimation) else { + guard let selectAnimationFile = telegramMediaFileFromApiDocument(selectAnimation, altDocuments: []) else { return nil } - guard let activateAnimationFile = telegramMediaFileFromApiDocument(activateAnimation) else { + guard let activateAnimationFile = telegramMediaFileFromApiDocument(activateAnimation, altDocuments: []) else { return nil } - guard let effectAnimationFile = telegramMediaFileFromApiDocument(effectAnimation) else { + guard let effectAnimationFile = telegramMediaFileFromApiDocument(effectAnimation, altDocuments: []) else { return nil } - let aroundAnimationFile = aroundAnimation.flatMap { telegramMediaFileFromApiDocument($0) } - let centerAnimationFile = centerIcon.flatMap { telegramMediaFileFromApiDocument($0) } + let aroundAnimationFile = aroundAnimation.flatMap { telegramMediaFileFromApiDocument($0, altDocuments: []) } + let centerAnimationFile = centerIcon.flatMap { telegramMediaFileFromApiDocument($0, altDocuments: []) } let isEnabled = (flags & (1 << 0)) == 0 let isPremium = (flags & (1 << 2)) != 0 self.init( diff --git a/submodules/TelegramCore/Sources/State/Holes.swift b/submodules/TelegramCore/Sources/State/Holes.swift index 2eff15a2db..30b75e8580 100644 --- a/submodules/TelegramCore/Sources/State/Holes.swift +++ b/submodules/TelegramCore/Sources/State/Holes.swift @@ -170,7 +170,7 @@ func resolveUnknownEmojiFiles(postbox: Postbox, source: FetchMessageHistoryHo for documentSet in documentSets { if let documentSet = documentSet { for document in documentSet { - if let file = telegramMediaFileFromApiDocument(document) { + if let file = telegramMediaFileFromApiDocument(document, altDocuments: []) { transaction.storeMediaIfNotPresent(media: file) } } diff --git a/submodules/TelegramCore/Sources/State/ManagedPremiumPromoConfigurationUpdates.swift b/submodules/TelegramCore/Sources/State/ManagedPremiumPromoConfigurationUpdates.swift index 7c26efb038..9f1018bef7 100644 --- a/submodules/TelegramCore/Sources/State/ManagedPremiumPromoConfigurationUpdates.swift +++ b/submodules/TelegramCore/Sources/State/ManagedPremiumPromoConfigurationUpdates.swift @@ -65,7 +65,7 @@ private extension PremiumPromoConfiguration { var videos: [String: TelegramMediaFile] = [:] for (key, document) in zip(videoSections, videoFiles) { - if let file = telegramMediaFileFromApiDocument(document) { + if let file = telegramMediaFileFromApiDocument(document, altDocuments: []) { videos[key] = file } } diff --git a/submodules/TelegramCore/Sources/State/ManagedRecentStickers.swift b/submodules/TelegramCore/Sources/State/ManagedRecentStickers.swift index 8e48ce953d..8974f82500 100644 --- a/submodules/TelegramCore/Sources/State/ManagedRecentStickers.swift +++ b/submodules/TelegramCore/Sources/State/ManagedRecentStickers.swift @@ -53,7 +53,7 @@ func managedRecentStickers(postbox: Postbox, network: Network, forceFetch: Bool case let .recentStickers(_, _, stickers, _): var items: [OrderedItemListEntry] = [] for sticker in stickers { - if let file = telegramMediaFileFromApiDocument(sticker), let id = file.id { + if let file = telegramMediaFileFromApiDocument(sticker, altDocuments: []), let id = file.id { if let entry = CodableEntry(RecentMediaItem(file)) { items.append(OrderedItemListEntry(id: RecentMediaItemId(id).rawValue, contents: entry)) } @@ -76,7 +76,7 @@ func managedRecentGifs(postbox: Postbox, network: Network, forceFetch: Bool = fa case let .savedGifs(_, gifs): var items: [OrderedItemListEntry] = [] for gif in gifs { - if let file = telegramMediaFileFromApiDocument(gif), let id = file.id { + if let file = telegramMediaFileFromApiDocument(gif, altDocuments: []), let id = file.id { if let entry = CodableEntry(RecentMediaItem(file)) { items.append(OrderedItemListEntry(id: RecentMediaItemId(id).rawValue, contents: entry)) } @@ -114,7 +114,7 @@ func managedSavedStickers(postbox: Postbox, network: Network, forceFetch: Bool = var items: [OrderedItemListEntry] = [] for sticker in stickers { - if let file = telegramMediaFileFromApiDocument(sticker), let id = file.id { + if let file = telegramMediaFileFromApiDocument(sticker, altDocuments: []), let id = file.id { var stringRepresentations: [String] = [] if let representations = fileStringRepresentations[id] { stringRepresentations = representations @@ -141,7 +141,7 @@ func managedGreetingStickers(postbox: Postbox, network: Network) -> Signal Signal Signal { } } + public func withMedia(_ media: T) -> MediaReference { + switch self { + case .standalone: + return .standalone(media: media) + case let .message(message, _): + return .message(message: message, media: media) + case let .webPage(webPage, _): + return .webPage(webPage: webPage, media: media) + case let .stickerPack(stickerPack, _): + return .stickerPack(stickerPack: stickerPack, media: media) + case .savedGif: + return .savedGif(media: media) + case .savedSticker: + return .savedSticker(media: media) + case .recentSticker: + return .recentSticker(media: media) + case let .avatarList(peer, _): + return .avatarList(peer: peer, media: media) + case let .attachBot(peer, _): + return .attachBot(peer: peer, media: media) + case .customEmoji: + return .customEmoji(media: media) + case let .story(peer, id, _): + return .story(peer: peer, id: id, media: media) + case let .starsTransaction(transaction, _): + return .starsTransaction(transaction: transaction, media: media) + } + } + public func resourceReference(_ resource: MediaResource) -> MediaResourceReference { return .media(media: self.abstract, resource: resource) } diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaFile.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaFile.swift index aaddf2debb..4e0c73441d 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaFile.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramMediaFile.swift @@ -235,7 +235,7 @@ public enum TelegramMediaFileAttribute: PostboxCoding, Equatable { case Sticker(displayText: String, packReference: StickerPackReference?, maskData: StickerMaskCoords?) case ImageSize(size: PixelDimensions) case Animated - case Video(duration: Double, size: PixelDimensions, flags: TelegramMediaVideoFlags, preloadSize: Int32?, coverTime: Double?) + case Video(duration: Double, size: PixelDimensions, flags: TelegramMediaVideoFlags, preloadSize: Int32?, coverTime: Double?, videoCodec: String?) case Audio(isVoice: Bool, duration: Int, title: String?, performer: String?, waveform: Data?) case HasLinkedStickers case hintFileIsLarge @@ -262,7 +262,7 @@ public enum TelegramMediaFileAttribute: PostboxCoding, Equatable { duration = Double(decoder.decodeInt32ForKey("du", orElse: 0)) } - self = .Video(duration: duration, size: PixelDimensions(width: decoder.decodeInt32ForKey("w", orElse: 0), height: decoder.decodeInt32ForKey("h", orElse: 0)), flags: TelegramMediaVideoFlags(rawValue: decoder.decodeInt32ForKey("f", orElse: 0)), preloadSize: decoder.decodeOptionalInt32ForKey("prs"), coverTime: decoder.decodeOptionalDoubleForKey("ct")) + self = .Video(duration: duration, size: PixelDimensions(width: decoder.decodeInt32ForKey("w", orElse: 0), height: decoder.decodeInt32ForKey("h", orElse: 0)), flags: TelegramMediaVideoFlags(rawValue: decoder.decodeInt32ForKey("f", orElse: 0)), preloadSize: decoder.decodeOptionalInt32ForKey("prs"), coverTime: decoder.decodeOptionalDoubleForKey("ct"), videoCodec: decoder.decodeOptionalStringForKey("vc")) case typeAudio: let waveformBuffer = decoder.decodeBytesForKeyNoCopy("wf") var waveform: Data? @@ -309,7 +309,7 @@ public enum TelegramMediaFileAttribute: PostboxCoding, Equatable { encoder.encodeInt32(Int32(size.height), forKey: "h") case .Animated: encoder.encodeInt32(typeAnimated, forKey: "t") - case let .Video(duration, size, flags, preloadSize, coverTime): + case let .Video(duration, size, flags, preloadSize, coverTime, videoCodec): encoder.encodeInt32(typeVideo, forKey: "t") encoder.encodeDouble(duration, forKey: "dur") encoder.encodeInt32(Int32(size.width), forKey: "w") @@ -325,6 +325,11 @@ public enum TelegramMediaFileAttribute: PostboxCoding, Equatable { } else { encoder.encodeNil(forKey: "ct") } + if let videoCodec { + encoder.encodeString(videoCodec, forKey: "vc") + } else { + encoder.encodeNil(forKey: "vc") + } case let .Audio(isVoice, duration, title, performer, waveform): encoder.encodeInt32(typeAudio, forKey: "t") encoder.encodeInt32(isVoice ? 1 : 0, forKey: "iv") @@ -440,6 +445,7 @@ public final class TelegramMediaFile: Media, Equatable, Codable { public let mimeType: String public let size: Int64? public let attributes: [TelegramMediaFileAttribute] + public let alternativeRepresentations: [Media] public let peerIds: [PeerId] = [] public var id: MediaId? { @@ -459,7 +465,7 @@ public final class TelegramMediaFile: Media, Equatable, Codable { return result.isEmpty ? nil : result } - public init(fileId: MediaId, partialReference: PartialMediaReference?, resource: TelegramMediaResource, previewRepresentations: [TelegramMediaImageRepresentation], videoThumbnails: [TelegramMediaFile.VideoThumbnail], immediateThumbnailData: Data?, mimeType: String, size: Int64?, attributes: [TelegramMediaFileAttribute]) { + public init(fileId: MediaId, partialReference: PartialMediaReference?, resource: TelegramMediaResource, previewRepresentations: [TelegramMediaImageRepresentation], videoThumbnails: [TelegramMediaFile.VideoThumbnail], immediateThumbnailData: Data?, mimeType: String, size: Int64?, attributes: [TelegramMediaFileAttribute], alternativeRepresentations: [Media]) { self.fileId = fileId self.partialReference = partialReference self.resource = resource @@ -469,6 +475,7 @@ public final class TelegramMediaFile: Media, Equatable, Codable { self.mimeType = mimeType self.size = size self.attributes = attributes + self.alternativeRepresentations = alternativeRepresentations } public init(decoder: PostboxDecoder) { @@ -487,6 +494,13 @@ public final class TelegramMediaFile: Media, Equatable, Codable { self.size = nil } self.attributes = decoder.decodeObjectArrayForKey("at") + if let altMedia = try? decoder.decodeObjectArrayWithCustomDecoderForKey("arep", decoder: { d in + return d.decodeRootObject() as! Media + }) { + self.alternativeRepresentations = altMedia + } else { + self.alternativeRepresentations = [] + } } public func encode(_ encoder: PostboxEncoder) { @@ -513,6 +527,9 @@ public final class TelegramMediaFile: Media, Equatable, Codable { encoder.encodeNil(forKey: "s64") } encoder.encodeObjectArray(self.attributes, forKey: "at") + encoder.encodeObjectArrayWithEncoder(self.alternativeRepresentations, forKey: "arep", encoder: { v, e in + e.encodeRootObject(v) + }) } public required init(from decoder: Decoder) throws { @@ -531,6 +548,7 @@ public final class TelegramMediaFile: Media, Equatable, Codable { self.mimeType = object.mimeType self.size = object.size self.attributes = object.attributes + self.alternativeRepresentations = object.alternativeRepresentations } public func encode(to encoder: Encoder) throws { @@ -597,7 +615,7 @@ public final class TelegramMediaFile: Media, Equatable, Codable { public var isInstantVideo: Bool { for attribute in self.attributes { - if case .Video(_, _, let flags, _, _) = attribute { + if case .Video(_, _, let flags, _, _, _) = attribute { return flags.contains(.instantRoundVideo) } } @@ -606,7 +624,7 @@ public final class TelegramMediaFile: Media, Equatable, Codable { public var preloadSize: Int32? { for attribute in self.attributes { - if case .Video(_, _, _, let preloadSize, _) = attribute { + if case .Video(_, _, _, let preloadSize, _, _) = attribute { return preloadSize } } @@ -803,6 +821,10 @@ public final class TelegramMediaFile: Media, Equatable, Codable { return false } + if !areMediaArraysEqual(self.alternativeRepresentations, other.alternativeRepresentations) { + return false + } + return true } @@ -849,27 +871,31 @@ public final class TelegramMediaFile: Media, Equatable, Codable { return false } + if !areMediaArraysSemanticallyEqual(self.alternativeRepresentations, other.alternativeRepresentations) { + return false + } + return true } public func withUpdatedPartialReference(_ partialReference: PartialMediaReference?) -> TelegramMediaFile { - return TelegramMediaFile(fileId: self.fileId, partialReference: partialReference, resource: self.resource, previewRepresentations: self.previewRepresentations, videoThumbnails: self.videoThumbnails, immediateThumbnailData: self.immediateThumbnailData, mimeType: self.mimeType, size: self.size, attributes: self.attributes) + return TelegramMediaFile(fileId: self.fileId, partialReference: partialReference, resource: self.resource, previewRepresentations: self.previewRepresentations, videoThumbnails: self.videoThumbnails, immediateThumbnailData: self.immediateThumbnailData, mimeType: self.mimeType, size: self.size, attributes: self.attributes, alternativeRepresentations: self.alternativeRepresentations) } public func withUpdatedResource(_ resource: TelegramMediaResource) -> TelegramMediaFile { - return TelegramMediaFile(fileId: self.fileId, partialReference: self.partialReference, resource: resource, previewRepresentations: self.previewRepresentations, videoThumbnails: self.videoThumbnails, immediateThumbnailData: self.immediateThumbnailData, mimeType: self.mimeType, size: self.size, attributes: self.attributes) + return TelegramMediaFile(fileId: self.fileId, partialReference: self.partialReference, resource: resource, previewRepresentations: self.previewRepresentations, videoThumbnails: self.videoThumbnails, immediateThumbnailData: self.immediateThumbnailData, mimeType: self.mimeType, size: self.size, attributes: self.attributes, alternativeRepresentations: self.alternativeRepresentations) } public func withUpdatedSize(_ size: Int64?) -> TelegramMediaFile { - return TelegramMediaFile(fileId: self.fileId, partialReference: self.partialReference, resource: self.resource, previewRepresentations: self.previewRepresentations, videoThumbnails: self.videoThumbnails, immediateThumbnailData: self.immediateThumbnailData, mimeType: self.mimeType, size: size, attributes: self.attributes) + return TelegramMediaFile(fileId: self.fileId, partialReference: self.partialReference, resource: self.resource, previewRepresentations: self.previewRepresentations, videoThumbnails: self.videoThumbnails, immediateThumbnailData: self.immediateThumbnailData, mimeType: self.mimeType, size: size, attributes: self.attributes, alternativeRepresentations: self.alternativeRepresentations) } public func withUpdatedPreviewRepresentations(_ previewRepresentations: [TelegramMediaImageRepresentation]) -> TelegramMediaFile { - return TelegramMediaFile(fileId: self.fileId, partialReference: self.partialReference, resource: self.resource, previewRepresentations: previewRepresentations, videoThumbnails: self.videoThumbnails, immediateThumbnailData: self.immediateThumbnailData, mimeType: self.mimeType, size: self.size, attributes: self.attributes) + return TelegramMediaFile(fileId: self.fileId, partialReference: self.partialReference, resource: self.resource, previewRepresentations: previewRepresentations, videoThumbnails: self.videoThumbnails, immediateThumbnailData: self.immediateThumbnailData, mimeType: self.mimeType, size: self.size, attributes: self.attributes, alternativeRepresentations: self.alternativeRepresentations) } public func withUpdatedAttributes(_ attributes: [TelegramMediaFileAttribute]) -> TelegramMediaFile { - return TelegramMediaFile(fileId: self.fileId, partialReference: self.partialReference, resource: self.resource, previewRepresentations: self.previewRepresentations, videoThumbnails: self.videoThumbnails, immediateThumbnailData: self.immediateThumbnailData, mimeType: self.mimeType, size: self.size, attributes: attributes) + return TelegramMediaFile(fileId: self.fileId, partialReference: self.partialReference, resource: self.resource, previewRepresentations: self.previewRepresentations, videoThumbnails: self.videoThumbnails, immediateThumbnailData: self.immediateThumbnailData, mimeType: self.mimeType, size: self.size, attributes: attributes, alternativeRepresentations: self.alternativeRepresentations) } } diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramWallpaper.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramWallpaper.swift index 5c0518226e..0872978f07 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramWallpaper.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_TelegramWallpaper.swift @@ -179,7 +179,7 @@ public struct TelegramWallpaperNativeCodable: Codable { public enum TelegramWallpaper: Equatable { public static func emoticonWallpaper(emoticon: String) -> TelegramWallpaper { - return .file(File(id: -1, accessHash: -1, isCreator: false, isDefault: false, isPattern: false, isDark: false, slug: "", file: TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: EmptyMediaResource(), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "", size: nil, attributes: []), settings: WallpaperSettings(emoticon: emoticon))) + return .file(File(id: -1, accessHash: -1, isCreator: false, isDefault: false, isPattern: false, isDark: false, slug: "", file: TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: EmptyMediaResource(), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "", size: nil, attributes: [], alternativeRepresentations: []), settings: WallpaperSettings(emoticon: emoticon))) } public struct Gradient: Equatable { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/AttachMenuBots.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/AttachMenuBots.swift index 0123ff375c..d353439b2c 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/AttachMenuBots.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/AttachMenuBots.swift @@ -309,7 +309,7 @@ func managedSynchronizeAttachMenuBots(accountPeerId: PeerId, postbox: Postbox, n for icon in botIcons { switch icon { case let .attachMenuBotIcon(_, name, icon, _): - if let iconName = AttachMenuBots.Bot.IconName(string: name), let icon = telegramMediaFileFromApiDocument(icon) { + if let iconName = AttachMenuBots.Bot.IconName(string: name), let icon = telegramMediaFileFromApiDocument(icon, altDocuments: []) { icons[iconName] = icon } } @@ -544,7 +544,7 @@ func _internal_getAttachMenuBot(accountPeerId: PeerId, postbox: Postbox, network for icon in botIcons { switch icon { case let .attachMenuBotIcon(_, name, icon, _): - if let iconName = AttachMenuBots.Bot.IconName(string: name), let icon = telegramMediaFileFromApiDocument(icon) { + if let iconName = AttachMenuBots.Bot.IconName(string: name), let icon = telegramMediaFileFromApiDocument(icon, altDocuments: []) { icons[iconName] = icon } } @@ -755,7 +755,7 @@ func _internal_getBotApp(account: Account, reference: BotAppReference) -> Signal if (botAppFlags & (1 << 2)) != 0 { appFlags.insert(.hasSettings) } - return .single(BotApp(id: id, accessHash: accessHash, shortName: shortName, title: title, description: description, photo: telegramMediaImageFromApiPhoto(photo), document: document.flatMap(telegramMediaFileFromApiDocument), hash: hash, flags: appFlags)) + return .single(BotApp(id: id, accessHash: accessHash, shortName: shortName, title: title, description: description, photo: telegramMediaImageFromApiPhoto(photo), document: document.flatMap { telegramMediaFileFromApiDocument($0, altDocuments: []) }, hash: hash, flags: appFlags)) case .botAppNotModified: return .complete() } @@ -770,7 +770,7 @@ extension BotApp { convenience init?(apiBotApp: Api.BotApp) { switch apiBotApp { case let .botApp(_, id, accessHash, shortName, title, description, photo, document, hash): - self.init(id: id, accessHash: accessHash, shortName: shortName, title: title, description: description, photo: telegramMediaImageFromApiPhoto(photo), document: document.flatMap(telegramMediaFileFromApiDocument), hash: hash, flags: []) + self.init(id: id, accessHash: accessHash, shortName: shortName, title: title, description: description, photo: telegramMediaImageFromApiPhoto(photo), document: document.flatMap { telegramMediaFileFromApiDocument($0, altDocuments: []) }, hash: hash, flags: []) case .botAppNotModified: return nil } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/EngineStoryViewListContext.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/EngineStoryViewListContext.swift index 523354bb0f..efcd222dd5 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/EngineStoryViewListContext.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/EngineStoryViewListContext.swift @@ -530,7 +530,7 @@ public final class EngineStoryViewListContext { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: item.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -575,7 +575,7 @@ public final class EngineStoryViewListContext { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: item.media, - alternativeMedia: item.alternativeMedia, + alternativeMediaList: item.alternativeMediaList, mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -615,7 +615,7 @@ public final class EngineStoryViewListContext { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: item.media, - alternativeMedia: item.alternativeMedia, + alternativeMediaList: item.alternativeMediaList, mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -727,7 +727,7 @@ public final class EngineStoryViewListContext { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: item.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/OutgoingMessageWithChatContextResult.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/OutgoingMessageWithChatContextResult.swift index 019afadf9e..c93f33e990 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/OutgoingMessageWithChatContextResult.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/OutgoingMessageWithChatContextResult.swift @@ -118,7 +118,7 @@ func _internal_outgoingMessageWithChatContextResult(to peerId: PeerId, threadId: if let dimensions = externalReference.content?.dimensions { fileAttributes.append(.ImageSize(size: dimensions)) if externalReference.type == "gif" { - fileAttributes.append(.Video(duration: externalReference.content?.duration ?? 0.0, size: dimensions, flags: [], preloadSize: nil, coverTime: nil)) + fileAttributes.append(.Video(duration: externalReference.content?.duration ?? 0.0, size: dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)) } } @@ -136,7 +136,7 @@ func _internal_outgoingMessageWithChatContextResult(to peerId: PeerId, threadId: resource = EmptyMediaResource() } - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: videoThumbnails, immediateThumbnailData: nil, mimeType: externalReference.content?.mimeType ?? "application/binary", size: nil, attributes: fileAttributes) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: videoThumbnails, immediateThumbnailData: nil, mimeType: externalReference.content?.mimeType ?? "application/binary", size: nil, attributes: fileAttributes, alternativeRepresentations: []) return .message(text: caption, attributes: attributes, inlineStickers: [:], mediaReference: .standalone(media: file), threadId: threadId, replyToMessageId: replyToMessageId, replyToStoryId: replyToStoryId, localGroupingKey: nil, correlationId: correlationId, bubbleUpEmojiOrStickersets: []) } else { return .message(text: caption, attributes: attributes, inlineStickers: [:], mediaReference: nil, threadId: threadId, replyToMessageId: replyToMessageId, replyToStoryId: replyToStoryId, localGroupingKey: nil, correlationId: correlationId, bubbleUpEmojiOrStickersets: []) diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/QuickReplyMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/QuickReplyMessages.swift index 989ff1ddd8..f282a1ad39 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/QuickReplyMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/QuickReplyMessages.swift @@ -742,7 +742,7 @@ extension TelegramBusinessIntro { convenience init(apiBusinessIntro: Api.BusinessIntro) { switch apiBusinessIntro { case let .businessIntro(_, title, description, sticker): - self.init(title: title, text: description, stickerFile: sticker.flatMap(telegramMediaFileFromApiDocument)) + self.init(title: title, text: description, stickerFile: sticker.flatMap { telegramMediaFileFromApiDocument($0, altDocuments: []) }) } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift index db9cfc0cbf..2336fd44b7 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/Stories.swift @@ -245,6 +245,7 @@ public enum Stories { case expirationTimestamp case media case alternativeMedia + case alternativeMediaList case mediaAreas case text case entities @@ -268,7 +269,7 @@ public enum Stories { public let timestamp: Int32 public let expirationTimestamp: Int32 public let media: Media? - public let alternativeMedia: Media? + public let alternativeMediaList: [Media] public let mediaAreas: [MediaArea] public let text: String public let entities: [MessageTextEntity] @@ -292,7 +293,7 @@ public enum Stories { timestamp: Int32, expirationTimestamp: Int32, media: Media?, - alternativeMedia: Media?, + alternativeMediaList: [Media], mediaAreas: [MediaArea], text: String, entities: [MessageTextEntity], @@ -315,7 +316,7 @@ public enum Stories { self.timestamp = timestamp self.expirationTimestamp = expirationTimestamp self.media = media - self.alternativeMedia = alternativeMedia + self.alternativeMediaList = alternativeMediaList self.mediaAreas = mediaAreas self.text = text self.entities = entities @@ -348,10 +349,18 @@ public enum Stories { self.media = nil } - if let alternativeMediaData = try container.decodeIfPresent(Data.self, forKey: .alternativeMedia) { - self.alternativeMedia = PostboxDecoder(buffer: MemoryBuffer(data: alternativeMediaData)).decodeRootObject() as? Media + if let alternativeMediaListData = try container.decodeIfPresent([Data].self, forKey: .alternativeMediaList) { + self.alternativeMediaList = alternativeMediaListData.compactMap { data -> Media? in + return PostboxDecoder(buffer: MemoryBuffer(data: data)).decodeRootObject() as? Media + } + } else if let alternativeMediaData = try container.decodeIfPresent(Data.self, forKey: .alternativeMedia) { + if let value = PostboxDecoder(buffer: MemoryBuffer(data: alternativeMediaData)).decodeRootObject() as? Media { + self.alternativeMediaList = [value] + } else { + self.alternativeMediaList = [] + } } else { - self.alternativeMedia = nil + self.alternativeMediaList = [] } self.mediaAreas = try container.decodeIfPresent([MediaArea].self, forKey: .mediaAreas) ?? [] @@ -388,12 +397,12 @@ public enum Stories { try container.encode(mediaData, forKey: .media) } - if let alternativeMedia = self.alternativeMedia { + let alternativeMediaListData = self.alternativeMediaList.map { alternativeMediaValue -> Data in let encoder = PostboxEncoder() - encoder.encodeRootObject(alternativeMedia) - let alternativeMediaData = encoder.makeData() - try container.encode(alternativeMediaData, forKey: .alternativeMedia) + encoder.encodeRootObject(alternativeMediaValue) + return encoder.makeData() } + try container.encode(alternativeMediaListData, forKey: .alternativeMediaList) try container.encode(self.mediaAreas, forKey: .mediaAreas) @@ -436,14 +445,8 @@ public enum Stories { } } - if let lhsAlternativeMedia = lhs.alternativeMedia, let rhsAlternativeMedia = rhs.alternativeMedia { - if !lhsAlternativeMedia.isEqual(to: rhsAlternativeMedia) { - return false - } - } else { - if (lhs.alternativeMedia == nil) != (rhs.alternativeMedia == nil) { - return false - } + if !areMediaArraysEqual(lhs.alternativeMediaList, rhs.alternativeMediaList) { + return false } if lhs.mediaAreas != rhs.mediaAreas { @@ -871,8 +874,9 @@ private func prepareUploadStoryContent(account: Account, media: EngineStoryInput mimeType: "video/mp4", size: nil, attributes: [ - TelegramMediaFileAttribute.Video(duration: duration, size: dimensions, flags: .supportsStreaming, preloadSize: nil, coverTime: coverTime) - ] + TelegramMediaFileAttribute.Video(duration: duration, size: dimensions, flags: .supportsStreaming, preloadSize: nil, coverTime: coverTime, videoCodec: nil) + ], + alternativeRepresentations: [] ) return fileMedia @@ -1209,7 +1213,7 @@ func _internal_uploadStoryImpl( timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: item.media, - alternativeMedia: item.alternativeMedia, + alternativeMediaList: item.alternativeMediaList, mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -1617,7 +1621,7 @@ func _internal_editStoryPrivacy(account: Account, id: Int32, privacy: EngineStor timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: item.media, - alternativeMedia: item.alternativeMedia, + alternativeMediaList: item.alternativeMediaList, mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -1649,7 +1653,7 @@ func _internal_editStoryPrivacy(account: Account, id: Int32, privacy: EngineStor timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: item.media, - alternativeMedia: item.alternativeMedia, + alternativeMediaList: item.alternativeMediaList, mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -1846,7 +1850,7 @@ func _internal_updateStoriesArePinned(account: Account, peerId: PeerId, ids: [In timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: item.media, - alternativeMedia: item.alternativeMedia, + alternativeMediaList: item.alternativeMediaList, mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -1877,7 +1881,7 @@ func _internal_updateStoriesArePinned(account: Account, peerId: PeerId, ids: [In timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: item.media, - alternativeMedia: item.alternativeMedia, + alternativeMediaList: item.alternativeMediaList, mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -2093,11 +2097,11 @@ extension Stories.StoredItem { mergedForwardInfo = forwardFrom.flatMap(Stories.Item.ForwardInfo.init(apiForwardInfo:)) } - var parsedAlternativeMedia: Media? + var parsedAlternativeMedia: [Media] = [] switch media { - case let .messageMediaDocument(_, _, altDocument, _): - if let altDocument = altDocument?.first { - parsedAlternativeMedia = telegramMediaFileFromApiDocument(altDocument) + case let .messageMediaDocument(_, _, altDocuments, _): + if let altDocuments { + parsedAlternativeMedia = altDocuments.compactMap { telegramMediaFileFromApiDocument($0, altDocuments: []) } } default: break @@ -2108,7 +2112,7 @@ extension Stories.StoredItem { timestamp: date, expirationTimestamp: expireDate, media: parsedMedia, - alternativeMedia: parsedAlternativeMedia, + alternativeMediaList: parsedAlternativeMedia, mediaAreas: mediaAreas?.compactMap(mediaAreaFromApiMediaArea) ?? [], text: caption ?? "", entities: entities.flatMap { entities in return messageTextEntitiesFromApiEntities(entities) } ?? [], @@ -2173,7 +2177,7 @@ func _internal_getStoryById(accountPeerId: PeerId, postbox: Postbox, network: Ne timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: item.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -2656,7 +2660,7 @@ func _internal_setStoryReaction(account: Account, peerId: EnginePeer.Id, id: Int timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: item.media, - alternativeMedia: item.alternativeMedia, + alternativeMediaList: item.alternativeMediaList, mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -2690,7 +2694,7 @@ func _internal_setStoryReaction(account: Account, peerId: EnginePeer.Id, id: Int timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: item.media, - alternativeMedia: item.alternativeMedia, + alternativeMediaList: item.alternativeMediaList, mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift index e22036f50a..77b650f91e 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/StoryListContext.swift @@ -67,7 +67,7 @@ public final class EngineStoryItem: Equatable { public let timestamp: Int32 public let expirationTimestamp: Int32 public let media: EngineMedia - public let alternativeMedia: EngineMedia? + public let alternativeMediaList: [EngineMedia] public let mediaAreas: [MediaArea] public let text: String public let entities: [MessageTextEntity] @@ -87,12 +87,12 @@ public final class EngineStoryItem: Equatable { public let forwardInfo: ForwardInfo? public let author: EnginePeer? - public init(id: Int32, timestamp: Int32, expirationTimestamp: Int32, media: EngineMedia, alternativeMedia: EngineMedia?, mediaAreas: [MediaArea], text: String, entities: [MessageTextEntity], views: Views?, privacy: EngineStoryPrivacy?, isPinned: Bool, isExpired: Bool, isPublic: Bool, isPending: Bool, isCloseFriends: Bool, isContacts: Bool, isSelectedContacts: Bool, isForwardingDisabled: Bool, isEdited: Bool, isMy: Bool, myReaction: MessageReaction.Reaction?, forwardInfo: ForwardInfo?, author: EnginePeer?) { + public init(id: Int32, timestamp: Int32, expirationTimestamp: Int32, media: EngineMedia, alternativeMediaList: [EngineMedia], mediaAreas: [MediaArea], text: String, entities: [MessageTextEntity], views: Views?, privacy: EngineStoryPrivacy?, isPinned: Bool, isExpired: Bool, isPublic: Bool, isPending: Bool, isCloseFriends: Bool, isContacts: Bool, isSelectedContacts: Bool, isForwardingDisabled: Bool, isEdited: Bool, isMy: Bool, myReaction: MessageReaction.Reaction?, forwardInfo: ForwardInfo?, author: EnginePeer?) { self.id = id self.timestamp = timestamp self.expirationTimestamp = expirationTimestamp self.media = media - self.alternativeMedia = alternativeMedia + self.alternativeMediaList = alternativeMediaList self.mediaAreas = mediaAreas self.text = text self.entities = entities @@ -126,7 +126,7 @@ public final class EngineStoryItem: Equatable { if lhs.media != rhs.media { return false } - if lhs.alternativeMedia != rhs.alternativeMedia { + if lhs.alternativeMediaList != rhs.alternativeMediaList { return false } if lhs.mediaAreas != rhs.mediaAreas { @@ -205,7 +205,7 @@ public extension EngineStoryItem { timestamp: self.timestamp, expirationTimestamp: self.expirationTimestamp, media: self.media._asMedia(), - alternativeMedia: self.alternativeMedia?._asMedia(), + alternativeMediaList: self.alternativeMediaList.map { $0._asMedia() }, mediaAreas: self.mediaAreas, text: self.text, entities: self.entities, @@ -670,7 +670,7 @@ public final class PeerStoryListContext: StoryListContext { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: item.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -839,7 +839,7 @@ public final class PeerStoryListContext: StoryListContext { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: item.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -1013,7 +1013,7 @@ public final class PeerStoryListContext: StoryListContext { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: item.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -1062,7 +1062,7 @@ public final class PeerStoryListContext: StoryListContext { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: item.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -1113,7 +1113,7 @@ public final class PeerStoryListContext: StoryListContext { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: item.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -1170,7 +1170,7 @@ public final class PeerStoryListContext: StoryListContext { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: item.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -1416,7 +1416,7 @@ public final class SearchStoryListContext: StoryListContext { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: item.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -1565,7 +1565,7 @@ public final class SearchStoryListContext: StoryListContext { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: item.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -1637,7 +1637,7 @@ public final class SearchStoryListContext: StoryListContext { timestamp: item.storyItem.timestamp, expirationTimestamp: item.storyItem.expirationTimestamp, media: item.storyItem.media, - alternativeMedia: item.storyItem.alternativeMedia, + alternativeMediaList: item.storyItem.alternativeMediaList, mediaAreas: item.storyItem.mediaAreas, text: item.storyItem.text, entities: item.storyItem.entities, @@ -1755,7 +1755,7 @@ public final class PeerExpiringStoryListContext { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: item.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -2211,7 +2211,7 @@ public final class BotPreviewStoryListContext: StoryListContext { timestamp: 0, expirationTimestamp: Int32.max, media: EngineMedia(item.media), - alternativeMedia: nil, + alternativeMediaList: [], mediaAreas: [], text: "", entities: [], @@ -2260,7 +2260,7 @@ public final class BotPreviewStoryListContext: StoryListContext { timestamp: item.timestamp, expirationTimestamp: Int32.max, media: EngineMedia(item.media), - alternativeMedia: nil, + alternativeMediaList: [], mediaAreas: [], text: "", entities: [], @@ -2371,7 +2371,7 @@ public final class BotPreviewStoryListContext: StoryListContext { timestamp: item.timestamp, expirationTimestamp: Int32.max, media: EngineMedia(item.media), - alternativeMedia: nil, + alternativeMediaList: [], mediaAreas: [], text: "", entities: [], @@ -2447,7 +2447,7 @@ public final class BotPreviewStoryListContext: StoryListContext { timestamp: 0, expirationTimestamp: Int32.max, media: EngineMedia(item.media), - alternativeMedia: nil, + alternativeMediaList: [], mediaAreas: [], text: "", entities: [], @@ -2510,7 +2510,7 @@ public final class BotPreviewStoryListContext: StoryListContext { timestamp: item.timestamp, expirationTimestamp: Int32.max, media: EngineMedia(item.media), - alternativeMedia: nil, + alternativeMediaList: [], mediaAreas: [], text: "", entities: [], diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift index 6378ef5988..22de0f6824 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Messages/TelegramEngineMessages.swift @@ -1235,8 +1235,8 @@ public extension TelegramEngine { } var selectedMedia: EngineMedia - if let alternativeMedia = itemAndPeer.item.alternativeMedia.flatMap(EngineMedia.init), (!preferHighQuality && !itemAndPeer.item.isMy) { - selectedMedia = alternativeMedia + if let alternativeMediaValue = itemAndPeer.item.alternativeMediaList.first.flatMap(EngineMedia.init), (!preferHighQuality && !itemAndPeer.item.isMy) { + selectedMedia = alternativeMediaValue } else { selectedMedia = EngineMedia(media) } @@ -1277,7 +1277,7 @@ public extension TelegramEngine { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: item.media, - alternativeMedia: item.alternativeMedia, + alternativeMediaList: item.alternativeMediaList, mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Peers/NotificationSoundList.swift b/submodules/TelegramCore/Sources/TelegramEngine/Peers/NotificationSoundList.swift index ce0e9ea56c..2ea20ee4b9 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Peers/NotificationSoundList.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Peers/NotificationSoundList.swift @@ -104,7 +104,7 @@ public final class NotificationSoundList: Equatable, Codable { private extension NotificationSoundList.NotificationSound { convenience init?(apiDocument: Api.Document) { - guard let file = telegramMediaFileFromApiDocument(apiDocument) else { + guard let file = telegramMediaFileFromApiDocument(apiDocument, altDocuments: []) else { return nil } self.init(file: file) @@ -313,7 +313,7 @@ func _internal_uploadNotificationSound(account: Account, title: String, data: Da return .generic } |> mapToSignal { result -> Signal in - guard let file = telegramMediaFileFromApiDocument(result) else { + guard let file = telegramMediaFileFromApiDocument(result, altDocuments: []) else { return .fail(.generic) } return account.postbox.transaction { transaction -> NotificationSoundList.NotificationSound in diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Stickers/ImportStickers.swift b/submodules/TelegramCore/Sources/TelegramEngine/Stickers/ImportStickers.swift index 1965ed1812..f8a8e133b1 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Stickers/ImportStickers.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Stickers/ImportStickers.swift @@ -87,8 +87,8 @@ func _internal_uploadSticker(account: Account, peer: Peer, resource: MediaResour |> mapError { _ -> UploadStickerError in return .generic } |> mapToSignal { media -> Signal in switch media { - case let .messageMediaDocument(_, document, _, _): - if let document = document, let file = telegramMediaFileFromApiDocument(document), let uploadedResource = file.resource as? CloudDocumentMediaResource { + case let .messageMediaDocument(_, document, altDocuments, _): + if let document = document, let file = telegramMediaFileFromApiDocument(document, altDocuments: altDocuments), let uploadedResource = file.resource as? CloudDocumentMediaResource { account.postbox.mediaBox.copyResourceData(from: resource.id, to: uploadedResource.id, synchronous: true) if let thumbnail, let previewRepresentation = file.previewRepresentations.first(where: { $0.dimensions == PixelDimensions(width: 320, height: 320) }) { account.postbox.mediaBox.copyResourceData(from: thumbnail.id, to: previewRepresentation.resource.id, synchronous: true) @@ -144,7 +144,7 @@ public extension ImportSticker { fileAttributes.append(.FileName(fileName: "sticker.webm")) fileAttributes.append(.Animated) fileAttributes.append(.Sticker(displayText: "", packReference: nil, maskData: nil)) - fileAttributes.append(.Video(duration: self.duration ?? 3.0, size: self.dimensions, flags: [], preloadSize: nil, coverTime: nil)) + fileAttributes.append(.Video(duration: self.duration ?? 3.0, size: self.dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)) } else if self.mimeType == "application/x-tgsticker" { fileAttributes.append(.FileName(fileName: "sticker.tgs")) fileAttributes.append(.Animated) @@ -159,7 +159,7 @@ public extension ImportSticker { previewRepresentations.append(TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 320, height: 320), resource: thumbnailResource, progressiveSizes: [], immediateThumbnailData: nil)) } - return StickerPackItem(index: ItemCollectionItemIndex(index: 0, id: 0), file: TelegramMediaFile(fileId: EngineMedia.Id(namespace: 0, id: 0), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: self.mimeType, size: nil, attributes: fileAttributes), indexKeys: []) + return StickerPackItem(index: ItemCollectionItemIndex(index: 0, id: 0), file: TelegramMediaFile(fileId: EngineMedia.Id(namespace: 0, id: 0), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: self.mimeType, size: nil, attributes: fileAttributes, alternativeRepresentations: []), indexKeys: []) } } @@ -560,7 +560,7 @@ func _internal_getMyStickerSets(account: Account) -> Signal<[(StickerPackCollect } let info = StickerPackCollectionInfo(apiSet: set, namespace: namespace) var firstItem: StickerPackItem? - if let file = telegramMediaFileFromApiDocument(cover), let id = file.id { + if let file = telegramMediaFileFromApiDocument(cover, altDocuments: []), let id = file.id { firstItem = StickerPackItem(index: ItemCollectionItemIndex(index: 0, id: id.id), file: file, indexKeys: []) } infos.append((info, firstItem)) @@ -579,7 +579,7 @@ func _internal_getMyStickerSets(account: Account) -> Signal<[(StickerPackCollect let info = StickerPackCollectionInfo(apiSet: set, namespace: namespace) var firstItem: StickerPackItem? if let apiDocument = documents.first { - if let file = telegramMediaFileFromApiDocument(apiDocument), let id = file.id { + if let file = telegramMediaFileFromApiDocument(apiDocument, altDocuments: []), let id = file.id { firstItem = StickerPackItem(index: ItemCollectionItemIndex(index: 0, id: id.id), file: file, indexKeys: []) } } @@ -642,7 +642,7 @@ private func parseStickerSetInfoAndItems(apiStickerSet: Api.messages.StickerSet) var items: [StickerPackItem] = [] for apiDocument in documents { - if let file = telegramMediaFileFromApiDocument(apiDocument), let id = file.id { + if let file = telegramMediaFileFromApiDocument(apiDocument, altDocuments: []), let id = file.id { let fileIndexKeys: [MemoryBuffer] if let indexKeys = indexKeysByFile[id] { fileIndexKeys = indexKeys diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Stickers/LoadedStickerPack.swift b/submodules/TelegramCore/Sources/TelegramEngine/Stickers/LoadedStickerPack.swift index b89e44c713..e2f78fa910 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Stickers/LoadedStickerPack.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Stickers/LoadedStickerPack.swift @@ -100,7 +100,7 @@ func updatedRemoteStickerPack(postbox: Postbox, network: Network, reference: Sti } for apiDocument in documents { - if let file = telegramMediaFileFromApiDocument(apiDocument), let id = file.id { + if let file = telegramMediaFileFromApiDocument(apiDocument, altDocuments: []), let id = file.id { let fileIndexKeys: [MemoryBuffer] if let indexKeys = indexKeysByFile[id] { fileIndexKeys = indexKeys diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Stickers/SearchStickers.swift b/submodules/TelegramCore/Sources/TelegramEngine/Stickers/SearchStickers.swift index 70ff4f5190..66d4f6abf3 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Stickers/SearchStickers.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Stickers/SearchStickers.swift @@ -320,7 +320,7 @@ func _internal_searchStickers(account: Account, query: [String], scope: SearchSt var files: [TelegramMediaFile] = [] for sticker in stickers { - if let file = telegramMediaFileFromApiDocument(sticker), let id = file.id { + if let file = telegramMediaFileFromApiDocument(sticker, altDocuments: []), let id = file.id { files.append(file) if !currentItemIds.contains(id) { if file.isPremiumSticker { @@ -705,7 +705,7 @@ func _internal_searchStickers(account: Account, category: EmojiSearchCategories. var files: [TelegramMediaFile] = [] for sticker in stickers { - if let file = telegramMediaFileFromApiDocument(sticker), let id = file.id { + if let file = telegramMediaFileFromApiDocument(sticker, altDocuments: []), let id = file.id { files.append(file) if !currentItemIds.contains(id) { if file.isPremiumSticker { diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Stickers/StickerSetInstallation.swift b/submodules/TelegramCore/Sources/TelegramEngine/Stickers/StickerSetInstallation.swift index 657fb8c198..db64fe57ab 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Stickers/StickerSetInstallation.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Stickers/StickerSetInstallation.swift @@ -117,7 +117,7 @@ func _internal_requestStickerSet(postbox: Postbox, network: Network, reference: } for apiDocument in documents { - if let file = telegramMediaFileFromApiDocument(apiDocument), let id = file.id { + if let file = telegramMediaFileFromApiDocument(apiDocument, altDocuments: []), let id = file.id { let fileIndexKeys: [MemoryBuffer] if let indexKeys = indexKeysByFile[id] { fileIndexKeys = indexKeys @@ -199,7 +199,7 @@ func _internal_installStickerSetInteractively(account: Account, info: StickerPac var items:[StickerPackItem] = [] for apiDocument in apiDocuments { - if let file = telegramMediaFileFromApiDocument(apiDocument), let id = file.id { + if let file = telegramMediaFileFromApiDocument(apiDocument, altDocuments: []), let id = file.id { items.append(StickerPackItem(index: ItemCollectionItemIndex(index: Int32(items.count), id: id.id), file: file, indexKeys: [])) } } diff --git a/submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift b/submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift index a3cf7bfac5..9e1d3d0a6e 100644 --- a/submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift +++ b/submodules/TelegramCore/Sources/TelegramEngine/Stickers/TelegramEngineStickers.swift @@ -361,7 +361,7 @@ public func _internal_resolveInlineStickers(postbox: Postbox, network: Network, for result in documentSets { if let result = result { for document in result { - if let file = telegramMediaFileFromApiDocument(document) { + if let file = telegramMediaFileFromApiDocument(document, altDocuments: []) { resultFiles[file.fileId.id] = file transaction.storeMediaIfNotPresent(media: file) } diff --git a/submodules/TelegramCore/Sources/Themes.swift b/submodules/TelegramCore/Sources/Themes.swift index 92d1992607..9bb600e89a 100644 --- a/submodules/TelegramCore/Sources/Themes.swift +++ b/submodules/TelegramCore/Sources/Themes.swift @@ -258,7 +258,7 @@ private func uploadTheme(account: Account, resource: MediaResource, thumbnailDat return account.network.request(Api.functions.account.uploadTheme(flags: flags, file: file, thumb: thumbnailFile, fileName: fileName, mimeType: mimeType)) |> mapError { _ in return UploadThemeError.generic } |> mapToSignal { document -> Signal in - if let file = telegramMediaFileFromApiDocument(document) { + if let file = telegramMediaFileFromApiDocument(document, altDocuments: []) { return .single(.complete(file)) } else { return .fail(.generic) diff --git a/submodules/TelegramCore/Sources/Utils/ImageRepresentationsUtils.swift b/submodules/TelegramCore/Sources/Utils/ImageRepresentationsUtils.swift index 6b71965fb9..20a3890c13 100644 --- a/submodules/TelegramCore/Sources/Utils/ImageRepresentationsUtils.swift +++ b/submodules/TelegramCore/Sources/Utils/ImageRepresentationsUtils.swift @@ -112,7 +112,7 @@ public func parseMediaData(data: Data) -> Media? { if let photo = object as? Api.Photo { return telegramMediaImageFromApiPhoto(photo) } else if let document = object as? Api.Document { - return telegramMediaFileFromApiDocument(document) + return telegramMediaFileFromApiDocument(document, altDocuments: []) } } return nil diff --git a/submodules/TelegramCore/Sources/Utils/MediaResourceNetworkStatsTag.swift b/submodules/TelegramCore/Sources/Utils/MediaResourceNetworkStatsTag.swift index 818111988f..618da9b69a 100644 --- a/submodules/TelegramCore/Sources/Utils/MediaResourceNetworkStatsTag.swift +++ b/submodules/TelegramCore/Sources/Utils/MediaResourceNetworkStatsTag.swift @@ -11,7 +11,7 @@ public enum MediaResourceStatsCategory { case voiceMessages } -final class TelegramMediaResourceFetchTag: MediaResourceFetchTag { +public final class TelegramMediaResourceFetchTag: MediaResourceFetchTag { public let statsCategory: MediaResourceStatsCategory public init(statsCategory: MediaResourceStatsCategory, userContentType: MediaResourceUserContentType?) { diff --git a/submodules/TelegramCore/Sources/Utils/MessageUtils.swift b/submodules/TelegramCore/Sources/Utils/MessageUtils.swift index acc484da19..7a3e295fa4 100644 --- a/submodules/TelegramCore/Sources/Utils/MessageUtils.swift +++ b/submodules/TelegramCore/Sources/Utils/MessageUtils.swift @@ -599,7 +599,7 @@ public func _internal_parseMediaAttachment(data: Data) -> Media? { if let photo = object as? Api.Photo { return telegramMediaImageFromApiPhoto(photo) } else if let file = object as? Api.Document { - return telegramMediaFileFromApiDocument(file) + return telegramMediaFileFromApiDocument(file, altDocuments: []) } else { return nil } diff --git a/submodules/TelegramPresentationData/Sources/DefaultDayPresentationTheme.swift b/submodules/TelegramPresentationData/Sources/DefaultDayPresentationTheme.swift index 0a7d969a05..ed54165140 100644 --- a/submodules/TelegramPresentationData/Sources/DefaultDayPresentationTheme.swift +++ b/submodules/TelegramPresentationData/Sources/DefaultDayPresentationTheme.swift @@ -599,7 +599,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -615,7 +615,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -654,7 +654,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -670,7 +670,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -711,7 +711,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: UIColor(white: 0.0, alpha: 0.1), reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -727,7 +727,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: UIColor(white: 0.0, alpha: 0.1), reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -763,7 +763,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -779,7 +779,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -821,7 +821,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -837,7 +837,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -878,7 +878,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -894,7 +894,7 @@ public func makeDefaultDayPresentationTheme(extendingThemeReference: Presentatio reactionActiveForeground: .clear, reactionStarsInactiveBackground: UIColor(rgb: 0xFEF1D4, alpha: 1.0), reactionStarsInactiveForeground: UIColor(rgb: 0xD3720A), - reactionStarsActiveBackground: UIColor(rgb: 0xD3720A, alpha: 1.0), + reactionStarsActiveBackground: UIColor(rgb: 0xFFBC2E, alpha: 1.0), reactionStarsActiveForeground: .white, reactionInactiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2), reactionActiveMediaPlaceholder: UIColor(rgb: 0xffffff, alpha: 0.2) @@ -1379,7 +1379,8 @@ public func defaultBuiltinWallpaper(data: BuiltinWallpaperData, colors: [UInt32] attributes: [ .ImageSize(size: PixelDimensions(width: 1440, height: 2960)), .FileName(fileName: "pattern.tgv") - ] + ], + alternativeRepresentations: [] ), settings: WallpaperSettings(colors: colors, intensity: intensity, rotation: rotation) )) diff --git a/submodules/TelegramPresentationData/Sources/PresentationThemeCodable.swift b/submodules/TelegramPresentationData/Sources/PresentationThemeCodable.swift index a5e38bb28f..8af0bd3bc0 100644 --- a/submodules/TelegramPresentationData/Sources/PresentationThemeCodable.swift +++ b/submodules/TelegramPresentationData/Sources/PresentationThemeCodable.swift @@ -122,7 +122,7 @@ struct TelegramWallpaperStandardizedCodable: Codable { } if let slug = slug { - self.value = .file(TelegramWallpaper.File(id: 0, accessHash: 0, isCreator: false, isDefault: false, isPattern: !colors.isEmpty, isDark: false, slug: slug, file: TelegramMediaFile(fileId: EngineMedia.Id(namespace: 0, id: 0), partialReference: nil, resource: WallpaperDataResource(slug: slug), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "", size: nil, attributes: []), settings: WallpaperSettings(blur: blur, motion: motion, colors: colors.map { $0.argb }, intensity: intensity, rotation: rotation))) + self.value = .file(TelegramWallpaper.File(id: 0, accessHash: 0, isCreator: false, isDefault: false, isPattern: !colors.isEmpty, isDark: false, slug: slug, file: TelegramMediaFile(fileId: EngineMedia.Id(namespace: 0, id: 0), partialReference: nil, resource: WallpaperDataResource(slug: slug), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "", size: nil, attributes: [], alternativeRepresentations: []), settings: WallpaperSettings(blur: blur, motion: motion, colors: colors.map { $0.argb }, intensity: intensity, rotation: rotation))) } else if colors.count > 1 { self.value = .gradient(TelegramWallpaper.Gradient(id: nil, colors: colors.map { $0.argb }, settings: WallpaperSettings(blur: blur, motion: motion, rotation: rotation))) } else { diff --git a/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift b/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift index a548f9aac1..e289c562b6 100644 --- a/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift +++ b/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift @@ -330,7 +330,7 @@ public func mediaContentKind(_ media: EngineMedia, message: EngineMessage? = nil return .file(performer) } } - case let .Video(_, _, flags, _, _): + case let .Video(_, _, flags, _, _, _): if file.isAnimated { result = .animation } else { diff --git a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift index 729ef31388..134e73fddc 100644 --- a/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift +++ b/submodules/TelegramStringFormatting/Sources/ServiceMessageStrings.swift @@ -235,7 +235,7 @@ public func universalServiceMessageString(presentationData: (PresentationTheme, } else { for attribute in file.attributes { switch attribute { - case let .Video(_, _, flags, _, _): + case let .Video(_, _, flags, _, _, _): if flags.contains(.instantRoundVideo) { type = .round } else { diff --git a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/CallBlobsLayer.swift b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/CallBlobsLayer.swift index 1fcba0be18..da2744a7c3 100644 --- a/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/CallBlobsLayer.swift +++ b/submodules/TelegramUI/Components/Calls/CallScreen/Sources/Components/CallBlobsLayer.swift @@ -130,7 +130,7 @@ public final class CallBlobsLayer: MetalEngineSubjectLayer, MetalEngineSubject { let phase = self.phase let blobs = self.blobs - context.renderToLayer(spec: RenderLayerSpec(size: RenderSize(width: Int(self.bounds.width * 3.0), height: Int(self.bounds.height * 3.0)), edgeInset: 4), state: RenderState.self, layer: self, commands: { encoder, placement in + context.renderToLayer(spec: RenderLayerSpec(size: RenderSize(width: Int(self.bounds.width * 3.0), height: Int(self.bounds.height * 3.0)), edgeInset: 2), state: RenderState.self, layer: self, commands: { encoder, placement in let rect = placement.effectiveRect for i in 0 ..< blobs.count { diff --git a/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/Sources/ChatContextResultPeekContent.swift b/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/Sources/ChatContextResultPeekContent.swift index 67891754b9..5e55312bd9 100644 --- a/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/Sources/ChatContextResultPeekContent.swift +++ b/submodules/TelegramUI/Components/Chat/ChatContextResultPeekContent/Sources/ChatContextResultPeekContent.swift @@ -172,7 +172,7 @@ private final class ChatContextResultPeekNode: ASDisplayNode, PeekControllerCont imageDimensions = externalReference.content?.dimensions?.cgSize if let content = externalReference.content, externalReference.type == "gif", let thumbnailResource = imageResource , let dimensions = content.dimensions { - videoFileReference = .standalone(media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: content.resource, previewRepresentations: [TelegramMediaImageRepresentation(dimensions: dimensions, resource: thumbnailResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: dimensions, flags: [], preloadSize: nil, coverTime: nil)])) + videoFileReference = .standalone(media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: content.resource, previewRepresentations: [TelegramMediaImageRepresentation(dimensions: dimensions, resource: thumbnailResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: [])) imageResource = nil } case let .internalReference(internalReference): diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageActionBubbleContentNode/Sources/ChatMessageActionBubbleContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageActionBubbleContentNode/Sources/ChatMessageActionBubbleContentNode.swift index feaacffdb1..cebcfdff62 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageActionBubbleContentNode/Sources/ChatMessageActionBubbleContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageActionBubbleContentNode/Sources/ChatMessageActionBubbleContentNode.swift @@ -272,7 +272,7 @@ public class ChatMessageActionBubbleContentNode: ChatMessageBubbleContentNode { strongSelf.mediaBackgroundNode.image = backgroundImage if let image = image, let video = image.videoRepresentations.last, let id = image.id?.id { - let videoFileReference = FileMediaReference.message(message: MessageReference(item.message), media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.resource, previewRepresentations: image.representations, videoThumbnails: [], immediateThumbnailData: image.immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.dimensions, flags: [], preloadSize: nil, coverTime: nil)])) + let videoFileReference = FileMediaReference.message(message: MessageReference(item.message), media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.resource, previewRepresentations: image.representations, videoThumbnails: [], immediateThumbnailData: image.immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: [])) 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 diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift index d43450b988..acf4bc4dbd 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageAnimatedStickerItemNode/Sources/ChatMessageAnimatedStickerItemNode.swift @@ -3061,7 +3061,7 @@ public struct AnimatedEmojiSoundsConfiguration { if let idString = dict["id"], let id = Int64(idString), let accessHashString = dict["access_hash"], let accessHash = Int64(accessHashString), let fileReference = Data(base64Encoded: fileReferenceString) { let resource = CloudDocumentMediaResource(datacenterId: 1, fileId: id, accessHash: accessHash, size: nil, fileReference: fileReference, fileName: nil) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: nil, attributes: []) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: nil, attributes: [], alternativeRepresentations: []) sounds[key] = file } } diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift index 6ae1859216..ada3bff052 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveFileNode/Sources/ChatMessageInteractiveFileNode.swift @@ -650,7 +650,7 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode { let messageTheme = arguments.incoming ? arguments.presentationData.theme.theme.chat.message.incoming : arguments.presentationData.theme.theme.chat.message.outgoing let isInstantVideo = arguments.file.isInstantVideo for attribute in arguments.file.attributes { - if case let .Video(videoDuration, _, flags, _, _) = attribute, flags.contains(.instantRoundVideo) { + if case let .Video(videoDuration, _, flags, _, _, _) = attribute, flags.contains(.instantRoundVideo) { isAudio = true isVoice = true @@ -1558,7 +1558,7 @@ public final class ChatMessageInteractiveFileNode: ASDisplayNode { var isVoice = false var audioDuration: Int32? for attribute in file.attributes { - if case let .Video(duration, _, flags, _, _) = attribute, flags.contains(.instantRoundVideo) { + if case let .Video(duration, _, flags, _, _, _) = attribute, flags.contains(.instantRoundVideo) { isAudio = true isVoice = true audioDuration = Int32(duration) diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift index 88b33b6c8d..e1586b4821 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageInteractiveMediaNode/Sources/ChatMessageInteractiveMediaNode.swift @@ -414,8 +414,8 @@ private class ExtendedMediaOverlayNode: ASDisplayNode { } private func selectStoryMedia(item: Stories.Item, preferredHighQuality: Bool) -> Media? { - if !preferredHighQuality, let alternativeMedia = item.alternativeMedia { - return alternativeMedia + if !preferredHighQuality, let alternativeMediaValue = item.alternativeMediaList.first { + return alternativeMediaValue } else { return item.media } @@ -430,7 +430,7 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr private var highQualityImageNode: TransformImageNode? private var videoNode: UniversalVideoNode? - private var videoContent: NativeVideoContent? + private var videoContent: UniversalVideoContent? private var animatedStickerNode: AnimatedStickerNode? private var statusNode: RadialStatusNode? public var videoNodeDecoration: ChatBubbleVideoDecoration? @@ -678,10 +678,10 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr } else if let media = media as? TelegramMediaImage, let resource = largestImageRepresentation(media.representations)?.resource { messageMediaImageCancelInteractiveFetch(context: context, messageId: message.id, image: media, resource: resource) } - if let alternativeMedia = item.alternativeMedia { - if let media = alternativeMedia as? TelegramMediaFile { + if let alternativeMediaValue = item.alternativeMediaList.first { + if let media = alternativeMediaValue as? TelegramMediaFile { messageMediaFileCancelInteractiveFetch(context: context, messageId: message.id, file: media) - } else if let media = alternativeMedia as? TelegramMediaImage, let resource = largestImageRepresentation(media.representations)?.resource { + } else if let media = alternativeMediaValue as? TelegramMediaImage, let resource = largestImageRepresentation(media.representations)?.resource { messageMediaImageCancelInteractiveFetch(context: context, messageId: message.id, image: media, resource: resource) } } @@ -715,7 +715,20 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr } } else if let fetchStatus = self.fetchStatus, case .Local = fetchStatus { var videoContentMatch = true - if let content = self.videoContent, case let .message(stableId, mediaId) = content.nativeId { + if let content = self.videoContent as? NativeVideoContent, case let .message(stableId, mediaId) = content.nativeId { + var media = self.media + if let invoice = media as? TelegramMediaInvoice, let extendedMedia = invoice.extendedMedia, case let .full(fullMedia) = extendedMedia { + media = fullMedia + } + + if let storyMedia = media as? TelegramMediaStory, let storyItem = self.message?.associatedStories[storyMedia.storyId]?.get(Stories.StoredItem.self) { + if case let .item(item) = storyItem, let _ = item.media { + media = selectStoryMedia(item: item, preferredHighQuality: self.preferredStoryHighQuality) + } + } + + videoContentMatch = self.message?.stableId == stableId && media?.id == mediaId + } else if let content = self.videoContent as? PlatformVideoContent, case let .message(_, stableId, mediaId) = content.nativeId { var media = self.media if let invoice = media as? TelegramMediaInvoice, let extendedMedia = invoice.extendedMedia, case let .full(fullMedia) = extendedMedia { media = fullMedia @@ -1644,12 +1657,18 @@ public final class ChatMessageInteractiveMediaNode: ASDisplayNode, GalleryItemTr let streamVideo = isMediaStreamable(message: message, media: updatedVideoFile) let loopVideo = updatedVideoFile.isAnimated - let videoContent = NativeVideoContent(id: .message(message.stableId, updatedVideoFile.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: updatedVideoFile), streamVideo: streamVideo ? .conservative : .none, loopVideo: loopVideo, enableSound: false, fetchAutomatically: false, onlyFullSizeThumbnail: (onlyFullSizeVideoThumbnail ?? false), continuePlayingWithoutSoundOnLostAudioSession: isInlinePlayableVideo, placeholderColor: emptyColor, captureProtected: message.isCopyProtected() || isExtendedMedia, storeAfterDownload: { [weak context] in - guard let context, let peerId else { - return - } - let _ = storeDownloadedMedia(storeManager: context.downloadedMediaStoreManager, media: .message(message: MessageReference(message), media: updatedVideoFile), peerId: peerId).startStandalone() - }) + + let videoContent: UniversalVideoContent + if NativeVideoContent.isHLSVideo(file: updatedVideoFile), context.sharedContext.immediateExperimentalUISettings.dynamicStreaming { + videoContent = HLSVideoContent(id: .message(message.id, message.stableId, updatedVideoFile.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: updatedVideoFile), streamVideo: true, loopVideo: loopVideo) + } else { + videoContent = NativeVideoContent(id: .message(message.stableId, updatedVideoFile.fileId), userLocation: .peer(message.id.peerId), fileReference: .message(message: MessageReference(message), media: updatedVideoFile), streamVideo: streamVideo ? .conservative : .none, loopVideo: loopVideo, enableSound: false, fetchAutomatically: false, onlyFullSizeThumbnail: (onlyFullSizeVideoThumbnail ?? false), continuePlayingWithoutSoundOnLostAudioSession: isInlinePlayableVideo, placeholderColor: emptyColor, captureProtected: message.isCopyProtected() || isExtendedMedia, storeAfterDownload: { [weak context] in + guard let context, let peerId else { + return + } + let _ = storeDownloadedMedia(storeManager: context.downloadedMediaStoreManager, media: .message(message: MessageReference(message), media: updatedVideoFile), peerId: peerId).startStandalone() + }) + } let videoNode = UniversalVideoNode(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 diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift index 465993c444..a90de7819d 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemImpl/Sources/ChatMessageItemImpl.swift @@ -27,7 +27,7 @@ private func mediaMergeableStyle(_ media: Media) -> ChatMessageMerge { switch attribute { case .Sticker: return .semanticallyMerged - case let .Video(_, _, flags, _, _): + case let .Video(_, _, flags, _, _, _): if flags.contains(.instantRoundVideo) { return .none } @@ -437,7 +437,7 @@ public final class ChatMessageItemImpl: ChatMessageItem, CustomStringConvertible viewClassName = ChatMessageStickerItemNode.self } break loop - case let .Video(_, _, flags, _, _): + case let .Video(_, _, flags, _, _, _): if flags.contains(.instantRoundVideo) { viewClassName = ChatMessageBubbleItemNode.self break loop diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift index 0a99196ab3..ec74e1c526 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageItemView/Sources/ChatMessageItemView.swift @@ -203,7 +203,7 @@ public final class ChatMessageAccessibilityData { text = item.presentationData.strings.VoiceOver_Chat_MusicTitle(title, performer).string text.append(item.presentationData.strings.VoiceOver_Chat_Duration(durationString).string) } - case let .Video(duration, _, flags, _, _): + case let .Video(duration, _, flags, _, _, _): isSpecialFile = true if isSelected == nil { hint = item.presentationData.strings.VoiceOver_Chat_PlayHint diff --git a/submodules/TelegramUI/Components/Chat/ChatMessageProfilePhotoSuggestionContentNode/Sources/ChatMessageProfilePhotoSuggestionContentNode.swift b/submodules/TelegramUI/Components/Chat/ChatMessageProfilePhotoSuggestionContentNode/Sources/ChatMessageProfilePhotoSuggestionContentNode.swift index 7f8236c6ee..47c902badc 100644 --- a/submodules/TelegramUI/Components/Chat/ChatMessageProfilePhotoSuggestionContentNode/Sources/ChatMessageProfilePhotoSuggestionContentNode.swift +++ b/submodules/TelegramUI/Components/Chat/ChatMessageProfilePhotoSuggestionContentNode/Sources/ChatMessageProfilePhotoSuggestionContentNode.swift @@ -218,7 +218,7 @@ public class ChatMessageProfilePhotoSuggestionContentNode: ChatMessageBubbleCont } if let photo = photo, let video = photo.videoRepresentations.last, let id = photo.id?.id { - let videoFileReference = FileMediaReference.message(message: MessageReference(item.message), media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.resource, previewRepresentations: photo.representations, videoThumbnails: [], immediateThumbnailData: photo.immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.dimensions, flags: [], preloadSize: nil, coverTime: nil)])) + let videoFileReference = FileMediaReference.message(message: MessageReference(item.message), media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.resource, previewRepresentations: photo.representations, videoThumbnails: [], immediateThumbnailData: photo.immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: [])) 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 diff --git a/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift b/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift index 7512ef03f8..86eb90094c 100644 --- a/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift +++ b/submodules/TelegramUI/Components/Chat/ChatQrCodeScreen/Sources/ChatQrCodeScreen.swift @@ -2477,10 +2477,15 @@ private func renderVideo(context: AccountContext, backgroundImage: UIImage, user let layerInstruction = compositionLayerInstruction(for: compositionTrack, assetTrack: assetTrack) instruction.layerInstructions = [layerInstruction] - guard let export = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else { + guard let exportValue = AVAssetExportSession(asset: composition, presetName: AVAssetExportPresetHighestQuality) else { completion(nil) return } + #if compiler(>=6.0) // Xcode 16 + nonisolated(unsafe) let export = exportValue + #else + let export = exportValue + #endif let videoName = UUID().uuidString let exportURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(videoName).appendingPathExtension("mp4") diff --git a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift index 1c43afa28c..b71fd8511c 100644 --- a/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift +++ b/submodules/TelegramUI/Components/Chat/ChatSendAudioMessageContextPreview/Sources/ChatSendAudioMessageContextPreview.swift @@ -257,7 +257,7 @@ public final class ChatSendAudioMessageContextPreview: UIView, ChatSendMessageCo public func update(containerSize: CGSize, transition: ComponentTransition) -> CGSize { let voiceAttributes: [TelegramMediaFileAttribute] = [.Audio(isVoice: true, duration: 23, title: nil, performer: nil, waveform: self.waveform.makeBitstream())] - let voiceMedia = TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: voiceAttributes) + let voiceMedia = TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: voiceAttributes, alternativeRepresentations: []) let message = Message(stableId: 1, stableVersion: 0, id: MessageId(peerId: self.context.account.peerId, namespace: 0, id: 1), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 0, flags: [.Incoming], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: nil, text: "", attributes: [], media: [voiceMedia], peers: SimpleDictionary(), associatedMessages: SimpleDictionary(), associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) diff --git a/submodules/TelegramUI/Components/EntityKeyboardGifContent/Sources/GifContext.swift b/submodules/TelegramUI/Components/EntityKeyboardGifContent/Sources/GifContext.swift index e133ecbacb..5178c9659e 100644 --- a/submodules/TelegramUI/Components/EntityKeyboardGifContent/Sources/GifContext.swift +++ b/submodules/TelegramUI/Components/EntityKeyboardGifContent/Sources/GifContext.swift @@ -126,7 +126,7 @@ public func paneGifSearchForQuery(context: AccountContext, query: String, offset )) } } - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: uniqueId ?? 0), partialReference: nil, resource: resource, previewRepresentations: previews, videoThumbnails: videoThumbnails, immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: dimensions, flags: [], preloadSize: nil, coverTime: nil)]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: uniqueId ?? 0), partialReference: nil, resource: resource, previewRepresentations: previews, videoThumbnails: videoThumbnails, immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: []) references.append(MultiplexedVideoNodeFile(file: FileMediaReference.standalone(media: file), contextResult: (collection, result))) } case let .internalReference(internalReference): diff --git a/submodules/TelegramUI/Components/GroupStickerPackSetupController/Sources/GroupStickerPackSetupController.swift b/submodules/TelegramUI/Components/GroupStickerPackSetupController/Sources/GroupStickerPackSetupController.swift index 3f12c31673..3fa698bbbd 100644 --- a/submodules/TelegramUI/Components/GroupStickerPackSetupController/Sources/GroupStickerPackSetupController.swift +++ b/submodules/TelegramUI/Components/GroupStickerPackSetupController/Sources/GroupStickerPackSetupController.swift @@ -302,7 +302,7 @@ private func groupStickerPackSetupControllerEntries(context: AccountContext, pre let thumbnail: StickerPackItem? if let thumbnailRep = info.thumbnail { - thumbnail = StickerPackItem(index: ItemCollectionItemIndex(index: 0, id: 0), file: TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: thumbnailRep.resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: info.immediateThumbnailData, mimeType: "", size: nil, attributes: []), indexKeys: []) + thumbnail = StickerPackItem(index: ItemCollectionItemIndex(index: 0, id: 0), file: TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: thumbnailRep.resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: info.immediateThumbnailData, mimeType: "", size: nil, attributes: [], alternativeRepresentations: []), indexKeys: []) } else { thumbnail = entry.firstItem as? StickerPackItem } diff --git a/submodules/TelegramUI/Components/LegacyInstantVideoController/Sources/LegacyInstantVideoController.swift b/submodules/TelegramUI/Components/LegacyInstantVideoController/Sources/LegacyInstantVideoController.swift index 0dc69ee662..b6b6b4ffdf 100644 --- a/submodules/TelegramUI/Components/LegacyInstantVideoController/Sources/LegacyInstantVideoController.swift +++ b/submodules/TelegramUI/Components/LegacyInstantVideoController/Sources/LegacyInstantVideoController.swift @@ -231,7 +231,7 @@ public func legacyInstantVideoController(theme: PresentationTheme, forStory: Boo } } - let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.FileName(fileName: "video.mp4"), .Video(duration: finalDuration, size: PixelDimensions(finalDimensions), flags: [.instantRoundVideo], preloadSize: nil, coverTime: nil)]) + let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.FileName(fileName: "video.mp4"), .Video(duration: finalDuration, size: PixelDimensions(finalDimensions), flags: [.instantRoundVideo], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: []) var message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: media), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) let scheduleTime: Int32? = scheduleTimestamp > 0 ? scheduleTimestamp : nil diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift index 9b259b362c..aee5cbbcb9 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/EditStories.swift @@ -87,7 +87,7 @@ public extension MediaEditorScreen { if cover, case let .file(file) = storyItem.media { videoPlaybackPosition = 0.0 for attribute in file.attributes { - if case let .Video(_, _, _, _, coverTime) = attribute { + if case let .Video(_, _, _, _, coverTime, _) = attribute { videoPlaybackPosition = coverTime break } @@ -258,8 +258,8 @@ public extension MediaEditorScreen { if case let .file(file) = storyItem.media { var updatedAttributes: [TelegramMediaFileAttribute] = [] for attribute in file.attributes { - if case let .Video(duration, size, flags, preloadSize, _) = attribute { - updatedAttributes.append(.Video(duration: duration, size: size, flags: flags, preloadSize: preloadSize, coverTime: min(duration, updatedCoverTimestamp))) + if case let .Video(duration, size, flags, preloadSize, _, videoCodec) = attribute { + updatedAttributes.append(.Video(duration: duration, size: size, flags: flags, preloadSize: preloadSize, coverTime: min(duration, updatedCoverTimestamp), videoCodec: videoCodec)) } else { updatedAttributes.append(attribute) } diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift index 0adc9b1edd..bc9ec74e11 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/MediaEditorScreen.swift @@ -8305,7 +8305,7 @@ private func stickerFile(resource: TelegramMediaResource, thumbnailResource: Tel fileAttributes.append(.FileName(fileName: isVideo ? "sticker.webm" : "sticker.webp")) fileAttributes.append(.Sticker(displayText: "", packReference: nil, maskData: nil)) if isVideo { - fileAttributes.append(.Video(duration: duration ?? 3.0, size: dimensions, flags: [], preloadSize: nil, coverTime: nil)) + fileAttributes.append(.Video(duration: duration ?? 3.0, size: dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)) } else { fileAttributes.append(.ImageSize(size: dimensions)) } @@ -8314,7 +8314,7 @@ private func stickerFile(resource: TelegramMediaResource, thumbnailResource: Tel previewRepresentations.append(TelegramMediaImageRepresentation(dimensions: dimensions, resource: thumbnailResource, progressiveSizes: [], immediateThumbnailData: nil)) } - return TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: isVideo ? "video/webm" : "image/webp", size: size, attributes: fileAttributes) + return TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: isVideo ? "video/webm" : "image/webp", size: size, attributes: fileAttributes, alternativeRepresentations: []) } private struct MediaEditorConfiguration { diff --git a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/StickerPackListContextItem.swift b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/StickerPackListContextItem.swift index 67da5b8fe1..d14432ef69 100644 --- a/submodules/TelegramUI/Components/MediaEditorScreen/Sources/StickerPackListContextItem.swift +++ b/submodules/TelegramUI/Components/MediaEditorScreen/Sources/StickerPackListContextItem.swift @@ -60,7 +60,7 @@ private final class StickerPackListContextItemNode: ASDisplayNode, ContextMenuCu if let resource = thumbnailResource as? CloudDocumentMediaResource { resourceId = resource.fileId } - let thumbnailFile = topItem?.file ?? TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.CloudFile, id: resourceId), partialReference: nil, resource: thumbnailResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "image/webp", size: thumbnailResource.size ?? 0, attributes: []) + let thumbnailFile = topItem?.file ?? TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.CloudFile, id: resourceId), partialReference: nil, resource: thumbnailResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "image/webp", size: thumbnailResource.size ?? 0, attributes: [], alternativeRepresentations: []) let _ = freeMediaFileInteractiveFetched(account: item.context.account, userLocation: .other, fileReference: .stickerPack(stickerPack: .id(id: pack.id.id, accessHash: pack.accessHash), media: thumbnailFile)).start() thumbnailIconSource = ContextMenuActionItemIconSource( diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift index 8922ed2a8d..827ec82b64 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoAvatarTransformContainerNode.swift @@ -327,7 +327,7 @@ final class PeerInfoAvatarTransformContainerNode: ASDisplayNode { markupNode.update(markup: markup, size: CGSize(width: 320.0, height: 320.0)) markupNode.updateVisibility(true) } else if threadInfo == nil, let video = videoRepresentations.last, let peerReference = PeerReference(peer) { - let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.representation.resource, previewRepresentations: representations.map { $0.representation }, videoThumbnails: [], immediateThumbnailData: immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.representation.dimensions, flags: [], preloadSize: nil, coverTime: nil)])) + let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.representation.resource, previewRepresentations: representations.map { $0.representation }, videoThumbnails: [], immediateThumbnailData: 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(videoId, nil), userLocation: .other, fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.representation.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, useLargeThumbnail: true, autoFetchFullSizeThumbnail: true, startTimestamp: video.representation.startTimestamp, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear, captureProtected: peer.isCopyProtectionEnabled, storeAfterDownload: nil) if videoContent.id != self.videoContent?.id { self.videoNode?.removeFromSupernode() diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift index a2e196d1e4..ea1c47a629 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoScreen/Sources/PeerInfoEditingAvatarNode.swift @@ -162,7 +162,7 @@ final class PeerInfoEditingAvatarNode: ASDisplayNode { markupNode.removeFromSupernode() } - let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.representation.resource, previewRepresentations: representations.map { $0.representation }, videoThumbnails: [], immediateThumbnailData: immediateThumbnailData, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: video.representation.dimensions, flags: [], preloadSize: nil, coverTime: nil)])) + let videoFileReference = FileMediaReference.avatarList(peer: peerReference, media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: video.representation.resource, previewRepresentations: representations.map { $0.representation }, videoThumbnails: [], immediateThumbnailData: 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(videoId, nil), userLocation: .other, fileReference: videoFileReference, streamVideo: isMediaStreamable(resource: video.representation.resource) ? .conservative : .none, loopVideo: true, enableSound: false, fetchAutomatically: true, onlyFullSizeThumbnail: false, useLargeThumbnail: true, autoFetchFullSizeThumbnail: true, startTimestamp: video.representation.startTimestamp, continuePlayingWithoutSoundOnLostAudioSession: false, placeholderColor: .clear, captureProtected: peer.isCopyProtectionEnabled, storeAfterDownload: nil) if videoContent.id != self.videoContent?.id { self.videoNode?.removeFromSupernode() diff --git a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift index 229ca4404e..4ccb715b37 100644 --- a/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift +++ b/submodules/TelegramUI/Components/PeerInfo/PeerInfoVisualMediaPaneNode/Sources/PeerInfoVisualMediaPaneNode.swift @@ -709,7 +709,8 @@ private final class SparseItemGridBindingImpl: SparseItemGridBinding, ListShimme immediateThumbnailData: nil, mimeType: "image/jpeg", size: nil, - attributes: [.FileName(fileName: "file")] + attributes: [.FileName(fileName: "file")], + alternativeRepresentations: [] ) let fakeMessage = Message( stableId: 1, @@ -2133,7 +2134,8 @@ public final class PeerInfoVisualMediaPaneNode: ASDisplayNode, PeerInfoPaneNode, immediateThumbnailData: nil, mimeType: "image/jpeg", size: nil, - attributes: [.FileName(fileName: "file")] + attributes: [.FileName(fileName: "file")], + alternativeRepresentations: [] ) let fakeMessage = Message( stableId: 1, diff --git a/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/ChannelAppearanceScreen.swift b/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/ChannelAppearanceScreen.swift index 2ca28c9923..8554d99355 100644 --- a/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/ChannelAppearanceScreen.swift +++ b/submodules/TelegramUI/Components/Settings/PeerNameColorScreen/Sources/ChannelAppearanceScreen.swift @@ -1310,7 +1310,7 @@ final class ChannelAppearanceScreenComponent: Component { var emojiPackFile: TelegramMediaFile? if let thumbnail = emojiPack?.thumbnail { - emojiPackFile = TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: thumbnail.resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: thumbnail.immediateThumbnailData, mimeType: "", size: nil, attributes: []) + emojiPackFile = TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: thumbnail.resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: thumbnail.immediateThumbnailData, mimeType: "", size: nil, attributes: [], alternativeRepresentations: []) } let emojiPackSectionSize = self.emojiPackSection.update( @@ -1438,7 +1438,7 @@ final class ChannelAppearanceScreenComponent: Component { var stickerPackFile: TelegramMediaFile? if let peerStickerPack = contentsData.peerStickerPack, let thumbnail = peerStickerPack.thumbnail { - stickerPackFile = TelegramMediaFile(fileId: MediaId(namespace: 0, id: peerStickerPack.id.id), partialReference: nil, resource: thumbnail.resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: thumbnail.immediateThumbnailData, mimeType: "", size: nil, attributes: []) + stickerPackFile = TelegramMediaFile(fileId: MediaId(namespace: 0, id: peerStickerPack.id.id), partialReference: nil, resource: thumbnail.resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: thumbnail.immediateThumbnailData, mimeType: "", size: nil, attributes: [], alternativeRepresentations: []) } let stickerPackSectionSize = self.stickerPackSection.update( diff --git a/submodules/TelegramUI/Components/Settings/ThemeAccentColorScreen/Sources/ThemeAccentColorControllerNode.swift b/submodules/TelegramUI/Components/Settings/ThemeAccentColorScreen/Sources/ThemeAccentColorControllerNode.swift index 819d491cbd..6eeecc59bd 100644 --- a/submodules/TelegramUI/Components/Settings/ThemeAccentColorScreen/Sources/ThemeAccentColorControllerNode.swift +++ b/submodules/TelegramUI/Components/Settings/ThemeAccentColorScreen/Sources/ThemeAccentColorControllerNode.swift @@ -1072,7 +1072,7 @@ final class ThemeAccentColorControllerNode: ASDisplayNode, ASScrollViewDelegate let waveformBase64 = "DAAOAAkACQAGAAwADwAMABAADQAPABsAGAALAA0AGAAfABoAHgATABgAGQAYABQADAAVABEAHwANAA0ACQAWABkACQAOAAwACQAfAAAAGQAVAAAAEwATAAAACAAfAAAAHAAAABwAHwAAABcAGQAAABQADgAAABQAHwAAAB8AHwAAAAwADwAAAB8AEwAAABoAFwAAAB8AFAAAAAAAHwAAAAAAHgAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAHwAAAAAAAAA=" let voiceAttributes: [TelegramMediaFileAttribute] = [.Audio(isVoice: true, duration: 23, title: nil, performer: nil, waveform: Data(base64Encoded: waveformBase64)!)] - let voiceMedia = TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: voiceAttributes) + let voiceMedia = TelegramMediaFile(fileId: MediaId(namespace: 0, id: 0), partialReference: nil, resource: LocalFileMediaResource(fileId: 0), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: 0, attributes: voiceAttributes, alternativeRepresentations: []) let message6 = Message(stableId: 6, stableVersion: 0, id: MessageId(peerId: peerId, namespace: 0, id: 6), globallyUniqueId: nil, groupingKey: nil, groupInfo: nil, threadId: nil, timestamp: 66005, flags: [.Incoming], tags: [], globalTags: [], localTags: [], customTags: [], forwardInfo: nil, author: peers[peerId], text: "", attributes: [], media: [voiceMedia], peers: peers, associatedMessages: messages, associatedMessageIds: [], associatedMedia: [:], associatedThreadInfo: nil, associatedStories: [:]) sampleMessages.append(message6) diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift index f5857b2e40..212e669dbf 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryChatContent.swift @@ -281,7 +281,7 @@ public final class StoryContentContextImpl: StoryContentContext { timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: item.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -329,7 +329,7 @@ public final class StoryContentContextImpl: StoryContentContext { timestamp: item.timestamp, expirationTimestamp: Int32.max, media: EngineMedia(item.media), - alternativeMedia: nil, + alternativeMediaList: [], mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -998,8 +998,8 @@ public final class StoryContentContextImpl: StoryContentContext { } var selectedMedia: EngineMedia - if let slice = stateValue.slice, let alternativeMedia = item.alternativeMedia, (!slice.additionalPeerData.preferHighQualityStories && !item.isMy) { - selectedMedia = alternativeMedia + if let slice = stateValue.slice, let alternativeMediaValue = item.alternativeMediaList.first, (!slice.additionalPeerData.preferHighQualityStories && !item.isMy) { + selectedMedia = alternativeMediaValue } else { selectedMedia = item.media } @@ -1315,7 +1315,7 @@ public final class SingleStoryContentContextImpl: StoryContentContext { timestamp: itemValue.timestamp, expirationTimestamp: itemValue.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: itemValue.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: itemValue.alternativeMediaList.map(EngineMedia.init), mediaAreas: itemValue.mediaAreas, text: itemValue.text, entities: itemValue.entities, @@ -1692,8 +1692,8 @@ public final class PeerStoryListContentContextImpl: StoryContentContext { } var selectedMedia: EngineMedia - if let alternativeMedia = item.storyItem.alternativeMedia, (!preferHighQualityStories && !item.storyItem.isMy) { - selectedMedia = alternativeMedia + if let alternativeMediaValue = item.storyItem.alternativeMediaList.first, (!preferHighQualityStories && !item.storyItem.isMy) { + selectedMedia = alternativeMediaValue } else { selectedMedia = item.storyItem.media } @@ -1820,7 +1820,7 @@ public func preloadStoryMedia(context: AccountContext, info: StoryPreloadInfo) - case let .file(file): var fetchRange: (Range, MediaBoxFetchPriority)? for attribute in file.attributes { - if case let .Video(_, _, _, preloadSize, _) = attribute { + if case let .Video(_, _, _, preloadSize, _, _) = attribute { if let preloadSize { fetchRange = (0 ..< Int64(preloadSize), .default) } @@ -2004,8 +2004,8 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine var fetchPriorityDisposable: Disposable? let selectedMedia: EngineMedia - if !preferHighQualityStories, let alternativeMedia = storyItem.alternativeMedia { - selectedMedia = alternativeMedia + if !preferHighQualityStories, let alternativeMediaValue = storyItem.alternativeMediaList.first { + selectedMedia = alternativeMediaValue } else { selectedMedia = storyItem.media } @@ -2047,7 +2047,7 @@ public func waitUntilStoryMediaPreloaded(context: AccountContext, peerId: Engine case let .file(file): var fetchRange: (Range, MediaBoxFetchPriority)? for attribute in file.attributes { - if case let .Video(_, _, _, preloadSize, _) = attribute { + if case let .Video(_, _, _, preloadSize, _, _) = attribute { if let preloadSize { fetchRange = (0 ..< Int64(preloadSize), .default) } @@ -2247,7 +2247,7 @@ private func getCachedStory(storyId: StoryId, transaction: Transaction) -> Engin timestamp: item.timestamp, expirationTimestamp: item.expirationTimestamp, media: EngineMedia(media), - alternativeMedia: item.alternativeMedia.flatMap(EngineMedia.init), + alternativeMediaList: item.alternativeMediaList.map(EngineMedia.init), mediaAreas: item.mediaAreas, text: item.text, entities: item.entities, @@ -2940,8 +2940,8 @@ public final class RepostStoriesContentContextImpl: StoryContentContext { } var selectedMedia: EngineMedia - if let slice = stateValue.slice, let alternativeMedia = item.alternativeMedia, (!slice.additionalPeerData.preferHighQualityStories && !item.isMy) { - selectedMedia = alternativeMedia + if let slice = stateValue.slice, let alternativeMediaValue = item.alternativeMediaList.first, (!slice.additionalPeerData.preferHighQualityStories && !item.isMy) { + selectedMedia = alternativeMediaValue } else { selectedMedia = item.media } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift index 178920de70..4d26f2e715 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryContainerScreen.swift @@ -1136,7 +1136,7 @@ private final class StoryContainerScreenComponent: Component { var isSilentVideo = false if case let .file(file) = slice.item.storyItem.media { for attribute in file.attributes { - if case let .Video(_, _, flags, _, _) = attribute { + if case let .Video(_, _, flags, _, _, _) = attribute { if flags.contains(.isSilent) { isSilentVideo = true } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift index 5f299827df..fad4658d7a 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemContentComponent.swift @@ -598,10 +598,10 @@ final class StoryItemContentComponent: Component { let selectedMedia: EngineMedia var messageMedia: EngineMedia? - if !component.preferHighQuality, !component.item.isMy, let alternativeMedia = component.item.alternativeMedia { - selectedMedia = alternativeMedia + if !component.preferHighQuality, !component.item.isMy, let alternativeMediaValue = component.item.alternativeMediaList.first { + selectedMedia = alternativeMediaValue - switch alternativeMedia { + switch alternativeMediaValue { case let .image(image): messageMedia = .image(image) case let .file(file): diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift index 6d68c120bb..b194377d78 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerComponent.swift @@ -3820,7 +3820,7 @@ public final class StoryItemSetContainerComponent: Component { isVideo = true soundAlpha = 1.0 for attribute in file.attributes { - if case let .Video(_, _, flags, _, _) = attribute { + if case let .Video(_, _, flags, _, _, _) = attribute { if flags.contains(.isSilent) { isSilentVideo = true soundAlpha = 0.5 @@ -3853,7 +3853,7 @@ public final class StoryItemSetContainerComponent: Component { var isSilentVideo = false if case let .file(file) = component.slice.item.storyItem.media { for attribute in file.attributes { - if case let .Video(_, _, flags, _, _) = attribute { + if case let .Video(_, _, flags, _, _, _) = attribute { if flags.contains(.isSilent) { isSilentVideo = true } @@ -5392,7 +5392,7 @@ public final class StoryItemSetContainerComponent: Component { if cover { if case let .file(file) = component.slice.item.storyItem.media { for attribute in file.attributes { - if case let .Video(_, _, _, _, coverTime) = attribute { + if case let .Video(_, _, _, _, coverTime, _) = attribute { videoPlaybackPosition = coverTime } } diff --git a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift index bb57628710..58e24479c6 100644 --- a/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift +++ b/submodules/TelegramUI/Components/Stories/StoryContainerScreen/Sources/StoryItemSetContainerViewSendMessage.swift @@ -580,7 +580,7 @@ final class StoryItemSetContainerSendMessage { let waveformBuffer = audio.waveform.makeBitstream() - let messages: [EnqueueMessage] = [.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: audio.resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(audio.fileSize), attributes: [.Audio(isVoice: true, duration: Int(audio.duration), title: nil, performer: nil, waveform: waveformBuffer)])), threadId: nil, replyToMessageId: nil, replyToStoryId: focusedStoryId, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])] + let messages: [EnqueueMessage] = [.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: audio.resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(audio.fileSize), attributes: [.Audio(isVoice: true, duration: Int(audio.duration), title: nil, performer: nil, waveform: waveformBuffer)], alternativeRepresentations: [])), threadId: nil, replyToMessageId: nil, replyToStoryId: focusedStoryId, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])] let _ = enqueueMessages(account: component.context.account, peerId: peerId, messages: messages).start() @@ -791,7 +791,7 @@ final class StoryItemSetContainerSendMessage { fileAttributes.append(.Sticker(displayText: "", packReference: nil, maskData: nil)) fileAttributes.append(.ImageSize(size: PixelDimensions(size))) - let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "image/webp", size: Int64(data.count), attributes: fileAttributes) + let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "image/webp", size: Int64(data.count), attributes: fileAttributes, alternativeRepresentations: []) let message = EnqueueMessage.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: media), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) self.sendMessages(view: view, peer: peer, messages: [message], silentPosting: false) @@ -896,7 +896,7 @@ final class StoryItemSetContainerSendMessage { guard let self, let view else { return } - self.sendMessages(view: view, peer: peer, messages: [.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(data.compressedData.count), attributes: [.Audio(isVoice: true, duration: Int(data.duration), title: nil, performer: nil, waveform: waveformBuffer)])), threadId: nil, replyToMessageId: nil, replyToStoryId: focusedStoryId, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]) + self.sendMessages(view: view, peer: peer, messages: [.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(data.compressedData.count), attributes: [.Audio(isVoice: true, duration: Int(data.duration), title: nil, performer: nil, waveform: waveformBuffer)], alternativeRepresentations: [])), threadId: nil, replyToMessageId: nil, replyToStoryId: focusedStoryId, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]) HapticFeedback().tap() }) @@ -2176,7 +2176,7 @@ final class StoryItemSetContainerSendMessage { attributes.append(.Audio(isVoice: false, duration: audioMetadata.duration, title: audioMetadata.title, performer: audioMetadata.performer, waveform: nil)) } - let file = TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: fileId), partialReference: nil, resource: ICloudFileResource(urlData: item.urlData, thumbnail: false), previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: mimeType, size: Int64(item.fileSize), attributes: attributes) + let file = TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: fileId), partialReference: nil, resource: ICloudFileResource(urlData: item.urlData, thumbnail: false), previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: mimeType, size: Int64(item.fileSize), attributes: attributes, alternativeRepresentations: []) let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: replyMessageId.flatMap { EngineMessageReplySubject(messageId: $0, quote: nil) }, replyToStoryId: replyToStoryId, localGroupingKey: groupingKey, correlationId: nil, bubbleUpEmojiOrStickersets: []) messages.append(message) } diff --git a/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift b/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift index 402b226194..d8d2908bae 100644 --- a/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift +++ b/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift @@ -1221,7 +1221,7 @@ public class VideoMessageCameraScreen: ViewController { let id = Int64.random(in: Int64.min ... Int64.max) let fileResource = LocalFileReferenceMediaResource(localFilePath: path, randomId: id) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: Int64(data.count), attributes: [.FileName(fileName: "video.mp4")]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: Int64(data.count), attributes: [.FileName(fileName: "video.mp4")], alternativeRepresentations: []) let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) let _ = enqueueMessages(account: self.context.engine.account, peerId: self.context.engine.account.peerId, messages: [message]).start() @@ -1877,7 +1877,7 @@ public class VideoMessageCameraScreen: ViewController { context.account.postbox.mediaBox.storeCachedResourceRepresentation(resource, representation: CachedVideoFirstFrameRepresentation(), data: data) } - let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.FileName(fileName: "video.mp4"), .Video(duration: finalDuration, size: video.dimensions, flags: [.instantRoundVideo], preloadSize: nil, coverTime: nil)]) + let media = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.FileName(fileName: "video.mp4"), .Video(duration: finalDuration, size: video.dimensions, flags: [.instantRoundVideo], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: []) var attributes: [MessageAttribute] = [] if self.cameraState.isViewOnceEnabled { diff --git a/submodules/TelegramUI/Sources/AppDelegate.swift b/submodules/TelegramUI/Sources/AppDelegate.swift index c3b1aedc8d..b39774b00d 100644 --- a/submodules/TelegramUI/Sources/AppDelegate.swift +++ b/submodules/TelegramUI/Sources/AppDelegate.swift @@ -1808,14 +1808,19 @@ private func extractAccountManagerState(records: AccountRecordsView MessageMediaEditingOptions return [] case .Animated: break - case let .Video(_, _, flags, _, _): + case let .Video(_, _, flags, _, _, _): if flags.contains(.instantRoundVideo) { return [] } else { @@ -983,7 +983,7 @@ func contextMenuForChatPresentationInterfaceState(chatPresentationInterfaceState strongController.dismiss() let id = Int64.random(in: Int64.min ... Int64.max) - let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: LocalFileReferenceMediaResource(localFilePath: logPath, randomId: id), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: nil, attributes: [.FileName(fileName: "CallStats.log")]) + let file = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: LocalFileReferenceMediaResource(localFilePath: logPath, randomId: id), previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "application/text", size: nil, attributes: [.FileName(fileName: "CallStats.log")], alternativeRepresentations: []) let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: []) let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [message]).startStandalone() diff --git a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift index d41b9923b6..94770716c2 100644 --- a/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift +++ b/submodules/TelegramUI/Sources/HorizontalListContextResultsChatInputPanelItem.swift @@ -260,7 +260,7 @@ final class HorizontalListContextResultsChatInputPanelItemNode: ListViewItemNode } imageDimensions = externalReference.content?.dimensions?.cgSize if externalReference.type == "gif", let thumbnailResource = externalReference.thumbnail?.resource, let content = externalReference.content, let dimensions = content.dimensions { - videoFile = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: thumbnailResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: dimensions, flags: [], preloadSize: nil, coverTime: nil)]) + videoFile = TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: thumbnailResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: []) imageResource = nil } diff --git a/submodules/TelegramUI/Sources/PeerMessagesMediaPlaylist.swift b/submodules/TelegramUI/Sources/PeerMessagesMediaPlaylist.swift index 9b28b54b49..9eb5ba1858 100644 --- a/submodules/TelegramUI/Sources/PeerMessagesMediaPlaylist.swift +++ b/submodules/TelegramUI/Sources/PeerMessagesMediaPlaylist.swift @@ -72,7 +72,7 @@ final class MessageMediaPlaylistItem: SharedMediaPlaylistItem { } else { return SharedMediaPlaybackData(type: .music, source: source) } - case let .Video(_, _, flags, _, _): + case let .Video(_, _, flags, _, _, _): if flags.contains(.instantRoundVideo) { return SharedMediaPlaybackData(type: .instantVideo, source: source) } else { @@ -129,7 +129,7 @@ final class MessageMediaPlaylistItem: SharedMediaPlaylistItem { displayData = SharedMediaPlaybackDisplayData.music(title: updatedTitle, performer: updatedPerformer, albumArt: albumArt, long: CGFloat(duration) > 10.0 * 60.0, caption: caption) } return displayData - case let .Video(_, _, flags, _, _): + case let .Video(_, _, flags, _, _, _): if flags.contains(.instantRoundVideo) { return SharedMediaPlaybackDisplayData.instantVideo(author: self.message.effectiveAuthor.flatMap(EnginePeer.init), peer: self.message.peers[self.message.id.peerId].flatMap(EnginePeer.init), timestamp: self.message.timestamp) } else { diff --git a/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift b/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift index cefaf5e573..011ccc1132 100644 --- a/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift +++ b/submodules/TelegramUIPreferences/Sources/ExperimentalUISettings.swift @@ -59,6 +59,7 @@ public struct ExperimentalUISettings: Codable, Equatable { public var allowWebViewInspection: Bool public var disableReloginTokens: Bool public var liveStreamV2: Bool + public var dynamicStreaming: Bool public static var defaultSettings: ExperimentalUISettings { return ExperimentalUISettings( @@ -95,7 +96,8 @@ public struct ExperimentalUISettings: Codable, Equatable { experimentalCallMute: false, allowWebViewInspection: false, disableReloginTokens: false, - liveStreamV2: false + liveStreamV2: false, + dynamicStreaming: false ) } @@ -133,7 +135,8 @@ public struct ExperimentalUISettings: Codable, Equatable { experimentalCallMute: Bool, allowWebViewInspection: Bool, disableReloginTokens: Bool, - liveStreamV2: Bool + liveStreamV2: Bool, + dynamicStreaming: Bool ) { self.keepChatNavigationStack = keepChatNavigationStack self.skipReadHistory = skipReadHistory @@ -169,6 +172,7 @@ public struct ExperimentalUISettings: Codable, Equatable { self.allowWebViewInspection = allowWebViewInspection self.disableReloginTokens = disableReloginTokens self.liveStreamV2 = liveStreamV2 + self.dynamicStreaming = dynamicStreaming } public init(from decoder: Decoder) throws { @@ -208,6 +212,7 @@ public struct ExperimentalUISettings: Codable, Equatable { self.allowWebViewInspection = try container.decodeIfPresent(Bool.self, forKey: "allowWebViewInspection") ?? false self.disableReloginTokens = try container.decodeIfPresent(Bool.self, forKey: "disableReloginTokens") ?? false self.liveStreamV2 = try container.decodeIfPresent(Bool.self, forKey: "liveStreamV2") ?? false + self.dynamicStreaming = try container.decodeIfPresent(Bool.self, forKey: "dynamicStreaming") ?? false } public func encode(to encoder: Encoder) throws { @@ -247,6 +252,7 @@ public struct ExperimentalUISettings: Codable, Equatable { try container.encode(self.allowWebViewInspection, forKey: "allowWebViewInspection") try container.encode(self.disableReloginTokens, forKey: "disableReloginTokens") try container.encode(self.liveStreamV2, forKey: "liveStreamV2") + try container.encode(self.dynamicStreaming, forKey: "dynamicStreaming") } } diff --git a/submodules/TelegramUniversalVideoContent/BUILD b/submodules/TelegramUniversalVideoContent/BUILD index 78bb6c366e..9baefbc0b0 100644 --- a/submodules/TelegramUniversalVideoContent/BUILD +++ b/submodules/TelegramUniversalVideoContent/BUILD @@ -23,6 +23,7 @@ swift_library( "//submodules/RadialStatusNode:RadialStatusNode", "//submodules/AppBundle:AppBundle", "//submodules/Utils/RangeSet:RangeSet", + "//submodules/TelegramVoip", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramUniversalVideoContent/Sources/HLSVideoContent.swift b/submodules/TelegramUniversalVideoContent/Sources/HLSVideoContent.swift new file mode 100644 index 0000000000..f04db2abf9 --- /dev/null +++ b/submodules/TelegramUniversalVideoContent/Sources/HLSVideoContent.swift @@ -0,0 +1,725 @@ +import Foundation +import UIKit +import AsyncDisplayKit +import Display +import SwiftSignalKit +import Postbox +import TelegramCore +import AVFoundation +import UniversalMediaPlayer +import TelegramAudio +import AccountContext +import PhotoResources +import RangeSet +import TelegramVoip + +public final class HLSVideoContent: UniversalVideoContent { + public let id: AnyHashable + public let nativeId: PlatformVideoContentId + let userLocation: MediaResourceUserLocation + public let fileReference: FileMediaReference + public let dimensions: CGSize + public let duration: Double + let streamVideo: Bool + let loopVideo: Bool + let enableSound: Bool + let baseRate: Double + let fetchAutomatically: Bool + + public init(id: PlatformVideoContentId, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, streamVideo: Bool = false, loopVideo: Bool = false, enableSound: Bool = true, baseRate: Double = 1.0, fetchAutomatically: Bool = true) { + self.id = id + self.userLocation = userLocation + self.nativeId = id + self.fileReference = fileReference + self.dimensions = self.fileReference.media.dimensions?.cgSize ?? CGSize(width: 480, height: 320) + self.duration = self.fileReference.media.duration ?? 0.0 + self.streamVideo = streamVideo + self.loopVideo = loopVideo + self.enableSound = enableSound + self.baseRate = baseRate + self.fetchAutomatically = fetchAutomatically + } + + public func makeContentNode(postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode { + return HLSVideoContentNode(postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, fileReference: self.fileReference, streamVideo: self.streamVideo, loopVideo: self.loopVideo, enableSound: self.enableSound, baseRate: self.baseRate, fetchAutomatically: self.fetchAutomatically) + } + + public func isEqual(to other: UniversalVideoContent) -> Bool { + if let other = other as? HLSVideoContent { + if case let .message(_, stableId, _) = self.nativeId { + if case .message(_, stableId, _) = other.nativeId { + if self.fileReference.media.isInstantVideo { + return true + } + } + } + } + return false + } +} + +private final class HLSVideoContentNode: ASDisplayNode, UniversalVideoContentNode { + private final class HLSServerSource: SharedHLSServer.Source { + let id: UUID + let postbox: Postbox + let userLocation: MediaResourceUserLocation + let playlistFiles: [Int: FileMediaReference] + let qualityFiles: [Int: FileMediaReference] + + private var playlistFetchDisposables: [Int: Disposable] = [:] + + init(id: UUID, postbox: Postbox, userLocation: MediaResourceUserLocation, playlistFiles: [Int: FileMediaReference], qualityFiles: [Int: FileMediaReference]) { + self.id = id + self.postbox = postbox + self.userLocation = userLocation + self.playlistFiles = playlistFiles + self.qualityFiles = qualityFiles + } + + deinit { + for (_, disposable) in self.playlistFetchDisposables { + disposable.dispose() + } + } + + func masterPlaylistData() -> Signal { + var playlistString: String = "" + playlistString.append("#EXTM3U\n") + + for (quality, file) in self.qualityFiles.sorted(by: { $0.key > $1.key }) { + let width = file.media.dimensions?.width ?? 1280 + let height = file.media.dimensions?.height ?? 720 + + let bandwidth: Int + if let size = file.media.size, let duration = file.media.duration, duration != 0.0 { + bandwidth = Int(Double(size) / duration) * 8 + } else { + bandwidth = 1000000 + } + + playlistString.append("#EXT-X-STREAM-INF:BANDWIDTH=\(bandwidth),RESOLUTION=\(width)x\(height)\n") + playlistString.append("hls_level_\(quality).m3u8\n") + } + return .single(playlistString) + } + + func playlistData(quality: Int) -> Signal { + guard let playlistFile = self.playlistFiles[quality] else { + return .never() + } + if self.playlistFetchDisposables[quality] == nil { + self.playlistFetchDisposables[quality] = freeMediaFileResourceInteractiveFetched(postbox: self.postbox, userLocation: self.userLocation, fileReference: playlistFile, resource: playlistFile.media.resource).startStrict() + } + + return self.postbox.mediaBox.resourceData(playlistFile.media.resource) + |> filter { data in + return data.complete + } + |> map { data -> String in + guard data.complete else { + return "" + } + guard let data = try? Data(contentsOf: URL(fileURLWithPath: data.path)) else { + return "" + } + guard var playlistString = String(data: data, encoding: .utf8) else { + return "" + } + let partRegex = try! NSRegularExpression(pattern: "mtproto:([\\d]+)", options: []) + let results = partRegex.matches(in: playlistString, range: NSRange(playlistString.startIndex..., in: playlistString)) + for result in results.reversed() { + if let range = Range(result.range, in: playlistString) { + if let fileIdRange = Range(result.range(at: 1), in: playlistString) { + let fileId = String(playlistString[fileIdRange]) + playlistString.replaceSubrange(range, with: "partfile\(fileId).mp4") + } + } + } + return playlistString + } + } + + func partData(index: Int, quality: Int) -> Signal { + return .never() + } + + func fileData(id: Int64, range: Range) -> Signal<(Data, Int)?, NoError> { + guard let file = self.qualityFiles.values.first(where: { $0.media.fileId.id == id }) else { + return .single(nil) + } + guard let size = file.media.size else { + return .single(nil) + } + + let postbox = self.postbox + let userLocation = self.userLocation + + let mappedRange: Range = Int64(range.lowerBound) ..< Int64(range.upperBound) + + return Signal { subscriber in + if let fetchResource = postbox.mediaBox.fetchResource { + let location = MediaResourceStorageLocation(userLocation: userLocation, reference: file.resourceReference(file.media.resource)) + let params = MediaResourceFetchParameters( + tag: TelegramMediaResourceFetchTag(statsCategory: .video, userContentType: .video), + info: TelegramCloudMediaResourceFetchInfo(reference: file.resourceReference(file.media.resource), preferBackgroundReferenceRevalidation: true, continueInBackground: true), + location: location, + contentType: .video, + isRandomAccessAllowed: true + ) + + final class StoredState { + let range: Range + var data: Data + var ranges: RangeSet + + init(range: Range) { + self.range = range + self.data = Data(count: Int(range.upperBound - range.lowerBound)) + self.ranges = RangeSet(range) + } + } + let storedState = Atomic(value: StoredState(range: mappedRange)) + + return fetchResource(file.media.resource, .single([(mappedRange, .elevated)]), params).start(next: { result in + switch result { + case let .dataPart(resourceOffset, data, _, _): + if !data.isEmpty { + let partRange = resourceOffset ..< (resourceOffset + Int64(data.count)) + var isReady = false + storedState.with { storedState in + let overlapRange = partRange.clamped(to: storedState.range) + guard !overlapRange.isEmpty else { + return + } + let innerRange = (overlapRange.lowerBound - storedState.range.lowerBound) ..< (overlapRange.upperBound - storedState.range.lowerBound) + let dataStart = overlapRange.lowerBound - partRange.lowerBound + let dataEnd = overlapRange.upperBound - partRange.lowerBound + let innerData = data.subdata(in: Int(dataStart) ..< Int(dataEnd)) + storedState.data.replaceSubrange(Int(innerRange.lowerBound) ..< Int(innerRange.upperBound), with: innerData) + storedState.ranges.subtract(RangeSet(overlapRange)) + if storedState.ranges.isEmpty { + isReady = true + } + } + if isReady { + subscriber.putNext((storedState.with({ $0.data }), Int(size))) + subscriber.putCompletion() + } + } + default: + break + } + }) + } else { + return EmptyDisposable + } + + /*let fetchDisposable = freeMediaFileResourceInteractiveFetched(postbox: postbox, userLocation: userLocation, fileReference: file, resource: file.media.resource, range: (mappedRange, .elevated)).startStandalone() + + let dataDisposable = postbox.mediaBox.resourceData(file.media.resource, size: size, in: mappedRange).startStandalone(next: { value, isComplete in + if isComplete { + subscriber.putNext((value, Int(size))) + subscriber.putCompletion() + } + }) + return ActionDisposable { + fetchDisposable.dispose() + dataDisposable.dispose() + }*/ + } + } + } + + private let postbox: Postbox + private let userLocation: MediaResourceUserLocation + private let fileReference: FileMediaReference + private let approximateDuration: Double + private let intrinsicDimensions: CGSize + + private let audioSessionManager: ManagedAudioSession + private let audioSessionDisposable = MetaDisposable() + private var hasAudioSession = false + + private let playbackCompletedListeners = Bag<() -> Void>() + + private var initializedStatus = false + private var statusValue = MediaPlayerStatus(generationTimestamp: 0.0, duration: 0.0, dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true) + private var isBuffering = false + private var seekId: Int = 0 + private let _status = ValuePromise() + var status: Signal { + return self._status.get() + } + + private let _bufferingStatus = Promise<(RangeSet, Int64)?>() + var bufferingStatus: Signal<(RangeSet, Int64)?, NoError> { + return self._bufferingStatus.get() + } + + private let _ready = Promise() + var ready: Signal { + return self._ready.get() + } + + private let _preloadCompleted = ValuePromise() + var preloadCompleted: Signal { + return self._preloadCompleted.get() + } + + private var playerSource: HLSServerSource? + private var serverDisposable: Disposable? + + private let imageNode: TransformImageNode + + private var playerItem: AVPlayerItem? + private let player: AVPlayer + private let playerNode: ASDisplayNode + + private var loadProgressDisposable: Disposable? + private var statusDisposable: Disposable? + + private var didPlayToEndTimeObserver: NSObjectProtocol? + private var didBecomeActiveObserver: NSObjectProtocol? + private var willResignActiveObserver: NSObjectProtocol? + private var failureObserverId: NSObjectProtocol? + private var errorObserverId: NSObjectProtocol? + private var playerItemFailedToPlayToEndTimeObserver: NSObjectProtocol? + + private let fetchDisposable = MetaDisposable() + + private var dimensions: CGSize? + private let dimensionsPromise = ValuePromise(CGSize()) + + private var validLayout: CGSize? + + private var statusTimer: Foundation.Timer? + + private var preferredVideoQuality: UniversalVideoContentVideoQuality = .auto + + init(postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, streamVideo: Bool, loopVideo: Bool, enableSound: Bool, baseRate: Double, fetchAutomatically: Bool) { + self.postbox = postbox + self.fileReference = fileReference + self.approximateDuration = fileReference.media.duration ?? 0.0 + self.audioSessionManager = audioSessionManager + self.userLocation = userLocation + + self.imageNode = TransformImageNode() + + var startTime = CFAbsoluteTimeGetCurrent() + + let player = AVPlayer(playerItem: nil) + self.player = player + if !enableSound { + player.volume = 0.0 + } + + print("Player created in \((CFAbsoluteTimeGetCurrent() - startTime) * 1000.0) ms") + + self.playerNode = ASDisplayNode() + self.playerNode.setLayerBlock({ + return AVPlayerLayer(player: player) + }) + + self.intrinsicDimensions = fileReference.media.dimensions?.cgSize ?? CGSize(width: 480.0, height: 320.0) + + self.playerNode.frame = CGRect(origin: CGPoint(), size: self.intrinsicDimensions) + + var qualityFiles: [Int: FileMediaReference] = [:] + for alternativeRepresentation in fileReference.media.alternativeRepresentations { + if let alternativeFile = alternativeRepresentation as? TelegramMediaFile { + for attribute in alternativeFile.attributes { + if case let .Video(_, size, _, _, _, videoCodec) = attribute { + let _ = size + if let videoCodec, NativeVideoContent.isVideoCodecSupported(videoCodec: videoCodec) { + qualityFiles[Int(size.height)] = fileReference.withMedia(alternativeFile) + } + } + } + } + } + /*for key in Array(qualityFiles.keys) { + if key != 144 && key != 720 { + qualityFiles.removeValue(forKey: key) + } + }*/ + var playlistFiles: [Int: FileMediaReference] = [:] + for alternativeRepresentation in fileReference.media.alternativeRepresentations { + if let alternativeFile = alternativeRepresentation as? TelegramMediaFile { + if alternativeFile.mimeType == "application/x-mpegurl" { + if let fileName = alternativeFile.fileName { + if fileName.hasPrefix("mtproto:") { + let fileIdString = String(fileName[fileName.index(fileName.startIndex, offsetBy: "mtproto:".count)...]) + if let fileId = Int64(fileIdString) { + for (quality, file) in qualityFiles { + if file.media.fileId.id == fileId { + playlistFiles[quality] = fileReference.withMedia(alternativeFile) + break + } + } + } + } + } + } + } + } + if !playlistFiles.isEmpty && playlistFiles.keys == qualityFiles.keys { + self.playerSource = HLSServerSource(id: UUID(), postbox: postbox, userLocation: userLocation, playlistFiles: playlistFiles, qualityFiles: qualityFiles) + } + + + super.init() + + self.imageNode.setSignal(internalMediaGridMessageVideo(postbox: postbox, userLocation: self.userLocation, videoReference: fileReference) |> map { [weak self] getSize, getData in + Queue.mainQueue().async { + if let strongSelf = self, strongSelf.dimensions == nil { + if let dimensions = getSize() { + strongSelf.dimensions = dimensions + strongSelf.dimensionsPromise.set(dimensions) + if let size = strongSelf.validLayout { + strongSelf.updateLayout(size: size, transition: .immediate) + } + } + } + } + return getData + }) + + self.addSubnode(self.imageNode) + self.addSubnode(self.playerNode) + self.player.actionAtItemEnd = .pause + + self.imageNode.imageUpdated = { [weak self] _ in + self?._ready.set(.single(Void())) + } + + self.player.addObserver(self, forKeyPath: "rate", options: [], context: nil) + + self._bufferingStatus.set(.single(nil)) + + startTime = CFAbsoluteTimeGetCurrent() + + if let playerSource = self.playerSource { + self.serverDisposable = SharedHLSServer.shared.registerPlayer(source: playerSource) + + let playerItem: AVPlayerItem + let assetUrl = "http://127.0.0.1:\(SharedHLSServer.shared.port)/\(playerSource.id)/master.m3u8" + #if DEBUG + print("HLSVideoContentNode: playing \(assetUrl)") + #endif + playerItem = AVPlayerItem(url: URL(string: assetUrl)!) + print("Player item created in \((CFAbsoluteTimeGetCurrent() - startTime) * 1000.0) ms") + + if #available(iOS 14.0, *) { + playerItem.startsOnFirstEligibleVariant = true + } + + startTime = CFAbsoluteTimeGetCurrent() + self.setPlayerItem(playerItem) + print("Set player item in \((CFAbsoluteTimeGetCurrent() - startTime) * 1000.0) ms") + } + + self.didPlayToEndTimeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: self.player.currentItem, queue: nil, using: { [weak self] notification in + self?.performActionAtEnd() + }) + + self.failureObserverId = NotificationCenter.default.addObserver(forName: AVPlayerItem.failedToPlayToEndTimeNotification, object: self.player.currentItem, queue: .main, using: { notification in + print("Player Error: \(notification.description)") + }) + self.errorObserverId = NotificationCenter.default.addObserver(forName: AVPlayerItem.newErrorLogEntryNotification, object: self.player.currentItem, queue: .main, using: { notification in + print("Player Error: \(notification.description)") + }) + + self.didBecomeActiveObserver = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil, using: { [weak self] _ in + guard let strongSelf = self, let layer = strongSelf.playerNode.layer as? AVPlayerLayer else { + return + } + layer.player = strongSelf.player + }) + self.willResignActiveObserver = NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil, using: { [weak self] _ in + guard let strongSelf = self, let layer = strongSelf.playerNode.layer as? AVPlayerLayer else { + return + } + layer.player = nil + }) + if let currentItem = self.player.currentItem { + currentItem.addObserver(self, forKeyPath: "presentationSize", options: [], context: nil) + } + } + + deinit { + self.player.removeObserver(self, forKeyPath: "rate") + if let currentItem = self.player.currentItem { + currentItem.removeObserver(self, forKeyPath: "presentationSize") + } + + self.setPlayerItem(nil) + + self.audioSessionDisposable.dispose() + + self.loadProgressDisposable?.dispose() + self.statusDisposable?.dispose() + + if let didPlayToEndTimeObserver = self.didPlayToEndTimeObserver { + NotificationCenter.default.removeObserver(didPlayToEndTimeObserver) + } + if let didBecomeActiveObserver = self.didBecomeActiveObserver { + NotificationCenter.default.removeObserver(didBecomeActiveObserver) + } + if let willResignActiveObserver = self.willResignActiveObserver { + NotificationCenter.default.removeObserver(willResignActiveObserver) + } + if let failureObserverId = self.failureObserverId { + NotificationCenter.default.removeObserver(failureObserverId) + } + if let errorObserverId = self.errorObserverId { + NotificationCenter.default.removeObserver(errorObserverId) + } + + self.serverDisposable?.dispose() + + self.statusTimer?.invalidate() + } + + private func setPlayerItem(_ item: AVPlayerItem?) { + if let playerItem = self.playerItem { + playerItem.removeObserver(self, forKeyPath: "playbackBufferEmpty") + playerItem.removeObserver(self, forKeyPath: "playbackLikelyToKeepUp") + playerItem.removeObserver(self, forKeyPath: "playbackBufferFull") + playerItem.removeObserver(self, forKeyPath: "status") + if let playerItemFailedToPlayToEndTimeObserver = self.playerItemFailedToPlayToEndTimeObserver { + NotificationCenter.default.removeObserver(playerItemFailedToPlayToEndTimeObserver) + self.playerItemFailedToPlayToEndTimeObserver = nil + } + } + + self.playerItem = item + + if let playerItem = self.playerItem { + playerItem.addObserver(self, forKeyPath: "playbackBufferEmpty", options: .new, context: nil) + playerItem.addObserver(self, forKeyPath: "playbackLikelyToKeepUp", options: .new, context: nil) + playerItem.addObserver(self, forKeyPath: "playbackBufferFull", options: .new, context: nil) + playerItem.addObserver(self, forKeyPath: "status", options: .new, context: nil) + self.playerItemFailedToPlayToEndTimeObserver = NotificationCenter.default.addObserver(forName: NSNotification.Name.AVPlayerItemFailedToPlayToEndTime, object: playerItem, queue: OperationQueue.main, using: { [weak self] _ in + guard let self else { + return + } + let _ = self + }) + } + + self.player.replaceCurrentItem(with: self.playerItem) + } + + private func updateStatus() { + let isPlaying = !self.player.rate.isZero + let status: MediaPlayerPlaybackStatus + if self.isBuffering { + status = .buffering(initial: false, whilePlaying: isPlaying, progress: 0.0, display: true) + } else { + status = isPlaying ? .playing : .paused + } + var timestamp = self.player.currentTime().seconds + if timestamp.isFinite && !timestamp.isNaN { + } else { + timestamp = 0.0 + } + self.statusValue = MediaPlayerStatus(generationTimestamp: CACurrentMediaTime(), duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: timestamp, baseRate: Double(self.player.rate), seekId: self.seekId, status: status, soundEnabled: true) + self._status.set(self.statusValue) + + if case .playing = status { + if self.statusTimer == nil { + self.statusTimer = Foundation.Timer.scheduledTimer(withTimeInterval: 1.0 / 30.0, repeats: true, block: { [weak self] _ in + guard let self else { + return + } + self.updateStatus() + }) + } + } else if let statusTimer = self.statusTimer { + self.statusTimer = nil + statusTimer.invalidate() + } + } + + override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { + if keyPath == "rate" { + let isPlaying = !self.player.rate.isZero + if isPlaying { + self.isBuffering = false + } + self.updateStatus() + } else if keyPath == "playbackBufferEmpty" { + self.isBuffering = true + self.updateStatus() + } else if keyPath == "playbackLikelyToKeepUp" || keyPath == "playbackBufferFull" { + self.isBuffering = false + self.updateStatus() + } else if keyPath == "presentationSize" { + if let currentItem = self.player.currentItem { + print("Presentation size: \(Int(currentItem.presentationSize.height))") + } + } + } + + private func performActionAtEnd() { + for listener in self.playbackCompletedListeners.copyItems() { + listener() + } + } + + func updateLayout(size: CGSize, transition: ContainedViewLayoutTransition) { + transition.updatePosition(node: self.playerNode, position: CGPoint(x: size.width / 2.0, y: size.height / 2.0)) + transition.updateTransformScale(node: self.playerNode, scale: size.width / self.intrinsicDimensions.width) + + transition.updateFrame(node: self.imageNode, frame: CGRect(origin: CGPoint(), size: size)) + + let makeImageLayout = self.imageNode.asyncLayout() + let applyImageLayout = makeImageLayout(TransformImageArguments(corners: ImageCorners(), imageSize: size, boundingSize: size, intrinsicInsets: UIEdgeInsets())) + applyImageLayout() + } + + func play() { + assert(Queue.mainQueue().isCurrent()) + if !self.initializedStatus { + self._status.set(MediaPlayerStatus(generationTimestamp: 0.0, duration: Double(self.approximateDuration), dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: self.seekId, status: .buffering(initial: true, whilePlaying: true, progress: 0.0, display: true), soundEnabled: true)) + } + if !self.hasAudioSession { + if self.player.volume != 0.0 { + self.audioSessionDisposable.set(self.audioSessionManager.push(audioSessionType: .play(mixWithOthers: false), activate: { [weak self] _ in + self?.hasAudioSession = true + self?.player.play() + }, deactivate: { [weak self] _ in + self?.hasAudioSession = false + self?.player.pause() + return .complete() + })) + } else { + self.player.play() + } + } else { + self.player.play() + } + } + + func pause() { + assert(Queue.mainQueue().isCurrent()) + self.player.pause() + } + + func togglePlayPause() { + assert(Queue.mainQueue().isCurrent()) + if self.player.rate.isZero { + self.play() + } else { + self.pause() + } + } + + func setSoundEnabled(_ value: Bool) { + assert(Queue.mainQueue().isCurrent()) + if value { + if !self.hasAudioSession { + self.audioSessionDisposable.set(self.audioSessionManager.push(audioSessionType: .play(mixWithOthers: false), activate: { [weak self] _ in + self?.hasAudioSession = true + self?.player.volume = 1.0 + }, deactivate: { [weak self] _ in + self?.hasAudioSession = false + self?.player.pause() + return .complete() + })) + } + } else { + self.player.volume = 0.0 + self.hasAudioSession = false + self.audioSessionDisposable.set(nil) + } + } + + func seek(_ timestamp: Double) { + assert(Queue.mainQueue().isCurrent()) + self.seekId += 1 + self.player.seek(to: CMTime(seconds: timestamp, preferredTimescale: 30)) + } + + func playOnceWithSound(playAndRecord: Bool, seek: MediaPlayerSeek, actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) { + self.player.volume = 1.0 + self.play() + } + + func setSoundMuted(soundMuted: Bool) { + self.player.volume = soundMuted ? 0.0 : 1.0 + } + + func continueWithOverridingAmbientMode(isAmbient: Bool) { + } + + func setForceAudioToSpeaker(_ forceAudioToSpeaker: Bool) { + } + + func continuePlayingWithoutSound(actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) { + self.player.volume = 0.0 + self.hasAudioSession = false + self.audioSessionDisposable.set(nil) + } + + func setContinuePlayingWithoutSoundOnLostAudioSession(_ value: Bool) { + } + + func setBaseRate(_ baseRate: Double) { + self.player.rate = Float(baseRate) + } + + func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) { + self.preferredVideoQuality = videoQuality + + guard let currentItem = self.player.currentItem else { + return + } + guard let playerSource = self.playerSource else { + return + } + + switch videoQuality { + case .auto: + currentItem.preferredPeakBitRate = 0.0 + case let .quality(qualityValue): + if let file = playerSource.qualityFiles[qualityValue] { + if let size = file.media.size, let duration = file.media.duration, duration != 0.0 { + let bandwidth = Int(Double(size) / duration) * 8 + currentItem.preferredPeakBitRate = Double(bandwidth) + } + } + } + + } + + func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? { + guard let currentItem = self.player.currentItem else { + return nil + } + guard let playerSource = self.playerSource else { + return nil + } + let current = Int(currentItem.presentationSize.height) + var available: [Int] = Array(playerSource.qualityFiles.keys) + available.sort(by: { $0 > $1 }) + return (current, self.preferredVideoQuality, available) + } + + func addPlaybackCompleted(_ f: @escaping () -> Void) -> Int { + return self.playbackCompletedListeners.add(f) + } + + func removePlaybackCompleted(_ index: Int) { + self.playbackCompletedListeners.remove(index) + } + + func fetchControl(_ control: UniversalVideoNodeFetchControl) { + } + + func notifyPlaybackControlsHidden(_ hidden: Bool) { + } + + func setCanPlaybackWithoutHierarchy(_ canPlaybackWithoutHierarchy: Bool) { + } +} diff --git a/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift b/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift index 3df84b6729..6bc07d1c0c 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/NativeVideoContent.swift @@ -40,6 +40,7 @@ public final class NativeVideoContent: UniversalVideoContent { public let beginWithAmbientSound: Bool public let mixWithOthers: Bool public let baseRate: Double + public let baseVideoQuality: UniversalVideoContentVideoQuality let fetchAutomatically: Bool let onlyFullSizeThumbnail: Bool let useLargeThumbnail: Bool @@ -56,7 +57,42 @@ public final class NativeVideoContent: UniversalVideoContent { let displayImage: Bool let hasSentFramesToDisplay: (() -> Void)? - public init(id: NativeVideoContentId, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, imageReference: ImageMediaReference? = nil, streamVideo: MediaPlayerStreaming = .none, loopVideo: Bool = false, enableSound: Bool = true, soundMuted: Bool = false, beginWithAmbientSound: Bool = false, mixWithOthers: Bool = false, baseRate: Double = 1.0, fetchAutomatically: Bool = true, onlyFullSizeThumbnail: Bool = false, useLargeThumbnail: Bool = false, autoFetchFullSizeThumbnail: Bool = false, startTimestamp: Double? = nil, endTimestamp: Double? = nil, continuePlayingWithoutSoundOnLostAudioSession: Bool = false, placeholderColor: UIColor = .white, tempFilePath: String? = nil, isAudioVideoMessage: Bool = false, captureProtected: Bool = false, hintDimensions: CGSize? = nil, storeAfterDownload: (() -> Void)?, displayImage: Bool = true, hasSentFramesToDisplay: (() -> Void)? = nil) { + public static func isVideoCodecSupported(videoCodec: String) -> Bool { + return videoCodec == "h264" || videoCodec == "h265" || videoCodec == "avc" || videoCodec == "hevc" + } + + public static func isHLSVideo(file: TelegramMediaFile) -> Bool { + for alternativeRepresentation in file.alternativeRepresentations { + if let alternativeFile = alternativeRepresentation as? TelegramMediaFile { + if alternativeFile.mimeType == "application/x-mpegurl" { + return true + } + } + } + return false + } + + public static func selectVideoQualityFile(file: TelegramMediaFile, quality: UniversalVideoContentVideoQuality) -> TelegramMediaFile { + guard case let .quality(qualityHeight) = quality else { + return file + } + for alternativeRepresentation in file.alternativeRepresentations { + if let alternativeFile = alternativeRepresentation as? TelegramMediaFile { + for attribute in alternativeFile.attributes { + if case let .Video(_, size, _, _, _, videoCodec) = attribute { + if let videoCodec, isVideoCodecSupported(videoCodec: videoCodec) { + if size.height == qualityHeight { + return alternativeFile + } + } + } + } + } + } + return file + } + + public init(id: NativeVideoContentId, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, imageReference: ImageMediaReference? = nil, streamVideo: MediaPlayerStreaming = .none, loopVideo: Bool = false, enableSound: Bool = true, soundMuted: Bool = false, beginWithAmbientSound: Bool = false, mixWithOthers: Bool = false, baseRate: Double = 1.0, baseVideoQuality: UniversalVideoContentVideoQuality = .auto, fetchAutomatically: Bool = true, onlyFullSizeThumbnail: Bool = false, useLargeThumbnail: Bool = false, autoFetchFullSizeThumbnail: Bool = false, startTimestamp: Double? = nil, endTimestamp: Double? = nil, continuePlayingWithoutSoundOnLostAudioSession: Bool = false, placeholderColor: UIColor = .white, tempFilePath: String? = nil, isAudioVideoMessage: Bool = false, captureProtected: Bool = false, hintDimensions: CGSize? = nil, storeAfterDownload: (() -> Void)?, displayImage: Bool = true, hasSentFramesToDisplay: (() -> Void)? = nil) { self.id = id self.nativeId = id self.userLocation = userLocation @@ -83,6 +119,7 @@ public final class NativeVideoContent: UniversalVideoContent { self.beginWithAmbientSound = beginWithAmbientSound self.mixWithOthers = mixWithOthers self.baseRate = baseRate + self.baseVideoQuality = baseVideoQuality self.fetchAutomatically = fetchAutomatically self.onlyFullSizeThumbnail = onlyFullSizeThumbnail self.useLargeThumbnail = useLargeThumbnail @@ -101,7 +138,7 @@ public final class NativeVideoContent: UniversalVideoContent { } public func makeContentNode(postbox: Postbox, audioSession: ManagedAudioSession) -> UniversalVideoContentNode & ASDisplayNode { - return NativeVideoContentNode(postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, fileReference: self.fileReference, imageReference: self.imageReference, streamVideo: self.streamVideo, loopVideo: self.loopVideo, enableSound: self.enableSound, soundMuted: self.soundMuted, beginWithAmbientSound: self.beginWithAmbientSound, mixWithOthers: self.mixWithOthers, baseRate: self.baseRate, fetchAutomatically: self.fetchAutomatically, onlyFullSizeThumbnail: self.onlyFullSizeThumbnail, useLargeThumbnail: self.useLargeThumbnail, autoFetchFullSizeThumbnail: self.autoFetchFullSizeThumbnail, startTimestamp: self.startTimestamp, endTimestamp: self.endTimestamp, continuePlayingWithoutSoundOnLostAudioSession: self.continuePlayingWithoutSoundOnLostAudioSession, placeholderColor: self.placeholderColor, tempFilePath: self.tempFilePath, isAudioVideoMessage: self.isAudioVideoMessage, captureProtected: self.captureProtected, hintDimensions: self.hintDimensions, storeAfterDownload: self.storeAfterDownload, displayImage: self.displayImage, hasSentFramesToDisplay: self.hasSentFramesToDisplay) + return NativeVideoContentNode(postbox: postbox, audioSessionManager: audioSession, userLocation: self.userLocation, fileReference: self.fileReference, imageReference: self.imageReference, streamVideo: self.streamVideo, loopVideo: self.loopVideo, enableSound: self.enableSound, soundMuted: self.soundMuted, beginWithAmbientSound: self.beginWithAmbientSound, mixWithOthers: self.mixWithOthers, baseRate: self.baseRate, baseVideoQuality: self.baseVideoQuality, fetchAutomatically: self.fetchAutomatically, onlyFullSizeThumbnail: self.onlyFullSizeThumbnail, useLargeThumbnail: self.useLargeThumbnail, autoFetchFullSizeThumbnail: self.autoFetchFullSizeThumbnail, startTimestamp: self.startTimestamp, endTimestamp: self.endTimestamp, continuePlayingWithoutSoundOnLostAudioSession: self.continuePlayingWithoutSoundOnLostAudioSession, placeholderColor: self.placeholderColor, tempFilePath: self.tempFilePath, isAudioVideoMessage: self.isAudioVideoMessage, captureProtected: self.captureProtected, hintDimensions: self.hintDimensions, storeAfterDownload: self.storeAfterDownload, displayImage: self.displayImage, hasSentFramesToDisplay: self.hasSentFramesToDisplay) } public func isEqual(to other: UniversalVideoContent) -> Bool { @@ -122,18 +159,21 @@ private final class NativeVideoContentNode: ASDisplayNode, UniversalVideoContent private let postbox: Postbox private let userLocation: MediaResourceUserLocation private let fileReference: FileMediaReference + private let streamVideo: MediaPlayerStreaming private let enableSound: Bool private let soundMuted: Bool private let beginWithAmbientSound: Bool private let mixWithOthers: Bool private let loopVideo: Bool private let baseRate: Double + private var baseVideoQuality: UniversalVideoContentVideoQuality private let audioSessionManager: ManagedAudioSession private let isAudioVideoMessage: Bool private let captureProtected: Bool + private let continuePlayingWithoutSoundOnLostAudioSession: Bool private let displayImage: Bool - private let player: MediaPlayer + private var player: MediaPlayer private var thumbnailPlayer: MediaPlayer? private let imageNode: TransformImageNode private let playerNode: MediaPlayerNode @@ -183,10 +223,11 @@ private final class NativeVideoContentNode: ASDisplayNode, UniversalVideoContent private let hasSentFramesToDisplay: (() -> Void)? - init(postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, imageReference: ImageMediaReference?, streamVideo: MediaPlayerStreaming, loopVideo: Bool, enableSound: Bool, soundMuted: Bool, beginWithAmbientSound: Bool, mixWithOthers: Bool, baseRate: Double, fetchAutomatically: Bool, onlyFullSizeThumbnail: Bool, useLargeThumbnail: Bool, autoFetchFullSizeThumbnail: Bool, startTimestamp: Double?, endTimestamp: Double?, continuePlayingWithoutSoundOnLostAudioSession: Bool = false, placeholderColor: UIColor, tempFilePath: String?, isAudioVideoMessage: Bool, captureProtected: Bool, hintDimensions: CGSize?, storeAfterDownload: (() -> Void)? = nil, displayImage: Bool, hasSentFramesToDisplay: (() -> Void)?) { + init(postbox: Postbox, audioSessionManager: ManagedAudioSession, userLocation: MediaResourceUserLocation, fileReference: FileMediaReference, imageReference: ImageMediaReference?, streamVideo: MediaPlayerStreaming, loopVideo: Bool, enableSound: Bool, soundMuted: Bool, beginWithAmbientSound: Bool, mixWithOthers: Bool, baseRate: Double, baseVideoQuality: UniversalVideoContentVideoQuality, fetchAutomatically: Bool, onlyFullSizeThumbnail: Bool, useLargeThumbnail: Bool, autoFetchFullSizeThumbnail: Bool, startTimestamp: Double?, endTimestamp: Double?, continuePlayingWithoutSoundOnLostAudioSession: Bool = false, placeholderColor: UIColor, tempFilePath: String?, isAudioVideoMessage: Bool, captureProtected: Bool, hintDimensions: CGSize?, storeAfterDownload: (() -> Void)? = nil, displayImage: Bool, hasSentFramesToDisplay: (() -> Void)?) { self.postbox = postbox self.userLocation = userLocation self.fileReference = fileReference + self.streamVideo = streamVideo self.placeholderColor = placeholderColor self.enableSound = enableSound self.soundMuted = soundMuted @@ -194,9 +235,11 @@ private final class NativeVideoContentNode: ASDisplayNode, UniversalVideoContent self.mixWithOthers = mixWithOthers self.loopVideo = loopVideo self.baseRate = baseRate + self.baseVideoQuality = baseVideoQuality self.audioSessionManager = audioSessionManager self.isAudioVideoMessage = isAudioVideoMessage self.captureProtected = captureProtected + self.continuePlayingWithoutSoundOnLostAudioSession = continuePlayingWithoutSoundOnLostAudioSession self.displayImage = displayImage self.hasSentFramesToDisplay = hasSentFramesToDisplay @@ -210,7 +253,9 @@ private final class NativeVideoContentNode: ASDisplayNode, UniversalVideoContent break } - self.player = MediaPlayer(audioSessionManager: audioSessionManager, postbox: postbox, userLocation: userLocation, userContentType: userContentType, resourceReference: fileReference.resourceReference(fileReference.media.resource), tempFilePath: tempFilePath, streamable: streamVideo, video: true, preferSoftwareDecoding: false, playAutomatically: false, enableSound: enableSound, baseRate: baseRate, fetchAutomatically: fetchAutomatically, soundMuted: soundMuted, ambient: beginWithAmbientSound, mixWithOthers: mixWithOthers, continuePlayingWithoutSoundOnLostAudioSession: continuePlayingWithoutSoundOnLostAudioSession, storeAfterDownload: storeAfterDownload, isAudioVideoMessage: isAudioVideoMessage) + let selectedFile = NativeVideoContent.selectVideoQualityFile(file: fileReference.media, quality: self.baseVideoQuality) + + self.player = MediaPlayer(audioSessionManager: audioSessionManager, postbox: postbox, userLocation: userLocation, userContentType: userContentType, resourceReference: fileReference.resourceReference(selectedFile.resource), tempFilePath: tempFilePath, streamable: streamVideo, video: true, preferSoftwareDecoding: false, playAutomatically: false, enableSound: enableSound, baseRate: baseRate, fetchAutomatically: fetchAutomatically, soundMuted: soundMuted, ambient: beginWithAmbientSound, mixWithOthers: mixWithOthers, continuePlayingWithoutSoundOnLostAudioSession: continuePlayingWithoutSoundOnLostAudioSession, storeAfterDownload: storeAfterDownload, isAudioVideoMessage: isAudioVideoMessage) var actionAtEndImpl: (() -> Void)? if enableSound && !loopVideo { @@ -279,7 +324,7 @@ private final class NativeVideoContentNode: ASDisplayNode, UniversalVideoContent return MediaPlayerStatus(generationTimestamp: status.generationTimestamp, duration: status.duration, dimensions: dimensions, timestamp: status.timestamp, baseRate: status.baseRate, seekId: status.seekId, status: status.status, soundEnabled: status.soundEnabled) }) - self.fetchStatusDisposable.set((postbox.mediaBox.resourceStatus(fileReference.media.resource) + self.fetchStatusDisposable.set((postbox.mediaBox.resourceStatus(selectedFile.resource) |> deliverOnMainQueue).start(next: { [weak self] status in guard let strongSelf = self else { return @@ -294,8 +339,8 @@ private final class NativeVideoContentNode: ASDisplayNode, UniversalVideoContent } })) - if let size = fileReference.media.size { - self._bufferingStatus.set(postbox.mediaBox.resourceRangesStatus(fileReference.media.resource) |> map { ranges in + if let size = selectedFile.size { + self._bufferingStatus.set(postbox.mediaBox.resourceRangesStatus(selectedFile.resource) |> map { ranges in return (ranges, size) }) } else { @@ -503,6 +548,98 @@ private final class NativeVideoContentNode: ASDisplayNode, UniversalVideoContent self.player.setBaseRate(baseRate) } + func setVideoQuality(_ quality: UniversalVideoContentVideoQuality) { + let _ = (self._status.get() + |> take(1) + |> deliverOnMainQueue).startStandalone(next: { [weak self] status in + guard let self else { + return + } + + if self.baseVideoQuality == quality { + return + } + self.baseVideoQuality = quality + + let selectedFile = NativeVideoContent.selectVideoQualityFile(file: self.fileReference.media, quality: self.baseVideoQuality) + + let updatedFileReference: FileMediaReference = self.fileReference.withMedia(selectedFile) + + var userContentType = MediaResourceUserContentType(file: selectedFile) + switch updatedFileReference { + case .story: + userContentType = .story + default: + break + } + + self._status.set(.never()) + self.player.pause() + + //TODO:release coordinate fetchAutomatically + self.player = MediaPlayer(audioSessionManager: self.audioSessionManager, postbox: self.postbox, userLocation: self.userLocation, userContentType: userContentType, resourceReference: updatedFileReference.resourceReference(selectedFile.resource), tempFilePath: nil, streamable: self.streamVideo, video: true, preferSoftwareDecoding: false, playAutomatically: false, enableSound: self.enableSound, baseRate: self.baseRate, fetchAutomatically: true, soundMuted: self.soundMuted, ambient: beginWithAmbientSound, mixWithOthers: mixWithOthers, continuePlayingWithoutSoundOnLostAudioSession: self.continuePlayingWithoutSoundOnLostAudioSession, storeAfterDownload: nil, isAudioVideoMessage: self.isAudioVideoMessage) + + var actionAtEndImpl: (() -> Void)? + if self.enableSound && !self.loopVideo { + self.player.actionAtEnd = .action({ + actionAtEndImpl?() + }) + } else { + self.player.actionAtEnd = .loop({ + actionAtEndImpl?() + }) + } + actionAtEndImpl = { [weak self] in + self?.performActionAtEnd() + } + + self._status.set(combineLatest(self.dimensionsPromise.get(), self.player.status) + |> map { dimensions, status in + return MediaPlayerStatus(generationTimestamp: status.generationTimestamp, duration: status.duration, dimensions: dimensions, timestamp: status.timestamp, baseRate: status.baseRate, seekId: status.seekId, status: status.status, soundEnabled: status.soundEnabled) + }) + + self.fetchStatusDisposable.set((self.postbox.mediaBox.resourceStatus(selectedFile.resource) + |> deliverOnMainQueue).start(next: { [weak self] status in + guard let strongSelf = self else { + return + } + switch status { + case .Local: + break + default: + if strongSelf.thumbnailPlayer == nil { + strongSelf.createThumbnailPlayer() + } + } + })) + + if let size = updatedFileReference.media.size { + self._bufferingStatus.set(postbox.mediaBox.resourceRangesStatus(selectedFile.resource) |> map { ranges in + return (ranges, size) + }) + } else { + self._bufferingStatus.set(.single(nil)) + } + + self.player.attachPlayerNode(self.playerNode) + + var play = false + switch status.status { + case .playing: + play = true + case let .buffering(_, whilePlaying, _, _): + play = whilePlaying + case .paused: + break + } + self.player.seek(timestamp: status.timestamp, play: play) + }) + } + + func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? { + return nil + } + func continuePlayingWithoutSound(actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd) { assert(Queue.mainQueue().isCurrent()) let action = { [weak self] in diff --git a/submodules/TelegramUniversalVideoContent/Sources/PlatformVideoContent.swift b/submodules/TelegramUniversalVideoContent/Sources/PlatformVideoContent.swift index 30bac1bc9c..f7a6529cb1 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/PlatformVideoContent.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/PlatformVideoContent.swift @@ -70,7 +70,7 @@ public final class PlatformVideoContent: UniversalVideoContent { } public let id: AnyHashable - let nativeId: PlatformVideoContentId + public let nativeId: PlatformVideoContentId let userLocation: MediaResourceUserLocation let content: Content public let dimensions: CGSize @@ -448,6 +448,13 @@ private final class PlatformVideoContentNode: ASDisplayNode, UniversalVideoConte func setBaseRate(_ baseRate: Double) { } + func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) { + } + + func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? { + return nil + } + func addPlaybackCompleted(_ f: @escaping () -> Void) -> Int { return self.playbackCompletedListeners.add(f) } diff --git a/submodules/TelegramUniversalVideoContent/Sources/SystemVideoContent.swift b/submodules/TelegramUniversalVideoContent/Sources/SystemVideoContent.swift index 756fcf51e9..66bca4d094 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/SystemVideoContent.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/SystemVideoContent.swift @@ -285,6 +285,13 @@ private final class SystemVideoContentNode: ASDisplayNode, UniversalVideoContent func setBaseRate(_ baseRate: Double) { } + func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) { + } + + func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? { + return nil + } + func addPlaybackCompleted(_ f: @escaping () -> Void) -> Int { return self.playbackCompletedListeners.add(f) } diff --git a/submodules/TelegramUniversalVideoContent/Sources/WebEmbedPlayerNode.swift b/submodules/TelegramUniversalVideoContent/Sources/WebEmbedPlayerNode.swift index a680f3b7a7..abe11070b1 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/WebEmbedPlayerNode.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/WebEmbedPlayerNode.swift @@ -2,7 +2,7 @@ import Foundation import UIKit import AsyncDisplayKit import SwiftSignalKit -import WebKit +@preconcurrency import WebKit import TelegramCore import UniversalMediaPlayer diff --git a/submodules/TelegramUniversalVideoContent/Sources/WebEmbedVideoContent.swift b/submodules/TelegramUniversalVideoContent/Sources/WebEmbedVideoContent.swift index d45c70364b..2cba6fdf64 100644 --- a/submodules/TelegramUniversalVideoContent/Sources/WebEmbedVideoContent.swift +++ b/submodules/TelegramUniversalVideoContent/Sources/WebEmbedVideoContent.swift @@ -183,6 +183,13 @@ final class WebEmbedVideoContentNode: ASDisplayNode, UniversalVideoContentNode { self.playerNode.setBaseRate(baseRate) } + func setVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) { + } + + func videoQualityState() -> (current: Int, preferred: UniversalVideoContentVideoQuality, available: [Int])? { + return nil + } + func addPlaybackCompleted(_ f: @escaping () -> Void) -> Int { return self.playbackCompletedListeners.add(f) } diff --git a/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift b/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift index 115ee7edb8..1acafab9a5 100644 --- a/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift +++ b/submodules/TelegramVoip/Sources/WrappedMediaStreamingContext.swift @@ -138,7 +138,7 @@ public final class WrappedMediaStreamingContext { } } @available(iOS 12.0, macOS 14.0, *) -public final class ExternalMediaStreamingContext { +public final class ExternalMediaStreamingContext: SharedHLSServerSource { private final class Impl { let queue: Queue @@ -274,21 +274,29 @@ public final class ExternalMediaStreamingContext { ) } } + + func fileData(id: Int64, range: Range) -> Signal<(Data, Int)?, NoError> { + return .never() + } } private let queue = Queue() - let id: CallSessionInternalId + let internalId: CallSessionInternalId private let impl: QueueLocalObject private var hlsServerDisposable: Disposable? + public var id: UUID { + return self.internalId + } + public init(id: CallSessionInternalId, rejoinNeeded: @escaping () -> Void) { - self.id = id + self.internalId = id let queue = self.queue self.impl = QueueLocalObject(queue: queue, generate: { return Impl(queue: queue, rejoinNeeded: rejoinNeeded) }) - self.hlsServerDisposable = SharedHLSServer.shared.registerPlayer(streamingContext: self) + self.hlsServerDisposable = SharedHLSServer.shared.registerPlayer(source: self) } deinit { @@ -322,9 +330,27 @@ public final class ExternalMediaStreamingContext { impl.partData(index: index, quality: quality).start(next: subscriber.putNext) } } + + public func fileData(id: Int64, range: Range) -> Signal<(Data, Int)?, NoError> { + return self.impl.signalWith { impl, subscriber in + impl.fileData(id: id, range: range).start(next: subscriber.putNext) + } + } } + +public protocol SharedHLSServerSource: AnyObject { + var id: UUID { get } + + func masterPlaylistData() -> Signal + func playlistData(quality: Int) -> Signal + func partData(index: Int, quality: Int) -> Signal + func fileData(id: Int64, range: Range) -> Signal<(Data, Int)?, NoError> +} + @available(iOS 12.0, macOS 14.0, *) public final class SharedHLSServer { + public typealias Source = SharedHLSServerSource + public static let shared: SharedHLSServer = { return SharedHLSServer() }() @@ -346,11 +372,11 @@ public final class SharedHLSServer { } } - private final class ContextReference { - weak var streamingContext: ExternalMediaStreamingContext? + private final class SourceReference { + weak var source: SharedHLSServerSource? - init(streamingContext: ExternalMediaStreamingContext) { - self.streamingContext = streamingContext + init(source: SharedHLSServerSource) { + self.source = source } } @available(iOS 12.0, macOS 14.0, *) @@ -360,7 +386,7 @@ public final class SharedHLSServer { private let port: NWEndpoint.Port private var listener: NWListener? - private var contextReferences = Bag() + private var sourceReferences = Bag() init(queue: Queue, port: UInt16) { self.queue = queue @@ -443,6 +469,20 @@ public final class SharedHLSServer { } let requestPath = String(firstLine[firstLine.startIndex ..< firstLine.index(firstLine.endIndex, offsetBy: -" HTTP/1.1".count)]) + var requestRange: Range? + if let rangeRange = requestString.range(of: "Range: bytes=") { + if let endRange = requestString.range(of: "\r\n", range: rangeRange.upperBound ..< requestString.endIndex) { + let rangeString = String(requestString[rangeRange.upperBound ..< endRange.lowerBound]) + if let dashRange = rangeString.range(of: "-") { + let lowerBoundString = String(rangeString[rangeString.startIndex ..< dashRange.lowerBound]) + let upperBoundString = String(rangeString[dashRange.upperBound ..< rangeString.endIndex]) + + if let lowerBound = Int(lowerBoundString), let upperBound = Int(upperBoundString) { + requestRange = lowerBound ..< upperBound + } + } + } + } guard let firstSlash = requestPath.range(of: "/") else { self.sendErrorAndClose(connection: connection, error: .notFound) @@ -452,14 +492,14 @@ public final class SharedHLSServer { self.sendErrorAndClose(connection: connection) return } - guard let streamingContext = self.contextReferences.copyItems().first(where: { $0.streamingContext?.id == streamId })?.streamingContext else { + guard let source = self.sourceReferences.copyItems().first(where: { $0.source?.id == streamId })?.source else { self.sendErrorAndClose(connection: connection) return } let filePath = String(requestPath[firstSlash.upperBound...]) if filePath == "master.m3u8" { - let _ = (streamingContext.masterPlaylistData() + let _ = (source.masterPlaylistData() |> deliverOn(self.queue) |> take(1)).start(next: { [weak self] result in guard let self else { @@ -474,7 +514,7 @@ public final class SharedHLSServer { return } - let _ = (streamingContext.playlistData(quality: levelIndex) + let _ = (source.playlistData(quality: levelIndex) |> deliverOn(self.queue) |> take(1)).start(next: { [weak self] result in guard let self else { @@ -497,7 +537,7 @@ public final class SharedHLSServer { self.sendErrorAndClose(connection: connection) return } - let _ = (streamingContext.partData(index: partIndex, quality: levelIndex) + let _ = (source.partData(index: partIndex, quality: levelIndex) |> deliverOn(self.queue) |> take(1)).start(next: { [weak self] result in guard let self else { @@ -529,6 +569,29 @@ public final class SharedHLSServer { self.sendErrorAndClose(connection: connection, error: .notFound) } }) + } else if filePath.hasPrefix("partfile") && filePath.hasSuffix(".mp4") { + let fileId = String(filePath[filePath.index(filePath.startIndex, offsetBy: "partfile".count) ..< filePath.index(filePath.endIndex, offsetBy: -".mp4".count)]) + guard let fileIdValue = Int64(fileId) else { + self.sendErrorAndClose(connection: connection) + return + } + guard let requestRange else { + self.sendErrorAndClose(connection: connection) + return + } + let _ = (source.fileData(id: fileIdValue, range: requestRange.lowerBound ..< requestRange.upperBound + 1) + |> deliverOn(self.queue) + |> take(1)).start(next: { [weak self] result in + guard let self else { + return + } + + if let (data, totalSize) = result { + self.sendResponseAndClose(connection: connection, data: data, range: requestRange, totalSize: totalSize) + } else { + self.sendErrorAndClose(connection: connection, error: .internalServerError) + } + }) } else { self.sendErrorAndClose(connection: connection, error: .notFound) } @@ -544,8 +607,16 @@ public final class SharedHLSServer { }) } - private func sendResponseAndClose(connection: NWConnection, data: Data) { - let responseHeaders = "HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nConnection: close\r\n\r\n" + private func sendResponseAndClose(connection: NWConnection, data: Data, range: Range? = nil, totalSize: Int? = nil) { + var responseHeaders = "HTTP/1.1 200 OK\r\n" + responseHeaders.append("Content-Length: \(data.count)\r\n") + if let range, let totalSize { + responseHeaders.append("Content-Range: bytes \(range.lowerBound)-\(range.upperBound)/\(totalSize)\r\n") + } + responseHeaders.append("Content-Type: application/octet-stream\r\n") + responseHeaders.append("Connection: close\r\n") + responseHeaders.append("Access-Control-Allow-Origin: *\r\n") + responseHeaders.append("\r\n") var responseData = Data() responseData.append(responseHeaders.data(using: .utf8)!) responseData.append(data) @@ -557,16 +628,16 @@ public final class SharedHLSServer { }) } - func registerPlayer(streamingContext: ExternalMediaStreamingContext) -> Disposable { + func registerPlayer(source: SharedHLSServerSource) -> Disposable { let queue = self.queue - let index = self.contextReferences.add(ContextReference(streamingContext: streamingContext)) + let index = self.sourceReferences.add(SourceReference(source: source)) return ActionDisposable { [weak self] in queue.async { guard let self else { return } - self.contextReferences.remove(index) + self.sourceReferences.remove(index) } } } @@ -584,11 +655,11 @@ public final class SharedHLSServer { }) } - fileprivate func registerPlayer(streamingContext: ExternalMediaStreamingContext) -> Disposable { + public func registerPlayer(source: SharedHLSServerSource) -> Disposable { let disposable = MetaDisposable() self.impl.with { impl in - disposable.set(impl.registerPlayer(streamingContext: streamingContext)) + disposable.set(impl.registerPlayer(source: source)) } return disposable diff --git a/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm b/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm index 66b6284805..4237c162a5 100644 --- a/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm +++ b/submodules/TgVoipWebrtc/Sources/OngoingCallThreadLocalContext.mm @@ -1781,7 +1781,11 @@ audioDevice:(SharedCallAudioDevice * _Nullable)audioDevice { NSMutableArray *result = [[NSMutableArray alloc] init]; for (auto &it : levels.updates) { [result addObject:@(it.ssrc)]; - [result addObject:@(it.value.level)]; + auto level = it.value.level; + if (it.value.isMuted) { + level = 0.0; + } + [result addObject:@(level)]; [result addObject:@(it.value.voice)]; } audioLevelsUpdated(result); diff --git a/submodules/WatchBridge/Sources/WatchBridge.swift b/submodules/WatchBridge/Sources/WatchBridge.swift index c7c75e42e1..8f88b40ff7 100644 --- a/submodules/WatchBridge/Sources/WatchBridge.swift +++ b/submodules/WatchBridge/Sources/WatchBridge.swift @@ -172,7 +172,7 @@ func makeBridgeMedia(message: Message, strings: PresentationStrings, chatPeer: P for attribute in file.attributes { switch attribute { - case let .Video(duration, size, flags, _, _): + case let .Video(duration, size, flags, _, _, _): bridgeVideo.duration = Int32(duration) bridgeVideo.dimensions = size.cgSize bridgeVideo.round = flags.contains(.instantRoundVideo) diff --git a/submodules/WatchBridge/Sources/WatchRequestHandlers.swift b/submodules/WatchBridge/Sources/WatchRequestHandlers.swift index 5c986acfb2..964990c99b 100644 --- a/submodules/WatchBridge/Sources/WatchRequestHandlers.swift +++ b/submodules/WatchBridge/Sources/WatchRequestHandlers.swift @@ -720,7 +720,7 @@ final class WatchAudioHandler: WatchRequestHandler { replyMessageId = MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: replyToMid) } - let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(data.count), attributes: [.Audio(isVoice: true, duration: Int(duration), title: nil, performer: nil, waveform: nil)])), threadId: nil, replyToMessageId: replyMessageId.flatMap { EngineMessageReplySubject(messageId: $0, quote: nil) }, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]).start() + let _ = enqueueMessages(account: context.account, peerId: peerId, messages: [.message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: TelegramMediaFile(fileId: MediaId(namespace: Namespaces.Media.LocalFile, id: randomId), partialReference: nil, resource: resource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "audio/ogg", size: Int64(data.count), attributes: [.Audio(isVoice: true, duration: Int(duration), title: nil, performer: nil, waveform: nil)], alternativeRepresentations: [])), threadId: nil, replyToMessageId: replyMessageId.flatMap { EngineMessageReplySubject(messageId: $0, quote: nil) }, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]).start() } }) } else { diff --git a/submodules/WebSearchUI/Sources/WebSearchGalleryController.swift b/submodules/WebSearchUI/Sources/WebSearchGalleryController.swift index f6b4715142..b9e7363af9 100644 --- a/submodules/WebSearchUI/Sources/WebSearchGalleryController.swift +++ b/submodules/WebSearchUI/Sources/WebSearchGalleryController.swift @@ -37,7 +37,7 @@ struct WebSearchGalleryEntry: Equatable { switch self.result { case let .externalReference(externalReference): if let content = externalReference.content, externalReference.type == "gif", let thumbnailResource = externalReference.thumbnail?.resource, let dimensions = content.dimensions { - let fileReference = FileMediaReference.standalone(media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: content.resource, previewRepresentations: [TelegramMediaImageRepresentation(dimensions: dimensions, resource: thumbnailResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: dimensions, flags: [], preloadSize: nil, coverTime: nil)])) + let fileReference = FileMediaReference.standalone(media: TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: 0), partialReference: nil, resource: content.resource, previewRepresentations: [TelegramMediaImageRepresentation(dimensions: dimensions, resource: thumbnailResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false)], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.Animated, .Video(duration: 0, size: dimensions, flags: [], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: [])) return WebSearchVideoGalleryItem(context: context, presentationData: presentationData, index: self.index, result: self.result, content: NativeVideoContent(id: .contextResult(self.result.queryId, self.result.id), userLocation: .other, fileReference: fileReference, loopVideo: true, enableSound: false, fetchAutomatically: true, storeAfterDownload: nil), controllerInteraction: controllerInteraction) } case let .internalReference(internalReference): diff --git a/submodules/WebUI/Sources/WebAppController.swift b/submodules/WebUI/Sources/WebAppController.swift index 73e4f1d321..9ee319ed4f 100644 --- a/submodules/WebUI/Sources/WebAppController.swift +++ b/submodules/WebUI/Sources/WebAppController.swift @@ -1,6 +1,6 @@ import Foundation import UIKit -import WebKit +@preconcurrency import WebKit import Display import AsyncDisplayKit import Postbox diff --git a/submodules/WidgetItemsUtils/Sources/WidgetItemsUtils.swift b/submodules/WidgetItemsUtils/Sources/WidgetItemsUtils.swift index e350f2ec7c..c643a025e0 100644 --- a/submodules/WidgetItemsUtils/Sources/WidgetItemsUtils.swift +++ b/submodules/WidgetItemsUtils/Sources/WidgetItemsUtils.swift @@ -22,7 +22,7 @@ public extension WidgetDataPeer.Message { switch attribute { case let .Sticker(altText, _, _): content = .sticker(WidgetDataPeer.Message.Content.Sticker(altText: altText)) - case let .Video(duration, _, flags, _, _): + case let .Video(duration, _, flags, _, _, _): if flags.contains(.instantRoundVideo) { content = .videoMessage(WidgetDataPeer.Message.Content.VideoMessage(duration: Int32(duration))) } else { diff --git a/versions.json b/versions.json index 50a7a34517..2d777b2e9e 100644 --- a/versions.json +++ b/versions.json @@ -1,6 +1,6 @@ { - "app": "11.1", + "app": "11.1.1", "xcode": "15.2", - "bazel": "7.1.1", + "bazel": "7.3.1", "macos": "13.0" }