From 21e432bc0c9806bce2023d77d4b3983714a4fe5d Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 24 Nov 2022 21:04:11 +0400 Subject: [PATCH 01/50] Just showing voice chat controller instead of media --- .../Sources/MediaStreamingController.swift | 7118 +++++++++++++++++ .../Sources/SharedAccountContext.swift | 4 +- 2 files changed, 7120 insertions(+), 2 deletions(-) create mode 100644 submodules/TelegramCallsUI/Sources/MediaStreamingController.swift diff --git a/submodules/TelegramCallsUI/Sources/MediaStreamingController.swift b/submodules/TelegramCallsUI/Sources/MediaStreamingController.swift new file mode 100644 index 0000000000..14a6f76f94 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/MediaStreamingController.swift @@ -0,0 +1,7118 @@ +import Foundation +import UIKit +import Display +import AsyncDisplayKit +import SwiftSignalKit +import TelegramPresentationData +import TelegramUIPreferences +import TelegramStringFormatting +import TelegramVoip +import TelegramAudio +import AccountContext +import Postbox +import TelegramCore +import MergeLists +import ItemListUI +import AppBundle +import ContextUI +import ShareController +import DeleteChatPeerActionSheetItem +import UndoUI +import AlertUI +import PresentationDataUtils +import DirectionalPanGesture +import PeerInfoUI +import AvatarNode +import TooltipUI +import LegacyUI +import LegacyComponents +import LegacyMediaPickerUI +import WebSearchUI +import MapResourceToAvatarSizes +import SolidRoundedButtonNode +import AudioBlob +import DeviceAccess + +//let panelBackgroundColor = UIColor(rgb: 0x1c1c1e) +//let secondaryPanelBackgroundColor = UIColor(rgb: 0x2c2c2e) +//let fullscreenBackgroundColor = UIColor(rgb: 0x000000) +private let smallButtonSize = CGSize(width: 36.0, height: 36.0) +private let sideButtonSize = CGSize(width: 56.0, height: 56.0) +private let topPanelHeight: CGFloat = 63.0 +//let bottomAreaHeight: CGFloat = 206.0 +private let fullscreenBottomAreaHeight: CGFloat = 80.0 +private let bottomGradientHeight: CGFloat = 70.0 + +/*func decorationCornersImage(top: Bool, bottom: Bool, dark: Bool) -> UIImage? { + if !top && !bottom { + return nil + } + return generateImage(CGSize(width: 50.0, height: 50.0), rotatedContext: { (size, context) in + let bounds = CGRect(origin: CGPoint(), size: size) + context.setFillColor((dark ? fullscreenBackgroundColor : panelBackgroundColor).cgColor) + context.fill(bounds) + + context.setBlendMode(.clear) + + var corners: UIRectCorner = [] + if top { + corners.insert(.topLeft) + corners.insert(.topRight) + } + if bottom { + corners.insert(.bottomLeft) + corners.insert(.bottomRight) + } + let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: 11.0, height: 11.0)) + context.addPath(path.cgPath) + context.fillPath() + })?.stretchableImage(withLeftCapWidth: 25, topCapHeight: 25) +} + +func decorationTopCornersImage(dark: Bool) -> UIImage? { + return generateImage(CGSize(width: 50.0, height: 110.0), rotatedContext: { (size, context) in + let bounds = CGRect(origin: CGPoint(), size: size) + context.setFillColor((dark ? fullscreenBackgroundColor : panelBackgroundColor).cgColor) + context.fill(bounds) + + context.setBlendMode(.clear) + + var corners: UIRectCorner = [] + corners.insert(.topLeft) + corners.insert(.topRight) + + let path = UIBezierPath(roundedRect: CGRect(x: 0.0, y: 60.0, width: 50.0, height: 50.0), byRoundingCorners: corners, cornerRadii: CGSize(width: 11.0, height: 11.0)) + context.addPath(path.cgPath) + context.fillPath() + })?.stretchableImage(withLeftCapWidth: 25, topCapHeight: 32) +} + +func decorationBottomCornersImage(dark: Bool) -> UIImage? { + return generateImage(CGSize(width: 50.0, height: 110.0), rotatedContext: { (size, context) in + let bounds = CGRect(origin: CGPoint(), size: size) + context.setFillColor((dark ? fullscreenBackgroundColor : panelBackgroundColor).cgColor) + context.fill(bounds) + + context.setBlendMode(.clear) + + var corners: UIRectCorner = [] + corners.insert(.bottomLeft) + corners.insert(.bottomRight) + + let path = UIBezierPath(roundedRect: CGRect(x: 0.0, y: 0.0, width: 50.0, height: 50.0), byRoundingCorners: corners, cornerRadii: CGSize(width: 11.0, height: 11.0)) + context.addPath(path.cgPath) + context.fillPath() + })?.resizableImage(withCapInsets: UIEdgeInsets(top: 25.0, left: 25.0, bottom: 0.0, right: 25.0), resizingMode: .stretch) +} +*/ +private func decorationBottomGradientImage(dark: Bool) -> UIImage? { + return generateImage(CGSize(width: 24.0, height: bottomGradientHeight), rotatedContext: { size, context in + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + + let color = dark ? fullscreenBackgroundColor : panelBackgroundColor + let colorsArray = [color.withAlphaComponent(0.0).cgColor, color.cgColor] as CFArray + var locations: [CGFloat] = [1.0, 0.0] + let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colorsArray, locations: &locations)! + context.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) + }) +} +/* +struct VoiceChatPeerEntry: Identifiable { + enum State { + case listening + case speaking + case invited + case raisedHand + } + + var peer: Peer + var about: String? + var isMyPeer: Bool + var videoEndpointId: String? + var videoPaused: Bool + var presentationEndpointId: String? + var presentationPaused: Bool + var effectiveSpeakerVideoEndpointId: String? + var state: State + var muteState: GroupCallParticipantsContext.Participant.MuteState? + var canManageCall: Bool + var volume: Int32? + var raisedHand: Bool + var displayRaisedHandStatus: Bool + var active: Bool + var isLandscape: Bool + + var effectiveVideoEndpointId: String? { + return self.presentationEndpointId ?? self.videoEndpointId + } + + init( + peer: Peer, + about: String?, + isMyPeer: Bool, + videoEndpointId: String?, + videoPaused: Bool, + presentationEndpointId: String?, + presentationPaused: Bool, + effectiveSpeakerVideoEndpointId: String?, + state: State, + muteState: GroupCallParticipantsContext.Participant.MuteState?, + canManageCall: Bool, + volume: Int32?, + raisedHand: Bool, + displayRaisedHandStatus: Bool, + active: Bool, + isLandscape: Bool + ) { + self.peer = peer + self.about = about + self.isMyPeer = isMyPeer + self.videoEndpointId = videoEndpointId + self.videoPaused = videoPaused + self.presentationEndpointId = presentationEndpointId + self.presentationPaused = presentationPaused + self.effectiveSpeakerVideoEndpointId = effectiveSpeakerVideoEndpointId + self.state = state + self.muteState = muteState + self.canManageCall = canManageCall + self.volume = volume + self.raisedHand = raisedHand + self.displayRaisedHandStatus = displayRaisedHandStatus + self.active = active + self.isLandscape = isLandscape + } + + var stableId: PeerId { + return self.peer.id + } + + static func ==(lhs: VoiceChatPeerEntry, rhs: VoiceChatPeerEntry) -> Bool { + if !lhs.peer.isEqual(rhs.peer) { + return false + } + if lhs.about != rhs.about { + return false + } + if lhs.isMyPeer != rhs.isMyPeer { + return false + } + if lhs.videoEndpointId != rhs.videoEndpointId { + return false + } + if lhs.videoPaused != rhs.videoPaused { + return false + } + if lhs.presentationEndpointId != rhs.presentationEndpointId { + return false + } + if lhs.presentationPaused != rhs.presentationPaused { + return false + } + if lhs.effectiveSpeakerVideoEndpointId != rhs.effectiveSpeakerVideoEndpointId { + return false + } + if lhs.state != rhs.state { + return false + } + if lhs.muteState != rhs.muteState { + return false + } + if lhs.canManageCall != rhs.canManageCall { + return false + } + if lhs.volume != rhs.volume { + return false + } + if lhs.raisedHand != rhs.raisedHand { + return false + } + if lhs.displayRaisedHandStatus != rhs.displayRaisedHandStatus { + return false + } + if lhs.active != rhs.active { + return false + } + if lhs.isLandscape != rhs.isLandscape { + return false + } + return true + } +} +*/ + +//public protocol VoiceChatController: ViewController { +// var call: PresentationGroupCall { get } +// var currentOverlayController: VoiceChatOverlayController? { get } +// var parentNavigationController: NavigationController? { get set } +// +// func dismiss(closing: Bool, manual: Bool) +//} + +public final class MediaStreamingControllerImpl: ViewController, VoiceChatController { + enum DisplayMode { + case modal(isExpanded: Bool, isFilled: Bool) + case fullscreen(controlsHidden: Bool) + } + + fileprivate final class Node: ViewControllerTracingNode, UIGestureRecognizerDelegate { + private struct ListTransition { + let deletions: [ListViewDeleteItem] + let insertions: [ListViewInsertItem] + let updates: [ListViewUpdateItem] + let isLoading: Bool + let isEmpty: Bool + let canInvite: Bool + let crossFade: Bool + let count: Int + let animated: Bool + } + + private final class Interaction { + let updateIsMuted: (PeerId, Bool) -> Void + let switchToPeer: (PeerId, String?, Bool) -> Void + let openInvite: () -> Void + let peerContextAction: (VoiceChatPeerEntry, ASDisplayNode, ContextGesture?, Bool) -> Void + let getPeerVideo: (String, GroupVideoNode.Position) -> GroupVideoNode? + var isExpanded: Bool = false + + private var audioLevels: [PeerId: ValuePipe] = [:] + + var updateAvatarPromise = Promise<(TelegramMediaImageRepresentation, Float)?>(nil) + + init( + updateIsMuted: @escaping (PeerId, Bool) -> Void, + switchToPeer: @escaping (PeerId, String?, Bool) -> Void, + openInvite: @escaping () -> Void, + peerContextAction: @escaping (VoiceChatPeerEntry, ASDisplayNode, ContextGesture?, Bool) -> Void, + getPeerVideo: @escaping (String, GroupVideoNode.Position) -> GroupVideoNode? + ) { + self.updateIsMuted = updateIsMuted + self.switchToPeer = switchToPeer + self.openInvite = openInvite + self.peerContextAction = peerContextAction + self.getPeerVideo = getPeerVideo + } + + func getAudioLevel(_ peerId: PeerId) -> Signal { + let signal: Signal + if let current = self.audioLevels[peerId] { + signal = current.signal() + } else { + let value = ValuePipe() + self.audioLevels[peerId] = value + signal = value.signal() + } + return signal + |> mapToSignal { value in + return .single(value) + } + } + + func updateAudioLevels(_ levels: [(PeerId, UInt32, Float, Bool)], reset: Bool = false) { + var updated = Set() + for (peerId, _, level, _) in levels { + if let pipe = self.audioLevels[peerId] { + if reset { + pipe.putNext(level) + } else { + pipe.putNext(max(0.001, level)) + } + updated.insert(peerId) + } + } + if !reset { + for (peerId, pipe) in self.audioLevels { + if !updated.contains(peerId) { + pipe.putNext(0.0) + } + } + } + } + } + + private enum EntryId: Hashable { + case tiles + case invite + case peerId(PeerId) + + static func <(lhs: EntryId, rhs: EntryId) -> Bool { + return lhs.hashValue < rhs.hashValue + } + + static func ==(lhs: EntryId, rhs: EntryId) -> Bool { + switch lhs { + case .tiles: + switch rhs { + case .tiles: + return true + default: + return false + } + case .invite: + switch rhs { + case .invite: + return true + default: + return false + } + case let .peerId(lhsId): + switch rhs { + case let .peerId(rhsId): + return lhsId == rhsId + default: + return false + } + } + } + } + + private enum ListEntry: Comparable, Identifiable { + case tiles([VoiceChatTileItem], VoiceChatTileLayoutMode, Int32, Bool) + case invite(PresentationTheme, PresentationStrings, String, Bool) + case peer(VoiceChatPeerEntry, Int32) + + var stableId: EntryId { + switch self { + case .tiles: + return .tiles + case .invite: + return .invite + case let .peer(peerEntry, _): + return .peerId(peerEntry.peer.id) + } + } + + static func ==(lhs: ListEntry, rhs: ListEntry) -> Bool { + switch lhs { + case let .tiles(lhsTiles, lhsLayoutMode, lhsVideoLimit, lhsReachedLimit): + if case let .tiles(rhsTiles, rhsLayoutMode, rhsVideoLimit, rhsReachedLimit) = rhs, lhsTiles == rhsTiles, lhsLayoutMode == rhsLayoutMode, lhsVideoLimit == rhsVideoLimit, lhsReachedLimit == rhsReachedLimit { + return true + } else { + return false + } + case let .invite(lhsTheme, lhsStrings, lhsText, lhsIsLink): + if case let .invite(rhsTheme, rhsStrings, rhsText, rhsIsLink) = rhs, lhsTheme === rhsTheme, lhsStrings === rhsStrings, lhsText == rhsText, lhsIsLink == rhsIsLink { + return true + } else { + return false + } + case let .peer(lhsPeerEntry, lhsIndex): + switch rhs { + case let .peer(rhsPeerEntry, rhsIndex): + return lhsPeerEntry == rhsPeerEntry && lhsIndex == rhsIndex + default: + return false + } + } + } + + static func <(lhs: ListEntry, rhs: ListEntry) -> Bool { + switch lhs { + case .tiles: + return true + case .invite: + return false + case let .peer(_, lhsIndex): + switch rhs { + case .tiles: + return false + case let .peer(_, rhsIndex): + return lhsIndex < rhsIndex + case .invite: + return true + } + } + } + + func tileItem(context: AccountContext, presentationData: PresentationData, interaction: Interaction, isTablet: Bool, videoEndpointId: String, videoReady: Bool, videoTimeouted: Bool, videoIsPaused: Bool, showAsPresentation: Bool, secondary: Bool) -> VoiceChatTileItem? { + guard case let .peer(peerEntry, _) = self else { + return nil + } + let peer = peerEntry.peer + + let icon: VoiceChatTileItem.Icon + var text: VoiceChatParticipantItem.ParticipantText + var additionalText: VoiceChatParticipantItem.ParticipantText? + var speaking = false + + var textIcon = VoiceChatParticipantItem.ParticipantText.TextIcon() + let yourText: String + if (peerEntry.about?.isEmpty ?? true) && peer.smallProfileImage == nil { + yourText = presentationData.strings.VoiceChat_TapToAddPhotoOrBio + } else if peer.smallProfileImage == nil { + yourText = presentationData.strings.VoiceChat_TapToAddPhoto + } else if (peerEntry.about?.isEmpty ?? true) { + yourText = presentationData.strings.VoiceChat_TapToAddBio + } else { + yourText = presentationData.strings.VoiceChat_You + } + + var state = peerEntry.state + if let muteState = peerEntry.muteState, case .speaking = state, muteState.mutedByYou || !muteState.canUnmute { + state = .listening + } + switch state { + case .listening: + if peerEntry.isMyPeer { + text = .text(yourText, textIcon, .accent) + } else if let muteState = peerEntry.muteState, muteState.mutedByYou { + text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) + } else if let about = peerEntry.about, !about.isEmpty { + text = .text(about, textIcon, .generic) + } else { + text = .text(presentationData.strings.VoiceChat_StatusListening, textIcon, .generic) + } + if let muteState = peerEntry.muteState, muteState.mutedByYou { + icon = .microphone(true) + additionalText = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) + } else { + icon = .microphone(peerEntry.muteState != nil) + } + case .speaking: + if let muteState = peerEntry.muteState, muteState.mutedByYou { + text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) + icon = .microphone(true) + additionalText = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) + } else { + if peerEntry.volume != nil { + textIcon.insert(.volume) + } + let volumeValue = peerEntry.volume.flatMap { $0 / 100 } + if let volume = volumeValue, volume != 100 { + text = .text( presentationData.strings.VoiceChat_StatusSpeakingVolume("\(volume)%").string, textIcon, .constructive) + } else { + text = .text(presentationData.strings.VoiceChat_StatusSpeaking, textIcon, .constructive) + } + icon = .microphone(false) + speaking = true + } + case .raisedHand, .invited: + text = .none + icon = .none + } + + if let about = peerEntry.about, !about.isEmpty { + textIcon = [] + text = .text(about, textIcon, .generic) + } + + return VoiceChatTileItem(account: context.account, peer: peerEntry.peer, videoEndpointId: videoEndpointId, videoReady: videoReady, videoTimeouted: videoTimeouted, isVideoLimit: false, videoLimit: 0, isPaused: videoIsPaused, isOwnScreencast: peerEntry.presentationEndpointId == videoEndpointId && peerEntry.isMyPeer, strings: presentationData.strings, nameDisplayOrder: presentationData.nameDisplayOrder, speaking: speaking, secondary: secondary, isTablet: isTablet, icon: showAsPresentation ? .presentation : icon, text: text, additionalText: additionalText, action: { + interaction.switchToPeer(peer.id, videoEndpointId, !secondary) + }, contextAction: { node, gesture in + interaction.peerContextAction(peerEntry, node, gesture, false) + }, getVideo: { position in + return interaction.getPeerVideo(videoEndpointId, position) + }, getAudioLevel: { + return interaction.getAudioLevel(peerEntry.peer.id) + }) + } + + func fullscreenItem(context: AccountContext, presentationData: PresentationData, interaction: Interaction) -> ListViewItem { + switch self { + case .tiles: + return VoiceChatActionItem(presentationData: ItemListPresentationData(presentationData), title: "", icon: .none, action: { + }) + case .invite: + return VoiceChatActionItem(presentationData: ItemListPresentationData(presentationData), title: "", icon: .generic(UIImage(bundleImageName: "Chat/Context Menu/AddUser")!), action: { + interaction.openInvite() + }) + case let .peer(peerEntry, _): + let peer = peerEntry.peer + var textColor: VoiceChatFullscreenParticipantItem.Color = .generic + var color: VoiceChatFullscreenParticipantItem.Color = .generic + let icon: VoiceChatFullscreenParticipantItem.Icon + var text: VoiceChatParticipantItem.ParticipantText + + var textIcon = VoiceChatParticipantItem.ParticipantText.TextIcon() + let yourText: String + if (peerEntry.about?.isEmpty ?? true) && peer.smallProfileImage == nil { + yourText = presentationData.strings.VoiceChat_TapToAddPhotoOrBio + } else if peer.smallProfileImage == nil { + yourText = presentationData.strings.VoiceChat_TapToAddPhoto + } else if (peerEntry.about?.isEmpty ?? true) { + yourText = presentationData.strings.VoiceChat_TapToAddBio + } else { + yourText = presentationData.strings.VoiceChat_You + } + + var state = peerEntry.state + if let muteState = peerEntry.muteState, case .speaking = state, muteState.mutedByYou || !muteState.canUnmute { + state = .listening + } + switch state { + case .listening: + if peerEntry.isMyPeer { + text = .text(yourText, textIcon, .accent) + } else if let muteState = peerEntry.muteState, muteState.mutedByYou { + text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) + } else if let about = peerEntry.about, !about.isEmpty { + text = .text(about, textIcon, .generic) + } else { + text = .text(presentationData.strings.VoiceChat_StatusListening, textIcon, .generic) + } + if let muteState = peerEntry.muteState, muteState.mutedByYou { + textColor = .destructive + color = .destructive + icon = .microphone(true, UIColor(rgb: 0xff3b30)) + } else { + icon = .microphone(peerEntry.muteState != nil, UIColor.white) + color = .accent + } + case .speaking: + if let muteState = peerEntry.muteState, muteState.mutedByYou { + text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) + textColor = .destructive + color = .destructive + icon = .microphone(true, UIColor(rgb: 0xff3b30)) + } else { + if peerEntry.volume != nil { + textIcon.insert(.volume) + } + let volumeValue = peerEntry.volume.flatMap { $0 / 100 } + if let volume = volumeValue, volume != 100 { + text = .text( presentationData.strings.VoiceChat_StatusSpeakingVolume("\(volume)%").string, textIcon, .constructive) + } else { + text = .text(presentationData.strings.VoiceChat_StatusSpeaking, textIcon, .constructive) + } + icon = .microphone(false, UIColor(rgb: 0x34c759)) + textColor = .constructive + color = .constructive + } + case .raisedHand: + text = .none + textColor = .accent + icon = .wantsToSpeak + case .invited: + text = .none + icon = .none + } + + if let about = peerEntry.about, !about.isEmpty { + textIcon = [] + text = .text(about, textIcon, .generic) + } + + var videoEndpointId = peerEntry.effectiveVideoEndpointId + var otherVideoEndpointId: String? + let hasBothVideos = peerEntry.presentationEndpointId != nil && peerEntry.videoEndpointId != nil + if hasBothVideos { + if let effectiveVideoEndpointId = peerEntry.effectiveSpeakerVideoEndpointId { + if effectiveVideoEndpointId == peerEntry.videoEndpointId { + videoEndpointId = peerEntry.presentationEndpointId + otherVideoEndpointId = videoEndpointId + } else if effectiveVideoEndpointId == peerEntry.presentationEndpointId { + videoEndpointId = peerEntry.videoEndpointId + otherVideoEndpointId = videoEndpointId + } + } + } + + var isPaused = false + if videoEndpointId == peerEntry.videoEndpointId { + isPaused = peerEntry.videoPaused + } else if videoEndpointId == peerEntry.presentationEndpointId { + isPaused = peerEntry.presentationPaused + } + + return VoiceChatFullscreenParticipantItem(presentationData: ItemListPresentationData(presentationData), nameDisplayOrder: presentationData.nameDisplayOrder, context: context, peer: peerEntry.peer, videoEndpointId: videoEndpointId, isPaused: isPaused, icon: icon, text: text, textColor: textColor, color: color, isLandscape: peerEntry.isLandscape, active: peerEntry.active, showVideoWhenActive: otherVideoEndpointId != nil, getAudioLevel: { return interaction.getAudioLevel(peerEntry.peer.id) }, getVideo: { + if let videoEndpointId = videoEndpointId { + return interaction.getPeerVideo(videoEndpointId, .list) + } else { + return nil + } + }, action: { _ in + interaction.switchToPeer(peerEntry.peer.id, otherVideoEndpointId, false) + }, contextAction: { node, gesture in + interaction.peerContextAction(peerEntry, node, gesture, true) + }, getUpdatingAvatar: { + return interaction.updateAvatarPromise.get() + }) + } + } + + func item(context: AccountContext, presentationData: PresentationData, interaction: Interaction) -> ListViewItem { + switch self { + case let .tiles(tiles, layoutMode, videoLimit, reachedLimit): + return VoiceChatTilesGridItem(context: context, tiles: tiles, layoutMode: layoutMode, videoLimit: videoLimit, reachedLimit: reachedLimit, getIsExpanded: { + return interaction.isExpanded + }) + case let .invite(_, _, text, isLink): + return VoiceChatActionItem(presentationData: ItemListPresentationData(presentationData), title: text, icon: .generic(UIImage(bundleImageName: isLink ? "Chat/Context Menu/Link" : "Chat/Context Menu/AddUser")!), action: { + interaction.openInvite() + }) + case let .peer(peerEntry, _): + let peer = peerEntry.peer + + var text: VoiceChatParticipantItem.ParticipantText + var expandedText: VoiceChatParticipantItem.ParticipantText? + let icon: VoiceChatParticipantItem.Icon + + var state = peerEntry.state + if let muteState = peerEntry.muteState, case .speaking = state, muteState.mutedByYou || !muteState.canUnmute { + state = .listening + } + + var textIcon = VoiceChatParticipantItem.ParticipantText.TextIcon() + let yourText: String + if (peerEntry.about?.isEmpty ?? true) && peer.smallProfileImage == nil { + yourText = presentationData.strings.VoiceChat_TapToAddPhotoOrBio + } else if peer.smallProfileImage == nil { + yourText = presentationData.strings.VoiceChat_TapToAddPhoto + } else if (peerEntry.about?.isEmpty ?? true) { + yourText = presentationData.strings.VoiceChat_TapToAddBio + } else { + yourText = presentationData.strings.VoiceChat_You + } + + switch state { + case .listening: + if peerEntry.isMyPeer { + text = .text(yourText, textIcon, .accent) + } else if let muteState = peerEntry.muteState, muteState.mutedByYou { + text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) + } else if let about = peerEntry.about, !about.isEmpty { + text = .text(about, textIcon, .generic) + } else { + text = .text(presentationData.strings.VoiceChat_StatusListening, textIcon, .generic) + } + let microphoneColor: UIColor + if let muteState = peerEntry.muteState, !muteState.canUnmute || muteState.mutedByYou { + microphoneColor = UIColor(rgb: 0xff3b30) + } else { + microphoneColor = UIColor(rgb: 0x979797) + } + icon = .microphone(peerEntry.muteState != nil, microphoneColor) + case .speaking: + if let muteState = peerEntry.muteState, muteState.mutedByYou { + text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) + icon = .microphone(true, UIColor(rgb: 0xff3b30)) + } else { + if peerEntry.volume != nil { + textIcon.insert(.volume) + } + let volumeValue = peerEntry.volume.flatMap { $0 / 100 } + if let volume = volumeValue, volume != 100 { + text = .text( presentationData.strings.VoiceChat_StatusSpeakingVolume("\(volume)%").string, textIcon, .constructive) + } else { + text = .text(presentationData.strings.VoiceChat_StatusSpeaking, textIcon, .constructive) + } + icon = .microphone(false, UIColor(rgb: 0x34c759)) + } + case .invited: + text = .text(presentationData.strings.VoiceChat_StatusInvited, textIcon, .generic) + icon = .invite(true) + case .raisedHand: + if peerEntry.isMyPeer && !peerEntry.displayRaisedHandStatus { + text = .text(yourText, textIcon, .accent) + } else if let about = peerEntry.about, !about.isEmpty && !peerEntry.displayRaisedHandStatus { + text = .text(about, textIcon, .generic) + } else { + text = .text(presentationData.strings.VoiceChat_StatusWantsToSpeak, textIcon, .accent) + } + icon = .wantsToSpeak + } + + if let about = peerEntry.about, !about.isEmpty { + textIcon = [] + expandedText = .text(about, textIcon, .generic) + } + + return VoiceChatParticipantItem(presentationData: ItemListPresentationData(presentationData), dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, context: context, peer: peer, text: text, expandedText: expandedText, icon: icon, getAudioLevel: { return interaction.getAudioLevel(peer.id) }, action: { node in + if let node = node { + interaction.peerContextAction(peerEntry, node, nil, false) + } + }, contextAction: { node, gesture in + interaction.peerContextAction(peerEntry, node, gesture, false) + }, getIsExpanded: { + return interaction.isExpanded + }, getUpdatingAvatar: { + return interaction.updateAvatarPromise.get() + }) + } + } + } + + private func preparedTransition(from fromEntries: [ListEntry], to toEntries: [ListEntry], isLoading: Bool, isEmpty: Bool, canInvite: Bool, crossFade: Bool, animated: Bool, context: AccountContext, presentationData: PresentationData, interaction: Interaction) -> ListTransition { + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) + + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } + + return ListTransition(deletions: deletions, insertions: insertions, updates: updates, isLoading: isLoading, isEmpty: isEmpty, canInvite: canInvite, crossFade: crossFade, count: toEntries.count, animated: animated) + } + + private func preparedFullscreenTransition(from fromEntries: [ListEntry], to toEntries: [ListEntry], isLoading: Bool, isEmpty: Bool, canInvite: Bool, crossFade: Bool, animated: Bool, context: AccountContext, presentationData: PresentationData, interaction: Interaction) -> ListTransition { + let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) + + let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } + let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.fullscreenItem(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } + let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.fullscreenItem(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } + + return ListTransition(deletions: deletions, insertions: insertions, updates: updates, isLoading: isLoading, isEmpty: isEmpty, canInvite: canInvite, crossFade: crossFade, count: toEntries.count, animated: animated) + } + + private let currentAvatarMixin = Atomic(value: nil) + + private var configuration: VoiceChatConfiguration? + + private weak var controller: MediaStreamingControllerImpl? + private let sharedContext: SharedAccountContext + private let context: AccountContext + private let call: PresentationGroupCall + private var presentationData: PresentationData + private var presentationDataDisposable: Disposable? + private var darkTheme: PresentationTheme + + private let dimNode: ASDisplayNode + private let contentContainer: ASDisplayNode + private let backgroundNode: ASDisplayNode + private let listContainer: ASDisplayNode + private let listNode: ListView + private let fullscreenListContainer: ASDisplayNode + private let fullscreenListNode: ListView + private let tileGridNode: VoiceChatTileGridNode + private let topPanelNode: ASDisplayNode + private let topPanelEdgeNode: ASDisplayNode + private let topPanelBackgroundNode: ASDisplayNode + private let optionsButton: VoiceChatHeaderButton + private let closeButton: VoiceChatHeaderButton + private let panelButton: VoiceChatHeaderButton + private let topCornersNode: ASImageNode + fileprivate let bottomPanelNode: ASDisplayNode + private let bottomGradientNode: ASDisplayNode + private let bottomPanelBackgroundNode: ASDisplayNode + private let bottomCornersNode: ASImageNode + fileprivate let audioButton: CallControllerButtonItemNode + fileprivate let cameraButton: CallControllerButtonItemNode + fileprivate let switchCameraButton: CallControllerButtonItemNode + fileprivate let leaveButton: CallControllerButtonItemNode + fileprivate let actionButton: VoiceChatActionButton + private let leftBorderNode: ASDisplayNode + private let rightBorderNode: ASDisplayNode + private let mainStageContainerNode: ASDisplayNode + private let mainStageBackgroundNode: ASDisplayNode + private let mainStageNode: VoiceChatMainStageNode + + private let transitionMaskView: UIView + private let transitionMaskTopFillLayer: CALayer + private let transitionMaskFillLayer: CALayer + private let transitionMaskGradientLayer: CAGradientLayer + private let transitionMaskBottomFillLayer: CALayer + private let transitionContainerNode: ASDisplayNode + + private var isScheduling = false + private let timerNode: VoiceChatTimerNode + private var pickerView: UIDatePicker? + private let dateFormatter: DateFormatter + private let scheduleTextNode: ImmediateTextNode + private let scheduleCancelButton: SolidRoundedButtonNode + private var scheduleButtonTitle = "" + + private let titleNode: VoiceChatTitleNode + private let participantsNode: VoiceChatTimerNode + + private var enqueuedTransitions: [ListTransition] = [] + private var enqueuedFullscreenTransitions: [ListTransition] = [] + + private var validLayout: (ContainerViewLayout, CGFloat)? + private var didSetContentsReady: Bool = false + private var didSetDataReady: Bool = false + + private var isFirstTime = true + private var topInset: CGFloat? + + private var animatingInsertion = false + private var animatingExpansion = false + private var animatingAppearance = false + private var animatingButtonsSwap = false + private var animatingMainStage = false + private var animatingContextMenu = false + private var panGestureArguments: (topInset: CGFloat, offset: CGFloat)? + private var isPanning = false + + private var peer: Peer? + private var currentTitle: String = "" + private var currentTitleIsCustom = false + private var currentSubtitle: String = "" + private var currentSpeakingSubtitle: String? + private var currentCallMembers: ([GroupCallParticipantsContext.Participant], String?)? + private var currentTotalCount: Int32 = 0 + private var currentInvitedPeers: [EnginePeer]? + private var currentSpeakingPeers: Set? + private var currentContentOffset: CGFloat? + private var currentNormalButtonColor: UIColor? + private var currentActiveButtonColor: UIColor? + + private var myEntry: VoiceChatPeerEntry? + private var mainEntry: VoiceChatPeerEntry? + private var currentEntries: [ListEntry] = [] + private var currentFullscreenEntries: [ListEntry] = [] + private var currentTileItems: [VoiceChatTileItem] = [] + private var displayPanelVideos = false + private var joinedVideo: Bool? + + private var peerViewDisposable: Disposable? + private let leaveDisposable = MetaDisposable() + + private var isMutedDisposable: Disposable? + private var isNoiseSuppressionEnabled: Bool = true + private var isNoiseSuppressionEnabledDisposable: Disposable? + private var callStateDisposable: Disposable? + + private var pushingToTalk = false + private var temporaryPushingToTalk = false + private let hapticFeedback = HapticFeedback() + + private var callState: PresentationGroupCallState? + + private var currentLoadToken: String? + + private var scrollAtTop = true + + private var effectiveMuteState: GroupCallParticipantsContext.Participant.MuteState? { + if self.pushingToTalk { + return nil + } else { + return self.callState?.muteState + } + } + + private var audioOutputStateDisposable: Disposable? + private var audioOutputState: ([AudioSessionOutput], AudioSessionOutput?)? + + private var audioLevelsDisposable: Disposable? + private var myAudioLevelDisposable: Disposable? + private var isSpeakingDisposable: Disposable? + private var memberStatesDisposable: Disposable? + private var actionButtonColorDisposable: Disposable? + + private var itemInteraction: Interaction? + + private let inviteDisposable = MetaDisposable() + private let memberEventsDisposable = MetaDisposable() + private let reconnectedAsEventsDisposable = MetaDisposable() + private let stateVersionDisposable = MetaDisposable() + private var applicationStateDisposable: Disposable? + + private let displayAsPeersPromise = Promise<[FoundPeer]>([]) + private let inviteLinksPromise = Promise(nil) + + private var raisedHandDisplayDisposables: [PeerId: Disposable] = [:] + private var displayedRaisedHands = Set() { + didSet { + self.displayedRaisedHandsPromise.set(self.displayedRaisedHands) + } + } + private let displayedRaisedHandsPromise = ValuePromise>(Set()) + + private var requestedVideoSources = Set() + private var requestedVideoChannels: [PresentationGroupCallRequestedVideo] = [] + + private var videoRenderingContext: VideoRenderingContext + private var videoNodes: [String: GroupVideoNode] = [:] + private var wideVideoNodes = Set() + private var videoOrder: [String] = [] + private var readyVideoEndpointIds = Set() + private var readyVideoEndpointIdsPromise = ValuePromise>(Set()) + private var timeoutedEndpointIds = Set() + private var readyVideoDisposables = DisposableDict() + private var myPeerVideoReadyDisposable = MetaDisposable() + + private var peerIdToEndpointId: [PeerId: String] = [:] + + private var currentSpeakers: [PeerId] = [] + private var currentDominantSpeaker: (PeerId, String?, Double)? + private var currentForcedSpeaker: (PeerId, String?)? + private var effectiveSpeaker: (PeerId, String?, Bool, Bool, Bool)? + + private var updateAvatarDisposable = MetaDisposable() + private let updateAvatarPromise = Promise<(TelegramMediaImageRepresentation, Float)?>(nil) + private var currentUpdatingAvatar: TelegramMediaImageRepresentation? + + private var connectedOnce = false + private var ignoreConnecting = false + private var ignoreConnectingTimer: SwiftSignalKit.Timer? + + private var displayUnmuteTooltipTimer: SwiftSignalKit.Timer? + private var dismissUnmuteTooltipTimer: SwiftSignalKit.Timer? + private var lastUnmuteTooltipDisplayTimestamp: Double? + + private var panelHidden = false + private var displayMode: DisplayMode = .modal(isExpanded: false, isFilled: false) { + didSet { + if case let .modal(isExpanded, _) = self.displayMode { + self.itemInteraction?.isExpanded = isExpanded + } else { + self.itemInteraction?.isExpanded = true + } + } + } + + private var isExpanded: Bool { + switch self.displayMode { + case .modal(true, _), .fullscreen: + return true + default: + return false + } + } + + private var statsDisposable: Disposable? + + init(controller: MediaStreamingControllerImpl, sharedContext: SharedAccountContext, call: PresentationGroupCall) { + self.controller = controller + self.sharedContext = sharedContext + self.context = call.accountContext + self.call = call + + self.videoRenderingContext = VideoRenderingContext() + + self.isScheduling = call.schedulePending + + let presentationData = sharedContext.currentPresentationData.with { $0 } + self.presentationData = presentationData + + self.darkTheme = defaultDarkColorPresentationTheme + self.currentSubtitle = self.presentationData.strings.SocksProxySetup_ProxyStatusConnecting + + self.dimNode = ASDisplayNode() + self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5) + + self.contentContainer = ASDisplayNode() + self.contentContainer.isHidden = true + + self.backgroundNode = ASDisplayNode() + self.backgroundNode.backgroundColor = self.isScheduling ? panelBackgroundColor : secondaryPanelBackgroundColor + self.backgroundNode.clipsToBounds = false + + self.listContainer = ASDisplayNode() + + self.listNode = ListView() + self.listNode.alpha = self.isScheduling ? 0.0 : 1.0 + self.listNode.isUserInteractionEnabled = !self.isScheduling + self.listNode.verticalScrollIndicatorColor = UIColor(white: 1.0, alpha: 0.3) + self.listNode.clipsToBounds = true + self.listNode.scroller.bounces = false + self.listNode.accessibilityPageScrolledString = { row, count in + return presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } + + self.fullscreenListContainer = ASDisplayNode() + self.fullscreenListContainer.isHidden = true + + self.fullscreenListNode = ListView() + self.fullscreenListNode.transform = CATransform3DMakeRotation(-CGFloat(CGFloat.pi / 2.0), 0.0, 0.0, 1.0) + self.fullscreenListNode.clipsToBounds = true + self.fullscreenListNode.accessibilityPageScrolledString = { row, count in + return presentationData.strings.VoiceOver_ScrollStatus(row, count).string + } + + self.tileGridNode = VoiceChatTileGridNode(context: self.context) + + self.topPanelNode = ASDisplayNode() + self.topPanelNode.clipsToBounds = false + + self.topPanelBackgroundNode = ASDisplayNode() + self.topPanelBackgroundNode.backgroundColor = panelBackgroundColor + self.topPanelBackgroundNode.isUserInteractionEnabled = false + + self.topPanelEdgeNode = ASDisplayNode() + self.topPanelEdgeNode.backgroundColor = panelBackgroundColor + self.topPanelEdgeNode.cornerRadius = 12.0 + self.topPanelEdgeNode.isUserInteractionEnabled = false + if #available(iOS 11.0, *) { + self.topPanelEdgeNode.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + } + + self.optionsButton = VoiceChatHeaderButton(context: self.context) + self.optionsButton.setContent(.more(optionsCircleImage(dark: false))) + self.closeButton = VoiceChatHeaderButton(context: self.context) + self.closeButton.setContent(.image(closeButtonImage(dark: false))) + self.panelButton = VoiceChatHeaderButton(context: self.context, wide: true) + self.panelButton.setContent(.image(panelButtonImage(dark: false))) + + self.titleNode = VoiceChatTitleNode(theme: self.presentationData.theme) + + self.topCornersNode = ASImageNode() + self.topCornersNode.displaysAsynchronously = false + self.topCornersNode.displayWithoutProcessing = true + self.topCornersNode.image = decorationTopCornersImage(dark: false) + self.topCornersNode.isUserInteractionEnabled = false + + self.bottomPanelNode = ASDisplayNode() + self.bottomPanelNode.clipsToBounds = false + + self.bottomPanelBackgroundNode = ASDisplayNode() + self.bottomPanelBackgroundNode.backgroundColor = panelBackgroundColor + self.bottomPanelBackgroundNode.isUserInteractionEnabled = false + + self.bottomGradientNode = ASDisplayNode() + self.bottomGradientNode.displaysAsynchronously = false + self.bottomGradientNode.backgroundColor = decorationBottomGradientImage(dark: false).flatMap { UIColor(patternImage: $0) } + + self.bottomCornersNode = ASImageNode() + self.bottomCornersNode.displaysAsynchronously = false + self.bottomCornersNode.displayWithoutProcessing = true + self.bottomCornersNode.image = decorationBottomCornersImage(dark: false) + self.bottomCornersNode.isUserInteractionEnabled = false + + self.audioButton = CallControllerButtonItemNode() + self.cameraButton = CallControllerButtonItemNode(largeButtonSize: sideButtonSize.width) + self.switchCameraButton = CallControllerButtonItemNode() + self.switchCameraButton.alpha = 0.0 + self.switchCameraButton.isUserInteractionEnabled = false + self.leaveButton = CallControllerButtonItemNode() + self.actionButton = VoiceChatActionButton() + + if self.isScheduling { + self.cameraButton.alpha = 0.0 + self.cameraButton.isUserInteractionEnabled = false + self.audioButton.alpha = 0.0 + self.audioButton.isUserInteractionEnabled = false + self.leaveButton.alpha = 0.0 + self.leaveButton.isUserInteractionEnabled = false + } + + self.leftBorderNode = ASDisplayNode() + self.leftBorderNode.backgroundColor = panelBackgroundColor + self.leftBorderNode.isUserInteractionEnabled = false + self.leftBorderNode.clipsToBounds = false + + self.rightBorderNode = ASDisplayNode() + self.rightBorderNode.backgroundColor = panelBackgroundColor + self.rightBorderNode.isUserInteractionEnabled = false + self.rightBorderNode.clipsToBounds = false + + self.mainStageContainerNode = ASDisplayNode() + self.mainStageContainerNode.clipsToBounds = true + self.mainStageContainerNode.isUserInteractionEnabled = false + self.mainStageContainerNode.isHidden = true + + self.mainStageBackgroundNode = ASDisplayNode() + self.mainStageBackgroundNode.backgroundColor = .black + self.mainStageBackgroundNode.alpha = 0.0 + self.mainStageBackgroundNode.isUserInteractionEnabled = false + + self.mainStageNode = VoiceChatMainStageNode(context: self.context, call: self.call) + + self.transitionMaskView = UIView() + self.transitionMaskTopFillLayer = CALayer() + self.transitionMaskTopFillLayer.backgroundColor = UIColor.white.cgColor + self.transitionMaskTopFillLayer.opacity = 0.0 + + self.transitionMaskFillLayer = CALayer() + self.transitionMaskFillLayer.backgroundColor = UIColor.white.cgColor + + self.transitionMaskGradientLayer = CAGradientLayer() + self.transitionMaskGradientLayer.colors = [UIColor.white.cgColor, UIColor.white.withAlphaComponent(0.0).cgColor] + self.transitionMaskGradientLayer.locations = [0.0, 1.0] + self.transitionMaskGradientLayer.startPoint = CGPoint(x: 0.0, y: 0.0) + self.transitionMaskGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) + + self.transitionMaskBottomFillLayer = CALayer() + self.transitionMaskBottomFillLayer.backgroundColor = UIColor.white.cgColor + self.transitionMaskBottomFillLayer.opacity = 0.0 + + self.transitionMaskView.layer.addSublayer(self.transitionMaskTopFillLayer) + self.transitionMaskView.layer.addSublayer(self.transitionMaskFillLayer) + self.transitionMaskView.layer.addSublayer(self.transitionMaskGradientLayer) + self.transitionMaskView.layer.addSublayer(self.transitionMaskBottomFillLayer) + + self.transitionContainerNode = ASDisplayNode() + self.transitionContainerNode.clipsToBounds = true + self.transitionContainerNode.isUserInteractionEnabled = false + self.transitionContainerNode.view.mask = self.transitionMaskView +// self.transitionContainerNode.view.addSubview(self.transitionMaskView) + + self.scheduleTextNode = ImmediateTextNode() + self.scheduleTextNode.isHidden = !self.isScheduling + self.scheduleTextNode.isUserInteractionEnabled = false + self.scheduleTextNode.textAlignment = .center + self.scheduleTextNode.maximumNumberOfLines = 4 + + self.scheduleCancelButton = SolidRoundedButtonNode(title: self.presentationData.strings.Common_Cancel, theme: SolidRoundedButtonTheme(backgroundColor: UIColor(rgb: 0x2b2b2f), foregroundColor: .white), height: 52.0, cornerRadius: 10.0) + self.scheduleCancelButton.isHidden = !self.isScheduling + + self.dateFormatter = DateFormatter() + self.dateFormatter.timeStyle = .none + self.dateFormatter.dateStyle = .short + self.dateFormatter.timeZone = TimeZone.current + + self.timerNode = VoiceChatTimerNode(strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat) + self.timerNode.isHidden = true + + self.participantsNode = VoiceChatTimerNode(strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat) + + super.init() + + let context = self.context + let currentAccountPeer = self.context.account.postbox.loadedPeerWithId(context.account.peerId) + |> map { peer in + return [FoundPeer(peer: peer, subscribers: nil)] + } + + self.isNoiseSuppressionEnabledDisposable = (call.isNoiseSuppressionEnabled + |> deliverOnMainQueue).start(next: { [weak self] value in + guard let strongSelf = self else { + return + } + strongSelf.isNoiseSuppressionEnabled = value + }) + + let displayAsPeers: Signal<[FoundPeer], NoError> = currentAccountPeer + |> then( + combineLatest(currentAccountPeer, context.engine.calls.cachedGroupCallDisplayAsAvailablePeers(peerId: call.peerId)) + |> map { currentAccountPeer, availablePeers -> [FoundPeer] in + var result = currentAccountPeer + result.append(contentsOf: availablePeers) + return result + } + ) + self.displayAsPeersPromise.set(displayAsPeers) + + self.inviteLinksPromise.set(.single(nil) + |> then(call.inviteLinks)) + + self.itemInteraction = Interaction(updateIsMuted: { [weak self] peerId, isMuted in + let _ = self?.call.updateMuteState(peerId: peerId, isMuted: isMuted) + }, switchToPeer: { [weak self] peerId, videoEndpointId, expand in + if let strongSelf = self, strongSelf.connectedOnce { + if expand, let videoEndpointId = videoEndpointId { + strongSelf.currentDominantSpeaker = (peerId, videoEndpointId, CACurrentMediaTime() + 3.0) + strongSelf.updateDisplayMode(.fullscreen(controlsHidden: false)) + } else { + strongSelf.currentForcedSpeaker = nil + if peerId != strongSelf.currentDominantSpeaker?.0 || (videoEndpointId != nil && videoEndpointId != strongSelf.currentDominantSpeaker?.1) { + strongSelf.currentDominantSpeaker = (peerId, videoEndpointId, CACurrentMediaTime()) + } + strongSelf.updateMainVideo(waitForFullSize: true, updateMembers: true, force: true) + } + } + }, openInvite: { [weak self] in + guard let strongSelf = self else { + return + } + + let groupPeer = strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.call.peerId)) + let _ = combineLatest(queue: Queue.mainQueue(), groupPeer, strongSelf.inviteLinksPromise.get() |> take(1)).start(next: { groupPeer, inviteLinks in + guard let strongSelf = self else { + return + } + guard let groupPeer = groupPeer else { + return + } + + 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 = inviteLinks { + strongSelf.presentShare(inviteLinks) + } + return + } + } + + var filters: [ChannelMembersSearchFilter] = [] + if let (currentCallMembers, _) = strongSelf.currentCallMembers { + filters.append(.disable(Array(currentCallMembers.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: strongSelf.context, peerId: groupPeer.id, forceTheme: strongSelf.darkTheme, mode: .inviteToCall, filters: filters, openPeer: { peer, participant in + guard let strongSelf = self else { + dismissController?() + return + } + + let peer = EnginePeer(peer) + + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } + if peer.id == strongSelf.callState?.myPeerId { + return + } + if let participant = participant { + dismissController?() + + if strongSelf.call.invitePeer(participant.peer.id) { + let text: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + text = strongSelf.presentationData.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string + } else { + text = strongSelf.presentationData.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string + } + strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: EnginePeer(participant.peer), text: text, action: nil), action: { _ in return false }) + } + } else { + if case let .channel(groupPeer) = groupPeer, let listenerLink = inviteLinks?.listenerLink, !groupPeer.hasPermission(.inviteMembers) { + let text = strongSelf.presentationData.strings.VoiceChat_SendPublicLinkText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string + + strongSelf.controller?.present(textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.VoiceChat_SendPublicLinkSend, action: { [weak self] in + dismissController?() + + if let strongSelf = self { + let _ = (enqueueMessages(account: strongSelf.context.account, peerId: peer.id, messages: [.message(text: listenerLink, attributes: [], inlineStickers: [:], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]) + |> deliverOnMainQueue).start(next: { [weak self] _ in + if let strongSelf = self { + strongSelf.presentUndoOverlay(content: .forward(savedMessages: false, text: strongSelf.presentationData.strings.UserInfo_LinkForwardTooltip_Chat_One(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string), action: { _ in return true }) + } + }) + } + })]), in: .window(.root)) + } else { + let text: String + if case let .channel(groupPeer) = groupPeer, case .broadcast = groupPeer.info { + text = strongSelf.presentationData.strings.VoiceChat_InviteMemberToChannelFirstText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string + } else { + text = strongSelf.presentationData.strings.VoiceChat_InviteMemberToGroupFirstText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), groupPeer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string + } + + strongSelf.controller?.present(textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.VoiceChat_InviteMemberToGroupFirstAdd, action: { + guard let strongSelf = self else { + return + } + + if case let .channel(groupPeer) = groupPeer { + let selfController = strongSelf.controller + let inviteDisposable = strongSelf.inviteDisposable + var inviteSignal = strongSelf.context.peerChannelMemberCategoriesContextsManager.addMembers(engine: strongSelf.context.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: { error in + dismissController?() + guard let strongSelf = self else { + return + } + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } + + let text: String + switch error { + case .limitExceeded: + text = presentationData.strings.Channel_ErrorAddTooMuch + case .tooMuchJoined: + text = presentationData.strings.Invite_ChannelsTooMuch + case .generic: + text = presentationData.strings.Login_UnknownError + case .restricted: + text = presentationData.strings.Channel_ErrorAddBlocked + case .notMutualContact: + if case .broadcast = groupPeer.info { + text = presentationData.strings.Channel_AddUserLeftError + } else { + text = presentationData.strings.GroupInfo_AddUserLeftError + } + case .botDoesntSupportGroups: + text = presentationData.strings.Channel_BotDoesntSupportGroups + case .tooMuchBots: + text = presentationData.strings.Channel_TooMuchBots + case .bot: + text = presentationData.strings.Login_UnknownError + case .kicked: + text = presentationData.strings.Channel_AddUserKickedError + } + strongSelf.controller?.present(textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + }, completed: { + guard let strongSelf = self else { + dismissController?() + return + } + dismissController?() + + if strongSelf.call.invitePeer(peer.id) { + let text: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + text = strongSelf.presentationData.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string + } else { + text = strongSelf.presentationData.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string + } + strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: peer, text: text, action: nil), action: { _ in return false }) + } + })) + } else if case let .legacyGroup(groupPeer) = groupPeer { + let selfController = strongSelf.controller + let inviteDisposable = strongSelf.inviteDisposable + var inviteSignal = strongSelf.context.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: { error in + dismissController?() + guard let strongSelf = self else { + return + } + let context = strongSelf.context + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } + + switch error { + case .privacy: + let _ = (strongSelf.context.account.postbox.loadedPeerWithId(peer.id) + |> deliverOnMainQueue).start(next: { peer in + self?.controller?.present(textAlertController(context: context, title: nil, text: presentationData.strings.Privacy_GroupsAndChannels_InviteToGroupError(EnginePeer(peer).compactDisplayTitle, EnginePeer(peer).compactDisplayTitle).string, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + }) + case .notMutualContact: + strongSelf.controller?.present(textAlertController(context: context, title: nil, text: presentationData.strings.GroupInfo_AddUserLeftError, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + case .tooManyChannels: + strongSelf.controller?.present(textAlertController(context: context, title: nil, text: presentationData.strings.Invite_ChannelsTooMuch, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + case .groupFull, .generic: + strongSelf.controller?.present(textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: presentationData.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) + } + }, completed: { + guard let strongSelf = self else { + dismissController?() + return + } + dismissController?() + + if strongSelf.call.invitePeer(peer.id) { + let text: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + text = strongSelf.presentationData.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string + } else { + text = strongSelf.presentationData.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string + } + strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: peer, text: text, action: nil), action: { _ in return false }) + } + })) + } + })]), in: .window(.root)) + } + } + }) + controller.copyInviteLink = { + dismissController?() + + guard let strongSelf = self else { + return + } + let callPeerId = strongSelf.call.peerId + + let _ = (strongSelf.context.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: { link in + guard let strongSelf = self else { + return + } + + if let link = link { + UIPasteboard.general.string = link + + strongSelf.presentUndoOverlay(content: .linkCopied(text: strongSelf.presentationData.strings.VoiceChat_InviteLinkCopiedText), action: { _ in return false }) + } + }) + } + dismissController = { [weak controller] in + controller?.dismiss() + } + strongSelf.controller?.push(controller) + }) + }, peerContextAction: { [weak self] entry, sourceNode, gesture, fullscreen in + guard let strongSelf = self, let sourceNode = sourceNode as? ContextExtractedContentContainingNode else { + return + } + + let muteStatePromise = Promise(entry.muteState) + + let itemsForEntry: (VoiceChatPeerEntry, GroupCallParticipantsContext.Participant.MuteState?) -> [ContextMenuItem] = { entry, muteState in + var items: [ContextMenuItem] = [] + + var hasVolumeSlider = false + let peer = entry.peer + if let muteState = muteState, !muteState.canUnmute || muteState.mutedByYou { + } else { + if entry.canManageCall || !entry.isMyPeer { + hasVolumeSlider = true + + let minValue: CGFloat + if let callState = strongSelf.callState, callState.canManageCall && callState.adminIds.contains(peer.id) && muteState != nil { + minValue = 0.01 + } else { + minValue = 0.0 + } + items.append(.custom(VoiceChatVolumeContextItem(minValue: minValue, value: entry.volume.flatMap { CGFloat($0) / 10000.0 } ?? 1.0, valueChanged: { newValue, finished in + if finished && newValue.isZero { + let updatedMuteState = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: true) + muteStatePromise.set(.single(updatedMuteState)) + } else { + strongSelf.call.setVolume(peerId: peer.id, volume: Int32(newValue * 10000), sync: finished) + } + }), true)) + } + } + + if entry.isMyPeer && !hasVolumeSlider && ((entry.about?.isEmpty ?? true) || entry.peer.smallProfileImage == nil) { + items.append(.custom(VoiceChatInfoContextItem(text: strongSelf.presentationData.strings.VoiceChat_ImproveYourProfileText, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Tip"), color: theme.actionSheet.primaryTextColor) + }), true)) + } + + if peer.id == strongSelf.callState?.myPeerId { + if entry.raisedHand { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_CancelSpeakRequest, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/RevokeSpeak"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + guard let strongSelf = self else { + return + } + + let _ = strongSelf.call.lowerHand() + f(.default) + }))) + } + items.append(.action(ContextMenuActionItem(text: peer.smallProfileImage == nil ? strongSelf.presentationData.strings.VoiceChat_AddPhoto : strongSelf.presentationData.strings.VoiceChat_ChangePhoto, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Camera"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + guard let strongSelf = self else { + return + } + + f(.default) + Queue.mainQueue().after(0.1) { + strongSelf.openAvatarForEditing(fromGallery: false, completion: {}) + } + }))) + + items.append(.action(ContextMenuActionItem(text: (entry.about?.isEmpty ?? true) ? strongSelf.presentationData.strings.VoiceChat_AddBio : strongSelf.presentationData.strings.VoiceChat_EditBio, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + guard let strongSelf = self else { + return + } + f(.default) + + Queue.mainQueue().after(0.1) { + let maxBioLength: Int + if peer.id.namespace == Namespaces.Peer.CloudUser { + maxBioLength = 70 + } else { + maxBioLength = 100 + } + let controller = voiceChatTitleEditController(sharedContext: strongSelf.context.sharedContext, account: strongSelf.context.account, forceTheme: strongSelf.darkTheme, title: presentationData.strings.VoiceChat_EditBioTitle, text: presentationData.strings.VoiceChat_EditBioText, placeholder: presentationData.strings.VoiceChat_EditBioPlaceholder, doneButtonTitle: presentationData.strings.VoiceChat_EditBioSave, value: entry.about, maxLength: maxBioLength, apply: { bio in + if let strongSelf = self, let bio = bio { + if peer.id.namespace == Namespaces.Peer.CloudUser { + let _ = (strongSelf.context.engine.accountData.updateAbout(about: bio) + |> `catch` { _ -> Signal in + return .complete() + }).start() + } else { + let _ = (strongSelf.context.engine.peers.updatePeerDescription(peerId: peer.id, description: bio) + |> `catch` { _ -> Signal in + return .complete() + }).start() + } + + strongSelf.presentUndoOverlay(content: .info(title: nil, text: strongSelf.presentationData.strings.VoiceChat_EditBioSuccess), action: { _ in return false }) + } + }) + self?.controller?.present(controller, in: .window(.root)) + } + }))) + + if let peer = peer as? TelegramUser { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_ChangeName, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ChangeName"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + guard let strongSelf = self else { + return + } + f(.default) + + Queue.mainQueue().after(0.1) { + let controller = voiceChatUserNameController(sharedContext: strongSelf.context.sharedContext, account: strongSelf.context.account, forceTheme: strongSelf.darkTheme, title: presentationData.strings.VoiceChat_ChangeNameTitle, firstNamePlaceholder: presentationData.strings.UserInfo_FirstNamePlaceholder, lastNamePlaceholder: presentationData.strings.UserInfo_LastNamePlaceholder, doneButtonTitle: presentationData.strings.VoiceChat_EditBioSave, firstName: peer.firstName, lastName: peer.lastName, maxLength: 128, apply: { firstAndLastName in + if let strongSelf = self, let (firstName, lastName) = firstAndLastName { + let _ = context.engine.accountData.updateAccountPeerName(firstName: firstName, lastName: lastName).start() + + strongSelf.presentUndoOverlay(content: .info(title: nil, text: strongSelf.presentationData.strings.VoiceChat_EditNameSuccess), action: { _ in return false }) + } + }) + self?.controller?.present(controller, in: .window(.root)) + } + }))) + } + } else { + if let callState = strongSelf.callState, (callState.canManageCall || callState.adminIds.contains(strongSelf.context.account.peerId)) { + if callState.adminIds.contains(peer.id) { + if let _ = muteState { + } else { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_MutePeer, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + guard let strongSelf = self else { + return + } + + let _ = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: true) + f(.default) + }))) + } + } else { + if let muteState = muteState, !muteState.canUnmute { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_UnmutePeer, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: entry.raisedHand ? "Call/Context Menu/AllowToSpeak" : "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + guard let strongSelf = self else { + return + } + + let _ = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: false) + f(.default) + + strongSelf.presentUndoOverlay(content: .voiceChatCanSpeak(text: presentationData.strings.VoiceChat_UserCanNowSpeak(EnginePeer(entry.peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string), action: { _ in return true }) + }))) + } else { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_MutePeer, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + guard let strongSelf = self else { + return + } + + let _ = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: true) + f(.default) + }))) + } + } + } else { + if let muteState = muteState, muteState.mutedByYou { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_UnmuteForMe, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + guard let strongSelf = self else { + return + } + + let _ = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: false) + f(.default) + }))) + } else { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_MuteForMe, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + guard let strongSelf = self else { + return + } + + let _ = strongSelf.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 = strongSelf.presentationData.strings.VoiceChat_OpenChannel + openIcon = UIImage(bundleImageName: "Chat/Context Menu/Channels") + } else { + openTitle = strongSelf.presentationData.strings.VoiceChat_OpenGroup + openIcon = UIImage(bundleImageName: "Chat/Context Menu/Groups") + } + } else { + openTitle = strongSelf.presentationData.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: { _, f in + guard let strongSelf = self, let navigationController = strongSelf.controller?.parentNavigationController else { + return + } + + let context = strongSelf.context + strongSelf.controller?.dismiss(completion: { + Queue.mainQueue().after(0.3) { + context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(EnginePeer(peer)), keepStack: .always, purposefulAction: {}, peekData: nil)) + } + }) + + f(.dismissWithoutContent) + }))) + + if let callState = strongSelf.callState, (callState.canManageCall && !callState.adminIds.contains(peer.id)), peer.id != strongSelf.call.peerId { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.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 strongSelf = self else { + return + } + + let _ = (strongSelf.context.account.postbox.loadedPeerWithId(strongSelf.call.peerId) + |> deliverOnMainQueue).start(next: { [weak self] chatPeer in + guard let strongSelf = self else { + return + } + + let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData.withUpdated(theme: strongSelf.darkTheme)) + var items: [ActionSheetItem] = [] + + items.append(DeleteChatPeerActionSheetItem(context: strongSelf.context, peer: EnginePeer(peer), chatPeer: EnginePeer(chatPeer), action: .removeFromGroup, strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder)) + + items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.VoiceChat_RemovePeerRemove, color: .destructive, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + + guard let strongSelf = self else { + return + } + + let _ = strongSelf.context.peerChannelMemberCategoriesContextsManager.updateMemberBannedRights(engine: strongSelf.context.engine, peerId: strongSelf.call.peerId, memberId: peer.id, bannedRights: TelegramChatBannedRights(flags: [.banReadMessages], untilDate: Int32.max)).start() + strongSelf.call.removedPeer(peer.id) + + strongSelf.presentUndoOverlay(content: .banned(text: strongSelf.presentationData.strings.VoiceChat_RemovedPeerText(EnginePeer(peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string), action: { _ in return false }) + })) + + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + strongSelf.controller?.present(actionSheet, in: .window(.root)) + }) + }) + }))) + } + } + return items + } + + let items = muteStatePromise.get() + |> map { muteState -> [ContextMenuItem] in + return itemsForEntry(entry, muteState) + } + + var centerVertically = entry.peer.smallProfileImage != nil || (!fullscreen && entry.effectiveVideoEndpointId != nil) + if let (layout, _) = strongSelf.validLayout, case .regular = layout.metrics.widthClass { + centerVertically = false + } + + var useMaskView = true + if case .fullscreen = strongSelf.displayMode { + useMaskView = false + } + + let dismissPromise = ValuePromise(false) + let source = VoiceChatContextExtractedContentSource(sourceNode: sourceNode, maskView: useMaskView ? strongSelf.transitionMaskView : nil, keepInPlace: false, blurBackground: true, centerVertically: centerVertically, shouldBeDismissed: dismissPromise.get(), animateTransitionIn: { [weak self] in + if let strongSelf = self { + strongSelf.animatingContextMenu = true + strongSelf.updateDecorationsLayout(transition: .immediate) + if strongSelf.isLandscape { + strongSelf.transitionMaskTopFillLayer.opacity = 1.0 + } + strongSelf.transitionContainerNode.view.mask = nil + strongSelf.transitionMaskBottomFillLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4, removeOnCompletion: false, completion: { [weak self] _ in + Queue.mainQueue().after(0.3) { + self?.transitionMaskTopFillLayer.opacity = 0.0 + self?.transitionMaskBottomFillLayer.removeAllAnimations() + self?.animatingContextMenu = false + self?.updateDecorationsLayout(transition: .immediate) + } + }) + } + }, animateTransitionOut: { [weak self] in + if let strongSelf = self { + strongSelf.animatingContextMenu = true + strongSelf.updateDecorationsLayout(transition: .immediate) + strongSelf.transitionMaskTopFillLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.4) + strongSelf.transitionMaskBottomFillLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.4, completion: { [weak self] _ in + self?.animatingContextMenu = false + self?.updateDecorationsLayout(transition: .immediate) + self?.transitionContainerNode.view.mask = self?.transitionMaskView + }) + } + }) + sourceNode.requestDismiss = { + dismissPromise.set(true) + } + + let contextController = ContextController(account: strongSelf.context.account, presentationData: strongSelf.presentationData.withUpdated(theme: strongSelf.darkTheme), source: .extracted(source), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) + contextController.useComplexItemsTransitionAnimation = true + strongSelf.controller?.presentInGlobalOverlay(contextController) + }, getPeerVideo: { [weak self] endpointId, position in + guard let strongSelf = self else { + return nil + } + var ignore = false + if case .mainstage = position { + ignore = false + } else if case .fullscreen = strongSelf.displayMode, !strongSelf.isPanning { + ignore = ![.mainstage, .list].contains(position) + } else { + ignore = position != .tile + } + if ignore { + return nil + } + if !strongSelf.readyVideoEndpointIds.contains(endpointId) { + return nil + } + for (listEndpointId, videoNode) in strongSelf.videoNodes { + if listEndpointId == endpointId { + if position != .mainstage && videoNode.isMainstageExclusive { + return nil + } + return videoNode + } + } + return nil + }) + self.itemInteraction?.updateAvatarPromise = self.updateAvatarPromise + + self.topPanelNode.addSubnode(self.topPanelEdgeNode) + self.topPanelNode.addSubnode(self.topPanelBackgroundNode) + self.topPanelNode.addSubnode(self.titleNode) + self.topPanelNode.addSubnode(self.optionsButton) + self.topPanelNode.addSubnode(self.closeButton) + self.topPanelNode.addSubnode(self.panelButton) + + self.bottomPanelNode.addSubnode(self.cameraButton) + self.bottomPanelNode.addSubnode(self.audioButton) + self.bottomPanelNode.addSubnode(self.switchCameraButton) + self.bottomPanelNode.addSubnode(self.leaveButton) + self.bottomPanelNode.addSubnode(self.actionButton) + self.bottomPanelNode.addSubnode(self.scheduleCancelButton) + + self.addSubnode(self.dimNode) + self.addSubnode(self.contentContainer) + + self.contentContainer.addSubnode(self.backgroundNode) + + self.contentContainer.addSubnode(self.listContainer) + self.contentContainer.addSubnode(self.topPanelNode) + self.listContainer.addSubnode(self.listNode) + self.listContainer.addSubnode(self.leftBorderNode) + self.listContainer.addSubnode(self.rightBorderNode) + self.listContainer.addSubnode(self.bottomCornersNode) + self.listContainer.addSubnode(self.topCornersNode) + self.contentContainer.addSubnode(self.bottomGradientNode) + self.contentContainer.addSubnode(self.bottomPanelBackgroundNode) +// self.contentContainer.addSubnode(self.participantsNode) + self.contentContainer.addSubnode(self.tileGridNode) + self.contentContainer.addSubnode(self.mainStageContainerNode) + self.contentContainer.addSubnode(self.transitionContainerNode) + self.contentContainer.addSubnode(self.bottomPanelNode) + self.contentContainer.addSubnode(self.timerNode) + self.contentContainer.addSubnode(self.scheduleTextNode) + self.contentContainer.addSubnode(self.fullscreenListContainer) + self.fullscreenListContainer.addSubnode(self.fullscreenListNode) + + self.mainStageContainerNode.addSubnode(self.mainStageBackgroundNode) + self.mainStageContainerNode.addSubnode(self.mainStageNode) + + self.updateDecorationsColors() + + let invitedPeers: Signal<[EnginePeer], NoError> = self.call.invitedPeers + |> mapToSignal { ids -> Signal<[EnginePeer], NoError> in + return context.engine.data.get(EngineDataList( + ids.map(TelegramEngine.EngineData.Item.Peer.Peer.init) + )) + |> map { itemList -> [EnginePeer] in + return itemList.compactMap { $0 } + } + } + + self.presentationDataDisposable = (sharedContext.presentationData + |> deliverOnMainQueue).start(next: { [weak self] presentationData in + if let strongSelf = self { + strongSelf.presentationData = presentationData + + let sourceColor = presentationData.theme.chatList.unreadBadgeInactiveBackgroundColor + let color: UIColor + if sourceColor.alpha < 1.0 { + color = presentationData.theme.chatList.unreadBadgeInactiveBackgroundColor.mixedWith(sourceColor.withAlphaComponent(1.0), alpha: sourceColor.alpha) + } else { + color = sourceColor + } + strongSelf.actionButton.connectingColor = color + } + }) + + self.memberStatesDisposable = (combineLatest(queue: .mainQueue(), + self.call.state, + self.call.members, + invitedPeers, + self.displayAsPeersPromise.get(), + self.context.account.postbox.preferencesView(keys: [PreferencesKeys.appConfiguration]) + ) + |> mapToThrottled { values in + return .single(values) + |> then(.complete() |> delay(0.1, queue: Queue.mainQueue())) + }).start(next: { [weak self] state, callMembers, invitedPeers, displayAsPeers, preferencesView in + guard let strongSelf = self else { + return + } + + let appConfiguration: AppConfiguration = preferencesView.values[PreferencesKeys.appConfiguration]?.get(AppConfiguration.self) ?? .defaultValue + let configuration = VoiceChatConfiguration.with(appConfiguration: appConfiguration) + strongSelf.configuration = configuration + + var animate = false + if strongSelf.callState != state { + if let previousCallState = strongSelf.callState { + var networkStateUpdated = false + if case .connecting = previousCallState.networkState, case .connected = state.networkState { + networkStateUpdated = true + strongSelf.connectedOnce = true + } + var canUnmuteUpdated = false + if previousCallState.muteState?.canUnmute != state.muteState?.canUnmute { + canUnmuteUpdated = true + } + if previousCallState.isVideoEnabled != state.isVideoEnabled || (state.isVideoEnabled && networkStateUpdated) || canUnmuteUpdated { + strongSelf.animatingButtonsSwap = true + animate = true + } + } + strongSelf.callState = state + strongSelf.mainStageNode.callState = state + + if let muteState = state.muteState, !muteState.canUnmute { + if strongSelf.pushingToTalk { + strongSelf.pushingToTalk = false + strongSelf.actionButton.pressing = false + strongSelf.actionButton.isUserInteractionEnabled = false + strongSelf.actionButton.isUserInteractionEnabled = true + } + } + } + + strongSelf.updateMembers(muteState: strongSelf.effectiveMuteState, callMembers: (callMembers?.participants ?? [], callMembers?.loadMoreToken), invitedPeers: invitedPeers, speakingPeers: callMembers?.speakingParticipants ?? []) + + let totalCount = Int32(max(1, callMembers?.totalCount ?? 0)) + strongSelf.currentTotalCount = totalCount + + let subtitle = strongSelf.presentationData.strings.VoiceChat_Panel_Members(totalCount) + strongSelf.currentSubtitle = subtitle + + if strongSelf.isScheduling { + strongSelf.optionsButton.isUserInteractionEnabled = false + strongSelf.optionsButton.alpha = 0.0 + strongSelf.closeButton.isUserInteractionEnabled = false + strongSelf.closeButton.alpha = 0.0 + strongSelf.panelButton.isUserInteractionEnabled = false + strongSelf.panelButton.alpha = 0.0 + } else { + if let (layout, _) = strongSelf.validLayout { + if case .regular = layout.metrics.widthClass, !strongSelf.peerIdToEndpointId.isEmpty { + strongSelf.panelButton.isUserInteractionEnabled = true + } else { + strongSelf.panelButton.isUserInteractionEnabled = false + } + } + if let callState = strongSelf.callState, callState.canManageCall { + strongSelf.optionsButton.isUserInteractionEnabled = true + } else if displayAsPeers.count > 1 { + strongSelf.optionsButton.isUserInteractionEnabled = true + } else { + strongSelf.optionsButton.isUserInteractionEnabled = true + } + } + + if let (layout, navigationHeight) = strongSelf.validLayout { + strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: animate ? .animated(duration: 0.4, curve: .spring) : .immediate) + } + }) + + let titleAndRecording: Signal<(String?, Bool), NoError> = self.call.state + |> map { state -> (String?, Bool) in + return (state.title, state.recordingStartTimestamp != nil) + } + self.peerViewDisposable = combineLatest(queue: Queue.mainQueue(), self.context.account.viewTracker.peerView(self.call.peerId), titleAndRecording).start(next: { [weak self] view, titleAndRecording in + guard let strongSelf = self else { + return + } + + let (title, isRecording) = titleAndRecording + if let peer = peerViewMainPeer(view) { + let isLivestream: Bool + if let channel = peer as? TelegramChannel, case .broadcast = channel.info { + isLivestream = true + } else { + isLivestream = false + } + strongSelf.participantsNode.isHidden = !isLivestream || strongSelf.isScheduled + + let hadPeer = strongSelf.peer != nil + strongSelf.peer = peer + strongSelf.currentTitleIsCustom = title != nil + strongSelf.currentTitle = title ?? EnginePeer(peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder) + + strongSelf.updateTitle(transition: .immediate) + strongSelf.titleNode.isRecording = isRecording + + if strongSelf.isScheduling && !hadPeer { + strongSelf.updateScheduleButtonTitle() + } + } + if !strongSelf.didSetDataReady { + strongSelf.didSetDataReady = true + strongSelf.updateMembers() + strongSelf.controller?.dataReady.set(true) + } + }) + + self.audioOutputStateDisposable = (self.call.audioOutputState + |> deliverOnMainQueue).start(next: { [weak self] state in + guard let strongSelf = self else { + return + } + + var existingOutputs = Set() + var filteredOutputs: [AudioSessionOutput] = [] + for output in state.0 { + if case let .port(port) = output { + if !existingOutputs.contains(port.name) { + existingOutputs.insert(port.name) + filteredOutputs.append(output) + } + } else { + filteredOutputs.append(output) + } + } + + let wasEmpty = strongSelf.audioOutputState == nil + strongSelf.audioOutputState = (filteredOutputs, state.1) + if let (layout, navigationHeight) = strongSelf.validLayout { + strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .immediate) + } + if wasEmpty { + strongSelf.controller?.audioOutputStateReady.set(true) + } + }) + + self.audioLevelsDisposable = (self.call.audioLevels + |> deliverOnMainQueue).start(next: { [weak self] levels in + guard let strongSelf = self else { + return + } + var levels = levels + if strongSelf.effectiveMuteState != nil { + levels = levels.filter { $0.0 != strongSelf.callState?.myPeerId } + } + + var maxLevelWithVideo: (PeerId, Float)? + for (peerId, source, level, hasSpeech) in levels { + let hasVideo = strongSelf.peerIdToEndpointId[peerId] != nil + if hasSpeech && source != 0 && hasVideo { + if let (_, currentLevel) = maxLevelWithVideo { + if currentLevel < level { + maxLevelWithVideo = (peerId, level) + } + } else { + maxLevelWithVideo = (peerId, level) + } + } + } + + if maxLevelWithVideo == nil { + if let (peerId, _, _) = strongSelf.currentDominantSpeaker { + maxLevelWithVideo = (peerId, 0.0) + } else if strongSelf.peerIdToEndpointId.count > 0 { + for entry in strongSelf.currentFullscreenEntries { + if case let .peer(peerEntry, _) = entry { + if let _ = peerEntry.effectiveVideoEndpointId { + maxLevelWithVideo = (peerEntry.peer.id, 0.0) + break + } + } + } + } + } + + if case .fullscreen = strongSelf.displayMode, !strongSelf.mainStageNode.animating && !strongSelf.animatingExpansion { + if let (peerId, _) = maxLevelWithVideo { + if let (currentPeerId, _, timestamp) = strongSelf.currentDominantSpeaker { + if CACurrentMediaTime() - timestamp > 2.5 && peerId != currentPeerId { + strongSelf.currentDominantSpeaker = (peerId, nil, CACurrentMediaTime()) + strongSelf.updateMainVideo(waitForFullSize: true) + } + } + } + } + + strongSelf.itemInteraction?.updateAudioLevels(levels) + }) + + self.myAudioLevelDisposable = (self.call.myAudioLevel + |> deliverOnMainQueue).start(next: { [weak self] level in + guard let strongSelf = self else { + return + } + var effectiveLevel: Float = 0.0 + if let state = strongSelf.callState, state.muteState == nil || strongSelf.pushingToTalk { + effectiveLevel = level + } else if level > 0.1 { + effectiveLevel = level * 0.5 + } + strongSelf.actionButton.updateLevel(CGFloat(effectiveLevel)) + }) + + self.isSpeakingDisposable = (self.call.isSpeaking + |> deliverOnMainQueue).start(next: { [weak self] isSpeaking in + guard let strongSelf = self else { + return + } + if let state = strongSelf.callState, state.muteState == nil || strongSelf.pushingToTalk { + strongSelf.displayUnmuteTooltipTimer?.invalidate() + strongSelf.displayUnmuteTooltipTimer = nil + strongSelf.dismissUnmuteTooltipTimer?.invalidate() + strongSelf.dismissUnmuteTooltipTimer = nil + } else { + if isSpeaking { + var shouldDisplayTooltip = false + if let previousTimstamp = strongSelf.lastUnmuteTooltipDisplayTimestamp, CACurrentMediaTime() > previousTimstamp + 45.0 { + shouldDisplayTooltip = true + } else if strongSelf.lastUnmuteTooltipDisplayTimestamp == nil { + shouldDisplayTooltip = true + } + if shouldDisplayTooltip { + strongSelf.dismissUnmuteTooltipTimer?.invalidate() + strongSelf.dismissUnmuteTooltipTimer = nil + + if strongSelf.displayUnmuteTooltipTimer == nil { + let timer = SwiftSignalKit.Timer(timeout: 1.0, repeat: false, completion: { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.lastUnmuteTooltipDisplayTimestamp = CACurrentMediaTime() + strongSelf.displayUnmuteTooltip() + strongSelf.displayUnmuteTooltipTimer?.invalidate() + strongSelf.displayUnmuteTooltipTimer = nil + strongSelf.dismissUnmuteTooltipTimer?.invalidate() + strongSelf.dismissUnmuteTooltipTimer = nil + }, queue: Queue.mainQueue()) + timer.start() + strongSelf.displayUnmuteTooltipTimer = timer + } + } + } else if strongSelf.dismissUnmuteTooltipTimer == nil && strongSelf.displayUnmuteTooltipTimer != nil { + let timer = SwiftSignalKit.Timer(timeout: 0.4, repeat: false, completion: { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.displayUnmuteTooltipTimer?.invalidate() + strongSelf.displayUnmuteTooltipTimer = nil + + strongSelf.dismissUnmuteTooltipTimer?.invalidate() + strongSelf.dismissUnmuteTooltipTimer = nil + }, queue: Queue.mainQueue()) + timer.start() + strongSelf.dismissUnmuteTooltipTimer = timer + } + } + }) + + self.leaveButton.addTarget(self, action: #selector(self.leavePressed), forControlEvents: .touchUpInside) + self.actionButton.addTarget(self, action: #selector(self.actionPressed), forControlEvents: .touchUpInside) + self.audioButton.addTarget(self, action: #selector(self.audioPressed), forControlEvents: .touchUpInside) + self.cameraButton.addTarget(self, action: #selector(self.cameraPressed), forControlEvents: .touchUpInside) + self.switchCameraButton.addTarget(self, action: #selector(self.switchCameraPressed), forControlEvents: .touchUpInside) + self.optionsButton.contextAction = { [weak self] sourceNode, gesture in + self?.openSettingsMenu(sourceNode: sourceNode, gesture: gesture) + } + self.optionsButton.addTarget(self, action: #selector(self.optionsPressed), forControlEvents: .touchUpInside) + self.closeButton.addTarget(self, action: #selector(self.closePressed), forControlEvents: .touchUpInside) + self.panelButton.addTarget(self, action: #selector(self.panelPressed), forControlEvents: .touchUpInside) + + self.actionButtonColorDisposable = (self.actionButton.outerColor + |> deliverOnMainQueue).start(next: { [weak self] normalColor, activeColor in + if let strongSelf = self { + let animated = strongSelf.currentNormalButtonColor != nil || strongSelf.currentActiveButtonColor == nil + strongSelf.currentNormalButtonColor = normalColor + strongSelf.currentActiveButtonColor = activeColor + strongSelf.updateButtons(transition: animated ? .animated(duration: 0.3, curve: .linear) : .immediate) + } + }) + + self.fullscreenListNode.updateFloatingHeaderOffset = { [weak self] _, _ in + guard let strongSelf = self else { + return + } + + var visiblePeerIds = Set() + strongSelf.fullscreenListNode.forEachVisibleItemNode { itemNode in + if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode, let item = itemNode.item { + if item.videoEndpointId == nil { + visiblePeerIds.insert(item.peer.id) + } + } + } + strongSelf.mainStageNode.update(visiblePeerIds: visiblePeerIds) + } + + self.listNode.updateFloatingHeaderOffset = { [weak self] offset, transition in + if let strongSelf = self { + strongSelf.currentContentOffset = offset + if !(strongSelf.animatingExpansion || strongSelf.animatingInsertion || strongSelf.animatingAppearance) && (strongSelf.panGestureArguments == nil || strongSelf.isExpanded) { + strongSelf.updateDecorationsLayout(transition: transition) + } + } + } + + self.listNode.visibleContentOffsetChanged = { [weak self] offset in + guard let strongSelf = self else { + return + } + var scrollAtTop = false + if case let .known(value) = offset, value < 180.0 { + scrollAtTop = true + } else { + scrollAtTop = false + } + if scrollAtTop != strongSelf.scrollAtTop { + strongSelf.scrollAtTop = scrollAtTop + strongSelf.updateTitle(transition: .immediate) + } + } + + self.listNode.visibleBottomContentOffsetChanged = { [weak self] offset in + guard let strongSelf = self else { + return + } + if case let .known(value) = offset, value < 200.0 { + if let loadMoreToken = strongSelf.currentCallMembers?.1 { + strongSelf.currentLoadToken = loadMoreToken + strongSelf.call.loadMoreMembers(token: loadMoreToken) + } + } + } + + self.memberEventsDisposable.set((self.call.memberEvents + |> deliverOnMainQueue).start(next: { [weak self] event in + guard let strongSelf = self else { + return + } + if event.joined { + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + return + } + let text = strongSelf.presentationData.strings.VoiceChat_PeerJoinedText(EnginePeer(event.peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string + strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: EnginePeer(event.peer), text: text, action: nil), action: { _ in return false }) + } + })) + + self.reconnectedAsEventsDisposable.set((self.call.reconnectedAsEvents + |> deliverOnMainQueue).start(next: { [weak self] peer in + guard let strongSelf = self else { + return + } + let text: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + text = strongSelf.presentationData.strings.LiveStream_DisplayAsSuccess(EnginePeer(peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string + } else { + text = strongSelf.presentationData.strings.VoiceChat_DisplayAsSuccess(EnginePeer(peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string + } + strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: EnginePeer(peer), text: text, action: nil), action: { _ in return false }) + })) + + self.stateVersionDisposable.set((self.call.stateVersion + |> distinctUntilChanged + |> deliverOnMainQueue).start(next: { [weak self] _ in + guard let strongSelf = self else { + return + } + strongSelf.callStateDidReset() + })) + + self.titleNode.tapped = { [weak self] in + if let strongSelf = self, !strongSelf.isScheduling { + if strongSelf.callState?.canManageCall ?? false { + strongSelf.openTitleEditing() + } else if !strongSelf.titleNode.recordingIconNode.isHidden { + var hasTooltipAlready = false + strongSelf.controller?.forEachController { controller -> Bool in + if controller is TooltipScreen { + hasTooltipAlready = true + } + return true + } + if !hasTooltipAlready { + let location = strongSelf.titleNode.recordingIconNode.convert(strongSelf.titleNode.recordingIconNode.bounds, to: nil) + let text: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + text = presentationData.strings.LiveStream_RecordingInProgress + } else { + text = presentationData.strings.VoiceChat_RecordingInProgress + } + strongSelf.controller?.present(TooltipScreen(account: strongSelf.context.account, text: text, icon: nil, location: .point(location.offsetBy(dx: 1.0, dy: 0.0), .top), displayDuration: .custom(3.0), shouldDismissOnTouch: { _ in + return .dismiss(consume: true) + }), in: .window(.root)) + } + } + } + } + + self.scheduleCancelButton.pressed = { [weak self] in + if let strongSelf = self { + strongSelf.dismissScheduled() + } + } + + self.mainStageNode.controlsHidden = { [weak self] hidden in + if let strongSelf = self { + if hidden { + strongSelf.fullscreenListNode.alpha = 0.0 + } else { + strongSelf.fullscreenListNode.alpha = 1.0 + strongSelf.fullscreenListNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + } + } + } + + self.mainStageNode.tapped = { [weak self] in + if let strongSelf = self, let (layout, navigationHeight) = strongSelf.validLayout, !strongSelf.animatingExpansion && !strongSelf.animatingMainStage && !strongSelf.mainStageNode.animating { + if case .regular = layout.metrics.widthClass { + strongSelf.panelHidden = !strongSelf.panelHidden + + strongSelf.animatingExpansion = true + let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) + strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) + strongSelf.updateDecorationsLayout(transition: transition) + } else { + let effectiveDisplayMode = strongSelf.displayMode + let nextDisplayMode: DisplayMode + switch effectiveDisplayMode { + case .modal: + nextDisplayMode = effectiveDisplayMode + case let .fullscreen(controlsHidden): + if controlsHidden { + nextDisplayMode = .fullscreen(controlsHidden: false) + } else { + nextDisplayMode = .fullscreen(controlsHidden: true) + } + } + strongSelf.updateDisplayMode(nextDisplayMode) + } + } + } + + self.mainStageNode.stopScreencast = { [weak self] in + if let strongSelf = self { + strongSelf.call.disableScreencast() + } + } + + self.mainStageNode.back = { [weak self] in + if let strongSelf = self, !strongSelf.isPanning && !strongSelf.animatingExpansion && !strongSelf.mainStageNode.animating { + strongSelf.currentForcedSpeaker = nil + strongSelf.updateDisplayMode(.modal(isExpanded: true, isFilled: true), fromPan: true) + strongSelf.effectiveSpeaker = nil + } + } + + self.mainStageNode.togglePin = { [weak self] in + if let strongSelf = self { + if let (peerId, videoEndpointId, _, _, _) = strongSelf.effectiveSpeaker { + if let _ = strongSelf.currentForcedSpeaker { + strongSelf.currentDominantSpeaker = (peerId, videoEndpointId, CACurrentMediaTime()) + strongSelf.currentForcedSpeaker = nil + } else { + strongSelf.currentForcedSpeaker = (peerId, videoEndpointId) + } + } + strongSelf.updateMembers() + } + } + + self.mainStageNode.switchTo = { [weak self] peerId in + if let strongSelf = self, let interaction = strongSelf.itemInteraction { + interaction.switchToPeer(peerId, nil, false) + } + } + + self.mainStageNode.getAudioLevel = { [weak self] peerId in + return self?.itemInteraction?.getAudioLevel(peerId) ?? .single(0.0) + } + + self.mainStageNode.getVideo = { [weak self] endpointId, isMyPeer, completion in + if let strongSelf = self { + if isMyPeer { + if strongSelf.readyVideoEndpointIds.contains(endpointId) { + completion(strongSelf.itemInteraction?.getPeerVideo(endpointId, .mainstage)) + } else { + strongSelf.myPeerVideoReadyDisposable.set((strongSelf.readyVideoEndpointIdsPromise.get() + |> filter { $0.contains(endpointId) } + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] _ in + if let strongSelf = self { + completion(strongSelf.itemInteraction?.getPeerVideo(endpointId, .mainstage)) + } + })) + } + } else { + if let input = (strongSelf.call as! PresentationGroupCallImpl).video(endpointId: endpointId) { + if let videoView = strongSelf.videoRenderingContext.makeView(input: input, blur: false) { + completion(GroupVideoNode(videoView: videoView, backdropVideoView: strongSelf.videoRenderingContext.makeView(input: input, blur: true))) + } + } + + /*strongSelf.call.makeIncomingVideoView(endpointId: endpointId, requestClone: GroupVideoNode.useBlurTransparency, completion: { videoView, backdropVideoView in + if let videoView = videoView { + completion(GroupVideoNode(videoView: videoView, backdropVideoView: backdropVideoView)) + } else { + completion(nil) + } + })*/ + } + } + } + + self.applicationStateDisposable = (self.context.sharedContext.applicationBindings.applicationIsActive + |> deliverOnMainQueue).start(next: { [weak self] active in + guard let strongSelf = self else { + return + } + strongSelf.appIsActive = active + }) + + if self.context.sharedContext.immediateExperimentalUISettings.enableDebugDataDisplay { + self.statsDisposable = ((call as! PresentationGroupCallImpl).getStats() + |> deliverOnMainQueue + |> then(.complete() |> delay(1.0, queue: .mainQueue())) + |> restart).start(next: { [weak self] stats in + guard let strongSelf = self else { + return + } + for (endpointId, videoNode) in strongSelf.videoNodes { + if let incomingVideoStats = stats.incomingVideoStats[endpointId] { + videoNode.updateDebugInfo(text: "in: \(incomingVideoStats.receivingQuality)\n srv: \(incomingVideoStats.availableQuality)") + } + } + if let (_, maybeEndpointId, _, _, _) = strongSelf.mainStageNode.currentPeer, let endpointId = maybeEndpointId { + if let incomingVideoStats = stats.incomingVideoStats[endpointId] { + strongSelf.mainStageNode.currentVideoNode?.updateDebugInfo(text: "in: \(incomingVideoStats.receivingQuality)\n srv: \(incomingVideoStats.availableQuality)") + } + } + }) + } + } + + deinit { + self.presentationDataDisposable?.dispose() + self.peerViewDisposable?.dispose() + self.leaveDisposable.dispose() + self.isMutedDisposable?.dispose() + self.isNoiseSuppressionEnabledDisposable?.dispose() + self.callStateDisposable?.dispose() + self.audioOutputStateDisposable?.dispose() + self.memberStatesDisposable?.dispose() + self.audioLevelsDisposable?.dispose() + self.myAudioLevelDisposable?.dispose() + self.isSpeakingDisposable?.dispose() + self.inviteDisposable.dispose() + self.memberEventsDisposable.dispose() + self.reconnectedAsEventsDisposable.dispose() + self.stateVersionDisposable.dispose() + self.updateAvatarDisposable.dispose() + self.ignoreConnectingTimer?.invalidate() + self.readyVideoDisposables.dispose() + self.applicationStateDisposable?.dispose() + self.myPeerVideoReadyDisposable.dispose() + self.statsDisposable?.dispose() + } + + private func openSettingsMenu(sourceNode: ASDisplayNode, gesture: ContextGesture?) { + let items: Signal<[ContextMenuItem], NoError> = self.contextMenuMainItems() + if let controller = self.controller { + let contextController = ContextController(account: self.context.account, presentationData: self.presentationData.withUpdated(theme: self.darkTheme), source: .reference(VoiceChatContextReferenceContentSource(controller: controller, sourceNode: self.optionsButton.referenceNode)), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) + controller.presentInGlobalOverlay(contextController) + } + } + + private func contextMenuMainItems() -> Signal<[ContextMenuItem], NoError> { + guard let myPeerId = self.callState?.myPeerId else { + return .single([]) + } + + let canManageCall = self.callState?.canManageCall == true + let avatarSize = CGSize(width: 28.0, height: 28.0) + return combineLatest(self.displayAsPeersPromise.get(), self.context.account.postbox.loadedPeerWithId(self.call.peerId), self.inviteLinksPromise.get()) + |> take(1) + |> deliverOnMainQueue + |> map { [weak self] peers, chatPeer, inviteLinks -> [ContextMenuItem] in + guard let strongSelf = self else { + return [] + } + + var items: [ContextMenuItem] = [] + + if peers.count > 1 { + for peer in peers { + if peer.peer.id == myPeerId { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_DisplayAs, textLayout: .secondLineWithValue(EnginePeer(peer.peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)), icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: peerAvatarCompleteImage(account: strongSelf.context.account, peer: EnginePeer(peer.peer), size: avatarSize)), action: { c, _ in + guard let strongSelf = self else { + return + } + c.setItems(strongSelf.contextMenuDisplayAsItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) + }))) + items.append(.separator) + break + } + } + } + + if let (availableOutputs, currentOutput) = strongSelf.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 = strongSelf.presentationData.strings.Call_AudioRouteSpeaker + case .headphones: + title = strongSelf.presentationData.strings.Call_AudioRouteHeadphones + case let .port(port): + title = port.name + } + currentOutputTitle = title + break + } + } + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_ContextAudio, textLayout: .secondLineWithValue(currentOutputTitle), icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Audio"), color: theme.actionSheet.primaryTextColor) + }, action: { c, _ in + guard let strongSelf = self else { + return + } + c.setItems(strongSelf.contextMenuAudioItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) + }))) + } + + if canManageCall { + let text: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + text = strongSelf.presentationData.strings.LiveStream_EditTitle + } else { + text = strongSelf.presentationData.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: { _, f in + f(.default) + + guard let strongSelf = self else { + return + } + strongSelf.openTitleEditing() + }))) + + var hasPermissions = true + if let chatPeer = chatPeer as? TelegramChannel { + if case .broadcast = chatPeer.info { + hasPermissions = false + } else if chatPeer.flags.contains(.isGigagroup) { + hasPermissions = false + } + } + if hasPermissions { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_EditPermissions, icon: { theme -> UIImage? in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Restrict"), color: theme.actionSheet.primaryTextColor) + }, action: { c, _ in + guard let strongSelf = self else { + return + } + c.setItems(strongSelf.contextMenuPermissionItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) + }))) + } + } + + if let inviteLinks = inviteLinks { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_Share, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + f(.default) + + self?.presentShare(inviteLinks) + }))) + } + + let isScheduled = strongSelf.isScheduled + + let canSpeak: Bool + if let callState = strongSelf.callState { + if let muteState = callState.muteState { + canSpeak = muteState.canUnmute + } else { + canSpeak = true + } + } else { + canSpeak = false + } + + if !isScheduled && canSpeak { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_NoiseSuppression, textColor: .primary, textLayout: .secondLineWithValue(strongSelf.isNoiseSuppressionEnabled ? strongSelf.presentationData.strings.VoiceChat_NoiseSuppressionEnabled : strongSelf.presentationData.strings.VoiceChat_NoiseSuppressionDisabled), icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Noise"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + f(.dismissWithoutContent) + if let strongSelf = self { + strongSelf.call.setIsNoiseSuppressionEnabled(!strongSelf.isNoiseSuppressionEnabled) + } + }))) + } + + if let callState = strongSelf.callState, callState.isVideoEnabled && (callState.muteState?.canUnmute ?? true) { + if #available(iOS 12.0, *) { + if strongSelf.call.hasScreencast { + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_StopScreenSharing, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ShareScreen"), color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + f(.default) + + self?.call.disableScreencast() + }))) + } else { + items.append(.custom(VoiceChatShareScreenContextItem(context: strongSelf.context, text: strongSelf.presentationData.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 = strongSelf.callState?.recordingStartTimestamp { + items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { _, f in + f(.dismissWithoutContent) + + guard let strongSelf = self else { + return + } + + let alertController = textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: strongSelf.presentationData.strings.VoiceChat_StopRecordingTitle, actions: [TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.VoiceChat_StopRecordingStop, action: { + if let strongSelf = self { + strongSelf.call.setShouldBeRecording(false, title: nil, videoOrientation: nil) + + + let text: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + text = strongSelf.presentationData.strings.LiveStream_RecordingSaved + } else { + text = strongSelf.presentationData.strings.VideoChat_RecordingSaved + } + + strongSelf.presentUndoOverlay(content: .forward(savedMessages: true, text: text), action: { [weak self] value in + if case .info = value, let strongSelf = self, let navigationController = strongSelf.controller?.navigationController as? NavigationController { + let context = strongSelf.context + strongSelf.controller?.dismiss(completion: { + 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 = peer else { + return + } + context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), keepStack: .always, purposefulAction: {}, peekData: nil)) + }) + } + }) + + return true + } + return false + }) + } + })]) + self?.controller?.present(alertController, in: .window(.root)) + }), false)) + } else { + let text: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + text = strongSelf.presentationData.strings.LiveStream_StartRecording + } else { + text = strongSelf.presentationData.strings.VoiceChat_StartRecording + } + if strongSelf.callState?.scheduleTimestamp == nil { + items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in + return generateStartRecordingIcon(color: theme.actionSheet.primaryTextColor) + }, action: { _, f in + f(.dismissWithoutContent) + + guard let strongSelf = self, let peer = strongSelf.peer else { + return + } + + let controller = VoiceChatRecordingSetupController(context: strongSelf.context, peer: peer, completion: { [weak self] videoOrientation in + if let strongSelf = self { + let title: String + let text: String + let placeholder: String + if let _ = videoOrientation { + placeholder = strongSelf.presentationData.strings.VoiceChat_RecordingTitlePlaceholderVideo + } else { + placeholder = strongSelf.presentationData.strings.VoiceChat_RecordingTitlePlaceholder + } + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + title = strongSelf.presentationData.strings.LiveStream_StartRecordingTitle + if let _ = videoOrientation { + text = strongSelf.presentationData.strings.LiveStream_StartRecordingTextVideo + } else { + text = strongSelf.presentationData.strings.LiveStream_StartRecordingText + } + } else { + title = strongSelf.presentationData.strings.VoiceChat_StartRecordingTitle + if let _ = videoOrientation { + text = strongSelf.presentationData.strings.VoiceChat_StartRecordingTextVideo + } else { + text = strongSelf.presentationData.strings.VoiceChat_StartRecordingText + } + } + + let controller = voiceChatTitleEditController(sharedContext: strongSelf.context.sharedContext, account: strongSelf.context.account, forceTheme: strongSelf.darkTheme, title: title, text: text, placeholder: placeholder, value: nil, maxLength: 40, apply: { title in + if let strongSelf = self, let title = title { + strongSelf.call.setShouldBeRecording(true, title: title, videoOrientation: videoOrientation) + + let text: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + text = strongSelf.presentationData.strings.LiveStream_RecordingStarted + } else { + text = strongSelf.presentationData.strings.VoiceChat_RecordingStarted + } + + strongSelf.presentUndoOverlay(content: .voiceChatRecording(text: text), action: { _ in return false }) + strongSelf.call.playTone(.recordingStarted) + } + }) + strongSelf.controller?.present(controller, in: .window(.root)) + } + }) + self?.controller?.present(controller, in: .window(.root)) + }))) + } + } + } + + if canManageCall { + let text: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + text = isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelLiveStream : strongSelf.presentationData.strings.VoiceChat_EndLiveStream + } else { + text = isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelVoiceChat : strongSelf.presentationData.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: { _, f in + f(.dismissWithoutContent) + + guard let strongSelf = self else { + return + } + + let action: () -> Void = { + guard let strongSelf = self else { + return + } + + let _ = (strongSelf.call.leave(terminateIfPossible: true) + |> filter { $0 } + |> take(1) + |> deliverOnMainQueue).start(completed: { + self?.controller?.dismiss() + }) + } + + let title: String + let text: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + title = isScheduled ? strongSelf.presentationData.strings.LiveStream_CancelConfirmationTitle : strongSelf.presentationData.strings.LiveStream_EndConfirmationTitle + text = isScheduled ? strongSelf.presentationData.strings.LiveStream_CancelConfirmationText : strongSelf.presentationData.strings.LiveStream_EndConfirmationText + } else { + title = isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationTitle : strongSelf.presentationData.strings.VoiceChat_EndConfirmationTitle + text = isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationText : strongSelf.presentationData.strings.VoiceChat_EndConfirmationText + } + + let alertController = textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationEnd : strongSelf.presentationData.strings.VoiceChat_EndConfirmationEnd, action: { + action() + })]) + strongSelf.controller?.present(alertController, in: .window(.root)) + }))) + } else { + let leaveText: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + leaveText = strongSelf.presentationData.strings.LiveStream_LeaveVoiceChat + } else { + leaveText = strongSelf.presentationData.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: { _, f in + f(.dismissWithoutContent) + + guard let strongSelf = self else { + return + } + + let _ = (strongSelf.call.leave(terminateIfPossible: false) + |> filter { $0 } + |> take(1) + |> deliverOnMainQueue).start(completed: { + self?.controller?.dismiss() + }) + }))) + } + return items + } + } + + private func contextMenuAudioItems() -> Signal<[ContextMenuItem], NoError> { + guard let (availableOutputs, currentOutput) = self.audioOutputState else { + return .single([]) + } + + var items: [ContextMenuItem] = [] + for output in availableOutputs { + let title: String + switch output { + case .builtin: + title = UIDevice.current.model + case .speaker: + title = self.presentationData.strings.Call_AudioRouteSpeaker + case .headphones: + title = self.presentationData.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) + self?.call.setCurrentAudioOutput(output) + }))) + } + items.append(.separator) + 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) + }, action: { [weak self] (c, _) in + guard let strongSelf = self else { + return + } + c.setItems(strongSelf.contextMenuMainItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) + }))) + return .single(items) + } + + private func contextMenuDisplayAsItems() -> Signal<[ContextMenuItem], NoError> { + guard let myPeerId = self.callState?.myPeerId else { + return .single([]) + } + + let avatarSize = CGSize(width: 28.0, height: 28.0) + let darkTheme = self.darkTheme + + return self.displayAsPeersPromise.get() + |> take(1) + |> map { [weak self] peers -> [ContextMenuItem] in + guard let strongSelf = self else { + return [] + } + + var items: [ContextMenuItem] = [] + + var isGroup = false + for peer in peers { + 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 ? strongSelf.presentationData.strings.VoiceChat_DisplayAsInfoGroup : strongSelf.presentationData.strings.VoiceChat_DisplayAsInfo, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Accounts"), color: theme.actionSheet.primaryTextColor) + }), true)) + + for peer in peers { + var subtitle: String? + if peer.peer.id.namespace == Namespaces.Peer.CloudUser { + subtitle = strongSelf.presentationData.strings.VoiceChat_PersonalAccount + } else if let subscribers = peer.subscribers { + if let peer = peer.peer as? TelegramChannel, case .broadcast = peer.info { + subtitle = strongSelf.presentationData.strings.Conversation_StatusSubscribers(subscribers) + } else { + subtitle = strongSelf.presentationData.strings.Conversation_StatusMembers(subscribers) + } + } + + let isSelected = peer.peer.id == myPeerId + let extendedAvatarSize = CGSize(width: 35.0, height: 35.0) + let avatarSignal = peerAvatarCompleteImage(account: strongSelf.context.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(darkTheme.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: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), textLayout: subtitle.flatMap { .secondLineWithValue($0) } ?? .singleLine, icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: isSelected ? extendedAvatarSize : avatarSize, signal: avatarSignal), action: { _, f in + f(.default) + + guard let strongSelf = self else { + return + } + + if peer.peer.id != myPeerId { + strongSelf.call.reconnect(as: peer.peer.id) + } + }))) + + if peer.peer.id.namespace == Namespaces.Peer.CloudUser { + items.append(.separator) + } + } + items.append(.separator) + items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Common_Back, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) + }, action: { (c, _) in + guard let strongSelf = self else { + return + } + c.setItems(strongSelf.contextMenuMainItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) + }))) + return items + } + } + + private func contextMenuPermissionItems() -> Signal<[ContextMenuItem], NoError> { + var items: [ContextMenuItem] = [] + if let callState = self.callState, callState.canManageCall, let defaultParticipantMuteState = callState.defaultParticipantMuteState { + let isMuted = defaultParticipantMuteState == .muted + + items.append(.action(ContextMenuActionItem(text: self.presentationData.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 strongSelf = self else { + return + } + strongSelf.call.updateDefaultParticipantsAreMuted(isMuted: false) + }))) + items.append(.action(ContextMenuActionItem(text: self.presentationData.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 strongSelf = self else { + return + } + strongSelf.call.updateDefaultParticipantsAreMuted(isMuted: true) + }))) + items.append(.separator) + 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) + }, action: { [weak self] (c, _) in + guard let strongSelf = self else { + return + } + c.setItems(strongSelf.contextMenuMainItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) + }))) + } + return .single(items) + } + + override func didLoad() { + super.didLoad() + + self.view.disablesInteractiveTransitionGestureRecognizer = true + self.view.disablesInteractiveModalDismiss = true + + self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) + + let longTapRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.actionButtonPressGesture(_:))) + longTapRecognizer.minimumPressDuration = 0.001 + longTapRecognizer.delegate = self + self.actionButton.view.addGestureRecognizer(longTapRecognizer) + + let panRecognizer = DirectionalPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) + panRecognizer.delegate = self + panRecognizer.delaysTouchesBegan = false + panRecognizer.cancelsTouchesInView = true + self.view.addGestureRecognizer(panRecognizer) + + if self.isScheduling { + self.setupSchedulePickerView() + self.updateScheduleButtonTitle() + } + } + + private func updateSchedulePickerLimits() { + let timeZone = TimeZone(secondsFromGMT: 0)! + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = timeZone + let currentDate = Date() + var components = calendar.dateComponents(Set([.era, .year, .month, .day, .hour, .minute, .second]), from: currentDate) + components.second = 0 + + let roundedDate = calendar.date(from: components)! + let next1MinDate = calendar.date(byAdding: .minute, value: 1, to: roundedDate) + + let minute = components.minute ?? 0 + components.minute = 0 + let roundedToHourDate = calendar.date(from: components)! + components.hour = 0 + + let roundedToMidnightDate = calendar.date(from: components)! + let nextTwoHourDate = calendar.date(byAdding: .hour, value: minute > 30 ? 4 : 3, to: roundedToHourDate) + let maxDate = calendar.date(byAdding: .day, value: 8, to: roundedToMidnightDate) + + if let date = calendar.date(byAdding: .day, value: 365, to: currentDate) { + self.pickerView?.maximumDate = date + } + if let next1MinDate = next1MinDate, let nextTwoHourDate = nextTwoHourDate { + self.pickerView?.minimumDate = next1MinDate + self.pickerView?.maximumDate = maxDate + self.pickerView?.date = nextTwoHourDate + } + } + + private func setupSchedulePickerView() { + var currentDate: Date? + if let pickerView = self.pickerView { + currentDate = pickerView.date + pickerView.removeFromSuperview() + } + + let textColor = UIColor.white + UILabel.setDateLabel(textColor) + + let pickerView = UIDatePicker() + pickerView.timeZone = TimeZone(secondsFromGMT: 0) + pickerView.datePickerMode = .countDownTimer + pickerView.datePickerMode = .dateAndTime + pickerView.locale = Locale.current + pickerView.timeZone = TimeZone.current + pickerView.minuteInterval = 1 + self.contentContainer.view.addSubview(pickerView) + pickerView.addTarget(self, action: #selector(self.scheduleDatePickerUpdated), for: .valueChanged) + if #available(iOS 13.4, *) { + pickerView.preferredDatePickerStyle = .wheels + } + pickerView.setValue(textColor, forKey: "textColor") + self.pickerView = pickerView + + self.updateSchedulePickerLimits() + if let currentDate = currentDate { + pickerView.date = currentDate + } + } + + private let calendar = Calendar(identifier: .gregorian) + private func updateScheduleButtonTitle() { + guard let date = self.pickerView?.date else { + return + } + + let calendar = Calendar(identifier: .gregorian) + let currentTimestamp = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) + let timestamp = Int32(date.timeIntervalSince1970) + let time = stringForMessageTimestamp(timestamp: timestamp, dateTimeFormat: self.presentationData.dateTimeFormat) + let buttonTitle: String + if calendar.isDateInToday(date) { + buttonTitle = self.presentationData.strings.ScheduleVoiceChat_ScheduleToday(time).string + } else if calendar.isDateInTomorrow(date) { + buttonTitle = self.presentationData.strings.ScheduleVoiceChat_ScheduleTomorrow(time).string + } else { + buttonTitle = self.presentationData.strings.ScheduleVoiceChat_ScheduleOn(self.dateFormatter.string(from: date), time).string + } + self.scheduleButtonTitle = buttonTitle + + let delta = timestamp - currentTimestamp + + var isGroup = true + if let peer = self.peer as? TelegramChannel, case .broadcast = peer.info { + isGroup = false + } + let intervalString = scheduledTimeIntervalString(strings: self.presentationData.strings, value: max(60, delta)) + self.scheduleTextNode.attributedText = NSAttributedString(string: isGroup ? self.presentationData.strings.ScheduleVoiceChat_GroupText(intervalString).string : self.presentationData.strings.ScheduleLiveStream_ChannelText(intervalString).string, font: Font.regular(14.0), textColor: UIColor(rgb: 0x8e8e93)) + + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) + } + } + + @objc private func scheduleDatePickerUpdated() { + self.updateScheduleButtonTitle() + } + + private func schedule() { + if let date = self.pickerView?.date, date > Date() { + self.call.schedule(timestamp: Int32(date.timeIntervalSince1970)) + + self.isScheduling = false + self.transitionToScheduled() + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) + } + } + } + + private func dismissScheduled() { + self.leaveDisposable.set((self.call.leave(terminateIfPossible: true) + |> deliverOnMainQueue).start(completed: { [weak self] in + self?.controller?.dismiss(closing: true) + })) + } + + private func transitionToScheduled() { + let springDuration: Double = 0.6 + let springDamping: CGFloat = 100.0 + + self.optionsButton.alpha = 1.0 + self.optionsButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.optionsButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, damping: springDamping) + self.optionsButton.isUserInteractionEnabled = true + + self.closeButton.alpha = 1.0 + self.closeButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.closeButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, damping: springDamping) + self.closeButton.isUserInteractionEnabled = true + + self.audioButton.alpha = 1.0 + self.audioButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.audioButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, damping: springDamping) + self.audioButton.isUserInteractionEnabled = true + + self.leaveButton.alpha = 1.0 + self.leaveButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.leaveButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, damping: springDamping) + self.leaveButton.isUserInteractionEnabled = true + + self.scheduleCancelButton.alpha = 0.0 + self.scheduleCancelButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15) + self.scheduleCancelButton.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: 26.0), duration: 0.2, removeOnCompletion: false, additive: true) + + self.actionButton.titleLabel.layer.animatePosition(from: CGPoint(x: 0.0, y: -26.0), to: CGPoint(), duration: 0.2, additive: true) + + if let pickerView = self.pickerView { + self.pickerView = nil + pickerView.alpha = 0.0 + pickerView.layer.animateScale(from: 1.0, to: 0.25, duration: 0.15, removeOnCompletion: false) + pickerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak pickerView] _ in + pickerView?.removeFromSuperview() + }) + pickerView.isUserInteractionEnabled = false + } + + self.timerNode.isHidden = false + self.timerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) + self.timerNode.animateIn() + + self.scheduleTextNode.alpha = 0.0 + self.scheduleTextNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25) + + self.updateTitle(slide: true, transition: .animated(duration: 0.2, curve: .easeInOut)) + } + + private func transitionToCall() { + self.updateDecorationsColors() + + self.listNode.alpha = 1.0 + self.listNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.listNode.isUserInteractionEnabled = true + + self.timerNode.alpha = 0.0 + self.timerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak self] _ in + self?.timerNode.isHidden = true + }) + + if self.audioButton.isHidden { + self.audioButton.isHidden = false + self.audioButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) + self.audioButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.6, damping: 100.0) + } + + self.updateTitle(transition: .animated(duration: 0.2, curve: .easeInOut)) + } + + @objc private func optionsPressed() { + self.optionsButton.play() + self.optionsButton.contextAction?(self.optionsButton.containerNode, nil) + } + + @objc private func closePressed() { + self.controller?.dismiss(closing: false) + self.controller?.dismissAllTooltips() + } + + @objc private func panelPressed() { + guard let (layout, navigationHeight) = self.validLayout, !self.animatingExpansion && !self.animatingMainStage && !self.mainStageNode.animating else { + return + } + self.panelHidden = !self.panelHidden + + self.animatingExpansion = true + let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) + self.updateDecorationsLayout(transition: transition) + } + + @objc private func leavePressed() { + self.hapticFeedback.impact(.light) + self.controller?.dismissAllTooltips() + + if let callState = self.callState, callState.canManageCall { + let action: () -> Void = { [weak self] in + guard let strongSelf = self else { + return + } + + strongSelf.leaveDisposable.set((strongSelf.call.leave(terminateIfPossible: true) + |> deliverOnMainQueue).start(completed: { + self?.controller?.dismiss() + })) + } + + let actionSheet = ActionSheetController(presentationData: self.presentationData.withUpdated(theme: self.darkTheme)) + var items: [ActionSheetItem] = [] + + let leaveTitle: String + let leaveAndCancelTitle: String + + if let channel = self.peer as? TelegramChannel, case .broadcast = channel.info { + leaveTitle = self.presentationData.strings.LiveStream_LeaveConfirmation + leaveAndCancelTitle = self.isScheduled ? self.presentationData.strings.LiveStream_LeaveAndCancelVoiceChat : self.presentationData.strings.LiveStream_LeaveAndEndVoiceChat + } else { + leaveTitle = self.presentationData.strings.VoiceChat_LeaveConfirmation + leaveAndCancelTitle = self.isScheduled ? self.presentationData.strings.VoiceChat_LeaveAndCancelVoiceChat : self.presentationData.strings.VoiceChat_LeaveAndEndVoiceChat + } + + items.append(ActionSheetTextItem(title: leaveTitle)) + items.append(ActionSheetButtonItem(title: leaveAndCancelTitle, color: .destructive, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + + if let strongSelf = self { + let title: String + let text: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + title = strongSelf.isScheduled ? strongSelf.presentationData.strings.LiveStream_CancelConfirmationTitle : strongSelf.presentationData.strings.LiveStream_EndConfirmationTitle + text = strongSelf.isScheduled ? strongSelf.presentationData.strings.LiveStream_CancelConfirmationText : strongSelf.presentationData.strings.LiveStream_EndConfirmationText + } else { + title = strongSelf.isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationTitle : strongSelf.presentationData.strings.VoiceChat_EndConfirmationTitle + text = strongSelf.isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationText : strongSelf.presentationData.strings.VoiceChat_EndConfirmationText + } + + if let (members, _) = strongSelf.currentCallMembers, members.count >= 10 || true { + let alertController = textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: strongSelf.isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationEnd : strongSelf.presentationData.strings.VoiceChat_EndConfirmationEnd, action: { + action() + })]) + strongSelf.controller?.present(alertController, in: .window(.root)) + } else { + action() + } + } + })) + + let leaveText: String + if let channel = self.peer as? TelegramChannel, case .broadcast = channel.info { + leaveText = self.presentationData.strings.LiveStream_LeaveVoiceChat + } else { + leaveText = self.presentationData.strings.VoiceChat_LeaveVoiceChat + } + + items.append(ActionSheetButtonItem(title: leaveText, color: .accent, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + + guard let strongSelf = self else { + return + } + + strongSelf.leaveDisposable.set((strongSelf.call.leave(terminateIfPossible: false) + |> deliverOnMainQueue).start(completed: { [weak self] in + self?.controller?.dismiss(closing: true) + })) + })) + + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: items), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + self.controller?.present(actionSheet, in: .window(.root)) + } else { + self.leaveDisposable.set((self.call.leave(terminateIfPossible: false) + |> deliverOnMainQueue).start(completed: { [weak self] in + self?.controller?.dismiss(closing: true) + })) + } + } + + @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { + if case .ended = recognizer.state { + if self.isScheduling { + self.dismissScheduled() + } else { + self.controller?.dismiss(closing: false) + self.controller?.dismissAllTooltips() + } + } + } + + private func presentUndoOverlay(content: UndoOverlayContent, action: @escaping (UndoOverlayAction) -> Bool) { + var animateInAsReplacement = false + self.controller?.forEachController { c in + if let c = c as? UndoOverlayController { + animateInAsReplacement = true + c.dismiss() + } + return true + } + self.controller?.present(UndoOverlayController(presentationData: self.presentationData, content: content, elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: action), in: .current) + } + + private func presentShare(_ inviteLinks: GroupCallInviteLinks) { + let formatSendTitle: (String) -> String = { string in + var string = string + if string.contains("[") && string.contains("]") { + if let startIndex = string.firstIndex(of: "["), let endIndex = string.firstIndex(of: "]") { + string.removeSubrange(startIndex ... endIndex) + } + } else { + string = string.trimmingCharacters(in: CharacterSet(charactersIn: "0123456789-,.")) + } + return string + } + + let _ = (self.context.account.postbox.loadedPeerWithId(self.call.peerId) + |> deliverOnMainQueue).start(next: { [weak self] peer in + if let strongSelf = self { + var inviteLinks = inviteLinks + + if let peer = peer as? TelegramChannel, case .group = peer.info, !peer.flags.contains(.isGigagroup), !(peer.addressName ?? "").isEmpty, let callState = strongSelf.callState, let defaultParticipantMuteState = callState.defaultParticipantMuteState { + let isMuted = defaultParticipantMuteState == .muted + + if !isMuted { + inviteLinks = GroupCallInviteLinks(listenerLink: inviteLinks.listenerLink, speakerLink: nil) + } + } + + let presentationData = strongSelf.presentationData + + var segmentedValues: [ShareControllerSegmentedValue]? + if let speakerLink = inviteLinks.speakerLink { + segmentedValues = [ShareControllerSegmentedValue(title: presentationData.strings.VoiceChat_InviteLink_Speaker, subject: .url(speakerLink), actionTitle: presentationData.strings.VoiceChat_InviteLink_CopySpeakerLink, formatSendTitle: { count in + return formatSendTitle(presentationData.strings.VoiceChat_InviteLink_InviteSpeakers(Int32(count))) + }), ShareControllerSegmentedValue(title: presentationData.strings.VoiceChat_InviteLink_Listener, subject: .url(inviteLinks.listenerLink), actionTitle: presentationData.strings.VoiceChat_InviteLink_CopyListenerLink, formatSendTitle: { count in + return formatSendTitle(presentationData.strings.VoiceChat_InviteLink_InviteListeners(Int32(count))) + })] + } + let shareController = ShareController(context: strongSelf.context, subject: .url(inviteLinks.listenerLink), segmentedValues: segmentedValues, forceTheme: strongSelf.darkTheme, forcedActionTitle: presentationData.strings.VoiceChat_CopyInviteLink) + shareController.completed = { [weak self] peerIds in + if let strongSelf = self { + let _ = (strongSelf.context.engine.data.get( + EngineDataList( + peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init) + ) + ) + |> deliverOnMainQueue).start(next: { [weak self] peerList in + if let strongSelf = self { + let peers = peerList.compactMap { $0 } + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } + + let text: String + var isSavedMessages = false + if peers.count == 1, let peer = peers.first { + isSavedMessages = peer.id == strongSelf.context.account.peerId + let peerName = peer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + text = presentationData.strings.VoiceChat_ForwardTooltip_Chat(peerName).string + } else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last { + let firstPeerName = firstPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + let secondPeerName = secondPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + text = presentationData.strings.VoiceChat_ForwardTooltip_TwoChats(firstPeerName, secondPeerName).string + } else if let peer = peers.first { + let peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + text = presentationData.strings.VoiceChat_ForwardTooltip_ManyChats(peerName, "\(peers.count - 1)").string + } else { + text = "" + } + + strongSelf.controller?.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: isSavedMessages, text: text), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) + } + }) + } + } + shareController.actionCompleted = { [weak self] in + if let strongSelf = self { + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } + strongSelf.controller?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: presentationData.strings.VoiceChat_InviteLinkCopiedText), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) + } + } + strongSelf.controller?.present(shareController, in: .window(.root)) + } + }) + } + + private var actionButtonPressTimer: SwiftSignalKit.Timer? + private var actionButtonPressedTimestamp: Double? + private func startActionButtonPressTimer() { + self.actionButtonPressTimer?.invalidate() + let pressTimer = SwiftSignalKit.Timer(timeout: 0.185, repeat: false, completion: { [weak self] in + self?.actionButtonPressedTimestamp = CACurrentMediaTime() + self?.actionButtonPressTimerFired() + self?.actionButtonPressTimer = nil + }, queue: Queue.mainQueue()) + self.actionButtonPressTimer = pressTimer + pressTimer.start() + } + + private func stopActionButtonPressTimer() { + self.actionButtonPressTimer?.invalidate() + self.actionButtonPressTimer = nil + } + + private func actionButtonPressTimerFired() { + guard let callState = self.callState else { + return + } + if callState.muteState != nil { + self.pushingToTalk = true + self.call.setIsMuted(action: .muted(isPushToTalkActive: true)) + } + + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) + } + + self.updateMembers() + } + + @objc private func actionButtonPressGesture(_ gestureRecognizer: UILongPressGestureRecognizer) { + guard let callState = self.callState else { + return + } + if case .connecting = callState.networkState, callState.scheduleTimestamp == nil && !self.isScheduling { + return + } + if callState.scheduleTimestamp != nil || self.isScheduling { + switch gestureRecognizer.state { + case .began: + self.actionButton.pressing = true + self.hapticFeedback.impact(.light) + case .ended, .cancelled: + self.actionButton.pressing = false + + let location = gestureRecognizer.location(in: self.actionButton.view) + if self.actionButton.hitTest(location, with: nil) != nil { + if self.isScheduling { + self.schedule() + } else if callState.canManageCall { + self.call.startScheduled() + } else { + if !callState.subscribedToScheduled { + let location = self.actionButton.view.convert(self.actionButton.bounds, to: self.view).center + let point = CGRect(origin: CGPoint(x: location.x - 5.0, y: location.y - 5.0 - 68.0), size: CGSize(width: 10.0, height: 10.0)) + self.controller?.present(TooltipScreen(account: self.context.account, text: self.presentationData.strings.VoiceChat_ReminderNotify, style: .gradient(UIColor(rgb: 0x262c5a), UIColor(rgb: 0x5d2835)), icon: nil, location: .point(point, .bottom), displayDuration: .custom(3.0), shouldDismissOnTouch: { _ in + return .dismiss(consume: false) + }), in: .window(.root)) + } + self.call.toggleScheduledSubscription(!callState.subscribedToScheduled) + } + } + default: + break + } + return + } + if let muteState = callState.muteState { + if !muteState.canUnmute { + switch gestureRecognizer.state { + case .began: + self.actionButton.pressing = true + self.hapticFeedback.impact(.light) + case .ended, .cancelled: + self.actionButton.pressing = false + + let location = gestureRecognizer.location(in: self.actionButton.view) + if self.actionButton.hitTest(location, with: nil) != nil { + self.call.raiseHand() + self.actionButton.playAnimation() + } + default: + break + } + return + } + } + switch gestureRecognizer.state { + case .began: + self.actionButton.pressing = true + self.hapticFeedback.impact(.light) + self.actionButtonPressedTimestamp = nil + self.startActionButtonPressTimer() + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) + } + case .ended, .cancelled: + if self.actionButtonPressTimer != nil { + self.pushingToTalk = false + self.actionButton.pressing = false + + self.stopActionButtonPressTimer() + self.call.toggleIsMuted() + } else { + self.hapticFeedback.impact(.light) + if self.pushingToTalk, let timestamp = self.actionButtonPressedTimestamp, CACurrentMediaTime() < timestamp + 0.5 { + self.pushingToTalk = false + self.temporaryPushingToTalk = true + self.call.setIsMuted(action: .unmuted) + + Queue.mainQueue().after(0.1) { + self.temporaryPushingToTalk = false + self.actionButton.pressing = false + + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) + } + } + } else { + self.pushingToTalk = false + self.actionButton.pressing = false + + self.call.setIsMuted(action: .muted(isPushToTalkActive: false)) + } + } + + if let callState = self.callState { + self.itemInteraction?.updateAudioLevels([(callState.myPeerId, 0, 0.0, false)], reset: true) + } + + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) + } + self.updateMembers() + default: + break + } + } + + @objc private func actionPressed() { + if self.isScheduling { + self.schedule() + } + } + + @objc private func audioPressed() { + self.hapticFeedback.impact(.light) + + if let _ = self.callState?.scheduleTimestamp { + if let callState = self.callState, let peer = self.peer, !callState.canManageCall && (peer.addressName?.isEmpty ?? true) { + return + } + + let _ = (self.inviteLinksPromise.get() + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] inviteLinks in + guard let strongSelf = self else { + return + } + + let _ = (strongSelf.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.call.peerId), + TelegramEngine.EngineData.Item.Peer.ExportedInvitation(id: strongSelf.call.peerId) + ) + |> map { peer, exportedInvitation -> GroupCallInviteLinks? in + if let inviteLinks = inviteLinks { + return inviteLinks + } else if let peer = peer, let addressName = peer.addressName, !addressName.isEmpty { + return GroupCallInviteLinks(listenerLink: "https://t.me/\(addressName)?voicechat", speakerLink: nil) + } else if let link = exportedInvitation?.link { + return GroupCallInviteLinks(listenerLink: link, speakerLink: nil) + } + return nil + } + |> deliverOnMainQueue).start(next: { links in + guard let strongSelf = self else { + return + } + + if let links = links { + strongSelf.presentShare(links) + } + }) + }) + return + } + + 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 { + self.call.setCurrentAudioOutput(output) + break + } + } + } else { + let actionSheet = ActionSheetController(presentationData: self.presentationData.withUpdated(theme: self.darkTheme)) + var items: [ActionSheetItem] = [] + for output in availableOutputs { + let title: String + var icon: UIImage? + switch output { + case .builtin: + title = UIDevice.current.model + case .speaker: + title = self.presentationData.strings.Call_AudioRouteSpeaker + icon = generateScaledImage(image: UIImage(bundleImageName: "Call/CallSpeakerButton"), size: CGSize(width: 48.0, height: 48.0), opaque: false) + case .headphones: + title = self.presentationData.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() + self?.call.setCurrentAudioOutput(output) + })) + } + + actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: self.presentationData.strings.Call_AudioRouteHide, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + self.controller?.present(actionSheet, in: .window(.calls)) + } + } + + @objc private func cameraPressed() { + self.hapticFeedback.impact(.light) + if self.call.hasVideo { + self.call.disableVideo() + + if let (layout, navigationHeight) = self.validLayout { + self.animatingButtonsSwap = true + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.4, curve: .spring)) + } + } else { + DeviceAccess.authorizeAccess(to: .camera(.videoCall), onlyCheck: true, presentationData: self.presentationData.withUpdated(theme: self.darkTheme), present: { [weak self] c, a in + self?.controller?.present(c, in: .window(.root), with: a) + }, openSettings: { [weak self] in + self?.context.sharedContext.applicationBindings.openSettings() + }, _: { [weak self] ready in + guard let strongSelf = self, ready else { + return + } + var isFrontCamera = true + let videoCapturer = OngoingCallVideoCapturer() + let input = videoCapturer.video() + if let videoView = strongSelf.videoRenderingContext.makeView(input: input, blur: false) { + videoView.updateIsEnabled(true) + + let cameraNode = GroupVideoNode(videoView: videoView, backdropVideoView: nil) + let controller = VoiceChatCameraPreviewController(sharedContext: strongSelf.context.sharedContext, cameraNode: cameraNode, shareCamera: { [weak self] _, unmuted in + if let strongSelf = self { + strongSelf.call.setIsMuted(action: unmuted ? .unmuted : .muted(isPushToTalkActive: false)) + (strongSelf.call as! PresentationGroupCallImpl).requestVideo(capturer: videoCapturer, useFrontCamera: isFrontCamera) + + if let (layout, navigationHeight) = strongSelf.validLayout { + strongSelf.animatingButtonsSwap = true + strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.4, curve: .spring)) + } + } + }, switchCamera: { + Queue.mainQueue().after(0.1) { + isFrontCamera = !isFrontCamera + videoCapturer.switchVideoInput(isFront: isFrontCamera) + } + }) + strongSelf.controller?.present(controller, in: .window(.root)) + } + }) + } + } + + @objc private func switchCameraPressed() { + self.hapticFeedback.impact(.light) + Queue.mainQueue().after(0.1) { + self.call.switchVideoCamera() + } + + if let callState = self.callState { + for entry in self.currentFullscreenEntries { + if case let .peer(peerEntry, _) = entry { + if peerEntry.peer.id == callState.myPeerId { + if let videoEndpointId = peerEntry.videoEndpointId, let videoNode = self.videoNodes[videoEndpointId] { + videoNode.flip(withBackground: false) + } + break + } + } + } + } + self.mainStageNode.flipVideoIfNeeded() + + let springDuration: Double = 0.7 + let springDamping: CGFloat = 100.0 + self.switchCameraButton.isUserInteractionEnabled = false + self.switchCameraButton.layer.animateSpring(from: 0.0 as NSNumber, to: CGFloat.pi as NSNumber, keyPath: "transform.rotation.z", duration: springDuration, damping: springDamping, completion: { [weak self] _ in + self?.switchCameraButton.isUserInteractionEnabled = true + }) + } + + private var isLandscape: Bool { + if let (layout, _) = self.validLayout, layout.size.width > layout.size.height, case .compact = layout.metrics.widthClass { + return true + } else { + return false + } + } + + private var effectiveBottomAreaHeight: CGFloat { + if let (layout, _) = self.validLayout, case .regular = layout.metrics.widthClass { + return bottomAreaHeight + } + switch self.displayMode { + case .modal: + return bottomAreaHeight + case let .fullscreen(controlsHidden): + return controlsHidden ? 0.0 : fullscreenBottomAreaHeight + } + } + + private var isFullscreen: Bool { + switch self.displayMode { + case .fullscreen(_), .modal(_, true): + return true + default: + return false + } + } + + private func updateDecorationsLayout(transition: ContainedViewLayoutTransition, completion: (() -> Void)? = nil) { + guard let (layout, _) = self.validLayout else { + return + } + + let isLandscape = self.isLandscape + + let layoutTopInset: CGFloat = max(layout.statusBarHeight ?? 0.0, layout.safeInsets.top) + let listTopInset = isLandscape ? topPanelHeight : layoutTopInset + topPanelHeight + let bottomPanelHeight = isLandscape ? layout.intrinsicInsets.bottom : bottomAreaHeight + layout.intrinsicInsets.bottom + + let size = layout.size + let contentWidth: CGFloat + var contentLeftInset: CGFloat = 0.0 + var forceUpdate = false + if case .regular = layout.metrics.widthClass { + contentWidth = max(320.0, min(375.0, floor(size.width * 0.3))) + if self.peerIdToEndpointId.isEmpty { + contentLeftInset = 0.0 + } else { + contentLeftInset = self.panelHidden ? layout.size.width : layout.size.width - contentWidth + } + forceUpdate = true + } else { + contentWidth = isLandscape ? min(530.0, size.width - 210.0) : size.width + } + + let listSize = CGSize(width: contentWidth, height: layout.size.height - listTopInset - bottomPanelHeight + bottomGradientHeight) + let topInset: CGFloat + if let (panInitialTopInset, panOffset) = self.panGestureArguments { + if self.isExpanded { + topInset = min(self.topInset ?? listSize.height, panInitialTopInset + max(0.0, panOffset)) + } else { + topInset = max(0.0, panInitialTopInset + min(0.0, panOffset)) + } + } else if case .regular = layout.metrics.widthClass { + topInset = 0.0 + } else if let currentTopInset = self.topInset { + topInset = self.isExpanded ? 0.0 : currentTopInset + } else { + topInset = listSize.height - 46.0 - floor(56.0 * 3.5) + } + + var bottomEdge: CGFloat = 0.0 + if case .regular = layout.metrics.widthClass { + bottomEdge = size.height + } else { + self.listNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? ListViewItemNode { + let convertedFrame = self.listNode.view.convert(itemNode.frame, to: self.contentContainer.view) + if convertedFrame.maxY > bottomEdge { + bottomEdge = convertedFrame.maxY + } + } + } + if bottomEdge.isZero { + bottomEdge = self.listNode.frame.minY + 46.0 + 56.0 + } + } + + let rawPanelOffset = topInset + listTopInset - topPanelHeight + let panelOffset = max(layoutTopInset, rawPanelOffset) + let topPanelFrame: CGRect + if isLandscape { + topPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: topPanelHeight)) + } else { + topPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: panelOffset), size: CGSize(width: size.width, height: topPanelHeight)) + } + + let sideInset: CGFloat = 14.0 + + let bottomPanelCoverHeight = bottomAreaHeight + layout.intrinsicInsets.bottom + var bottomGradientFrame = CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - bottomPanelCoverHeight), size: CGSize(width: size.width, height: bottomGradientHeight)) + if isLandscape { + bottomGradientFrame.origin.y = layout.size.height + } + + let transitionContainerFrame = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) + transition.updateFrame(node: self.transitionContainerNode, frame: transitionContainerFrame) + transition.updateFrame(view: self.transitionMaskView, frame: CGRect(x: 0.0, y: 0.0, width: transitionContainerFrame.width, height: transitionContainerFrame.height)) + let updateMaskLayers = { + var topPanelFrame = topPanelFrame + if self.animatingContextMenu { + topPanelFrame.origin.y = 0.0 + } + transition.updateFrame(layer: self.transitionMaskTopFillLayer, frame: CGRect(x: 0.0, y: 0.0, width: transitionContainerFrame.width, height: topPanelFrame.maxY)) + transition.updateFrame(layer: self.transitionMaskFillLayer, frame: CGRect(x: 0.0, y: topPanelFrame.maxY, width: transitionContainerFrame.width, height: bottomGradientFrame.minY - topPanelFrame.maxY)) + transition.updateFrame(layer: self.transitionMaskGradientLayer, frame: CGRect(x: 0.0, y: bottomGradientFrame.minY, width: transitionContainerFrame.width, height: bottomGradientFrame.height)) + transition.updateFrame(layer: self.transitionMaskBottomFillLayer, frame: CGRect(x: 0.0, y: bottomGradientFrame.minY, width: transitionContainerFrame.width, height: max(0.0, transitionContainerFrame.height - bottomGradientFrame.minY))) + } + if transition.isAnimated { + updateMaskLayers() + } else { + CATransaction.begin() + CATransaction.setDisableActions(true) + updateMaskLayers() + CATransaction.commit() + } + + var bottomInset: CGFloat = 0.0 + if case .compact = layout.metrics.widthClass, case let .fullscreen(controlsHidden) = self.displayMode { + if !controlsHidden { + bottomInset = 80.0 + } + } + transition.updateAlpha(node: self.bottomGradientNode, alpha: self.isLandscape ? 0.0 : 1.0) + + var isTablet = false + let videoFrame: CGRect + let videoContainerFrame: CGRect + if case .regular = layout.metrics.widthClass { + isTablet = true + let videoTopEdgeY = topPanelFrame.maxY + let videoBottomEdgeY = layout.size.height - layout.intrinsicInsets.bottom + videoFrame = CGRect(x: sideInset, y: 0.0, width: contentLeftInset - sideInset, height: videoBottomEdgeY - videoTopEdgeY) + videoContainerFrame = CGRect(origin: CGPoint(x: 0.0, y: videoTopEdgeY), size: CGSize(width: contentLeftInset, height: layout.size.height)) + } else { + let videoTopEdgeY = isLandscape ? 0.0 : layoutTopInset + let videoBottomEdgeY = self.isLandscape ? layout.size.height : layout.size.height - layout.intrinsicInsets.bottom - 92.0 + videoFrame = CGRect(x: 0.0, y: videoTopEdgeY, width: isLandscape ? max(0.0, layout.size.width - layout.safeInsets.right - 92.0) : layout.size.width, height: videoBottomEdgeY - videoTopEdgeY) + videoContainerFrame = CGRect(origin: CGPoint(), size: layout.size) + } + transition.updateFrame(node: self.mainStageContainerNode, frame: videoContainerFrame) + transition.updateFrame(node: self.mainStageBackgroundNode, frame: videoFrame) + if !self.mainStageNode.animating { + transition.updateFrame(node: self.mainStageNode, frame: videoFrame) + } + self.mainStageNode.update(size: videoFrame.size, sideInset: layout.safeInsets.left, bottomInset: self.isLandscape ? 0.0 : bottomInset, isLandscape: videoFrame.width > videoFrame.height, isTablet: isTablet, transition: transition) + + let backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: topPanelFrame.maxY), size: CGSize(width: size.width, height: layout.size.height)) + + let leftBorderFrame: CGRect + let rightBorderFrame: CGRect + let additionalInset: CGFloat = 60.0 + let additionalSideInset = (size.width - contentWidth) / 2.0 + let additionalLeftInset = size.width / 2.0 + if isLandscape { + leftBorderFrame = CGRect(origin: CGPoint(x: 0.0, y: topPanelFrame.maxY - additionalInset), size: CGSize(width: (size.width - contentWidth) / 2.0 + sideInset, height: layout.size.height)) + rightBorderFrame = CGRect(origin: CGPoint(x: size.width - (size.width - contentWidth) / 2.0 - sideInset, y: topPanelFrame.maxY - additionalInset), size: CGSize(width: layout.safeInsets.right + (size.width - contentWidth) / 2.0 + sideInset, height: layout.size.height)) + } else { + var isFullscreen = false + if case .fullscreen = self.displayMode { + isFullscreen = true + forceUpdate = true + } + leftBorderFrame = CGRect(origin: CGPoint(x: -additionalInset - additionalLeftInset, y: topPanelFrame.maxY - additionalInset * (isFullscreen ? 0.95 : 0.8)), size: CGSize(width: sideInset + additionalInset + additionalLeftInset + (contentLeftInset.isZero ? additionalSideInset : contentLeftInset), height: layout.size.height)) + rightBorderFrame = CGRect(origin: CGPoint(x: size.width - sideInset - (contentLeftInset.isZero ? additionalSideInset : 0.0), y: topPanelFrame.maxY - additionalInset * (isFullscreen ? 0.95 : 0.8)), size: CGSize(width: sideInset + additionalInset + additionalLeftInset + additionalSideInset, height: layout.size.height)) + } + + let topCornersFrame = CGRect(x: sideInset + (contentLeftInset.isZero ? floorToScreenPixels((size.width - contentWidth) / 2.0) : contentLeftInset), y: topPanelFrame.maxY - 60.0, width: contentWidth - sideInset * 2.0, height: 50.0 + 60.0) + + let previousTopPanelFrame = self.topPanelNode.frame + let previousBackgroundFrame = self.backgroundNode.frame + let previousLeftBorderFrame = self.leftBorderNode.frame + let previousRightBorderFrame = self.rightBorderNode.frame + + if !topPanelFrame.equalTo(previousTopPanelFrame) || forceUpdate { + if topPanelFrame.width != previousTopPanelFrame.width { + transition.updateFrame(node: self.topPanelNode, frame: topPanelFrame) + transition.updateFrame(node: self.topCornersNode, frame: topCornersFrame) + transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame) + transition.updateFrame(node: self.leftBorderNode, frame: leftBorderFrame) + transition.updateFrame(node: self.rightBorderNode, frame: rightBorderFrame) + } else { + self.topPanelNode.frame = topPanelFrame + let positionDelta = CGPoint(x: 0.0, y: topPanelFrame.minY - previousTopPanelFrame.minY) + transition.animateOffsetAdditive(layer: self.topPanelNode.layer, offset: positionDelta.y, completion: completion) + + transition.updateFrame(node: self.topCornersNode, frame: topCornersFrame) + + self.backgroundNode.frame = backgroundFrame + let backgroundPositionDelta = CGPoint(x: 0.0, y: previousBackgroundFrame.minY - backgroundFrame.minY) + transition.animatePositionAdditive(node: self.backgroundNode, offset: backgroundPositionDelta) + + self.leftBorderNode.frame = leftBorderFrame + let leftBorderPositionDelta = CGPoint(x: previousLeftBorderFrame.maxX - leftBorderFrame.maxX, y: previousLeftBorderFrame.minY - leftBorderFrame.minY) + transition.animatePositionAdditive(node: self.leftBorderNode, offset: leftBorderPositionDelta) + + self.rightBorderNode.frame = rightBorderFrame + let rightBorderPositionDelta = CGPoint(x: previousRightBorderFrame.minX - rightBorderFrame.minX, y: previousRightBorderFrame.minY - rightBorderFrame.minY) + transition.animatePositionAdditive(node: self.rightBorderNode, offset: rightBorderPositionDelta) + } + } else { + completion?() + } + + self.topPanelBackgroundNode.frame = CGRect(x: 0.0, y: topPanelHeight - 24.0, width: size.width, height: min(topPanelFrame.height, 24.0)) + + let listMaxY = listTopInset + listSize.height + let bottomOffset = min(0.0, bottomEdge - listMaxY) + layout.size.height - bottomPanelHeight + + let bottomCornersFrame = CGRect(origin: CGPoint(x: sideInset + floorToScreenPixels((size.width - contentWidth) / 2.0), y: -50.0 + bottomOffset + bottomGradientHeight), size: CGSize(width: contentWidth - sideInset * 2.0, height: 50.0 + 60.0)) + let bottomPanelBackgroundFrame = CGRect(x: 0.0, y: bottomOffset + bottomGradientHeight, width: size.width, height: 2000.0) + let previousBottomCornersFrame = self.bottomCornersNode.frame + if !bottomCornersFrame.equalTo(previousBottomCornersFrame) { + if bottomCornersFrame.width != previousBottomCornersFrame.width { + transition.updateFrame(node: self.bottomCornersNode, frame: bottomCornersFrame) + transition.updateFrame(node: self.bottomPanelBackgroundNode, frame: bottomPanelBackgroundFrame) + } else { + self.bottomCornersNode.frame = bottomCornersFrame + self.bottomPanelBackgroundNode.frame = bottomPanelBackgroundFrame + + let positionDelta = CGPoint(x: 0.0, y: previousBottomCornersFrame.minY - bottomCornersFrame.minY) + transition.animatePositionAdditive(node: self.bottomCornersNode, offset: positionDelta) + transition.animatePositionAdditive(node: self.bottomPanelBackgroundNode, offset: positionDelta) + } + } + + let participantsFrame = CGRect(x: 0.0, y: bottomCornersFrame.maxY - 100.0, width: size.width, height: 216.0) + transition.updateFrame(node: self.participantsNode, frame: participantsFrame) + self.participantsNode.update(size: participantsFrame.size, participants: self.currentTotalCount, groupingSeparator: self.presentationData.dateTimeFormat.groupingSeparator, transition: .immediate) + } + + private var decorationsAreDark: Bool? + private var ignoreLayout = false + private func updateDecorationsColors() { + guard let (layout, _) = self.validLayout else { + return + } + + let isFullscreen = self.isFullscreen + let effectiveDisplayMode = self.displayMode + + self.ignoreLayout = true + self.controller?.statusBar.updateStatusBarStyle(isFullscreen ? .White : .Ignore, animated: true) + self.ignoreLayout = false + + let size = layout.size + let topEdgeFrame: CGRect + if isFullscreen { + let offset: CGFloat + if let statusBarHeight = layout.statusBarHeight { + offset = statusBarHeight + } else { + offset = 44.0 + } + topEdgeFrame = CGRect(x: 0.0, y: -offset, width: size.width, height: topPanelHeight + offset) + } else { + topEdgeFrame = CGRect(x: 0.0, y: 0.0, width: size.width, height: topPanelHeight) + } + + let backgroundColor: UIColor + if case .fullscreen = effectiveDisplayMode { + backgroundColor = isFullscreen ? panelBackgroundColor : secondaryPanelBackgroundColor + } else if self.isScheduling || self.callState?.scheduleTimestamp != nil { + backgroundColor = panelBackgroundColor + } else { + backgroundColor = isFullscreen ? panelBackgroundColor : secondaryPanelBackgroundColor + } + + let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .linear) + transition.updateFrame(node: self.topPanelEdgeNode, frame: topEdgeFrame) + transition.updateCornerRadius(node: self.topPanelEdgeNode, cornerRadius: isFullscreen ? layout.deviceMetrics.screenCornerRadius - 0.5 : 12.0) + transition.updateBackgroundColor(node: self.topPanelBackgroundNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) + transition.updateBackgroundColor(node: self.topPanelEdgeNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) + transition.updateBackgroundColor(node: self.backgroundNode, color: backgroundColor) + transition.updateBackgroundColor(node: self.bottomPanelBackgroundNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) + transition.updateBackgroundColor(node: self.leftBorderNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) + transition.updateBackgroundColor(node: self.rightBorderNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) + + var gridNode: VoiceChatTilesGridItemNode? + self.listNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? VoiceChatTilesGridItemNode { + gridNode = itemNode + } + } + if let gridNode = gridNode { + transition.updateBackgroundColor(node: gridNode.backgroundNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) + } + + let previousDark = self.decorationsAreDark + self.decorationsAreDark = isFullscreen + if previousDark != self.decorationsAreDark { + if let snapshotView = self.topCornersNode.view.snapshotContentTree() { + snapshotView.frame = self.topCornersNode.bounds + self.topCornersNode.view.addSubview(snapshotView) + + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in + snapshotView?.removeFromSuperview() + }) + } + self.topCornersNode.image = decorationTopCornersImage(dark: isFullscreen) + + if let snapshotView = self.bottomCornersNode.view.snapshotContentTree() { + snapshotView.frame = self.bottomCornersNode.bounds + self.bottomCornersNode.view.addSubview(snapshotView) + + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in + snapshotView?.removeFromSuperview() + }) + } + self.bottomCornersNode.image = decorationBottomCornersImage(dark: isFullscreen) + + if let gridNode = gridNode { + if let snapshotView = gridNode.cornersNode.view.snapshotContentTree() { + snapshotView.frame = gridNode.cornersNode.bounds + gridNode.cornersNode.view.addSubview(snapshotView) + + snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in + snapshotView?.removeFromSuperview() + }) + } + gridNode.cornersNode.image = decorationCornersImage(top: true, bottom: false, dark: isFullscreen) + gridNode.supernode?.addSubnode(gridNode) + } + + UIView.transition(with: self.bottomGradientNode.view, duration: 0.3, options: [.transitionCrossDissolve, .curveLinear]) { + self.bottomGradientNode.backgroundColor = decorationBottomGradientImage(dark: isFullscreen).flatMap { UIColor(patternImage: $0) } + } completion: { _ in + } + + self.closeButton.setContent(.image(closeButtonImage(dark: isFullscreen)), animated: transition.isAnimated) + self.optionsButton.setContent(.more(optionsCircleImage(dark: isFullscreen)), animated: transition.isAnimated) + self.panelButton.setContent(.image(panelButtonImage(dark: isFullscreen)), animated: transition.isAnimated) + } + + self.updateTitle(transition: transition) + } + + private func updateTitle(slide: Bool = false, transition: ContainedViewLayoutTransition) { + guard let _ = self.validLayout else { + return + } + + var title = self.currentTitle + if self.isScheduling { + if let peer = self.peer as? TelegramChannel, case .broadcast = peer.info { + title = self.presentationData.strings.ScheduleLiveStream_Title + } else { + title = self.presentationData.strings.ScheduleVoiceChat_Title + } + } else if case .modal(_, false) = self.displayMode, !self.currentTitleIsCustom { + if let navigationController = self.controller?.navigationController as? NavigationController { + for controller in navigationController.viewControllers.reversed() { + if let controller = controller as? ChatController, case let .peer(peerId) = controller.chatLocation, peerId == self.call.peerId { + if let peer = self.peer as? TelegramChannel, case .broadcast = peer.info { + title = self.presentationData.strings.VoiceChatChannel_Title + } else { + title = self.presentationData.strings.VoiceChat_Title + } + } + } + } + } + + var subtitle = "" + var speaking = false + if self.scrollAtTop { + subtitle = self.currentSubtitle + speaking = false + } else { + subtitle = self.currentSpeakingSubtitle ?? self.currentSubtitle + speaking = self.currentSpeakingSubtitle != nil + } + if self.isScheduling { + subtitle = "" + speaking = false + } else if self.callState?.scheduleTimestamp != nil { + if self.callState?.canManageCall ?? false { + subtitle = self.presentationData.strings.VoiceChat_TapToEditTitle + } else { + subtitle = self.presentationData.strings.VoiceChat_Scheduled + } + speaking = false + } + + self.titleNode.update(size: CGSize(width: self.titleNode.bounds.width, height: 44.0), title: title, subtitle: subtitle, speaking: speaking, slide: slide, transition: transition) + } + + private func updateButtons(transition: ContainedViewLayoutTransition) { + guard let (layout, _) = self.validLayout else { + return + } + var audioMode: CallControllerButtonsSpeakerMode = .none + //var hasAudioRouteMenu: Bool = false + if let (availableOutputs, maybeCurrentOutput) = self.audioOutputState, let currentOutput = maybeCurrentOutput { + //hasAudioRouteMenu = availableOutputs.count > 2 + switch currentOutput { + case .builtin: + audioMode = .builtin + case .speaker: + audioMode = .speaker + case .headphones: + audioMode = .headphones + case let .port(port): + var type: CallControllerButtonsSpeakerMode.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 + } + audioMode = .bluetooth(type) + } + if availableOutputs.count <= 1 { + audioMode = .none + } + } + + let normalButtonAppearance: CallControllerButtonItemNode.Content.Appearance + let activeButtonAppearance: CallControllerButtonItemNode.Content.Appearance + if let color = self.currentNormalButtonColor { + normalButtonAppearance = .color(.custom(color.rgb, 1.0)) + } else { + normalButtonAppearance = .color(.custom(self.isFullscreen ? 0x1c1c1e : 0x2c2c2e, 1.0)) + } + if let color = self.currentActiveButtonColor { + activeButtonAppearance = .color(.custom(color.rgb, 1.0)) + } else { + activeButtonAppearance = .color(.custom(self.isFullscreen ? 0x1c1c1e : 0x2c2c2e, 1.0)) + } + + var soundImage: CallControllerButtonItemNode.Content.Image + var soundAppearance: CallControllerButtonItemNode.Content.Appearance = normalButtonAppearance + var soundTitle: String = self.presentationData.strings.Call_Speaker + switch audioMode { + case .none, .builtin: + soundImage = .speaker + case .speaker: + soundImage = .speaker + soundAppearance = activeButtonAppearance + case .headphones: + soundImage = .headphones + soundTitle = self.presentationData.strings.Call_Audio + case let .bluetooth(type): + switch type { + case .generic: + soundImage = .bluetooth + case .airpods: + soundImage = .airpods + case .airpodsPro: + soundImage = .airpodsPro + case .airpodsMax: + soundImage = .airpodsMax + } + soundTitle = self.presentationData.strings.Call_Audio + } + + let isScheduled = self.isScheduling || self.callState?.scheduleTimestamp != nil + + var isSoundEnabled = true + if isScheduled { + if let callState = self.callState, let peer = self.peer, !callState.canManageCall && (peer.addressName?.isEmpty ?? true) { + isSoundEnabled = false + } else { + soundImage = .share + soundTitle = self.presentationData.strings.VoiceChat_ShareShort + soundAppearance = normalButtonAppearance + } + } + + let audioButtonSize: CGSize + var buttonsTitleAlpha: CGFloat + let effectiveDisplayMode = self.displayMode + + let hasCameraButton = self.cameraButton.isUserInteractionEnabled + let hasVideo = self.call.hasVideo + switch effectiveDisplayMode { + case .modal: + audioButtonSize = hasCameraButton ? smallButtonSize : sideButtonSize + buttonsTitleAlpha = 1.0 + case .fullscreen: + if case .regular = layout.metrics.widthClass { + audioButtonSize = hasCameraButton ? smallButtonSize : sideButtonSize + } else { + audioButtonSize = sideButtonSize + } + if case .regular = layout.metrics.widthClass { + buttonsTitleAlpha = 1.0 + } else { + buttonsTitleAlpha = 0.0 + } + } + + self.cameraButton.update(size: sideButtonSize, content: CallControllerButtonItemNode.Content(appearance: hasVideo ? activeButtonAppearance : normalButtonAppearance, image: hasVideo ? .cameraOn : .cameraOff), text: self.presentationData.strings.VoiceChat_Video, transition: transition) + + self.switchCameraButton.update(size: audioButtonSize, content: CallControllerButtonItemNode.Content(appearance: normalButtonAppearance, image: .flipCamera), text: "", transition: transition) + + transition.updateAlpha(node: self.switchCameraButton, alpha: hasCameraButton && hasVideo ? 1.0 : 0.0) + transition.updateTransformScale(node: self.switchCameraButton, scale: hasCameraButton && hasVideo ? 1.0 : 0.0) + + transition.updateTransformScale(node: self.cameraButton, scale: hasCameraButton ? 1.0 : 0.0) + + let hasAudioButton = !self.isScheduling + transition.updateAlpha(node: self.audioButton, alpha: hasCameraButton || !hasAudioButton ? 0.0 : 1.0) + transition.updateTransformScale(node: self.audioButton, scale: hasCameraButton || !hasAudioButton ? 0.0 : 1.0) + + self.audioButton.update(size: audioButtonSize, content: CallControllerButtonItemNode.Content(appearance: soundAppearance, image: soundImage, isEnabled: isSoundEnabled), text: soundTitle, transition: transition) + self.audioButton.isUserInteractionEnabled = isSoundEnabled + + self.leaveButton.update(size: sideButtonSize, content: CallControllerButtonItemNode.Content(appearance: .color(.custom(0xff3b30, 0.3)), image: .cancel), text: self.presentationData.strings.VoiceChat_Leave, transition: .immediate) + + transition.updateAlpha(node: self.cameraButton.textNode, alpha: buttonsTitleAlpha) + transition.updateAlpha(node: self.switchCameraButton.textNode, alpha: buttonsTitleAlpha) + transition.updateAlpha(node: self.audioButton.textNode, alpha: buttonsTitleAlpha) + transition.updateAlpha(node: self.leaveButton.textNode, alpha: buttonsTitleAlpha) + } + + func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationHeight: CGFloat, transition: ContainedViewLayoutTransition) { + guard !self.ignoreLayout else { + return + } + let isFirstTime = self.validLayout == nil + let previousLayout = self.validLayout?.0 + self.validLayout = (layout, navigationHeight) + + let size = layout.size + let contentWidth: CGFloat + let headerWidth: CGFloat + let contentLeftInset: CGFloat + if case .regular = layout.metrics.widthClass { + contentWidth = max(320.0, min(375.0, floor(size.width * 0.3))) + headerWidth = size.width + if self.peerIdToEndpointId.isEmpty { + contentLeftInset = 0.0 + } else { + contentLeftInset = self.panelHidden ? layout.size.width : layout.size.width - contentWidth + } + } else { + contentWidth = self.isLandscape ? min(530.0, size.width - 210.0) : size.width + headerWidth = contentWidth + contentLeftInset = 0.0 + } + + var previousIsLandscape = false + if let previousLayout = previousLayout, case .compact = previousLayout.metrics.widthClass, previousLayout.size.width > previousLayout.size.height { + previousIsLandscape = true + } + var shouldSwitchToExpanded = false + if case let .modal(isExpanded, _) = self.displayMode { + if previousIsLandscape != self.isLandscape && !isExpanded { + shouldSwitchToExpanded = true + } else if case .regular = layout.metrics.widthClass, !isExpanded { + shouldSwitchToExpanded = true + } + } + if shouldSwitchToExpanded { + self.displayMode = .modal(isExpanded: true, isFilled: true) + self.updateDecorationsColors() + self.updateDecorationsLayout(transition: transition) + self.updateMembers() + } else if case .fullscreen = self.displayMode, previousIsLandscape != self.isLandscape { + self.updateMembers() + } + + let effectiveDisplayMode = self.displayMode + + transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - headerWidth) / 2.0), y: 10.0), size: CGSize(width: headerWidth, height: 44.0))) + self.updateTitle(transition: transition) + + transition.updateFrame(node: self.optionsButton, frame: CGRect(origin: CGPoint(x: 20.0 + floorToScreenPixels((size.width - headerWidth) / 2.0), y: 18.0), size: CGSize(width: 28.0, height: 28.0))) + transition.updateFrame(node: self.panelButton, frame: CGRect(origin: CGPoint(x: size.width - floorToScreenPixels((size.width - headerWidth) / 2.0) - 20.0 - 28.0 - 38.0 - 24.0, y: 18.0), size: CGSize(width: 38.0, height: 28.0))) + transition.updateFrame(node: self.closeButton, frame: CGRect(origin: CGPoint(x: size.width - floorToScreenPixels((size.width - headerWidth) / 2.0) - 20.0 - 28.0, y: 18.0), size: CGSize(width: 28.0, height: 28.0))) + + transition.updateAlpha(node: self.optionsButton, alpha: self.optionsButton.isUserInteractionEnabled ? 1.0 : 0.0) + transition.updateAlpha(node: self.panelButton, alpha: self.panelButton.isUserInteractionEnabled ? 1.0 : 0.0) + + transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) + transition.updateFrame(node: self.contentContainer, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - size.width) / 2.0), y: 0.0), size: size)) + + let layoutTopInset: CGFloat = max(layout.statusBarHeight ?? 0.0, layout.safeInsets.top) + let sideInset: CGFloat = 14.0 + + var listInsets = UIEdgeInsets() + listInsets.left = sideInset + (self.isLandscape ? 0.0 : layout.safeInsets.left) + listInsets.right = sideInset + (self.isLandscape ? 0.0 : layout.safeInsets.right) + + let topEdgeOffset: CGFloat + if let statusBarHeight = layout.statusBarHeight { + topEdgeOffset = statusBarHeight + } else { + topEdgeOffset = 44.0 + } + + if self.isLandscape { + transition.updateFrame(node: self.topPanelEdgeNode, frame: CGRect(x: 0.0, y: -topEdgeOffset, width: size.width, height: topPanelHeight + topEdgeOffset)) + } else if let _ = self.panGestureArguments { + } else { + let topEdgeFrame: CGRect + if self.isFullscreen { + topEdgeFrame = CGRect(x: 0.0, y: -topEdgeOffset, width: size.width, height: topPanelHeight + topEdgeOffset) + } else { + topEdgeFrame = CGRect(x: 0.0, y: 0.0, width: size.width, height: topPanelHeight) + } + transition.updateFrame(node: self.topPanelEdgeNode, frame: topEdgeFrame) + } + + let bottomPanelHeight = self.effectiveBottomAreaHeight + layout.intrinsicInsets.bottom + var listTopInset = layoutTopInset + topPanelHeight + if self.isLandscape { + listTopInset = topPanelHeight + } + + let listSize = CGSize(width: contentWidth, height: layout.size.height - listTopInset - (self.isLandscape ? layout.intrinsicInsets.bottom : bottomPanelHeight) + bottomGradientHeight) + let topInset: CGFloat + if let (panInitialTopInset, panOffset) = self.panGestureArguments { + if self.isExpanded { + topInset = min(self.topInset ?? listSize.height, panInitialTopInset + max(0.0, panOffset)) + } else { + topInset = max(0.0, panInitialTopInset + min(0.0, panOffset)) + } + } else if case .regular = layout.metrics.widthClass { + topInset = 0.0 + } else if let currentTopInset = self.topInset { + topInset = self.isExpanded ? 0.0 : currentTopInset + } else { + topInset = listSize.height - 46.0 - floor(56.0 * 3.5) - bottomGradientHeight + } + + transition.updateFrameAsPositionAndBounds(node: self.listContainer, frame: CGRect(origin: CGPoint(), size: size)) + transition.updateFrame(node: self.listNode, frame: CGRect(origin: CGPoint(x: contentLeftInset.isZero ? floorToScreenPixels((size.width - contentWidth) / 2.0) : contentLeftInset, y: listTopInset + topInset), size: listSize)) + + let tileGridSize = CGSize(width: max(0.0, contentLeftInset - sideInset), height: size.height - layout.intrinsicInsets.bottom - listTopInset - topInset) + + if contentLeftInset > 0.0 { + self.tileGridNode.isHidden = false + } + if !self.tileGridNode.isHidden { + let _ = self.tileGridNode.update(size: tileGridSize, layoutMode: .grid, items: self.currentTileItems, transition: transition, completion: { [weak self] in + if contentLeftInset.isZero && transition.isAnimated { + self?.tileGridNode.isHidden = true + } + }) + } + transition.updateFrame(node: self.tileGridNode, frame: CGRect(origin: CGPoint(x: sideInset, y: listTopInset + topInset), size: tileGridSize)) + self.tileGridNode.updateAbsoluteRect(CGRect(origin: CGPoint(), size: tileGridSize), within: tileGridSize) + + listInsets.bottom = bottomGradientHeight + + let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: listSize, insets: listInsets, duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + + let fullscreenListWidth: CGFloat + let fullscreenListHeight: CGFloat = 84.0 + let fullscreenListTransform: CATransform3D + let fullscreenListInset: CGFloat = 14.0 + let fullscreenListUpdateSizeAndInsets: ListViewUpdateSizeAndInsets + let fullscreenListContainerFrame: CGRect + if self.isLandscape { + fullscreenListWidth = layout.size.height + fullscreenListTransform = CATransform3DIdentity + fullscreenListUpdateSizeAndInsets = ListViewUpdateSizeAndInsets(size: CGSize(width: fullscreenListHeight, height: layout.size.height), insets: UIEdgeInsets(top: fullscreenListInset, left: 0.0, bottom: fullscreenListInset, right: 0.0), duration: duration, curve: curve) + fullscreenListContainerFrame = CGRect(x: layout.size.width - min(self.effectiveBottomAreaHeight, fullscreenBottomAreaHeight) - layout.safeInsets.right - fullscreenListHeight - 4.0, y: 0.0, width: fullscreenListHeight, height: layout.size.height) + } else { + fullscreenListWidth = layout.size.width + fullscreenListTransform = CATransform3DMakeRotation(-CGFloat(CGFloat.pi / 2.0), 0.0, 0.0, 1.0) + fullscreenListUpdateSizeAndInsets = ListViewUpdateSizeAndInsets(size: CGSize(width: fullscreenListHeight, height: layout.size.width), insets: UIEdgeInsets(top: fullscreenListInset + layout.safeInsets.left, left: 0.0, bottom: fullscreenListInset + layout.safeInsets.left, right: 0.0), duration: duration, curve: curve) + fullscreenListContainerFrame = CGRect(x: 0.0, y: layout.size.height - min(bottomPanelHeight, fullscreenBottomAreaHeight + layout.intrinsicInsets.bottom) - fullscreenListHeight - 4.0, width: layout.size.width, height: fullscreenListHeight) + } + + transition.updateFrame(node: self.fullscreenListContainer, frame: fullscreenListContainerFrame) + + self.fullscreenListNode.bounds = CGRect(x: 0.0, y: 0.0, width: fullscreenListHeight, height: fullscreenListWidth) + transition.updatePosition(node: self.fullscreenListNode, position: CGPoint(x: fullscreenListContainerFrame.width / 2.0, y: fullscreenListContainerFrame.height / 2.0)) + + self.fullscreenListNode.transform = fullscreenListTransform + self.fullscreenListNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: fullscreenListUpdateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + + if case .regular = layout.metrics.widthClass { + self.transitionContainerNode.view.mask = nil + } else { + self.transitionContainerNode.view.mask = self.transitionMaskView + } + + var childrenLayout = layout + var childrenInsets = childrenLayout.intrinsicInsets + var childrenSafeInsets = childrenLayout.safeInsets + if case .regular = layout.metrics.widthClass { + let childrenLayoutWidth: CGFloat = 375.0 + if contentLeftInset.isZero { + childrenSafeInsets.left = floorToScreenPixels((size.width - childrenLayoutWidth) / 2.0) + childrenSafeInsets.right = floorToScreenPixels((size.width - childrenLayoutWidth) / 2.0) + } else { + childrenSafeInsets.left = floorToScreenPixels((contentLeftInset - childrenLayoutWidth) / 2.0) + childrenSafeInsets.right = childrenSafeInsets.left + (size.width - contentLeftInset) + } + } else if !self.isLandscape, case .fullscreen = effectiveDisplayMode { + childrenInsets.bottom += self.effectiveBottomAreaHeight + fullscreenListHeight + 36.0 + } + childrenLayout.safeInsets = childrenSafeInsets + childrenLayout.intrinsicInsets = childrenInsets + self.controller?.presentationContext.containerLayoutUpdated(childrenLayout, transition: transition) + + var bottomPanelLeftInset = contentLeftInset + var bottomPanelWidth = size.width - contentLeftInset + if case .regular = layout.metrics.widthClass, bottomPanelLeftInset.isZero { + bottomPanelLeftInset = floorToScreenPixels((layout.size.width - contentWidth) / 2.0) + bottomPanelWidth = contentWidth + } + + var bottomPanelFrame = CGRect(origin: CGPoint(x: bottomPanelLeftInset, y: layout.size.height - bottomPanelHeight), size: CGSize(width: bottomPanelWidth, height: bottomPanelHeight)) + let bottomPanelCoverHeight = bottomAreaHeight + layout.intrinsicInsets.bottom + if self.isLandscape { + bottomPanelFrame = CGRect(origin: CGPoint(x: layout.size.width - fullscreenBottomAreaHeight - layout.safeInsets.right, y: 0.0), size: CGSize(width: fullscreenBottomAreaHeight + layout.safeInsets.right, height: layout.size.height)) + } + let bottomGradientFrame = CGRect(origin: CGPoint(x: bottomPanelLeftInset, y: layout.size.height - bottomPanelCoverHeight), size: CGSize(width: bottomPanelWidth, height: bottomGradientHeight)) + transition.updateFrame(node: self.bottomGradientNode, frame: bottomGradientFrame) + transition.updateFrame(node: self.bottomPanelNode, frame: bottomPanelFrame) + + if let pickerView = self.pickerView { + transition.updateFrame(view: pickerView, frame: CGRect(x: 0.0, y: layout.size.height - bottomPanelHeight - 216.0, width: size.width, height: 216.0)) + } + + let timerFrame = CGRect(x: 0.0, y: layout.size.height - bottomPanelHeight - 216.0, width: size.width, height: 216.0) + transition.updateFrame(node: self.timerNode, frame: timerFrame) + self.timerNode.update(size: timerFrame.size, scheduleTime: self.callState?.scheduleTimestamp, transition: .immediate) + + let scheduleTextSize = self.scheduleTextNode.updateLayout(CGSize(width: size.width - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude)) + self.scheduleTextNode.frame = CGRect(origin: CGPoint(x: floor((size.width - scheduleTextSize.width) / 2.0), y: layout.size.height - layout.intrinsicInsets.bottom - scheduleTextSize.height - 145.0), size: scheduleTextSize) + + let centralButtonSide = min(contentWidth, size.height) - 32.0 + let centralButtonSize = CGSize(width: centralButtonSide, height: centralButtonSide) + let cameraButtonSize = smallButtonSize + let sideButtonMinimalInset: CGFloat = 16.0 + let sideButtonOffset = min(42.0, floor((((contentWidth - 112.0) / 2.0) - sideButtonSize.width) / 2.0)) + let sideButtonOrigin = max(sideButtonMinimalInset, floor((contentWidth - 112.0) / 2.0) - sideButtonOffset - sideButtonSize.width) + + let smallButtons: Bool + if case .regular = layout.metrics.widthClass { + smallButtons = false + } else { + switch effectiveDisplayMode { + case .modal: + smallButtons = self.isLandscape + case .fullscreen: + smallButtons = true + } + } + let actionButtonState: VoiceChatActionButton.State + let actionButtonTitle: String + let actionButtonSubtitle: String + var actionButtonEnabled = true + if let callState = self.callState, !self.isScheduling { + if callState.scheduleTimestamp != nil { + self.ignoreConnecting = true + if callState.canManageCall { + actionButtonState = .scheduled(state: .start) + actionButtonTitle = self.presentationData.strings.VoiceChat_StartNow + actionButtonSubtitle = "" + } else { + if callState.subscribedToScheduled { + actionButtonState = .scheduled(state: .unsubscribe) + actionButtonTitle = self.presentationData.strings.VoiceChat_CancelReminder + } else { + actionButtonState = .scheduled(state: .subscribe) + actionButtonTitle = self.presentationData.strings.VoiceChat_SetReminder + } + actionButtonSubtitle = "" + } + } else { + let connected = self.ignoreConnecting || callState.networkState == .connected + if case .connected = callState.networkState { + self.ignoreConnecting = false + self.ignoreConnectingTimer?.invalidate() + self.ignoreConnectingTimer = nil + } else if self.ignoreConnecting { + if self.ignoreConnectingTimer == nil { + let timer = SwiftSignalKit.Timer(timeout: 3.0, repeat: false, completion: { [weak self] in + if let strongSelf = self { + strongSelf.ignoreConnecting = false + strongSelf.ignoreConnectingTimer?.invalidate() + strongSelf.ignoreConnectingTimer = nil + + if let (layout, navigationHeight) = strongSelf.validLayout { + strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .immediate) + } + } + }, queue: Queue.mainQueue()) + self.ignoreConnectingTimer = timer + timer.start() + } + } + + if connected { + if let muteState = callState.muteState, !self.pushingToTalk && !self.temporaryPushingToTalk { + if muteState.canUnmute { + actionButtonState = .active(state: .muted) + + actionButtonTitle = self.presentationData.strings.VoiceChat_Unmute + actionButtonSubtitle = "" + } else { + actionButtonState = .active(state: .cantSpeak) + + if callState.raisedHand { + actionButtonTitle = self.presentationData.strings.VoiceChat_AskedToSpeak + actionButtonSubtitle = self.presentationData.strings.VoiceChat_AskedToSpeakHelp + } else { + actionButtonTitle = self.presentationData.strings.VoiceChat_MutedByAdmin + actionButtonSubtitle = self.presentationData.strings.VoiceChat_MutedByAdminHelp + } + } + } else { + actionButtonState = .active(state: .on) + + actionButtonTitle = self.pushingToTalk ? self.presentationData.strings.VoiceChat_Live : self.presentationData.strings.VoiceChat_Mute + actionButtonSubtitle = "" + } + } else { + actionButtonState = .connecting + actionButtonTitle = self.presentationData.strings.VoiceChat_Connecting + actionButtonSubtitle = "" + actionButtonEnabled = false + } + } + } else { + if self.isScheduling { + actionButtonState = .button(text: self.scheduleButtonTitle) + actionButtonTitle = "" + actionButtonSubtitle = "" + actionButtonEnabled = true + } else { + actionButtonState = .connecting + actionButtonTitle = self.presentationData.strings.VoiceChat_Connecting + actionButtonSubtitle = "" + actionButtonEnabled = false + } + } + + self.actionButton.isDisabled = !actionButtonEnabled + self.actionButton.update(size: centralButtonSize, buttonSize: CGSize(width: 112.0, height: 112.0), state: actionButtonState, title: actionButtonTitle, subtitle: actionButtonSubtitle, dark: self.isFullscreen, small: smallButtons, animated: true) + + let isVideoEnabled = self.callState?.isVideoEnabled ?? false + var hasCameraButton = isVideoEnabled + if let joinedVideo = self.joinedVideo { + hasCameraButton = joinedVideo + } + if !isVideoEnabled { + hasCameraButton = false + } + switch actionButtonState { + case let .active(state): + switch state { + case .cantSpeak: + hasCameraButton = false + case .on, .muted: + break + } + case .connecting: + if !self.connectedOnce { + hasCameraButton = false + } + case .scheduled, .button: + hasCameraButton = false + } + let hasVideo = hasCameraButton && self.call.hasVideo + + let upperButtonDistance: CGFloat = 12.0 + let firstButtonFrame: CGRect + let secondButtonFrame: CGRect + let thirdButtonFrame: CGRect + let forthButtonFrame: CGRect + + let leftButtonFrame: CGRect + if self.isScheduled || !hasVideo { + leftButtonFrame = CGRect(origin: CGPoint(x: sideButtonOrigin, y: floor((self.effectiveBottomAreaHeight - sideButtonSize.height) / 2.0)), size: sideButtonSize) + } else { + leftButtonFrame = CGRect(origin: CGPoint(x: sideButtonOrigin, y: floor((self.effectiveBottomAreaHeight - sideButtonSize.height - upperButtonDistance - cameraButtonSize.height) / 2.0) + upperButtonDistance + cameraButtonSize.height), size: sideButtonSize) + } + let rightButtonFrame = CGRect(origin: CGPoint(x: contentWidth - sideButtonOrigin - sideButtonSize.width, y: floor((self.effectiveBottomAreaHeight - sideButtonSize.height) / 2.0)), size: sideButtonSize) + var centerButtonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - centralButtonSize.width) / 2.0), y: floor((self.effectiveBottomAreaHeight - centralButtonSize.height) / 2.0) - 3.0), size: centralButtonSize) + + if case .regular = layout.metrics.widthClass { + centerButtonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((contentWidth - centralButtonSize.width) / 2.0), y: floor((self.effectiveBottomAreaHeight - centralButtonSize.height) / 2.0) - 3.0), size: centralButtonSize) + + if hasCameraButton { + firstButtonFrame = CGRect(origin: CGPoint(x: floor(leftButtonFrame.midX - cameraButtonSize.width / 2.0), y: leftButtonFrame.minY - upperButtonDistance - cameraButtonSize.height), size: cameraButtonSize) + } else { + firstButtonFrame = CGRect(origin: CGPoint(x: leftButtonFrame.center.x - cameraButtonSize.width / 2.0, y: leftButtonFrame.center.y - cameraButtonSize.height / 2.0), size: cameraButtonSize) + } + secondButtonFrame = leftButtonFrame + thirdButtonFrame = centerButtonFrame + forthButtonFrame = rightButtonFrame + } else { + switch effectiveDisplayMode { + case .modal: + if self.isLandscape { + let sideInset: CGFloat + let buttonsCount: Int + if hasVideo { + sideInset = 26.0 + buttonsCount = 4 + } else { + sideInset = 42.0 + buttonsCount = 3 + } + let spacing = floor((layout.size.height - sideInset * 2.0 - sideButtonSize.height * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) + let x = floor((fullscreenBottomAreaHeight - sideButtonSize.width) / 2.0) + forthButtonFrame = CGRect(origin: CGPoint(x: x, y: sideInset), size: sideButtonSize) + let thirdButtonPreFrame = CGRect(origin: CGPoint(x: x, y: sideInset + sideButtonSize.height + spacing), size: sideButtonSize) + thirdButtonFrame = CGRect(origin: CGPoint(x: floor(thirdButtonPreFrame.midX - centralButtonSize.width / 2.0), y: floor(thirdButtonPreFrame.midY - centralButtonSize.height / 2.0)), size: centralButtonSize) + secondButtonFrame = CGRect(origin: CGPoint(x: x, y: thirdButtonPreFrame.maxY + spacing), size: sideButtonSize) + if hasCameraButton { + firstButtonFrame = CGRect(origin: CGPoint(x: x, y: layout.size.height - sideInset - sideButtonSize.height), size: sideButtonSize) + } else { + firstButtonFrame = secondButtonFrame + } + } else { + if hasCameraButton { + firstButtonFrame = CGRect(origin: CGPoint(x: floor(leftButtonFrame.midX - cameraButtonSize.width / 2.0), y: leftButtonFrame.minY - upperButtonDistance - cameraButtonSize.height), size: cameraButtonSize) + } else { + firstButtonFrame = CGRect(origin: CGPoint(x: leftButtonFrame.center.x - cameraButtonSize.width / 2.0, y: leftButtonFrame.center.y - cameraButtonSize.height / 2.0), size: cameraButtonSize) + } + secondButtonFrame = leftButtonFrame + thirdButtonFrame = centerButtonFrame + forthButtonFrame = rightButtonFrame + } + case let .fullscreen(controlsHidden): + if self.isLandscape { + let sideInset: CGFloat + let buttonsCount: Int + if hasVideo { + sideInset = 26.0 + buttonsCount = 4 + } else { + sideInset = 42.0 + buttonsCount = 3 + } + let spacing = floor((layout.size.height - sideInset * 2.0 - sideButtonSize.height * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) + let x = controlsHidden ? fullscreenBottomAreaHeight + layout.safeInsets.right + 30.0 : floor((fullscreenBottomAreaHeight - sideButtonSize.width) / 2.0) + forthButtonFrame = CGRect(origin: CGPoint(x: x, y: sideInset), size: sideButtonSize) + let thirdButtonPreFrame = CGRect(origin: CGPoint(x: x, y: sideInset + sideButtonSize.height + spacing), size: sideButtonSize) + thirdButtonFrame = CGRect(origin: CGPoint(x: floor(thirdButtonPreFrame.midX - centralButtonSize.width / 2.0), y: floor(thirdButtonPreFrame.midY - centralButtonSize.height / 2.0)), size: centralButtonSize) + secondButtonFrame = CGRect(origin: CGPoint(x: x, y: thirdButtonPreFrame.maxY + spacing), size: sideButtonSize) + if hasVideo { + firstButtonFrame = CGRect(origin: CGPoint(x: x, y: layout.size.height - sideInset - sideButtonSize.height), size: sideButtonSize) + } else { + firstButtonFrame = secondButtonFrame + } + } else { + let sideInset: CGFloat + let buttonsCount: Int + if hasVideo { + sideInset = 26.0 + buttonsCount = 4 + } else { + sideInset = 42.0 + buttonsCount = 3 + } + let spacing = floor((layout.size.width - sideInset * 2.0 - sideButtonSize.width * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) + let y = controlsHidden ? self.effectiveBottomAreaHeight + layout.intrinsicInsets.bottom + 30.0: floor((self.effectiveBottomAreaHeight - sideButtonSize.height) / 2.0) + if hasVideo { + firstButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: y), size: sideButtonSize) + secondButtonFrame = CGRect(origin: CGPoint(x: firstButtonFrame.maxX + spacing, y: y), size: sideButtonSize) + } else { + firstButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: y), size: sideButtonSize) + secondButtonFrame = firstButtonFrame + } + let thirdButtonPreFrame = CGRect(origin: CGPoint(x: secondButtonFrame.maxX + spacing, y: y), size: sideButtonSize) + thirdButtonFrame = CGRect(origin: CGPoint(x: floor(thirdButtonPreFrame.midX - centralButtonSize.width / 2.0), y: floor(thirdButtonPreFrame.midY - centralButtonSize.height / 2.0)), size: centralButtonSize) + forthButtonFrame = CGRect(origin: CGPoint(x: thirdButtonPreFrame.maxX + spacing, y: y), size: sideButtonSize) + } + } + } + + let buttonHeight = self.scheduleCancelButton.updateLayout(width: size.width - 32.0, transition: .immediate) + self.scheduleCancelButton.frame = CGRect(x: 16.0, y: 137.0, width: size.width - 32.0, height: buttonHeight) + + if self.actionButton.supernode === self.bottomPanelNode { + transition.updateFrame(node: self.actionButton, frame: thirdButtonFrame, completion: transition.isAnimated ? { [weak self] _ in + self?.animatingExpansion = false + } : nil) + } + + self.cameraButton.isUserInteractionEnabled = hasCameraButton + + var buttonsTransition: ContainedViewLayoutTransition = .immediate + if !isFirstTime { + if case .animated(_, .spring) = transition { + buttonsTransition = transition + } else { + buttonsTransition = .animated(duration: 0.3, curve: .linear) + } + } + self.updateButtons(transition: buttonsTransition) + + if self.audioButton.supernode === self.bottomPanelNode { + transition.updateAlpha(node: self.cameraButton, alpha: hasCameraButton ? 1.0 : 0.0) + transition.updateFrameAsPositionAndBounds(node: self.switchCameraButton, frame: firstButtonFrame) + + if !self.animatingButtonsSwap || transition.isAnimated { + transition.updateFrameAsPositionAndBounds(node: self.audioButton, frame: secondButtonFrame, completion: { [weak self] _ in + self?.animatingButtonsSwap = false + }) + transition.updateFrameAsPositionAndBounds(node: self.cameraButton, frame: secondButtonFrame) + } + transition.updateFrameAsPositionAndBounds(node: self.leaveButton, frame: forthButtonFrame) + } + if isFirstTime { + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransition() + } + while !self.enqueuedFullscreenTransitions.isEmpty { + self.dequeueFullscreenTransition() + } + } + } + + private var appIsActive = true { + didSet { + if self.appIsActive != oldValue { + self.updateVisibility() + self.updateRequestedVideoChannels() + } + } + } + private var visibility = false { + didSet { + if self.visibility != oldValue { + self.updateVisibility() + self.updateRequestedVideoChannels() + } + } + } + + private func updateVisibility() { + let visible = self.appIsActive && self.visibility + if self.tileGridNode.isHidden { + self.tileGridNode.visibility = false + } else { + self.tileGridNode.visibility = visible + } + self.mainStageNode.visibility = visible + self.listNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? VoiceChatTilesGridItemNode { + itemNode.gridVisibility = visible + } + } + self.fullscreenListNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode { + itemNode.gridVisibility = visible + } + } + + self.videoRenderingContext.updateVisibility(isVisible: visible) + } + + func animateIn() { + guard let (layout, navigationHeight) = self.validLayout else { + return + } + + self.visibility = true + + self.updateDecorationsLayout(transition: .immediate) + + self.animatingAppearance = true + + let initialBounds = self.contentContainer.bounds + let topPanelFrame = self.topPanelNode.view.convert(self.topPanelNode.bounds, to: self.view) + self.contentContainer.bounds = initialBounds.offsetBy(dx: 0.0, dy: -(layout.size.height - topPanelFrame.minY)) + self.contentContainer.isHidden = false + + let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) + transition.animateView({ + self.contentContainer.view.bounds = initialBounds + }, completion: { _ in + self.animatingAppearance = false + if self.actionButton.supernode !== self.bottomPanelNode { + self.actionButton.ignoreHierarchyChanges = true + self.audioButton.isHidden = false + self.cameraButton.isHidden = false + self.leaveButton.isHidden = false + self.audioButton.layer.removeAllAnimations() + self.cameraButton.layer.removeAllAnimations() + self.leaveButton.layer.removeAllAnimations() + self.bottomPanelNode.addSubnode(self.cameraButton) + self.bottomPanelNode.addSubnode(self.audioButton) + self.bottomPanelNode.addSubnode(self.leaveButton) + self.bottomPanelNode.addSubnode(self.actionButton) + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .immediate) + self.actionButton.ignoreHierarchyChanges = false + } + + self.controller?.currentOverlayController?.dismiss() + self.controller?.currentOverlayController = nil + }) + self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) + } + + func animateOut(completion: (() -> Void)?) { + guard let (layout, _) = self.validLayout else { + return + } + var offsetCompleted = false + let internalCompletion: () -> Void = { [weak self] in + if offsetCompleted { + if let strongSelf = self { + strongSelf.contentContainer.layer.removeAllAnimations() + strongSelf.dimNode.layer.removeAllAnimations() + + var bounds = strongSelf.contentContainer.bounds + bounds.origin.y = 0.0 + strongSelf.contentContainer.bounds = bounds + + strongSelf.visibility = false + } + completion?() + } + } + + let topPanelFrame = self.topPanelNode.view.convert(self.topPanelNode.bounds, to: self.view) + self.contentContainer.layer.animateBoundsOriginYAdditive(from: self.contentContainer.bounds.origin.y, to: -(layout.size.height - topPanelFrame.minY) - 44.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in + offsetCompleted = true + internalCompletion() + }) + self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) + } + + private func enqueueTransition(_ transition: ListTransition) { + self.enqueuedTransitions.append(transition) + + if let _ = self.validLayout { + while !self.enqueuedTransitions.isEmpty { + self.dequeueTransition() + } + } + } + + private func enqueueFullscreenTransition(_ transition: ListTransition) { + self.enqueuedFullscreenTransitions.append(transition) + + if let _ = self.validLayout { + while !self.enqueuedFullscreenTransitions.isEmpty { + self.dequeueFullscreenTransition() + } + } + } + + private func dequeueTransition() { + guard let (layout, _) = self.validLayout, let transition = self.enqueuedTransitions.first else { + return + } + self.enqueuedTransitions.remove(at: 0) + + if let callState = self.callState { + if callState.scheduleTimestamp != nil && self.listNode.alpha > 0.0 { + self.timerNode.isHidden = false + self.cameraButton.alpha = 0.0 + self.cameraButton.isUserInteractionEnabled = false + self.listNode.alpha = 0.0 + self.listNode.isUserInteractionEnabled = false + self.backgroundNode.backgroundColor = panelBackgroundColor + self.updateDecorationsColors() + } else if callState.scheduleTimestamp == nil && !self.isScheduling && self.listNode.alpha == 0.0 { + self.transitionToCall() + } + } + + var options = ListViewDeleteAndInsertOptions() + let isFirstTime = self.isFirstTime + if isFirstTime { + self.isFirstTime = false + } else { + if transition.crossFade { + options.insert(.AnimateCrossfade) + } + if transition.animated { + options.insert(.AnimateInsertion) + } + } + options.insert(.LowLatency) + options.insert(.PreferSynchronousResourceLoading) + + var size = layout.size + if case .regular = layout.metrics.widthClass { + size.width = floor(min(size.width, size.height) * 0.5) + } + + let bottomPanelHeight = self.isLandscape ? layout.intrinsicInsets.bottom : bottomAreaHeight + layout.intrinsicInsets.bottom + let layoutTopInset: CGFloat = max(layout.statusBarHeight ?? 0.0, layout.safeInsets.top) + let listTopInset = layoutTopInset + topPanelHeight + let listSize = CGSize(width: size.width, height: layout.size.height - listTopInset - bottomPanelHeight + bottomGradientHeight) + + self.topInset = listSize.height - 46.0 - floor(56.0 * 3.5) - bottomGradientHeight + + if transition.animated { + self.animatingInsertion = true + } + self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, scrollToItem: nil, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in + guard let strongSelf = self else { + return + } + if isFirstTime { + strongSelf.updateDecorationsLayout(transition: .immediate) + } else if strongSelf.animatingInsertion { + strongSelf.updateDecorationsLayout(transition: .animated(duration: 0.2, curve: .easeInOut)) + } + strongSelf.animatingInsertion = false + if !strongSelf.didSetContentsReady { + strongSelf.didSetContentsReady = true + strongSelf.controller?.contentsReady.set(true) + } + strongSelf.updateVisibility() + }) + } + + private func dequeueFullscreenTransition() { + guard let _ = self.validLayout, let transition = self.enqueuedFullscreenTransitions.first else { + return + } + self.enqueuedFullscreenTransitions.remove(at: 0) + + var options = ListViewDeleteAndInsertOptions() + let isFirstTime = self.isFirstTime + if !isFirstTime { + if transition.animated { + options.insert(.AnimateInsertion) + } + } + + self.fullscreenListNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, scrollToItem: nil, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { _ in + }) + } + + private func updateMembers(maybeUpdateVideo: Bool = true, force: Bool = false) { + self.updateMembers(muteState: self.effectiveMuteState, callMembers: self.currentCallMembers ?? ([], nil), invitedPeers: self.currentInvitedPeers ?? [], speakingPeers: self.currentSpeakingPeers ?? Set(), maybeUpdateVideo: maybeUpdateVideo, force: force) + } + + private func updateMembers(muteState: GroupCallParticipantsContext.Participant.MuteState?, callMembers: ([GroupCallParticipantsContext.Participant], String?), invitedPeers: [EnginePeer], speakingPeers: Set, maybeUpdateVideo: Bool = true, force: Bool = false) { + var disableAnimation = false + if self.currentCallMembers?.1 != callMembers.1 { + disableAnimation = true + } + + let speakingPeersUpdated = self.currentSpeakingPeers != speakingPeers + self.currentCallMembers = callMembers + self.currentInvitedPeers = invitedPeers + + var entries: [ListEntry] = [] + var fullscreenEntries: [ListEntry] = [] + var index: Int32 = 0 + var fullscreenIndex: Int32 = 0 + var processedPeerIds = Set() + var processedFullscreenPeerIds = Set() + + var peerIdToCameraEndpointId: [PeerId: String] = [:] + var peerIdToEndpointId: [PeerId: String] = [:] + + var requestedVideoChannels: [PresentationGroupCallRequestedVideo] = [] + var gridTileItems: [VoiceChatTileItem] = [] + var tileItems: [VoiceChatTileItem] = [] + var gridTileByVideoEndpoint: [String: VoiceChatTileItem] = [:] + var tileByVideoEndpoint: [String: VoiceChatTileItem] = [:] + var entryByPeerId: [PeerId: VoiceChatPeerEntry] = [:] + var latestWideVideo: String? = nil + + var isTablet = false + var displayPanelVideos = false + if let (layout, _) = self.validLayout, case .regular = layout.metrics.widthClass { + isTablet = true + displayPanelVideos = self.displayPanelVideos + } + +// let isLivestream: Bool +// if let channel = self.peer as? TelegramChannel, case .broadcast = channel.info { +// isLivestream = true +// } else { +// isLivestream = false +// } + + let canManageCall = self.callState?.canManageCall ?? false + + var joinedVideo = self.joinedVideo ?? true + + var myEntry: VoiceChatPeerEntry? + var mainEntry: VoiceChatPeerEntry? + for member in callMembers.0 { + if processedPeerIds.contains(member.peer.id) { + continue + } + processedPeerIds.insert(member.peer.id) + + let memberState: VoiceChatPeerEntry.State + var memberMuteState: GroupCallParticipantsContext.Participant.MuteState? + if member.hasRaiseHand && !(member.muteState?.canUnmute ?? true) { +// if isLivestream && !canManageCall { +// continue +// } + memberState = .raisedHand + memberMuteState = member.muteState + + if self.raisedHandDisplayDisposables[member.peer.id] == nil { + var displayedRaisedHands = self.displayedRaisedHands + displayedRaisedHands.insert(member.peer.id) + self.displayedRaisedHands = displayedRaisedHands + + let signal: Signal = Signal.complete() |> delay(3.0, queue: Queue.mainQueue()) + self.raisedHandDisplayDisposables[member.peer.id] = signal.start(completed: { [weak self] in + if let strongSelf = self { + var displayedRaisedHands = strongSelf.displayedRaisedHands + displayedRaisedHands.remove(member.peer.id) + strongSelf.displayedRaisedHands = displayedRaisedHands + strongSelf.updateMembers() + } + }) + } + } else { + if member.peer.id == self.callState?.myPeerId { + if muteState == nil { + memberState = speakingPeers.contains(member.peer.id) ? .speaking : .listening + } else { + memberState = .listening + memberMuteState = member.muteState + } + } else { + memberState = speakingPeers.contains(member.peer.id) ? .speaking : .listening + memberMuteState = member.muteState + } + + if let disposable = self.raisedHandDisplayDisposables[member.peer.id] { + disposable.dispose() + self.raisedHandDisplayDisposables[member.peer.id] = nil + } + +// if isLivestream && !(memberMuteState?.canUnmute ?? true) { +// continue +// } + } + + var memberPeer = member.peer + if member.peer.id == self.callState?.myPeerId { + joinedVideo = member.joinedVideo + if let user = memberPeer as? TelegramUser, let photo = self.currentUpdatingAvatar { + memberPeer = user.withUpdatedPhoto([photo]) + } + } + + joinedVideo = true + + if let videoEndpointId = member.videoEndpointId { + peerIdToCameraEndpointId[member.peer.id] = videoEndpointId + } + if let anyEndpointId = member.presentationEndpointId ?? member.videoEndpointId { + peerIdToEndpointId[member.peer.id] = anyEndpointId + } + + let peerEntry = VoiceChatPeerEntry( + peer: memberPeer, + about: member.about, + isMyPeer: self.callState?.myPeerId == member.peer.id, + videoEndpointId: member.videoEndpointId, + videoPaused: member.videoDescription?.isPaused ?? false, + presentationEndpointId: member.presentationEndpointId, + presentationPaused: member.presentationDescription?.isPaused ?? false, + effectiveSpeakerVideoEndpointId: self.effectiveSpeaker?.1, + state: memberState, + muteState: memberMuteState, + canManageCall: canManageCall, + volume: member.volume, + raisedHand: member.hasRaiseHand, + displayRaisedHandStatus: self.displayedRaisedHands.contains(member.peer.id), + active: memberPeer.id == self.effectiveSpeaker?.0, + isLandscape: self.isLandscape + ) + if peerEntry.isMyPeer { + myEntry = peerEntry + } + if peerEntry.active { + mainEntry = peerEntry + } + entryByPeerId[peerEntry.peer.id] = peerEntry + + var isTile = false + if let interaction = self.itemInteraction { + if let videoEndpointId = member.presentationEndpointId { + if !self.videoOrder.contains(videoEndpointId) { + if peerEntry.isMyPeer { + self.videoOrder.insert(videoEndpointId, at: 0) + } else { + self.videoOrder.append(videoEndpointId) + } + } + if isTablet { + if let tileItem = ListEntry.peer(peerEntry, 0).tileItem(context: self.context, presentationData: self.presentationData, interaction: interaction, isTablet: isTablet, videoEndpointId: videoEndpointId, videoReady: self.readyVideoEndpointIds.contains(videoEndpointId), videoTimeouted: self.timeoutedEndpointIds.contains(videoEndpointId), videoIsPaused: member.presentationDescription?.isPaused ?? false, showAsPresentation: peerIdToCameraEndpointId[peerEntry.peer.id] != nil, secondary: false) { + isTile = true + gridTileByVideoEndpoint[videoEndpointId] = tileItem + } + } + if let tileItem = ListEntry.peer(peerEntry, 0).tileItem(context: self.context, presentationData: self.presentationData, interaction: interaction, isTablet: isTablet, videoEndpointId: videoEndpointId, videoReady: self.readyVideoEndpointIds.contains(videoEndpointId), videoTimeouted: self.timeoutedEndpointIds.contains(videoEndpointId), videoIsPaused: member.presentationDescription?.isPaused ?? false, showAsPresentation: peerIdToCameraEndpointId[peerEntry.peer.id] != nil, secondary: displayPanelVideos) { + isTile = true + tileByVideoEndpoint[videoEndpointId] = tileItem + } + if self.wideVideoNodes.contains(videoEndpointId) { + latestWideVideo = videoEndpointId + } + } + if let videoEndpointId = member.videoEndpointId { + if !self.videoOrder.contains(videoEndpointId) { + if peerEntry.isMyPeer { + self.videoOrder.insert(videoEndpointId, at: 0) + } else { + self.videoOrder.append(videoEndpointId) + } + } + if isTablet { + if let tileItem = ListEntry.peer(peerEntry, 0).tileItem(context: self.context, presentationData: self.presentationData, interaction: interaction, isTablet: isTablet, videoEndpointId: videoEndpointId, videoReady: self.readyVideoEndpointIds.contains(videoEndpointId), videoTimeouted: self.timeoutedEndpointIds.contains(videoEndpointId), videoIsPaused: member.videoDescription?.isPaused ?? false, showAsPresentation: false, secondary: false) { + isTile = true + gridTileByVideoEndpoint[videoEndpointId] = tileItem + } + } + if let tileItem = ListEntry.peer(peerEntry, 0).tileItem(context: self.context, presentationData: self.presentationData, interaction: interaction, isTablet: isTablet, videoEndpointId: videoEndpointId, videoReady: self.readyVideoEndpointIds.contains(videoEndpointId), videoTimeouted: self.timeoutedEndpointIds.contains(videoEndpointId), videoIsPaused: member.videoDescription?.isPaused ?? false, showAsPresentation: false, secondary: displayPanelVideos) { + isTile = true + tileByVideoEndpoint[videoEndpointId] = tileItem + } + if self.wideVideoNodes.contains(videoEndpointId) { + latestWideVideo = videoEndpointId + } + } + } + + if !isTile || isTablet || !joinedVideo { + entries.append(.peer(peerEntry, index)) + } + + index += 1 + + if self.callState?.networkState == .connecting { + } else { + if var videoChannel = member.requestedVideoChannel(minQuality: .thumbnail, maxQuality: .medium) { + if self.effectiveSpeaker?.1 == videoChannel.endpointId { + videoChannel.maxQuality = .full + } + requestedVideoChannels.append(videoChannel) + } + if member.peer.id != self.callState?.myPeerId { + if var presentationChannel = member.requestedPresentationVideoChannel(minQuality: .thumbnail, maxQuality: .thumbnail) { + if self.effectiveSpeaker?.1 == presentationChannel.endpointId { + presentationChannel.minQuality = .full + presentationChannel.maxQuality = .full + } + requestedVideoChannels.append(presentationChannel) + } + } + } + } + + var temporaryList: [String] = [] + for tileVideoEndpoint in self.videoOrder { + if let _ = tileByVideoEndpoint[tileVideoEndpoint] { + temporaryList.append(tileVideoEndpoint) + } + } + + if (tileByVideoEndpoint.count % 2) != 0, let last = temporaryList.last, !self.wideVideoNodes.contains(last), let latestWide = latestWideVideo { + self.videoOrder.removeAll(where: { $0 == latestWide }) + self.videoOrder.append(latestWide) + } + + for tileVideoEndpoint in self.videoOrder { + if let tileItem = gridTileByVideoEndpoint[tileVideoEndpoint] { + gridTileItems.append(tileItem) + } + if let tileItem = tileByVideoEndpoint[tileVideoEndpoint] { + if displayPanelVideos && tileItem.peer.id == self.effectiveSpeaker?.0 { + } else { + tileItems.append(tileItem) + } + if let fullscreenEntry = entryByPeerId[tileItem.peer.id] { + if processedFullscreenPeerIds.contains(tileItem.peer.id) { + continue + } + fullscreenEntries.append(.peer(fullscreenEntry, fullscreenIndex)) + processedFullscreenPeerIds.insert(fullscreenEntry.peer.id) + fullscreenIndex += 1 + } + } + } + + self.joinedVideo = joinedVideo + + let configuration = self.configuration ?? VoiceChatConfiguration.defaultValue + var reachedLimit = false + + if !joinedVideo && (!tileItems.isEmpty || !gridTileItems.isEmpty), let peer = self.peer { + tileItems.removeAll() + gridTileItems.removeAll() + + tileItems.append(VoiceChatTileItem(account: self.context.account, peer: peer, videoEndpointId: "", videoReady: false, videoTimeouted: true, isVideoLimit: true, videoLimit: configuration.videoParticipantsMaxCount, isPaused: false, isOwnScreencast: false, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder, speaking: false, secondary: false, isTablet: false, icon: .none, text: .none, additionalText: nil, action: {}, contextAction: nil, getVideo: { _ in return nil }, getAudioLevel: nil)) + } else if let callState = self.callState, !tileItems.isEmpty && callState.isVideoWatchersLimitReached && self.connectedOnce && (callState.canManageCall || callState.adminIds.contains(self.context.account.peerId)) { + reachedLimit = true + } + + for member in callMembers.0 { + if processedFullscreenPeerIds.contains(member.peer.id) { + continue + } + processedFullscreenPeerIds.insert(member.peer.id) + if let peerEntry = entryByPeerId[member.peer.id] { + fullscreenEntries.append(.peer(peerEntry, fullscreenIndex)) + fullscreenIndex += 1 + } + } + + for peer in invitedPeers { + if processedPeerIds.contains(peer.id) { + continue + } + processedPeerIds.insert(peer.id) + + entries.append(.peer(VoiceChatPeerEntry( + peer: peer._asPeer(), + about: nil, + isMyPeer: false, + videoEndpointId: nil, + videoPaused: false, + presentationEndpointId: nil, + presentationPaused: false, + effectiveSpeakerVideoEndpointId: nil, + state: .invited, + muteState: nil, + canManageCall: false, + volume: nil, + raisedHand: false, + displayRaisedHandStatus: false, + active: false, + isLandscape: false + ), index)) + index += 1 + } + + self.requestedVideoChannels = requestedVideoChannels + + var myVideoUpdated = false + if let previousMyEntry = self.myEntry, let myEntry = myEntry, previousMyEntry.effectiveVideoEndpointId == nil && myEntry.effectiveVideoEndpointId != nil && self.currentForcedSpeaker == nil { + self.currentDominantSpeaker = (myEntry.peer.id, myEntry.effectiveVideoEndpointId, CACurrentMediaTime()) + myVideoUpdated = true + } + self.myEntry = myEntry + + guard self.didSetDataReady && (force || (!self.isPanning && !self.animatingExpansion && !self.animatingMainStage)) else { + return + } + + let previousMainEntry = self.mainEntry + self.mainEntry = mainEntry + if let mainEntry = mainEntry { + self.mainStageNode.update(peerEntry: mainEntry, pinned: self.currentForcedSpeaker != nil) + + if let previousMainEntry = previousMainEntry, maybeUpdateVideo { + if previousMainEntry.effectiveVideoEndpointId != mainEntry.effectiveVideoEndpointId || previousMainEntry.videoPaused != mainEntry.videoPaused || myVideoUpdated { + self.updateMainVideo(waitForFullSize: true, entries: fullscreenEntries, force: true) + return + } + } + } else if self.effectiveSpeaker != nil, !fullscreenEntries.isEmpty { + self.updateMainVideo(waitForFullSize: true, entries: fullscreenEntries, force: true) + return + } + + self.updateRequestedVideoChannels() + + self.currentSpeakingPeers = speakingPeers + self.peerIdToEndpointId = peerIdToEndpointId + + var updateLayout = false + var animatingLayout = false + if self.currentTileItems.isEmpty != gridTileItems.isEmpty { + animatingLayout = true + updateLayout = true + } + if isTablet { + updateLayout = true + self.currentTileItems = gridTileItems + if displayPanelVideos && !tileItems.isEmpty { + entries.insert(.tiles(tileItems, .pairs, configuration.videoParticipantsMaxCount, reachedLimit), at: 0) + } + } else { + if !tileItems.isEmpty { + entries.insert(.tiles(tileItems, .pairs, configuration.videoParticipantsMaxCount, reachedLimit), at: 0) + } + } + + var canInvite = true + var inviteIsLink = false + if let peer = self.peer as? TelegramChannel { + 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 + } + } + if canInvite { + entries.append(.invite(self.presentationData.theme, self.presentationData.strings, inviteIsLink ? self.presentationData.strings.VoiceChat_Share : self.presentationData.strings.VoiceChat_InviteMember, inviteIsLink)) + } + + let previousEntries = self.currentEntries + let previousFullscreenEntries = self.currentFullscreenEntries + self.currentEntries = entries + self.currentFullscreenEntries = fullscreenEntries + + if previousEntries.count == entries.count { + var allEqual = true + for i in 0 ..< previousEntries.count { + if previousEntries[i].stableId != entries[i].stableId { + if case let .peer(lhsPeer, _) = previousEntries[i], case let .peer(rhsPeer, _) = entries[i] { + if lhsPeer.isMyPeer != rhsPeer.isMyPeer { + allEqual = false + break + } + } else { + allEqual = false + break + } + } + } + if allEqual { + disableAnimation = true + } + } else if abs(previousEntries.count - entries.count) > 10 { + disableAnimation = true + } + + let presentationData = self.presentationData.withUpdated(theme: self.darkTheme) + let transition = self.preparedTransition(from: previousEntries, to: entries, isLoading: false, isEmpty: false, canInvite: canInvite, crossFade: false, animated: !disableAnimation, context: self.context, presentationData: presentationData, interaction: self.itemInteraction!) + self.enqueueTransition(transition) + + let fullscreenTransition = self.preparedFullscreenTransition(from: previousFullscreenEntries, to: fullscreenEntries, isLoading: false, isEmpty: false, canInvite: canInvite, crossFade: false, animated: true, context: self.context, presentationData: presentationData, interaction: self.itemInteraction!) + if !isTablet { + self.enqueueFullscreenTransition(fullscreenTransition) + } + + if speakingPeersUpdated { + var speakingPeers = speakingPeers + var updatedSpeakers: [PeerId] = [] + for peerId in self.currentSpeakers { + if speakingPeers.contains(peerId) { + updatedSpeakers.append(peerId) + speakingPeers.remove(peerId) + } + } + + var currentSpeakingSubtitle = "" + for peerId in Array(speakingPeers) { + updatedSpeakers.append(peerId) + if let peer = entryByPeerId[peerId]?.peer { + let displayName = speakingPeers.count == 1 ? EnginePeer(peer).displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder) : EnginePeer(peer).compactDisplayTitle + if currentSpeakingSubtitle.isEmpty { + currentSpeakingSubtitle.append(displayName) + } else { + currentSpeakingSubtitle.append(", \(displayName)") + } + } + } + self.currentSpeakers = updatedSpeakers + self.currentSpeakingSubtitle = currentSpeakingSubtitle.isEmpty ? nil : currentSpeakingSubtitle + self.updateTitle(transition: .immediate) + } + + if case .fullscreen = self.displayMode, !self.mainStageNode.animating { + if speakingPeersUpdated { + self.mainStageNode.update(speakingPeerId: self.currentSpeakers.first) + } + } else { + self.mainStageNode.update(speakingPeerId: nil) + } + + if updateLayout, let (layout, navigationHeight) = self.validLayout { + let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .easeInOut) + if animatingLayout { + self.animatingExpansion = true + } + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) + self.updateDecorationsLayout(transition: transition) + } + } + + private func callStateDidReset() { + self.requestedVideoSources.removeAll() + self.filterRequestedVideoChannels(channels: []) + self.updateRequestedVideoChannels() + } + + private func filterRequestedVideoChannels(channels: [PresentationGroupCallRequestedVideo]) { + var validSources = Set() + for channel in channels { + validSources.insert(channel.endpointId) + + if !self.requestedVideoSources.contains(channel.endpointId) { + self.requestedVideoSources.insert(channel.endpointId) + + let input = (self.call as! PresentationGroupCallImpl).video(endpointId: channel.endpointId) + if let input = input, let videoView = self.videoRenderingContext.makeView(input: input, blur: false) { + let videoNode = GroupVideoNode(videoView: videoView, backdropVideoView: self.videoRenderingContext.makeView(input: input, blur: true)) + + self.readyVideoDisposables.set((combineLatest(videoNode.ready, .single(false) |> then(.single(true) |> delay(10.0, queue: Queue.mainQueue()))) + |> deliverOnMainQueue + ).start(next: { [weak self, weak videoNode] ready, timeouted in + if let strongSelf = self, let videoNode = videoNode { + Queue.mainQueue().after(0.1) { + if timeouted && !ready { + strongSelf.timeoutedEndpointIds.insert(channel.endpointId) + strongSelf.readyVideoEndpointIds.remove(channel.endpointId) + strongSelf.readyVideoEndpointIdsPromise.set(strongSelf.readyVideoEndpointIds) + strongSelf.wideVideoNodes.remove(channel.endpointId) + + strongSelf.updateMembers() + } else if ready { + strongSelf.readyVideoEndpointIds.insert(channel.endpointId) + strongSelf.readyVideoEndpointIdsPromise.set(strongSelf.readyVideoEndpointIds) + strongSelf.timeoutedEndpointIds.remove(channel.endpointId) + if videoNode.aspectRatio <= 0.77 { + strongSelf.wideVideoNodes.insert(channel.endpointId) + } else { + strongSelf.wideVideoNodes.remove(channel.endpointId) + } + strongSelf.updateMembers() + + if let (layout, _) = strongSelf.validLayout, case .compact = layout.metrics.widthClass { + if let interaction = strongSelf.itemInteraction { + loop: for i in 0 ..< strongSelf.currentFullscreenEntries.count { + let entry = strongSelf.currentFullscreenEntries[i] + switch entry { + case let .peer(peerEntry, _): + if peerEntry.effectiveVideoEndpointId == channel.endpointId { + let presentationData = strongSelf.presentationData.withUpdated(theme: strongSelf.darkTheme) + strongSelf.fullscreenListNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [ListViewUpdateItem(index: i, previousIndex: i, item: entry.fullscreenItem(context: strongSelf.context, presentationData: presentationData, interaction: interaction), directionHint: nil)], options: [.Synchronous], updateOpaqueState: nil) + break loop + } + default: + break + } + } + } + } + } + } + } + }), forKey: channel.endpointId) + self.videoNodes[channel.endpointId] = videoNode + + if let _ = self.validLayout { + self.updateMembers() + } + } + + /*self.call.makeIncomingVideoView(endpointId: channel.endpointId, requestClone: GroupVideoNode.useBlurTransparency, completion: { [weak self] videoView, backdropVideoView in + Queue.mainQueue().async { + guard let strongSelf = self, let videoView = videoView else { + return + } + let videoNode = GroupVideoNode(videoView: videoView, backdropVideoView: backdropVideoView) + + strongSelf.readyVideoDisposables.set((combineLatest(videoNode.ready, .single(false) |> then(.single(true) |> delay(10.0, queue: Queue.mainQueue()))) + |> deliverOnMainQueue + ).start(next: { [weak self, weak videoNode] ready, timeouted in + if let strongSelf = self, let videoNode = videoNode { + Queue.mainQueue().after(0.1) { + if timeouted && !ready { + strongSelf.timeoutedEndpointIds.insert(channel.endpointId) + strongSelf.readyVideoEndpointIds.remove(channel.endpointId) + strongSelf.readyVideoEndpointIdsPromise.set(strongSelf.readyVideoEndpointIds) + strongSelf.wideVideoNodes.remove(channel.endpointId) + + strongSelf.updateMembers() + } else if ready { + strongSelf.readyVideoEndpointIds.insert(channel.endpointId) + strongSelf.readyVideoEndpointIdsPromise.set(strongSelf.readyVideoEndpointIds) + strongSelf.timeoutedEndpointIds.remove(channel.endpointId) + if videoNode.aspectRatio <= 0.77 { + strongSelf.wideVideoNodes.insert(channel.endpointId) + } else { + strongSelf.wideVideoNodes.remove(channel.endpointId) + } + strongSelf.updateMembers() + + if let (layout, _) = strongSelf.validLayout, case .compact = layout.metrics.widthClass { + if let interaction = strongSelf.itemInteraction { + loop: for i in 0 ..< strongSelf.currentFullscreenEntries.count { + let entry = strongSelf.currentFullscreenEntries[i] + switch entry { + case let .peer(peerEntry, _): + if peerEntry.effectiveVideoEndpointId == channel.endpointId { + let presentationData = strongSelf.presentationData.withUpdated(theme: strongSelf.darkTheme) + strongSelf.fullscreenListNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [ListViewUpdateItem(index: i, previousIndex: i, item: entry.fullscreenItem(context: strongSelf.context, presentationData: presentationData, interaction: interaction), directionHint: nil)], options: [.Synchronous], updateOpaqueState: nil) + break loop + } + default: + break + } + } + } + } + } + } + } + }), forKey: channel.endpointId) + strongSelf.videoNodes[channel.endpointId] = videoNode + + if let _ = strongSelf.validLayout { + strongSelf.updateMembers() + } + } + })*/ + } + } + + var removeRequestedVideoSources: [String] = [] + for source in self.requestedVideoSources { + if !validSources.contains(source) { + removeRequestedVideoSources.append(source) + } + } + for source in removeRequestedVideoSources { + self.requestedVideoSources.remove(source) + } + + for (videoEndpointId, _) in self.videoNodes { + if !validSources.contains(videoEndpointId) { + self.videoNodes[videoEndpointId] = nil + self.videoOrder.removeAll(where: { $0 == videoEndpointId }) + self.readyVideoEndpointIds.remove(videoEndpointId) + self.readyVideoEndpointIdsPromise.set(self.readyVideoEndpointIds) + self.readyVideoDisposables.set(nil, forKey: videoEndpointId) + } + } + } + + private func updateMainVideo(waitForFullSize: Bool, entries: [ListEntry]? = nil, updateMembers: Bool = true, force: Bool = false, completion: (() -> Void)? = nil) { + let effectiveMainSpeaker = self.currentForcedSpeaker ?? self.currentDominantSpeaker.flatMap { ($0.0, $0.1) } + guard effectiveMainSpeaker?.0 != self.effectiveSpeaker?.0 || effectiveMainSpeaker?.1 != self.effectiveSpeaker?.1 || force else { + return + } + + let currentEntries = entries ?? self.currentFullscreenEntries + var effectiveSpeaker: (PeerId, String?, Bool, Bool, Bool)? = nil + var anySpeakerWithVideo: (PeerId, String?, Bool, Bool, Bool)? = nil + var anySpeaker: (PeerId, Bool)? = nil + if let (peerId, preferredVideoEndpointId) = effectiveMainSpeaker { + for entry in currentEntries { + switch entry { + case let .peer(peer, _): + if peer.peer.id == peerId { + if let preferredVideoEndpointId = preferredVideoEndpointId, peer.videoEndpointId == preferredVideoEndpointId || peer.presentationEndpointId == preferredVideoEndpointId { + var isPaused = false + if peer.presentationEndpointId != nil && preferredVideoEndpointId == peer.presentationEndpointId { + isPaused = peer.presentationPaused + } else if peer.videoEndpointId != nil && preferredVideoEndpointId == peer.videoEndpointId { + isPaused = peer.videoPaused + } + effectiveSpeaker = (peerId, preferredVideoEndpointId, peer.isMyPeer, peer.presentationEndpointId != nil && preferredVideoEndpointId == peer.presentationEndpointId, isPaused) + } else { + var isPaused = false + if peer.effectiveVideoEndpointId != nil && peer.effectiveVideoEndpointId == peer.presentationEndpointId { + isPaused = peer.presentationPaused + } else if peer.effectiveVideoEndpointId != nil && peer.effectiveVideoEndpointId == peer.videoEndpointId { + isPaused = peer.videoPaused + } + effectiveSpeaker = (peerId, peer.effectiveVideoEndpointId, peer.isMyPeer, peer.presentationEndpointId != nil && peer.effectiveVideoEndpointId == peer.presentationEndpointId, isPaused) + } + } else if anySpeakerWithVideo == nil, let videoEndpointId = peer.effectiveVideoEndpointId { + var isPaused = false + if videoEndpointId == peer.presentationEndpointId { + isPaused = peer.presentationPaused + } else if videoEndpointId == peer.videoEndpointId { + isPaused = peer.videoPaused + } + anySpeakerWithVideo = (peer.peer.id, videoEndpointId, peer.isMyPeer, peer.presentationEndpointId != nil && videoEndpointId == peer.presentationEndpointId, isPaused) + } else if anySpeaker == nil { + anySpeaker = (peer.peer.id, peer.isMyPeer) + } + default: + break + } + } + } + + if effectiveSpeaker == nil { + self.currentForcedSpeaker = nil + effectiveSpeaker = anySpeakerWithVideo ?? anySpeaker.flatMap { ($0.0, nil, $0.1, false, false) } + if let (peerId, videoEndpointId, _, _, _) = effectiveSpeaker { + self.currentDominantSpeaker = (peerId, videoEndpointId, CACurrentMediaTime()) + } else { + self.currentDominantSpeaker = nil + } + } + + self.effectiveSpeaker = effectiveSpeaker + if updateMembers { + self.updateMembers(maybeUpdateVideo: false, force: force) + } + + var waitForFullSize = waitForFullSize + var isReady = false + if let (_, maybeVideoEndpointId, _, _, _) = effectiveSpeaker, let videoEndpointId = maybeVideoEndpointId { + isReady = true + if !self.readyVideoEndpointIds.contains(videoEndpointId) { + isReady = false + if entries == nil { + waitForFullSize = false + } + } + } + + self.mainStageNode.update(peer: effectiveSpeaker, isReady: isReady, waitForFullSize: waitForFullSize, completion: { + completion?() + }) + } + + private func updateRequestedVideoChannels() { + Queue.mainQueue().after(0.3) { + let enableVideo = self.appIsActive && self.visibility + + self.call.setRequestedVideoList(items: enableVideo ? self.requestedVideoChannels : []) + self.filterRequestedVideoChannels(channels: self.requestedVideoChannels) + } + } + + override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer is UILongPressGestureRecognizer { + return !self.isScheduling + } else if gestureRecognizer is DirectionalPanGestureRecognizer { + if self.mainStageNode.animating || self.animatingMainStage { + return false + } + + let bottomPanelLocation = gestureRecognizer.location(in: self.bottomPanelNode.view) + let containerLocation = gestureRecognizer.location(in: self.contentContainer.view) + let mainStageLocation = gestureRecognizer.location(in: self.mainStageNode.view) + + if self.isLandscape && self.mainStageContainerNode.isUserInteractionEnabled && mainStageLocation.x > self.mainStageNode.frame.width - 80.0 { + return false + } + + if self.audioButton.frame.contains(bottomPanelLocation) || (!self.cameraButton.isHidden && self.cameraButton.frame.contains(bottomPanelLocation)) || self.leaveButton.frame.contains(bottomPanelLocation) || self.pickerView?.frame.contains(containerLocation) == true || (self.mainStageContainerNode.isUserInteractionEnabled && (mainStageLocation.y < 44.0 || mainStageLocation.y > self.mainStageNode.frame.height - 100.0)) { + return false + } + } + return true + } + + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer { + return true + } + return false + } + + @objc func panGesture(_ recognizer: UIPanGestureRecognizer) { + guard let (layout, _) = self.validLayout else { + return + } + let contentOffset = self.listNode.visibleContentOffset() + switch recognizer.state { + case .began: + let topInset: CGFloat + if case .regular = layout.metrics.widthClass { + topInset = 0.0 + } else if self.isExpanded { + topInset = 0.0 + } else if let currentTopInset = self.topInset { + topInset = currentTopInset + } else { + topInset = self.listNode.frame.height + } + self.panGestureArguments = (topInset, 0.0) + + self.controller?.dismissAllTooltips() + + if case .fullscreen = self.displayMode, case .compact = layout.metrics.widthClass { + self.isPanning = true + + self.mainStageBackgroundNode.alpha = 0.0 + self.mainStageBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.4) + self.mainStageNode.setControlsHidden(true, animated: true) + + self.fullscreenListNode.alpha = 0.0 + self.fullscreenListNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, completion: { [weak self] finished in + self?.attachTileVideos() + + self?.fullscreenListContainer.subnodeTransform = CATransform3DIdentity + }) + + self.listContainer.transform = CATransform3DMakeScale(0.86, 0.86, 1.0) + + self.contentContainer.insertSubnode(self.mainStageContainerNode, aboveSubnode: self.bottomPanelNode) + } + case .changed: + var translation = recognizer.translation(in: self.contentContainer.view).y + if self.isScheduled && translation < 0.0 { + return + } + + let translateBounds: Bool + if case .regular = layout.metrics.widthClass { + translateBounds = true + } else { + switch self.displayMode { + case let .modal(isExpanded, previousIsFilled): + var topInset: CGFloat = 0.0 + if let (currentTopInset, currentPanOffset) = self.panGestureArguments { + topInset = currentTopInset + + if case let .known(value) = contentOffset, value <= 0.5 { + } else { + translation = currentPanOffset + if self.isExpanded { + recognizer.setTranslation(CGPoint(), in: self.contentContainer.view) + } + } + + self.panGestureArguments = (currentTopInset, translation) + } + + let currentOffset = topInset + translation + + var isFilled = previousIsFilled + if currentOffset < 20.0 { + isFilled = true + } else if currentOffset > 40.0 { + isFilled = false + } + if isFilled != previousIsFilled { + self.displayMode = .modal(isExpanded: isExpanded, isFilled: isFilled) + self.updateDecorationsColors() + } + + if self.isExpanded { + } else { + if currentOffset > 0.0 { + self.listNode.scroller.panGestureRecognizer.setTranslation(CGPoint(), in: self.listNode.scroller) + } + } + case .fullscreen: + if abs(translation) > 32.0 { + if self.fullscreenListNode.layer.animationKeys()?.contains("opacity") == true { + self.fullscreenListNode.layer.removeAllAnimations() + } + } + var bounds = self.mainStageContainerNode.bounds + bounds.origin.y = -translation + self.mainStageContainerNode.bounds = bounds + + var backgroundFrame = self.mainStageNode.frame + backgroundFrame.origin.y += -translation + self.mainStageBackgroundNode.frame = backgroundFrame + + self.fullscreenListContainer.subnodeTransform = CATransform3DMakeTranslation(0.0, translation, 0.0) + } + + translateBounds = !self.isExpanded + } + + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .immediate) + self.updateDecorationsLayout(transition: .immediate) + } + + if translateBounds { + var bounds = self.contentContainer.bounds + bounds.origin.y = -translation + bounds.origin.y = min(0.0, bounds.origin.y) + self.contentContainer.bounds = bounds + } + case .ended: + let translation = recognizer.translation(in: self.contentContainer.view) + var velocity = recognizer.velocity(in: self.contentContainer.view) + + if self.isScheduled && (translation.y < 0.0 || velocity.y < 0.0) { + return + } + + if case let .known(value) = contentOffset, value > 0.0 { + velocity = CGPoint() + } else if case .unknown = contentOffset { + velocity = CGPoint() + } + + var bounds = self.contentContainer.bounds + bounds.origin.y = -translation.y + bounds.origin.y = min(0.0, bounds.origin.y) + + let offset: CGFloat + if let (inset, panOffset) = self.panGestureArguments { + offset = inset + panOffset + } else { + offset = 0.0 + } + + let topInset: CGFloat + if let currentTopInset = self.topInset { + topInset = currentTopInset + } else { + topInset = self.listNode.frame.height + } + + if case .fullscreen = self.displayMode, case .compact = layout.metrics.widthClass { + self.panGestureArguments = nil + self.fullscreenListContainer.subnodeTransform = CATransform3DIdentity + if abs(translation.y) > 100.0 || abs(velocity.y) > 300.0 { + self.mainStageBackgroundNode.layer.removeAllAnimations() + self.currentForcedSpeaker = nil + self.updateDisplayMode(.modal(isExpanded: true, isFilled: true), fromPan: true) + self.effectiveSpeaker = nil + } else { + self.isPanning = false + self.mainStageBackgroundNode.alpha = 1.0 + self.mainStageBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, completion: { [weak self] _ in + self?.attachFullscreenVideos() + }) + self.mainStageNode.setControlsHidden(false, animated: true, delay: 0.15) + + self.fullscreenListNode.alpha = 1.0 + self.fullscreenListNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: 0.15) + + var bounds = self.mainStageContainerNode.bounds + let previousBounds = bounds + bounds.origin.y = 0.0 + self.mainStageContainerNode.bounds = bounds + self.mainStageContainerNode.layer.animateBounds(from: previousBounds, to: self.mainStageContainerNode.bounds, duration: 0.2, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak self] _ in + if let strongSelf = self { + strongSelf.listContainer.transform = CATransform3DIdentity + strongSelf.contentContainer.insertSubnode(strongSelf.mainStageContainerNode, belowSubnode: strongSelf.transitionContainerNode) + strongSelf.updateMembers() + } + }) + } + } else if case .modal(true, _) = self.displayMode, case .compact = layout.metrics.widthClass { + self.panGestureArguments = nil + if velocity.y > 300.0 || offset > topInset / 2.0 { + self.displayMode = .modal(isExpanded: false, isFilled: false) + self.updateDecorationsColors() + self.animatingExpansion = true + self.listNode.scroller.setContentOffset(CGPoint(), animated: false) + + let distance: CGFloat + if let topInset = self.topInset { + distance = topInset - offset + } else { + distance = 0.0 + } + let initialVelocity: CGFloat = distance.isZero ? 0.0 : abs(velocity.y / distance) + let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) + } + self.updateDecorationsLayout(transition: transition, completion: { + self.animatingExpansion = false + }) + } else { + self.displayMode = .modal(isExpanded: true, isFilled: true) + self.updateDecorationsColors() + self.animatingExpansion = true + + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) + } + self.updateDecorationsLayout(transition: .animated(duration: 0.3, curve: .easeInOut), completion: { + self.animatingExpansion = false + }) + } + } else { + self.panGestureArguments = nil + var dismissing = false + if bounds.minY < -60 || (bounds.minY < 0.0 && velocity.y > 300.0) { + if self.isScheduling { + self.dismissScheduled() + dismissing = true + } else if case .regular = layout.metrics.widthClass { + self.controller?.dismiss(closing: false, manual: true) + dismissing = true + } else { + if case .fullscreen = self.displayMode { + } else { + self.controller?.dismiss(closing: false, manual: true) + dismissing = true + } + } + } else if !self.isScheduling && (velocity.y < -300.0 || offset < topInset / 2.0) { + if velocity.y > -2200.0 && !self.isFullscreen { + DispatchQueue.main.async { + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + } + } + + let initialVelocity: CGFloat = offset.isZero ? 0.0 : abs(velocity.y / offset) + let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) + if case .modal = self.displayMode { + self.displayMode = .modal(isExpanded: true, isFilled: true) + } + self.updateDecorationsColors() + self.animatingExpansion = true + + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) + } + self.updateDecorationsLayout(transition: transition, completion: { + self.animatingExpansion = false + }) + } else if !self.isScheduling { + self.updateDecorationsColors() + self.animatingExpansion = true + self.listNode.scroller.setContentOffset(CGPoint(), animated: false) + + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) + } + self.updateDecorationsLayout(transition: .animated(duration: 0.3, curve: .easeInOut), completion: { + self.animatingExpansion = false + }) + } + if !dismissing { + var bounds = self.contentContainer.bounds + let previousBounds = bounds + bounds.origin.y = 0.0 + self.contentContainer.bounds = bounds + self.contentContainer.layer.animateBounds(from: previousBounds, to: self.contentContainer.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) + } + } + case .cancelled: + self.panGestureArguments = nil + + let previousBounds = self.contentContainer.bounds + var bounds = self.contentContainer.bounds + bounds.origin.y = 0.0 + self.contentContainer.bounds = bounds + self.contentContainer.layer.animateBounds(from: previousBounds, to: self.contentContainer.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) + + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) + } + self.updateDecorationsLayout(transition: .animated(duration: 0.3, curve: .easeInOut), completion: { + self.animatingExpansion = false + }) + + if case .fullscreen = self.displayMode, case .regular = layout.metrics.widthClass { + self.fullscreenListContainer.subnodeTransform = CATransform3DIdentity + self.isPanning = false + self.mainStageBackgroundNode.alpha = 1.0 + self.mainStageBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, completion: { [weak self] _ in + self?.attachFullscreenVideos() + }) + self.mainStageNode.setControlsHidden(false, animated: true, delay: 0.15) + + self.fullscreenListNode.alpha = 1.0 + self.fullscreenListNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: 0.15) + + var bounds = self.mainStageContainerNode.bounds + let previousBounds = bounds + bounds.origin.y = 0.0 + self.mainStageContainerNode.bounds = bounds + self.mainStageContainerNode.layer.animateBounds(from: previousBounds, to: self.mainStageContainerNode.bounds, duration: 0.2, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak self] _ in + if let strongSelf = self { + strongSelf.contentContainer.insertSubnode(strongSelf.mainStageContainerNode, belowSubnode: strongSelf.transitionContainerNode) + strongSelf.updateMembers() + + strongSelf.listContainer.transform = CATransform3DIdentity + } + }) + } + default: + break + } + } + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let result = super.hitTest(point, with: event) + if result === self.topPanelNode.view { + return self.view + } + if result === self.bottomPanelNode.view { + return self.view + } + if !self.bounds.contains(point) { + return nil + } + if point.y < self.topPanelNode.frame.minY { + return self.dimNode.view + } + return result + } + + fileprivate func scrollToTop() { + if self.isExpanded { + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) + } + } + + private func openTitleEditing() { + let _ = (self.context.account.postbox.loadedPeerWithId(self.call.peerId) + |> deliverOnMainQueue).start(next: { [weak self] chatPeer in + guard let strongSelf = self else { + return + } + + let initialTitle = strongSelf.callState?.title ?? "" + + let title: String + let text: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + title = strongSelf.presentationData.strings.LiveStream_EditTitle + text = strongSelf.presentationData.strings.LiveStream_EditTitleText + } else { + title = strongSelf.presentationData.strings.VoiceChat_EditTitle + text = strongSelf.presentationData.strings.VoiceChat_EditTitleText + } + + let controller = voiceChatTitleEditController(sharedContext: strongSelf.context.sharedContext, account: strongSelf.context.account, forceTheme: strongSelf.darkTheme, title: title, text: text, placeholder: EnginePeer(chatPeer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), value: initialTitle, maxLength: 40, apply: { title in + if let strongSelf = self, let title = title, title != initialTitle { + strongSelf.call.updateTitle(title) + + let text: String + if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { + text = title.isEmpty ? strongSelf.presentationData.strings.LiveStream_EditTitleRemoveSuccess : strongSelf.presentationData.strings.LiveStream_EditTitleSuccess(title).string + } else { + text = title.isEmpty ? strongSelf.presentationData.strings.VoiceChat_EditTitleRemoveSuccess : strongSelf.presentationData.strings.VoiceChat_EditTitleSuccess(title).string + } + + strongSelf.presentUndoOverlay(content: .voiceChatFlag(text: text), action: { _ in return false }) + } + }) + strongSelf.controller?.present(controller, in: .window(.root)) + }) + } + + private func openAvatarForEditing(fromGallery: Bool = false, completion: @escaping () -> Void = {}) { + guard let peerId = self.callState?.myPeerId else { + return + } + + let _ = (self.context.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 strongSelf = self, let peer = peer else { + return + } + + let presentationData = strongSelf.presentationData + + let legacyController = LegacyController(presentation: .custom, theme: strongSelf.darkTheme) + 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) + + strongSelf.view.endEditing(true) + strongSelf.controller?.present(legacyController, in: .window(.root)) + + var hasPhotos = false + if !peer.profileImageRepresentations.isEmpty { + hasPhotos = true + } + + let paintStickersContext = LegacyPaintStickersContext(context: strongSelf.context) +// paintStickersContext.presentStickersController = { completion in +// let controller = DrawingStickersScreen(context: strongSelf.context, selectSticker: { fileReference, node, rect in +// let coder = PostboxEncoder() +// coder.encodeRootObject(fileReference.media) +// completion?(coder.makeData(), fileReference.media.isAnimatedSticker, node.view, rect) +// return true +// }) +// strongSelf.controller?.present(controller, in: .window(.root)) +// return controller +// } + + 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)! + mixin.forceDark = true + mixin.stickersContext = paintStickersContext + let _ = strongSelf.currentAvatarMixin.swap(mixin) + mixin.requestSearchController = { [weak self] assetsController in + guard let strongSelf = self else { + return + } + let controller = WebSearchController(context: strongSelf.context, peer: peer, chatLocation: nil, configuration: searchBotsConfiguration, mode: .avatar(initialQuery: peer.id.namespace == Namespaces.Peer.CloudUser ? nil : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), completion: { [weak self] result in + assetsController?.dismiss() + self?.updateProfilePhoto(result) + })) + controller.navigationPresentation = .modal + strongSelf.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 = { + guard let strongSelf = self else { + return + } + + let proceed = { + let _ = strongSelf.currentAvatarMixin.swap(nil) + let postbox = strongSelf.context.account.postbox + strongSelf.updateAvatarDisposable.set((strongSelf.context.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.withUpdated(theme: strongSelf.darkTheme)) + let items: [ActionSheetItem] = [ + ActionSheetButtonItem(title: presentationData.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() + }) + ]) + ]) + strongSelf.controller?.present(actionSheet, in: .window(.root)) + } + mixin.didDismiss = { [weak legacyController] in + guard let strongSelf = self else { + return + } + let _ = strongSelf.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 data = image.jpegData(compressionQuality: 0.6), let peerId = self.callState?.myPeerId else { + return + } + + let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) + self.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) + + self.currentUpdatingAvatar = representation + self.updateAvatarPromise.set(.single((representation, 0.0))) + + let postbox = self.call.account.postbox + let signal = peerId.namespace == Namespaces.Peer.CloudUser ? self.call.accountContext.engine.accountData.updateAccountPhoto(resource: resource, videoResource: nil, videoStartTimestamp: nil, mapResourceToAvatarSizes: { resource, representations in + return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) + }) : self.call.accountContext.engine.peers.updatePeerPhoto(peerId: peerId, photo: self.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 strongSelf = self else { + return + } + switch result { + case .complete: + strongSelf.updateAvatarPromise.set(.single(nil)) + case let .progress(value): + strongSelf.updateAvatarPromise.set(.single((representation, value))) + } + })) + + self.updateMembers() + } + + private func updateProfileVideo(_ image: UIImage, asset: Any?, adjustments: TGVideoEditAdjustments?) { + guard let data = image.jpegData(compressionQuality: 0.6), let peerId = self.callState?.myPeerId else { + return + } + + let photoResource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) + self.context.account.postbox.mediaBox.storeResourceData(photoResource.id, data: data) + let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: photoResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false) + + self.currentUpdatingAvatar = representation + self.updateAvatarPromise.set(.single((representation, 0.0))) + + var videoStartTimestamp: Double? = nil + if let adjustments = adjustments, adjustments.videoStartValue > 0.0 { + videoStartTimestamp = adjustments.videoStartValue - adjustments.trimStartValue + } + + let context = self.context + let account = self.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(account: account, adjustments: adjustments) + } else { + return nil + } + } + let uploadInterface = LegacyLiveUploadInterface(context: context) + let signal: SSignal + if let asset = asset as? AVAsset { + signal = TGMediaVideoConverter.convert(asset, adjustments: adjustments, watcher: uploadInterface, entityRenderer: entityRenderer)! + } else if let url = asset as? URL, 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, watcher: nil, entityRenderer: entityRenderer)! + } else { + return SSignal.single(nil) + } + }) + + } 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) + } + } + subscriber.putCompletion() + } else if let strongSelf = self, let progress = next as? NSNumber { + Queue.mainQueue().async { + strongSelf.updateAvatarPromise.set(.single((representation, Float(truncating: progress) * 0.25))) + } + } + }, 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, 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 strongSelf = self else { + return + } + switch result { + case .complete: + strongSelf.updateAvatarPromise.set(.single(nil)) + case let .progress(value): + strongSelf.updateAvatarPromise.set(.single((representation, 0.25 + value * 0.75))) + } + })) + } + + private func displayUnmuteTooltip() { + guard let (layout, _) = self.validLayout else { + return + } + let location = self.actionButton.view.convert(self.actionButton.bounds, to: self.view).center + var point = CGRect(origin: CGPoint(x: location.x - 5.0, y: location.y - 5.0 - 68.0), size: CGSize(width: 10.0, height: 10.0)) + var position: TooltipScreen.ArrowPosition = .bottom + if case .compact = layout.metrics.widthClass { + if self.isLandscape { + point.origin.x = location.x - 5.0 - 36.0 + point.origin.y = location.y - 5.0 + position = .right + } else if case .fullscreen = self.displayMode { + point.origin.y += 32.0 + } + } + self.controller?.present(TooltipScreen(account: self.context.account, text: self.presentationData.strings.VoiceChat_UnmuteSuggestion, style: .gradient(UIColor(rgb: 0x1d446c), UIColor(rgb: 0x193e63)), icon: nil, location: .point(point, position), displayDuration: .custom(8.0), shouldDismissOnTouch: { _ in + return .dismiss(consume: false) + }), in: .window(.root)) + } + + private var isScheduled: Bool { + return self.isScheduling || self.callState?.scheduleTimestamp != nil + } + + private func attachFullscreenVideos() { + guard let (layout, _) = self.validLayout, case .compact = layout.metrics.widthClass else { + return + } + var verticalItemNodes: [String: ASDisplayNode] = [:] + self.listNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? VoiceChatTilesGridItemNode { + for tileNode in itemNode.tileNodes { + if let item = tileNode.item { + verticalItemNodes[String(item.peer.id.toInt64()) + "_" + item.videoEndpointId] = tileNode + } + + if tileNode.item?.peer.id == self.effectiveSpeaker?.0 && tileNode.item?.videoEndpointId == self.effectiveSpeaker?.1 { + tileNode.isHidden = false + } + } + } + } + + self.fullscreenListNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode, let item = itemNode.item { + let otherItemNode = verticalItemNodes[String(item.peer.id.toInt64()) + "_" + (item.videoEndpointId ?? "")] + itemNode.transitionIn(from: otherItemNode) + } + } + } + + private func attachTileVideos() { + var fullscreenItemNodes: [String: VoiceChatFullscreenParticipantItemNode] = [:] + var tileNodes: [VoiceChatTileItemNode] = [] + if !self.tileGridNode.isHidden { + tileNodes = self.tileGridNode.tileNodes + } else { + self.fullscreenListNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode, let item = itemNode.item { + fullscreenItemNodes[String(item.peer.id.toInt64()) + "_" + (item.videoEndpointId ?? "")] = itemNode + } + } + self.listNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? VoiceChatTilesGridItemNode { + tileNodes = itemNode.tileNodes + } + } + } + + for tileNode in tileNodes { + if let item = tileNode.item { + let otherItemNode = fullscreenItemNodes[String(item.peer.id.toInt64()) + "_" + item.videoEndpointId] + tileNode.transitionIn(from: otherItemNode) + + if tileNode.item?.peer.id == self.effectiveSpeaker?.0 && tileNode.item?.videoEndpointId == self.effectiveSpeaker?.1 { + tileNode.isHidden = true + } + } + } + } + + private func updateDisplayMode(_ displayMode: DisplayMode, fromPan: Bool = false) { + guard !self.animatingExpansion && !self.animatingMainStage && !self.mainStageNode.animating else { + return + } + self.updateMembers() + + let previousDisplayMode = self.displayMode + var isFullscreen = false + if case .fullscreen = displayMode { + isFullscreen = true + } + + if case .fullscreen = previousDisplayMode, case .fullscreen = displayMode { + self.animatingExpansion = true + } else { + self.animatingMainStage = true + } + + var hasFullscreenList = false + if let (layout, _) = self.validLayout, case .compact = layout.metrics.widthClass { + hasFullscreenList = true + } + + let completion = { + self.displayMode = displayMode + self.updateDecorationsColors() + + self.mainStageContainerNode.isHidden = false + self.mainStageContainerNode.isUserInteractionEnabled = isFullscreen + + let transition: ContainedViewLayoutTransition = .animated(duration: 0.55, curve: .spring) + if case .modal = previousDisplayMode, case .fullscreen = self.displayMode { + self.mainStageNode.alpha = 0.0 + self.updateDecorationsLayout(transition: .immediate) + + var verticalItemNodes: [String: ASDisplayNode] = [:] + + var tileNodes: [VoiceChatTileItemNode] = [] + if !self.tileGridNode.isHidden { + tileNodes = self.tileGridNode.tileNodes + } else { + self.listNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? VoiceChatTilesGridItemNode { + tileNodes = itemNode.tileNodes + } + } + } + for tileNode in tileNodes { + if let item = tileNode.item { + verticalItemNodes[String(item.peer.id.toInt64()) + "_" + item.videoEndpointId] = tileNode + } + } + + let completion = { + let effectiveSpeakerPeerId = self.effectiveSpeaker?.0 + + if hasFullscreenList { + self.fullscreenListContainer.isHidden = false + self.fullscreenListNode.alpha = 0.0 + } + + var gridSnapshotView: UIView? + if !hasFullscreenList, let snapshotView = self.tileGridNode.view.snapshotView(afterScreenUpdates: false) { + gridSnapshotView = snapshotView + self.tileGridNode.view.addSubview(snapshotView) + self.displayPanelVideos = true + self.updateMembers(maybeUpdateVideo: false, force: true) + } + + let completion = { + if hasFullscreenList { + self.attachFullscreenVideos() + + self.fullscreenListNode.alpha = 1.0 + self.fullscreenListNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) + } + } + if let effectiveSpeakerPeerId = effectiveSpeakerPeerId, let otherItemNode = verticalItemNodes[String(effectiveSpeakerPeerId.toInt64()) + "_" + (self.effectiveSpeaker?.1 ?? "")] { + + if hasFullscreenList { + let transitionStartPosition = otherItemNode.view.convert(CGPoint(x: otherItemNode.frame.width / 2.0, y: otherItemNode.frame.height), to: self.fullscreenListContainer.view.superview) + self.fullscreenListContainer.layer.animatePosition(from: transitionStartPosition, to: self.fullscreenListContainer.position, duration: 0.55, timingFunction: kCAMediaTimingFunctionSpring) + } + + self.mainStageNode.animateTransitionIn(from: otherItemNode, transition: transition, completion: { [weak self] in + self?.animatingMainStage = false + }) + self.mainStageNode.alpha = 1.0 + + self.mainStageBackgroundNode.alpha = 1.0 + self.mainStageBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: hasFullscreenList ? 0.13 : 0.3, completion: { [weak otherItemNode] _ in + otherItemNode?.alpha = 0.0 + gridSnapshotView?.removeFromSuperview() + completion() + }) + } else { + completion() + } + + if hasFullscreenList { + self.listContainer.layer.animateScale(from: 1.0, to: 0.86, duration: 0.55, timingFunction: kCAMediaTimingFunctionSpring) + } + + if self.isLandscape { + self.transitionMaskTopFillLayer.opacity = 1.0 + } + self.transitionMaskBottomFillLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3, removeOnCompletion: false, completion: { [weak self] _ in + Queue.mainQueue().after(0.3) { + self?.transitionMaskTopFillLayer.opacity = 0.0 + self?.transitionMaskBottomFillLayer.removeAllAnimations() + } + }) + + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) + self.updateDecorationsLayout(transition: transition) + } + } + let effectiveSpeakerPeerId = self.effectiveSpeaker?.0 + var index = 0 + for item in self.currentFullscreenEntries { + if case let .peer(entry, _) = item, entry.peer.id == effectiveSpeakerPeerId { + break + } else { + index += 1 + } + } + let position: ListViewScrollPosition + if index > self.currentFullscreenEntries.count - 3 { + index = self.currentFullscreenEntries.count - 1 + position = .bottom(0.0) + } else { + position = .center(.bottom) + } + self.fullscreenListNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: index, position: position, animated: false, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in + completion() + }) + } else if case .fullscreen = previousDisplayMode, case .modal = self.displayMode { + var minimalVisiblePeerid: (PeerId, CGFloat)? + var fullscreenItemNodes: [String: VoiceChatFullscreenParticipantItemNode] = [:] + self.fullscreenListNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode, let item = itemNode.item { + let convertedFrame = itemNode.view.convert(itemNode.bounds, to: self.transitionContainerNode.view) + if let (_, x) = minimalVisiblePeerid { + if convertedFrame.minX >= 0.0 && convertedFrame.minX < x { + minimalVisiblePeerid = (item.peer.id, convertedFrame.minX) + } + } else if convertedFrame.minX >= 0.0 { + minimalVisiblePeerid = (item.peer.id, convertedFrame.minX) + } + fullscreenItemNodes[String(item.peer.id.toInt64()) + "_" + (item.videoEndpointId ?? "")] = itemNode + } + } + + let completion = { + let effectiveSpeakerPeerId = self.effectiveSpeaker?.0 + var targetTileNode: VoiceChatTileItemNode? + + self.transitionContainerNode.addSubnode(self.mainStageNode) + + self.listContainer.transform = CATransform3DIdentity + + var tileNodes: [VoiceChatTileItemNode] = [] + if !self.tileGridNode.isHidden { + tileNodes = self.tileGridNode.tileNodes + } else { + self.listNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? VoiceChatTilesGridItemNode { + tileNodes = itemNode.tileNodes + } + } + } + for tileNode in tileNodes { + if let item = tileNode.item { + if item.peer.id == effectiveSpeakerPeerId, item.videoEndpointId == self.effectiveSpeaker?.1 { + targetTileNode = tileNode + } + } + } + + var transitionOffset = -self.mainStageContainerNode.bounds.minY + if transitionOffset.isZero, let (layout, _) = self.validLayout { + if case .regular = layout.metrics.widthClass { + transitionOffset += 87.0 + } + if let targetTileNode = targetTileNode { + let transitionTargetPosition = targetTileNode.view.convert(CGPoint(x: targetTileNode.frame.width / 2.0, y: targetTileNode.frame.height), to: self.fullscreenListContainer.view.superview) + self.fullscreenListContainer.layer.animatePosition(from: self.fullscreenListContainer.position, to: transitionTargetPosition, duration: 0.55, timingFunction: kCAMediaTimingFunctionSpring) + } + + if !hasFullscreenList { + self.displayPanelVideos = false + self.listNode.forEachItemNode { itemNode in + if let itemNode = itemNode as? VoiceChatTilesGridItemNode { + itemNode.snapshotForDismissal() + } + } + self.updateMembers(maybeUpdateVideo: false, force: true) + self.attachTileVideos() + + self.mainStageBackgroundNode.alpha = 0.0 + self.mainStageBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25) + } else { + self.fullscreenListNode.alpha = 0.0 + self.mainStageBackgroundNode.alpha = 1.0 + self.fullscreenListNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, completion: { [weak self] _ in + if let strongSelf = self { + strongSelf.fullscreenListContainer.isHidden = true + strongSelf.fullscreenListNode.alpha = 1.0 + strongSelf.attachTileVideos() + + strongSelf.mainStageBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25) + strongSelf.mainStageBackgroundNode.alpha = 0.0 + } + }) + } + } + self.mainStageNode.animateTransitionOut(to: targetTileNode, offset: transitionOffset, transition: transition, completion: { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.effectiveSpeaker = nil + strongSelf.mainStageNode.update(peer: nil, waitForFullSize: false) + strongSelf.mainStageNode.setControlsHidden(false, animated: false) + strongSelf.fullscreenListContainer.isHidden = true + strongSelf.mainStageContainerNode.isHidden = true + strongSelf.mainStageContainerNode.addSubnode(strongSelf.mainStageNode) + + var bounds = strongSelf.mainStageContainerNode.bounds + bounds.origin.y = 0.0 + strongSelf.mainStageContainerNode.bounds = bounds + + strongSelf.contentContainer.insertSubnode(strongSelf.mainStageContainerNode, belowSubnode: strongSelf.transitionContainerNode) + + strongSelf.isPanning = false + strongSelf.animatingMainStage = false + }) + + if hasFullscreenList { + self.listContainer.layer.animateScale(from: 0.86, to: 1.0, duration: 0.55, timingFunction: kCAMediaTimingFunctionSpring) + } + + self.transitionMaskTopFillLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3) + if !transitionOffset.isZero { + self.transitionMaskBottomFillLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3) + } + + if let (layout, navigationHeight) = self.validLayout { + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) + self.updateDecorationsLayout(transition: transition) + } + } + if false, let (peerId, _) = minimalVisiblePeerid { + var index = 0 + for item in self.currentEntries { + if case let .peer(entry, _) = item, entry.peer.id == peerId { + break + } else { + index += 1 + } + } + self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: index, position: .top(0.0), animated: false, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in + completion() + }) + } else { + completion() + } + } else if case .fullscreen = self.displayMode { + if let (layout, navigationHeight) = self.validLayout { + let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring) + self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) + self.updateDecorationsLayout(transition: transition) + } + } + } + + if case .fullscreen(false) = displayMode, case .modal = previousDisplayMode { + self.updateMainVideo(waitForFullSize: true, updateMembers: true, force: true, completion: { + completion() + }) + } else { + completion() + } + } + + fileprivate var actionButtonPosition: CGPoint { + guard let (layout, _) = self.validLayout else { + return CGPoint() + } + let size = layout.size + let hasCameraButton = self.cameraButton.isUserInteractionEnabled + let centralButtonSide = min(size.width, size.height) - 32.0 + let centralButtonSize = CGSize(width: centralButtonSide, height: centralButtonSide) + + if case .regular = layout.metrics.widthClass { + let contentWidth: CGFloat = max(320.0, min(375.0, floor(size.width * 0.3))) + let contentLeftInset: CGFloat + if self.peerIdToEndpointId.isEmpty { + contentLeftInset = floorToScreenPixels((layout.size.width - contentWidth) / 2.0) + } else { + contentLeftInset = self.panelHidden ? layout.size.width : layout.size.width - contentWidth + } + return CGPoint(x: contentLeftInset + floorToScreenPixels(contentWidth / 2.0), y: layout.size.height - self.effectiveBottomAreaHeight - layout.intrinsicInsets.bottom + floor(self.effectiveBottomAreaHeight / 2.0) - 3.0) + } else { + switch self.displayMode { + case .modal: + if self.isLandscape { + let sideInset: CGFloat + let buttonsCount: Int + if hasCameraButton { + sideInset = 26.0 + buttonsCount = 4 + } else { + sideInset = 42.0 + buttonsCount = 3 + } + let spacing = floor((layout.size.height - sideInset * 2.0 - sideButtonSize.height * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) + let x = layout.size.width - fullscreenBottomAreaHeight - layout.safeInsets.right + floor((fullscreenBottomAreaHeight - sideButtonSize.width) / 2.0) + let actionButtonFrame = CGRect(origin: CGPoint(x: x, y: sideInset + sideButtonSize.height + spacing), size: sideButtonSize) + return actionButtonFrame.center + } else { + let actionButtonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - centralButtonSize.width) / 2.0), y: layout.size.height - self.effectiveBottomAreaHeight - layout.intrinsicInsets.bottom + floor((self.effectiveBottomAreaHeight - centralButtonSize.height) / 2.0) - 3.0), size: centralButtonSize) + return actionButtonFrame.center + } + case let .fullscreen(controlsHidden): + if self.isLandscape { + let sideInset: CGFloat + let buttonsCount: Int + if hasCameraButton { + sideInset = 26.0 + buttonsCount = 4 + } else { + sideInset = 42.0 + buttonsCount = 3 + } + let spacing = floor((layout.size.height - sideInset * 2.0 - sideButtonSize.height * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) + let x = layout.size.width - fullscreenBottomAreaHeight - layout.safeInsets.right + (controlsHidden ? fullscreenBottomAreaHeight + layout.safeInsets.right + 30.0 : floor((fullscreenBottomAreaHeight - sideButtonSize.width) / 2.0)) + let actionButtonFrame = CGRect(origin: CGPoint(x: x, y: sideInset + sideButtonSize.height + spacing), size: sideButtonSize) + return actionButtonFrame.center + } else { + let sideInset: CGFloat + let buttonsCount: Int + if hasCameraButton { + sideInset = 26.0 + buttonsCount = 4 + } else { + sideInset = 42.0 + buttonsCount = 3 + } + let spacing = floor((layout.size.width - sideInset * 2.0 - sideButtonSize.width * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) + let y = layout.size.height - self.effectiveBottomAreaHeight - layout.intrinsicInsets.bottom + (controlsHidden ? self.effectiveBottomAreaHeight + layout.intrinsicInsets.bottom + 30.0: floor((self.effectiveBottomAreaHeight - sideButtonSize.height) / 2.0)) + let secondButtonFrame: CGRect + if hasCameraButton { + let firstButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: y), size: sideButtonSize) + secondButtonFrame = CGRect(origin: CGPoint(x: firstButtonFrame.maxX + spacing, y: y), size: sideButtonSize) + } else { + secondButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: y), size: sideButtonSize) + } + let actionButtonFrame = CGRect(origin: CGPoint(x: secondButtonFrame.maxX + spacing, y: y), size: sideButtonSize) + return actionButtonFrame.center + } + } + } + } + } + + private let sharedContext: SharedAccountContext + public let call: PresentationGroupCall + private let presentationData: PresentationData + public var parentNavigationController: NavigationController? + + fileprivate let contentsReady = ValuePromise(false, ignoreRepeated: true) + fileprivate let dataReady = ValuePromise(false, ignoreRepeated: true) + fileprivate let audioOutputStateReady = ValuePromise(false, ignoreRepeated: true) + private let _ready = Promise(false) + override public var ready: Promise { + return self._ready + } + + public var onViewDidAppear: (() -> Void)? + public var onViewDidDisappear: (() -> Void)? + private var reclaimActionButton: (() -> Void)? + + private var didAppearOnce: Bool = false + private var isDismissed: Bool = false + private var isDisconnected: Bool = false + + private var controllerNode: Node { + return self.displayNode as! Node + } + + private let idleTimerExtensionDisposable = MetaDisposable() + + public weak var currentOverlayController: VoiceChatOverlayController? + + private var validLayout: ContainerViewLayout? + + public init(sharedContext: SharedAccountContext, accountContext: AccountContext, call: PresentationGroupCall) { + self.sharedContext = sharedContext + self.call = call + self.presentationData = sharedContext.currentPresentationData.with { $0 } + + super.init(navigationBarPresentationData: nil) + + self.automaticallyControlPresentationContextLayout = false + self.blocksBackgroundWhenInOverlay = true + + self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .all) + + self.statusBar.statusBarStyle = .Ignore + + self._ready.set(combineLatest([ + self.contentsReady.get(), + self.dataReady.get(), + self.audioOutputStateReady.get() + ]) + |> map { values -> Bool in + for value in values { + if !value { + return false + } + } + return true + } + |> filter { $0 }) + + self.scrollToTop = { [weak self] in + self?.controllerNode.scrollToTop() + } + } + + required init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.idleTimerExtensionDisposable.dispose() + + if let currentOverlayController = self.currentOverlayController { + currentOverlayController.animateOut(reclaim: false, targetPosition: CGPoint(), completion: { _ in }) + } + } + + override public func loadDisplayNode() { + self.displayNode = Node(controller: self, sharedContext: self.sharedContext, call: self.call) + + self.displayNodeDidLoad() + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.isDismissed = false + + if !self.didAppearOnce { + self.didAppearOnce = true + + self.reclaimActionButton?() + self.controllerNode.animateIn() + + self.idleTimerExtensionDisposable.set(self.sharedContext.applicationBindings.pushIdleTimerExtension()) + } + + DispatchQueue.main.async { + self.onViewDidAppear?() + } + } + + override public func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + self.idleTimerExtensionDisposable.set(nil) + + DispatchQueue.main.async { + self.didAppearOnce = false + self.isDismissed = true + self.detachActionButton() + self.onViewDidDisappear?() + } + } + + private var dismissedManually: Bool = false + public func dismiss(closing: Bool, manual: Bool = false) { + if closing { + self.isDisconnected = true + } else { + if let navigationController = self.navigationController as? NavigationController { + let count = navigationController.viewControllers.count + if count == 2 || navigationController.viewControllers[count - 2] is ChatController { + if case .active(.cantSpeak) = self.controllerNode.actionButton.stateValue { + } else if case .button = self.controllerNode.actionButton.stateValue { + } else if case .scheduled = self.controllerNode.actionButton.stateValue { + } else if let chatController = navigationController.viewControllers[count - 2] as? ChatController, chatController.isSendButtonVisible { + } else if let tabBarController = navigationController.viewControllers[count - 2] as? TabBarController, let chatListController = tabBarController.controllers[tabBarController.selectedIndex] as? ChatListController, chatListController.isSearchActive { + } else { + if manual { + self.dismissedManually = true + Queue.mainQueue().after(0.05) { + self.detachActionButton() + } + } else { + self.detachActionButton() + } + } + } + } + } + + self.dismiss() + } + + private func dismissAllTooltips() { + self.window?.forEachController({ controller in + if let controller = controller as? UndoOverlayController { + controller.dismissWithCommitAction() + } + }) + self.forEachController({ controller in + if let controller = controller as? UndoOverlayController { + controller.dismissWithCommitAction() + } + if let controller = controller as? TooltipScreen { + controller.dismiss() + } + return true + }) + } + + private func detachActionButton() { + guard self.currentOverlayController == nil && !self.isDisconnected else { + return + } + + let overlayController = VoiceChatOverlayController(actionButton: self.controllerNode.actionButton, audioOutputNode: self.controllerNode.audioButton, cameraNode: self.controllerNode.cameraButton, leaveNode: self.controllerNode.leaveButton, navigationController: self.navigationController as? NavigationController, initiallyHidden: self.dismissedManually) + if let navigationController = self.navigationController as? NavigationController { + navigationController.presentOverlay(controller: overlayController, inGlobal: true, blockInteraction: false) + } + + self.currentOverlayController = overlayController + self.dismissedManually = false + + self.reclaimActionButton = { [weak self, weak overlayController] in + if let strongSelf = self { + overlayController?.animateOut(reclaim: true, targetPosition: strongSelf.controllerNode.actionButtonPosition, completion: { immediate in + if let strongSelf = self, immediate { + strongSelf.controllerNode.actionButton.ignoreHierarchyChanges = true + strongSelf.controllerNode.bottomPanelNode.addSubnode(strongSelf.controllerNode.cameraButton) + strongSelf.controllerNode.bottomPanelNode.addSubnode(strongSelf.controllerNode.audioButton) + strongSelf.controllerNode.bottomPanelNode.addSubnode(strongSelf.controllerNode.leaveButton) + strongSelf.controllerNode.bottomPanelNode.addSubnode(strongSelf.controllerNode.actionButton) + + if immediate, let layout = strongSelf.validLayout { + strongSelf.containerLayoutUpdated(layout, transition: .immediate) + } + strongSelf.controllerNode.actionButton.ignoreHierarchyChanges = false + } + }) + strongSelf.reclaimActionButton = nil + } + } + } + + override public func dismiss(completion: (() -> Void)? = nil) { + if !self.isDismissed { + self.isDismissed = true + self.didAppearOnce = false + + self.controllerNode.animateOut(completion: { [weak self] in + completion?() + self?.dismiss(animated: false) + }) + + DispatchQueue.main.async { + self.onViewDidDisappear?() + } + } + } + + override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { + super.containerLayoutUpdated(layout, transition: transition) + self.validLayout = layout + self.controllerNode.containerLayoutUpdated(layout, navigationHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition) + } +} + +private final class VoiceChatContextExtractedContentSource: ContextExtractedContentSource { + var keepInPlace: Bool + let ignoreContentTouches: Bool = false + let blurBackground: Bool + let maskView: UIView? + + private var animateTransitionIn: () -> Void + private var animateTransitionOut: () -> Void + + private let sourceNode: ContextExtractedContentContainingNode + + var centerVertically: Bool + var shouldBeDismissed: Signal + + init(sourceNode: ContextExtractedContentContainingNode, maskView: UIView?, keepInPlace: Bool, blurBackground: Bool, centerVertically: Bool, shouldBeDismissed: Signal, animateTransitionIn: @escaping () -> Void, animateTransitionOut: @escaping () -> Void) { + self.sourceNode = sourceNode + self.maskView = maskView + self.keepInPlace = keepInPlace + self.blurBackground = blurBackground + self.centerVertically = centerVertically + self.shouldBeDismissed = shouldBeDismissed + self.animateTransitionIn = animateTransitionIn + self.animateTransitionOut = animateTransitionOut + } + + func takeView() -> ContextControllerTakeViewInfo? { + self.animateTransitionIn() + return ContextControllerTakeViewInfo(containingItem: .node(self.sourceNode), contentAreaInScreenSpace: UIScreen.main.bounds, maskView: self.maskView) + } + + func putBack() -> ContextControllerPutBackViewInfo? { + self.animateTransitionOut() + return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds, maskView: self.maskView) + } +} + +private final class VoiceChatContextReferenceContentSource: ContextReferenceContentSource { + private let controller: ViewController + private let sourceNode: ContextReferenceContentNode + + init(controller: ViewController, sourceNode: ContextReferenceContentNode) { + self.controller = controller + self.sourceNode = sourceNode + } + + func transitionInfo() -> ContextControllerReferenceViewInfo? { + return ContextControllerReferenceViewInfo(referenceView: self.sourceNode.view, contentAreaInScreenSpace: UIScreen.main.bounds) + } +} diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 0a7e5a3c94..2e787f91bf 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -103,7 +103,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { private var callController: CallController? public let hasOngoingCall = ValuePromise(false) private let callState = Promise(nil) - + // Rename to LiveStreamingController private var groupCallController: VoiceChatController? public var currentGroupCallController: ViewController? { return self.groupCallController @@ -665,7 +665,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { if call.isStream { strongSelf.hasGroupCallOnScreenPromise.set(true) - let groupCallController = MediaStreamComponentController(call: call) + let groupCallController = MediaStreamingControllerImpl(sharedContext: strongSelf, accountContext: call.accountContext, call: call) // MediaStreamComponentController(call: call) groupCallController.onViewDidAppear = { [weak self] in if let strongSelf = self { strongSelf.hasGroupCallOnScreenPromise.set(true) From a9ed9202a6bb30d9547b9725828dd98e1f2076d4 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Fri, 25 Nov 2022 22:18:54 +0400 Subject: [PATCH 02/50] Integrating streaming video into basic sheet --- .../Components/MediaStreamComponent.swift | 58 ++++++++++++++++--- .../MediaStreamVideoComponent.swift | 43 ++++++++++---- 2 files changed, 83 insertions(+), 18 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 0d65d07d55..cb9049b7b1 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -517,7 +517,7 @@ private final class ToolbarComponent: CombinedComponent { } } -public final class MediaStreamComponent: CombinedComponent { +public final class _MediaStreamComponent: CombinedComponent { struct OriginInfo: Equatable { var title: String var memberCount: Int @@ -531,7 +531,7 @@ public final class MediaStreamComponent: CombinedComponent { self.call = call } - public static func ==(lhs: MediaStreamComponent, rhs: MediaStreamComponent) -> Bool { + public static func ==(lhs: _MediaStreamComponent, rhs: _MediaStreamComponent) -> Bool { if lhs.call !== rhs.call { return false } @@ -569,6 +569,9 @@ public final class MediaStreamComponent: CombinedComponent { let deactivatePictureInPictureIfVisible = StoredActionSlot(Void.self) + // MARK: - Added + var videoHiddenForPip = false + init(call: PresentationGroupCallImpl) { self.call = call @@ -711,6 +714,8 @@ public final class MediaStreamComponent: CombinedComponent { let navigationBar = Child(NavigationBarComponent.self) let toolbar = Child(ToolbarComponent.self) + let sheet = Child(StreamSheetComponent.self) + let activatePictureInPicture = StoredActionSlot(Action.self) let deactivatePictureInPicture = StoredActionSlot(Void.self) let moreButtonTag = GenericComponentViewTag() @@ -724,7 +729,7 @@ public final class MediaStreamComponent: CombinedComponent { } let background = background.update( - component: Rectangle(color: .black), + component: Rectangle(color: .black.withAlphaComponent(0.12)), availableSize: context.availableSize, transition: context.transition ) @@ -740,6 +745,8 @@ public final class MediaStreamComponent: CombinedComponent { if controller.view.window == nil { return } + state.videoHiddenForPip = false + state.updated(transition: .easeInOut(duration: 3)) deactivatePictureInPicture.invoke(Void()) } @@ -759,7 +766,7 @@ public final class MediaStreamComponent: CombinedComponent { } call.accountContext.sharedContext.mainWindow?.inCallNavigate?() - + // TODO: bring up sheet completed() }, pictureInPictureClosed: { [weak call] in @@ -770,8 +777,22 @@ public final class MediaStreamComponent: CombinedComponent { transition: context.transition ) + let sheet = sheet.update( + component: StreamSheetComponent( + leftItem: AnyComponent(Button( + content: AnyComponent(Text(text: environment.strings.Common_Close, font: Font.regular(17.0), color: .white)), + action: { [weak call] in + let _ = call?.leave(terminateIfPossible: false) + }) + ) + ), + availableSize: context.availableSize, + transition: context.transition + ) + var navigationRightItems: [AnyComponentWithIdentity] = [] - if context.state.isPictureInPictureSupported, context.state.hasVideo { + let contextView = context.view + if /*true || context.state.isPictureInPictureSupported,*/ context.state.hasVideo { navigationRightItems.append(AnyComponentWithIdentity(id: "pip", component: AnyComponent(Button( content: AnyComponent(BundleIconComponent( name: "Media Gallery/PictureInPictureButton", @@ -782,7 +803,15 @@ public final class MediaStreamComponent: CombinedComponent { guard let controller = controller() as? MediaStreamComponentController else { return } - controller.dismiss(closing: false, manual: true) + state.videoHiddenForPip = true + UIView.animate(withDuration: 5, animations: { + contextView.alpha = 0 + }, completion: { _ in + state.videoHiddenForPip = true + state.updateDismissOffset(value: 2000, interactive: false) + controller.dismiss(closing: false, manual: true) + contextView.alpha = 1 + }) }) } ).minSize(CGSize(width: 44.0, height: 44.0))))) @@ -1115,6 +1144,7 @@ public final class MediaStreamComponent: CombinedComponent { case let .updated(offset): state.updateDismissOffset(value: offset.y, interactive: true) case let .ended(velocity): + // TODO: Dismiss sheet depending on velocity if abs(velocity.y) > 200.0 { activatePictureInPicture.invoke(Action { [weak state] in guard let state = state, let controller = controller() as? MediaStreamComponentController else { @@ -1130,6 +1160,13 @@ public final class MediaStreamComponent: CombinedComponent { }) ) + let sheetHeight: CGFloat = 300 + let sheetOffset: CGFloat = context.availableSize.height - sheetHeight + context.state.dismissOffset + // TODO: work with sheet here + context.add(sheet + .position(.init(x: context.availableSize.width / 2.0, y: sheetOffset)) + ) + print("DismissOffset: \(context.state.dismissOffset)") context.add(video .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0 + context.state.dismissOffset)) ) @@ -1149,7 +1186,7 @@ public final class MediaStreamComponent: CombinedComponent { } } -public final class MediaStreamComponentController: ViewControllerComponentContainer, VoiceChatController { +public final class _MediaStreamComponentController: ViewControllerComponentContainer, VoiceChatController { private let context: AccountContext public let call: PresentationGroupCall public private(set) var currentOverlayController: VoiceChatOverlayController? = nil @@ -1162,6 +1199,10 @@ public final class MediaStreamComponentController: ViewControllerComponentContai private let inviteLinksPromise = Promise(nil) + public convenience init(sharedContext: SharedAccountContext, accountContext: AccountContext, call: PresentationGroupCall) { + self.init(call: call) + } + public init(call: PresentationGroupCall) { self.context = call.accountContext self.call = call @@ -1210,6 +1251,7 @@ public final class MediaStreamComponentController: ViewControllerComponentContai } strongSelf.view.layer.allowsGroupOpacity = false }) + // self.view.backgroundColor = .cyan } override public func viewDidDisappear(_ animated: Bool) { @@ -1377,3 +1419,5 @@ public final class MediaStreamComponentController: ViewControllerComponentContai }) } } + +public typealias MediaStreamComponentController = _MediaStreamComponentController diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 951ffad732..23843689eb 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -7,7 +7,9 @@ import AVKit import MultilineTextComponent import Display -final class MediaStreamVideoComponent: Component { +typealias MediaStreamVideoComponent = _MediaStreamVideoComponent + +final class _MediaStreamVideoComponent: Component { let call: PresentationGroupCallImpl let hasVideo: Bool let isVisible: Bool @@ -40,7 +42,7 @@ final class MediaStreamVideoComponent: Component { self.pictureInPictureClosed = pictureInPictureClosed } - public static func ==(lhs: MediaStreamVideoComponent, rhs: MediaStreamVideoComponent) -> Bool { + public static func ==(lhs: _MediaStreamVideoComponent, rhs: _MediaStreamVideoComponent) -> Bool { if lhs.call !== rhs.call { return false } @@ -70,7 +72,7 @@ final class MediaStreamVideoComponent: Component { return State() } - public final class View: UIScrollView, AVPictureInPictureControllerDelegate, ComponentTaggedView { + public final class View: UIView, AVPictureInPictureControllerDelegate, ComponentTaggedView { public final class Tag { } @@ -83,7 +85,7 @@ final class MediaStreamVideoComponent: Component { private var pictureInPictureController: AVPictureInPictureController? - private var component: MediaStreamVideoComponent? + private var component: _MediaStreamVideoComponent? private var hadVideo: Bool = false private var requestedExpansion: Bool = false @@ -96,9 +98,9 @@ final class MediaStreamVideoComponent: Component { override init(frame: CGRect) { self.blurTintView = UIView() self.blurTintView.backgroundColor = UIColor(white: 0.0, alpha: 0.55) - super.init(frame: frame) +// self.backgroundColor = UIColor.green.withAlphaComponent(0.4) self.isUserInteractionEnabled = false self.clipsToBounds = true @@ -123,10 +125,18 @@ final class MediaStreamVideoComponent: Component { } } - func update(component: MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { +// let sheetView = UIView() +// let sheetBackdropView = UIView() +// var sheetTop: CGFloat = 0 +// var sheetHeight: CGFloat = 0 + + func update(component: _MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { self.state = state if component.hasVideo, self.videoView == nil { +// self.addSubview(sheetBackdropView) +// self.addSubview(sheetView) + if let input = component.call.video(endpointId: "unified") { if let videoBlurView = self.videoRenderingContext.makeView(input: input, blur: true) { self.videoBlurView = videoBlurView @@ -142,8 +152,7 @@ final class MediaStreamVideoComponent: Component { if #available(iOS 13.0, *) { sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true } - - if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { +// if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { final class PlaybackDelegateImpl: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate { func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) { @@ -168,15 +177,25 @@ final class MediaStreamVideoComponent: Component { return false } } - - let pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: PlaybackDelegateImpl())) + let pictureInPictureController: AVPictureInPictureController + if #available(iOS 15.0, *) { + pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: PlaybackDelegateImpl())) + } else { + // TODO: support PiP for iOS < 15.0 + // sampleBufferVideoView.sampleBufferLayer + pictureInPictureController = AVPictureInPictureController.init(playerLayer: AVPlayerLayer(player: AVPlayer()))! + } pictureInPictureController.delegate = self + if #available(iOS 14.2, *) { pictureInPictureController.canStartPictureInPictureAutomaticallyFromInline = true + } + if #available(iOS 14.0, *) { pictureInPictureController.requiresLinearPlayback = true + } self.pictureInPictureController = pictureInPictureController - } +// } } videoView.setOnOrientationUpdated { [weak state] _, _ in @@ -207,6 +226,8 @@ final class MediaStreamVideoComponent: Component { } } +// sheetView.frame = .init(x: 0, y: sheetTop, width: availableSize.width, height: sheetHeight) + if let videoView = self.videoView { var isVideoVisible = component.isVisible if let pictureInPictureController = self.pictureInPictureController { From 2f9f07d8604edffb4a44a392c86dad82e1bc3cb0 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Sat, 26 Nov 2022 13:22:38 +0400 Subject: [PATCH 03/50] Adding components, proper animations and info, video glowing and basic fullscreen --- .../Components/MediaStreamComponent.swift | 374 ++++++++++++------ .../MediaStreamVideoComponent.swift | 97 ++++- .../Components/StreamSheetComponent.swift | 286 ++++++++++++++ .../Sources/VoiceChatController.swift | 10 +- .../Sources/SharedAccountContext.swift | 3 +- 5 files changed, 622 insertions(+), 148 deletions(-) create mode 100644 submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index cb9049b7b1..fb8ed57d3e 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -106,10 +106,12 @@ final class NavigationBackButtonComponent: Component { final class StreamTitleComponent: Component { let text: String let isRecording: Bool + let isActive: Bool - init(text: String, isRecording: Bool) { + init(text: String, isRecording: Bool, isActive: Bool) { self.text = text self.isRecording = isRecording + self.isActive = isActive } static func ==(lhs: StreamTitleComponent, rhs: StreamTitleComponent) -> Bool { @@ -119,12 +121,66 @@ final class StreamTitleComponent: Component { if lhs.isRecording != rhs.isRecording { return false } + if lhs.isActive != rhs.isActive { + return false + } return false } + final class LiveIndicatorView: UIView { + private let label = UILabel() + private let stalledAnimatedGradient = CAGradientLayer() + private var wasLive = false + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + + addSubview(label) + label.text = "LIVE" + label.font = .systemFont(ofSize: 10, weight: .medium) + label.textAlignment = .center + layer.addSublayer(stalledAnimatedGradient) + self.clipsToBounds = true + toggle(isLive: false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + label.frame = bounds + stalledAnimatedGradient.frame = bounds + self.layer.cornerRadius = min(bounds.width, bounds.height) / 2 + } + + func toggle(isLive: Bool) { + // TODO: get actual colors + if isLive { + if !wasLive { + // TODO: animate + } + self.backgroundColor = .systemPink + stalledAnimatedGradient.opacity = 0 + stalledAnimatedGradient.removeAllAnimations() + } else { + if wasLive { + // TODO: animate + } + self.backgroundColor = .gray + stalledAnimatedGradient.opacity = 1 +// stalledAnimatedGradient.add(<#T##anim: CAAnimation##CAAnimation#>, forKey: <#T##String?#>) + } + wasLive = isLive + } + } + public final class View: UIView { private let textView: ComponentHostView private var indicatorView: UIImageView? + let liveIndicatorView = LiveIndicatorView() private let trackingLayer: HierarchyTrackingLayer @@ -136,6 +192,7 @@ final class StreamTitleComponent: Component { super.init(frame: frame) self.addSubview(self.textView) + self.addSubview(self.liveIndicatorView) self.trackingLayer.didEnterHierarchy = { [weak self] in guard let strongSelf = self else { @@ -190,14 +247,16 @@ final class StreamTitleComponent: Component { indicatorView.removeFromSuperview() } } - + liveIndicatorView.toggle(isLive: component.isActive) let sideInset: CGFloat = 20.0 let size = CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height) let textFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) self.textView.frame = textFrame + liveIndicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: floorToScreenPixels((size.height - textSize.height) / 2.0) + 1.0), size: .init(width: 40, height: 18)) + if let indicatorView = self.indicatorView, let image = indicatorView.image { - indicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: floorToScreenPixels((size.height - image.size.height) / 2.0) + 1.0), size: image.size) + indicatorView.frame = CGRect(origin: CGPoint(x: liveIndicatorView.frame.maxX + 6.0, y: floorToScreenPixels((size.height - image.size.height) / 2.0) + 1.0), size: image.size) } return size @@ -227,7 +286,7 @@ private final class NavigationBarComponent: CombinedComponent { rightItems: [AnyComponentWithIdentity], centerItem: AnyComponent? ) { - self.topInset = topInset + self.topInset = 0 // topInset self.sideInset = sideInset self.leftItem = leftItem self.rightItems = rightItems @@ -255,7 +314,6 @@ private final class NavigationBarComponent: CombinedComponent { } static var body: Body { - let background = Child(Rectangle.self) let leftItem = Child(environment: Empty.self) let rightItems = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) let centerItem = Child(environment: Empty.self) @@ -267,8 +325,6 @@ private final class NavigationBarComponent: CombinedComponent { let contentHeight: CGFloat = 44.0 let size = CGSize(width: context.availableSize.width, height: context.component.topInset + contentHeight) - let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) - let leftItem = context.component.leftItem.flatMap { leftItemComponent in return leftItem.update( component: leftItemComponent, @@ -302,10 +358,6 @@ private final class NavigationBarComponent: CombinedComponent { availableWidth -= centerItem.size.width } - context.add(background - .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) - ) - var centerLeftInset = sideInset if let leftItem = leftItem { context.add(leftItem @@ -450,7 +502,7 @@ private final class ToolbarComponent: CombinedComponent { let contentHeight: CGFloat = 44.0 let size = CGSize(width: context.availableSize.width, height: contentHeight + context.component.bottomInset) - let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) + let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: 0)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) let leftItem = context.component.leftItem.flatMap { leftItemComponent in return leftItem.update( @@ -550,8 +602,10 @@ public final class _MediaStreamComponent: CombinedComponent { private(set) var displayUI: Bool = true var dismissOffset: CGFloat = 0.0 - + // TODO: remove (replaced by isFullscreen) var storedIsLandscape: Bool? + var isFullscreen: Bool = false + var videoSize: CGSize? private(set) var canManageCall: Bool = false let isPictureInPictureSupported: Bool @@ -569,8 +623,9 @@ public final class _MediaStreamComponent: CombinedComponent { let deactivatePictureInPictureIfVisible = StoredActionSlot(Void.self) - // MARK: - Added var videoHiddenForPip = false + /// To update videoHiddenForPip + var onExpandedFromPictureInPicture: ((State) -> Void)? init(call: PresentationGroupCallImpl) { self.call = call @@ -711,8 +766,8 @@ public final class _MediaStreamComponent: CombinedComponent { public static var body: Body { let background = Child(Rectangle.self) let video = Child(MediaStreamVideoComponent.self) - let navigationBar = Child(NavigationBarComponent.self) - let toolbar = Child(ToolbarComponent.self) +// let navigationBar = Child(NavigationBarComponent.self) +// let toolbar = Child(ToolbarComponent.self) let sheet = Child(StreamSheetComponent.self) @@ -721,6 +776,8 @@ public final class _MediaStreamComponent: CombinedComponent { let moreButtonTag = GenericComponentViewTag() let moreAnimationTag = GenericComponentViewTag() + var debugUpdate = true + var lastVideoPos: CGFloat = 0 return { context in let environment = context.environment[ViewControllerComponentContainer.Environment.self].value if environment.isVisible { @@ -737,7 +794,10 @@ public final class _MediaStreamComponent: CombinedComponent { let call = context.component.call let state = context.state let controller = environment.controller - + //? + if environment.isVisible { + state.videoHiddenForPip = false + } context.state.deactivatePictureInPictureIfVisible.connect { guard let controller = controller() else { return @@ -749,7 +809,7 @@ public final class _MediaStreamComponent: CombinedComponent { state.updated(transition: .easeInOut(duration: 3)) deactivatePictureInPicture.invoke(Void()) } - + let isFullscreen = state.isFullscreen let video = video.update( component: MediaStreamVideoComponent( call: context.component.call, @@ -757,6 +817,9 @@ public final class _MediaStreamComponent: CombinedComponent { isVisible: environment.isVisible && context.state.isVisibleInHierarchy, isAdmin: context.state.canManageCall, peerTitle: context.state.peerTitle, + // TODO: find out how to get image + peerImage: nil, + isFullscreen: isFullscreen, activatePictureInPicture: activatePictureInPicture, deactivatePictureInPicture: deactivatePictureInPicture, bringBackControllerForPictureInPictureDeactivation: { [weak call] completed in @@ -771,28 +834,19 @@ public final class _MediaStreamComponent: CombinedComponent { }, pictureInPictureClosed: { [weak call] in let _ = call?.leave(terminateIfPossible: false) + }, + onVideoSizeRetrieved: { [weak state] size in + state?.videoSize = size } ), availableSize: context.availableSize, transition: context.transition - ) - - let sheet = sheet.update( - component: StreamSheetComponent( - leftItem: AnyComponent(Button( - content: AnyComponent(Text(text: environment.strings.Common_Close, font: Font.regular(17.0), color: .white)), - action: { [weak call] in - let _ = call?.leave(terminateIfPossible: false) - }) - ) - ), - availableSize: context.availableSize, - transition: context.transition - ) + )// .opacity(state.videoHiddenForPip ? 0 : 1) +// let height = context.availableSize.height var navigationRightItems: [AnyComponentWithIdentity] = [] - let contextView = context.view - if /*true || context.state.isPictureInPictureSupported,*/ context.state.hasVideo { +// let contextView = context.view + if context.state.isPictureInPictureSupported, context.state.hasVideo { navigationRightItems.append(AnyComponentWithIdentity(id: "pip", component: AnyComponent(Button( content: AnyComponent(BundleIconComponent( name: "Media Gallery/PictureInPictureButton", @@ -804,22 +858,17 @@ public final class _MediaStreamComponent: CombinedComponent { return } state.videoHiddenForPip = true - UIView.animate(withDuration: 5, animations: { - contextView.alpha = 0 - }, completion: { _ in - state.videoHiddenForPip = true - state.updateDismissOffset(value: 2000, interactive: false) - controller.dismiss(closing: false, manual: true) - contextView.alpha = 1 - }) + + controller.dismiss(closing: false, manual: true) }) } ).minSize(CGSize(width: 44.0, height: 44.0))))) } - + var topLeftButton: AnyComponent? if context.state.canManageCall { let whiteColor = UIColor(white: 1.0, alpha: 1.0) - navigationRightItems.append(AnyComponentWithIdentity(id: "more", component: AnyComponent(Button( + /*navigationRightItems.append(*/ topLeftButton = //AnyComponentWithIdentity(id: "more", component: + AnyComponent(Button( content: AnyComponent(ZStack([ AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( strokeColor: .white, @@ -1048,26 +1097,27 @@ public final class _MediaStreamComponent: CombinedComponent { }*/ controller.presentInGlobalOverlay(contextController) } - ).minSize(CGSize(width: 44.0, height: 44.0)).tagged(moreButtonTag)))) + ).minSize(CGSize(width: 44.0, height: 44.0)).tagged(moreButtonTag))//)//) } - - let navigationBar = navigationBar.update( - component: NavigationBarComponent( - topInset: environment.statusBarHeight, - sideInset: environment.safeInsets.left, - leftItem: AnyComponent(Button( - content: AnyComponent(Text(text: environment.strings.Common_Close, font: Font.regular(17.0), color: .white)), - action: { [weak call] in - let _ = call?.leave(terminateIfPossible: false) - }) - ), - rightItems: navigationRightItems, - centerItem: AnyComponent(StreamTitleComponent(text: environment.strings.VoiceChatChannel_Title, isRecording: state.recordingStartTimestamp != nil)) - ), - availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), - transition: context.transition + let navigationComponent = NavigationBarComponent( + topInset: environment.statusBarHeight, + sideInset: environment.safeInsets.left, + leftItem: topLeftButton/*AnyComponent(Button( + content: AnyComponent(Text(text: environment.strings.Common_Close, font: Font.regular(17.0), color: .white)), + action: { [weak call] in + let _ = call?.leave(terminateIfPossible: false) + }) + )*/, + rightItems: navigationRightItems, + centerItem: AnyComponent(StreamTitleComponent(text: state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isActive: call.hasVideo)) ) +// let navigationBar = navigationBar.update( +// component: navigationComponent, +// availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), +// transition: context.transition +// ) + let isLandscape = context.availableSize.width > context.availableSize.height if context.state.storedIsLandscape != isLandscape { context.state.storedIsLandscape = isLandscape @@ -1092,40 +1142,49 @@ public final class _MediaStreamComponent: CombinedComponent { )) } - let toolbar = toolbar.update( - component: ToolbarComponent( - bottomInset: environment.safeInsets.bottom, - sideInset: environment.safeInsets.left, - leftItem: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Chat/Input/Accessory Panels/MessageSelectionForward", - tintColor: .white - )), - action: { - guard let controller = controller() as? MediaStreamComponentController else { - return - } - controller.presentShare() + let toolbar = ToolbarComponent( + bottomInset: environment.safeInsets.bottom, + sideInset: environment.safeInsets.left, + leftItem: AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: "Chat/Input/Accessory Panels/MessageSelectionForward", + tintColor: .white + )), + action: { + guard let controller = controller() as? MediaStreamComponentController else { + return } - ).minSize(CGSize(width: 44.0, height: 44.0))), - rightItem: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: isLandscape ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", - tintColor: .white - )), - action: { - if let controller = controller() as? MediaStreamComponentController { - controller.updateOrientation(orientation: isLandscape ? .portrait : .landscapeRight) + controller.presentShare() + } + ).minSize(CGSize(width: 44.0, height: 44.0))), + // TODO: disable button instead of hiding + rightItem: state.hasVideo ? AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: isLandscape ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", + tintColor: .white + )), + action: { + if let controller = controller() as? MediaStreamComponentController { + guard let size = state.videoSize else { return } + state.isFullscreen.toggle() + if state.isFullscreen { + if size.width > size.height { + controller.updateOrientation(orientation: .landscapeRight) + } else { + controller.updateOrientation(orientation: .portrait) + // TODO: Update to portrait when open from landscape(?) + } + } else { + // TODO: Check and respect current device orientation + controller.updateOrientation(orientation: .portrait) } +// controller.updateOrientation(orientation: isLandscape ? .portrait : .landscapeRight) } - ).minSize(CGSize(width: 44.0, height: 44.0))), - centerItem: infoItem - ), - availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), - transition: context.transition + } + ).minSize(CGSize(width: 44.0, height: 44.0))) : nil, + centerItem: infoItem ) - let height = context.availableSize.height context.add(background .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) .gesture(.tap { [weak state] in @@ -1145,41 +1204,82 @@ public final class _MediaStreamComponent: CombinedComponent { state.updateDismissOffset(value: offset.y, interactive: true) case let .ended(velocity): // TODO: Dismiss sheet depending on velocity - if abs(velocity.y) > 200.0 { - activatePictureInPicture.invoke(Action { [weak state] in + if velocity.y > 200.0 { + let _ = call.leave(terminateIfPossible: false) + /*activatePictureInPicture.invoke(Action { [weak state] in guard let state = state, let controller = controller() as? MediaStreamComponentController else { return } state.updateDismissOffset(value: velocity.y < 0 ? -height : height, interactive: false) controller.dismiss(closing: false, manual: true) - }) + })*/ } else { state.updateDismissOffset(value: 0.0, interactive: false) } } }) ) + let videoHeight: CGFloat = context.availableSize.width / 16 * 9 + let sheetHeight: CGFloat = (50 + 70 + 20 + 80 + videoHeight) + let isFullyDragged = context.availableSize.height - sheetHeight + state.dismissOffset < 30 - let sheetHeight: CGFloat = 300 + let sheet = sheet.update( + component: StreamSheetComponent( + topComponent: AnyComponent(navigationComponent), + bottomButtonsRow: AnyComponent(toolbar), + topOffset: context.availableSize.height - sheetHeight + context.state.dismissOffset, + sheetHeight: max(sheetHeight - context.state.dismissOffset, sheetHeight), + backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor) + ), + availableSize: context.availableSize, + transition: context.transition + ) + // TODO: calculate (although not necessary currently) let sheetOffset: CGFloat = context.availableSize.height - sheetHeight + context.state.dismissOffset - // TODO: work with sheet here - context.add(sheet - .position(.init(x: context.availableSize.width / 2.0, y: sheetOffset)) - ) + let sheetPosition = sheetOffset + sheetHeight / 2 + // Sheet underneath the video when in sheet + if !isFullscreen { + // TODO: work with sheet here + context.add(sheet + .position(.init(x: context.availableSize.width / 2.0, y: /*isFullscreen ?*/ context.availableSize.height / 2)) //: sheetPosition)) + ) + } print("DismissOffset: \(context.state.dismissOffset)") + // Only modal// context.state.videoSize?.height ?? 160 + var videoPos: CGFloat = 0 + if 2 < 10 { + if debugUpdate { + videoPos = videoHeight - videoHeight / 2 * 2 + sheetPosition - sheetPosition + debugUpdate = false + } else { + videoPos = lastVideoPos + } + if isFullscreen { + videoPos = context.availableSize.height / 2 + } else { + videoPos = sheetPosition - sheetHeight / 2 + videoHeight / 2 + 50 + } + lastVideoPos = videoPos + } context.add(video - .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0 + context.state.dismissOffset)) + .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)/*sheetPosition + videoHeight / 2 + 50 - context.availableSize.height / 2*/)// context.availableSize.height / 2.0 + context.state.dismissOffset)) ) - context.add(navigationBar - .position(CGPoint(x: context.availableSize.width / 2.0, y: navigationBar.size.height / 2.0)) - .opacity(context.state.displayUI ? 1.0 : 0.0) - ) + if isFullscreen { + context.add(sheet + .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) + ) + } - context.add(toolbar - .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - toolbar.size.height / 2.0)) - .opacity(context.state.displayUI ? 1.0 : 0.0) - ) +// context.add(navigationBar +// .position(CGPoint(x: context.availableSize.width / 2.0, y: navigationBar.size.height / 2.0)) +// .opacity(context.state.displayUI ? 1.0 : 0.0) +// ) + +// context.add(toolbar +// .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - toolbar.size.height / 2.0)) +// .opacity(context.state.displayUI ? 1.0 : 0.0) +// ) return context.availableSize } @@ -1231,26 +1331,31 @@ public final class _MediaStreamComponentController: ViewControllerComponentConta view.expandFromPictureInPicture() } - if let validLayout = self.validLayout { + if let _ = self.validLayout { self.view.clipsToBounds = true - self.view.layer.cornerRadius = validLayout.deviceMetrics.screenCornerRadius +// self.view.layer.cornerRadius = validLayout.deviceMetrics.screenCornerRadius if #available(iOS 13.0, *) { self.view.layer.cornerCurve = .continuous } - self.view.layer.animatePosition(from: CGPoint(x: self.view.frame.width * 0.9, y: 117.0), to: self.view.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak self] _ in - self?.view.layer.cornerRadius = 0.0 + self.view.layer.animatePosition(from: CGPoint(x: self.view.frame.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), to: self.view.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in // [weak self] _ in +// self?.view.layer.cornerRadius = 0.0 }) - self.view.layer.animateScale(from: 0.001, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) +// self.view.layer.animateScale(from: 0.001, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) } self.view.layer.allowsGroupOpacity = true - self.view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, completion: { [weak self] _ in + self.view.layer.animateAlpha(from: 1.0, to: 1.0, duration: 0.2, completion: { [weak self] _ in guard let strongSelf = self else { return } strongSelf.view.layer.allowsGroupOpacity = false }) + self.backgroundDimView.layer.animateAlpha(from: 0, to: 1, duration: 0.3) + if backgroundDimView.superview == nil { + guard let superview = view.superview else { return } + superview.insertSubview(backgroundDimView, belowSubview: view) + } // self.view.backgroundColor = .cyan } @@ -1267,13 +1372,32 @@ public final class _MediaStreamComponentController: ViewControllerComponentConta } } + override public func viewDidLoad() { + super.viewDidLoad() +// view.insertSubview(backgroundDimView, at: 0) + // TODO: replace with actual color + backgroundDimView.backgroundColor = .black.withAlphaComponent(0.3) + self.view.clipsToBounds = false + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + } + + override public func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + backgroundDimView.frame = .init(x: 0, y: -view.bounds.height * 3, width: view.bounds.width, height: view.bounds.height * 4) + } + public func dismiss(closing: Bool, manual: Bool) { self.dismiss(completion: nil) } + let backgroundDimView = UIView() + override public func dismiss(completion: (() -> Void)? = nil) { self.view.layer.allowsGroupOpacity = true - self.view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false, completion: { [weak self] _ in + self.view.layer.animateAlpha(from: 1.0, to: 1.0, duration: 0.25, removeOnCompletion: false, completion: { [weak self] _ in guard let strongSelf = self else { completion?() return @@ -1281,18 +1405,18 @@ public final class _MediaStreamComponentController: ViewControllerComponentConta strongSelf.view.layer.allowsGroupOpacity = false strongSelf.dismissImpl(completion: completion) }) - - if let validLayout = self.validLayout { - self.view.clipsToBounds = true - self.view.layer.cornerRadius = validLayout.deviceMetrics.screenCornerRadius - if #available(iOS 13.0, *) { - self.view.layer.cornerCurve = .continuous - } + self.backgroundDimView.layer.animateAlpha(from: 1.0, to: 0, duration: 0.3, removeOnCompletion: false) + // if let validLayout = self.validLayout { + // self.view.clipsToBounds = true + // self.view.layer.cornerRadius = validLayout.deviceMetrics.screenCornerRadius + // if #available(iOS 13.0, *) { + // self.view.layer.cornerCurve = .continuous + // } - self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.frame.width * 0.9, y: 117.0), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in - }) - self.view.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) - } + self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in + }) + // self.view.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + // } } private func dismissImpl(completion: (() -> Void)? = nil) { diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 23843689eb..ace32beefd 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -19,17 +19,22 @@ final class _MediaStreamVideoComponent: Component { let deactivatePictureInPicture: ActionSlot let bringBackControllerForPictureInPictureDeactivation: (@escaping () -> Void) -> Void let pictureInPictureClosed: () -> Void - + let peerImage: Any? + let isFullscreen: Bool + let onVideoSizeRetrieved: (CGSize) -> Void init( call: PresentationGroupCallImpl, hasVideo: Bool, isVisible: Bool, isAdmin: Bool, peerTitle: String, + peerImage: Any?, + isFullscreen: Bool, activatePictureInPicture: ActionSlot>, deactivatePictureInPicture: ActionSlot, bringBackControllerForPictureInPictureDeactivation: @escaping (@escaping () -> Void) -> Void, - pictureInPictureClosed: @escaping () -> Void + pictureInPictureClosed: @escaping () -> Void, + onVideoSizeRetrieved: @escaping (CGSize) -> Void ) { self.call = call self.hasVideo = hasVideo @@ -40,6 +45,10 @@ final class _MediaStreamVideoComponent: Component { self.deactivatePictureInPicture = deactivatePictureInPicture self.bringBackControllerForPictureInPictureDeactivation = bringBackControllerForPictureInPictureDeactivation self.pictureInPictureClosed = pictureInPictureClosed + + self.peerImage = peerImage + self.isFullscreen = isFullscreen + self.onVideoSizeRetrieved = onVideoSizeRetrieved } public static func ==(lhs: _MediaStreamVideoComponent, rhs: _MediaStreamVideoComponent) -> Bool { @@ -81,6 +90,7 @@ final class _MediaStreamVideoComponent: Component { private var videoBlurView: VideoRenderingView? private var videoView: VideoRenderingView? private var activityIndicatorView: ComponentHostView? + private var videoPlaceholderView: UIView? private var noSignalView: ComponentHostView? private var pictureInPictureController: AVPictureInPictureController? @@ -124,12 +134,8 @@ final class _MediaStreamVideoComponent: Component { self.pictureInPictureController?.stopPictureInPicture() } } - -// let sheetView = UIView() -// let sheetBackdropView = UIView() -// var sheetTop: CGFloat = 0 -// var sheetHeight: CGFloat = 0 - + let maskGradientLayer = CAGradientLayer() + private var wasVisible = true func update(component: _MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { self.state = state @@ -141,13 +147,21 @@ final class _MediaStreamVideoComponent: Component { if let videoBlurView = self.videoRenderingContext.makeView(input: input, blur: true) { self.videoBlurView = videoBlurView self.insertSubview(videoBlurView, belowSubview: self.blurTintView) + + self.maskGradientLayer.type = .radial + self.maskGradientLayer.colors = [UIColor(rgb: 0x000000, alpha: 0.5).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor] + self.maskGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5) + self.maskGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0) +// self.maskGradientLayer.transform = CATransform3DMakeScale(0.3, 0.3, 1.0) +// self.maskGradientLayer.isHidden = true + } if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) { self.videoView = videoView self.addSubview(videoView) - + videoView.alpha = 1 if let sampleBufferVideoView = videoView as? SampleBufferVideoRenderingView { if #available(iOS 13.0, *) { sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true @@ -167,6 +181,7 @@ final class _MediaStreamVideoComponent: Component { } func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) { + print("pip finished") } func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) { @@ -227,9 +242,42 @@ final class _MediaStreamVideoComponent: Component { } // sheetView.frame = .init(x: 0, y: sheetTop, width: availableSize.width, height: sheetHeight) + // var aspect = videoView.getAspect() +// if aspect <= 0.01 { + // let aspect = !component.isFullscreen ? 16.0 / 9.0 : // 3.0 / 4.0 +// } + + let videoInset: CGFloat + if !component.isFullscreen { + videoInset = 16 + } else { + videoInset = 0 + } if let videoView = self.videoView { + var aspect = videoView.getAspect() + // saveAspect(aspect) + if component.isFullscreen { + if aspect <= 0.01 { + aspect = 3.0 / 4.0 + } + } else { + aspect = 16.0 / 9 + } + + let videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) + let blurredVideoSize = videoSize.aspectFilled(availableSize) + + component.onVideoSizeRetrieved(videoSize) + var isVideoVisible = component.isVisible + + if !wasVisible && component.isVisible { + videoView.layer.animateAlpha(from: 0, to: 1, duration: 0.2) + } else if wasVisible && !component.isVisible { + videoView.layer.animateAlpha(from: 1, to: 0, duration: 0.2) + } + if let pictureInPictureController = self.pictureInPictureController { if pictureInPictureController.isPictureInPictureActive { isVideoVisible = true @@ -237,25 +285,40 @@ final class _MediaStreamVideoComponent: Component { } videoView.updateIsEnabled(isVideoVisible) + videoView.clipsToBounds = true + videoView.layer.cornerRadius = component.isFullscreen ? 0 : 10 + // var aspect = videoView.getAspect() +// if aspect <= 0.01 { - var aspect = videoView.getAspect() - if aspect <= 0.01 { - aspect = 3.0 / 4.0 - } - - let videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(availableSize) - let blurredVideoSize = videoSize.aspectFilled(availableSize) transition.withAnimation(.none).setFrame(view: videoView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize), completion: nil) if let videoBlurView = self.videoBlurView { videoBlurView.updateIsEnabled(component.isVisible) +// videoBlurView.isHidden = component.isFullscreen + if component.isFullscreen { + transition.withAnimation(.none).setFrame(view: videoBlurView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - blurredVideoSize.width) / 2.0), y: floor((availableSize.height - blurredVideoSize.height) / 2.0)), size: blurredVideoSize), completion: nil) + } else { + videoBlurView.frame = videoView.frame.insetBy(dx: -69 * aspect, dy: -69) + } - transition.withAnimation(.none).setFrame(view: videoBlurView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - blurredVideoSize.width) / 2.0), y: floor((availableSize.height - blurredVideoSize.height) / 2.0)), size: blurredVideoSize), completion: nil) + if !component.isFullscreen { + videoBlurView.layer.mask = maskGradientLayer + } else { + videoBlurView.layer.mask = nil + } + + self.maskGradientLayer.frame = videoBlurView.bounds// CGRect(x: videoBlurView.bounds.midX, y: videoBlurView.bounds.midY, width: videoBlurView.bounds.width, height: videoBlurView.bounds.height) } } if !self.hadVideo { + // TODO: hide fullscreen button without video + let aspect: CGFloat = 16.0 / 9 + let videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) + // loadingpreview.frame = .init(, videoSize) + print(videoSize) + // TODO: remove activity indicator var activityIndicatorTransition = transition let activityIndicatorView: ComponentHostView if let current = self.activityIndicatorView { diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift new file mode 100644 index 0000000000..4e369283c7 --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -0,0 +1,286 @@ +import Foundation +import UIKit +import ComponentFlow +import ActivityIndicatorComponent +import AccountContext +import AVKit +import MultilineTextComponent +import Display + +final class StreamSheetComponent: CombinedComponent { +// let color: UIColor +// let leftItem: AnyComponent? + let topComponent: AnyComponent? +// let viewerCounter: AnyComponent? + let bottomButtonsRow: AnyComponent? + // TODO: sync + let sheetHeight: CGFloat + let topOffset: CGFloat + let backgroundColor: UIColor + init( +// color: UIColor, + topComponent: AnyComponent, + bottomButtonsRow: AnyComponent, + topOffset: CGFloat, + sheetHeight: CGFloat, + backgroundColor: UIColor + ) { +// self.leftItem = leftItem + self.topComponent = topComponent +// self.viewerCounter = AnyComponent(ViewerCountComponent(count: 0)) + self.bottomButtonsRow = bottomButtonsRow + self.topOffset = topOffset + self.sheetHeight = sheetHeight + self.backgroundColor = backgroundColor + } + + static func ==(lhs: StreamSheetComponent, rhs: StreamSheetComponent) -> Bool { + if lhs.topComponent != rhs.topComponent { + return false + } + if lhs.bottomButtonsRow != rhs.bottomButtonsRow { + return false + } + if lhs.topOffset != rhs.topOffset { + return false + } + if lhs.backgroundColor != rhs.backgroundColor { + return false + } + if lhs.sheetHeight != rhs.sheetHeight { + return false + } + + return true + } +// + final class View: UIView { + var overlayComponentsFrames = [CGRect]() + + override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + for subframe in overlayComponentsFrames { + if subframe.contains(point) { return true } + } + return false + } + + func update(component: StreamSheetComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { + self.backgroundColor = .purple.withAlphaComponent(0.6) + return availableSize + } + + override func draw(_ rect: CGRect) { + super.draw(rect) + +// guard let context = UIGraphicsGetCurrentContext() else { return } +// context.setFillColor(UIColor.red.cgColor) +// overlayComponentsFrames.forEach { frame in +// context.addRect(frame) +// context.fillPath() +// } + } + } + + func makeView() -> View { + View() + } + + public final class State: ComponentState { + override init() { + super.init() + } + } + + public func makeState() -> State { + return State() + } + + private weak var state: State? +// func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { +// view.isUserInteractionEnabled = false +// return availableSize +// } + /*public func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, state: state, transition: transition) + }*/ + + static var body: Body { + let background = Child(SheetBackgroundComponent.self) +// let leftItem = Child(environment: Empty.self) + let topItem = Child(environment: Empty.self) +// let viewerCounter = Child(environment: Empty.self) + let bottomButtonsRow = Child(environment: Empty.self) +// let bottomButtons = Child(environment: Empty.self) +// let rightItems = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) +// let centerItem = Child(environment: Empty.self) + + return { context in + let availableWidth = context.availableSize.width +// let sideInset: CGFloat = 16.0 + context.component.sideInset + + let contentHeight: CGFloat = 44.0 + let size = context.availableSize// CGSize(width: context.availableSize.width, height:44)// context.component.topInset + contentHeight) + + let background = background.update(component: SheetBackgroundComponent(color: context.component.backgroundColor), availableSize: CGSize(width: size.width, height: context.component.sheetHeight), transition: context.transition) + + let topItem = context.component.topComponent.flatMap { topItemComponent in + return topItem.update( + component: topItemComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + } + +// let viewerCounter = context.component.viewerCounter.flatMap { viewerCounterComponent in +// return viewerCounter.update( +// component: viewerCounterComponent, +// availableSize: context.availableSize, +// transition: context.transition +// ) +// } + + let bottomButtonsRow = context.component.bottomButtonsRow.flatMap { bottomButtonsRowComponent in + return bottomButtonsRow.update( + component: bottomButtonsRowComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + } + + let topOffset = context.component.topOffset + + context.add(background + .position(CGPoint(x: size.width / 2.0, y: context.component.topOffset + context.component.sheetHeight / 2)) + ) + + (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames = [] + context.view.backgroundColor = .clear + if let topItem = topItem { + context.add(topItem + .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + contentHeight / 2.0)) + ) + (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: topOffset, width: topItem.size.width, height: topItem.size.height)) + } + +// if let viewerCounter = viewerCounter { +// let videoHeight = availableWidth / 2 +// let topRowHeight: CGFloat = 50 +// context.add(viewerCounter +// .position(CGPoint(x: viewerCounter.size.width / 2, y: topRowHeight + videoHeight + 32)) +// ) +// } + + if let bottomButtonsRow = bottomButtonsRow { + context.add(bottomButtonsRow + .position(CGPoint(x: bottomButtonsRow.size.width / 2, y: context.component.sheetHeight - 50 / 2 - 16 + topOffset)) + ) + (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: context.component.sheetHeight - 50 - 16 + topOffset, width: bottomButtonsRow.size.width, height: bottomButtonsRow.size.height)) + } + /*if let leftItem = leftItem { + print(leftItem) + context.add(leftItem + .position(CGPoint(x: leftItem.size.width / 2.0, y: contentHeight / 2.0)) + ) + (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames = [ + .init(x: 0, y: 0, width: leftItem.size.width, height: leftItem.size.height) + ] + }*/ + + return size + } + } +} + +import TelegramPresentationData +import TelegramStringFormatting + +private let purple = UIColor(rgb: 0x3252ef) +private let pink = UIColor(rgb: 0xef436c) + +private let latePurple = UIColor(rgb: 0x974aa9) +private let latePink = UIColor(rgb: 0xf0436c) + +final class ViewerCountComponent: Component { + private let count: Int + +// private let counterView: VoiceChatTimerNode + + static func ==(lhs: ViewerCountComponent, rhs: ViewerCountComponent) -> Bool { + if lhs.count != rhs.count { + return false + } + return true + } + + init(count: Int) { + self.count = count + } + + public func update(view: UIView, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + + /*self.foregroundView.frame = CGRect(origin: CGPoint(), size: size) + self.foregroundGradientLayer.frame = self.foregroundView.bounds + self.maskView.frame = self.foregroundView.bounds + + let text: String = presentationStringsFormattedNumber(participants, groupingSeparator) + let subtitle = "listening" + + self.titleNode.attributedText = NSAttributedString(string: "", font: Font.with(size: 23.0, design: .round, weight: .semibold, traits: []), textColor: .white) + let titleSize = self.titleNode.updateLayout(size) + self.titleNode.frame = CGRect(x: floor((size.width - titleSize.width) / 2.0), y: 48.0, width: titleSize.width, height: titleSize.height) + + self.timerNode.attributedText = NSAttributedString(string: text, font: Font.with(size: 68.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) + + var timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) + if timerSize.width > size.width - 32.0 { + self.timerNode.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) + timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) + } + + self.timerNode.frame = CGRect(x: floor((size.width - timerSize.width) / 2.0), y: 78.0, width: timerSize.width, height: timerSize.height) + + self.subtitleNode.attributedText = NSAttributedString(string: subtitle, font: Font.with(size: 21.0, design: .round, weight: .semibold, traits: []), textColor: .white) + let subtitleSize = self.subtitleNode.updateLayout(size) + self.subtitleNode.frame = CGRect(x: floor((size.width - subtitleSize.width) / 2.0), y: 164.0, width: subtitleSize.width, height: subtitleSize.height) + + self.foregroundView.frame = CGRect(origin: CGPoint(), size: size) + */ + return availableSize + } +} + +final class SheetBackgroundComponent: Component { + private let color: UIColor + private let backgroundView = UIView() + + static func ==(lhs: SheetBackgroundComponent, rhs: SheetBackgroundComponent) -> Bool { + if !lhs.color.isEqual(rhs.color) { + return false + } +// if lhs.width != rhs.width { +// return false +// } +// if lhs.height != rhs.height { +// return false +// } + return true + } + + public init(color: UIColor) { + self.color = color + } + + public func update(view: UIView, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + if backgroundView.superview == nil { + view.addSubview(backgroundView) + } + backgroundView.frame = .init(origin: .zero, size: availableSize) + backgroundView.backgroundColor = self.color// .withAlphaComponent(0.4) + backgroundView.isUserInteractionEnabled = false + backgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + backgroundView.layer.cornerRadius = 16 + backgroundView.clipsToBounds = true + backgroundView.layer.masksToBounds = true + return availableSize + } +} diff --git a/submodules/TelegramCallsUI/Sources/VoiceChatController.swift b/submodules/TelegramCallsUI/Sources/VoiceChatController.swift index 83cfe1a95d..284fd6ee37 100644 --- a/submodules/TelegramCallsUI/Sources/VoiceChatController.swift +++ b/submodules/TelegramCallsUI/Sources/VoiceChatController.swift @@ -36,12 +36,12 @@ import DeviceAccess let panelBackgroundColor = UIColor(rgb: 0x1c1c1e) let secondaryPanelBackgroundColor = UIColor(rgb: 0x2c2c2e) let fullscreenBackgroundColor = UIColor(rgb: 0x000000) -private let smallButtonSize = CGSize(width: 36.0, height: 36.0) -private let sideButtonSize = CGSize(width: 56.0, height: 56.0) -private let topPanelHeight: CGFloat = 63.0 +let smallButtonSize = CGSize(width: 36.0, height: 36.0) +let sideButtonSize = CGSize(width: 56.0, height: 56.0) +let topPanelHeight: CGFloat = 63.0 let bottomAreaHeight: CGFloat = 206.0 -private let fullscreenBottomAreaHeight: CGFloat = 80.0 -private let bottomGradientHeight: CGFloat = 70.0 +let fullscreenBottomAreaHeight: CGFloat = 80.0 +let bottomGradientHeight: CGFloat = 70.0 func decorationCornersImage(top: Bool, bottom: Bool, dark: Bool) -> UIImage? { if !top && !bottom { diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 2e787f91bf..19544e5667 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -665,7 +665,8 @@ public final class SharedAccountContextImpl: SharedAccountContext { if call.isStream { strongSelf.hasGroupCallOnScreenPromise.set(true) - let groupCallController = MediaStreamingControllerImpl(sharedContext: strongSelf, accountContext: call.accountContext, call: call) // MediaStreamComponentController(call: call) + // TODO: remove sharedContext and accountContext from init + let groupCallController = _MediaStreamComponentController(sharedContext: strongSelf, accountContext: call.accountContext, call: call) // MediaStreamComponentController(call: call)ue groupCallController.onViewDidAppear = { [weak self] in if let strongSelf = self { strongSelf.hasGroupCallOnScreenPromise.set(true) From fe2244a413346e2fca9d174c64f21e20fe9ec429 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Sat, 26 Nov 2022 16:10:24 +0400 Subject: [PATCH 04/50] Fixing fullscreen presentation --- .../Components/MediaStreamComponent.swift | 17 +++++--- .../MediaStreamVideoComponent.swift | 4 ++ .../Components/StreamSheetComponent.swift | 41 +++++++++++++------ 3 files changed, 44 insertions(+), 18 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index fb8ed57d3e..08902fc539 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -786,7 +786,7 @@ public final class _MediaStreamComponent: CombinedComponent { } let background = background.update( - component: Rectangle(color: .black.withAlphaComponent(0.12)), + component: Rectangle(color: .black.withAlphaComponent(0.0)), availableSize: context.availableSize, transition: context.transition ) @@ -809,7 +809,15 @@ public final class _MediaStreamComponent: CombinedComponent { state.updated(transition: .easeInOut(duration: 3)) deactivatePictureInPicture.invoke(Void()) } - let isFullscreen = state.isFullscreen + var isFullscreen = state.isFullscreen + let isLandscape = context.availableSize.width > context.availableSize.height + if let videoSize = context.state.videoSize { + if videoSize.width > videoSize.height && isLandscape && !isFullscreen { + state.isFullscreen = true + isFullscreen = true + } + } + let video = video.update( component: MediaStreamVideoComponent( call: context.component.call, @@ -1118,7 +1126,6 @@ public final class _MediaStreamComponent: CombinedComponent { // transition: context.transition // ) - let isLandscape = context.availableSize.width > context.availableSize.height if context.state.storedIsLandscape != isLandscape { context.state.storedIsLandscape = isLandscape if isLandscape { @@ -1160,7 +1167,7 @@ public final class _MediaStreamComponent: CombinedComponent { // TODO: disable button instead of hiding rightItem: state.hasVideo ? AnyComponent(Button( content: AnyComponent(BundleIconComponent( - name: isLandscape ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", + name: isFullscreen ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", tintColor: .white )), action: { @@ -1220,7 +1227,7 @@ public final class _MediaStreamComponent: CombinedComponent { }) ) let videoHeight: CGFloat = context.availableSize.width / 16 * 9 - let sheetHeight: CGFloat = (50 + 70 + 20 + 80 + videoHeight) + let sheetHeight: CGFloat = isFullscreen ? context.availableSize.height : (50 + 70 + 20 + 80 + videoHeight) let isFullyDragged = context.availableSize.height - sheetHeight + state.dismissOffset < 30 let sheet = sheet.update( diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index ace32beefd..1707793c82 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -68,6 +68,10 @@ final class _MediaStreamVideoComponent: Component { return false } + if lhs.isFullscreen != rhs.isFullscreen { + return false + } + return true } diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 4e369283c7..1859932ba3 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -50,7 +50,9 @@ final class StreamSheetComponent: CombinedComponent { if lhs.sheetHeight != rhs.sheetHeight { return false } - + if !lhs.backgroundColor.isEqual(rhs.backgroundColor) { + return false + } return true } // @@ -251,7 +253,29 @@ final class ViewerCountComponent: Component { final class SheetBackgroundComponent: Component { private let color: UIColor - private let backgroundView = UIView() + + class View: UIView { + private let backgroundView = UIView() + + func update(availableSize: CGSize, color: UIColor) { + if backgroundView.superview == nil { + self.addSubview(backgroundView) + } + // To fix release animation + let extraBottom: CGFloat = 500 + backgroundView.frame = .init(origin: .zero, size: .init(width: availableSize.width, height: availableSize.height + extraBottom)) + backgroundView.backgroundColor = color// .withAlphaComponent(0.4) + backgroundView.isUserInteractionEnabled = false + backgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + backgroundView.layer.cornerRadius = 16 + backgroundView.clipsToBounds = true + backgroundView.layer.masksToBounds = true + } + } + + func makeView() -> View { + View() + } static func ==(lhs: SheetBackgroundComponent, rhs: SheetBackgroundComponent) -> Bool { if !lhs.color.isEqual(rhs.color) { @@ -270,17 +294,8 @@ final class SheetBackgroundComponent: Component { self.color = color } - public func update(view: UIView, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - if backgroundView.superview == nil { - view.addSubview(backgroundView) - } - backgroundView.frame = .init(origin: .zero, size: availableSize) - backgroundView.backgroundColor = self.color// .withAlphaComponent(0.4) - backgroundView.isUserInteractionEnabled = false - backgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - backgroundView.layer.cornerRadius = 16 - backgroundView.clipsToBounds = true - backgroundView.layer.masksToBounds = true + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + view.update(availableSize: availableSize, color: color) return availableSize } } From cbfe481a3865ff324612cd539c1801a97a095851 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Sat, 26 Nov 2022 23:10:23 +0400 Subject: [PATCH 05/50] Make buttons nice again --- .../Components/MediaStreamComponent.swift | 490 ++++++++++++++---- .../MediaStreamVideoComponent.swift | 41 +- .../Components/StreamSheetComponent.swift | 25 +- 3 files changed, 453 insertions(+), 103 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 08902fc539..e1b6734e36 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -366,20 +366,20 @@ private final class NavigationBarComponent: CombinedComponent { centerLeftInset += leftItem.size.width + 4.0 } - var centerRightInset = sideInset +// var centerRightInset = sideInset var rightItemX = context.availableSize.width - sideInset for item in rightItemList.reversed() { context.add(item .position(CGPoint(x: rightItemX - item.size.width / 2.0, y: context.component.topInset + contentHeight / 2.0)) ) rightItemX -= item.size.width + 8.0 - centerRightInset += item.size.width + 8.0 +// centerRightInset += item.size.width + 8.0 } - let maxCenterInset = max(centerLeftInset, centerRightInset) +// let maxCenterInset = max(centerLeftInset, centerRightInset) if let centerItem = centerItem { context.add(centerItem - .position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: context.component.topInset + contentHeight / 2.0)) + .position(CGPoint(x: context.availableSize.width / 2 /*maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0*/, y: context.component.topInset + contentHeight / 2.0)) ) } @@ -569,6 +569,194 @@ private final class ToolbarComponent: CombinedComponent { } } +private final class ButtonsRowComponent: CombinedComponent { + let bottomInset: CGFloat + let sideInset: CGFloat + let leftItem: AnyComponent? + let rightItem: AnyComponent? + let centerItem: AnyComponent? + + init( + bottomInset: CGFloat, + sideInset: CGFloat, + leftItem: AnyComponent?, + rightItem: AnyComponent?, + centerItem: AnyComponent? + ) { + self.bottomInset = bottomInset + self.sideInset = sideInset + self.leftItem = leftItem + self.rightItem = rightItem + self.centerItem = centerItem + } + + static func ==(lhs: ButtonsRowComponent, rhs: ButtonsRowComponent) -> Bool { + if lhs.bottomInset != rhs.bottomInset { + return false + } + if lhs.sideInset != rhs.sideInset { + return false + } + if lhs.leftItem != rhs.leftItem { + return false + } + if lhs.rightItem != rhs.rightItem { + return false + } + if lhs.centerItem != rhs.centerItem { + return false + } + + return true + } + + static var body: Body { + let leftItem = Child(environment: Empty.self) + let rightItem = Child(environment: Empty.self) + let centerItem = Child(environment: Empty.self) + + return { context in + var availableWidth = context.availableSize.width + let sideInset: CGFloat = 40 + context.component.sideInset + + let contentHeight: CGFloat = 80 // 44 + let size = CGSize(width: context.availableSize.width, height: contentHeight + context.component.bottomInset) + + let leftItem = context.component.leftItem.flatMap { leftItemComponent in + return leftItem.update( + component: leftItemComponent, + availableSize: CGSize(width: 50, height: contentHeight), + transition: context.transition + ) + } + if let leftItem = leftItem { + availableWidth -= leftItem.size.width + } + + let rightItem = context.component.rightItem.flatMap { rightItemComponent in + return rightItem.update( + component: rightItemComponent, + availableSize: CGSize(width: 50, height: contentHeight), + transition: context.transition + ) + } + if let rightItem = rightItem { + availableWidth -= rightItem.size.width + } + + let centerItem = context.component.centerItem.flatMap { centerItemComponent in + return centerItem.update( + component: centerItemComponent, + availableSize: CGSize(width: 50, height: contentHeight), + transition: context.transition + ) + } + if let centerItem = centerItem { + availableWidth -= centerItem.size.width + } + + var centerLeftInset = sideInset + if let leftItem = leftItem { + context.add(leftItem + .position(CGPoint(x: sideInset + leftItem.size.width / 2.0, y: contentHeight / 2.0)) + ) + centerLeftInset += leftItem.size.width + 4.0 + } + + var centerRightInset = sideInset + if let rightItem = rightItem { + context.add(rightItem + .position(CGPoint(x: context.availableSize.width - sideInset - rightItem.size.width / 2.0, y: contentHeight / 2.0)) + ) + centerRightInset += rightItem.size.width + 4.0 + } + + let maxCenterInset = max(centerLeftInset, centerRightInset) + if let centerItem = centerItem { + context.add(centerItem + .position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: contentHeight / 2.0)) + ) + } + + return size + } + } +} + +final class RoundGradientButtonComponent: Component { + init(gradientColors: [CGColor], icon: String? = nil, image: UIImage? = nil, title: String) { + self.gradientColors = gradientColors + self.icon = icon + self.image = image + self.title = title + } + + static func == (lhs: RoundGradientButtonComponent, rhs: RoundGradientButtonComponent) -> Bool { + if lhs.icon != rhs.icon { + return false + } + if lhs.gradientColors != rhs.gradientColors { + return false + } + return true + } + + let gradientColors: [CGColor] + let icon: String? + let image: UIImage? + let title: String + + final class View: UIView { + let gradientLayer = CAGradientLayer() + let iconView = UIImageView() + let titleLabel = UILabel() + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + + gradientLayer.type = .radial + gradientLayer.startPoint = .init(x: 1, y: 1) + gradientLayer.endPoint = .init(x: 0, y: 0) + + self.layer.addSublayer(gradientLayer) + self.addSubview(iconView) + self.clipsToBounds = false + + self.addSubview(titleLabel) + titleLabel.textAlignment = .center + iconView.contentMode = .scaleAspectFit + titleLabel.font = .systemFont(ofSize: 13) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + titleLabel.invalidateIntrinsicContentSize() + let heightForIcon = bounds.height - max(titleLabel.intrinsicContentSize.height, 12) - 6 + iconView.frame = .init(x: bounds.midX - heightForIcon / 2, y: 0, width: heightForIcon, height: heightForIcon) + gradientLayer.masksToBounds = true + gradientLayer.cornerRadius = min(iconView.frame.width, iconView.frame.height) / 2 + gradientLayer.frame = iconView.frame + titleLabel.frame = .init(x: 0, y: bounds.height - titleLabel.intrinsicContentSize.height, width: bounds.width, height: titleLabel.intrinsicContentSize.height) + } + } + + func makeView() -> View { + View() + } + + func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + view.iconView.image = image ?? icon.flatMap { UIImage(bundleImageName: $0) } + view.gradientLayer.colors = gradientColors + view.titleLabel.text = title + view.setNeedsLayout() + return availableSize + } +} + public final class _MediaStreamComponent: CombinedComponent { struct OriginInfo: Equatable { var title: String @@ -770,14 +958,13 @@ public final class _MediaStreamComponent: CombinedComponent { // let toolbar = Child(ToolbarComponent.self) let sheet = Child(StreamSheetComponent.self) + let fullscreenOverlay = Child(StreamSheetComponent.self) let activatePictureInPicture = StoredActionSlot(Action.self) let deactivatePictureInPicture = StoredActionSlot(Void.self) let moreButtonTag = GenericComponentViewTag() let moreAnimationTag = GenericComponentViewTag() - var debugUpdate = true - var lastVideoPos: CGFloat = 0 return { context in let environment = context.environment[ViewControllerComponentContainer.Environment.self].value if environment.isVisible { @@ -1149,48 +1336,10 @@ public final class _MediaStreamComponent: CombinedComponent { )) } - let toolbar = ToolbarComponent( - bottomInset: environment.safeInsets.bottom, - sideInset: environment.safeInsets.left, - leftItem: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Chat/Input/Accessory Panels/MessageSelectionForward", - tintColor: .white - )), - action: { - guard let controller = controller() as? MediaStreamComponentController else { - return - } - controller.presentShare() - } - ).minSize(CGSize(width: 44.0, height: 44.0))), - // TODO: disable button instead of hiding - rightItem: state.hasVideo ? AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: isFullscreen ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", - tintColor: .white - )), - action: { - if let controller = controller() as? MediaStreamComponentController { - guard let size = state.videoSize else { return } - state.isFullscreen.toggle() - if state.isFullscreen { - if size.width > size.height { - controller.updateOrientation(orientation: .landscapeRight) - } else { - controller.updateOrientation(orientation: .portrait) - // TODO: Update to portrait when open from landscape(?) - } - } else { - // TODO: Check and respect current device orientation - controller.updateOrientation(orientation: .portrait) - } -// controller.updateOrientation(orientation: isLandscape ? .portrait : .landscapeRight) - } - } - ).minSize(CGSize(width: 44.0, height: 44.0))) : nil, - centerItem: infoItem - ) + let videoHeight: CGFloat = context.availableSize.width / 16 * 9 + let bottomPadding = 40 + environment.safeInsets.bottom + let sheetHeight: CGFloat = isFullscreen ? context.availableSize.height : (44 + videoHeight + 40 + 69 + 32 + 70 + bottomPadding) + let isFullyDragged = context.availableSize.height - sheetHeight + state.dismissOffset < 30 context.add(background .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) @@ -1212,7 +1361,15 @@ public final class _MediaStreamComponent: CombinedComponent { case let .ended(velocity): // TODO: Dismiss sheet depending on velocity if velocity.y > 200.0 { - let _ = call.leave(terminateIfPossible: false) + if state.isFullscreen { + state.isFullscreen = false + state.updateDismissOffset(value: 0.0, interactive: false) + if let controller = controller() as? MediaStreamComponentController { + controller.updateOrientation(orientation: .portrait) + } + } else { + let _ = call.leave(terminateIfPossible: false) + } /*activatePictureInPicture.invoke(Action { [weak state] in guard let state = state, let controller = controller() as? MediaStreamComponentController else { return @@ -1226,54 +1383,211 @@ public final class _MediaStreamComponent: CombinedComponent { } }) ) - let videoHeight: CGFloat = context.availableSize.width / 16 * 9 - let sheetHeight: CGFloat = isFullscreen ? context.availableSize.height : (50 + 70 + 20 + 80 + videoHeight) - let isFullyDragged = context.availableSize.height - sheetHeight + state.dismissOffset < 30 +// var bottomComponent: AnyComponent? +// var fullScreenToolbarComponent: AnyComponent? - let sheet = sheet.update( - component: StreamSheetComponent( - topComponent: AnyComponent(navigationComponent), - bottomButtonsRow: AnyComponent(toolbar), - topOffset: context.availableSize.height - sheetHeight + context.state.dismissOffset, - sheetHeight: max(sheetHeight - context.state.dismissOffset, sheetHeight), - backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor) - ), - availableSize: context.availableSize, - transition: context.transition - ) - // TODO: calculate (although not necessary currently) - let sheetOffset: CGFloat = context.availableSize.height - sheetHeight + context.state.dismissOffset - let sheetPosition = sheetOffset + sheetHeight / 2 - // Sheet underneath the video when in sheet if !isFullscreen { - // TODO: work with sheet here - context.add(sheet - .position(.init(x: context.availableSize.width / 2.0, y: /*isFullscreen ?*/ context.availableSize.height / 2)) //: sheetPosition)) + let bottomComponent = AnyComponent(ButtonsRowComponent( + bottomInset: environment.safeInsets.bottom, + sideInset: environment.safeInsets.left, + leftItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent(// BundleIconComponent( + gradientColors: [UIColor(red: 0.18, green: 0.17, blue: 0.30, alpha: 1).cgColor, UIColor(red: 0.17, green: 0.16, blue: 0.30, alpha: 1).cgColor], + image: generateTintedImage(image: UIImage(bundleImageName: "Call/CallShareButton"), color: .white),// "Chat/Input/Accessory Panels/MessageSelectionForward" + // TODO: localize: + title: "share")), + action: { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.presentShare() + } + ).minSize(CGSize(width: 65, height: 80))), + // TODO: disable button instead of hiding + rightItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent(// BundleIconComponent( + gradientColors: [UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor, UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor], + image: generateImage(CGSize(width: 44.0, height: 44), opaque: false, rotatedContext: { size, context in + context.translateBy(x: size.width / 2, y: size.height / 2) + context.scaleBy(x: 0.4, y: 0.4) + context.translateBy(x: -size.width / 2, y: -size.height / 2) +// context.translateBy(x: size.width * 0.66, y: size.height * 0.66) + let imageColor = UIColor.white + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + let lineWidth: CGFloat = size.width / 6 + context.setLineWidth(lineWidth - UIScreenPixel) + context.setLineCap(.round) + context.setStrokeColor(imageColor.cgColor) + + context.move(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: lineWidth / 2 + UIScreenPixel)) + context.addLine(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) + context.strokePath() + + context.move(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: lineWidth / 2 + UIScreenPixel)) + context.addLine(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) + context.strokePath() + }), + title: + // TODO: localize + "leave" + )), + action: { [weak call] in + let _ = call?.leave(terminateIfPossible: false) + } + ).minSize(CGSize(width: 44.0, height: 44.0))), + centerItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent( + gradientColors: [UIColor(red: 0.23, green: 0.17, blue: 0.29, alpha: 1).cgColor, UIColor(red: 0.21, green: 0.16, blue: 0.29, alpha: 1).cgColor], + image: generateImage(CGSize(width: 44, height: 44), opaque: false, rotatedContext: { size, context in + + let imageColor = UIColor.white + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + + context.setLineWidth(2.7 - UIScreenPixel) + context.setLineCap(.round) + context.setStrokeColor(imageColor.cgColor) +// context.setLineJoin(.round) + + let lineSide = size.width / 5 + let centerOffset = size.width / 16 + context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - centerOffset)) + context.addLine(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 + centerOffset, y: size.height / 2 - lineSide)) + context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 + centerOffset, y: size.height / 2 - centerOffset)) + context.strokePath() + + context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + centerOffset)) + context.addLine(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 - centerOffset, y: size.height / 2 + lineSide)) + context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 - centerOffset, y: size.height / 2 + centerOffset)) + context.strokePath() + //context.move(to: CGPoint(x: 26.0 - UIScreenPixel, y: 2.0 + UIScreenPixel)) + //context.addLine(to: CGPoint(x: 2.0 + UIScreenPixel, y: 26.0 - UIScreenPixel)) +// context.strokePath() + }), + title: "expand" + )), + action: { + guard state.hasVideo else { return } + if let controller = controller() as? MediaStreamComponentController { + guard let size = state.videoSize else { return } + state.isFullscreen.toggle() + if state.isFullscreen { + if size.width > size.height { + controller.updateOrientation(orientation: .landscapeRight) + } else { + controller.updateOrientation(orientation: .portrait) + // TODO: Update to portrait when open from landscape(?) + } + } else { + // TODO: Check and respect current device orientation + controller.updateOrientation(orientation: .portrait) + } + // controller.updateOrientation(orientation: isLandscape ? .portrait : .landscapeRight) + } + } + ).minSize(CGSize(width: 44.0, height: 44.0))) + )) + + let sheet = sheet.update( + component: StreamSheetComponent( + topComponent: AnyComponent(navigationComponent), + bottomButtonsRow: bottomComponent, + topOffset: context.availableSize.height - sheetHeight + context.state.dismissOffset, + sheetHeight: max(sheetHeight - context.state.dismissOffset, sheetHeight), + backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), + bottomPadding: bottomPadding + ), + availableSize: context.availableSize, + transition: context.transition ) - } - print("DismissOffset: \(context.state.dismissOffset)") - // Only modal// context.state.videoSize?.height ?? 160 - var videoPos: CGFloat = 0 - if 2 < 10 { - if debugUpdate { - videoPos = videoHeight - videoHeight / 2 * 2 + sheetPosition - sheetPosition - debugUpdate = false - } else { - videoPos = lastVideoPos - } + + // TODO: calculate (although not necessary currently) + let sheetOffset: CGFloat = context.availableSize.height - sheetHeight + context.state.dismissOffset + let sheetPosition = sheetOffset + sheetHeight / 2 + // Sheet underneath the video when in sheet +// if !isFullscreen { + // TODO: work with sheet here + context.add(sheet + .position(.init(x: context.availableSize.width / 2.0, y: /*isFullscreen ?*/ context.availableSize.height / 2)) //: sheetPosition)) + ) +// } + let videoPos: CGFloat + if isFullscreen { - videoPos = context.availableSize.height / 2 + videoPos = context.availableSize.height / 2 + state.dismissOffset } else { videoPos = sheetPosition - sheetHeight / 2 + videoHeight / 2 + 50 } - lastVideoPos = videoPos + context.add(video + .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)/*sheetPosition + videoHeight / 2 + 50 - context.availableSize.height / 2*/)// context.availableSize.height / 2.0 + context.state.dismissOffset)) + ) + } else { + context.add(video + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2 + state.dismissOffset) + )) } - context.add(video - .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)/*sheetPosition + videoHeight / 2 + 50 - context.availableSize.height / 2*/)// context.availableSize.height / 2.0 + context.state.dismissOffset)) - ) if isFullscreen { - context.add(sheet + let fullScreenToolbarComponent = AnyComponent(ToolbarComponent( + bottomInset: environment.safeInsets.bottom, + sideInset: environment.safeInsets.left, + leftItem: AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: "Chat/Input/Accessory Panels/MessageSelectionForward", + tintColor: .white + )), + action: { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.presentShare() + } + ).minSize(CGSize(width: 64.0, height: 80))), + // TODO: disable button instead of hiding + rightItem: state.hasVideo ? AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: isFullscreen ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", + tintColor: .white + )), + action: { + if let controller = controller() as? MediaStreamComponentController { + guard let size = state.videoSize else { return } + state.isFullscreen.toggle() + if state.isFullscreen { + if size.width > size.height { + controller.updateOrientation(orientation: .landscapeRight) + } else { + controller.updateOrientation(orientation: .portrait) + // TODO: Update to portrait when open from landscape(?) + } + } else { + // TODO: Check and respect current device orientation + controller.updateOrientation(orientation: .portrait) + } + // controller.updateOrientation(orientation: isLandscape ? .portrait : .landscapeRight) + } + } + ).minSize(CGSize(width: 64.0, height: 80))) : nil, + centerItem: infoItem + )) + let fullScreenOverlayComponent = fullscreenOverlay.update( + component: StreamSheetComponent( + topComponent: AnyComponent(navigationComponent), + bottomButtonsRow: fullScreenToolbarComponent, + topOffset: context.availableSize.height - sheetHeight + context.state.dismissOffset, + sheetHeight: max(sheetHeight - context.state.dismissOffset, sheetHeight), + backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), + bottomPadding: 12 + ), + availableSize: context.availableSize, + transition: context.transition + ) + context.add(fullScreenOverlayComponent .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) ) } @@ -1420,7 +1734,7 @@ public final class _MediaStreamComponentController: ViewControllerComponentConta // self.view.layer.cornerCurve = .continuous // } - self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in + self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 1.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in }) // self.view.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) // } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 1707793c82..6c294f44af 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -7,6 +7,8 @@ import AVKit import MultilineTextComponent import Display +import TelegramCore + typealias MediaStreamVideoComponent = _MediaStreamVideoComponent final class _MediaStreamVideoComponent: Component { @@ -94,6 +96,8 @@ final class _MediaStreamVideoComponent: Component { private var videoBlurView: VideoRenderingView? private var videoView: VideoRenderingView? private var activityIndicatorView: ComponentHostView? + private var loadingView: ComponentHostView? + private var videoPlaceholderView: UIView? private var noSignalView: ComponentHostView? @@ -140,8 +144,20 @@ final class _MediaStreamVideoComponent: Component { } let maskGradientLayer = CAGradientLayer() private var wasVisible = true + func update(component: _MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { self.state = state + /*let groupPeer = component.call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: component.call.peerId)) + let _ = groupPeer.start(next: { peer in + switch peer { + case let .channel(channel): + let photo = channel.photo + photo[0].resource. + print(photo) + default: break + } + let tileShimmer = VoiceChatTileShimmeringNode(account: component.call.account, peer: peer!._asPeer()) + })*/ if component.hasVideo, self.videoView == nil { // self.addSubview(sheetBackdropView) @@ -172,6 +188,7 @@ final class _MediaStreamVideoComponent: Component { } // if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { final class PlaybackDelegateImpl: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate { + var onTransitionFinished: (() -> Void)? func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) { } @@ -185,6 +202,7 @@ final class _MediaStreamVideoComponent: Component { } func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) { + onTransitionFinished?() print("pip finished") } @@ -196,21 +214,29 @@ final class _MediaStreamVideoComponent: Component { return false } } - let pictureInPictureController: AVPictureInPictureController + let pictureInPictureController: AVPictureInPictureController? if #available(iOS 15.0, *) { - pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: PlaybackDelegateImpl())) + pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: { + let delegate = PlaybackDelegateImpl() + delegate.onTransitionFinished = { [weak self] in + if self?.videoView?.alpha == 0 { + self?.videoView?.alpha = 1 + } + } + return delegate + }())) } else { // TODO: support PiP for iOS < 15.0 // sampleBufferVideoView.sampleBufferLayer - pictureInPictureController = AVPictureInPictureController.init(playerLayer: AVPlayerLayer(player: AVPlayer()))! + pictureInPictureController = AVPictureInPictureController.init(playerLayer: AVPlayerLayer(player: AVPlayer())) } - pictureInPictureController.delegate = self + pictureInPictureController?.delegate = self if #available(iOS 14.2, *) { - pictureInPictureController.canStartPictureInPictureAutomaticallyFromInline = true + pictureInPictureController?.canStartPictureInPictureAutomaticallyFromInline = true } if #available(iOS 14.0, *) { - pictureInPictureController.requiresLinearPlayback = true + pictureInPictureController?.requiresLinearPlayback = true } self.pictureInPictureController = pictureInPictureController @@ -236,9 +262,6 @@ final class _MediaStreamVideoComponent: Component { strongSelf.noSignalView?.removeFromSuperview() strongSelf.noSignalView = nil - //strongSelf.translatesAutoresizingMaskIntoConstraints = false - //strongSelf.maximumZoomScale = 4.0 - state?.updated(transition: .immediate) } } diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 1859932ba3..8310a1b4bd 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -17,13 +17,16 @@ final class StreamSheetComponent: CombinedComponent { let sheetHeight: CGFloat let topOffset: CGFloat let backgroundColor: UIColor + + let bottomPadding: CGFloat init( // color: UIColor, topComponent: AnyComponent, bottomButtonsRow: AnyComponent, topOffset: CGFloat, sheetHeight: CGFloat, - backgroundColor: UIColor + backgroundColor: UIColor, + bottomPadding: CGFloat ) { // self.leftItem = leftItem self.topComponent = topComponent @@ -32,6 +35,7 @@ final class StreamSheetComponent: CombinedComponent { self.topOffset = topOffset self.sheetHeight = sheetHeight self.backgroundColor = backgroundColor + self.bottomPadding = bottomPadding } static func ==(lhs: StreamSheetComponent, rhs: StreamSheetComponent) -> Bool { @@ -53,6 +57,9 @@ final class StreamSheetComponent: CombinedComponent { if !lhs.backgroundColor.isEqual(rhs.backgroundColor) { return false } + if lhs.bottomPadding != rhs.bottomPadding { + return false + } return true } // @@ -174,9 +181,9 @@ final class StreamSheetComponent: CombinedComponent { if let bottomButtonsRow = bottomButtonsRow { context.add(bottomButtonsRow - .position(CGPoint(x: bottomButtonsRow.size.width / 2, y: context.component.sheetHeight - 50 / 2 - 16 + topOffset)) + .position(CGPoint(x: bottomButtonsRow.size.width / 2, y: context.component.sheetHeight - 50 / 2 + topOffset - context.component.bottomPadding)) ) - (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: context.component.sheetHeight - 50 - 16 + topOffset, width: bottomButtonsRow.size.width, height: bottomButtonsRow.size.height)) + (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: context.component.sheetHeight - 50 + topOffset - context.component.bottomPadding, width: bottomButtonsRow.size.width, height: bottomButtonsRow.size.height)) } /*if let leftItem = leftItem { print(leftItem) @@ -257,14 +264,20 @@ final class SheetBackgroundComponent: Component { class View: UIView { private let backgroundView = UIView() - func update(availableSize: CGSize, color: UIColor) { + func update(availableSize: CGSize, color: UIColor, transition: Transition) { if backgroundView.superview == nil { self.addSubview(backgroundView) } // To fix release animation let extraBottom: CGFloat = 500 backgroundView.frame = .init(origin: .zero, size: .init(width: availableSize.width, height: availableSize.height + extraBottom)) - backgroundView.backgroundColor = color// .withAlphaComponent(0.4) + if backgroundView.backgroundColor != color { + UIView.animate(withDuration: 0.4) { [self] in + backgroundView.backgroundColor = color + } + } else { + backgroundView.backgroundColor = color + } backgroundView.isUserInteractionEnabled = false backgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] backgroundView.layer.cornerRadius = 16 @@ -295,7 +308,7 @@ final class SheetBackgroundComponent: Component { } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - view.update(availableSize: availableSize, color: color) + view.update(availableSize: availableSize, color: color, transition: transition) return availableSize } } From da7beffdd6e0873a8c7afb0e8cb5fbb90223cc0f Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Sun, 27 Nov 2022 14:12:07 +0400 Subject: [PATCH 06/50] Adding animated counter --- .../Components/MediaStreamComponent.swift | 107 +++- .../MediaStreamVideoComponent.swift | 2 +- .../Components/StreamSheetComponent.swift | 462 +++++++++++++++++- 3 files changed, 541 insertions(+), 30 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index e1b6734e36..b53bcf8d51 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -814,6 +814,7 @@ public final class _MediaStreamComponent: CombinedComponent { var videoHiddenForPip = false /// To update videoHiddenForPip var onExpandedFromPictureInPicture: ((State) -> Void)? + private let infoThrottler = Throttler.init(duration: 5, queue: .main) init(call: PresentationGroupCallImpl) { self.call = call @@ -821,7 +822,7 @@ public final class _MediaStreamComponent: CombinedComponent { if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { self.isPictureInPictureSupported = true } else { - self.isPictureInPictureSupported = false + self.isPictureInPictureSupported = true } super.init() @@ -853,6 +854,22 @@ public final class _MediaStreamComponent: CombinedComponent { } var updated = false + // TODO: remove debug + Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in + strongSelf.infoThrottler.publish(members.totalCount/*Int.random(in: 0..<10000000)*/) { [weak strongSelf] latestCount in + guard let strongSelf = strongSelf else { return } + var updated = false + let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: members.totalCount) + if strongSelf.originInfo != originInfo { + strongSelf.originInfo = originInfo + updated = true + } + // + if updated { + strongSelf.updated(transition: .immediate) + } + } + }.fire() if state.canManageCall != strongSelf.canManageCall { strongSelf.canManageCall = state.canManageCall updated = true @@ -873,12 +890,12 @@ public final class _MediaStreamComponent: CombinedComponent { updated = true } - let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: members.totalCount) - if strongSelf.originInfo != originInfo { - strongSelf.originInfo = originInfo - updated = true - } - +// let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: members.totalCount) +// if strongSelf.originInfo != originInfo { +// strongSelf.originInfo = originInfo +// updated = true +// } +// if updated { strongSelf.updated(transition: .immediate) } @@ -998,8 +1015,9 @@ public final class _MediaStreamComponent: CombinedComponent { } var isFullscreen = state.isFullscreen let isLandscape = context.availableSize.width > context.availableSize.height - if let videoSize = context.state.videoSize { - if videoSize.width > videoSize.height && isLandscape && !isFullscreen { + if let _ = context.state.videoSize { + // Always fullscreen in landscape + if /*videoSize.width > videoSize.height &&*/ isLandscape && !isFullscreen { state.isFullscreen = true isFullscreen = true } @@ -1500,7 +1518,9 @@ public final class _MediaStreamComponent: CombinedComponent { topOffset: context.availableSize.height - sheetHeight + context.state.dismissOffset, sheetHeight: max(sheetHeight - context.state.dismissOffset, sheetHeight), backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), - bottomPadding: bottomPadding + bottomPadding: bottomPadding, + participantsCount: // [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! + context.state.originInfo?.memberCount ?? 0 ), availableSize: context.availableSize, transition: context.transition @@ -1582,7 +1602,8 @@ public final class _MediaStreamComponent: CombinedComponent { topOffset: context.availableSize.height - sheetHeight + context.state.dismissOffset, sheetHeight: max(sheetHeight - context.state.dismissOffset, sheetHeight), backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), - bottomPadding: 12 + bottomPadding: 12, + participantsCount: -1 // context.state.originInfo?.memberCount ?? 0 ), availableSize: context.availableSize, transition: context.transition @@ -1866,3 +1887,67 @@ public final class _MediaStreamComponentController: ViewControllerComponentConta } public typealias MediaStreamComponentController = _MediaStreamComponentController + +public final class Throttler { + public var duration: TimeInterval = 0.25 + public var queue: DispatchQueue = .main + public var isEnabled: Bool { duration > 0 } + + private var isThrottling: Bool = false + private var lastValue: T? + private var accumulator = Set() + private var lastCompletedValue: T? + + public init(duration: TimeInterval = 0.25, queue: DispatchQueue = .main) { + self.duration = duration + self.queue = queue + } + + public func publish(_ value: T, includingLatest: Bool = false, using completion: ((T) -> Void)?) { + accumulator.insert(value) + + if !isThrottling { + isThrottling = true + lastValue = nil + queue.async { + completion?(value) + self.lastCompletedValue = value + } + } else { + lastValue = value + } + + if lastValue == nil { + queue.asyncAfter(deadline: .now() + duration) { [self] in + accumulator.removeAll() + isThrottling = false + + guard + let lastValue = lastValue, + lastCompletedValue != lastValue || includingLatest + else { return } + + accumulator.insert(lastValue) + self.lastValue = nil + completion?(lastValue) + lastCompletedValue = lastValue + } + } + } + + public func cancelCurrent() { + lastValue = nil + isThrottling = false + accumulator.removeAll() + } + + public func canEmit(_ value: T) -> Bool { + !accumulator.contains(value) + } +} + +public extension Throttler where T == Bool { + func throttle(includingLatest: Bool = false, _ completion: ((T) -> Void)?) { + publish(true, includingLatest: includingLatest, using: completion) + } +} diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 6c294f44af..7048b8ad93 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -220,7 +220,7 @@ final class _MediaStreamVideoComponent: Component { let delegate = PlaybackDelegateImpl() delegate.onTransitionFinished = { [weak self] in if self?.videoView?.alpha == 0 { - self?.videoView?.alpha = 1 +// self?.videoView?.alpha = 1 } } return delegate diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 8310a1b4bd..4de232af33 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -17,8 +17,9 @@ final class StreamSheetComponent: CombinedComponent { let sheetHeight: CGFloat let topOffset: CGFloat let backgroundColor: UIColor - + let participantsCount: Int let bottomPadding: CGFloat + init( // color: UIColor, topComponent: AnyComponent, @@ -26,7 +27,8 @@ final class StreamSheetComponent: CombinedComponent { topOffset: CGFloat, sheetHeight: CGFloat, backgroundColor: UIColor, - bottomPadding: CGFloat + bottomPadding: CGFloat, + participantsCount: Int ) { // self.leftItem = leftItem self.topComponent = topComponent @@ -36,6 +38,7 @@ final class StreamSheetComponent: CombinedComponent { self.sheetHeight = sheetHeight self.backgroundColor = backgroundColor self.bottomPadding = bottomPadding + self.participantsCount = participantsCount } static func ==(lhs: StreamSheetComponent, rhs: StreamSheetComponent) -> Bool { @@ -60,6 +63,9 @@ final class StreamSheetComponent: CombinedComponent { if lhs.bottomPadding != rhs.bottomPadding { return false } + if lhs.participantsCount != rhs.participantsCount { + return false + } return true } // @@ -117,7 +123,7 @@ final class StreamSheetComponent: CombinedComponent { let background = Child(SheetBackgroundComponent.self) // let leftItem = Child(environment: Empty.self) let topItem = Child(environment: Empty.self) -// let viewerCounter = Child(environment: Empty.self) + let viewerCounter = Child(ParticipantsComponent.self) let bottomButtonsRow = Child(environment: Empty.self) // let bottomButtons = Child(environment: Empty.self) // let rightItems = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) @@ -140,13 +146,11 @@ final class StreamSheetComponent: CombinedComponent { ) } -// let viewerCounter = context.component.viewerCounter.flatMap { viewerCounterComponent in -// return viewerCounter.update( -// component: viewerCounterComponent, -// availableSize: context.availableSize, -// transition: context.transition -// ) -// } + let viewerCounter = viewerCounter.update( + component: ParticipantsComponent(count: context.component.participantsCount), + availableSize: CGSize(width: context.availableSize.width, height: 70), + transition: context.transition + ) let bottomButtonsRow = context.component.bottomButtonsRow.flatMap { bottomButtonsRowComponent in return bottomButtonsRow.update( @@ -164,6 +168,7 @@ final class StreamSheetComponent: CombinedComponent { (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames = [] context.view.backgroundColor = .clear + if let topItem = topItem { context.add(topItem .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + contentHeight / 2.0)) @@ -171,13 +176,13 @@ final class StreamSheetComponent: CombinedComponent { (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: topOffset, width: topItem.size.width, height: topItem.size.height)) } -// if let viewerCounter = viewerCounter { -// let videoHeight = availableWidth / 2 -// let topRowHeight: CGFloat = 50 -// context.add(viewerCounter -// .position(CGPoint(x: viewerCounter.size.width / 2, y: topRowHeight + videoHeight + 32)) -// ) -// } + let animatedParticipantsVisible = context.component.participantsCount != -1 + if animatedParticipantsVisible { + // let videoHeight = availableWidth / 2 + context.add(viewerCounter + .position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50 + 200 + 40 + 30)) + ) + } if let bottomButtonsRow = bottomButtonsRow { context.add(bottomButtonsRow @@ -204,7 +209,7 @@ import TelegramPresentationData import TelegramStringFormatting private let purple = UIColor(rgb: 0x3252ef) -private let pink = UIColor(rgb: 0xef436c) +private let pink = UIColor(rgb: 0xe4436c) private let latePurple = UIColor(rgb: 0x974aa9) private let latePink = UIColor(rgb: 0xf0436c) @@ -312,3 +317,424 @@ final class SheetBackgroundComponent: Component { return availableSize } } + +final class ParticipantsComponent: Component { + static func == (lhs: ParticipantsComponent, rhs: ParticipantsComponent) -> Bool { + lhs.count == rhs.count + } + + func makeView() -> View { + View(frame: .zero) + } + + func update(view: View, availableSize: CGSize, state: ComponentFlow.EmptyComponentState, environment: ComponentFlow.Environment, transition: ComponentFlow.Transition) -> CGSize { + view.counter.update( + countString: count > 0 ? presentationStringsFormattedNumber(Int32(count), ",") : "", + subtitle: count > 0 ? "watching" : "no viewers" + )// environment.strings.LiveStream_NoViewers) + return availableSize + } + + private let count: Int + + init(count: Int) { + self.count = count + } + + final class View: UIView { + let counter = AnimatedCountView()// VoiceChatTimerNode.init(strings: .init(), dateTimeFormat: .init()) + + override init(frame: CGRect) { + super.init(frame: frame) + self.addSubview(counter) + counter.clipsToBounds = false + } + + override func layoutSubviews() { + super.layoutSubviews() + self.counter.frame = self.bounds + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } + +} + +public final class AnimatedCountView: UIView { + let countLabel = AnimatedCountLabel() +// let titleLabel = UILabel() + let subtitleLabel = UILabel() + + private let foregroundView = UIView() + private let foregroundGradientLayer = CAGradientLayer() + private let maskingView = UIView() + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + + self.foregroundGradientLayer.type = .radial + self.foregroundGradientLayer.colors = [pink.cgColor, purple.cgColor, purple.cgColor] + self.foregroundGradientLayer.locations = [0.0, 0.85, 1.0] + self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0) + self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) + + self.foregroundView.mask = self.maskingView + self.foregroundView.layer.addSublayer(self.foregroundGradientLayer) + + self.addSubview(self.foregroundView) +// self.addSubview(self.titleLabel) + self.addSubview(self.subtitleLabel) + + self.maskingView.addSubview(countLabel) + + subtitleLabel.textAlignment = .center +// self.backgroundColor = UIColor.white.withAlphaComponent(0.1) + } + + override public func layoutSubviews() { + super.layoutSubviews() + + self.foregroundView.frame = CGRect(origin: CGPoint.zero, size: bounds.size)// .insetBy(dx: -40, dy: -40) + self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60) + self.maskingView.frame = CGRect(origin: .zero, size: bounds.size) + countLabel.frame = CGRect(origin: .zero, size: bounds.size) + subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - 10 : bounds.height, width: subtitleLabel.intrinsicContentSize.width + 20, height: 20) + } + + func update(countString: String, subtitle: String) { + self.setupGradientAnimations() + + let text: String = countString// presentationStringsFormattedNumber(Int32(count), ",") + + // self.titleNode.attributedText = NSAttributedString(string: "", font: Font.with(size: 23.0, design: .round, weight: .semibold, traits: []), textColor: .white) + // let titleSize = self.titleNode.updateLayout(size) + // self.titleNode.frame = CGRect(x: floor((size.width - titleSize.width) / 2.0), y: 48.0, width: titleSize.width, height: titleSize.height) + if CGFloat(text.count * 40) < bounds.width - 32 { + self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) + } else { + self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: 54.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) + } +// var timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) +// if timerSize.width > size.width - 32.0 { +// self.timerNode.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) +// timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) +// } + +// self.timerNode.frame = CGRect(x: floor((size.width - timerSize.width) / 2.0), y: 78.0, width: timerSize.width, height: timerSize.height) + + self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, font: Font.with(size: 16.0, design: .round, weight: .semibold, traits: []), textColor: .white) + self.subtitleLabel.isHidden = subtitle.isEmpty +// let subtitleSize = self.subtitleNode.updateLayout(size) +// self.subtitleNode.frame = CGRect(x: floor((size.width - subtitleSize.width) / 2.0), y: 164.0, width: subtitleSize.width, height: subtitleSize.height) + +// self.foregroundView.frame = CGRect(origin: CGPoint(), size: size) + // self.setNeedsLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupGradientAnimations() { + if let _ = self.foregroundGradientLayer.animation(forKey: "movement") { + } else { + let previousValue = self.foregroundGradientLayer.startPoint + let newValue = CGPoint(x: CGFloat.random(in: 0.65 ..< 0.85), y: CGFloat.random(in: 0.1 ..< 0.45)) + self.foregroundGradientLayer.startPoint = newValue + + CATransaction.begin() + + let animation = CABasicAnimation(keyPath: "startPoint") + animation.duration = Double.random(in: 0.8 ..< 1.4) + animation.fromValue = previousValue + animation.toValue = newValue + + CATransaction.setCompletionBlock { [weak self] in +// if let isCurrentlyInHierarchy = self?.isCurrentlyInHierarchy, isCurrentlyInHierarchy { + self?.setupGradientAnimations() +// } + } + self.foregroundGradientLayer.add(animation, forKey: "movement") + CATransaction.commit() + } + } +} + +class AnimatedCharLayer: CATextLayer { + var text: String? { + get { + self.string as? String ?? (self.string as? NSAttributedString)?.string + } + set { + self.string = newValue + } + } + var attributedText: NSAttributedString? { + get { + self.string as? NSAttributedString //?? (self.string as? String).map { NSAttributed.init + } + set { + self.string = newValue + } + } + + var layer: CALayer { self } + + override init() { + super.init() + + self.contentsScale = UIScreen.main.scale + } + + override init(layer: Any) { + super.init(layer: layer) + self.contentsScale = UIScreen.main.scale + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class AnimatedCountLabel: UILabel { + override var text: String? { + get { + chars.reduce("") { $0 + ($1.text ?? "") } + } + set { + update(with: newValue ?? "") + } + } + + override var attributedText: NSAttributedString? { + get { + let string = NSMutableAttributedString() + for char in chars { + string.append(char.attributedText ?? NSAttributedString()) + } + return string + } + set { + udpateAttributed(with: newValue ?? NSAttributedString()) + } + } + + private var chars = [AnimatedCharLayer]() + private let containerView = UIView() + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + + addSubview(containerView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + let interItemSpacing: CGFloat = 0 + let countWidth = chars.reduce(0) { $0 + $1.frame.width + interItemSpacing } - interItemSpacing + + containerView.frame = .init(x: bounds.midX - countWidth / 2, y: 0, width: countWidth, height: bounds.height) + chars.enumerated().forEach { (index, char) in + char.frame.origin.x = CGFloat(chars.count - 1 - index) * (40 + interItemSpacing) + char.frame.origin.y = 0 + } + } + /// Unused + func update(with newString: String) { + /*let itemWidth: CGFloat = 40 + let initialDuration: TimeInterval = 0.3 + let newChars = Array(newString).map { String($0) } + let currentChars = chars.map { $0.text ?? "X" } + +// let currentWidth = itemWidth * CGFloat(currentChars.count) + let newWidth = itemWidth * CGFloat(newChars.count) + + let interItemDelay: TimeInterval = 0.15 + var changeIndex = 0 + + var newLayers = [AnimatedCharLayer]() + + for index in 0.. Date: Sun, 27 Nov 2022 17:56:18 +0400 Subject: [PATCH 07/50] Fixing counter animation --- .../Components/MediaStreamComponent.swift | 2 +- .../MediaStreamVideoComponent.swift | 4 +- .../Components/StreamSheetComponent.swift | 91 ++++++++++++++----- 3 files changed, 71 insertions(+), 26 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index b53bcf8d51..981c5e33b9 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -822,7 +822,7 @@ public final class _MediaStreamComponent: CombinedComponent { if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { self.isPictureInPictureSupported = true } else { - self.isPictureInPictureSupported = true + self.isPictureInPictureSupported = AVPictureInPictureController.isPictureInPictureSupported() } super.init() diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 7048b8ad93..eca99c62b5 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -214,7 +214,7 @@ final class _MediaStreamVideoComponent: Component { return false } } - let pictureInPictureController: AVPictureInPictureController? + var pictureInPictureController: AVPictureInPictureController? = nil if #available(iOS 15.0, *) { pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: { let delegate = PlaybackDelegateImpl() @@ -225,7 +225,7 @@ final class _MediaStreamVideoComponent: Component { } return delegate }())) - } else { + } else if AVPictureInPictureController.isPictureInPictureSupported() { // TODO: support PiP for iOS < 15.0 // sampleBufferVideoView.sampleBufferLayer pictureInPictureController = AVPictureInPictureController.init(playerLayer: AVPlayerLayer(player: AVPlayer())) diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 4de232af33..30d97bc3c1 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -176,11 +176,12 @@ final class StreamSheetComponent: CombinedComponent { (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: topOffset, width: topItem.size.width, height: topItem.size.height)) } + let videoHeight = (availableWidth - 32) / 16 * 9 let animatedParticipantsVisible = context.component.participantsCount != -1 - if animatedParticipantsVisible { - // let videoHeight = availableWidth / 2 + if true { context.add(viewerCounter - .position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50 + 200 + 40 + 30)) + .position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50 + videoHeight + 40 + 30)) + .opacity(animatedParticipantsVisible ? 1 : 0) ) } @@ -533,15 +534,27 @@ class AnimatedCountLabel: UILabel { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - + var itemWidth: CGFloat { 36 } override func layoutSubviews() { super.layoutSubviews() let interItemSpacing: CGFloat = 0 - let countWidth = chars.reduce(0) { $0 + $1.frame.width + interItemSpacing } - interItemSpacing + let countWidth = chars.reduce(0) { + if $1.attributedText?.string == "," { + return $0 + 12 + } + return $0 + itemWidth + interItemSpacing + } - interItemSpacing containerView.frame = .init(x: bounds.midX - countWidth / 2, y: 0, width: countWidth, height: bounds.height) chars.enumerated().forEach { (index, char) in - char.frame.origin.x = CGFloat(chars.count - 1 - index) * (40 + interItemSpacing) + let offset = chars[0.. Date: Sun, 27 Nov 2022 20:20:31 +0400 Subject: [PATCH 08/50] Stylizing elements --- .../Components/MediaStreamComponent.swift | 173 +++++++----------- .../MediaStreamVideoComponent.swift | 25 ++- .../Components/StreamSheetComponent.swift | 81 +++----- 3 files changed, 110 insertions(+), 169 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 981c5e33b9..eb0108c1b0 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -19,90 +19,6 @@ import CreateExternalMediaStreamScreen import HierarchyTrackingLayer import UndoPanelComponent -final class NavigationBackButtonComponent: Component { - let text: String - let color: UIColor - - init(text: String, color: UIColor) { - self.text = text - self.color = color - } - - static func ==(lhs: NavigationBackButtonComponent, rhs: NavigationBackButtonComponent) -> Bool { - if lhs.text != rhs.text { - return false - } - if lhs.color != rhs.color { - return false - } - return false - } - - public final class View: UIView { - private let arrowView: UIImageView - private let textView: ComponentHostView - - private var component: NavigationBackButtonComponent? - - override init(frame: CGRect) { - self.arrowView = UIImageView() - self.textView = ComponentHostView() - - super.init(frame: frame) - - self.addSubview(self.arrowView) - self.addSubview(self.textView) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - func update(component: NavigationBackButtonComponent, availableSize: CGSize, transition: Transition) -> CGSize { - let spacing: CGFloat = 6.0 - let innerArrowInset: CGFloat = -8.0 - - if self.component?.color != component.color { - self.arrowView.image = NavigationBarTheme.generateBackArrowImage(color: component.color) - } - - self.component = component - - let textSize = self.textView.update( - transition: .immediate, - component: AnyComponent(Text( - text: component.text, - font: Font.regular(17.0), - color: component.color - )), - environment: {}, - containerSize: availableSize - ) - - var leftInset: CGFloat = 0.0 - var size = textSize - if let arrowImage = self.arrowView.image { - size.width += innerArrowInset + arrowImage.size.width + spacing - size.height = max(size.height, arrowImage.size.height) - - self.arrowView.frame = CGRect(origin: CGPoint(x: innerArrowInset, y: floor((size.height - arrowImage.size.height) / 2.0)), size: arrowImage.size) - leftInset += innerArrowInset + arrowImage.size.width + spacing - } - self.textView.frame = CGRect(origin: CGPoint(x: leftInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) - - return size - } - } - - public func makeView() -> View { - return View(frame: CGRect()) - } - - public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - return view.update(component: self, availableSize: availableSize, transition: transition) - } -} - final class StreamTitleComponent: Component { let text: String let isRecording: Bool @@ -137,10 +53,13 @@ final class StreamTitleComponent: Component { addSubview(label) label.text = "LIVE" - label.font = .systemFont(ofSize: 10, weight: .medium) + label.font = .systemFont(ofSize: 12, weight: .semibold) label.textAlignment = .center layer.addSublayer(stalledAnimatedGradient) self.clipsToBounds = true + if #available(iOS 13.0, *) { + self.layer.cornerCurve = .continuous + } toggle(isLive: false) } @@ -161,15 +80,32 @@ final class StreamTitleComponent: Component { if isLive { if !wasLive { // TODO: animate + wasLive = true + let frame = self.frame + UIView.animate(withDuration: 0.15, animations: { + self.toggle(isLive: true) + self.transform = .init(scaleX: 1.5, y: 1.5) + }, completion: { _ in + UIView.animate(withDuration: 0.15) { + self.transform = .identity + self.frame = frame + } + }) + return } - self.backgroundColor = .systemPink + self.backgroundColor = UIColor(red: 0.82, green: 0.26, blue: 0.37, alpha: 1) stalledAnimatedGradient.opacity = 0 stalledAnimatedGradient.removeAllAnimations() } else { if wasLive { // TODO: animate + wasLive = false + UIView.animate(withDuration: 0.3) { + self.toggle(isLive: false) + } + return } - self.backgroundColor = .gray + self.backgroundColor = UIColor(white: 0.36, alpha: 1) stalledAnimatedGradient.opacity = 1 // stalledAnimatedGradient.add(<#T##anim: CAAnimation##CAAnimation#>, forKey: <#T##String?#>) } @@ -247,13 +183,13 @@ final class StreamTitleComponent: Component { indicatorView.removeFromSuperview() } } - liveIndicatorView.toggle(isLive: component.isActive) let sideInset: CGFloat = 20.0 let size = CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height) let textFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) self.textView.frame = textFrame - liveIndicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: floorToScreenPixels((size.height - textSize.height) / 2.0) + 1.0), size: .init(width: 40, height: 18)) + liveIndicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: floorToScreenPixels((size.height - textSize.height) / 2.0 - 2) + 1.0), size: .init(width: 40, height: 22)) + self.liveIndicatorView.toggle(isLive: component.isActive) if let indicatorView = self.indicatorView, let image = indicatorView.image { indicatorView.frame = CGRect(origin: CGPoint(x: liveIndicatorView.frame.maxX + 6.0, y: floorToScreenPixels((size.height - image.size.height) / 2.0) + 1.0), size: image.size) @@ -377,9 +313,10 @@ private final class NavigationBarComponent: CombinedComponent { } // let maxCenterInset = max(centerLeftInset, centerRightInset) + let someUndesiredOffset: CGFloat = 16 if let centerItem = centerItem { context.add(centerItem - .position(CGPoint(x: context.availableSize.width / 2 /*maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0*/, y: context.component.topInset + contentHeight / 2.0)) + .position(CGPoint(x: context.availableSize.width / 2 - someUndesiredOffset /*maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0*/, y: context.component.topInset + contentHeight / 2.0)) ) } @@ -785,6 +722,7 @@ public final class _MediaStreamComponent: CombinedComponent { private(set) var hasVideo: Bool = false private var stateDisposable: Disposable? private var infoDisposable: Disposable? + private var connectionDisposable: Disposable? private(set) var originInfo: OriginInfo? @@ -808,13 +746,14 @@ public final class _MediaStreamComponent: CombinedComponent { private var isVisibleInHierarchyDisposable: Disposable? private var scheduledDismissUITimer: SwiftSignalKit.Timer? + var videoStalled: Bool = false let deactivatePictureInPictureIfVisible = StoredActionSlot(Void.self) var videoHiddenForPip = false /// To update videoHiddenForPip var onExpandedFromPictureInPicture: ((State) -> Void)? - private let infoThrottler = Throttler.init(duration: 5, queue: .main) + private let infoThrottler = Throttler.init(duration: 5, queue: .main) init(call: PresentationGroupCallImpl) { self.call = call @@ -845,6 +784,15 @@ public final class _MediaStreamComponent: CombinedComponent { strongSelf.updated(transition: .immediate) }) + self.connectionDisposable = call.state.start(next: { [weak self] state in + switch state.networkState { + case .connected: + self?.videoStalled = false + default: + self?.videoStalled = true + } + }) + let callPeer = call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: call.peerId)) self.infoDisposable = (combineLatest(queue: .mainQueue(), call.state, call.members, callPeer) @@ -855,11 +803,12 @@ public final class _MediaStreamComponent: CombinedComponent { var updated = false // TODO: remove debug - Timer.scheduledTimer(withTimeInterval: 2, repeats: true) { _ in - strongSelf.infoThrottler.publish(members.totalCount/*Int.random(in: 0..<10000000)*/) { [weak strongSelf] latestCount in + Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + strongSelf.infoThrottler.publish(/*members.totalCount*/Int.random(in: 0..<10000000)) { [weak strongSelf] latestCount in guard let strongSelf = strongSelf else { return } var updated = false - let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: members.totalCount) + print(members) + let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: latestCount) if strongSelf.originInfo != originInfo { strongSelf.originInfo = originInfo updated = true @@ -927,6 +876,7 @@ public final class _MediaStreamComponent: CombinedComponent { self.stateDisposable?.dispose() self.infoDisposable?.dispose() self.isVisibleInHierarchyDisposable?.dispose() + self.connectionDisposable?.dispose() } func toggleDisplayUI() { @@ -1015,11 +965,14 @@ public final class _MediaStreamComponent: CombinedComponent { } var isFullscreen = state.isFullscreen let isLandscape = context.availableSize.width > context.availableSize.height - if let _ = context.state.videoSize { + if let videoSize = context.state.videoSize { // Always fullscreen in landscape if /*videoSize.width > videoSize.height &&*/ isLandscape && !isFullscreen { state.isFullscreen = true isFullscreen = true + } else if videoSize.width > videoSize.height && !isLandscape && isFullscreen { + state.isFullscreen = false + isFullscreen = false } } @@ -1033,6 +986,7 @@ public final class _MediaStreamComponent: CombinedComponent { // TODO: find out how to get image peerImage: nil, isFullscreen: isFullscreen, + videoLoading: context.state.videoStalled, activatePictureInPicture: activatePictureInPicture, deactivatePictureInPicture: deactivatePictureInPicture, bringBackControllerForPictureInPictureDeactivation: { [weak call] completed in @@ -1061,9 +1015,16 @@ public final class _MediaStreamComponent: CombinedComponent { // let contextView = context.view if context.state.isPictureInPictureSupported, context.state.hasVideo { navigationRightItems.append(AnyComponentWithIdentity(id: "pip", component: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Media Gallery/PictureInPictureButton", - tintColor: .white + content: AnyComponent(ZStack([ + AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( + fillColor: .white.withAlphaComponent(0.08), + size: CGSize(width: 32.0, height: 32.0) + ))), + AnyComponentWithIdentity(id: "a", component: AnyComponent(BundleIconComponent( + name: "Media Gallery/PictureInPictureButton", + tintColor: .white + ))) + ] )), action: { activatePictureInPicture.invoke(Action { @@ -1084,9 +1045,8 @@ public final class _MediaStreamComponent: CombinedComponent { AnyComponent(Button( content: AnyComponent(ZStack([ AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( - strokeColor: .white, - strokeWidth: 1.5, - size: CGSize(width: 22.0, height: 22.0) + fillColor: .white.withAlphaComponent(0.08), + size: CGSize(width: 32.0, height: 32.0) ))), AnyComponentWithIdentity(id: "a", component: AnyComponent(LottieAnimationComponent( animation: LottieAnimationComponent.AnimationItem( @@ -1098,7 +1058,7 @@ public final class _MediaStreamComponent: CombinedComponent { "Point 3.Group 1.Fill 1": whiteColor, "Point 1.Group 1.Fill 1": whiteColor ], - size: CGSize(width: 22.0, height: 22.0) + size: CGSize(width: 32.0, height: 32.0) ).tagged(moreAnimationTag))), ])), action: { [weak call, weak state] in @@ -1322,7 +1282,7 @@ public final class _MediaStreamComponent: CombinedComponent { }) )*/, rightItems: navigationRightItems, - centerItem: AnyComponent(StreamTitleComponent(text: state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isActive: call.hasVideo)) + centerItem: AnyComponent(StreamTitleComponent(text: state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isActive: context.state.hasVideo)) ) // let navigationBar = navigationBar.update( @@ -1356,7 +1316,7 @@ public final class _MediaStreamComponent: CombinedComponent { let videoHeight: CGFloat = context.availableSize.width / 16 * 9 let bottomPadding = 40 + environment.safeInsets.bottom - let sheetHeight: CGFloat = isFullscreen ? context.availableSize.height : (44 + videoHeight + 40 + 69 + 32 + 70 + bottomPadding) + let sheetHeight: CGFloat = isFullscreen ? context.availableSize.height : (44 + videoHeight + 40 + 69 + 16 + 32 + 70 + bottomPadding) let isFullyDragged = context.availableSize.height - sheetHeight + state.dismissOffset < 30 context.add(background @@ -1519,8 +1479,8 @@ public final class _MediaStreamComponent: CombinedComponent { sheetHeight: max(sheetHeight - context.state.dismissOffset, sheetHeight), backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), bottomPadding: bottomPadding, - participantsCount: // [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! - context.state.originInfo?.memberCount ?? 0 + participantsCount: context.state.originInfo?.memberCount ?? 0 // Int.random(in: 0...999998)// [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! + // ), availableSize: context.availableSize, transition: context.transition @@ -1610,6 +1570,7 @@ public final class _MediaStreamComponent: CombinedComponent { ) context.add(fullScreenOverlayComponent .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) + .opacity(state.displayUI ? 1 : 0) ) } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index eca99c62b5..a4bb209c79 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -24,6 +24,7 @@ final class _MediaStreamVideoComponent: Component { let peerImage: Any? let isFullscreen: Bool let onVideoSizeRetrieved: (CGSize) -> Void + let videoLoading: Bool init( call: PresentationGroupCallImpl, hasVideo: Bool, @@ -32,6 +33,7 @@ final class _MediaStreamVideoComponent: Component { peerTitle: String, peerImage: Any?, isFullscreen: Bool, + videoLoading: Bool, activatePictureInPicture: ActionSlot>, deactivatePictureInPicture: ActionSlot, bringBackControllerForPictureInPictureDeactivation: @escaping (@escaping () -> Void) -> Void, @@ -43,6 +45,7 @@ final class _MediaStreamVideoComponent: Component { self.isVisible = isVisible self.isAdmin = isAdmin self.peerTitle = peerTitle + self.videoLoading = videoLoading self.activatePictureInPicture = activatePictureInPicture self.deactivatePictureInPicture = deactivatePictureInPicture self.bringBackControllerForPictureInPictureDeactivation = bringBackControllerForPictureInPictureDeactivation @@ -100,7 +103,8 @@ final class _MediaStreamVideoComponent: Component { private var videoPlaceholderView: UIView? private var noSignalView: ComponentHostView? - + private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .light)) + private let shimmerOverlayView = CALayer() private var pictureInPictureController: AVPictureInPictureController? private var component: _MediaStreamVideoComponent? @@ -181,7 +185,6 @@ final class _MediaStreamVideoComponent: Component { if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) { self.videoView = videoView self.addSubview(videoView) - videoView.alpha = 1 if let sampleBufferVideoView = videoView as? SampleBufferVideoRenderingView { if #available(iOS 13.0, *) { sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true @@ -262,6 +265,8 @@ final class _MediaStreamVideoComponent: Component { strongSelf.noSignalView?.removeFromSuperview() strongSelf.noSignalView = nil + let snapshot = strongSelf.videoView?.snapshotView(afterScreenUpdates: true) + strongSelf.addSubview(snapshot ?? UIVisualEffectView(effect: UIBlurEffect(style: .dark))) state?.updated(transition: .immediate) } } @@ -316,7 +321,12 @@ final class _MediaStreamVideoComponent: Component { videoView.layer.cornerRadius = component.isFullscreen ? 0 : 10 // var aspect = videoView.getAspect() // if aspect <= 0.01 { - + // TODO: remove debug +// if component.videoLoading { +// videoView.alpha = 0.5 +// } else { +// videoView.alpha = 1 +// } transition.withAnimation(.none).setFrame(view: videoView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize), completion: nil) @@ -354,7 +364,7 @@ final class _MediaStreamVideoComponent: Component { activityIndicatorTransition = transition.withAnimation(.none) activityIndicatorView = ComponentHostView() self.activityIndicatorView = activityIndicatorView - self.addSubview(activityIndicatorView) +// self.addSubview(activityIndicatorView) } let activityIndicatorSize = activityIndicatorView.update( @@ -431,6 +441,11 @@ final class _MediaStreamVideoComponent: Component { return availableSize } + func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + videoView?.alpha = 0 + videoBlurView?.alpha = 0 + } + public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { guard let component = self.component else { completionHandler(false) @@ -455,6 +470,8 @@ final class _MediaStreamVideoComponent: Component { } func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + self.videoView?.alpha = 1 + self.videoBlurView?.alpha = 1 self.state?.updated(transition: .immediate) } } diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 30d97bc3c1..f382743231 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -171,16 +171,16 @@ final class StreamSheetComponent: CombinedComponent { if let topItem = topItem { context.add(topItem - .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + contentHeight / 2.0)) + .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + 32)) ) (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: topOffset, width: topItem.size.width, height: topItem.size.height)) } - let videoHeight = (availableWidth - 32) / 16 * 9 + let sheetHeight = context.component.sheetHeight let animatedParticipantsVisible = context.component.participantsCount != -1 if true { context.add(viewerCounter - .position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50 + videoHeight + 40 + 30)) + .position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50 + videoHeight + (sheetHeight - 69 - videoHeight - 50 - context.component.bottomPadding) / 2 - 12)) .opacity(animatedParticipantsVisible ? 1 : 0) ) } @@ -215,55 +215,6 @@ private let pink = UIColor(rgb: 0xe4436c) private let latePurple = UIColor(rgb: 0x974aa9) private let latePink = UIColor(rgb: 0xf0436c) -final class ViewerCountComponent: Component { - private let count: Int - -// private let counterView: VoiceChatTimerNode - - static func ==(lhs: ViewerCountComponent, rhs: ViewerCountComponent) -> Bool { - if lhs.count != rhs.count { - return false - } - return true - } - - init(count: Int) { - self.count = count - } - - public func update(view: UIView, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - - /*self.foregroundView.frame = CGRect(origin: CGPoint(), size: size) - self.foregroundGradientLayer.frame = self.foregroundView.bounds - self.maskView.frame = self.foregroundView.bounds - - let text: String = presentationStringsFormattedNumber(participants, groupingSeparator) - let subtitle = "listening" - - self.titleNode.attributedText = NSAttributedString(string: "", font: Font.with(size: 23.0, design: .round, weight: .semibold, traits: []), textColor: .white) - let titleSize = self.titleNode.updateLayout(size) - self.titleNode.frame = CGRect(x: floor((size.width - titleSize.width) / 2.0), y: 48.0, width: titleSize.width, height: titleSize.height) - - self.timerNode.attributedText = NSAttributedString(string: text, font: Font.with(size: 68.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) - - var timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) - if timerSize.width > size.width - 32.0 { - self.timerNode.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) - timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) - } - - self.timerNode.frame = CGRect(x: floor((size.width - timerSize.width) / 2.0), y: 78.0, width: timerSize.width, height: timerSize.height) - - self.subtitleNode.attributedText = NSAttributedString(string: subtitle, font: Font.with(size: 21.0, design: .round, weight: .semibold, traits: []), textColor: .white) - let subtitleSize = self.subtitleNode.updateLayout(size) - self.subtitleNode.frame = CGRect(x: floor((size.width - subtitleSize.width) / 2.0), y: 164.0, width: subtitleSize.width, height: subtitleSize.height) - - self.foregroundView.frame = CGRect(origin: CGPoint(), size: size) - */ - return availableSize - } -} - final class SheetBackgroundComponent: Component { private let color: UIColor @@ -401,7 +352,7 @@ public final class AnimatedCountView: UIView { self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60) self.maskingView.frame = CGRect(origin: .zero, size: bounds.size) countLabel.frame = CGRect(origin: .zero, size: bounds.size) - subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - 10 : bounds.height, width: subtitleLabel.intrinsicContentSize.width + 20, height: 20) + subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - 10 : bounds.height - 6, width: subtitleLabel.intrinsicContentSize.width + 20, height: 20) } func update(countString: String, subtitle: String) { @@ -614,7 +565,6 @@ class AnimatedCountLabel: UILabel { } func udpateAttributed(with newString: NSAttributedString) { - let initialDuration: TimeInterval = 0.25 let interItemSpacing: CGFloat = 0 let separatedStrings = Array(newString.string).map { String($0) } @@ -629,10 +579,22 @@ class AnimatedCountLabel: UILabel { let currentChars = chars.map { $0.attributedText ?? .init() } + let maxAnimationDuration: TimeInterval = 0.5 + var numberOfChanges = abs(newChars.count - currentChars.count) + for index in 0.. Date: Sun, 27 Nov 2022 23:10:23 +0400 Subject: [PATCH 09/50] Final tweaking --- .../ShimmerEffect/Sources/ShimmerEffect.swift | 48 ++++++++++++ .../Components/MediaStreamComponent.swift | 51 ++++++------- .../MediaStreamVideoComponent.swift | 72 +++++++++++++----- .../Components/StreamSheetComponent.swift | 24 ++---- .../Call/close.imageset/Contents.json | 23 ++++++ .../Call/close.imageset/close.png | Bin 0 -> 414 bytes .../Call/close.imageset/close@2x.png | Bin 0 -> 724 bytes .../Call/close.imageset/close@3x.png | Bin 0 -> 1169 bytes .../Call/expand.imageset/Contents.json | 23 ++++++ .../Call/expand.imageset/expand.png | Bin 0 -> 491 bytes .../Call/expand.imageset/expand@2x.png | Bin 0 -> 821 bytes .../Call/expand.imageset/expand@3x.png | Bin 0 -> 1228 bytes .../Call/more.imageset/Contents.json | 23 ++++++ .../Call/more.imageset/more.png | Bin 0 -> 189 bytes .../Call/more.imageset/more@2x.png | Bin 0 -> 235 bytes .../Call/more.imageset/more@3x.png | Bin 0 -> 306 bytes .../Call/pip.imageset/Contents.json | 23 ++++++ .../Images.xcassets/Call/pip.imageset/pip.png | Bin 0 -> 316 bytes .../Call/pip.imageset/pip@2x.png | Bin 0 -> 504 bytes .../Call/pip.imageset/pip@3x.png | Bin 0 -> 590 bytes .../Call/share.imageset/Contents.json | 23 ++++++ .../Call/share.imageset/share.png | Bin 0 -> 504 bytes .../Call/share.imageset/share@2x.png | Bin 0 -> 959 bytes .../Call/share.imageset/share@3x.png | Bin 0 -> 1393 bytes 24 files changed, 248 insertions(+), 62 deletions(-) create mode 100644 submodules/TelegramUI/Images.xcassets/Call/close.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Call/close.imageset/close.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@2x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@3x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/expand.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@2x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@3x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/more.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Call/more.imageset/more.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@2x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@3x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/pip.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@2x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@3x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/share.imageset/Contents.json create mode 100644 submodules/TelegramUI/Images.xcassets/Call/share.imageset/share.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@2x.png create mode 100644 submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@3x.png diff --git a/submodules/ShimmerEffect/Sources/ShimmerEffect.swift b/submodules/ShimmerEffect/Sources/ShimmerEffect.swift index a527784fa8..65c86728dd 100644 --- a/submodules/ShimmerEffect/Sources/ShimmerEffect.swift +++ b/submodules/ShimmerEffect/Sources/ShimmerEffect.swift @@ -477,6 +477,35 @@ public final class StandaloneShimmerEffect { self.updateLayer() } + public func testUpdate(background: UIColor, foreground: UIColor) { + if self.background == background && self.foreground == foreground { + return + } + self.background = background + self.foreground = foreground + + self.image = generateImage(CGSize(width: 320, height: 1), opaque: false, scale: 1.0, rotatedContext: { size, context in + context.clear(CGRect(origin: CGPoint(), size: size)) + context.setFillColor(background.cgColor) + context.fill(CGRect(origin: CGPoint(), size: size)) + + context.clip(to: CGRect(origin: CGPoint(), size: size)) + + let transparentColor = foreground.withAlphaComponent(0.0).cgColor + let peakColor = foreground.cgColor + + var locations: [CGFloat] = [0.0, 0.44, 0.55, 1.0] + let colors: [CGColor] = [transparentColor, peakColor, peakColor, transparentColor] + + let colorSpace = CGColorSpaceCreateDeviceRGB() + guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations) else { return } + + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: 0.3), options: CGGradientDrawingOptions()) + }) + + self.testUpdateLayer() + } + public func updateLayer() { guard let layer = self.layer, let image = self.image else { return @@ -495,4 +524,23 @@ public final class StandaloneShimmerEffect { layer.add(animation, forKey: "shimmer") } } + + private func testUpdateLayer() { + guard let layer = self.layer, let image = self.image else { + return + } + + layer.contents = image.cgImage + + if layer.animation(forKey: "shimmer") == nil { + let animation = CABasicAnimation(keyPath: "contentsRect.origin.x") + animation.fromValue = 1.0 as NSNumber + animation.toValue = -1.0 as NSNumber + animation.isAdditive = true + animation.repeatCount = .infinity + animation.duration = 0.8 + animation.beginTime = layer.convertTime(1.0, from: nil) + layer.add(animation, forKey: "shimmer") + } + } } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index eb0108c1b0..30bf2b9a48 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -785,12 +785,16 @@ public final class _MediaStreamComponent: CombinedComponent { }) self.connectionDisposable = call.state.start(next: { [weak self] state in + let prev = self?.videoStalled switch state.networkState { case .connected: self?.videoStalled = false default: self?.videoStalled = true } + if prev != self?.videoStalled { + self?.updated(transition: .immediate) + } }) let callPeer = call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: call.peerId)) @@ -804,16 +808,14 @@ public final class _MediaStreamComponent: CombinedComponent { var updated = false // TODO: remove debug Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in - strongSelf.infoThrottler.publish(/*members.totalCount*/Int.random(in: 0..<10000000)) { [weak strongSelf] latestCount in + strongSelf.infoThrottler.publish(members.totalCount/*Int.random(in: 0..<10000000)*/) { [weak strongSelf] latestCount in guard let strongSelf = strongSelf else { return } var updated = false - print(members) let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: latestCount) if strongSelf.originInfo != originInfo { strongSelf.originInfo = originInfo updated = true - } - // + } if updated { strongSelf.updated(transition: .immediate) } @@ -1021,7 +1023,7 @@ public final class _MediaStreamComponent: CombinedComponent { size: CGSize(width: 32.0, height: 32.0) ))), AnyComponentWithIdentity(id: "a", component: AnyComponent(BundleIconComponent( - name: "Media Gallery/PictureInPictureButton", + name: "Call/pip", tintColor: .white ))) ] @@ -1383,17 +1385,16 @@ public final class _MediaStreamComponent: CombinedComponent { ).minSize(CGSize(width: 65, height: 80))), // TODO: disable button instead of hiding rightItem: AnyComponent(Button( - content: AnyComponent(RoundGradientButtonComponent(// BundleIconComponent( + content: AnyComponent(RoundGradientButtonComponent( gradientColors: [UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor, UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor], image: generateImage(CGSize(width: 44.0, height: 44), opaque: false, rotatedContext: { size, context in context.translateBy(x: size.width / 2, y: size.height / 2) context.scaleBy(x: 0.4, y: 0.4) context.translateBy(x: -size.width / 2, y: -size.height / 2) -// context.translateBy(x: size.width * 0.66, y: size.height * 0.66) let imageColor = UIColor.white let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) - let lineWidth: CGFloat = size.width / 6 + let lineWidth: CGFloat = size.width / 7 context.setLineWidth(lineWidth - UIScreenPixel) context.setLineCap(.round) context.setStrokeColor(imageColor.cgColor) @@ -1406,10 +1407,8 @@ public final class _MediaStreamComponent: CombinedComponent { context.addLine(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) context.strokePath() }), - title: - // TODO: localize - "leave" - )), + title: "leave" + )), action: { [weak call] in let _ = call?.leave(terminateIfPossible: false) } @@ -1423,34 +1422,34 @@ public final class _MediaStreamComponent: CombinedComponent { let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) - context.setLineWidth(2.7 - UIScreenPixel) + context.setLineWidth(2.4 - UIScreenPixel) context.setLineCap(.round) context.setStrokeColor(imageColor.cgColor) // context.setLineJoin(.round) let lineSide = size.width / 5 - let centerOffset = size.width / 16 - context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - centerOffset)) + let centerOffset = size.width / 20 + context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - centerOffset / 2)) context.addLine(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) - context.addLine(to: CGPoint(x: size.width / 2 + centerOffset, y: size.height / 2 - lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 + centerOffset / 2, y: size.height / 2 - lineSide)) context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) context.addLine(to: CGPoint(x: size.width / 2 + centerOffset, y: size.height / 2 - centerOffset)) context.strokePath() - context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + centerOffset)) + context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + centerOffset / 2)) context.addLine(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) - context.addLine(to: CGPoint(x: size.width / 2 - centerOffset, y: size.height / 2 + lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 - centerOffset / 2, y: size.height / 2 + lineSide)) context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) context.addLine(to: CGPoint(x: size.width / 2 - centerOffset, y: size.height / 2 + centerOffset)) context.strokePath() - //context.move(to: CGPoint(x: 26.0 - UIScreenPixel, y: 2.0 + UIScreenPixel)) - //context.addLine(to: CGPoint(x: 2.0 + UIScreenPixel, y: 26.0 - UIScreenPixel)) -// context.strokePath() }), title: "expand" )), action: { - guard state.hasVideo else { return } + guard state.hasVideo else { + state.isFullscreen = false + return + } if let controller = controller() as? MediaStreamComponentController { guard let size = state.videoSize else { return } state.isFullscreen.toggle() @@ -1669,10 +1668,10 @@ public final class _MediaStreamComponentController: ViewControllerComponentConta self.onViewDidDisappear?() } - if let initialOrientation = self.initialOrientation { - self.initialOrientation = nil - self.call.accountContext.sharedContext.applicationBindings.forceOrientation(initialOrientation) - } +// if let initialOrientation = self.initialOrientation { +// self.initialOrientation = nil +// self.call.accountContext.sharedContext.applicationBindings.forceOrientation(initialOrientation) +// } } override public func viewDidLoad() { diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index a4bb209c79..2b1781d797 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -6,6 +6,7 @@ import AccountContext import AVKit import MultilineTextComponent import Display +import ShimmerEffect import TelegramCore @@ -103,7 +104,7 @@ final class _MediaStreamVideoComponent: Component { private var videoPlaceholderView: UIView? private var noSignalView: ComponentHostView? - private let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .light)) + private let loadingBlurView = UIVisualEffectView(effect: UIBlurEffect(style: .light)) private let shimmerOverlayView = CALayer() private var pictureInPictureController: AVPictureInPictureController? @@ -122,7 +123,6 @@ final class _MediaStreamVideoComponent: Component { self.blurTintView.backgroundColor = UIColor(white: 0.0, alpha: 0.55) super.init(frame: frame) -// self.backgroundColor = UIColor.green.withAlphaComponent(0.4) self.isUserInteractionEnabled = false self.clipsToBounds = true @@ -148,43 +148,65 @@ final class _MediaStreamVideoComponent: Component { } let maskGradientLayer = CAGradientLayer() private var wasVisible = true + let shimmer = StandaloneShimmerEffect() + let borderShimmer = StandaloneShimmerEffect() + let shimmerOverlayLayer = CALayer() + let shimmerBorderLayer = CALayer() func update(component: _MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { self.state = state - /*let groupPeer = component.call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: component.call.peerId)) - let _ = groupPeer.start(next: { peer in - switch peer { - case let .channel(channel): - let photo = channel.photo - photo[0].resource. - print(photo) - default: break + if component.videoLoading { + if loadingBlurView.superview == nil { + addSubview(loadingBlurView) } - let tileShimmer = VoiceChatTileShimmeringNode(account: component.call.account, peer: peer!._asPeer()) - })*/ + if shimmerOverlayLayer.superlayer == nil { + loadingBlurView.layer.addSublayer(shimmerOverlayLayer) + loadingBlurView.layer.addSublayer(shimmerBorderLayer) + } + loadingBlurView.clipsToBounds = true + shimmer.layer = shimmerOverlayLayer + shimmerOverlayView.compositingFilter = "softLightBlendMode" + shimmer.testUpdate(background: .clear, foreground: .white.withAlphaComponent(0.4)) + loadingBlurView.layer.cornerRadius = 10 + shimmerOverlayLayer.opacity = 0.6 + + shimmerBorderLayer.cornerRadius = 10 + shimmerBorderLayer.masksToBounds = true + shimmerBorderLayer.compositingFilter = "softLightBlendMode" + shimmerBorderLayer.borderWidth = 2 + shimmerBorderLayer.borderColor = UIColor.white.cgColor + + let borderMask = CALayer() + shimmerBorderLayer.mask = borderMask + borderShimmer.layer = borderMask + borderShimmer.testUpdate(background: .clear, foreground: .white) + } else { + loadingBlurView.removeFromSuperview() + } if component.hasVideo, self.videoView == nil { -// self.addSubview(sheetBackdropView) -// self.addSubview(sheetView) - if let input = component.call.video(endpointId: "unified") { if let videoBlurView = self.videoRenderingContext.makeView(input: input, blur: true) { self.videoBlurView = videoBlurView self.insertSubview(videoBlurView, belowSubview: self.blurTintView) + videoBlurView.alpha = 0 + UIView.animate(withDuration: 0.3) { + videoBlurView.alpha = 1 + } self.maskGradientLayer.type = .radial self.maskGradientLayer.colors = [UIColor(rgb: 0x000000, alpha: 0.5).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor] self.maskGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5) self.maskGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0) -// self.maskGradientLayer.transform = CATransform3DMakeScale(0.3, 0.3, 1.0) -// self.maskGradientLayer.isHidden = true - } - if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) { self.videoView = videoView self.addSubview(videoView) + videoView.alpha = 0 + UIView.animate(withDuration: 0.3) { + videoView.alpha = 1 + } if let sampleBufferVideoView = videoView as? SampleBufferVideoRenderingView { if #available(iOS 13.0, *) { sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true @@ -345,10 +367,20 @@ final class _MediaStreamVideoComponent: Component { videoBlurView.layer.mask = nil } - self.maskGradientLayer.frame = videoBlurView.bounds// CGRect(x: videoBlurView.bounds.midX, y: videoBlurView.bounds.midY, width: videoBlurView.bounds.width, height: videoBlurView.bounds.height) + self.maskGradientLayer.frame = videoBlurView.bounds } } + let videoSize = CGSize(width: 16 / 9 * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) + loadingBlurView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) + loadingBlurView.layer.cornerRadius = 10 + + shimmerOverlayLayer.frame = loadingBlurView.bounds + shimmerBorderLayer.frame = loadingBlurView.bounds + shimmerBorderLayer.mask?.frame = loadingBlurView.bounds + if component.isFullscreen { + loadingBlurView.removeFromSuperview() + } if !self.hadVideo { // TODO: hide fullscreen button without video let aspect: CGFloat = 16.0 / 9 diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index f382743231..6820f71989 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -189,18 +189,9 @@ final class StreamSheetComponent: CombinedComponent { context.add(bottomButtonsRow .position(CGPoint(x: bottomButtonsRow.size.width / 2, y: context.component.sheetHeight - 50 / 2 + topOffset - context.component.bottomPadding)) ) - (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: context.component.sheetHeight - 50 + topOffset - context.component.bottomPadding, width: bottomButtonsRow.size.width, height: bottomButtonsRow.size.height)) + (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: context.component.sheetHeight - 50 - 20 + topOffset - context.component.bottomPadding, width: bottomButtonsRow.size.width, height: bottomButtonsRow.size.height )) } - /*if let leftItem = leftItem { - print(leftItem) - context.add(leftItem - .position(CGPoint(x: leftItem.size.width / 2.0, y: contentHeight / 2.0)) - ) - (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames = [ - .init(x: 0, y: 0, width: leftItem.size.width, height: leftItem.size.height) - ] - }*/ - + return size } } @@ -486,12 +477,13 @@ class AnimatedCountLabel: UILabel { fatalError("init(coder:) has not been implemented") } var itemWidth: CGFloat { 36 } + var commaWidth: CGFloat { 8 } override func layoutSubviews() { super.layoutSubviews() let interItemSpacing: CGFloat = 0 let countWidth = chars.reduce(0) { if $1.attributedText?.string == "," { - return $0 + 12 + return $0 + commaWidth } return $0 + itemWidth + interItemSpacing } - interItemSpacing @@ -500,7 +492,7 @@ class AnimatedCountLabel: UILabel { chars.enumerated().forEach { (index, char) in let offset = chars[0..+=HiX_To;rJzKCV1LNKpIT{q3WJ#Agm+0SY$ zr#B^pKPdINtg_c&+nLSnT+Dyfcg;_k_1tpnA&DN{maUVOR32OG+yD1Ug{u8}8NM?| zow!@(d7QOaz~*?6R}&hwFDQ83n0scovu->dBu|c`ni=h`Czs z(XEMF4ru3V%$oQ`v^G1Yu;``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&di3{07xE{-7;jBoD*_APP{aZ8*b`hu&J{T0`h zsm7oEA32w6ujmbU{%p>Y8l#Pe(nB;()%^%hzwxi6&Cj8N>5#C%yoL`fE$Rw3j1M_E z{3#GR%NV!*dfx84|KyinUKzdn+OyA#7cT5Op8V}~z2xt|brGxgeEa@0MsNDUrL&ms z-H7V={=vxT_%tU5z1|-j{s%fgef;v`fNrZr+w?*UnYL+JTn2B;c3%}({=sPb zwJbh^wT&J}ynUILsS2*FSQ&X>3yYGZzA5hvyH1be-e;MY`74AS@a8yRqMjtWr-jj{ zSRw3?x5j}l(nqo?IN4|1b6nFgeL}-a(IcxWIJsxcb6nFi{X#<~*G8kbL(I#{1+*V5 z6#~g>RjlMmsAAhF6sJ`h9(DiwW*hda0ZUfK&%S%4QfaGD!}BG?X4X*{>c4Y`XSF(ahH4D zl8w68ydT_^axUrq>e0_I{mAaG9DxTWzI!_{p85SXTbJ)A{HMG>xaYL-x#yoZl&wAc qyt-D->hQDrr3a}OxuAq{FMeI>snngf+P(o3A%mx@pUXO@geCxz!Y%{= literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@3x.png b/submodules/TelegramUI/Images.xcassets/Call/close.imageset/close@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..8aee41f11ea18012e60962c3edf4d639f03c1d32 GIT binary patch literal 1169 zcmeAS@N?(olHy`uVBq!ia0vp^D?pfo4M>)FFWmy9I14-?iy0UcEkKyjb(&!UP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBu9~NdV@L(#+q81@vYa_Ex3OD`rY3P-rjmxTdO<$bpL$c z(5sKFmZdDnJ+P1e(eu|YUuI5fzWHtE+usWxXFUuH(YtUptF%C1xzx**7WVOPZ>-id z_q>&3roO$hvhwA;`Sb11J-oEFHs|-PZ$GD$#YOqM9R z)Anxn?JLQc<2(D}^v&8C_kFg{zF?Vozxdp{+dlgiZJIu_jjzCKN$|bz*&31=#zz~) zp8KaV=PomO@aWF^k_Bf)*y852Pvzg@$>%WV_+8fpzDXR9Y~JQEXityB z|6Oh#kM_)Q*e_wMQP}6a;C_$bG>hZP7i{%7edav=b$MUOpMrC`;_nyM*2w>|UVU}d z_q`$C-4?jd5q>G~$mZ-VANMT2sHDNr)6`{AO~@G;ktNR_@rI1pP~iAl>(Tg)-}zY#+_a zFYVeQ-zZn`uHCgQrb#H1-@5KY;cgkGzsK08ZRYsGy}jm%;EVXYS*KMl%)Q+=IdaFr zL%bJtYLj2i&Jg4OeKf(vYR*&fdP|Xse94TrrCF3N{nKHrTWsKRtG`|2jseHSdCv?@ z%=sLb9Iu=Z?!cgAduFkKGh5t}-<30RE;Mw1QSX1uC!I0zU;Igj>8GDAVgE75L%jRw zx%vwG)z_Zu+#*|T`t%(r2>>HCuJ75L)Us{2bHjo0{`;6&>()taZc!hf?JjCO zm$rHGZJ$iV^@dHL|+!^iPYemj(jhS;vFwRzf{F>RmXEQe0DfJK~BGMH2cnE998B;l{d6Tf3! Qz!HSP)78&qol`;+0FERDTmS$7 literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/Contents.json new file mode 100644 index 0000000000..8a5c7bd6df --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "expand.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "expand@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "expand@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand.png b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand.png new file mode 100644 index 0000000000000000000000000000000000000000..e29cf1f2f730098af451daa4502778ab6b50c0b7 GIT binary patch literal 491 zcmVRN>e+LABF$d6Q2DgD9IIQUJPsSUU>KX7wI zkUJvC9TDV?2y#aRxg&zy`2_;TafD@A4zCqOu^Z=k4pmiwH7YX0FyQv=(rf!clr1bu zbe?ChC3=ZAP4jp^D^V<87aHFTR9Fk0lqFmQ?uW?zH;DaOtO}VyhPiOfm z9Yv$;{k8Ub843Ghnj8ay`06 z(FeT>;!J*;z1Wi0C2EuK6+yFi3ft2(!Md)y&)yx$Q#AXew@W^dzt6ZMg4_{7?ua0F hM36fo$er5@{s9S~@!dXZ_(%W%002ovPDHLkV1j5_$zlKi literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@2x.png b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..c31034129474a8791cd455b7ad6da26f494c2043 GIT binary patch literal 821 zcmeAS@N?(olHy`uVBq!ia0vp^1t8491|*L?_~H$uI14-?iy0WiR6&^0Gf3qFP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&di3{0myT^vIy7~kHxSop|5#4T~-X4V;e8jXDo zTs)!-q7Ix>TsTV@WHWZ2Q8P}=Nwzi%x3{|A^V#79H@mX?q?t!{Gep)iEfEZmVLVD1 zap(So&cAi?eaDk`+|8T1fBxCDg=?QbKll7|frU&{_l(A`Rk`H}y;X0@cE3nWvOm;5 z+i&^S^m_qai#V>oHr*qg{J`N@UGtTbN)P@jo7Ak*v2!|`wwZJJ<;*L_M;|Xeqm~fA z`sypeeZ2kCCmC*VoL&*f5#g*jPceaYYDHUvf85b%9Wg~s!TZxrr#{X(ZF}!=W$FI! z4hR0x2#+DDR1Y76phoE_5lccyc^c(MH>598cDQ8V*}&o2=% zSbeo)+0@F}iJ8{5W;1=XVi(?pZuo!t%?9227n)WpW(j`W zwcTN^_W?!y!@7U2A8pxdSoB!PfX8HdgsI`&+cV!3iCP_rNxJ*aaQ*Ic{B2wBnSGvj{<(aAtlsnsVRwt)zR^2v ubm#bi3C!<$1&;3(e8i#A*FYihPJUD5v-Tg`tbysDfx*+&&t;ucLK6T!k7Jqu literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@3x.png b/submodules/TelegramUI/Images.xcassets/Call/expand.imageset/expand@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..34504618f6d418b07ca15f9719f8a3102a17114a GIT binary patch literal 1228 zcmeAS@N?(olHy`uVBq!ia0vp^D?pfo4M>)FFWmy9I14-?iy0UcEkKyjb(&!UP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBZlb4)V@L(#+q)M-rQIdk9v1q&Tgd!^!HU7E zLFh$8kPGV+23ZDK2lfk$Arn$OYWk9uH)>}u$uFD#{M7LSce~5VZvK3JJO00hz^i_y zgDhSqjY7T)T!b|)(uKLjp4->f#%9X%=g&WV`}XbSb$&j+QvvPm?WsM#{=R?zJ~cl- z|I7Jh0vXDtC%U`4`yGVm%%4Af_Uzf4_V16c`gAsNQ|=Xu_wU|$O}c#fa#4MKe3jA# zwpYua-Mks;8N$4F;kQG+LCpRA{i>m{{NG&`@T#o;Jnin9r9v6Z?(XibX9Y5tOM9vt{BgzO@stS^x6AXL8l_Nzz7*V#N!`%qn-TFI;A~FYBo4 zKH>Y1YpSZWij^+#oiRP|9LNHyw>^o$B!K`v9VKE3TDhG zeY_@L^Xs>D&jpxxmCnU;nzUHht6yMv6yvwRTJ79bmR-(|{>*YHcbRjTqlEp4V9 z2We@k*&tJowe=hnd|t$q`(}0R$44=~3!*pV@SOYJxM_A*ao5DzI-6MS`wy-(e*9Yf z`7-t%-blYWbLLE$H)Dg1;a7g9)6X7V_y72-`;zeVADxj~=d5Vg_;O7Bh0E4BznpG| z&SygYTk_sNs<%0~_TEgXowmMrS$Dl**&gQ~l)UxbwFRsF3SNIyF%5gIbwN>m$5so^ zvqeeeyLKJnuPj~Lw&mo^57UHqo9?tP&3mP?HT1U0E1|`8cZ(L;_Rq94OyLjw8{%u3 zReg%(*JMNKJ5|;*wz@O@J$U2p^u4lYZYekZOP{;;;NHEvt|>Q8`}$~2`6oH^t>(WS zuFI~v@OX8$?X1!TYIRR$=Pr2k=CBU4Xu5rx_zH{PMynl;-kg7vMeCg1S*3u&Z`q$3 zyEf-f<_nqgcXrN#M|0Xd*|g5RoDlah5H)(og4aT-vE z{ATWuIkhuGfrg)7lXm60t;_Z6*HeRg4rbf&3+IDLJ6`Pch)>8<@Q_j|MR-hKP(e*ND&|G_nG^|OL!lhqb+PRU?Wo#oJ}7C``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBevqe&V@L(#+cO6_4=C`sJ-ncIA#hjV^B2?Z zE=c#^)#!G@QNZQ#*V+#Xf7&}37#@7d2>ku_+q=`Rnsbi!?kzrX9=CEeDOS!k!Vd;YpP zJ(fY@UoO9$yoL2|iSDz->)zEr5xZaQ^X?t@ub10?Y<7Pw5!$9`davGiW-ZvH25wu% X{Z%bR-gBcig9JQX{an^LB{Ts5yuVS# literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@3x.png b/submodules/TelegramUI/Images.xcassets/Call/more.imageset/more@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..7d4d62533d57c4061363e12b4cca30cfb15be990 GIT binary patch literal 306 zcmeAS@N?(olHy`uVBq!ia0vp^Q6S901|%(3I5Gh#&H|6fVg?393lL^>oo1K-6l5$8 za(7}_cTVOdki(Mh=F{Fa=?e*K7ha3c0FI?o(Ie6((=e7jb zi(Ffpr!3Oeva`Fw>r(bXW=H+u)|2itdV!h{pke=JjmW8ARF7wWQ;ofnxpukc=}C(3 zxc8>*IG6G|^W5&Ga?_(#UT1oV&!4_4b?%Dgme2p++ur@Pv&wDuQIo!@pEs-6EnQI5kLPtRDOE&)^CXyOs_E` doB(9qoXg;uc|~-yZ|^~nxTmY1%Q~loCIHGUaz6k7 literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/Contents.json new file mode 100644 index 0000000000..fde2fc9c91 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "pip.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "pip@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "pip@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip.png b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip.png new file mode 100644 index 0000000000000000000000000000000000000000..bbb43de088a3ee4908a62ebeece1e2318fc39128 GIT binary patch literal 316 zcmeAS@N?(olHy`uVBq!ia0vp^av;pX1|+Qw)-3{3oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eB{zXq0$B+ufw^JVSHW>)C${%68;V|b&=&r^> zX7dZYZ#eQ6_%!(`NIwwdJIsGeF8BY0JJa`Ud017sb9azK;FPt&6-jL+N2;VI@ycpF zG}}8lYt7i9<-A+^<;cFxdll!VRX;xY+u;D8QJYv?rR{_&j%PCt|2Vcx zJgEJL;UCS`=RBF`mx&rFtgT2|wdLcSN<~KQgJ*pXUVkN(7}Xu9&xcFpQdXB-< L)z4*}Q$iB}iM4-i literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@2x.png b/submodules/TelegramUI/Images.xcassets/Call/pip.imageset/pip@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..adc3d2036d8f9f700fe0c2c9ebf1a81102acb622 GIT binary patch literal 504 zcmVnR}&W#*GzFX(V~zR`4U~m8wB_+X?wQoVf$!8Bdr=! zb?x$g9$vF(Fr96Me3bLO(LLkQ<9{n>TO%LsY}besn(&$ix2v%BzOOCE@GGB$@39Ge znE1rJtGe$uC`>Y8_v|Z7NxPM;QJ7@H9s15aQYd);T=N83YItp(zfaf^$qJ7Y=9NzQ z_~}i$!VUWCRHLu#u9CGR6CS(quBGbIYeKGP9r^hg$qIKSUa-E5HNp7QlxUL89~Ncv!Nfb7phiJ`y+o_q!XwrQL(bz`3az^!Zh3K^ku zxlQbtCA1pNVqa3DpX3z!O8Xwz@^)1u;h5#npkLlw9ym09j$4Z?>7?ubjv@*3KK$1) u;f%5k)LkVLen%X8!j2#!A|fIpA_3noJ=T5oc`yh70000oo1K-6l5$8 za(7}_cTVOdki(Mh=-}ZF^Ul8RO#T(+a>4DdIp5b7!&+UX zjMFdvZ{XZ@e5SU7Za$#}J>~&B7e^K4y%CdKA z{0VcxFEZO7>|0%J&y_D<`A4HBz5U|T%~i`SUf*8!TJhSC^l7_%X33mh(kW1)#4^`o zrDJ%m>8JfZUsm=S-R?3temL^`$?7#(Qv|=et{1tln8+&l=*q{ZfBr9gyfyRPZN9Ex z&2v7R@^4(29eSsS?`WP_&qt+pGet4+k`=4@cGtgkefYLDE;RZ**Ji=J7kTGedBslW z5IexylyYQlBG5VxM|GzQ5SxL_psWk?y&gw%him?uRkHX{{V5(;gfK94f97WXKD++O Rh5T5Mpr@;!%Q~loCIA3!0kHr8 literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Call/share.imageset/Contents.json b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/Contents.json new file mode 100644 index 0000000000..e1308a36f9 --- /dev/null +++ b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "share.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "share@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "share@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share.png b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share.png new file mode 100644 index 0000000000000000000000000000000000000000..1301ea076836945424be8424c7e61cf3fd3cd6bd GIT binary patch literal 504 zcmeAS@N?(olHy`uVBq!ia0vp^79h;Q1|(OsS<3+_&H|6fVg?3oVGw3ym^DWND9BhG z!*fxF&D=?9P4fr)GzV&)L*|{P^RCF-4cl z1)j!u=iN(*@m{B7(YILh||NQft*;R+UPS4&EqxURkTI#BGuWxRQ zI@hDhsxwpDKjx01@Qxe%A64Gl|MADavRHP#A7Rs`r2hOJ?;nzxxRiDJy}Wk^&pf=y zdaXL}PP)?h=fS4Fp9727Hn2!HPm8vxyTy|rd48&a>9wZ!CAM=jBe-Ad{d##K+!T9I c?R~(1PM&*eYy;13U_>!^y85}Sb4q9e089_dmH+?% literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@2x.png b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..004e1d87c6691d03aa0e758d5c8a0580732a9b33 GIT binary patch literal 959 zcmeAS@N?(olHy`uVBq!ia0vp^1t8491|*L?_~H$uI14-?iy0WiR6&^0Gf3qFP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&di49p>(E{-7;jBoEeoTwNma`dBh^zT|V79|Ik z4u?(#!3m5?3d}AI948uh1Q;dv)Gp87?Ug$-DtXyg+qZvK?JCP&eT}{T@1A{kjq??a z7Jfb5GK*m;HD~{Q^?LU`XVZdbbhO4h3F`H-AAfAfx$AD;S`IFGfz!{|)@Pf|KDp`C zyQSuack`|vd%V%G`e*Em43kBDD^7Df?px7NztJt^fOf}?*I#=&nf@i)%~!X+61_XT zpUbq+E(85&LWl zWk1x|J>Qh_?|sK~se|0t8TZbq+}>BwDQHt5ZQ$p6=h)%KsRx;-PnPg;Hn{fb%-JPs z7R_(1%4(kdcw?-)aK1DT+q=hS(>6b7UEN^u+ot_hO!-A~IwF<^dH&n8^^D@ZaOOu_3(W6T+Y|^2lwWz@YQFty zmMZ_#w2y4%R&!H?T$C*e&ez(Vk(1xacKH8B>;0d9{jE!wT~a5JgmcXn%u$+W__ae`sQYtx@>9JB#@pW)YRt`P zoAFfc!>@f&VjuHjqaJ)MY)f|c)_mH7m?*-)y`3_3=G>j_6xz82v2~qH@wiN$z*#SKXhmw7@#m3z**+JYD@<);T3K F0RYD-q7VQ8 literal 0 HcmV?d00001 diff --git a/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@3x.png b/submodules/TelegramUI/Images.xcassets/Call/share.imageset/share@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..5d1a2c011a20f083063e3796fe0a6aa5c28031c9 GIT binary patch literal 1393 zcmeAS@N?(olHy`uVBq!ia0vp^D?pfo4M>)FFWmy9I14-?iy0UcEkKyjb(&!UP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&di46JOPE{-7;jBoG$H&k(yIQDVhYHkM(mj<2_ z4IBcDk_t>N8(bPVPA~{6Fd5C5oxX0_sg2)`-&k4utaktWRl4P+-`iLHj{E+lC$I(p=fbZw`x}s&s{1 zUpk}Dq1r$A8)qq8 z`p9v`L;B^C+aWgvR?IXD(p})NU|Pv*&xvudu~Xl0T)FY@U)`@*btcp4wb@_%55|=A zu6TX;`t|G2uD^9RG5x;Lc!|f;A3t`aI4=m+u!!Z>@~WM-)pVw5(7EQmi*A+0#mU=e zT`PT=VWJf&@PeHyINkF6`SWY}3v+UFr@nsu`k6pUg{V~Nl+eHY>;A84{*jZD69QBa zTJq98;O(s!InM;j^TWc!Pv5;8``L8WmF+W4=H}<+@s(v89{pE4^}_Aj(V_ZgY{ql_ ze_v6~xbf2NuE_m7WjE^y3p3-mm;bu8#buJ#g%`n#!oKcO>lMkIl^xJKXZP;i3)f#) zI>(pk7I1W>^n1w+m9^_Vo$sjo8aBP!;5yC1RXKCYOr@Fyw^ro!FL(a1YFic85@Nrw{f5}bzdde?wRQ_Azwi%#zbkifW=Op37WGLn`{t)| z3BI^|Ir!xvU7yaX7mHU Date: Tue, 6 Dec 2022 21:14:14 +0400 Subject: [PATCH 10/50] Fixing counter separator and labels text color --- .../Components/AnimatedCountView.swift | 465 ++++++++++++++++++ .../Components/AnimatedCounterView.swift | 451 +++++++++++++++++ .../Components/MediaStreamComponent.swift | 17 +- .../MediaStreamVideoComponent.swift | 1 - .../Components/StreamSheetComponent.swift | 434 ---------------- 5 files changed, 928 insertions(+), 440 deletions(-) create mode 100644 submodules/TelegramCallsUI/Sources/Components/AnimatedCountView.swift create mode 100644 submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCountView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCountView.swift new file mode 100644 index 0000000000..70b3a9cbbb --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCountView.swift @@ -0,0 +1,465 @@ +/*import Foundation +import UIKit + +import Display + +private let purple = UIColor(rgb: 0x3252ef) +private let pink = UIColor(rgb: 0xe4436c) + +private let latePurple = UIColor(rgb: 0x974aa9) +private let latePink = UIColor(rgb: 0xf0436c) + +public final class AnimatedCountView: UIView { + let countLabel = AnimatedCountLabel() +// let titleLabel = UILabel() + let subtitleLabel = UILabel() + + private let foregroundView = UIView() + private let foregroundGradientLayer = CAGradientLayer() + private let maskingView = UIView() + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + + self.foregroundGradientLayer.type = .radial + self.foregroundGradientLayer.colors = [pink.cgColor, purple.cgColor, purple.cgColor] + self.foregroundGradientLayer.locations = [0.0, 0.85, 1.0] + self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0) + self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) + + self.foregroundView.mask = self.maskingView + self.foregroundView.layer.addSublayer(self.foregroundGradientLayer) + + self.addSubview(self.foregroundView) +// self.addSubview(self.titleLabel) + self.addSubview(self.subtitleLabel) + + self.maskingView.addSubview(countLabel) + countLabel.clipsToBounds = false + subtitleLabel.textAlignment = .center +// self.backgroundColor = UIColor.white.withAlphaComponent(0.1) + } + + override public func layoutSubviews() { + super.layoutSubviews() + + self.foregroundView.frame = CGRect(origin: CGPoint.zero, size: bounds.size)// .insetBy(dx: -40, dy: -40) + self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60) + self.maskingView.frame = CGRect(origin: .zero, size: bounds.size) + countLabel.frame = CGRect(origin: .zero, size: bounds.size) + subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - 10 : bounds.height - 6, width: subtitleLabel.intrinsicContentSize.width + 20, height: 20) + } + + func update(countString: String, subtitle: String) { + self.setupGradientAnimations() + + let text: String = countString// presentationStringsFormattedNumber(Int32(count), ",") + + // self.titleNode.attributedText = NSAttributedString(string: "", font: Font.with(size: 23.0, design: .round, weight: .semibold, traits: []), textColor: .white) + // let titleSize = self.titleNode.updateLayout(size) + // self.titleNode.frame = CGRect(x: floor((size.width - titleSize.width) / 2.0), y: 48.0, width: titleSize.width, height: titleSize.height) + if CGFloat(text.count * 40) < bounds.width - 32 { + self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) + } else { + self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: 54.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) + } +// var timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) +// if timerSize.width > size.width - 32.0 { +// self.timerNode.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) +// timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) +// } + +// self.timerNode.frame = CGRect(x: floor((size.width - timerSize.width) / 2.0), y: 78.0, width: timerSize.width, height: timerSize.height) + + self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, font: Font.with(size: 16.0, design: .round, weight: .semibold, traits: []), textColor: .white) + self.subtitleLabel.isHidden = subtitle.isEmpty +// let subtitleSize = self.subtitleNode.updateLayout(size) +// self.subtitleNode.frame = CGRect(x: floor((size.width - subtitleSize.width) / 2.0), y: 164.0, width: subtitleSize.width, height: subtitleSize.height) + +// self.foregroundView.frame = CGRect(origin: CGPoint(), size: size) + // self.setNeedsLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupGradientAnimations() { + if let _ = self.foregroundGradientLayer.animation(forKey: "movement") { + } else { + let previousValue = self.foregroundGradientLayer.startPoint + let newValue = CGPoint(x: CGFloat.random(in: 0.65 ..< 0.85), y: CGFloat.random(in: 0.1 ..< 0.45)) + self.foregroundGradientLayer.startPoint = newValue + + CATransaction.begin() + + let animation = CABasicAnimation(keyPath: "startPoint") + animation.duration = Double.random(in: 0.8 ..< 1.4) + animation.fromValue = previousValue + animation.toValue = newValue + + CATransaction.setCompletionBlock { [weak self] in +// if let isCurrentlyInHierarchy = self?.isCurrentlyInHierarchy, isCurrentlyInHierarchy { + self?.setupGradientAnimations() +// } + } + self.foregroundGradientLayer.add(animation, forKey: "movement") + CATransaction.commit() + } + } +} + +class AnimatedCharLayer: CATextLayer { + var text: String? { + get { + self.string as? String ?? (self.string as? NSAttributedString)?.string + } + set { + self.string = newValue + } + } + var attributedText: NSAttributedString? { + get { + self.string as? NSAttributedString //?? (self.string as? String).map { NSAttributed.init + } + set { + self.string = newValue + } + } + + var layer: CALayer { self } + + override init() { + super.init() + + self.contentsScale = UIScreen.main.scale + } + + override init(layer: Any) { + super.init(layer: layer) + self.contentsScale = UIScreen.main.scale + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class AnimatedCountLabel: UILabel { + override var text: String? { + get { + chars.reduce("") { $0 + ($1.text ?? "") } + } + set { + update(with: newValue ?? "") + } + } + + override var attributedText: NSAttributedString? { + get { + let string = NSMutableAttributedString() + for char in chars { + string.append(char.attributedText ?? NSAttributedString()) + } + return string + } + set { + udpateAttributed(with: newValue ?? NSAttributedString()) + } + } + + private var chars = [AnimatedCharLayer]() + private let containerView = UIView() + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + containerView.clipsToBounds = false + addSubview(containerView) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + var itemWidth: CGFloat { 36 } + var commaWidth: CGFloat { 8 } + var interItemSpacing: CGFloat { 0 } + + private func offsetForChar(at index: Int, within characters: [NSAttributedString]? = nil) -> CGFloat { + if let characters { + return characters[0.. size.width - 32.0 { +// self.timerNode.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) +// timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) +// } + +// self.timerNode.frame = CGRect(x: floor((size.width - timerSize.width) / 2.0), y: 78.0, width: timerSize.width, height: timerSize.height) + + self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, attributes: [.font: UIFont.systemFont(ofSize: 16, weight: .semibold)]) + self.subtitleLabel.isHidden = subtitle.isEmpty +// let subtitleSize = self.subtitleNode.updateLayout(size) +// self.subtitleNode.frame = CGRect(x: floor((size.width - subtitleSize.width) / 2.0), y: 164.0, width: subtitleSize.width, height: subtitleSize.height) + +// self.foregroundView.frame = CGRect(origin: CGPoint(), size: size) + // self.setNeedsLayout() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func setupGradientAnimations() { + if let _ = self.foregroundGradientLayer.animation(forKey: "movement") { + } else { + let previousValue = self.foregroundGradientLayer.startPoint + let newValue = CGPoint(x: CGFloat.random(in: 0.65 ..< 0.85), y: CGFloat.random(in: 0.1 ..< 0.45)) + self.foregroundGradientLayer.startPoint = newValue + + CATransaction.begin() + + let animation = CABasicAnimation(keyPath: "startPoint") + animation.duration = Double.random(in: 0.8 ..< 1.4) + animation.fromValue = previousValue + animation.toValue = newValue + + CATransaction.setCompletionBlock { [weak self] in +// if let isCurrentlyInHierarchy = self?.isCurrentlyInHierarchy, isCurrentlyInHierarchy { + self?.setupGradientAnimations() +// } + } + self.foregroundGradientLayer.add(animation, forKey: "movement") + CATransaction.commit() + } + } +} + +class AnimatedCharLayer: CATextLayer { + var text: String? { + get { + self.string as? String ?? (self.string as? NSAttributedString)?.string + } + set { + self.string = newValue + } + } + var attributedText: NSAttributedString? { + get { + self.string as? NSAttributedString //?? (self.string as? String).map { NSAttributed.init + } + set { + self.string = newValue + } + } + + var layer: CALayer { self } + + override init() { + super.init() + self.contentsScale = UIScreen.main.scale + self.masksToBounds = false + } + + override init(layer: Any) { + super.init(layer: layer) + self.contentsScale = UIScreen.main.scale + self.masksToBounds = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +class AnimatedCountLabel: UILabel { + override var text: String? { + get { + chars.reduce("") { $0 + ($1.text ?? "") } + } + set { +// update(with: newValue ?? "") + } + } + + override var attributedText: NSAttributedString? { + get { + let string = NSMutableAttributedString() + for char in chars { + string.append(char.attributedText ?? NSAttributedString()) + } + return string + } + set { + udpateAttributed(with: newValue ?? NSAttributedString()) + } + } + + private var chars = [AnimatedCharLayer]() + private let containerView = UIView() + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + containerView.clipsToBounds = false + addSubview(containerView) + self.clipsToBounds = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + var itemWidth: CGFloat { 36 } + var commaWidthForSpacing: CGFloat { 8 } + var commaFrameWidth: CGFloat { 36 } + var interItemSpacing: CGFloat { 0 } + var didBegin = false + + private func offsetForChar(at index: Int, within characters: [NSAttributedString]? = nil) -> CGFloat { + if let characters { + var offset = characters[0.. index && characters[index].string == "," { + offset -= 4 + } + return offset + } else { + var offset = self.chars[0.. index && self.chars[index].attributedText?.string == "," { + offset -= 4 + } + return offset + } + } + + override func layoutSubviews() { + super.layoutSubviews() + let countWidth = offsetForChar(at: chars.count) /*chars.reduce(0) { + if $1.attributedText?.string == "," { + return $0 + commaWidth + interItemSpacing + } + return $0 + itemWidth + interItemSpacing + }*/ - interItemSpacing + + containerView.frame = .init(x: bounds.midX - countWidth / 2, y: 0, width: countWidth, height: bounds.height) + chars.enumerated().forEach { (index, char) in + let offset = offsetForChar(at: index) +// char.frame.size.width = char.attributedText?.string == "," ? commaFrameWidth : itemWidth + char.frame.origin.x = offset +// char.frame.origin.x = CGFloat(chars.count - 1 - index) * (40 + interItemSpacing) + char.frame.origin.y = 0 + } + } + + func udpateAttributed(with newString: NSAttributedString) { + let interItemSpacing: CGFloat = 0 + + let separatedStrings = Array(newString.string).map { String($0) } + var range = NSRange(location: 0, length: 0) + var newChars = [NSAttributedString]() + for string in separatedStrings { + range.length = string.count + let attributedString = newString.attributedSubstring(from: range) + newChars.append(attributedString) + range.location += range.length + } + + let currentChars = chars.map { $0.attributedText ?? .init() } + + let maxAnimationDuration: TimeInterval = 0.5 + var numberOfChanges = abs(newChars.count - currentChars.count) + for index in 0.. self.bounds.width { + let scale = countWidth / self.bounds.width + self.transform = .init(scaleX: scale, y: scale) + } else { + self.transform = .identity + } + // containerView.backgroundColor = .red.withAlphaComponent(0.3) + } + } else { + containerView.frame = .init(x: self.bounds.midX - countWidth / 2, y: 0, width: countWidth, height: self.bounds.height) + didBegin = true + } +// self.backgroundColor = .green.withAlphaComponent(0.2) + self.clipsToBounds = false + } + func animateOut(for layer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) { +// let animation = CAKeyframeAnimation() +// animation.keyPath = "opacity" +// animation.values = [layer.presentation()?.value(forKey: "opacity") ?? 1, 0.0] +// animation.keyTimes = [0, 1] +// animation.duration = duration +// animation.beginTime = CACurrentMediaTime() + beginTime +//// animation.isAdditive = true +// animation.isRemovedOnCompletion = false +// animation.fillMode = .backwards +// layer.opacity = 0 +// layer.add(animation, forKey: "opacity") +// +// + let opacityInAnimation = CABasicAnimation(keyPath: "opacity") + opacityInAnimation.fromValue = 1 + opacityInAnimation.toValue = 0 + opacityInAnimation.duration = duration + opacityInAnimation.beginTime = CACurrentMediaTime() + beginTime + layer.add(opacityInAnimation, forKey: "opacity") + + Timer.scheduledTimer(withTimeInterval: duration + beginTime, repeats: false) { timer in + DispatchQueue.main.async { // After(deadline: .now() + duration + beginTime) { + layer.removeFromSuperlayer() + } + } + + let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale") + scaleOutAnimation.fromValue = 1 // layer.presentation()?.value(forKey: "transform.scale") ?? 1 + scaleOutAnimation.toValue = 0.1 + scaleOutAnimation.duration = duration + scaleOutAnimation.beginTime = CACurrentMediaTime() + beginTime + layer.add(scaleOutAnimation, forKey: "scaleout") + + let translate = CABasicAnimation(keyPath: "transform.translation") + translate.fromValue = CGPoint.zero + translate.toValue = CGPoint(x: 0, y: -layer.bounds.height * 0.3)// -layer.bounds.height + 3.0) + translate.duration = duration + translate.beginTime = CACurrentMediaTime() + beginTime + layer.add(translate, forKey: "translate") + } + + func animateIn(for newLayer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) { + newLayer.opacity = 0 + // newLayer.backgroundColor = UIColor.red.cgColor + + let opacityInAnimation = CABasicAnimation(keyPath: "opacity") + opacityInAnimation.fromValue = 0 + opacityInAnimation.toValue = 1 + opacityInAnimation.duration = duration + opacityInAnimation.beginTime = CACurrentMediaTime() + beginTime +// opacityInAnimation.isAdditive = true + opacityInAnimation.fillMode = .backwards + newLayer.opacity = 1 + newLayer.add(opacityInAnimation, forKey: "opacity") +// newLayer.opacity = 1 + + let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale") + scaleOutAnimation.fromValue = 0 + scaleOutAnimation.toValue = 1 + scaleOutAnimation.duration = duration + scaleOutAnimation.beginTime = CACurrentMediaTime() + beginTime +// scaleOutAnimation.isAdditive = true + newLayer.add(scaleOutAnimation, forKey: "scalein") + + let animation = CAKeyframeAnimation() + animation.keyPath = "position.y" + animation.values = [18, -6, 0] + animation.keyTimes = [0, 0.64, 1] + animation.timingFunction = CAMediaTimingFunction.init(name: .easeInEaseOut) + animation.duration = duration / 0.64 + animation.beginTime = CACurrentMediaTime() + beginTime + animation.isAdditive = true + newLayer.add(animation, forKey: "pos") + } +} diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 30bf2b9a48..d1ff3a84a8 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -55,6 +55,7 @@ final class StreamTitleComponent: Component { label.text = "LIVE" label.font = .systemFont(ofSize: 12, weight: .semibold) label.textAlignment = .center + label.textColor = .white layer.addSublayer(stalledAnimatedGradient) self.clipsToBounds = true if #available(iOS 13.0, *) { @@ -81,14 +82,14 @@ final class StreamTitleComponent: Component { if !wasLive { // TODO: animate wasLive = true - let frame = self.frame +// let frame = self.frame UIView.animate(withDuration: 0.15, animations: { self.toggle(isLive: true) self.transform = .init(scaleX: 1.5, y: 1.5) }, completion: { _ in UIView.animate(withDuration: 0.15) { self.transform = .identity - self.frame = frame +// self.frame = frame } }) return @@ -663,6 +664,7 @@ final class RoundGradientButtonComponent: Component { titleLabel.textAlignment = .center iconView.contentMode = .scaleAspectFit titleLabel.font = .systemFont(ofSize: 13) + titleLabel.textColor = .white } required init?(coder: NSCoder) { @@ -806,9 +808,10 @@ public final class _MediaStreamComponent: CombinedComponent { } var updated = false - // TODO: remove debug +// TODO: remove debug timer Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in - strongSelf.infoThrottler.publish(members.totalCount/*Int.random(in: 0..<10000000)*/) { [weak strongSelf] latestCount in + strongSelf.infoThrottler.publish(/*members.totalCount*/ Int.random(in: 0..<10000000)) { [weak strongSelf] latestCount in + print(members.totalCount) guard let strongSelf = strongSelf else { return } var updated = false let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: latestCount) @@ -1846,6 +1849,7 @@ public final class _MediaStreamComponentController: ViewControllerComponentConta } } +public typealias MediaStreamComponent = _MediaStreamComponent public typealias MediaStreamComponentController = _MediaStreamComponentController public final class Throttler { @@ -1880,7 +1884,10 @@ public final class Throttler { if lastValue == nil { queue.asyncAfter(deadline: .now() + duration) { [self] in accumulator.removeAll() - isThrottling = false + // TODO: quick fix, replace with timer + queue.asyncAfter(deadline: .now() + duration) { [self] in + isThrottling = false + } guard let lastValue = lastValue, diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 2b1781d797..ec41f4b8dd 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -9,7 +9,6 @@ import Display import ShimmerEffect import TelegramCore - typealias MediaStreamVideoComponent = _MediaStreamVideoComponent final class _MediaStreamVideoComponent: Component { diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 6820f71989..436bb7a054 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -304,437 +304,3 @@ final class ParticipantsComponent: Component { } } - -public final class AnimatedCountView: UIView { - let countLabel = AnimatedCountLabel() -// let titleLabel = UILabel() - let subtitleLabel = UILabel() - - private let foregroundView = UIView() - private let foregroundGradientLayer = CAGradientLayer() - private let maskingView = UIView() - - override init(frame: CGRect = .zero) { - super.init(frame: frame) - - self.foregroundGradientLayer.type = .radial - self.foregroundGradientLayer.colors = [pink.cgColor, purple.cgColor, purple.cgColor] - self.foregroundGradientLayer.locations = [0.0, 0.85, 1.0] - self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0) - self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) - - self.foregroundView.mask = self.maskingView - self.foregroundView.layer.addSublayer(self.foregroundGradientLayer) - - self.addSubview(self.foregroundView) -// self.addSubview(self.titleLabel) - self.addSubview(self.subtitleLabel) - - self.maskingView.addSubview(countLabel) - - subtitleLabel.textAlignment = .center -// self.backgroundColor = UIColor.white.withAlphaComponent(0.1) - } - - override public func layoutSubviews() { - super.layoutSubviews() - - self.foregroundView.frame = CGRect(origin: CGPoint.zero, size: bounds.size)// .insetBy(dx: -40, dy: -40) - self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60) - self.maskingView.frame = CGRect(origin: .zero, size: bounds.size) - countLabel.frame = CGRect(origin: .zero, size: bounds.size) - subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - 10 : bounds.height - 6, width: subtitleLabel.intrinsicContentSize.width + 20, height: 20) - } - - func update(countString: String, subtitle: String) { - self.setupGradientAnimations() - - let text: String = countString// presentationStringsFormattedNumber(Int32(count), ",") - - // self.titleNode.attributedText = NSAttributedString(string: "", font: Font.with(size: 23.0, design: .round, weight: .semibold, traits: []), textColor: .white) - // let titleSize = self.titleNode.updateLayout(size) - // self.titleNode.frame = CGRect(x: floor((size.width - titleSize.width) / 2.0), y: 48.0, width: titleSize.width, height: titleSize.height) - if CGFloat(text.count * 40) < bounds.width - 32 { - self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) - } else { - self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: 54.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) - } -// var timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) -// if timerSize.width > size.width - 32.0 { -// self.timerNode.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) -// timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) -// } - -// self.timerNode.frame = CGRect(x: floor((size.width - timerSize.width) / 2.0), y: 78.0, width: timerSize.width, height: timerSize.height) - - self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, font: Font.with(size: 16.0, design: .round, weight: .semibold, traits: []), textColor: .white) - self.subtitleLabel.isHidden = subtitle.isEmpty -// let subtitleSize = self.subtitleNode.updateLayout(size) -// self.subtitleNode.frame = CGRect(x: floor((size.width - subtitleSize.width) / 2.0), y: 164.0, width: subtitleSize.width, height: subtitleSize.height) - -// self.foregroundView.frame = CGRect(origin: CGPoint(), size: size) - // self.setNeedsLayout() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupGradientAnimations() { - if let _ = self.foregroundGradientLayer.animation(forKey: "movement") { - } else { - let previousValue = self.foregroundGradientLayer.startPoint - let newValue = CGPoint(x: CGFloat.random(in: 0.65 ..< 0.85), y: CGFloat.random(in: 0.1 ..< 0.45)) - self.foregroundGradientLayer.startPoint = newValue - - CATransaction.begin() - - let animation = CABasicAnimation(keyPath: "startPoint") - animation.duration = Double.random(in: 0.8 ..< 1.4) - animation.fromValue = previousValue - animation.toValue = newValue - - CATransaction.setCompletionBlock { [weak self] in -// if let isCurrentlyInHierarchy = self?.isCurrentlyInHierarchy, isCurrentlyInHierarchy { - self?.setupGradientAnimations() -// } - } - self.foregroundGradientLayer.add(animation, forKey: "movement") - CATransaction.commit() - } - } -} - -class AnimatedCharLayer: CATextLayer { - var text: String? { - get { - self.string as? String ?? (self.string as? NSAttributedString)?.string - } - set { - self.string = newValue - } - } - var attributedText: NSAttributedString? { - get { - self.string as? NSAttributedString //?? (self.string as? String).map { NSAttributed.init - } - set { - self.string = newValue - } - } - - var layer: CALayer { self } - - override init() { - super.init() - - self.contentsScale = UIScreen.main.scale - } - - override init(layer: Any) { - super.init(layer: layer) - self.contentsScale = UIScreen.main.scale - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -class AnimatedCountLabel: UILabel { - override var text: String? { - get { - chars.reduce("") { $0 + ($1.text ?? "") } - } - set { - update(with: newValue ?? "") - } - } - - override var attributedText: NSAttributedString? { - get { - let string = NSMutableAttributedString() - for char in chars { - string.append(char.attributedText ?? NSAttributedString()) - } - return string - } - set { - udpateAttributed(with: newValue ?? NSAttributedString()) - } - } - - private var chars = [AnimatedCharLayer]() - private let containerView = UIView() - - override init(frame: CGRect = .zero) { - super.init(frame: frame) - - addSubview(containerView) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - var itemWidth: CGFloat { 36 } - var commaWidth: CGFloat { 8 } - override func layoutSubviews() { - super.layoutSubviews() - let interItemSpacing: CGFloat = 0 - let countWidth = chars.reduce(0) { - if $1.attributedText?.string == "," { - return $0 + commaWidth - } - return $0 + itemWidth + interItemSpacing - } - interItemSpacing - - containerView.frame = .init(x: bounds.midX - countWidth / 2, y: 0, width: countWidth, height: bounds.height) - chars.enumerated().forEach { (index, char) in - let offset = chars[0.. Date: Tue, 6 Dec 2022 21:21:59 +0400 Subject: [PATCH 11/50] Adding proper "live" animation --- .../Sources/Components/AnimatedCounterView.swift | 2 +- .../Components/MediaStreamComponent.swift | 16 +++++++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index a5cd532032..afb69f52a8 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -63,7 +63,7 @@ public final class AnimatedCountView: UIView { // } else { // self.countLabel.attributedText = NSAttributedString(string: text, attributes: [.font: UIFont.systemFont(ofSize: 54, weight: .semibold)]) // } - self.countLabel.attributedText = NSAttributedString(string: text, attributes: [.font: UIFont.systemFont(ofSize: 60, weight: .semibold)]) + self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) // self.countLabel.attributedText = NSAttributedString(string: text, attributes: [.font: UIFont.systemFont(ofSize: 60, weight: .semibold)]) // var timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) // if timerSize.width > size.width - 32.0 { diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index d1ff3a84a8..d32af0563a 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -83,14 +83,20 @@ final class StreamTitleComponent: Component { // TODO: animate wasLive = true // let frame = self.frame + let anim = CAKeyframeAnimation(keyPath: "transform.scale") + anim.values = [1.0, 1.4, 1.0] + anim.keyTimes = [0, 0.5, 1] + self.layer.add(anim, forKey: "transform") + +// UIView.transition(with: self, duration: <#T##TimeInterval#>, animations: <#T##(() -> Void)?##(() -> Void)?##() -> Void#>) UIView.animate(withDuration: 0.15, animations: { self.toggle(isLive: true) - self.transform = .init(scaleX: 1.5, y: 1.5) +// self.transform = .init(scaleX: 1.5, y: 1.5) }, completion: { _ in - UIView.animate(withDuration: 0.15) { - self.transform = .identity -// self.frame = frame - } +// UIView.animate(withDuration: 0.15) { +// self.transform = .identity +//// self.frame = frame +// } }) return } From 96876f1611de188d7e5b3f97ecd50ce7b133d44b Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Tue, 6 Dec 2022 23:44:06 +0400 Subject: [PATCH 12/50] Implementing sheet full expansion --- .../Source/Base/Transition.swift | 19 +++++ .../Components/MediaStreamComponent.swift | 73 +++++++++++++------ .../Components/StreamSheetComponent.swift | 70 ++++++++++++++---- 3 files changed, 128 insertions(+), 34 deletions(-) diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index b7601ab3db..cb73129ceb 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -617,6 +617,25 @@ public struct Transition { } } + public func animateCornerRadius(layer: CALayer, from fromValue: CGFloat, to toValue: CGFloat) { + switch self.animation { + case .none: + break + case let .curve(duration, curve): + layer.animate( + from: fromValue as NSNumber, + to: toValue as NSNumber, + keyPath: "cornerRadius", + duration: duration, + delay: 0.0, + curve: curve, + removeOnCompletion: true, + additive: false, + completion: nil + ) + } + } + public func animateBoundsOrigin(layer: CALayer, from fromValue: CGPoint, to toValue: CGPoint, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { switch self.animation { case .none: diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index d32af0563a..4665bac49b 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -736,6 +736,7 @@ public final class _MediaStreamComponent: CombinedComponent { private(set) var displayUI: Bool = true var dismissOffset: CGFloat = 0.0 + var initialOffset: CGFloat = 0.0 // TODO: remove (replaced by isFullscreen) var storedIsLandscape: Bool? var isFullscreen: Bool = false @@ -976,6 +977,7 @@ public final class _MediaStreamComponent: CombinedComponent { } var isFullscreen = state.isFullscreen let isLandscape = context.availableSize.width > context.availableSize.height + if let videoSize = context.state.videoSize { // Always fullscreen in landscape if /*videoSize.width > videoSize.height &&*/ isLandscape && !isFullscreen { @@ -987,6 +989,16 @@ public final class _MediaStreamComponent: CombinedComponent { } } + let videoHeight: CGFloat = context.availableSize.width / 16 * 9 + let bottomPadding = 40 + environment.safeInsets.bottom + let sheetHeight: CGFloat = isFullscreen ? context.availableSize.height : (44 + videoHeight + 40 + 69 + 16 + 32 + 70 + bottomPadding) + let isFullyDragged = context.availableSize.height - sheetHeight + state.dismissOffset - context.view.safeAreaInsets.top < 30 + + var dragOffset = context.state.dismissOffset + if isFullyDragged { + dragOffset = max(context.state.dismissOffset, sheetHeight - context.availableSize.height + context.view.safeAreaInsets.top)// sheetHeight - UIScreen.main.bounds.height + } + let video = video.update( component: MediaStreamVideoComponent( call: context.component.call, @@ -1324,12 +1336,8 @@ public final class _MediaStreamComponent: CombinedComponent { subtitle: memberCountString )) } - - let videoHeight: CGFloat = context.availableSize.width / 16 * 9 - let bottomPadding = 40 + environment.safeInsets.bottom - let sheetHeight: CGFloat = isFullscreen ? context.availableSize.height : (44 + videoHeight + 40 + 69 + 16 + 32 + 70 + bottomPadding) - let isFullyDragged = context.availableSize.height - sheetHeight + state.dismissOffset < 30 - + let availableSize = context.availableSize + let safeAreaTop = context.view.safeAreaInsets.top context.add(background .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) .gesture(.tap { [weak state] in @@ -1344,9 +1352,9 @@ public final class _MediaStreamComponent: CombinedComponent { } switch panState { case .began: - break + state.initialOffset = state.dismissOffset case let .updated(offset): - state.updateDismissOffset(value: offset.y, interactive: true) + state.updateDismissOffset(value: state.initialOffset + offset.y, interactive: true) case let .ended(velocity): // TODO: Dismiss sheet depending on velocity if velocity.y > 200.0 { @@ -1357,7 +1365,11 @@ public final class _MediaStreamComponent: CombinedComponent { controller.updateOrientation(orientation: .portrait) } } else { - let _ = call.leave(terminateIfPossible: false) + if isFullyDragged || state.initialOffset != 0 { + state.updateDismissOffset(value: 0.0, interactive: false) + } else { + let _ = call.leave(terminateIfPossible: false) + } } /*activatePictureInPicture.invoke(Action { [weak state] in guard let state = state, let controller = controller() as? MediaStreamComponentController else { @@ -1367,7 +1379,16 @@ public final class _MediaStreamComponent: CombinedComponent { controller.dismiss(closing: false, manual: true) })*/ } else { - state.updateDismissOffset(value: 0.0, interactive: false) + if isFullyDragged { + state.updateDismissOffset(value: sheetHeight - availableSize.height + safeAreaTop, interactive: false) + } else { + if velocity.y < -200 { + // Expand + state.updateDismissOffset(value: sheetHeight - availableSize.height + safeAreaTop, interactive: false) + } else { + state.updateDismissOffset(value: 0.0, interactive: false) + } + } } } }) @@ -1483,19 +1504,21 @@ public final class _MediaStreamComponent: CombinedComponent { component: StreamSheetComponent( topComponent: AnyComponent(navigationComponent), bottomButtonsRow: bottomComponent, - topOffset: context.availableSize.height - sheetHeight + context.state.dismissOffset, - sheetHeight: max(sheetHeight - context.state.dismissOffset, sheetHeight), + topOffset: context.availableSize.height - sheetHeight + dragOffset, + sheetHeight: max(sheetHeight - dragOffset, sheetHeight), backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), bottomPadding: bottomPadding, - participantsCount: context.state.originInfo?.memberCount ?? 0 // Int.random(in: 0...999998)// [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! + participantsCount: context.state.originInfo?.memberCount ?? 0, // Int.random(in: 0...999998)// [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! // + isFullyExtended: isFullyDragged, + deviceCornerRadius: deviceCornerRadius ?? 0 ), availableSize: context.availableSize, transition: context.transition ) // TODO: calculate (although not necessary currently) - let sheetOffset: CGFloat = context.availableSize.height - sheetHeight + context.state.dismissOffset + let sheetOffset: CGFloat = context.availableSize.height - sheetHeight + dragOffset let sheetPosition = sheetOffset + sheetHeight / 2 // Sheet underneath the video when in sheet // if !isFullscreen { @@ -1507,7 +1530,7 @@ public final class _MediaStreamComponent: CombinedComponent { let videoPos: CGFloat if isFullscreen { - videoPos = context.availableSize.height / 2 + state.dismissOffset + videoPos = context.availableSize.height / 2 + dragOffset } else { videoPos = sheetPosition - sheetHeight / 2 + videoHeight / 2 + 50 } @@ -1516,7 +1539,7 @@ public final class _MediaStreamComponent: CombinedComponent { ) } else { context.add(video - .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2 + state.dismissOffset) + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2 + dragOffset) )) } @@ -1571,7 +1594,9 @@ public final class _MediaStreamComponent: CombinedComponent { sheetHeight: max(sheetHeight - context.state.dismissOffset, sheetHeight), backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), bottomPadding: 12, - participantsCount: -1 // context.state.originInfo?.memberCount ?? 0 + participantsCount: -1, // context.state.originInfo?.memberCount ?? 0 + isFullyExtended: isFullyDragged, + deviceCornerRadius: deviceCornerRadius ?? 0 ), availableSize: context.availableSize, transition: context.transition @@ -1597,6 +1622,9 @@ public final class _MediaStreamComponent: CombinedComponent { } } +// TODO: pass to component properly +var deviceCornerRadius: CGFloat? = nil + public final class _MediaStreamComponentController: ViewControllerComponentContainer, VoiceChatController { private let context: AccountContext public let call: PresentationGroupCall @@ -1642,12 +1670,15 @@ public final class _MediaStreamComponentController: ViewControllerComponentConta view.expandFromPictureInPicture() } - if let _ = self.validLayout { + if let validLayout = self.validLayout { self.view.clipsToBounds = true + + // TODO: pass to component properly + deviceCornerRadius = validLayout.deviceMetrics.screenCornerRadius - 1// 0.5 // self.view.layer.cornerRadius = validLayout.deviceMetrics.screenCornerRadius - if #available(iOS 13.0, *) { - self.view.layer.cornerCurve = .continuous - } +// if #available(iOS 13.0, *) { +// self.view.layer.cornerCurve = .continuous +// } self.view.layer.animatePosition(from: CGPoint(x: self.view.frame.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), to: self.view.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in // [weak self] _ in // self?.view.layer.cornerRadius = 0.0 diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 436bb7a054..971e8b0d6b 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -19,6 +19,8 @@ final class StreamSheetComponent: CombinedComponent { let backgroundColor: UIColor let participantsCount: Int let bottomPadding: CGFloat + let isFullyExtended: Bool + let deviceCornerRadius: CGFloat init( // color: UIColor, @@ -28,7 +30,9 @@ final class StreamSheetComponent: CombinedComponent { sheetHeight: CGFloat, backgroundColor: UIColor, bottomPadding: CGFloat, - participantsCount: Int + participantsCount: Int, + isFullyExtended: Bool, + deviceCornerRadius: CGFloat ) { // self.leftItem = leftItem self.topComponent = topComponent @@ -39,6 +43,8 @@ final class StreamSheetComponent: CombinedComponent { self.backgroundColor = backgroundColor self.bottomPadding = bottomPadding self.participantsCount = participantsCount + self.isFullyExtended = isFullyExtended + self.deviceCornerRadius = deviceCornerRadius } static func ==(lhs: StreamSheetComponent, rhs: StreamSheetComponent) -> Bool { @@ -66,6 +72,9 @@ final class StreamSheetComponent: CombinedComponent { if lhs.participantsCount != rhs.participantsCount { return false } + if lhs.isFullyExtended != rhs.isFullyExtended { + return false + } return true } // @@ -80,7 +89,7 @@ final class StreamSheetComponent: CombinedComponent { } func update(component: StreamSheetComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { - self.backgroundColor = .purple.withAlphaComponent(0.6) +// self.backgroundColor = .purple.withAlphaComponent(0.6) return availableSize } @@ -132,11 +141,21 @@ final class StreamSheetComponent: CombinedComponent { return { context in let availableWidth = context.availableSize.width // let sideInset: CGFloat = 16.0 + context.component.sideInset - let contentHeight: CGFloat = 44.0 let size = context.availableSize// CGSize(width: context.availableSize.width, height:44)// context.component.topInset + contentHeight) - let background = background.update(component: SheetBackgroundComponent(color: context.component.backgroundColor), availableSize: CGSize(width: size.width, height: context.component.sheetHeight), transition: context.transition) + let topOffset = context.component.topOffset + let backgroundExtraOffset = context.component.isFullyExtended ? -context.view.safeAreaInsets.top : 0 + + let background = background.update( + component: SheetBackgroundComponent( + color: context.component.backgroundColor, + radius: context.component.isFullyExtended ? context.component.deviceCornerRadius : 16, + offset: backgroundExtraOffset + ), + availableSize: CGSize(width: size.width, height: context.component.sheetHeight), + transition: context.transition + ) let topItem = context.component.topComponent.flatMap { topItemComponent in return topItem.update( @@ -160,10 +179,9 @@ final class StreamSheetComponent: CombinedComponent { ) } - let topOffset = context.component.topOffset - context.add(background - .position(CGPoint(x: size.width / 2.0, y: context.component.topOffset + context.component.sheetHeight / 2)) + .position(CGPoint(x: size.width / 2.0, y: topOffset + context.component.sheetHeight / 2)) + // .position(CGPoint(x: size.width / 2.0, y: context.component.topOffset + context.component.sheetHeight / 2 + backgroundExtraOffset)) ) (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames = [] @@ -208,27 +226,45 @@ private let latePink = UIColor(rgb: 0xf0436c) final class SheetBackgroundComponent: Component { private let color: UIColor + private let radius: CGFloat + private let offset: CGFloat class View: UIView { private let backgroundView = UIView() - func update(availableSize: CGSize, color: UIColor, transition: Transition) { + func update(availableSize: CGSize, color: UIColor, cornerRadius: CGFloat, offset: CGFloat, transition: Transition) { if backgroundView.superview == nil { self.addSubview(backgroundView) } // To fix release animation let extraBottom: CGFloat = 500 - backgroundView.frame = .init(origin: .zero, size: .init(width: availableSize.width, height: availableSize.height + extraBottom)) - if backgroundView.backgroundColor != color { + + if backgroundView.backgroundColor != color && backgroundView.backgroundColor != nil { +// let initialVelocity: CGFloat = 0 +// let xtransition = ComponentFlow.Transition(animation: .curve(duration: 0.45, curve: .spring))// .animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) + UIView.animate(withDuration: 0.4) { [self] in backgroundView.backgroundColor = color + // TODO: determine if animation is needed (with facts and logic, not color) + backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottom)) } + + let anim = CABasicAnimation(keyPath: "cornerRadius") + anim.fromValue = backgroundView.layer.cornerRadius + backgroundView.layer.cornerRadius = cornerRadius + anim.toValue = cornerRadius + anim.duration = 0.4 + backgroundView.layer.add(anim, forKey: "cornerRadius") } else { backgroundView.backgroundColor = color + backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottom)) + backgroundView.layer.cornerRadius = cornerRadius } backgroundView.isUserInteractionEnabled = false backgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - backgroundView.layer.cornerRadius = 16 +// let currentRadius = backgroundView.layer.cornerRadius +// backgroundView.layer.cornerRadius = cornerRadius +// transition.animateCornerRadius(layer: backgroundView.layer, from: currentRadius, to: cornerRadius) backgroundView.clipsToBounds = true backgroundView.layer.masksToBounds = true } @@ -242,6 +278,12 @@ final class SheetBackgroundComponent: Component { if !lhs.color.isEqual(rhs.color) { return false } + if lhs.radius != rhs.radius { + return false + } + if lhs.offset != rhs.offset { + return false + } // if lhs.width != rhs.width { // return false // } @@ -251,12 +293,14 @@ final class SheetBackgroundComponent: Component { return true } - public init(color: UIColor) { + public init(color: UIColor, radius: CGFloat, offset: CGFloat) { self.color = color + self.radius = radius + self.offset = offset } public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - view.update(availableSize: availableSize, color: color, transition: transition) + view.update(availableSize: availableSize, color: color, cornerRadius: radius, offset: offset, transition: transition) return availableSize } } From a688d65873d9d881f50993ecd90f451035ceea85 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Wed, 7 Dec 2022 01:12:13 +0400 Subject: [PATCH 13/50] Fixing PiP transition corner radius and flickering --- .../Components/AnimatedCounterView.swift | 2 ++ .../Components/MediaStreamComponent.swift | 11 ++++++--- .../MediaStreamVideoComponent.swift | 24 ++++++++++++++++--- 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index afb69f52a8..7074143ed9 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -37,6 +37,8 @@ public final class AnimatedCountView: UIView { countLabel.clipsToBounds = false subtitleLabel.textAlignment = .center self.clipsToBounds = false + + subtitleLabel.textColor = .white // self.backgroundColor = UIColor.white.withAlphaComponent(0.1) } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 4665bac49b..53769805f8 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -743,6 +743,7 @@ public final class _MediaStreamComponent: CombinedComponent { var videoSize: CGSize? private(set) var canManageCall: Bool = false + // TODO: also handle pictureInPicturePossible let isPictureInPictureSupported: Bool private(set) var callTitle: String? @@ -757,6 +758,10 @@ public final class _MediaStreamComponent: CombinedComponent { private var scheduledDismissUITimer: SwiftSignalKit.Timer? var videoStalled: Bool = false + var videoIsPlayable: Bool { + !videoStalled && hasVideo + } + let deactivatePictureInPictureIfVisible = StoredActionSlot(Void.self) var videoHiddenForPip = false @@ -1036,7 +1041,7 @@ public final class _MediaStreamComponent: CombinedComponent { // let height = context.availableSize.height var navigationRightItems: [AnyComponentWithIdentity] = [] // let contextView = context.view - if context.state.isPictureInPictureSupported, context.state.hasVideo { + if context.state.isPictureInPictureSupported, context.state.videoIsPlayable { navigationRightItems.append(AnyComponentWithIdentity(id: "pip", component: AnyComponent(Button( content: AnyComponent(ZStack([ AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( @@ -1305,7 +1310,7 @@ public final class _MediaStreamComponent: CombinedComponent { }) )*/, rightItems: navigationRightItems, - centerItem: AnyComponent(StreamTitleComponent(text: state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isActive: context.state.hasVideo)) + centerItem: AnyComponent(StreamTitleComponent(text: state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isActive: context.state.videoIsPlayable)) ) // let navigationBar = navigationBar.update( @@ -1476,7 +1481,7 @@ public final class _MediaStreamComponent: CombinedComponent { title: "expand" )), action: { - guard state.hasVideo else { + guard state.videoIsPlayable else { state.isFullscreen = false return } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index ec41f4b8dd..e3b84da552 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -207,6 +207,9 @@ final class _MediaStreamVideoComponent: Component { videoView.alpha = 1 } if let sampleBufferVideoView = videoView as? SampleBufferVideoRenderingView { + sampleBufferVideoView.sampleBufferLayer.masksToBounds = true + sampleBufferVideoView.sampleBufferLayer.cornerRadius = 20 + if #available(iOS 13.0, *) { sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true } @@ -249,6 +252,8 @@ final class _MediaStreamVideoComponent: Component { } return delegate }())) + pictureInPictureController?.playerLayer.masksToBounds = false + pictureInPictureController?.playerLayer.cornerRadius = 30 } else if AVPictureInPictureController.isPictureInPictureSupported() { // TODO: support PiP for iOS < 15.0 // sampleBufferVideoView.sampleBufferLayer @@ -473,8 +478,15 @@ final class _MediaStreamVideoComponent: Component { } func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { - videoView?.alpha = 0 - videoBlurView?.alpha = 0 + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + self.videoView?.alpha = 0 + } + UIView.animate(withDuration: 0.3) { [self] in + videoBlurView?.alpha = 0 + } + // TODO: make safe + UIApplication.shared.windows.first?/*(where: { $0.layer !== (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.keyWindow?.layer })?*/.layer.cornerRadius = 10// (where: { !($0 is NativeWindow)*/ }) + UIApplication.shared.windows.first?.layer.masksToBounds = true } public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { @@ -498,11 +510,17 @@ final class _MediaStreamVideoComponent: Component { } else { self.component?.pictureInPictureClosed() } + // TODO: extract precise animation or observe window changes + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + self.videoView?.alpha = 1 + } + UIView.animate(withDuration: 0.3) { [self] in + self.videoBlurView?.alpha = 1 + } } func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { self.videoView?.alpha = 1 - self.videoBlurView?.alpha = 1 self.state?.updated(transition: .immediate) } } From a2dd1a14a8fe7d6eb027921b4a8f09c29f0de3a1 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Wed, 7 Dec 2022 05:03:24 +0400 Subject: [PATCH 14/50] Animation and placeholder progress --- .../Components/AnimatedCounterView.swift | 65 ++++++++++------ .../Components/MediaStreamComponent.swift | 2 +- .../MediaStreamVideoComponent.swift | 74 +++++++++++++++++-- 3 files changed, 112 insertions(+), 29 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index 7074143ed9..e57fa01fb3 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -252,7 +252,7 @@ class AnimatedCountLabel: UILabel { let currentChars = chars.map { $0.attributedText ?? .init() } - let maxAnimationDuration: TimeInterval = 0.5 + let maxAnimationDuration: TimeInterval = 1.2 var numberOfChanges = abs(newChars.count - currentChars.count) for index in 0.. 0 { containerView.frame = .init(x: self.bounds.midX - countWidth / 2, y: 0, width: countWidth, height: self.bounds.height) didBegin = true } @@ -389,32 +391,49 @@ class AnimatedCountLabel: UILabel { // layer.add(animation, forKey: "opacity") // // + let beginTimeOffset: CFTimeInterval = /*beginTime == .zero ? 0 :*/ /*CFTimeInterval(DispatchTime.now().uptimeNanoseconds / 1000000000)*/ layer.convertTime(CACurrentMediaTime(), to: nil) + let opacityInAnimation = CABasicAnimation(keyPath: "opacity") opacityInAnimation.fromValue = 1 opacityInAnimation.toValue = 0 - opacityInAnimation.duration = duration - opacityInAnimation.beginTime = CACurrentMediaTime() + beginTime - layer.add(opacityInAnimation, forKey: "opacity") +// opacityInAnimation.duration = duration +// opacityInAnimation.beginTime = beginTimeOffset + beginTime +// opacityInAnimation.completion = { _ in +// layer.removeFromSuperlayer() +// } +// layer.add(opacityInAnimation, forKey: "opacity") - Timer.scheduledTimer(withTimeInterval: duration + beginTime, repeats: false) { timer in - DispatchQueue.main.async { // After(deadline: .now() + duration + beginTime) { - layer.removeFromSuperlayer() - } - } +// let timer = Timer.scheduledTimer(withTimeInterval: duration + beginTime, repeats: false) { timer in + DispatchQueue.main.asyncAfter(deadline: .now() + duration * 0.95 + beginTime) { +// DispatchQueue.main.async { +// layer.backgroundColor = UIColor.red.withAlphaComponent(0.3).cgColor +// } +// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + layer.removeFromSuperlayer() +// } +// timer.invalidate() + } +// RunLoop.current.add(timer, forMode: .common) let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale") scaleOutAnimation.fromValue = 1 // layer.presentation()?.value(forKey: "transform.scale") ?? 1 - scaleOutAnimation.toValue = 0.1 - scaleOutAnimation.duration = duration - scaleOutAnimation.beginTime = CACurrentMediaTime() + beginTime - layer.add(scaleOutAnimation, forKey: "scaleout") + scaleOutAnimation.toValue = 0.0 +// scaleOutAnimation.duration = duration +// scaleOutAnimation.beginTime = beginTimeOffset + beginTime +// layer.add(scaleOutAnimation, forKey: "scaleout") let translate = CABasicAnimation(keyPath: "transform.translation") translate.fromValue = CGPoint.zero translate.toValue = CGPoint(x: 0, y: -layer.bounds.height * 0.3)// -layer.bounds.height + 3.0) - translate.duration = duration - translate.beginTime = CACurrentMediaTime() + beginTime - layer.add(translate, forKey: "translate") +// translate.duration = duration +// translate.beginTime = beginTimeOffset + beginTime +// layer.add(translate, forKey: "translate") + + let group = CAAnimationGroup() + group.animations = [opacityInAnimation, scaleOutAnimation, translate] + group.duration = duration + group.beginTime = beginTimeOffset + beginTime + layer.add(group, forKey: "out") } func animateIn(for newLayer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) { @@ -442,7 +461,7 @@ class AnimatedCountLabel: UILabel { let animation = CAKeyframeAnimation() animation.keyPath = "position.y" - animation.values = [18, -6, 0] + animation.values = [20, -6, 0] animation.keyTimes = [0, 0.64, 1] animation.timingFunction = CAMediaTimingFunction.init(name: .easeInEaseOut) animation.duration = duration / 0.64 diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 53769805f8..e567888e80 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -1760,7 +1760,7 @@ public final class _MediaStreamComponentController: ViewControllerComponentConta // self.view.layer.cornerCurve = .continuous // } - self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 1.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in + self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 0.4, /*timingFunction: kCAMediaTimingFunctionSpring, */completion: { _ in }) // self.view.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) // } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index e3b84da552..6566f3c2e8 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -151,12 +151,27 @@ final class _MediaStreamVideoComponent: Component { let borderShimmer = StandaloneShimmerEffect() let shimmerOverlayLayer = CALayer() let shimmerBorderLayer = CALayer() + let placeholderView = UIImageView() func update(component: _MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { self.state = state + if component.videoLoading && placeholderView.superview == nil { + addSubview(placeholderView) + } + placeholderView.alpha = 0.7 +// placeholderView.image = lastFrame[component.call.peerId.id.description] + if let frame = lastFrame[component.call.peerId.id.description] { + placeholderView.addSubview(frame) + frame.frame = placeholderView.bounds + placeholderView.backgroundColor = .green + } else { + placeholderView.subviews.forEach { $0.removeFromSuperview() } + placeholderView.backgroundColor = .red + } + placeholderView.backgroundColor = .red if component.videoLoading { if loadingBlurView.superview == nil { - addSubview(loadingBlurView) +// addSubview(loadingBlurView) } if shimmerOverlayLayer.superlayer == nil { loadingBlurView.layer.addSublayer(shimmerOverlayLayer) @@ -201,6 +216,7 @@ final class _MediaStreamVideoComponent: Component { if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) { self.videoView = videoView + self.placeholderView.removeFromSuperview() self.addSubview(videoView) videoView.alpha = 0 UIView.animate(withDuration: 0.3) { @@ -313,6 +329,12 @@ final class _MediaStreamVideoComponent: Component { } if let videoView = self.videoView { + // TODO: REMOVE FROM HERE and move to call end (or at least to background) +// if let presentation = videoView.snapshotView(afterScreenUpdates: false) { + if videoView.bounds.size.width > 0, let snapshot = videoView.snapshotView(afterScreenUpdates: false) ?? videoView.snapshotView(afterScreenUpdates: true) { + lastFrame[component.call.peerId.id.description] = snapshot// ()! + } +// } var aspect = videoView.getAspect() // saveAspect(aspect) if component.isFullscreen { @@ -379,6 +401,10 @@ final class _MediaStreamVideoComponent: Component { loadingBlurView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) loadingBlurView.layer.cornerRadius = 10 + placeholderView.frame = loadingBlurView.frame + placeholderView.layer.cornerRadius = 10 + placeholderView.clipsToBounds = true + shimmerOverlayLayer.frame = loadingBlurView.bounds shimmerBorderLayer.frame = loadingBlurView.bounds shimmerBorderLayer.mask?.frame = loadingBlurView.bounds @@ -478,10 +504,26 @@ final class _MediaStreamVideoComponent: Component { } func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - self.videoView?.alpha = 0 - } - UIView.animate(withDuration: 0.3) { [self] in + // Fading to make + let presentation = self.videoView!.snapshotView(afterScreenUpdates: false)! // (self.videoView?.layer.presentation())! + self.addSubview(presentation) + presentation.frame = self.videoView!.frame +// let image = UIGraphicsImageRenderer(size: presentation.bounds.size).image { context in +// presentation.render(in: context.cgContext) +// } +// print(image) + self.videoView?.alpha = 0 +// self.videoView?.alpha = 0.5 +// presentation.animateAlpha(from: 1, to: 0, duration: 0.1, completion: { _ in presentation.removeFromSuperlayer() }) + UIView.animate(withDuration: 0.1, animations: { + presentation.alpha = 0 + }, completion: { _ in + presentation.removeFromSuperview() + }) +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { +// presentation.removeFromSuperlayer() +// } + UIView.animate(withDuration: 0.1) { [self] in videoBlurView?.alpha = 0 } // TODO: make safe @@ -533,3 +575,25 @@ final class _MediaStreamVideoComponent: Component { return view.update(component: self, availableSize: availableSize, state: state, transition: transition) } } + +// TODO: move to appropriate place +var lastFrame: [String: UIView] = [:] + +extension UIView { + func snapshot() -> UIImage? { + UIGraphicsBeginImageContextWithOptions(bounds.size, true, UIScreen.main.scale) + + guard let currentContext = UIGraphicsGetCurrentContext() else { + UIGraphicsEndImageContext() + return nil + } + + layer.render(in: currentContext) + + let image = UIGraphicsGetImageFromCurrentImageContext() + + UIGraphicsEndImageContext() + + return image + } +} From aafc704c7182ef8dffc2c210bcc638df6c7ca0f2 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Wed, 7 Dec 2022 06:18:14 +0400 Subject: [PATCH 15/50] Adding blurred "last frame" and tweaking counter animation --- .../Components/AnimatedCounterView.swift | 165 ++++++++++-------- .../MediaStreamVideoComponent.swift | 50 ++++-- 2 files changed, 131 insertions(+), 84 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index e57fa01fb3..71fa96d44a 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -391,82 +391,101 @@ class AnimatedCountLabel: UILabel { // layer.add(animation, forKey: "opacity") // // - let beginTimeOffset: CFTimeInterval = /*beginTime == .zero ? 0 :*/ /*CFTimeInterval(DispatchTime.now().uptimeNanoseconds / 1000000000)*/ layer.convertTime(CACurrentMediaTime(), to: nil) - - let opacityInAnimation = CABasicAnimation(keyPath: "opacity") - opacityInAnimation.fromValue = 1 - opacityInAnimation.toValue = 0 -// opacityInAnimation.duration = duration -// opacityInAnimation.beginTime = beginTimeOffset + beginTime -// opacityInAnimation.completion = { _ in -// layer.removeFromSuperlayer() -// } -// layer.add(opacityInAnimation, forKey: "opacity") - -// let timer = Timer.scheduledTimer(withTimeInterval: duration + beginTime, repeats: false) { timer in - DispatchQueue.main.asyncAfter(deadline: .now() + duration * 0.95 + beginTime) { -// DispatchQueue.main.async { -// layer.backgroundColor = UIColor.red.withAlphaComponent(0.3).cgColor -// } -// DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - layer.removeFromSuperlayer() -// } -// timer.invalidate() - } -// RunLoop.current.add(timer, forMode: .common) - - let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale") - scaleOutAnimation.fromValue = 1 // layer.presentation()?.value(forKey: "transform.scale") ?? 1 - scaleOutAnimation.toValue = 0.0 -// scaleOutAnimation.duration = duration -// scaleOutAnimation.beginTime = beginTimeOffset + beginTime -// layer.add(scaleOutAnimation, forKey: "scaleout") - - let translate = CABasicAnimation(keyPath: "transform.translation") - translate.fromValue = CGPoint.zero - translate.toValue = CGPoint(x: 0, y: -layer.bounds.height * 0.3)// -layer.bounds.height + 3.0) -// translate.duration = duration -// translate.beginTime = beginTimeOffset + beginTime -// layer.add(translate, forKey: "translate") - - let group = CAAnimationGroup() - group.animations = [opacityInAnimation, scaleOutAnimation, translate] - group.duration = duration - group.beginTime = beginTimeOffset + beginTime - layer.add(group, forKey: "out") + let beginTimeOffset: CFTimeInterval = 0/*beginTime == .zero ? 0 :*/ // CFTimeInterval(DispatchTime.now().uptimeNanoseconds / 1000000000) /*layer.convertTime(*/// CACurrentMediaTime()//, to: nil) + DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) { + let currentTime = CFTimeInterval(DispatchTime.now().uptimeNanoseconds / 1000000000) + let beginTime: CFTimeInterval = 0 + print("[DIFF-out] \(currentTime - beginTimeOffset)") + let opacityInAnimation = CABasicAnimation(keyPath: "opacity") + opacityInAnimation.fromValue = 1 + opacityInAnimation.toValue = 0 + opacityInAnimation.fillMode = .forwards + opacityInAnimation.isRemovedOnCompletion = false + // opacityInAnimation.duration = duration + // opacityInAnimation.beginTime = beginTimeOffset + beginTime + // opacityInAnimation.completion = { _ in + // layer.removeFromSuperlayer() + // } + // layer.add(opacityInAnimation, forKey: "opacity") + + // let timer = Timer.scheduledTimer(withTimeInterval: duration + beginTime, repeats: false) { timer in + // DispatchQueue.main.asyncAfter(deadline: .now() + duration + beginTime) { + // DispatchQueue.main.async { + // layer.backgroundColor = UIColor.red.withAlphaComponent(0.3).cgColor + // } + // DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + // layer.removeFromSuperlayer() + // } + // timer.invalidate() + // } + // RunLoop.current.add(timer, forMode: .common) + + let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale") + scaleOutAnimation.fromValue = 1 // layer.presentation()?.value(forKey: "transform.scale") ?? 1 + scaleOutAnimation.toValue = 0.0 + // scaleOutAnimation.duration = duration + // scaleOutAnimation.beginTime = beginTimeOffset + beginTime + // layer.add(scaleOutAnimation, forKey: "scaleout") + + let translate = CABasicAnimation(keyPath: "transform.translation") + translate.fromValue = CGPoint.zero + translate.toValue = CGPoint(x: 0, y: -layer.bounds.height * 0.3)// -layer.bounds.height + 3.0) + // translate.duration = duration + // translate.beginTime = beginTimeOffset + beginTime + // layer.add(translate, forKey: "translate") + + let group = CAAnimationGroup() + group.animations = [opacityInAnimation, scaleOutAnimation, translate] + group.duration = duration + group.beginTime = beginTimeOffset + beginTime + group.fillMode = .forwards + group.isRemovedOnCompletion = false + group.completion = { _ in + layer.removeFromSuperlayer() + } + // layer.opacity = 0 + layer.add(group, forKey: "out") + } } func animateIn(for newLayer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) { - newLayer.opacity = 0 - // newLayer.backgroundColor = UIColor.red.cgColor - let opacityInAnimation = CABasicAnimation(keyPath: "opacity") - opacityInAnimation.fromValue = 0 - opacityInAnimation.toValue = 1 - opacityInAnimation.duration = duration - opacityInAnimation.beginTime = CACurrentMediaTime() + beginTime -// opacityInAnimation.isAdditive = true - opacityInAnimation.fillMode = .backwards - newLayer.opacity = 1 - newLayer.add(opacityInAnimation, forKey: "opacity") -// newLayer.opacity = 1 - - let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale") - scaleOutAnimation.fromValue = 0 - scaleOutAnimation.toValue = 1 - scaleOutAnimation.duration = duration - scaleOutAnimation.beginTime = CACurrentMediaTime() + beginTime -// scaleOutAnimation.isAdditive = true - newLayer.add(scaleOutAnimation, forKey: "scalein") - - let animation = CAKeyframeAnimation() - animation.keyPath = "position.y" - animation.values = [20, -6, 0] - animation.keyTimes = [0, 0.64, 1] - animation.timingFunction = CAMediaTimingFunction.init(name: .easeInEaseOut) - animation.duration = duration / 0.64 - animation.beginTime = CACurrentMediaTime() + beginTime - animation.isAdditive = true - newLayer.add(animation, forKey: "pos") + let beginTimeOffset: CFTimeInterval = 0// CFTimeInterval(DispatchTime.now().uptimeNanoseconds / 1000000000)// CACurrentMediaTime() + DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) { + let currentTime = CFTimeInterval(DispatchTime.now().uptimeNanoseconds / 1000000000) + let beginTime: CFTimeInterval = 0 + print("[DIFF-in] \(currentTime - beginTimeOffset)") + newLayer.opacity = 0 + // newLayer.backgroundColor = UIColor.red.cgColor + + let opacityInAnimation = CABasicAnimation(keyPath: "opacity") + opacityInAnimation.fromValue = 0 + opacityInAnimation.toValue = 1 + opacityInAnimation.duration = duration + opacityInAnimation.beginTime = beginTimeOffset + beginTime + // opacityInAnimation.isAdditive = true + opacityInAnimation.fillMode = .backwards + newLayer.opacity = 1 + newLayer.add(opacityInAnimation, forKey: "opacity") + // newLayer.opacity = 1 + + let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale") + scaleOutAnimation.fromValue = 0 + scaleOutAnimation.toValue = 1 + scaleOutAnimation.duration = duration + scaleOutAnimation.beginTime = beginTimeOffset + beginTime + // scaleOutAnimation.isAdditive = true + newLayer.add(scaleOutAnimation, forKey: "scalein") + + let animation = CAKeyframeAnimation() + animation.keyPath = "position.y" + animation.values = [20, -6, 0] + animation.keyTimes = [0, 0.64, 1] + animation.timingFunction = CAMediaTimingFunction.init(name: .easeInEaseOut) + animation.duration = duration / 0.64 + animation.beginTime = beginTimeOffset + beginTime + animation.isAdditive = true + newLayer.add(animation, forKey: "pos") + } } } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 6566f3c2e8..beb759e362 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -11,6 +11,28 @@ import ShimmerEffect import TelegramCore typealias MediaStreamVideoComponent = _MediaStreamVideoComponent +class CustomIntensityVisualEffectView: UIVisualEffectView { + + /// Create visual effect view with given effect and its intensity + /// + /// - Parameters: + /// - effect: visual effect, eg UIBlurEffect(style: .dark) + /// - intensity: custom intensity from 0.0 (no effect) to 1.0 (full effect) using linear scale + init(effect: UIVisualEffect, intensity: CGFloat) { + super.init(effect: nil) + animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [unowned self] in self.effect = effect } + animator.fractionComplete = intensity + } + + required init?(coder aDecoder: NSCoder) { + fatalError() + } + + // MARK: Private + private var animator: UIViewPropertyAnimator! + +} + final class _MediaStreamVideoComponent: Component { let call: PresentationGroupCallImpl let hasVideo: Bool @@ -103,7 +125,7 @@ final class _MediaStreamVideoComponent: Component { private var videoPlaceholderView: UIView? private var noSignalView: ComponentHostView? - private let loadingBlurView = UIVisualEffectView(effect: UIBlurEffect(style: .light)) + private let loadingBlurView = CustomIntensityVisualEffectView(effect: UIBlurEffect(style: .light), intensity: 0.4) private let shimmerOverlayView = CALayer() private var pictureInPictureController: AVPictureInPictureController? @@ -155,23 +177,24 @@ final class _MediaStreamVideoComponent: Component { func update(component: _MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { self.state = state - if component.videoLoading && placeholderView.superview == nil { - addSubview(placeholderView) - } - placeholderView.alpha = 0.7 +// placeholderView.alpha = 0.7 // placeholderView.image = lastFrame[component.call.peerId.id.description] if let frame = lastFrame[component.call.peerId.id.description] { + placeholderView.subviews.forEach { $0.removeFromSuperview() } placeholderView.addSubview(frame) frame.frame = placeholderView.bounds - placeholderView.backgroundColor = .green +// placeholderView.backgroundColor = .green } else { - placeholderView.subviews.forEach { $0.removeFromSuperview() } - placeholderView.backgroundColor = .red +// placeholderView.subviews.forEach { $0.removeFromSuperview() } +// placeholderView.backgroundColor = .red } placeholderView.backgroundColor = .red if component.videoLoading { + if placeholderView.superview == nil { + addSubview(placeholderView) + } if loadingBlurView.superview == nil { -// addSubview(loadingBlurView) + addSubview(loadingBlurView) } if shimmerOverlayLayer.superlayer == nil { loadingBlurView.layer.addSublayer(shimmerOverlayLayer) @@ -194,8 +217,14 @@ final class _MediaStreamVideoComponent: Component { shimmerBorderLayer.mask = borderMask borderShimmer.layer = borderMask borderShimmer.testUpdate(background: .clear, foreground: .white) + loadingBlurView.alpha = 1 } else { - loadingBlurView.removeFromSuperview() + UIView.animate(withDuration: 0.2, animations: { + self.loadingBlurView.alpha = 0 + }, completion: { _ in + self.loadingBlurView.removeFromSuperview() + }) + placeholderView.removeFromSuperview() } if component.hasVideo, self.videoView == nil { @@ -216,7 +245,6 @@ final class _MediaStreamVideoComponent: Component { if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) { self.videoView = videoView - self.placeholderView.removeFromSuperview() self.addSubview(videoView) videoView.alpha = 0 UIView.animate(withDuration: 0.3) { From 76030cd8af3304c5d9e5d4eb8b7b784906b7e4b0 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Wed, 7 Dec 2022 07:13:45 +0400 Subject: [PATCH 16/50] Animating loading border --- .../MediaStreamVideoComponent.swift | 44 +++++++++++-------- 1 file changed, 25 insertions(+), 19 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index beb759e362..75374f9a19 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -179,17 +179,18 @@ final class _MediaStreamVideoComponent: Component { self.state = state // placeholderView.alpha = 0.7 // placeholderView.image = lastFrame[component.call.peerId.id.description] - if let frame = lastFrame[component.call.peerId.id.description] { - placeholderView.subviews.forEach { $0.removeFromSuperview() } - placeholderView.addSubview(frame) - frame.frame = placeholderView.bounds -// placeholderView.backgroundColor = .green - } else { -// placeholderView.subviews.forEach { $0.removeFromSuperview() } -// placeholderView.backgroundColor = .red - } - placeholderView.backgroundColor = .red + if component.videoLoading { + if let frame = lastFrame[component.call.peerId.id.description] { + placeholderView.subviews.forEach { $0.removeFromSuperview() } + placeholderView.addSubview(frame) + frame.frame = placeholderView.bounds + // placeholderView.backgroundColor = .green + } else { + // placeholderView.subviews.forEach { $0.removeFromSuperview() } + // placeholderView.backgroundColor = .red + } + if placeholderView.superview == nil { addSubview(placeholderView) } @@ -210,12 +211,15 @@ final class _MediaStreamVideoComponent: Component { shimmerBorderLayer.cornerRadius = 10 shimmerBorderLayer.masksToBounds = true shimmerBorderLayer.compositingFilter = "softLightBlendMode" - shimmerBorderLayer.borderWidth = 2 - shimmerBorderLayer.borderColor = UIColor.white.cgColor - let borderMask = CALayer() + let borderMask = CAShapeLayer() + borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: loadingBlurView.bounds.width, height: loadingBlurView.bounds.height), cornerWidth: 10, cornerHeight: 10, transform: nil) + borderMask.fillColor = UIColor.clear.cgColor + borderMask.strokeColor = UIColor.white.cgColor + borderMask.lineWidth = 4 +// let borderMask = CALayer() shimmerBorderLayer.mask = borderMask - borderShimmer.layer = borderMask + borderShimmer.layer = shimmerBorderLayer borderShimmer.testUpdate(background: .clear, foreground: .white) loadingBlurView.alpha = 1 } else { @@ -245,7 +249,7 @@ final class _MediaStreamVideoComponent: Component { if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) { self.videoView = videoView - self.addSubview(videoView) + self/*.insertSubview(videoView, belowSubview: loadingBlurView)*/.addSubview(videoView) videoView.alpha = 0 UIView.animate(withDuration: 0.3) { videoView.alpha = 1 @@ -335,8 +339,6 @@ final class _MediaStreamVideoComponent: Component { strongSelf.noSignalView?.removeFromSuperview() strongSelf.noSignalView = nil - let snapshot = strongSelf.videoView?.snapshotView(afterScreenUpdates: true) - strongSelf.addSubview(snapshot ?? UIVisualEffectView(effect: UIBlurEffect(style: .dark))) state?.updated(transition: .immediate) } } @@ -359,7 +361,9 @@ final class _MediaStreamVideoComponent: Component { if let videoView = self.videoView { // TODO: REMOVE FROM HERE and move to call end (or at least to background) // if let presentation = videoView.snapshotView(afterScreenUpdates: false) { - if videoView.bounds.size.width > 0, let snapshot = videoView.snapshotView(afterScreenUpdates: false) ?? videoView.snapshotView(afterScreenUpdates: true) { + if videoView.bounds.size.width > 0, + videoView.alpha > 0, + let snapshot = videoView.snapshotView(afterScreenUpdates: false) ?? videoView.snapshotView(afterScreenUpdates: true) { lastFrame[component.call.peerId.id.description] = snapshot// ()! } // } @@ -489,6 +493,7 @@ final class _MediaStreamVideoComponent: Component { noSignalTransition = transition.withAnimation(.none) noSignalView = ComponentHostView() self.noSignalView = noSignalView + // TODO: above blurred animation self.addSubview(noSignalView) noSignalView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) } @@ -533,9 +538,10 @@ final class _MediaStreamVideoComponent: Component { func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { // Fading to make - let presentation = self.videoView!.snapshotView(afterScreenUpdates: false)! // (self.videoView?.layer.presentation())! + let presentation = self.videoView!.snapshotView(afterScreenUpdates: false)! self.addSubview(presentation) presentation.frame = self.videoView!.frame + lastFrame[self.component!.call.peerId.id.description] = presentation // let image = UIGraphicsImageRenderer(size: presentation.bounds.size).image { context in // presentation.render(in: context.cgContext) // } From a74098cd09f9a7592da0db87c33c60eb5e8d1081 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Wed, 7 Dec 2022 08:55:04 +0400 Subject: [PATCH 17/50] Observing network state to show loading --- .../Components/MediaStreamComponent.swift | 25 ++++++++++++--- .../MediaStreamVideoComponent.swift | 31 +++++++++++++------ 2 files changed, 42 insertions(+), 14 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index e567888e80..ea8e3de213 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -731,6 +731,7 @@ public final class _MediaStreamComponent: CombinedComponent { private var stateDisposable: Disposable? private var infoDisposable: Disposable? private var connectionDisposable: Disposable? + private var networkStateDisposable: Disposable? private(set) var originInfo: OriginInfo? @@ -768,7 +769,7 @@ public final class _MediaStreamComponent: CombinedComponent { /// To update videoHiddenForPip var onExpandedFromPictureInPicture: ((State) -> Void)? private let infoThrottler = Throttler.init(duration: 5, queue: .main) - + init(call: PresentationGroupCallImpl) { self.call = call @@ -798,12 +799,28 @@ public final class _MediaStreamComponent: CombinedComponent { strongSelf.updated(transition: .immediate) }) + self.networkStateDisposable = (call.account.networkState |> deliverOnMainQueue).start(next: { [weak self] state in + guard let strongSelf = self else { return } + switch state { + case .waitingForNetwork, .connecting: + print("[NEW] videoStalled") + strongSelf.videoStalled = true + default: + strongSelf.videoStalled = !strongSelf.hasVideo + } + strongSelf.updated(transition: .immediate) +// if let strongSelf = self, case .standard(previewing: false) = strongSelf.presentationInterfaceState.mode { +// strongSelf.chatTitleView?.networkState = state +// } + }) + self.connectionDisposable = call.state.start(next: { [weak self] state in let prev = self?.videoStalled switch state.networkState { case .connected: self?.videoStalled = false default: + print("[ALERT] video stalled") self?.videoStalled = true } if prev != self?.videoStalled { @@ -821,8 +838,8 @@ public final class _MediaStreamComponent: CombinedComponent { var updated = false // TODO: remove debug timer - Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in - strongSelf.infoThrottler.publish(/*members.totalCount*/ Int.random(in: 0..<10000000)) { [weak strongSelf] latestCount in +// Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + strongSelf.infoThrottler.publish(members.totalCount /*Int.random(in: 0..<10000000)*/) { [weak strongSelf] latestCount in print(members.totalCount) guard let strongSelf = strongSelf else { return } var updated = false @@ -835,7 +852,7 @@ public final class _MediaStreamComponent: CombinedComponent { strongSelf.updated(transition: .immediate) } } - }.fire() +// }.fire() if state.canManageCall != strongSelf.canManageCall { strongSelf.canManageCall = state.canManageCall updated = true diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 75374f9a19..273f6e8ef1 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -94,11 +94,12 @@ final class _MediaStreamVideoComponent: Component { if lhs.peerTitle != rhs.peerTitle { return false } - if lhs.isFullscreen != rhs.isFullscreen { return false } - + if lhs.videoLoading != rhs.videoLoading { + return false + } return true } @@ -169,8 +170,8 @@ final class _MediaStreamVideoComponent: Component { } let maskGradientLayer = CAGradientLayer() private var wasVisible = true - let shimmer = StandaloneShimmerEffect() - let borderShimmer = StandaloneShimmerEffect() + var shimmer = StandaloneShimmerEffect() + var borderShimmer = StandaloneShimmerEffect() let shimmerOverlayLayer = CALayer() let shimmerBorderLayer = CALayer() let placeholderView = UIImageView() @@ -191,7 +192,7 @@ final class _MediaStreamVideoComponent: Component { // placeholderView.backgroundColor = .red } - if placeholderView.superview == nil { + if !hadVideo && placeholderView.superview == nil { addSubview(placeholderView) } if loadingBlurView.superview == nil { @@ -202,6 +203,7 @@ final class _MediaStreamVideoComponent: Component { loadingBlurView.layer.addSublayer(shimmerBorderLayer) } loadingBlurView.clipsToBounds = true + shimmer = .init() shimmer.layer = shimmerOverlayLayer shimmerOverlayView.compositingFilter = "softLightBlendMode" shimmer.testUpdate(background: .clear, foreground: .white.withAlphaComponent(0.4)) @@ -219,16 +221,25 @@ final class _MediaStreamVideoComponent: Component { borderMask.lineWidth = 4 // let borderMask = CALayer() shimmerBorderLayer.mask = borderMask + borderShimmer = .init() borderShimmer.layer = shimmerBorderLayer borderShimmer.testUpdate(background: .clear, foreground: .white) loadingBlurView.alpha = 1 } else { - UIView.animate(withDuration: 0.2, animations: { - self.loadingBlurView.alpha = 0 - }, completion: { _ in + if hadVideo { self.loadingBlurView.removeFromSuperview() - }) - placeholderView.removeFromSuperview() + placeholderView.removeFromSuperview() + } else { + // Accounting for delay in first frame received + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [self] in + UIView.transition(with: self.loadingBlurView, duration: 0.2, animations: { + self.loadingBlurView.alpha = 0 + }, completion: { _ in + self.loadingBlurView.removeFromSuperview() + }) + placeholderView.removeFromSuperview() + } + } } if component.hasVideo, self.videoView == nil { From c65ad5ad97442489fe2006f79421c34af4f357c7 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Wed, 7 Dec 2022 17:04:37 +0400 Subject: [PATCH 18/50] Observing video input to detect stall --- .../Components/AnimatedCounterView.swift | 42 +++-- .../Components/MediaStreamComponent.swift | 10 +- .../MediaStreamVideoComponent.swift | 175 ++++++++++++++++-- 3 files changed, 185 insertions(+), 42 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index 71fa96d44a..255b419402 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -16,6 +16,7 @@ public final class AnimatedCountView: UIView { private let foregroundView = UIView() private let foregroundGradientLayer = CAGradientLayer() private let maskingView = UIView() + private var scaleFactor: CGFloat { 0.7 } override init(frame: CGRect = .zero) { super.init(frame: frame) @@ -49,7 +50,7 @@ public final class AnimatedCountView: UIView { self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60) self.maskingView.frame = CGRect(origin: .zero, size: bounds.size) countLabel.frame = CGRect(origin: .zero, size: bounds.size) - subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - 10 : bounds.height - 6, width: subtitleLabel.intrinsicContentSize.width + 20, height: 20) + subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - 8 : bounds.height - 6, width: subtitleLabel.intrinsicContentSize.width + 20, height: 20) } func update(countString: String, subtitle: String) { @@ -64,8 +65,9 @@ public final class AnimatedCountView: UIView { // self.countLabel.attributedText = NSAttributedString(string: text, attributes: [.font: UIFont.systemFont(ofSize: 60, weight: .semibold)]) // } else { // self.countLabel.attributedText = NSAttributedString(string: text, attributes: [.font: UIFont.systemFont(ofSize: 54, weight: .semibold)]) -// } - self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) +// + self.countLabel.fontSize = 48 + self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: 48, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) // self.countLabel.attributedText = NSAttributedString(string: text, attributes: [.font: UIFont.systemFont(ofSize: 60, weight: .semibold)]) // var timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) // if timerSize.width > size.width - 32.0 { @@ -176,6 +178,14 @@ class AnimatedCountLabel: UILabel { private var chars = [AnimatedCharLayer]() private let containerView = UIView() + var itemWidth: CGFloat { 36 * fontSize / 60 } + var commaWidthForSpacing: CGFloat { 8 * fontSize / 60 } + var commaFrameWidth: CGFloat { 36 * fontSize / 60 } + var interItemSpacing: CGFloat { 0 * fontSize / 60 } + var didBegin = false + var fontSize: CGFloat = 60 + var scaleFactor: CGFloat { 1 } + override init(frame: CGRect = .zero) { super.init(frame: frame) containerView.clipsToBounds = false @@ -186,11 +196,6 @@ class AnimatedCountLabel: UILabel { required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } - var itemWidth: CGFloat { 36 } - var commaWidthForSpacing: CGFloat { 8 } - var commaFrameWidth: CGFloat { 36 } - var interItemSpacing: CGFloat { 0 } - var didBegin = false private func offsetForChar(at index: Int, within characters: [NSAttributedString]? = nil) -> CGFloat { if let characters { @@ -201,7 +206,7 @@ class AnimatedCountLabel: UILabel { return $0 + itemWidth + interItemSpacing } if characters.count > index && characters[index].string == "," { - offset -= 4 + offset -= commaWidthForSpacing / 2 // 4 } return offset } else { @@ -212,7 +217,7 @@ class AnimatedCountLabel: UILabel { return $0 + itemWidth + interItemSpacing } if self.chars.count > index && self.chars[index].attributedText?.string == "," { - offset -= 4 + offset -= commaWidthForSpacing / 2 } return offset } @@ -226,8 +231,7 @@ class AnimatedCountLabel: UILabel { } return $0 + itemWidth + interItemSpacing }*/ - interItemSpacing - - containerView.frame = .init(x: bounds.midX - countWidth / 2, y: 0, width: countWidth, height: bounds.height) + containerView.frame = .init(x: bounds.midX - countWidth / 2 * scaleFactor, y: 0, width: countWidth * scaleFactor, height: bounds.height) chars.enumerated().forEach { (index, char) in let offset = offsetForChar(at: index) // char.frame.size.width = char.attributedText?.string == "," ? commaFrameWidth : itemWidth @@ -362,16 +366,16 @@ class AnimatedCountLabel: UILabel { if didBegin && prevCount != chars.count { UIView.animate(withDuration: Double(changeIndex) * initialDuration/*, delay: initialDuration * Double(changeIndex)*/) { [self] in containerView.frame = .init(x: self.bounds.midX - countWidth / 2, y: 0, width: countWidth, height: self.bounds.height) - if countWidth > self.bounds.width { - let scale = countWidth / self.bounds.width - self.transform = .init(scaleX: scale, y: scale) + if countWidth * scaleFactor > self.bounds.width { + let scale = (self.bounds.width - 32) / (countWidth * scaleFactor) + containerView.transform = .init(scaleX: scale, y: scale) } else { - self.transform = .identity + containerView.transform = .init(scaleX: scaleFactor, y: scaleFactor) } // containerView.backgroundColor = .red.withAlphaComponent(0.3) } } else if countWidth > 0 { - containerView.frame = .init(x: self.bounds.midX - countWidth / 2, y: 0, width: countWidth, height: self.bounds.height) + containerView.frame = .init(x: self.bounds.midX - countWidth / 2 * scaleFactor, y: 0, width: countWidth * scaleFactor, height: self.bounds.height) didBegin = true } // self.backgroundColor = .green.withAlphaComponent(0.2) @@ -451,7 +455,7 @@ class AnimatedCountLabel: UILabel { func animateIn(for newLayer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) { let beginTimeOffset: CFTimeInterval = 0// CFTimeInterval(DispatchTime.now().uptimeNanoseconds / 1000000000)// CACurrentMediaTime() - DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) { + DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) { [self] in let currentTime = CFTimeInterval(DispatchTime.now().uptimeNanoseconds / 1000000000) let beginTime: CFTimeInterval = 0 print("[DIFF-in] \(currentTime - beginTimeOffset)") @@ -479,7 +483,7 @@ class AnimatedCountLabel: UILabel { let animation = CAKeyframeAnimation() animation.keyPath = "position.y" - animation.values = [20, -6, 0] + animation.values = [20 * fontSize / 60, -6 * fontSize / 60, 0] animation.keyTimes = [0, 0.64, 1] animation.timingFunction = CAMediaTimingFunction.init(name: .easeInEaseOut) animation.duration = duration / 0.64 diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index ea8e3de213..276bdf7365 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -967,6 +967,7 @@ public final class _MediaStreamComponent: CombinedComponent { let moreAnimationTag = GenericComponentViewTag() return { context in + var forceFullScreenInLandscape: Bool { false } let environment = context.environment[ViewControllerComponentContainer.Environment.self].value if environment.isVisible { } else { @@ -1000,16 +1001,17 @@ public final class _MediaStreamComponent: CombinedComponent { var isFullscreen = state.isFullscreen let isLandscape = context.availableSize.width > context.availableSize.height - if let videoSize = context.state.videoSize { +// if let videoSize = context.state.videoSize { // Always fullscreen in landscape - if /*videoSize.width > videoSize.height &&*/ isLandscape && !isFullscreen { + // TODO: support landscape sheet (wrap in scrollview, video size same as portrait) + if forceFullScreenInLandscape && /*videoSize.width > videoSize.height &&*/ isLandscape && !isFullscreen { state.isFullscreen = true isFullscreen = true - } else if videoSize.width > videoSize.height && !isLandscape && isFullscreen { + } else if let videoSize = context.state.videoSize, videoSize.width > videoSize.height && !isLandscape && isFullscreen { state.isFullscreen = false isFullscreen = false } - } +// } let videoHeight: CGFloat = context.availableSize.width / 16 * 9 let bottomPadding = 40 + environment.safeInsets.bottom diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 273f6e8ef1..c56bde81f0 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -9,15 +9,11 @@ import Display import ShimmerEffect import TelegramCore +import SwiftSignalKit + typealias MediaStreamVideoComponent = _MediaStreamVideoComponent class CustomIntensityVisualEffectView: UIVisualEffectView { - - /// Create visual effect view with given effect and its intensity - /// - /// - Parameters: - /// - effect: visual effect, eg UIBlurEffect(style: .dark) - /// - intensity: custom intensity from 0.0 (no effect) to 1.0 (full effect) using linear scale init(effect: UIVisualEffect, intensity: CGFloat) { super.init(effect: nil) animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [unowned self] in self.effect = effect } @@ -27,10 +23,27 @@ class CustomIntensityVisualEffectView: UIVisualEffectView { required init?(coder aDecoder: NSCoder) { fatalError() } - - // MARK: Private - private var animator: UIViewPropertyAnimator! - + + var animator: UIViewPropertyAnimator! + + private var displayLink: CADisplayLink? + + func setIntensity(_ intensity: CGFloat, animated: Bool) { + self.displayLink?.invalidate() + let displaylink = CADisplayLink( + target: self, + selector: #selector(displayLinkStep) + ) + self.displayLink = displaylink + displaylink.add( + to: .current, + forMode: RunLoop.Mode.default + ) + } + + @objc func displayLinkStep() { + + } } final class _MediaStreamVideoComponent: Component { @@ -135,7 +148,7 @@ final class _MediaStreamVideoComponent: Component { private var requestedExpansion: Bool = false - private var noSignalTimer: Timer? + private var noSignalTimer: Foundation.Timer? private var noSignalTimeout: Bool = false private weak var state: State? @@ -176,12 +189,27 @@ final class _MediaStreamVideoComponent: Component { let shimmerBorderLayer = CALayer() let placeholderView = UIImageView() - func update(component: _MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { - self.state = state -// placeholderView.alpha = 0.7 -// placeholderView.image = lastFrame[component.call.peerId.id.description] - - if component.videoLoading { + var videoStalled = false { + didSet { + if videoStalled != oldValue { + self.updateVideoStalled(isStalled: self.videoStalled) +// state?.updated() + } + } + } + private var frameInputDisposable: Disposable? + + private func updateVideoStalled(isStalled: Bool) { + if isStalled { + guard let component = self.component else { return } +// let effect = UIBlurEffect(style: .light) +// let intensity: CGFloat = 0.4 +// self.loadingBlurView.effect = nil +// self.loadingBlurView.animator.stopAnimation(true) +// self.loadingBlurView.animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [unowned loadingBlurView] in loadingBlurView.effect = effect } +// self.loadingBlurView.animator.fractionComplete = intensity +// self.loadingBlurView.animator.fractionComplete = 0.4 +// self.loadingBlurView.effect = UIBlurEffect(style: .light) if let frame = lastFrame[component.call.peerId.id.description] { placeholderView.subviews.forEach { $0.removeFromSuperview() } placeholderView.addSubview(frame) @@ -220,8 +248,8 @@ final class _MediaStreamVideoComponent: Component { borderMask.strokeColor = UIColor.white.cgColor borderMask.lineWidth = 4 // let borderMask = CALayer() - shimmerBorderLayer.mask = borderMask borderShimmer = .init() + shimmerBorderLayer.mask = borderMask borderShimmer.layer = shimmerBorderLayer borderShimmer.testUpdate(background: .clear, foreground: .white) loadingBlurView.alpha = 1 @@ -229,6 +257,81 @@ final class _MediaStreamVideoComponent: Component { if hadVideo { self.loadingBlurView.removeFromSuperview() placeholderView.removeFromSuperview() + } else { + // Accounting for delay in first frame received + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [self] in + guard !self.videoStalled else { return } + UIView.transition(with: self, duration: 0.2, animations: { +// self.loadingBlurView.animator.fractionComplete = 0 +// self.loadingBlurView.effect = nil + self.loadingBlurView.alpha = 0 + }, completion: { _ in + self.loadingBlurView.removeFromSuperview() + }) + placeholderView.removeFromSuperview() + } + } + } + } + + var stallTimer: Foundation.Timer? + let fullScreenBackgroundPlaceholder = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + func update(component: _MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { + self.state = state +// placeholderView.alpha = 0.7 +// placeholderView.image = lastFrame[component.call.peerId.id.description] + self.component = component + + if component.videoLoading || self.videoStalled { + updateVideoStalled(isStalled: true) + /*if let frame = lastFrame[component.call.peerId.id.description] { + placeholderView.subviews.forEach { $0.removeFromSuperview() } + placeholderView.addSubview(frame) + frame.frame = placeholderView.bounds + // placeholderView.backgroundColor = .green + } else { + // placeholderView.subviews.forEach { $0.removeFromSuperview() } + // placeholderView.backgroundColor = .red + } + + if !hadVideo && placeholderView.superview == nil { + addSubview(placeholderView) + } + if loadingBlurView.superview == nil { + addSubview(loadingBlurView) + } + if shimmerOverlayLayer.superlayer == nil { + loadingBlurView.layer.addSublayer(shimmerOverlayLayer) + loadingBlurView.layer.addSublayer(shimmerBorderLayer) + } + loadingBlurView.clipsToBounds = true + shimmer = .init() + shimmer.layer = shimmerOverlayLayer + shimmerOverlayView.compositingFilter = "softLightBlendMode" + shimmer.testUpdate(background: .clear, foreground: .white.withAlphaComponent(0.4)) + loadingBlurView.layer.cornerRadius = 10 + shimmerOverlayLayer.opacity = 0.6 + + shimmerBorderLayer.cornerRadius = 10 + shimmerBorderLayer.masksToBounds = true + shimmerBorderLayer.compositingFilter = "softLightBlendMode" + + let borderMask = CAShapeLayer() + borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: loadingBlurView.bounds.width, height: loadingBlurView.bounds.height), cornerWidth: 10, cornerHeight: 10, transform: nil) + borderMask.fillColor = UIColor.clear.cgColor + borderMask.strokeColor = UIColor.white.cgColor + borderMask.lineWidth = 4 +// let borderMask = CALayer() + borderShimmer = .init() + shimmerBorderLayer.mask = borderMask + borderShimmer.layer = shimmerBorderLayer + borderShimmer.testUpdate(background: .clear, foreground: .white) + loadingBlurView.alpha = 1*/ + } else { + updateVideoStalled(isStalled: false) + /*if hadVideo { + self.loadingBlurView.removeFromSuperview() + placeholderView.removeFromSuperview() } else { // Accounting for delay in first frame received DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [self] in @@ -239,11 +342,39 @@ final class _MediaStreamVideoComponent: Component { }) placeholderView.removeFromSuperview() } - } + }*/ } if component.hasVideo, self.videoView == nil { if let input = component.call.video(endpointId: "unified") { + var _stallTimer: Foundation.Timer { Foundation.Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in + guard let strongSelf = self else { return timer.invalidate() } + print("Timer emitting \(timer)") + DispatchQueue.main.async { + strongSelf.videoStalled = true + } + } + } + // TODO: use mapToThrottled (?) + frameInputDisposable = input.start(next: { [weak self] input in + guard let strongSelf = self else { return } + print("input") + // TODO: optimize with throttle + DispatchQueue.main.async { + strongSelf.stallTimer?.invalidate() + strongSelf.stallTimer = _stallTimer +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { +// print(strongSelf.videoStalled) +// if strongSelf.videoStalled { +// strongSelf.stallTimer?.fire() +// } + RunLoop.main.add(strongSelf.stallTimer!, forMode: .common) + strongSelf.videoStalled = false + } + }) + stallTimer = _stallTimer +// RunLoop.main.add(stallTimer!, forMode: .common) + if let videoBlurView = self.videoRenderingContext.makeView(input: input, blur: true) { self.videoBlurView = videoBlurView self.insertSubview(videoBlurView, belowSubview: self.blurTintView) @@ -354,6 +485,12 @@ final class _MediaStreamVideoComponent: Component { } } } + fullScreenBackgroundPlaceholder.removeFromSuperview() + } else if component.isFullscreen { + if fullScreenBackgroundPlaceholder.superview == nil { + insertSubview(fullScreenBackgroundPlaceholder, at: 0) + } + fullScreenBackgroundPlaceholder.frame = self.bounds } // sheetView.frame = .init(x: 0, y: sheetTop, width: availableSize.width, height: sheetHeight) From c28d2bc888e14b650fe3ace0507d78b42a442eb3 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Wed, 7 Dec 2022 20:02:12 +0400 Subject: [PATCH 19/50] Adding avatar placeholder and tweaking blur animation --- .../Components/MediaStreamComponent.swift | 3 + .../MediaStreamVideoComponent.swift | 191 +++++++++++++----- .../Sources/MediaStreamingController.swift | 28 +-- 3 files changed, 157 insertions(+), 65 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 276bdf7365..8960b73879 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -19,6 +19,8 @@ import CreateExternalMediaStreamScreen import HierarchyTrackingLayer import UndoPanelComponent +import AvatarNode + final class StreamTitleComponent: Component { let text: String let isRecording: Bool @@ -1034,6 +1036,7 @@ public final class _MediaStreamComponent: CombinedComponent { peerImage: nil, isFullscreen: isFullscreen, videoLoading: context.state.videoStalled, + callPeer: context.state.chatPeer, activatePictureInPicture: activatePictureInPicture, deactivatePictureInPicture: deactivatePictureInPicture, bringBackControllerForPictureInPictureDeactivation: { [weak call] completed in diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index c56bde81f0..b9236d73bd 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -10,6 +10,8 @@ import ShimmerEffect import TelegramCore import SwiftSignalKit +import AvatarNode +import Postbox typealias MediaStreamVideoComponent = _MediaStreamVideoComponent @@ -17,7 +19,24 @@ class CustomIntensityVisualEffectView: UIVisualEffectView { init(effect: UIVisualEffect, intensity: CGFloat) { super.init(effect: nil) animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [unowned self] in self.effect = effect } + self.animator?.startAnimation() + self.animator?.pauseAnimation() animator.fractionComplete = intensity + animator.pausesOnCompletion = true +// subviews.forEach { +// if $0.backgroundColor != nil { +// $0.backgroundColor = $0.backgroundColor?.withAlphaComponent(0.5) +// } +// } + } + + override func didMoveToSuperview() { + super.didMoveToSuperview() +// let effect = self.effect +// self.effect = nil +// animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [unowned self] in self.effect = effect } +// animator.fractionComplete = 0.1// intensity +// animator.pausesOnCompletion = true } required init?(coder aDecoder: NSCoder) { @@ -26,24 +45,24 @@ class CustomIntensityVisualEffectView: UIVisualEffectView { var animator: UIViewPropertyAnimator! - private var displayLink: CADisplayLink? - - func setIntensity(_ intensity: CGFloat, animated: Bool) { - self.displayLink?.invalidate() - let displaylink = CADisplayLink( - target: self, - selector: #selector(displayLinkStep) - ) - self.displayLink = displaylink - displaylink.add( - to: .current, - forMode: RunLoop.Mode.default - ) - } - - @objc func displayLinkStep() { - - } +// private var displayLink: CADisplayLink? +// +// func setIntensity(_ intensity: CGFloat, animated: Bool) { +// self.displayLink?.invalidate() +// let displaylink = CADisplayLink( +// target: self, +// selector: #selector(displayLinkStep) +// ) +// self.displayLink = displaylink +// displaylink.add( +// to: .current, +// forMode: RunLoop.Mode.default +// ) +// } +// +// @objc func displayLinkStep(_:) { +// +// } } final class _MediaStreamVideoComponent: Component { @@ -60,6 +79,8 @@ final class _MediaStreamVideoComponent: Component { let isFullscreen: Bool let onVideoSizeRetrieved: (CGSize) -> Void let videoLoading: Bool + let callPeer: Peer? + init( call: PresentationGroupCallImpl, hasVideo: Bool, @@ -69,6 +90,7 @@ final class _MediaStreamVideoComponent: Component { peerImage: Any?, isFullscreen: Bool, videoLoading: Bool, + callPeer: Peer?, activatePictureInPicture: ActionSlot>, deactivatePictureInPicture: ActionSlot, bringBackControllerForPictureInPictureDeactivation: @escaping (@escaping () -> Void) -> Void, @@ -86,6 +108,7 @@ final class _MediaStreamVideoComponent: Component { self.bringBackControllerForPictureInPictureDeactivation = bringBackControllerForPictureInPictureDeactivation self.pictureInPictureClosed = pictureInPictureClosed + self.callPeer = callPeer self.peerImage = peerImage self.isFullscreen = isFullscreen self.onVideoSizeRetrieved = onVideoSizeRetrieved @@ -216,6 +239,7 @@ final class _MediaStreamVideoComponent: Component { frame.frame = placeholderView.bounds // placeholderView.backgroundColor = .green } else { +// placeholderView.addSubview(avatarPlaceholderView) // placeholderView.subviews.forEach { $0.removeFromSuperview() } // placeholderView.backgroundColor = .red } @@ -225,63 +249,128 @@ final class _MediaStreamVideoComponent: Component { } if loadingBlurView.superview == nil { addSubview(loadingBlurView) + let anim = CABasicAnimation(keyPath: "opacity") + anim.duration = 0.5 + anim.fromValue = 0 + anim.toValue = 1 + anim.fillMode = .forwards + anim.isRemovedOnCompletion = false + loadingBlurView.layer.add(anim, forKey: "opacity") } - if shimmerOverlayLayer.superlayer == nil { - loadingBlurView.layer.addSublayer(shimmerOverlayLayer) - loadingBlurView.layer.addSublayer(shimmerBorderLayer) + if shimmerBorderLayer.superlayer == nil { +// loadingBlurView.contentView.layer.addSublayer(shimmerOverlayLayer) + loadingBlurView.contentView.layer.addSublayer(shimmerBorderLayer) } loadingBlurView.clipsToBounds = true - shimmer = .init() - shimmer.layer = shimmerOverlayLayer - shimmerOverlayView.compositingFilter = "softLightBlendMode" - shimmer.testUpdate(background: .clear, foreground: .white.withAlphaComponent(0.4)) + if shimmerOverlayLayer.mask == nil { + shimmer = .init() + shimmer.layer = shimmerOverlayLayer + shimmerOverlayView.compositingFilter = "softLightBlendMode" + shimmer.testUpdate(background: .clear, foreground: .white.withAlphaComponent(0.4)) + } loadingBlurView.layer.cornerRadius = 10 shimmerOverlayLayer.opacity = 0.6 shimmerBorderLayer.cornerRadius = 10 shimmerBorderLayer.masksToBounds = true shimmerBorderLayer.compositingFilter = "softLightBlendMode" - + shimmerBorderLayer.frame = loadingBlurView.bounds let borderMask = CAShapeLayer() - borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: loadingBlurView.bounds.width, height: loadingBlurView.bounds.height), cornerWidth: 10, cornerHeight: 10, transform: nil) - borderMask.fillColor = UIColor.clear.cgColor - borderMask.strokeColor = UIColor.white.cgColor - borderMask.lineWidth = 4 + borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: 10, cornerHeight: 10, transform: nil) + borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor + borderMask.strokeColor = UIColor.white.withAlphaComponent(0.8).cgColor + borderMask.lineWidth = 2 + borderMask.frame = shimmerBorderLayer.bounds + +// let testBorder = CAShapeLayer() +// testBorder.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: 10, cornerHeight: 10, transform: nil) +// testBorder.fillColor = UIColor.white.withAlphaComponent(0.2).cgColor +// testBorder.strokeColor = UIColor.white.cgColor +// testBorder.lineWidth = 4 +// testBorder.frame = shimmerBorderLayer.bounds // let borderMask = CALayer() - borderShimmer = .init() - shimmerBorderLayer.mask = borderMask - borderShimmer.layer = shimmerBorderLayer - borderShimmer.testUpdate(background: .clear, foreground: .white) +// shimmerBorderLayer.removeAllAnimations() +// if shimmerBorderLayer.mask == nil { + borderShimmer = .init() + shimmerBorderLayer.mask = borderMask + borderShimmer.layer = shimmerBorderLayer + shimmerBorderLayer.backgroundColor = UIColor.clear.cgColor +// shimmerBorderLayer.backgroundColor = UIColor.green.withAlphaComponent(0.4).cgColor + borderShimmer.testUpdate(background: .clear, foreground: .white) +// } loadingBlurView.alpha = 1 } else { if hadVideo { - self.loadingBlurView.removeFromSuperview() - placeholderView.removeFromSuperview() - } else { - // Accounting for delay in first frame received - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [self] in - guard !self.videoStalled else { return } - UIView.transition(with: self, duration: 0.2, animations: { -// self.loadingBlurView.animator.fractionComplete = 0 -// self.loadingBlurView.effect = nil - self.loadingBlurView.alpha = 0 - }, completion: { _ in - self.loadingBlurView.removeFromSuperview() - }) + loadingBlurView.layer.removeAllAnimations() + let anim = CABasicAnimation(keyPath: "opacity") + anim.duration = 0.5 + anim.fromValue = 1 + anim.toValue = 0 + anim.fillMode = .forwards + anim.isRemovedOnCompletion = false + anim.completion = { [self] _ in +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in + loadingBlurView.removeFromSuperview() + // loadingBlurView = .init(effect: UIBlurEffect(style: .light), intensity: 0.4) placeholderView.removeFromSuperview() } + loadingBlurView.layer.add(anim, forKey: "opacity") + } else { + // Accounting for delay in first frame received + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [self] in + guard !self.videoStalled else { return } + + // TODO: animate blur intesity with UIPropertyAnimator + loadingBlurView.layer.removeAllAnimations() + let anim = CABasicAnimation(keyPath: "opacity") + anim.duration = 0.5 + anim.fromValue = 1 + anim.toValue = 0 + anim.fillMode = .forwards + anim.isRemovedOnCompletion = false + anim.completion = { _ in +// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in + self.loadingBlurView.removeFromSuperview() + self.placeholderView.removeFromSuperview() + } + loadingBlurView.layer.add(anim, forKey: "opacity") +// UIView.transition(with: self, duration: 0.2, animations: { +//// self.loadingBlurView.animator.fractionComplete = 0 +//// self.loadingBlurView.effect = nil +//// self.loadingBlurView.alpha = 0 +// }, completion: { _ in +// self.loadingBlurView = .init(effect: UIBlurEffect(style: .light), intensity: 0.4) +// }) + } } +// loadingBlurView.backgroundColor = .yellow.withAlphaComponent(0.4) } } var stallTimer: Foundation.Timer? let fullScreenBackgroundPlaceholder = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + + var avatarDisposable: Disposable? + var didBeginLoadingAvatar = false +// let avatarPlaceholderView = UIImageView() + func update(component: _MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { self.state = state // placeholderView.alpha = 0.7 // placeholderView.image = lastFrame[component.call.peerId.id.description] self.component = component + if let peer = component.callPeer, !didBeginLoadingAvatar { + didBeginLoadingAvatar = true + + avatarDisposable = peerAvatarCompleteImage(account: component.call.account, peer: EnginePeer(peer), size: CGSize(width: 250.0, height: 250.0), round: false, font: Font.regular(16.0), drawLetters: false, fullSize: false, blurred: true).start(next: { [weak self] image in + DispatchQueue.main.async { + self?.placeholderView.contentMode = .scaleAspectFill + self?.placeholderView.image = image + } + }) + } + if component.videoLoading || self.videoStalled { updateVideoStalled(isStalled: true) /*if let frame = lastFrame[component.call.peerId.id.description] { @@ -359,9 +448,9 @@ final class _MediaStreamVideoComponent: Component { frameInputDisposable = input.start(next: { [weak self] input in guard let strongSelf = self else { return } print("input") + strongSelf.stallTimer?.invalidate() // TODO: optimize with throttle DispatchQueue.main.async { - strongSelf.stallTimer?.invalidate() strongSelf.stallTimer = _stallTimer // DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // print(strongSelf.videoStalled) @@ -488,7 +577,7 @@ final class _MediaStreamVideoComponent: Component { fullScreenBackgroundPlaceholder.removeFromSuperview() } else if component.isFullscreen { if fullScreenBackgroundPlaceholder.superview == nil { - insertSubview(fullScreenBackgroundPlaceholder, at: 0) +// insertSubview(fullScreenBackgroundPlaceholder, at: 0) } fullScreenBackgroundPlaceholder.frame = self.bounds } @@ -584,10 +673,10 @@ final class _MediaStreamVideoComponent: Component { placeholderView.frame = loadingBlurView.frame placeholderView.layer.cornerRadius = 10 placeholderView.clipsToBounds = true - +// avatarPlaceholderView.frame = placeholderView.bounds shimmerOverlayLayer.frame = loadingBlurView.bounds shimmerBorderLayer.frame = loadingBlurView.bounds - shimmerBorderLayer.mask?.frame = loadingBlurView.bounds +// shimmerBorderLayer.mask?.frame = loadingBlurView.bounds if component.isFullscreen { loadingBlurView.removeFromSuperview() } diff --git a/submodules/TelegramCallsUI/Sources/MediaStreamingController.swift b/submodules/TelegramCallsUI/Sources/MediaStreamingController.swift index 14a6f76f94..eb8caf9148 100644 --- a/submodules/TelegramCallsUI/Sources/MediaStreamingController.swift +++ b/submodules/TelegramCallsUI/Sources/MediaStreamingController.swift @@ -36,12 +36,12 @@ import DeviceAccess //let panelBackgroundColor = UIColor(rgb: 0x1c1c1e) //let secondaryPanelBackgroundColor = UIColor(rgb: 0x2c2c2e) //let fullscreenBackgroundColor = UIColor(rgb: 0x000000) -private let smallButtonSize = CGSize(width: 36.0, height: 36.0) -private let sideButtonSize = CGSize(width: 56.0, height: 56.0) -private let topPanelHeight: CGFloat = 63.0 +//private let smallButtonSize = CGSize(width: 36.0, height: 36.0) +//private let sideButtonSize = CGSize(width: 56.0, height: 56.0) +//private let topPanelHeight: CGFloat = 63.0 //let bottomAreaHeight: CGFloat = 206.0 -private let fullscreenBottomAreaHeight: CGFloat = 80.0 -private let bottomGradientHeight: CGFloat = 70.0 +//private let fullscreenBottomAreaHeight: CGFloat = 80.0 +//private let bottomGradientHeight: CGFloat = 70.0 /*func decorationCornersImage(top: Bool, bottom: Bool, dark: Bool) -> UIImage? { if !top && !bottom { @@ -1853,16 +1853,16 @@ public final class MediaStreamingControllerImpl: ViewController, VoiceChatContro self.contentContainer.addSubnode(self.backgroundNode) - self.contentContainer.addSubnode(self.listContainer) - self.contentContainer.addSubnode(self.topPanelNode) - self.listContainer.addSubnode(self.listNode) - self.listContainer.addSubnode(self.leftBorderNode) - self.listContainer.addSubnode(self.rightBorderNode) - self.listContainer.addSubnode(self.bottomCornersNode) +// self.contentContainer.addSubnode(self.listContainer) +// self.contentContainer.addSubnode(self.topPanelNode) +// self.listContainer.addSubnode(self.listNode) +// self.listContainer.addSubnode(self.leftBorderNode) +// self.listContainer.addSubnode(self.rightBorderNode) +// self.listContainer.addSubnode(self.bottomCornersNode) self.listContainer.addSubnode(self.topCornersNode) self.contentContainer.addSubnode(self.bottomGradientNode) - self.contentContainer.addSubnode(self.bottomPanelBackgroundNode) -// self.contentContainer.addSubnode(self.participantsNode) +// self.contentContainer.addSubnode(self.bottomPanelBackgroundNode) + self.contentContainer.addSubnode(self.participantsNode) self.contentContainer.addSubnode(self.tileGridNode) self.contentContainer.addSubnode(self.mainStageContainerNode) self.contentContainer.addSubnode(self.transitionContainerNode) @@ -4010,7 +4010,7 @@ public final class MediaStreamingControllerImpl: ViewController, VoiceChatContro let participantsFrame = CGRect(x: 0.0, y: bottomCornersFrame.maxY - 100.0, width: size.width, height: 216.0) transition.updateFrame(node: self.participantsNode, frame: participantsFrame) - self.participantsNode.update(size: participantsFrame.size, participants: self.currentTotalCount, groupingSeparator: self.presentationData.dateTimeFormat.groupingSeparator, transition: .immediate) + self.participantsNode.update(size: participantsFrame.size, participants: Int32.random(in: 0..<999999999)/*self.currentTotalCount*/, groupingSeparator: self.presentationData.dateTimeFormat.groupingSeparator, transition: .immediate) } private var decorationsAreDark: Bool? From bfa7de8e27425ab7965607faab4a20c29a2877ac Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Wed, 7 Dec 2022 22:34:55 +0400 Subject: [PATCH 20/50] Tweaking offsets and landscape mode --- .../Components/AnimatedCounterView.swift | 2 +- .../Components/MediaStreamComponent.swift | 48 ++++-- .../MediaStreamVideoComponent.swift | 142 +++++++----------- .../Components/StreamSheetComponent.swift | 12 +- 4 files changed, 98 insertions(+), 106 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index 255b419402..56e561b012 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -50,7 +50,7 @@ public final class AnimatedCountView: UIView { self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60) self.maskingView.frame = CGRect(origin: .zero, size: bounds.size) countLabel.frame = CGRect(origin: .zero, size: bounds.size) - subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - 8 : bounds.height - 6, width: subtitleLabel.intrinsicContentSize.width + 20, height: 20) + subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - 8 : bounds.height - 10, width: subtitleLabel.intrinsicContentSize.width + 20, height: 20) } func update(countString: String, subtitle: String) { diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 8960b73879..5efce89817 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -969,7 +969,7 @@ public final class _MediaStreamComponent: CombinedComponent { let moreAnimationTag = GenericComponentViewTag() return { context in - var forceFullScreenInLandscape: Bool { false } + var forceFullScreenInLandscape: Bool { true } let environment = context.environment[ViewControllerComponentContainer.Environment.self].value if environment.isVisible { } else { @@ -1000,24 +1000,36 @@ public final class _MediaStreamComponent: CombinedComponent { state.updated(transition: .easeInOut(duration: 3)) deactivatePictureInPicture.invoke(Void()) } - var isFullscreen = state.isFullscreen + let isFullscreen: Bool // = state.isFullscreen let isLandscape = context.availableSize.width > context.availableSize.height // if let videoSize = context.state.videoSize { // Always fullscreen in landscape // TODO: support landscape sheet (wrap in scrollview, video size same as portrait) - if forceFullScreenInLandscape && /*videoSize.width > videoSize.height &&*/ isLandscape && !isFullscreen { - state.isFullscreen = true - isFullscreen = true - } else if let videoSize = context.state.videoSize, videoSize.width > videoSize.height && !isLandscape && isFullscreen { - state.isFullscreen = false - isFullscreen = false - } -// } + if forceFullScreenInLandscape && /*videoSize.width > videoSize.height &&*/ isLandscape && !state.isFullscreen { + state.isFullscreen = true + isFullscreen = true + } else if let videoSize = context.state.videoSize, videoSize.width > videoSize.height && !isLandscape && state.isFullscreen { + state.isFullscreen = false + isFullscreen = false + } else { + isFullscreen = state.isFullscreen + } + // } + let videoInset: CGFloat + if !isFullscreen { + videoInset = 16 + } else { + videoInset = 0 + } - let videoHeight: CGFloat = context.availableSize.width / 16 * 9 + let videoHeight: CGFloat = forceFullScreenInLandscape + ? (context.availableSize.width - videoInset * 2) / 16 * 9 + : context.state.videoSize?.height ?? (context.availableSize.width - videoInset * 2) / 16 * 9 let bottomPadding = 40 + environment.safeInsets.bottom - let sheetHeight: CGFloat = isFullscreen ? context.availableSize.height : (44 + videoHeight + 40 + 69 + 16 + 32 + 70 + bottomPadding) + let sheetHeight: CGFloat = isFullscreen + ? context.availableSize.height + : (44 + videoHeight + 40 + 69 + 16 + 32 + 70 + bottomPadding) let isFullyDragged = context.availableSize.height - sheetHeight + state.dismissOffset - context.view.safeAreaInsets.top < 30 var dragOffset = context.state.dismissOffset @@ -1054,6 +1066,10 @@ public final class _MediaStreamComponent: CombinedComponent { }, onVideoSizeRetrieved: { [weak state] size in state?.videoSize = size + }, + onVideoPlaybackLiveChange: { [weak state] isLive in + state?.videoStalled = !isLive + state?.updated() } ), availableSize: context.availableSize, @@ -1538,7 +1554,8 @@ public final class _MediaStreamComponent: CombinedComponent { participantsCount: context.state.originInfo?.memberCount ?? 0, // Int.random(in: 0...999998)// [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! // isFullyExtended: isFullyDragged, - deviceCornerRadius: deviceCornerRadius ?? 0 + deviceCornerRadius: deviceCornerRadius ?? 0, + videoHeight: videoHeight ), availableSize: context.availableSize, transition: context.transition @@ -1559,7 +1576,7 @@ public final class _MediaStreamComponent: CombinedComponent { if isFullscreen { videoPos = context.availableSize.height / 2 + dragOffset } else { - videoPos = sheetPosition - sheetHeight / 2 + videoHeight / 2 + 50 + videoPos = sheetPosition - sheetHeight / 2 + videoHeight / 2 + 50 + 12 } context.add(video .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)/*sheetPosition + videoHeight / 2 + 50 - context.availableSize.height / 2*/)// context.availableSize.height / 2.0 + context.state.dismissOffset)) @@ -1623,7 +1640,8 @@ public final class _MediaStreamComponent: CombinedComponent { bottomPadding: 12, participantsCount: -1, // context.state.originInfo?.memberCount ?? 0 isFullyExtended: isFullyDragged, - deviceCornerRadius: deviceCornerRadius ?? 0 + deviceCornerRadius: deviceCornerRadius ?? 0, + videoHeight: videoHeight ), availableSize: context.availableSize, transition: context.transition diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index b9236d73bd..8b84a9780c 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -80,6 +80,7 @@ final class _MediaStreamVideoComponent: Component { let onVideoSizeRetrieved: (CGSize) -> Void let videoLoading: Bool let callPeer: Peer? + let onVideoPlaybackLiveChange: (Bool) -> Void init( call: PresentationGroupCallImpl, @@ -95,7 +96,8 @@ final class _MediaStreamVideoComponent: Component { deactivatePictureInPicture: ActionSlot, bringBackControllerForPictureInPictureDeactivation: @escaping (@escaping () -> Void) -> Void, pictureInPictureClosed: @escaping () -> Void, - onVideoSizeRetrieved: @escaping (CGSize) -> Void + onVideoSizeRetrieved: @escaping (CGSize) -> Void, + onVideoPlaybackLiveChange: @escaping (Bool) -> Void ) { self.call = call self.hasVideo = hasVideo @@ -107,6 +109,7 @@ final class _MediaStreamVideoComponent: Component { self.deactivatePictureInPicture = deactivatePictureInPicture self.bringBackControllerForPictureInPictureDeactivation = bringBackControllerForPictureInPictureDeactivation self.pictureInPictureClosed = pictureInPictureClosed + self.onVideoPlaybackLiveChange = onVideoPlaybackLiveChange self.callPeer = callPeer self.peerImage = peerImage @@ -220,6 +223,8 @@ final class _MediaStreamVideoComponent: Component { } } } + var onVideoPlaybackChange: ((Bool) -> Void) = { _ in } + private var frameInputDisposable: Disposable? private func updateVideoStalled(isStalled: Bool) { @@ -268,15 +273,15 @@ final class _MediaStreamVideoComponent: Component { shimmerOverlayView.compositingFilter = "softLightBlendMode" shimmer.testUpdate(background: .clear, foreground: .white.withAlphaComponent(0.4)) } - loadingBlurView.layer.cornerRadius = 10 +// loadingBlurView.layer.cornerRadius = 10 shimmerOverlayLayer.opacity = 0.6 - - shimmerBorderLayer.cornerRadius = 10 + let cornerRadius = loadingBlurView.layer.cornerRadius + shimmerBorderLayer.cornerRadius = cornerRadius // TODO: check isFullScreeen shimmerBorderLayer.masksToBounds = true shimmerBorderLayer.compositingFilter = "softLightBlendMode" shimmerBorderLayer.frame = loadingBlurView.bounds let borderMask = CAShapeLayer() - borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: 10, cornerHeight: 10, transform: nil) + borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor borderMask.strokeColor = UIColor.white.withAlphaComponent(0.8).cgColor borderMask.lineWidth = 2 @@ -353,12 +358,17 @@ final class _MediaStreamVideoComponent: Component { var avatarDisposable: Disposable? var didBeginLoadingAvatar = false // let avatarPlaceholderView = UIImageView() + var timeLastFrameReceived: CFAbsoluteTime? + + var isFullscreen: Bool = false func update(component: _MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { self.state = state // placeholderView.alpha = 0.7 // placeholderView.image = lastFrame[component.call.peerId.id.description] self.component = component + self.onVideoPlaybackChange = component.onVideoPlaybackLiveChange + self.isFullscreen = component.isFullscreen if let peer = component.callPeer, !didBeginLoadingAvatar { didBeginLoadingAvatar = true @@ -373,96 +383,46 @@ final class _MediaStreamVideoComponent: Component { if component.videoLoading || self.videoStalled { updateVideoStalled(isStalled: true) - /*if let frame = lastFrame[component.call.peerId.id.description] { - placeholderView.subviews.forEach { $0.removeFromSuperview() } - placeholderView.addSubview(frame) - frame.frame = placeholderView.bounds - // placeholderView.backgroundColor = .green - } else { - // placeholderView.subviews.forEach { $0.removeFromSuperview() } - // placeholderView.backgroundColor = .red - } - - if !hadVideo && placeholderView.superview == nil { - addSubview(placeholderView) - } - if loadingBlurView.superview == nil { - addSubview(loadingBlurView) - } - if shimmerOverlayLayer.superlayer == nil { - loadingBlurView.layer.addSublayer(shimmerOverlayLayer) - loadingBlurView.layer.addSublayer(shimmerBorderLayer) - } - loadingBlurView.clipsToBounds = true - shimmer = .init() - shimmer.layer = shimmerOverlayLayer - shimmerOverlayView.compositingFilter = "softLightBlendMode" - shimmer.testUpdate(background: .clear, foreground: .white.withAlphaComponent(0.4)) - loadingBlurView.layer.cornerRadius = 10 - shimmerOverlayLayer.opacity = 0.6 - - shimmerBorderLayer.cornerRadius = 10 - shimmerBorderLayer.masksToBounds = true - shimmerBorderLayer.compositingFilter = "softLightBlendMode" - - let borderMask = CAShapeLayer() - borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: loadingBlurView.bounds.width, height: loadingBlurView.bounds.height), cornerWidth: 10, cornerHeight: 10, transform: nil) - borderMask.fillColor = UIColor.clear.cgColor - borderMask.strokeColor = UIColor.white.cgColor - borderMask.lineWidth = 4 -// let borderMask = CALayer() - borderShimmer = .init() - shimmerBorderLayer.mask = borderMask - borderShimmer.layer = shimmerBorderLayer - borderShimmer.testUpdate(background: .clear, foreground: .white) - loadingBlurView.alpha = 1*/ } else { updateVideoStalled(isStalled: false) - /*if hadVideo { - self.loadingBlurView.removeFromSuperview() - placeholderView.removeFromSuperview() - } else { - // Accounting for delay in first frame received - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [self] in - UIView.transition(with: self.loadingBlurView, duration: 0.2, animations: { - self.loadingBlurView.alpha = 0 - }, completion: { _ in - self.loadingBlurView.removeFromSuperview() - }) - placeholderView.removeFromSuperview() - } - }*/ } if component.hasVideo, self.videoView == nil { if let input = component.call.video(endpointId: "unified") { var _stallTimer: Foundation.Timer { Foundation.Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in guard let strongSelf = self else { return timer.invalidate() } - print("Timer emitting \(timer)") + +// print("Timer emitting \(timer)") + let currentTime = CFAbsoluteTimeGetCurrent() + if let lastFrameTime = strongSelf.timeLastFrameReceived, + currentTime - lastFrameTime > 0.5 { DispatchQueue.main.async { strongSelf.videoStalled = true + strongSelf.onVideoPlaybackChange(false) } } - } + } } // TODO: use mapToThrottled (?) frameInputDisposable = input.start(next: { [weak self] input in guard let strongSelf = self else { return } - print("input") - strongSelf.stallTimer?.invalidate() +// print("input") + // strongSelf.stallTimer?.invalidate() // TODO: optimize with throttle + strongSelf.timeLastFrameReceived = CFAbsoluteTimeGetCurrent() DispatchQueue.main.async { - strongSelf.stallTimer = _stallTimer -// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { -// print(strongSelf.videoStalled) -// if strongSelf.videoStalled { -// strongSelf.stallTimer?.fire() -// } - RunLoop.main.add(strongSelf.stallTimer!, forMode: .common) + // strongSelf.stallTimer = _stallTimer + // DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + // print(strongSelf.videoStalled) + // if strongSelf.videoStalled { + // strongSelf.stallTimer?.fire() + // } + // RunLoop.main.add(strongSelf.stallTimer!, forMode: .common) strongSelf.videoStalled = false + strongSelf.onVideoPlaybackChange(true) } }) stallTimer = _stallTimer -// RunLoop.main.add(stallTimer!, forMode: .common) + // RunLoop.main.add(stallTimer!, forMode: .common) if let videoBlurView = self.videoRenderingContext.makeView(input: input, blur: true) { self.videoBlurView = videoBlurView @@ -487,7 +447,7 @@ final class _MediaStreamVideoComponent: Component { } if let sampleBufferVideoView = videoView as? SampleBufferVideoRenderingView { sampleBufferVideoView.sampleBufferLayer.masksToBounds = true - sampleBufferVideoView.sampleBufferLayer.cornerRadius = 20 +// sampleBufferVideoView.sampleBufferLayer.cornerRadius = 10 if #available(iOS 13.0, *) { sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true @@ -532,7 +492,7 @@ final class _MediaStreamVideoComponent: Component { return delegate }())) pictureInPictureController?.playerLayer.masksToBounds = false - pictureInPictureController?.playerLayer.cornerRadius = 30 + pictureInPictureController?.playerLayer.cornerRadius = 10 } else if AVPictureInPictureController.isPictureInPictureSupported() { // TODO: support PiP for iOS < 15.0 // sampleBufferVideoView.sampleBufferLayer @@ -577,9 +537,11 @@ final class _MediaStreamVideoComponent: Component { fullScreenBackgroundPlaceholder.removeFromSuperview() } else if component.isFullscreen { if fullScreenBackgroundPlaceholder.superview == nil { -// insertSubview(fullScreenBackgroundPlaceholder, at: 0) + insertSubview(fullScreenBackgroundPlaceholder, at: 0) } fullScreenBackgroundPlaceholder.frame = self.bounds + } else { + fullScreenBackgroundPlaceholder.removeFromSuperview() } // sheetView.frame = .init(x: 0, y: sheetTop, width: availableSize.width, height: sheetHeight) @@ -595,6 +557,8 @@ final class _MediaStreamVideoComponent: Component { videoInset = 0 } + let videoSize: CGSize + let videoCornerRadius: CGFloat = component.isFullscreen ? 0 : 10 if let videoView = self.videoView { // TODO: REMOVE FROM HERE and move to call end (or at least to background) // if let presentation = videoView.snapshotView(afterScreenUpdates: false) { @@ -608,13 +572,17 @@ final class _MediaStreamVideoComponent: Component { // saveAspect(aspect) if component.isFullscreen { if aspect <= 0.01 { - aspect = 3.0 / 4.0 + aspect = 16.0 / 9 // 3.0 / 4.0 } } else { aspect = 16.0 / 9 } - let videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) + if component.isFullscreen { + videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) + } else { + videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: min(availableSize.width, availableSize.height) - videoInset * 2, height: max(availableSize.height, availableSize.width))) + } let blurredVideoSize = videoSize.aspectFilled(availableSize) component.onVideoSizeRetrieved(videoSize) @@ -635,7 +603,7 @@ final class _MediaStreamVideoComponent: Component { videoView.updateIsEnabled(isVideoVisible) videoView.clipsToBounds = true - videoView.layer.cornerRadius = component.isFullscreen ? 0 : 10 + videoView.layer.cornerRadius = videoCornerRadius // var aspect = videoView.getAspect() // if aspect <= 0.01 { // TODO: remove debug @@ -664,21 +632,21 @@ final class _MediaStreamVideoComponent: Component { self.maskGradientLayer.frame = videoBlurView.bounds } + } else { + videoSize = CGSize(width: 16 / 9 * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) } - - let videoSize = CGSize(width: 16 / 9 * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) loadingBlurView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) - loadingBlurView.layer.cornerRadius = 10 + loadingBlurView.layer.cornerRadius = videoCornerRadius placeholderView.frame = loadingBlurView.frame - placeholderView.layer.cornerRadius = 10 + placeholderView.layer.cornerRadius = videoCornerRadius placeholderView.clipsToBounds = true -// avatarPlaceholderView.frame = placeholderView.bounds + shimmerOverlayLayer.frame = loadingBlurView.bounds shimmerBorderLayer.frame = loadingBlurView.bounds -// shimmerBorderLayer.mask?.frame = loadingBlurView.bounds + if component.isFullscreen { - loadingBlurView.removeFromSuperview() +// loadingBlurView.removeFromSuperview() } if !self.hadVideo { // TODO: hide fullscreen button without video diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 971e8b0d6b..d15603a52e 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -21,6 +21,7 @@ final class StreamSheetComponent: CombinedComponent { let bottomPadding: CGFloat let isFullyExtended: Bool let deviceCornerRadius: CGFloat + let videoHeight: CGFloat init( // color: UIColor, @@ -32,7 +33,8 @@ final class StreamSheetComponent: CombinedComponent { bottomPadding: CGFloat, participantsCount: Int, isFullyExtended: Bool, - deviceCornerRadius: CGFloat + deviceCornerRadius: CGFloat, + videoHeight: CGFloat ) { // self.leftItem = leftItem self.topComponent = topComponent @@ -45,6 +47,7 @@ final class StreamSheetComponent: CombinedComponent { self.participantsCount = participantsCount self.isFullyExtended = isFullyExtended self.deviceCornerRadius = deviceCornerRadius + self.videoHeight = videoHeight } static func ==(lhs: StreamSheetComponent, rhs: StreamSheetComponent) -> Bool { @@ -75,6 +78,9 @@ final class StreamSheetComponent: CombinedComponent { if lhs.isFullyExtended != rhs.isFullyExtended { return false } + if lhs.videoHeight != rhs.videoHeight { + return false + } return true } // @@ -95,7 +101,7 @@ final class StreamSheetComponent: CombinedComponent { override func draw(_ rect: CGRect) { super.draw(rect) - + // Debug interactive area // guard let context = UIGraphicsGetCurrentContext() else { return } // context.setFillColor(UIColor.red.cgColor) // overlayComponentsFrames.forEach { frame in @@ -193,7 +199,7 @@ final class StreamSheetComponent: CombinedComponent { ) (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: topOffset, width: topItem.size.width, height: topItem.size.height)) } - let videoHeight = (availableWidth - 32) / 16 * 9 + let videoHeight = context.component.videoHeight // ?? (min(availableWidth, context.availableSize.height) - 32) / 16 * 9 let sheetHeight = context.component.sheetHeight let animatedParticipantsVisible = context.component.participantsCount != -1 if true { From 17ef4ae35bd0a19fe32e4327db37de7f7365821a Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 8 Dec 2022 00:07:59 +0400 Subject: [PATCH 21/50] Throttling playback status change --- .../Components/MediaStreamComponent.swift | 129 ++++++++++------- .../MediaStreamVideoComponent.swift | 134 ++++++++++-------- 2 files changed, 153 insertions(+), 110 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 5efce89817..061901b673 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -801,7 +801,8 @@ public final class _MediaStreamComponent: CombinedComponent { strongSelf.updated(transition: .immediate) }) - self.networkStateDisposable = (call.account.networkState |> deliverOnMainQueue).start(next: { [weak self] state in + // TODO: retest to uncomment or delete. Relying only on video frames + /*self.networkStateDisposable = (call.account.networkState |> deliverOnMainQueue).start(next: { [weak self] state in guard let strongSelf = self else { return } switch state { case .waitingForNetwork, .connecting: @@ -828,7 +829,7 @@ public final class _MediaStreamComponent: CombinedComponent { if prev != self?.videoStalled { self?.updated(transition: .immediate) } - }) + })*/ let callPeer = call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: call.peerId)) @@ -956,6 +957,7 @@ public final class _MediaStreamComponent: CombinedComponent { public static var body: Body { let background = Child(Rectangle.self) + let dismissTapComponent = Child(Rectangle.self) let video = Child(MediaStreamVideoComponent.self) // let navigationBar = Child(NavigationBarComponent.self) // let toolbar = Child(ToolbarComponent.self) @@ -1037,6 +1039,14 @@ public final class _MediaStreamComponent: CombinedComponent { dragOffset = max(context.state.dismissOffset, sheetHeight - context.availableSize.height + context.view.safeAreaInsets.top)// sheetHeight - UIScreen.main.bounds.height } + let dismissTapAreaHeight = isFullscreen ? 0 : (context.availableSize.height - sheetHeight + dragOffset) + let dismissTapComponent = dismissTapComponent.update( + component: Rectangle(color: .red.withAlphaComponent(0)), + availableSize: CGSize(width: context.availableSize.width, height: dismissTapAreaHeight), + transition: context.transition + ) + + let video = video.update( component: MediaStreamVideoComponent( call: context.component.call, @@ -1044,7 +1054,7 @@ public final class _MediaStreamComponent: CombinedComponent { isVisible: environment.isVisible && context.state.isVisibleInHierarchy, isAdmin: context.state.canManageCall, peerTitle: context.state.peerTitle, - // TODO: find out how to get image + // TODO: remove // find out how to get image peerImage: nil, isFullscreen: isFullscreen, videoLoading: context.state.videoStalled, @@ -1068,8 +1078,12 @@ public final class _MediaStreamComponent: CombinedComponent { state?.videoSize = size }, onVideoPlaybackLiveChange: { [weak state] isLive in - state?.videoStalled = !isLive - state?.updated() + guard let state else { return } + let wasLive = !state.videoStalled + if isLive != wasLive { + state.videoStalled = !isLive + state.updated() + } } ), availableSize: context.availableSize, @@ -1381,64 +1395,77 @@ public final class _MediaStreamComponent: CombinedComponent { } let availableSize = context.availableSize let safeAreaTop = context.view.safeAreaInsets.top + + let onPanGesture: ((Gesture.PanGestureState) -> Void) = { [weak state] panState in + guard let state = state else { + return + } + switch panState { + case .began: + state.initialOffset = state.dismissOffset + case let .updated(offset): + state.updateDismissOffset(value: state.initialOffset + offset.y, interactive: true) + case let .ended(velocity): + // TODO: Dismiss sheet depending on velocity + if velocity.y > 200.0 { + if state.isFullscreen { + state.isFullscreen = false + state.updateDismissOffset(value: 0.0, interactive: false) + if let controller = controller() as? MediaStreamComponentController { + controller.updateOrientation(orientation: .portrait) + } + } else { + if isFullyDragged || state.initialOffset != 0 { + state.updateDismissOffset(value: 0.0, interactive: false) + } else { + let _ = call.leave(terminateIfPossible: false) + } + } + /*activatePictureInPicture.invoke(Action { [weak state] in + guard let state = state, let controller = controller() as? MediaStreamComponentController else { + return + } + state.updateDismissOffset(value: velocity.y < 0 ? -height : height, interactive: false) + controller.dismiss(closing: false, manual: true) + })*/ + } else { + if isFullyDragged { + state.updateDismissOffset(value: sheetHeight - availableSize.height + safeAreaTop, interactive: false) + } else { + if velocity.y < -200 { + // Expand + state.updateDismissOffset(value: sheetHeight - availableSize.height + safeAreaTop, interactive: false) + } else { + state.updateDismissOffset(value: 0.0, interactive: false) + } + } + } + } + } + context.add(background .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) .gesture(.tap { [weak state] in - guard let state = state else { + guard let state = state, state.isFullscreen else { return } state.toggleDisplayUI() }) - .gesture(.pan { [weak state] panState in - guard let state = state else { - return - } - switch panState { - case .began: - state.initialOffset = state.dismissOffset - case let .updated(offset): - state.updateDismissOffset(value: state.initialOffset + offset.y, interactive: true) - case let .ended(velocity): - // TODO: Dismiss sheet depending on velocity - if velocity.y > 200.0 { - if state.isFullscreen { - state.isFullscreen = false - state.updateDismissOffset(value: 0.0, interactive: false) - if let controller = controller() as? MediaStreamComponentController { - controller.updateOrientation(orientation: .portrait) - } - } else { - if isFullyDragged || state.initialOffset != 0 { - state.updateDismissOffset(value: 0.0, interactive: false) - } else { - let _ = call.leave(terminateIfPossible: false) - } - } - /*activatePictureInPicture.invoke(Action { [weak state] in - guard let state = state, let controller = controller() as? MediaStreamComponentController else { - return - } - state.updateDismissOffset(value: velocity.y < 0 ? -height : height, interactive: false) - controller.dismiss(closing: false, manual: true) - })*/ - } else { - if isFullyDragged { - state.updateDismissOffset(value: sheetHeight - availableSize.height + safeAreaTop, interactive: false) - } else { - if velocity.y < -200 { - // Expand - state.updateDismissOffset(value: sheetHeight - availableSize.height + safeAreaTop, interactive: false) - } else { - state.updateDismissOffset(value: 0.0, interactive: false) - } - } - } - } + .gesture(.pan { panState in + onPanGesture(panState) }) ) // var bottomComponent: AnyComponent? // var fullScreenToolbarComponent: AnyComponent? + context.add(dismissTapComponent + .position(CGPoint(x: context.availableSize.width / 2, y: dismissTapAreaHeight / 2)) + .gesture(.tap { + _ = call.leave(terminateIfPossible: false) + }) + .gesture(.pan(onPanGesture)) + ) + if !isFullscreen { let bottomComponent = AnyComponent(ButtonsRowComponent( bottomInset: environment.safeInsets.bottom, diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 8b84a9780c..e60772bed3 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -238,10 +238,10 @@ final class _MediaStreamVideoComponent: Component { // self.loadingBlurView.animator.fractionComplete = intensity // self.loadingBlurView.animator.fractionComplete = 0.4 // self.loadingBlurView.effect = UIBlurEffect(style: .light) - if let frame = lastFrame[component.call.peerId.id.description] { + if let frameView = lastFrame[component.call.peerId.id.description] { placeholderView.subviews.forEach { $0.removeFromSuperview() } - placeholderView.addSubview(frame) - frame.frame = placeholderView.bounds + placeholderView.addSubview(frameView) + frameView.frame = placeholderView.bounds // placeholderView.backgroundColor = .green } else { // placeholderView.addSubview(avatarPlaceholderView) @@ -252,30 +252,36 @@ final class _MediaStreamVideoComponent: Component { if !hadVideo && placeholderView.superview == nil { addSubview(placeholderView) } + + let needsFadeInAnimation = hadVideo + if loadingBlurView.superview == nil { addSubview(loadingBlurView) - let anim = CABasicAnimation(keyPath: "opacity") - anim.duration = 0.5 - anim.fromValue = 0 - anim.toValue = 1 - anim.fillMode = .forwards - anim.isRemovedOnCompletion = false - loadingBlurView.layer.add(anim, forKey: "opacity") + if needsFadeInAnimation { + let anim = CABasicAnimation(keyPath: "opacity") + anim.duration = 0.5 + anim.fromValue = 0 + anim.toValue = 1 + anim.fillMode = .forwards + anim.isRemovedOnCompletion = false + loadingBlurView.layer.add(anim, forKey: "opacity") + } } if shimmerBorderLayer.superlayer == nil { // loadingBlurView.contentView.layer.addSublayer(shimmerOverlayLayer) loadingBlurView.contentView.layer.addSublayer(shimmerBorderLayer) } loadingBlurView.clipsToBounds = true - if shimmerOverlayLayer.mask == nil { - shimmer = .init() - shimmer.layer = shimmerOverlayLayer - shimmerOverlayView.compositingFilter = "softLightBlendMode" - shimmer.testUpdate(background: .clear, foreground: .white.withAlphaComponent(0.4)) - } +// if shimmerOverlayLayer.mask == nil { +// shimmer = .init() +// shimmer.layer = shimmerOverlayLayer +// shimmerOverlayView.compositingFilter = "softLightBlendMode" +// shimmer.testUpdate(background: .clear, foreground: .white.withAlphaComponent(0.4)) +// } // loadingBlurView.layer.cornerRadius = 10 - shimmerOverlayLayer.opacity = 0.6 + let cornerRadius = loadingBlurView.layer.cornerRadius +// shimmerOverlayLayer.opacity = 0.6 shimmerBorderLayer.cornerRadius = cornerRadius // TODO: check isFullScreeen shimmerBorderLayer.masksToBounds = true shimmerBorderLayer.compositingFilter = "softLightBlendMode" @@ -295,14 +301,14 @@ final class _MediaStreamVideoComponent: Component { // testBorder.frame = shimmerBorderLayer.bounds // let borderMask = CALayer() // shimmerBorderLayer.removeAllAnimations() -// if shimmerBorderLayer.mask == nil { - borderShimmer = .init() - shimmerBorderLayer.mask = borderMask - borderShimmer.layer = shimmerBorderLayer + // if shimmerBorderLayer.mask == nil { + borderShimmer = .init() + shimmerBorderLayer.mask = borderMask + borderShimmer.layer = shimmerBorderLayer shimmerBorderLayer.backgroundColor = UIColor.clear.cgColor -// shimmerBorderLayer.backgroundColor = UIColor.green.withAlphaComponent(0.4).cgColor - borderShimmer.testUpdate(background: .clear, foreground: .white) -// } + // shimmerBorderLayer.backgroundColor = UIColor.green.withAlphaComponent(0.4).cgColor + borderShimmer.testUpdate(background: .clear, foreground: .white) + // } loadingBlurView.alpha = 1 } else { if hadVideo { @@ -313,32 +319,32 @@ final class _MediaStreamVideoComponent: Component { anim.toValue = 0 anim.fillMode = .forwards anim.isRemovedOnCompletion = false - anim.completion = { [self] _ in -// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in - loadingBlurView.removeFromSuperview() - // loadingBlurView = .init(effect: UIBlurEffect(style: .light), intensity: 0.4) - placeholderView.removeFromSuperview() + anim.completion = { [weak self] _ in + guard self?.videoStalled == false else { return } + self?.loadingBlurView.removeFromSuperview() + self?.placeholderView.removeFromSuperview() } loadingBlurView.layer.add(anim, forKey: "opacity") } else { // Accounting for delay in first frame received - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [self] in - guard !self.videoStalled else { return } + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + guard self?.videoStalled == false else { return } // TODO: animate blur intesity with UIPropertyAnimator - loadingBlurView.layer.removeAllAnimations() + self?.loadingBlurView.layer.removeAllAnimations() let anim = CABasicAnimation(keyPath: "opacity") anim.duration = 0.5 anim.fromValue = 1 anim.toValue = 0 anim.fillMode = .forwards anim.isRemovedOnCompletion = false - anim.completion = { _ in + anim.completion = { [weak self] _ in + guard self?.videoStalled == false else { return } // DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in - self.loadingBlurView.removeFromSuperview() - self.placeholderView.removeFromSuperview() + self?.loadingBlurView.removeFromSuperview() + self?.placeholderView.removeFromSuperview() } - loadingBlurView.layer.add(anim, forKey: "opacity") + self?.loadingBlurView.layer.add(anim, forKey: "opacity") // UIView.transition(with: self, duration: 0.2, animations: { //// self.loadingBlurView.animator.fractionComplete = 0 //// self.loadingBlurView.effect = nil @@ -361,6 +367,7 @@ final class _MediaStreamVideoComponent: Component { var timeLastFrameReceived: CFAbsoluteTime? var isFullscreen: Bool = false + let videoLoadingThrottler = Throttler(duration: 1, queue: .main) func update(component: _MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { self.state = state @@ -381,7 +388,7 @@ final class _MediaStreamVideoComponent: Component { }) } - if component.videoLoading || self.videoStalled { + if !component.hasVideo || component.videoLoading || self.videoStalled { updateVideoStalled(isStalled: true) } else { updateVideoStalled(isStalled: false) @@ -396,10 +403,13 @@ final class _MediaStreamVideoComponent: Component { let currentTime = CFAbsoluteTimeGetCurrent() if let lastFrameTime = strongSelf.timeLastFrameReceived, currentTime - lastFrameTime > 0.5 { - DispatchQueue.main.async { - strongSelf.videoStalled = true - strongSelf.onVideoPlaybackChange(false) +// DispatchQueue.main.async { + strongSelf.videoLoadingThrottler.publish(true, includingLatest: true) { isStalled in + strongSelf.videoStalled = isStalled + strongSelf.onVideoPlaybackChange(!isStalled) } + + // } } } } // TODO: use mapToThrottled (?) @@ -409,7 +419,7 @@ final class _MediaStreamVideoComponent: Component { // strongSelf.stallTimer?.invalidate() // TODO: optimize with throttle strongSelf.timeLastFrameReceived = CFAbsoluteTimeGetCurrent() - DispatchQueue.main.async { +// DispatchQueue.main.async { // strongSelf.stallTimer = _stallTimer // DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { // print(strongSelf.videoStalled) @@ -417,9 +427,13 @@ final class _MediaStreamVideoComponent: Component { // strongSelf.stallTimer?.fire() // } // RunLoop.main.add(strongSelf.stallTimer!, forMode: .common) - strongSelf.videoStalled = false - strongSelf.onVideoPlaybackChange(true) + strongSelf.videoLoadingThrottler.publish(false, includingLatest: true) { isStalled in + strongSelf.videoStalled = isStalled + strongSelf.onVideoPlaybackChange(!isStalled) } +// strongSelf.videoStalled = false +// strongSelf.onVideoPlaybackChange(true) +// } }) stallTimer = _stallTimer // RunLoop.main.add(stallTimer!, forMode: .common) @@ -743,22 +757,24 @@ final class _MediaStreamVideoComponent: Component { func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { // Fading to make - let presentation = self.videoView!.snapshotView(afterScreenUpdates: false)! - self.addSubview(presentation) - presentation.frame = self.videoView!.frame - lastFrame[self.component!.call.peerId.id.description] = presentation -// let image = UIGraphicsImageRenderer(size: presentation.bounds.size).image { context in -// presentation.render(in: context.cgContext) -// } -// print(image) - self.videoView?.alpha = 0 -// self.videoView?.alpha = 0.5 -// presentation.animateAlpha(from: 1, to: 0, duration: 0.1, completion: { _ in presentation.removeFromSuperlayer() }) - UIView.animate(withDuration: 0.1, animations: { - presentation.alpha = 0 - }, completion: { _ in - presentation.removeFromSuperview() - }) + if let presentation = self.videoView!.snapshotView(afterScreenUpdates: false) { + self.addSubview(presentation) + presentation.frame = self.videoView!.frame + lastFrame[self.component!.call.peerId.id.description] = presentation + + // let image = UIGraphicsImageRenderer(size: presentation.bounds.size).image { context in + // presentation.render(in: context.cgContext) + // } + // print(image) + self.videoView?.alpha = 0 + // self.videoView?.alpha = 0.5 + // presentation.animateAlpha(from: 1, to: 0, duration: 0.1, completion: { _ in presentation.removeFromSuperlayer() }) + UIView.animate(withDuration: 0.1, animations: { + presentation.alpha = 0 + }, completion: { _ in + presentation.removeFromSuperview() + }) + } // DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { // presentation.removeFromSuperlayer() // } From 43ce52a26c8b8c640f7357232aa7c6a35b516a53 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 8 Dec 2022 00:35:36 +0400 Subject: [PATCH 22/50] Fixing opening stream in landscape --- .../Sources/Components/MediaStreamVideoComponent.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index e60772bed3..b5ed94f15a 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -548,7 +548,7 @@ final class _MediaStreamVideoComponent: Component { } } } - fullScreenBackgroundPlaceholder.removeFromSuperview() +// fullScreenBackgroundPlaceholder.removeFromSuperview() } else if component.isFullscreen { if fullScreenBackgroundPlaceholder.superview == nil { insertSubview(fullScreenBackgroundPlaceholder, at: 0) @@ -583,8 +583,8 @@ final class _MediaStreamVideoComponent: Component { } // } var aspect = videoView.getAspect() - // saveAspect(aspect) - if component.isFullscreen { + // aspect == 1 the first run + if component.isFullscreen && self.hadVideo { if aspect <= 0.01 { aspect = 16.0 / 9 // 3.0 / 4.0 } @@ -650,6 +650,7 @@ final class _MediaStreamVideoComponent: Component { videoSize = CGSize(width: 16 / 9 * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) } loadingBlurView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) + print("[LBVFrame] \(loadingBlurView.frame)") loadingBlurView.layer.cornerRadius = videoCornerRadius placeholderView.frame = loadingBlurView.frame From b1d44fb98e63cb57619e727e4185c61b7985c0c2 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 8 Dec 2022 04:49:29 +0400 Subject: [PATCH 23/50] Fullscreen before loaded layout --- .../Components/MediaStreamComponent.swift | 2 +- .../MediaStreamVideoComponent.swift | 31 ++++++++++++------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 061901b673..b799936a17 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -759,7 +759,7 @@ public final class _MediaStreamComponent: CombinedComponent { private var isVisibleInHierarchyDisposable: Disposable? private var scheduledDismissUITimer: SwiftSignalKit.Timer? - var videoStalled: Bool = false + var videoStalled: Bool = true var videoIsPlayable: Bool { !videoStalled && hasVideo diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index b5ed94f15a..8f24d9ebb0 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -284,14 +284,15 @@ final class _MediaStreamVideoComponent: Component { // shimmerOverlayLayer.opacity = 0.6 shimmerBorderLayer.cornerRadius = cornerRadius // TODO: check isFullScreeen shimmerBorderLayer.masksToBounds = true - shimmerBorderLayer.compositingFilter = "softLightBlendMode" + shimmerBorderLayer.compositingFilter = "overlayBlendMode"// "softLightBlendMode" shimmerBorderLayer.frame = loadingBlurView.bounds let borderMask = CAShapeLayer() borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor - borderMask.strokeColor = UIColor.white.withAlphaComponent(0.8).cgColor - borderMask.lineWidth = 2 - borderMask.frame = shimmerBorderLayer.bounds + borderMask.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor + borderMask.lineWidth = 3 + shimmerBorderLayer.mask = borderMask +// borderMask.frame = shimmerBorderLayer.bounds // let testBorder = CAShapeLayer() // testBorder.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: 10, cornerHeight: 10, transform: nil) @@ -303,9 +304,8 @@ final class _MediaStreamVideoComponent: Component { // shimmerBorderLayer.removeAllAnimations() // if shimmerBorderLayer.mask == nil { borderShimmer = .init() - shimmerBorderLayer.mask = borderMask borderShimmer.layer = shimmerBorderLayer - shimmerBorderLayer.backgroundColor = UIColor.clear.cgColor +// shimmerBorderLayer.backgroundColor = UIColor.clear.cgColor // shimmerBorderLayer.backgroundColor = UIColor.green.withAlphaComponent(0.4).cgColor borderShimmer.testUpdate(background: .clear, foreground: .white) // } @@ -326,8 +326,9 @@ final class _MediaStreamVideoComponent: Component { } loadingBlurView.layer.add(anim, forKey: "opacity") } else { + // Wait for state to update with first frame // Accounting for delay in first frame received - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in + /*DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in guard self?.videoStalled == false else { return } // TODO: animate blur intesity with UIPropertyAnimator @@ -352,7 +353,7 @@ final class _MediaStreamVideoComponent: Component { // }, completion: { _ in // self.loadingBlurView = .init(effect: UIBlurEffect(style: .light), intensity: 0.4) // }) - } + }*/ } // loadingBlurView.backgroundColor = .yellow.withAlphaComponent(0.4) } @@ -553,10 +554,11 @@ final class _MediaStreamVideoComponent: Component { if fullScreenBackgroundPlaceholder.superview == nil { insertSubview(fullScreenBackgroundPlaceholder, at: 0) } - fullScreenBackgroundPlaceholder.frame = self.bounds + fullScreenBackgroundPlaceholder.backgroundColor = UIColor.black.withAlphaComponent(0.5) } else { fullScreenBackgroundPlaceholder.removeFromSuperview() } + fullScreenBackgroundPlaceholder.frame = .init(origin: .zero, size: availableSize) // sheetView.frame = .init(x: 0, y: sheetTop, width: availableSize.width, height: sheetHeight) // var aspect = videoView.getAspect() @@ -657,8 +659,15 @@ final class _MediaStreamVideoComponent: Component { placeholderView.layer.cornerRadius = videoCornerRadius placeholderView.clipsToBounds = true - shimmerOverlayLayer.frame = loadingBlurView.bounds +// shimmerOverlayLayer.frame = loadingBlurView.bounds shimmerBorderLayer.frame = loadingBlurView.bounds + let borderMask = CAShapeLayer() + borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil) + borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor + borderMask.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor + borderMask.lineWidth = 3 + shimmerBorderLayer.mask = borderMask + shimmerBorderLayer.cornerRadius = videoCornerRadius if component.isFullscreen { // loadingBlurView.removeFromSuperview() @@ -729,7 +738,7 @@ final class _MediaStreamVideoComponent: Component { environment: {}, containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 1000.0) ) - noSignalTransition.setFrame(view: noSignalView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - noSignalSize.width) / 2.0), y: activityIndicatorFrame.maxY + 24.0), size: noSignalSize), completion: nil) + noSignalTransition.setFrame(view: noSignalView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - noSignalSize.width) / 2.0), y: (availableSize.height - noSignalSize.height) / 2.0/*activityIndicatorFrame.maxY + 24.0*/), size: noSignalSize), completion: nil) } } From ff4267774f2286d54c32ddd5b31008af402cb62a Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 8 Dec 2022 05:59:24 +0400 Subject: [PATCH 24/50] Fixing fullscreen on iPad --- .../Components/AnimatedCounterView.swift | 14 ++++++-- .../Components/MediaStreamComponent.swift | 35 +++++++++++++------ 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index 56e561b012..eb4337d720 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -50,7 +50,7 @@ public final class AnimatedCountView: UIView { self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60) self.maskingView.frame = CGRect(origin: .zero, size: bounds.size) countLabel.frame = CGRect(origin: .zero, size: bounds.size) - subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - 8 : bounds.height - 10, width: subtitleLabel.intrinsicContentSize.width + 20, height: 20) + subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - 8 : bounds.height - 12, width: subtitleLabel.intrinsicContentSize.width + 20, height: 20) } func update(countString: String, subtitle: String) { @@ -206,7 +206,11 @@ class AnimatedCountLabel: UILabel { return $0 + itemWidth + interItemSpacing } if characters.count > index && characters[index].string == "," { - offset -= commaWidthForSpacing / 2 // 4 + if index > 0, ["1", "7"].contains(characters[index - 1].string) { + offset -= commaWidthForSpacing / 2 + } else { + offset -= commaWidthForSpacing / 3 + } } return offset } else { @@ -217,7 +221,11 @@ class AnimatedCountLabel: UILabel { return $0 + itemWidth + interItemSpacing } if self.chars.count > index && self.chars[index].attributedText?.string == "," { - offset -= commaWidthForSpacing / 2 + if index > 0, let prevChar = self.chars[index - 1].attributedText?.string, ["1", "7"].contains(prevChar) { + offset -= commaWidthForSpacing / 2 + } else { + offset -= commaWidthForSpacing / 3 + } } return offset } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index b799936a17..909106ebe1 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -841,8 +841,8 @@ public final class _MediaStreamComponent: CombinedComponent { var updated = false // TODO: remove debug timer -// Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in - strongSelf.infoThrottler.publish(members.totalCount /*Int.random(in: 0..<10000000)*/) { [weak strongSelf] latestCount in + Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + strongSelf.infoThrottler.publish(/*members.totalCount */Int.random(in: 0..<10000000)) { [weak strongSelf] latestCount in print(members.totalCount) guard let strongSelf = strongSelf else { return } var updated = false @@ -855,7 +855,7 @@ public final class _MediaStreamComponent: CombinedComponent { strongSelf.updated(transition: .immediate) } } -// }.fire() + }.fire() if state.canManageCall != strongSelf.canManageCall { strongSelf.canManageCall = state.canManageCall updated = true @@ -971,7 +971,8 @@ public final class _MediaStreamComponent: CombinedComponent { let moreAnimationTag = GenericComponentViewTag() return { context in - var forceFullScreenInLandscape: Bool { true } + let canEnforceOrientation = UIDevice.current.model != "iPad" + var forceFullScreenInLandscape: Bool { canEnforceOrientation && true } let environment = context.environment[ViewControllerComponentContainer.Environment.self].value if environment.isVisible { } else { @@ -1011,7 +1012,7 @@ public final class _MediaStreamComponent: CombinedComponent { if forceFullScreenInLandscape && /*videoSize.width > videoSize.height &&*/ isLandscape && !state.isFullscreen { state.isFullscreen = true isFullscreen = true - } else if let videoSize = context.state.videoSize, videoSize.width > videoSize.height && !isLandscape && state.isFullscreen { + } else if let videoSize = context.state.videoSize, videoSize.width > videoSize.height && !isLandscape && state.isFullscreen && canEnforceOrientation { state.isFullscreen = false isFullscreen = false } else { @@ -1027,7 +1028,7 @@ public final class _MediaStreamComponent: CombinedComponent { let videoHeight: CGFloat = forceFullScreenInLandscape ? (context.availableSize.width - videoInset * 2) / 16 * 9 - : context.state.videoSize?.height ?? (context.availableSize.width - videoInset * 2) / 16 * 9 + : context.state.videoSize?.height ?? (min(context.availableSize.width, context.availableSize.height) - videoInset * 2) / 16 * 9 let bottomPadding = 40 + environment.safeInsets.bottom let sheetHeight: CGFloat = isFullscreen ? context.availableSize.height @@ -1467,6 +1468,7 @@ public final class _MediaStreamComponent: CombinedComponent { ) if !isFullscreen { + let imageRenderScale = UIScreen.main.scale let bottomComponent = AnyComponent(ButtonsRowComponent( bottomInset: environment.safeInsets.bottom, sideInset: environment.safeInsets.left, @@ -1487,7 +1489,7 @@ public final class _MediaStreamComponent: CombinedComponent { rightItem: AnyComponent(Button( content: AnyComponent(RoundGradientButtonComponent( gradientColors: [UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor, UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor], - image: generateImage(CGSize(width: 44.0, height: 44), opaque: false, rotatedContext: { size, context in + image: generateImage(CGSize(width: 44.0 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in context.translateBy(x: size.width / 2, y: size.height / 2) context.scaleBy(x: 0.4, y: 0.4) context.translateBy(x: -size.width / 2, y: -size.height / 2) @@ -1516,13 +1518,13 @@ public final class _MediaStreamComponent: CombinedComponent { centerItem: AnyComponent(Button( content: AnyComponent(RoundGradientButtonComponent( gradientColors: [UIColor(red: 0.23, green: 0.17, blue: 0.29, alpha: 1).cgColor, UIColor(red: 0.21, green: 0.16, blue: 0.29, alpha: 1).cgColor], - image: generateImage(CGSize(width: 44, height: 44), opaque: false, rotatedContext: { size, context in + image: generateImage(CGSize(width: 44 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in let imageColor = UIColor.white let bounds = CGRect(origin: CGPoint(), size: size) context.clear(bounds) - context.setLineWidth(2.4 - UIScreenPixel) + context.setLineWidth(2.4 * imageRenderScale - UIScreenPixel) context.setLineCap(.round) context.setStrokeColor(imageColor.cgColor) // context.setLineJoin(.round) @@ -1564,6 +1566,9 @@ public final class _MediaStreamComponent: CombinedComponent { // TODO: Check and respect current device orientation controller.updateOrientation(orientation: .portrait) } + if !canEnforceOrientation { + state.updated() // updated(.easeInOut(duration: 0.3)) + } // controller.updateOrientation(orientation: isLandscape ? .portrait : .landscapeRight) } } @@ -1637,9 +1642,9 @@ public final class _MediaStreamComponent: CombinedComponent { tintColor: .white )), action: { - if let controller = controller() as? MediaStreamComponentController { + /*if let controller = controller() as? MediaStreamComponentController { guard let size = state.videoSize else { return } - state.isFullscreen.toggle() + state.isFullscreen = false if state.isFullscreen { if size.width > size.height { controller.updateOrientation(orientation: .landscapeRight) @@ -1652,6 +1657,14 @@ public final class _MediaStreamComponent: CombinedComponent { controller.updateOrientation(orientation: .portrait) } // controller.updateOrientation(orientation: isLandscape ? .portrait : .landscapeRight) + }*/ + state.isFullscreen = false + if let controller = controller() as? MediaStreamComponentController { + if canEnforceOrientation { + controller.updateOrientation(orientation: .portrait) + } else { + state.updated() // updated(.easeInOut(duration: 0.3)) + } } } ).minSize(CGSize(width: 64.0, height: 80))) : nil, From 417ea85da5f880c9e694a3b0c7149e688c372486 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 8 Dec 2022 06:09:56 +0400 Subject: [PATCH 25/50] Adjusting spacing for comma after 1 and 7 --- .../Sources/Components/AnimatedCounterView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index eb4337d720..21484ebcc9 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -207,7 +207,7 @@ class AnimatedCountLabel: UILabel { } if characters.count > index && characters[index].string == "," { if index > 0, ["1", "7"].contains(characters[index - 1].string) { - offset -= commaWidthForSpacing / 2 + offset -= commaWidthForSpacing * 0.7 } else { offset -= commaWidthForSpacing / 3 } @@ -222,7 +222,7 @@ class AnimatedCountLabel: UILabel { } if self.chars.count > index && self.chars[index].attributedText?.string == "," { if index > 0, let prevChar = self.chars[index - 1].attributedText?.string, ["1", "7"].contains(prevChar) { - offset -= commaWidthForSpacing / 2 + offset -= commaWidthForSpacing * 0.7 } else { offset -= commaWidthForSpacing / 3 } From c918416fa819ccf7668bbe2f17a9c3447efbc841 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 8 Dec 2022 06:31:35 +0400 Subject: [PATCH 26/50] Disabling debug members --- .../Sources/Components/MediaStreamComponent.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 909106ebe1..19986169a5 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -841,8 +841,8 @@ public final class _MediaStreamComponent: CombinedComponent { var updated = false // TODO: remove debug timer - Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in - strongSelf.infoThrottler.publish(/*members.totalCount */Int.random(in: 0..<10000000)) { [weak strongSelf] latestCount in +// Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + strongSelf.infoThrottler.publish(members.totalCount/*Int.random(in: 0..<10000000)*/) { [weak strongSelf] latestCount in print(members.totalCount) guard let strongSelf = strongSelf else { return } var updated = false @@ -855,7 +855,7 @@ public final class _MediaStreamComponent: CombinedComponent { strongSelf.updated(transition: .immediate) } } - }.fire() +// }.fire() if state.canManageCall != strongSelf.canManageCall { strongSelf.canManageCall = state.canManageCall updated = true From abe0be6d6387790412150082559512caaebc0943 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 8 Dec 2022 07:37:37 +0400 Subject: [PATCH 27/50] Tweaking pip substitution animation --- .../Components/MediaStreamComponent.swift | 1 + .../MediaStreamVideoComponent.swift | 27 ++++++++++--------- 2 files changed, 16 insertions(+), 12 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 19986169a5..3a7b1792ce 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -914,6 +914,7 @@ public final class _MediaStreamComponent: CombinedComponent { self.infoDisposable?.dispose() self.isVisibleInHierarchyDisposable?.dispose() self.connectionDisposable?.dispose() + self.networkStateDisposable?.dispose() } func toggleDisplayUI() { diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 8f24d9ebb0..1923c4dd14 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -239,6 +239,7 @@ final class _MediaStreamVideoComponent: Component { // self.loadingBlurView.animator.fractionComplete = 0.4 // self.loadingBlurView.effect = UIBlurEffect(style: .light) if let frameView = lastFrame[component.call.peerId.id.description] { + frameView.removeFromSuperview() placeholderView.subviews.forEach { $0.removeFromSuperview() } placeholderView.addSubview(frameView) frameView.frame = placeholderView.bounds @@ -370,6 +371,11 @@ final class _MediaStreamVideoComponent: Component { var isFullscreen: Bool = false let videoLoadingThrottler = Throttler(duration: 1, queue: .main) + deinit { + avatarDisposable?.dispose() + frameInputDisposable?.dispose() + } + func update(component: _MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { self.state = state // placeholderView.alpha = 0.7 @@ -580,6 +586,7 @@ final class _MediaStreamVideoComponent: Component { // if let presentation = videoView.snapshotView(afterScreenUpdates: false) { if videoView.bounds.size.width > 0, videoView.alpha > 0, + self.hadVideo, let snapshot = videoView.snapshotView(afterScreenUpdates: false) ?? videoView.snapshotView(afterScreenUpdates: true) { lastFrame[component.call.peerId.id.description] = snapshot// ()! } @@ -766,20 +773,16 @@ final class _MediaStreamVideoComponent: Component { } func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { - // Fading to make - if let presentation = self.videoView!.snapshotView(afterScreenUpdates: false) { + if let videoView = self.videoView, let presentation = videoView.snapshotView(afterScreenUpdates: false) { self.addSubview(presentation) - presentation.frame = self.videoView!.frame - lastFrame[self.component!.call.peerId.id.description] = presentation + presentation.frame = videoView.frame + if let callId = self.component?.call.peerId.id.description { + lastFrame[callId] = presentation + } - // let image = UIGraphicsImageRenderer(size: presentation.bounds.size).image { context in - // presentation.render(in: context.cgContext) - // } - // print(image) - self.videoView?.alpha = 0 - // self.videoView?.alpha = 0.5 - // presentation.animateAlpha(from: 1, to: 0, duration: 0.1, completion: { _ in presentation.removeFromSuperlayer() }) - UIView.animate(withDuration: 0.1, animations: { + videoView.alpha = 0 + + UIView.animate(withDuration: 0.07, delay: 0.07, animations: { presentation.alpha = 0 }, completion: { _ in presentation.removeFromSuperview() From 073920c5ecdcf8a76c4ad140e35c22bc55438285 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 8 Dec 2022 08:02:16 +0400 Subject: [PATCH 28/50] Supporting varying aspect ratio --- .../Components/MediaStreamComponent.swift | 52 +++++++++---------- .../MediaStreamVideoComponent.swift | 9 +++- 2 files changed, 33 insertions(+), 28 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 3a7b1792ce..7440aab92a 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -1013,7 +1013,7 @@ public final class _MediaStreamComponent: CombinedComponent { if forceFullScreenInLandscape && /*videoSize.width > videoSize.height &&*/ isLandscape && !state.isFullscreen { state.isFullscreen = true isFullscreen = true - } else if let videoSize = context.state.videoSize, videoSize.width > videoSize.height && !isLandscape && state.isFullscreen && canEnforceOrientation { + } else if /*let videoSize = context.state.videoSize, videoSize.width > videoSize.height &&*/ !isLandscape && state.isFullscreen && canEnforceOrientation { state.isFullscreen = false isFullscreen = false } else { @@ -1991,36 +1991,36 @@ public final class Throttler { } public func publish(_ value: T, includingLatest: Bool = false, using completion: ((T) -> Void)?) { - accumulator.insert(value) - - if !isThrottling { - isThrottling = true - lastValue = nil - queue.async { + queue.async { [self] in + accumulator.insert(value) + + if !isThrottling { + isThrottling = true + lastValue = nil completion?(value) self.lastCompletedValue = value + } else { + lastValue = value } - } else { - lastValue = value - } - - if lastValue == nil { - queue.asyncAfter(deadline: .now() + duration) { [self] in - accumulator.removeAll() - // TODO: quick fix, replace with timer + + if lastValue == nil { queue.asyncAfter(deadline: .now() + duration) { [self] in - isThrottling = false + accumulator.removeAll() + // TODO: quick fix, replace with timer + queue.asyncAfter(deadline: .now() + duration) { [self] in + isThrottling = false + } + + guard + let lastValue = lastValue, + lastCompletedValue != lastValue || includingLatest + else { return } + + accumulator.insert(lastValue) + self.lastValue = nil + completion?(lastValue) + lastCompletedValue = lastValue } - - guard - let lastValue = lastValue, - lastCompletedValue != lastValue || includingLatest - else { return } - - accumulator.insert(lastValue) - self.lastValue = nil - completion?(lastValue) - lastCompletedValue = lastValue } } } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 1923c4dd14..458c7ed2b1 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -595,8 +595,10 @@ final class _MediaStreamVideoComponent: Component { // aspect == 1 the first run if component.isFullscreen && self.hadVideo { if aspect <= 0.01 { - aspect = 16.0 / 9 // 3.0 / 4.0 + aspect = 16.0 / 9 } + } else if self.hadVideo { + // aspect = aspect } else { aspect = 16.0 / 9 } @@ -604,7 +606,10 @@ final class _MediaStreamVideoComponent: Component { if component.isFullscreen { videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) } else { - videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: min(availableSize.width, availableSize.height) - videoInset * 2, height: max(availableSize.height, availableSize.width))) + let availableVideoWidth = availableSize.width - videoInset * 2 + let availableVideoHeight = availableVideoWidth * 9.0 / 16 + + videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableVideoWidth, height: availableVideoHeight)) } let blurredVideoSize = videoSize.aspectFilled(availableSize) From cd09a54fe4734dd4097dfbd3d036e8a8d19321d8 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 8 Dec 2022 08:43:31 +0400 Subject: [PATCH 29/50] Refactoring --- .../Components/AnimatedCountView.swift | 465 -- .../Components/MediaStreamComponent.swift | 2495 +++--- .../MediaStreamVideoComponent.swift | 174 +- .../Components/StreamSheetComponent.swift | 9 +- .../Sources/MediaStreamingController.swift | 7118 ----------------- .../Sources/SharedAccountContext.swift | 3 +- 6 files changed, 1226 insertions(+), 9038 deletions(-) delete mode 100644 submodules/TelegramCallsUI/Sources/Components/AnimatedCountView.swift delete mode 100644 submodules/TelegramCallsUI/Sources/MediaStreamingController.swift diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCountView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCountView.swift deleted file mode 100644 index 70b3a9cbbb..0000000000 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCountView.swift +++ /dev/null @@ -1,465 +0,0 @@ -/*import Foundation -import UIKit - -import Display - -private let purple = UIColor(rgb: 0x3252ef) -private let pink = UIColor(rgb: 0xe4436c) - -private let latePurple = UIColor(rgb: 0x974aa9) -private let latePink = UIColor(rgb: 0xf0436c) - -public final class AnimatedCountView: UIView { - let countLabel = AnimatedCountLabel() -// let titleLabel = UILabel() - let subtitleLabel = UILabel() - - private let foregroundView = UIView() - private let foregroundGradientLayer = CAGradientLayer() - private let maskingView = UIView() - - override init(frame: CGRect = .zero) { - super.init(frame: frame) - - self.foregroundGradientLayer.type = .radial - self.foregroundGradientLayer.colors = [pink.cgColor, purple.cgColor, purple.cgColor] - self.foregroundGradientLayer.locations = [0.0, 0.85, 1.0] - self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0) - self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) - - self.foregroundView.mask = self.maskingView - self.foregroundView.layer.addSublayer(self.foregroundGradientLayer) - - self.addSubview(self.foregroundView) -// self.addSubview(self.titleLabel) - self.addSubview(self.subtitleLabel) - - self.maskingView.addSubview(countLabel) - countLabel.clipsToBounds = false - subtitleLabel.textAlignment = .center -// self.backgroundColor = UIColor.white.withAlphaComponent(0.1) - } - - override public func layoutSubviews() { - super.layoutSubviews() - - self.foregroundView.frame = CGRect(origin: CGPoint.zero, size: bounds.size)// .insetBy(dx: -40, dy: -40) - self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60) - self.maskingView.frame = CGRect(origin: .zero, size: bounds.size) - countLabel.frame = CGRect(origin: .zero, size: bounds.size) - subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - 10 : bounds.height - 6, width: subtitleLabel.intrinsicContentSize.width + 20, height: 20) - } - - func update(countString: String, subtitle: String) { - self.setupGradientAnimations() - - let text: String = countString// presentationStringsFormattedNumber(Int32(count), ",") - - // self.titleNode.attributedText = NSAttributedString(string: "", font: Font.with(size: 23.0, design: .round, weight: .semibold, traits: []), textColor: .white) - // let titleSize = self.titleNode.updateLayout(size) - // self.titleNode.frame = CGRect(x: floor((size.width - titleSize.width) / 2.0), y: 48.0, width: titleSize.width, height: titleSize.height) - if CGFloat(text.count * 40) < bounds.width - 32 { - self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) - } else { - self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: 54.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) - } -// var timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) -// if timerSize.width > size.width - 32.0 { -// self.timerNode.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) -// timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) -// } - -// self.timerNode.frame = CGRect(x: floor((size.width - timerSize.width) / 2.0), y: 78.0, width: timerSize.width, height: timerSize.height) - - self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, font: Font.with(size: 16.0, design: .round, weight: .semibold, traits: []), textColor: .white) - self.subtitleLabel.isHidden = subtitle.isEmpty -// let subtitleSize = self.subtitleNode.updateLayout(size) -// self.subtitleNode.frame = CGRect(x: floor((size.width - subtitleSize.width) / 2.0), y: 164.0, width: subtitleSize.width, height: subtitleSize.height) - -// self.foregroundView.frame = CGRect(origin: CGPoint(), size: size) - // self.setNeedsLayout() - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func setupGradientAnimations() { - if let _ = self.foregroundGradientLayer.animation(forKey: "movement") { - } else { - let previousValue = self.foregroundGradientLayer.startPoint - let newValue = CGPoint(x: CGFloat.random(in: 0.65 ..< 0.85), y: CGFloat.random(in: 0.1 ..< 0.45)) - self.foregroundGradientLayer.startPoint = newValue - - CATransaction.begin() - - let animation = CABasicAnimation(keyPath: "startPoint") - animation.duration = Double.random(in: 0.8 ..< 1.4) - animation.fromValue = previousValue - animation.toValue = newValue - - CATransaction.setCompletionBlock { [weak self] in -// if let isCurrentlyInHierarchy = self?.isCurrentlyInHierarchy, isCurrentlyInHierarchy { - self?.setupGradientAnimations() -// } - } - self.foregroundGradientLayer.add(animation, forKey: "movement") - CATransaction.commit() - } - } -} - -class AnimatedCharLayer: CATextLayer { - var text: String? { - get { - self.string as? String ?? (self.string as? NSAttributedString)?.string - } - set { - self.string = newValue - } - } - var attributedText: NSAttributedString? { - get { - self.string as? NSAttributedString //?? (self.string as? String).map { NSAttributed.init - } - set { - self.string = newValue - } - } - - var layer: CALayer { self } - - override init() { - super.init() - - self.contentsScale = UIScreen.main.scale - } - - override init(layer: Any) { - super.init(layer: layer) - self.contentsScale = UIScreen.main.scale - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} - -class AnimatedCountLabel: UILabel { - override var text: String? { - get { - chars.reduce("") { $0 + ($1.text ?? "") } - } - set { - update(with: newValue ?? "") - } - } - - override var attributedText: NSAttributedString? { - get { - let string = NSMutableAttributedString() - for char in chars { - string.append(char.attributedText ?? NSAttributedString()) - } - return string - } - set { - udpateAttributed(with: newValue ?? NSAttributedString()) - } - } - - private var chars = [AnimatedCharLayer]() - private let containerView = UIView() - - override init(frame: CGRect = .zero) { - super.init(frame: frame) - containerView.clipsToBounds = false - addSubview(containerView) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - var itemWidth: CGFloat { 36 } - var commaWidth: CGFloat { 8 } - var interItemSpacing: CGFloat { 0 } - - private func offsetForChar(at index: Int, within characters: [NSAttributedString]? = nil) -> CGFloat { - if let characters { - return characters[0.. Bool { + if lhs.call !== rhs.call { + return false + } + + return true + } + + public final class State: ComponentState { + private let call: PresentationGroupCallImpl + + private(set) var hasVideo: Bool = false + private var stateDisposable: Disposable? + private var infoDisposable: Disposable? + private var connectionDisposable: Disposable? + private var networkStateDisposable: Disposable? + + private(set) var originInfo: OriginInfo? + + private(set) var displayUI: Bool = true + var dismissOffset: CGFloat = 0.0 + var initialOffset: CGFloat = 0.0 + var storedIsFullscreen: Bool? + var isFullscreen: Bool = false + var videoSize: CGSize? + + private(set) var canManageCall: Bool = false + // TODO: also handle pictureInPicturePossible + let isPictureInPictureSupported: Bool + + private(set) var callTitle: String? + private(set) var recordingStartTimestamp: Int32? + + private(set) var peerTitle: String = "" + private(set) var chatPeer: Peer? + + private(set) var isVisibleInHierarchy: Bool = false + private var isVisibleInHierarchyDisposable: Disposable? + + private var scheduledDismissUITimer: SwiftSignalKit.Timer? + var videoStalled: Bool = true + + var videoIsPlayable: Bool { + !videoStalled && hasVideo + } + + let deactivatePictureInPictureIfVisible = StoredActionSlot(Void.self) + + private let infoThrottler = Throttler.init(duration: 5, queue: .main) + + init(call: PresentationGroupCallImpl) { + self.call = call + + if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { + self.isPictureInPictureSupported = true + } else { + self.isPictureInPictureSupported = AVPictureInPictureController.isPictureInPictureSupported() + } + + super.init() + + self.stateDisposable = (call.state + |> map { state -> Bool in + switch state.networkState { + case .connected: + return true + default: + return false + } + } + |> filter { $0 } + |> take(1)).start(next: { [weak self] _ in + guard let strongSelf = self else { + return + } + strongSelf.hasVideo = true + strongSelf.updated(transition: .immediate) + }) + + // TODO: retest to uncomment or delete. Relying only on video frames + /*self.networkStateDisposable = (call.account.networkState |> deliverOnMainQueue).start(next: { [weak self] state in + guard let strongSelf = self else { return } + switch state { + case .waitingForNetwork, .connecting: + print("[NEW] videoStalled") + strongSelf.videoStalled = true + default: + strongSelf.videoStalled = !strongSelf.hasVideo + } + strongSelf.updated(transition: .immediate) +// if let strongSelf = self, case .standard(previewing: false) = strongSelf.presentationInterfaceState.mode { +// strongSelf.chatTitleView?.networkState = state +// } + }) + + self.connectionDisposable = call.state.start(next: { [weak self] state in + let prev = self?.videoStalled + switch state.networkState { + case .connected: + self?.videoStalled = false + default: + print("[ALERT] video stalled") + self?.videoStalled = true + } + if prev != self?.videoStalled { + self?.updated(transition: .immediate) + } + })*/ + + let callPeer = call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: call.peerId)) + + self.infoDisposable = (combineLatest(queue: .mainQueue(), call.state, call.members, callPeer) + |> deliverOnMainQueue).start(next: { [weak self] state, members, callPeer in + guard let strongSelf = self, let members = members, let callPeer = callPeer else { + return + } + + var updated = false +// TODO: remove debug timer +// Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + strongSelf.infoThrottler.publish(members.totalCount/*Int.random(in: 0..<10000000)*/) { [weak strongSelf] latestCount in + print(members.totalCount) + guard let strongSelf = strongSelf else { return } + var updated = false + let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: latestCount) + if strongSelf.originInfo != originInfo { + strongSelf.originInfo = originInfo + updated = true + } + if updated { + strongSelf.updated(transition: .immediate) + } + } +// }.fire() + if state.canManageCall != strongSelf.canManageCall { + strongSelf.canManageCall = state.canManageCall + updated = true + } + if strongSelf.peerTitle != callPeer.debugDisplayTitle { + strongSelf.peerTitle = callPeer.debugDisplayTitle + updated = true + } + strongSelf.chatPeer = callPeer._asPeer() + + if strongSelf.callTitle != state.title { + strongSelf.callTitle = state.title + updated = true + } + + if strongSelf.recordingStartTimestamp != state.recordingStartTimestamp { + strongSelf.recordingStartTimestamp = state.recordingStartTimestamp + updated = true + } + +// let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: members.totalCount) +// if strongSelf.originInfo != originInfo { +// strongSelf.originInfo = originInfo +// updated = true +// } +// + if updated { + strongSelf.updated(transition: .immediate) + } + }) + + self.isVisibleInHierarchyDisposable = (call.accountContext.sharedContext.applicationBindings.applicationInForeground + |> deliverOnMainQueue).start(next: { [weak self] inForeground in + guard let strongSelf = self else { + return + } + if strongSelf.isVisibleInHierarchy != inForeground { + strongSelf.isVisibleInHierarchy = inForeground + strongSelf.updated(transition: .immediate) + + if inForeground { + Queue.mainQueue().after(0.5, { + guard let strongSelf = self, strongSelf.isVisibleInHierarchy else { + return + } + + strongSelf.deactivatePictureInPictureIfVisible.invoke(Void()) + }) + } + } + }) + } + + deinit { + self.stateDisposable?.dispose() + self.infoDisposable?.dispose() + self.isVisibleInHierarchyDisposable?.dispose() + self.connectionDisposable?.dispose() + self.networkStateDisposable?.dispose() + } + + func toggleDisplayUI() { + self.displayUI = !self.displayUI + self.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .easeInOut))) + } + + func cancelScheduledDismissUI() { + self.scheduledDismissUITimer?.invalidate() + self.scheduledDismissUITimer = nil + } + + func scheduleDismissUI() { + if self.scheduledDismissUITimer == nil { + self.scheduledDismissUITimer = SwiftSignalKit.Timer(timeout: 3.0, repeat: false, completion: { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.scheduledDismissUITimer = nil + if strongSelf.displayUI { + strongSelf.toggleDisplayUI() + } + }, queue: .mainQueue()) + self.scheduledDismissUITimer?.start() + } + } + + func updateDismissOffset(value: CGFloat, interactive: Bool) { + self.dismissOffset = value + if interactive { + self.updated(transition: .immediate) + } else { + self.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) + } + } + } + + public func makeState() -> State { + return State(call: self.call) + } + + public static var body: Body { + let background = Child(Rectangle.self) + let dismissTapComponent = Child(Rectangle.self) + let video = Child(MediaStreamVideoComponent.self) +// let navigationBar = Child(NavigationBarComponent.self) +// let toolbar = Child(ToolbarComponent.self) + + let sheet = Child(StreamSheetComponent.self) + let fullscreenOverlay = Child(StreamSheetComponent.self) + + let activatePictureInPicture = StoredActionSlot(Action.self) + let deactivatePictureInPicture = StoredActionSlot(Void.self) + let moreButtonTag = GenericComponentViewTag() + let moreAnimationTag = GenericComponentViewTag() + + return { context in + let canEnforceOrientation = UIDevice.current.model != "iPad" + var forceFullScreenInLandscape: Bool { canEnforceOrientation && true } + let environment = context.environment[ViewControllerComponentContainer.Environment.self].value + if environment.isVisible { + } else { + context.state.dismissOffset = 0.0 + } + + let background = background.update( + component: Rectangle(color: .black.withAlphaComponent(0.0)), + availableSize: context.availableSize, + transition: context.transition + ) + + let call = context.component.call + let state = context.state + let controller = environment.controller + + context.state.deactivatePictureInPictureIfVisible.connect { + guard let controller = controller() else { + return + } + if controller.view.window == nil { + return + } + state.updated(transition: .easeInOut(duration: 3)) + deactivatePictureInPicture.invoke(Void()) + } + let isFullscreen: Bool // = state.isFullscreen + let isLandscape = context.availableSize.width > context.availableSize.height + +// if let videoSize = context.state.videoSize { + // Always fullscreen in landscape + // TODO: support landscape sheet (wrap in scrollview, video size same as portrait) + if forceFullScreenInLandscape && isLandscape && !state.isFullscreen { + state.isFullscreen = true + isFullscreen = true + } else if !isLandscape && state.isFullscreen && canEnforceOrientation { + state.isFullscreen = false + isFullscreen = false + } else { + isFullscreen = state.isFullscreen + } + // } + let videoInset: CGFloat + if !isFullscreen { + videoInset = 16 + } else { + videoInset = 0 + } + + let videoHeight: CGFloat = forceFullScreenInLandscape + ? (context.availableSize.width - videoInset * 2) / 16 * 9 + : context.state.videoSize?.height ?? (min(context.availableSize.width, context.availableSize.height) - videoInset * 2) / 16 * 9 + let bottomPadding = 40 + environment.safeInsets.bottom + let sheetHeight: CGFloat = isFullscreen + ? context.availableSize.height + : (44 + videoHeight + 40 + 69 + 16 + 32 + 70 + bottomPadding) + let isFullyDragged = context.availableSize.height - sheetHeight + state.dismissOffset - context.view.safeAreaInsets.top < 30 + + var dragOffset = context.state.dismissOffset + if isFullyDragged { + dragOffset = max(context.state.dismissOffset, sheetHeight - context.availableSize.height + context.view.safeAreaInsets.top)// sheetHeight - UIScreen.main.bounds.height + } + + let dismissTapAreaHeight = isFullscreen ? 0 : (context.availableSize.height - sheetHeight + dragOffset) + let dismissTapComponent = dismissTapComponent.update( + component: Rectangle(color: .red.withAlphaComponent(0)), + availableSize: CGSize(width: context.availableSize.width, height: dismissTapAreaHeight), + transition: context.transition + ) + + + let video = video.update( + component: MediaStreamVideoComponent( + call: context.component.call, + hasVideo: context.state.hasVideo, + isVisible: environment.isVisible && context.state.isVisibleInHierarchy, + isAdmin: context.state.canManageCall, + peerTitle: context.state.peerTitle, + isFullscreen: isFullscreen, + videoLoading: context.state.videoStalled, + callPeer: context.state.chatPeer, + activatePictureInPicture: activatePictureInPicture, + deactivatePictureInPicture: deactivatePictureInPicture, + bringBackControllerForPictureInPictureDeactivation: { [weak call] completed in + guard let call = call else { + completed() + return + } + + call.accountContext.sharedContext.mainWindow?.inCallNavigate?() + completed() + }, + pictureInPictureClosed: { [weak call] in + let _ = call?.leave(terminateIfPossible: false) + }, + onVideoSizeRetrieved: { [weak state] size in + state?.videoSize = size + }, + onVideoPlaybackLiveChange: { [weak state] isLive in + guard let state else { return } + let wasLive = !state.videoStalled + if isLive != wasLive { + state.videoStalled = !isLive + state.updated() + } + } + ), + availableSize: context.availableSize, + transition: context.transition + ) + + var navigationRightItems: [AnyComponentWithIdentity] = [] + + if context.state.isPictureInPictureSupported, context.state.videoIsPlayable { + navigationRightItems.append(AnyComponentWithIdentity(id: "pip", component: AnyComponent(Button( + content: AnyComponent(ZStack([ + AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( + fillColor: .white.withAlphaComponent(0.08), + size: CGSize(width: 32.0, height: 32.0) + ))), + AnyComponentWithIdentity(id: "a", component: AnyComponent(BundleIconComponent( + name: "Call/pip", + tintColor: .white + ))) + ] + )), + action: { + activatePictureInPicture.invoke(Action { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.dismiss(closing: false, manual: true) + }) + } + ).minSize(CGSize(width: 44.0, height: 44.0))))) + } + var topLeftButton: AnyComponent? + if context.state.canManageCall { + let whiteColor = UIColor(white: 1.0, alpha: 1.0) + /*navigationRightItems.append(*/ topLeftButton = //AnyComponentWithIdentity(id: "more", component: + AnyComponent(Button( + content: AnyComponent(ZStack([ + AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( + fillColor: .white.withAlphaComponent(0.08), + size: CGSize(width: 32.0, height: 32.0) + ))), + AnyComponentWithIdentity(id: "a", component: AnyComponent(LottieAnimationComponent( + animation: LottieAnimationComponent.AnimationItem( + name: "anim_profilemore", + mode: .still(position: .begin) + ), + colors: [ + "Point 2.Group 1.Fill 1": whiteColor, + "Point 3.Group 1.Fill 1": whiteColor, + "Point 1.Group 1.Fill 1": whiteColor + ], + size: CGSize(width: 32.0, height: 32.0) + ).tagged(moreAnimationTag))), + ])), + action: { [weak call, weak state] in + guard let call = call, let state = state else { + return + } + guard let controller = controller() as? MediaStreamComponentController else { + return + } + guard let anchorView = controller.node.hostView.findTaggedView(tag: moreButtonTag) else { + return + } + + if let animationView = controller.node.hostView.findTaggedView(tag: moreAnimationTag) as? LottieAnimationComponent.View { + animationView.playOnce() + } + + let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } + + var items: [ContextMenuItem] = [] + + items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.LiveStream_EditTitle, textColor: .primary, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Pencil"), color: theme.actionSheet.primaryTextColor) + }, action: { [weak call, weak controller, weak state] _, a in + guard let call = call, let controller = controller, let state = state, let chatPeer = state.chatPeer else { + return + } + + let initialTitle = state.callTitle ?? "" + + let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } + + let title: String = presentationData.strings.LiveStream_EditTitle + let text: String = presentationData.strings.LiveStream_EditTitleText + + let editController = voiceChatTitleEditController(sharedContext: call.accountContext.sharedContext, account: call.accountContext.account, forceTheme: defaultDarkPresentationTheme, title: title, text: text, placeholder: EnginePeer(chatPeer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), value: initialTitle, maxLength: 40, apply: { [weak call] title in + guard let call = call else { + return + } + + let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } + + if let title = title, title != initialTitle { + call.updateTitle(title) + + let text: String = title.isEmpty ? presentationData.strings.LiveStream_EditTitleRemoveSuccess : presentationData.strings.LiveStream_EditTitleSuccess(title).string + + let _ = text + //strongSelf.presentUndoOverlay(content: .voiceChatFlag(text: text), action: { _ in return false }) + } + }) + controller.present(editController, in: .window(.root)) + + a(.default) + }))) + + if let recordingStartTimestamp = state.recordingStartTimestamp { + items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { [weak call, weak controller] _, f in + f(.dismissWithoutContent) + + guard let call = call, let controller = controller else { + return + } + + let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } + + let alertController = textAlertController(context: call.accountContext, forceTheme: defaultDarkPresentationTheme, title: nil, text: presentationData.strings.VoiceChat_StopRecordingTitle, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.VoiceChat_StopRecordingStop, action: { [weak call, weak controller] in + guard let call = call, let controller = controller else { + return + } + call.setShouldBeRecording(false, title: nil, videoOrientation: nil) + + let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } + let text = presentationData.strings.LiveStream_RecordingSaved + + let _ = text + let _ = controller + + /*strongSelf.presentUndoOverlay(content: .forward(savedMessages: true, text: text), action: { [weak self] value in + if case .info = value, let strongSelf = self, let navigationController = strongSelf.controller?.navigationController as? NavigationController { + let context = strongSelf.context + strongSelf.controller?.dismiss(completion: { + Queue.mainQueue().justDispatch { + context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(context.account.peerId), keepStack: .always, purposefulAction: {}, peekData: nil)) + } + }) + + return true + } + return false + })*/ + })]) + controller.present(alertController, in: .window(.root)) + }), false)) + } else { + let text = presentationData.strings.LiveStream_StartRecording + items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in + return generateStartRecordingIcon(color: theme.actionSheet.primaryTextColor) + }, action: { [weak call, weak state, weak controller] _, f in + f(.dismissWithoutContent) + + guard let call = call, let state = state, let _ = state.chatPeer, let controller = controller else { + return + } + + let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } + + let title: String + let text: String + let placeholder: String = presentationData.strings.VoiceChat_RecordingTitlePlaceholderVideo + + title = presentationData.strings.LiveStream_StartRecordingTitle + text = presentationData.strings.LiveStream_StartRecordingTextVideo + + let editController = voiceChatTitleEditController(sharedContext: call.accountContext.sharedContext, account: call.accountContext.account, forceTheme: defaultDarkPresentationTheme, title: title, text: text, placeholder: placeholder, value: nil, maxLength: 40, apply: { [weak call, weak controller] title in + guard let call = call, let controller = controller else { + return + } + + + let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } + + if let title = title { + call.setShouldBeRecording(true, title: title, videoOrientation: false) + + let text = presentationData.strings.LiveStream_RecordingStarted + let _ = text + + let _ = controller + + call.playTone(.recordingStarted) + } + }) + controller.present(editController, in: .window(.root)) + }))) + } + + let credentialsPromise = Promise() + credentialsPromise.set(call.accountContext.engine.calls.getGroupCallStreamCredentials(peerId: call.peerId, revokePreviousCredentials: false) |> `catch` { _ -> Signal in return .never() }) + + items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.LiveStream_ViewCredentials, textColor: .primary, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.contextMenu.primaryColor, backgroundColor: nil) + }, action: { [weak call, weak controller] _, a in + guard let call = call, let controller = controller else { + return + } + + controller.push(CreateExternalMediaStreamScreen(context: call.accountContext, peerId: call.peerId, credentialsPromise: credentialsPromise, mode: .view)) + + a(.default) + }))) + + items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.VoiceChat_StopRecordingStop, textColor: .destructive, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in + return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.contextMenu.destructiveColor, backgroundColor: nil) + }, action: { [weak call] _, a in + guard let call = call else { + return + } + + let _ = call.leave(terminateIfPossible: true).start() + + a(.default) + }))) + + final class ReferenceContentSource: ContextReferenceContentSource { + private let sourceView: UIView + + init(sourceView: UIView) { + self.sourceView = sourceView + } + + func transitionInfo() -> ContextControllerReferenceViewInfo? { + return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds) + } + } + + let contextController = ContextController(account: call.accountContext.account, presentationData: presentationData.withUpdated(theme: defaultDarkPresentationTheme), source: .reference(ReferenceContentSource(sourceView: anchorView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil) + /*contextController.passthroughTouchEvent = { sourceView, point in + guard let strongSelf = self else { + return .ignore + } + + let localPoint = strongSelf.view.convert(sourceView.convert(point, to: nil), from: nil) + guard let localResult = strongSelf.hitTest(localPoint, with: nil) else { + return .dismiss(consume: true, result: nil) + } + + var testView: UIView? = localResult + while true { + if let testViewValue = testView { + if let node = testViewValue.asyncdisplaykit_node as? PeerInfoHeaderNavigationButton { + node.isUserInteractionEnabled = false + DispatchQueue.main.async { + node.isUserInteractionEnabled = true + } + return .dismiss(consume: false, result: nil) + } else if let node = testViewValue.asyncdisplaykit_node as? PeerInfoVisualMediaPaneNode { + node.brieflyDisableTouchActions() + return .dismiss(consume: false, result: nil) + } else { + testView = testViewValue.superview + } + } else { + break + } + } + + return .dismiss(consume: true, result: nil) + }*/ + controller.presentInGlobalOverlay(contextController) + } + ).minSize(CGSize(width: 44.0, height: 44.0)).tagged(moreButtonTag))//)//) + } + let navigationComponent = NavigationBarComponent( + topInset: environment.statusBarHeight, + sideInset: environment.safeInsets.left, + leftItem: topLeftButton/*AnyComponent(Button( + content: AnyComponent(Text(text: environment.strings.Common_Close, font: Font.regular(17.0), color: .white)), + action: { [weak call] in + let _ = call?.leave(terminateIfPossible: false) + }) + )*/, + rightItems: navigationRightItems, + centerItem: AnyComponent(StreamTitleComponent(text: state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isActive: context.state.videoIsPlayable)) + ) + +// let navigationBar = navigationBar.update( +// component: navigationComponent, +// availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), +// transition: context.transition +// ) + + if context.state.storedIsFullscreen != isFullscreen { + context.state.storedIsFullscreen = isFullscreen + if isFullscreen { + context.state.scheduleDismissUI() + } else { + context.state.cancelScheduledDismissUI() + } + } + + var infoItem: AnyComponent? + if let originInfo = context.state.originInfo { + let memberCountString: String + if originInfo.memberCount == 0 { + memberCountString = environment.strings.LiveStream_NoViewers + } else { + memberCountString = environment.strings.LiveStream_ViewerCount(Int32(originInfo.memberCount)) + } + infoItem = AnyComponent(OriginInfoComponent( + title: state.callTitle ?? originInfo.title, + subtitle: memberCountString + )) + } + let availableSize = context.availableSize + let safeAreaTop = context.view.safeAreaInsets.top + + let onPanGesture: ((Gesture.PanGestureState) -> Void) = { [weak state] panState in + guard let state = state else { + return + } + switch panState { + case .began: + state.initialOffset = state.dismissOffset + case let .updated(offset): + state.updateDismissOffset(value: state.initialOffset + offset.y, interactive: true) + case let .ended(velocity): + if velocity.y > 200.0 { + if state.isFullscreen { + state.isFullscreen = false + state.updateDismissOffset(value: 0.0, interactive: false) + if let controller = controller() as? MediaStreamComponentController { + controller.updateOrientation(orientation: .portrait) + } + } else { + if isFullyDragged || state.initialOffset != 0 { + state.updateDismissOffset(value: 0.0, interactive: false) + } else { + let _ = call.leave(terminateIfPossible: false) + } + } + } else { + if isFullyDragged { + state.updateDismissOffset(value: sheetHeight - availableSize.height + safeAreaTop, interactive: false) + } else { + if velocity.y < -200 { + // Expand + state.updateDismissOffset(value: sheetHeight - availableSize.height + safeAreaTop, interactive: false) + } else { + state.updateDismissOffset(value: 0.0, interactive: false) + } + } + } + } + } + + context.add(background + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) + .gesture(.tap { [weak state] in + guard let state = state, state.isFullscreen else { + return + } + state.toggleDisplayUI() + }) + .gesture(.pan { panState in + onPanGesture(panState) + }) + ) +// var bottomComponent: AnyComponent? +// var fullScreenToolbarComponent: AnyComponent? + + context.add(dismissTapComponent + .position(CGPoint(x: context.availableSize.width / 2, y: dismissTapAreaHeight / 2)) + .gesture(.tap { + _ = call.leave(terminateIfPossible: false) + }) + .gesture(.pan(onPanGesture)) + ) + + if !isFullscreen { + let imageRenderScale = UIScreen.main.scale + let bottomComponent = AnyComponent(ButtonsRowComponent( + bottomInset: environment.safeInsets.bottom, + sideInset: environment.safeInsets.left, + leftItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent(// BundleIconComponent( + gradientColors: [UIColor(red: 0.18, green: 0.17, blue: 0.30, alpha: 1).cgColor, UIColor(red: 0.17, green: 0.16, blue: 0.30, alpha: 1).cgColor], + image: generateTintedImage(image: UIImage(bundleImageName: "Call/CallShareButton"), color: .white), + // TODO: localize: + title: "share")), + action: { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.presentShare() + } + ).minSize(CGSize(width: 65, height: 80))), + rightItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent( + gradientColors: [UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor, UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor], + image: generateImage(CGSize(width: 44.0 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in + context.translateBy(x: size.width / 2, y: size.height / 2) + context.scaleBy(x: 0.4, y: 0.4) + context.translateBy(x: -size.width / 2, y: -size.height / 2) + let imageColor = UIColor.white + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + let lineWidth: CGFloat = size.width / 7 + context.setLineWidth(lineWidth - UIScreenPixel) + context.setLineCap(.round) + context.setStrokeColor(imageColor.cgColor) + + context.move(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: lineWidth / 2 + UIScreenPixel)) + context.addLine(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) + context.strokePath() + + context.move(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: lineWidth / 2 + UIScreenPixel)) + context.addLine(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) + context.strokePath() + }), + title: "leave" + )), + action: { [weak call] in + let _ = call?.leave(terminateIfPossible: false) + } + ).minSize(CGSize(width: 44.0, height: 44.0))), + centerItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent( + gradientColors: [UIColor(red: 0.23, green: 0.17, blue: 0.29, alpha: 1).cgColor, UIColor(red: 0.21, green: 0.16, blue: 0.29, alpha: 1).cgColor], + image: generateImage(CGSize(width: 44 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in + + let imageColor = UIColor.white + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + + context.setLineWidth(2.4 * imageRenderScale - UIScreenPixel) + context.setLineCap(.round) + context.setStrokeColor(imageColor.cgColor) +// context.setLineJoin(.round) + + let lineSide = size.width / 5 + let centerOffset = size.width / 20 + context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - centerOffset / 2)) + context.addLine(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 + centerOffset / 2, y: size.height / 2 - lineSide)) + context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 + centerOffset, y: size.height / 2 - centerOffset)) + context.strokePath() + + context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + centerOffset / 2)) + context.addLine(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 - centerOffset / 2, y: size.height / 2 + lineSide)) + context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 - centerOffset, y: size.height / 2 + centerOffset)) + context.strokePath() + }), + title: "expand" + )), + action: { + guard state.videoIsPlayable else { + state.isFullscreen = false + return + } + if let controller = controller() as? MediaStreamComponentController { + guard let size = state.videoSize else { return } + state.isFullscreen.toggle() + if state.isFullscreen { + if size.width > size.height { + controller.updateOrientation(orientation: .landscapeRight) + } else { + controller.updateOrientation(orientation: .portrait) + } + } else { + // TODO: Check and mind current device orientation + controller.updateOrientation(orientation: .portrait) + } + if !canEnforceOrientation { + state.updated() // updated(.easeInOut(duration: 0.3)) + } + // controller.updateOrientation(orientation: isLandscape ? .portrait : .landscapeRight) + } + } + ).minSize(CGSize(width: 44.0, height: 44.0))) + )) + + let sheet = sheet.update( + component: StreamSheetComponent( + topComponent: AnyComponent(navigationComponent), + bottomButtonsRow: bottomComponent, + topOffset: context.availableSize.height - sheetHeight + dragOffset, + sheetHeight: max(sheetHeight - dragOffset, sheetHeight), + backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), + bottomPadding: bottomPadding, + participantsCount: context.state.originInfo?.memberCount ?? 0, // Int.random(in: 0...999998)// [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! + // + isFullyExtended: isFullyDragged, + deviceCornerRadius: (controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 0, + videoHeight: videoHeight + ), + availableSize: context.availableSize, + transition: context.transition + ) + + let sheetOffset: CGFloat = context.availableSize.height - sheetHeight + dragOffset + let sheetPosition = sheetOffset + sheetHeight / 2 + // Sheet underneath the video when in sheet + context.add(sheet + .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) + ) + let videoPos: CGFloat + + if isFullscreen { + videoPos = context.availableSize.height / 2 + dragOffset + } else { + videoPos = sheetPosition - sheetHeight / 2 + videoHeight / 2 + 50 + 12 + } + context.add(video + .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)/*sheetPosition + videoHeight / 2 + 50 - context.availableSize.height / 2*/)// context.availableSize.height / 2.0 + context.state.dismissOffset)) + ) + } else { + context.add(video + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2 + dragOffset) + )) + } + + if isFullscreen { + let fullScreenToolbarComponent = AnyComponent(ToolbarComponent( + bottomInset: environment.safeInsets.bottom, + sideInset: environment.safeInsets.left, + leftItem: AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: "Chat/Input/Accessory Panels/MessageSelectionForward", + tintColor: .white + )), + action: { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.presentShare() + } + ).minSize(CGSize(width: 64.0, height: 80))), + rightItem: state.hasVideo ? AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: isFullscreen ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", + tintColor: .white + )), + action: { + state.isFullscreen = false + if let controller = controller() as? MediaStreamComponentController { + if canEnforceOrientation { + controller.updateOrientation(orientation: .portrait) + } else { + state.updated() // updated(.easeInOut(duration: 0.3)) + } + } + } + ).minSize(CGSize(width: 64.0, height: 80))) : nil, + centerItem: infoItem + )) + let fullScreenOverlayComponent = fullscreenOverlay.update( + component: StreamSheetComponent( + topComponent: AnyComponent(navigationComponent), + bottomButtonsRow: fullScreenToolbarComponent, + topOffset: context.availableSize.height - sheetHeight + context.state.dismissOffset, + sheetHeight: max(sheetHeight - context.state.dismissOffset, sheetHeight), + backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), + bottomPadding: 12, + participantsCount: -1, // context.state.originInfo?.memberCount ?? 0 + isFullyExtended: isFullyDragged, + deviceCornerRadius: (controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 0, + videoHeight: videoHeight + ), + availableSize: context.availableSize, + transition: context.transition + ) + context.add(fullScreenOverlayComponent + .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) + .opacity(state.displayUI ? 1 : 0) + ) + } + +// context.add(navigationBar +// .position(CGPoint(x: context.availableSize.width / 2.0, y: navigationBar.size.height / 2.0)) +// .opacity(context.state.displayUI ? 1.0 : 0.0) +// ) + +// context.add(toolbar +// .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - toolbar.size.height / 2.0)) +// .opacity(context.state.displayUI ? 1.0 : 0.0) +// ) + + return context.availableSize + } + } +} + +// TODO: pass to component properly +//internal var deviceCornerRadius: CGFloat? = nil + +public final class MediaStreamComponentController: ViewControllerComponentContainer, VoiceChatController { + private let context: AccountContext + public let call: PresentationGroupCall + public private(set) var currentOverlayController: VoiceChatOverlayController? = nil + public var parentNavigationController: NavigationController? + + public var onViewDidAppear: (() -> Void)? + public var onViewDidDisappear: (() -> Void)? + + private var initialOrientation: UIInterfaceOrientation? + + private let inviteLinksPromise = Promise(nil) + + public init(call: PresentationGroupCall) { + self.context = call.accountContext + self.call = call + + super.init(context: call.accountContext, component: MediaStreamComponent(call: call as! PresentationGroupCallImpl), navigationBarAppearance: .none) + + self.statusBar.statusBarStyle = .White + self.view.disablesInteractiveModalDismiss = true + + self.inviteLinksPromise.set(.single(nil) + |> then(call.inviteLinks)) + } + + required public init(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + DispatchQueue.main.async { + self.onViewDidAppear?() + } + + if let view = self.node.hostView.findTaggedView(tag: MediaStreamVideoComponent.View.Tag()) as? MediaStreamVideoComponent.View { + view.expandFromPictureInPicture() + } + + self.view.clipsToBounds = true + + self.view.layer.animatePosition(from: CGPoint(x: self.view.frame.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), to: self.view.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in + }) + + self.view.layer.allowsGroupOpacity = true + self.view.layer.animateAlpha(from: 1.0, to: 1.0, duration: 0.2, completion: { [weak self] _ in + guard let strongSelf = self else { + return + } + strongSelf.view.layer.allowsGroupOpacity = false + }) + self.backgroundDimView.layer.animateAlpha(from: 0, to: 1, duration: 0.3) + if backgroundDimView.superview == nil { + guard let superview = view.superview else { return } + superview.insertSubview(backgroundDimView, belowSubview: view) + } + } + + override public func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + DispatchQueue.main.async { + self.onViewDidDisappear?() + } + +// if let initialOrientation = self.initialOrientation { +// self.initialOrientation = nil +// self.call.accountContext.sharedContext.applicationBindings.forceOrientation(initialOrientation) +// } + } + + override public func viewDidLoad() { + super.viewDidLoad() + // TODO: replace with actual color + backgroundDimView.backgroundColor = .black.withAlphaComponent(0.3) + self.view.clipsToBounds = false + } + + override public func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + } + + override public func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + backgroundDimView.frame = .init(x: 0, y: -view.bounds.height * 3, width: view.bounds.width, height: view.bounds.height * 4) + } + + public func dismiss(closing: Bool, manual: Bool) { + self.dismiss(completion: nil) + } + + let backgroundDimView = UIView() + + override public func dismiss(completion: (() -> Void)? = nil) { + self.view.layer.allowsGroupOpacity = true + self.view.layer.animateAlpha(from: 1.0, to: 1.0, duration: 0.25, removeOnCompletion: false, completion: { [weak self] _ in + guard let strongSelf = self else { + completion?() + return + } + strongSelf.view.layer.allowsGroupOpacity = false + strongSelf.dismissImpl(completion: completion) + }) + self.backgroundDimView.layer.animateAlpha(from: 1.0, to: 0, duration: 0.3, removeOnCompletion: false) + // if let validLayout = self.validLayout { + // self.view.clipsToBounds = true + // self.view.layer.cornerRadius = validLayout.deviceMetrics.screenCornerRadius + // if #available(iOS 13.0, *) { + // self.view.layer.cornerCurve = .continuous + // } + + self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 0.4, /*timingFunction: kCAMediaTimingFunctionSpring, */completion: { _ in + }) + // self.view.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) + // } + } + + private func dismissImpl(completion: (() -> Void)? = nil) { + super.dismiss(completion: completion) + } + + func updateOrientation(orientation: UIInterfaceOrientation) { + if self.initialOrientation == nil { + self.initialOrientation = orientation == .portrait ? .landscapeRight : .portrait + } else if self.initialOrientation == orientation { + self.initialOrientation = nil + } + self.call.accountContext.sharedContext.applicationBindings.forceOrientation(orientation) + } + + func presentShare() { + let _ = (self.inviteLinksPromise.get() + |> take(1) + |> deliverOnMainQueue).start(next: { [weak self] inviteLinks in + guard let strongSelf = self else { + return + } + + let _ = (strongSelf.context.engine.data.get( + TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.call.peerId), + TelegramEngine.EngineData.Item.Peer.ExportedInvitation(id: strongSelf.call.peerId) + ) + |> map { peer, exportedInvitation -> GroupCallInviteLinks? in + if let inviteLinks = inviteLinks { + return inviteLinks + } else if let peer = peer, let addressName = peer.addressName, !addressName.isEmpty { + return GroupCallInviteLinks(listenerLink: "https://t.me/\(addressName)?voicechat", speakerLink: nil) + } else if let link = exportedInvitation?.link { + return GroupCallInviteLinks(listenerLink: link, speakerLink: nil) + } + return nil + } + |> deliverOnMainQueue).start(next: { links in + guard let strongSelf = self else { + return + } + + if let links = links { + strongSelf.presentShare(links: links) + } + }) + }) + } + + func presentShare(links inviteLinks: GroupCallInviteLinks) { + let formatSendTitle: (String) -> String = { string in + var string = string + if string.contains("[") && string.contains("]") { + if let startIndex = string.firstIndex(of: "["), let endIndex = string.firstIndex(of: "]") { + string.removeSubrange(startIndex ... endIndex) + } + } else { + string = string.trimmingCharacters(in: CharacterSet(charactersIn: "0123456789-,.")) + } + return string + } + let _ = formatSendTitle + + let _ = (combineLatest(queue: .mainQueue(), self.context.account.postbox.loadedPeerWithId(self.call.peerId), self.call.state |> take(1)) + |> deliverOnMainQueue).start(next: { [weak self] peer, callState in + if let strongSelf = self { + var inviteLinks = inviteLinks + + if let peer = peer as? TelegramChannel, case .group = peer.info, !peer.flags.contains(.isGigagroup), !(peer.addressName ?? "").isEmpty, let defaultParticipantMuteState = callState.defaultParticipantMuteState { + let isMuted = defaultParticipantMuteState == .muted + + if !isMuted { + inviteLinks = GroupCallInviteLinks(listenerLink: inviteLinks.listenerLink, speakerLink: nil) + } + } + + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } + + var segmentedValues: [ShareControllerSegmentedValue]? + segmentedValues = nil + let shareController = ShareController(context: strongSelf.context, subject: .url(inviteLinks.listenerLink), segmentedValues: segmentedValues, forceTheme: defaultDarkPresentationTheme, forcedActionTitle: presentationData.strings.VoiceChat_CopyInviteLink) + shareController.completed = { [weak self] peerIds in + if let strongSelf = self { + let _ = (strongSelf.context.engine.data.get( + EngineDataList( + peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init) + ) + ) + |> deliverOnMainQueue).start(next: { [weak self] peerList in + if let strongSelf = self { + let peers = peerList.compactMap { $0 } + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } + + let text: String + var isSavedMessages = false + if peers.count == 1, let peer = peers.first { + isSavedMessages = peer.id == strongSelf.context.account.peerId + let peerName = peer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + text = presentationData.strings.VoiceChat_ForwardTooltip_Chat(peerName).string + } else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last { + let firstPeerName = firstPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + let secondPeerName = secondPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + text = presentationData.strings.VoiceChat_ForwardTooltip_TwoChats(firstPeerName, secondPeerName).string + } else if let peer = peers.first { + let peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) + text = presentationData.strings.VoiceChat_ForwardTooltip_ManyChats(peerName, "\(peers.count - 1)").string + } else { + text = "" + } + + strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: isSavedMessages, text: text), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) + } + }) + } + } + shareController.actionCompleted = { + if let strongSelf = self { + let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } + strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: presentationData.strings.VoiceChat_InviteLinkCopiedText), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) + } + } + strongSelf.present(shareController, in: .window(.root)) + } + }) + } +} + +// MARK: - Subcomponents + final class StreamTitleComponent: Component { let text: String let isRecording: Bool @@ -79,27 +1286,16 @@ final class StreamTitleComponent: Component { } func toggle(isLive: Bool) { - // TODO: get actual colors if isLive { if !wasLive { - // TODO: animate wasLive = true -// let frame = self.frame let anim = CAKeyframeAnimation(keyPath: "transform.scale") anim.values = [1.0, 1.4, 1.0] anim.keyTimes = [0, 0.5, 1] self.layer.add(anim, forKey: "transform") -// UIView.transition(with: self, duration: <#T##TimeInterval#>, animations: <#T##(() -> Void)?##(() -> Void)?##() -> Void#>) UIView.animate(withDuration: 0.15, animations: { - self.toggle(isLive: true) -// self.transform = .init(scaleX: 1.5, y: 1.5) - }, completion: { _ in -// UIView.animate(withDuration: 0.15) { -// self.transform = .identity -//// self.frame = frame -// } - }) + self.toggle(isLive: true) }) return } self.backgroundColor = UIColor(red: 0.82, green: 0.26, blue: 0.37, alpha: 1) @@ -107,7 +1303,6 @@ final class StreamTitleComponent: Component { stalledAnimatedGradient.removeAllAnimations() } else { if wasLive { - // TODO: animate wasLive = false UIView.animate(withDuration: 0.3) { self.toggle(isLive: false) @@ -116,7 +1311,6 @@ final class StreamTitleComponent: Component { } self.backgroundColor = UIColor(white: 0.36, alpha: 1) stalledAnimatedGradient.opacity = 1 -// stalledAnimatedGradient.add(<#T##anim: CAAnimation##CAAnimation#>, forKey: <#T##String?#>) } wasLive = isLive } @@ -704,1277 +1898,6 @@ final class RoundGradientButtonComponent: Component { } } -public final class _MediaStreamComponent: CombinedComponent { - struct OriginInfo: Equatable { - var title: String - var memberCount: Int - } - - public typealias EnvironmentType = ViewControllerComponentContainer.Environment - - public let call: PresentationGroupCallImpl - - public init(call: PresentationGroupCallImpl) { - self.call = call - } - - public static func ==(lhs: _MediaStreamComponent, rhs: _MediaStreamComponent) -> Bool { - if lhs.call !== rhs.call { - return false - } - - return true - } - - public final class State: ComponentState { - private let call: PresentationGroupCallImpl - - private(set) var hasVideo: Bool = false - private var stateDisposable: Disposable? - private var infoDisposable: Disposable? - private var connectionDisposable: Disposable? - private var networkStateDisposable: Disposable? - - private(set) var originInfo: OriginInfo? - - private(set) var displayUI: Bool = true - var dismissOffset: CGFloat = 0.0 - var initialOffset: CGFloat = 0.0 - // TODO: remove (replaced by isFullscreen) - var storedIsLandscape: Bool? - var isFullscreen: Bool = false - var videoSize: CGSize? - - private(set) var canManageCall: Bool = false - // TODO: also handle pictureInPicturePossible - let isPictureInPictureSupported: Bool - - private(set) var callTitle: String? - private(set) var recordingStartTimestamp: Int32? - - private(set) var peerTitle: String = "" - private(set) var chatPeer: Peer? - - private(set) var isVisibleInHierarchy: Bool = false - private var isVisibleInHierarchyDisposable: Disposable? - - private var scheduledDismissUITimer: SwiftSignalKit.Timer? - var videoStalled: Bool = true - - var videoIsPlayable: Bool { - !videoStalled && hasVideo - } - - let deactivatePictureInPictureIfVisible = StoredActionSlot(Void.self) - - var videoHiddenForPip = false - /// To update videoHiddenForPip - var onExpandedFromPictureInPicture: ((State) -> Void)? - private let infoThrottler = Throttler.init(duration: 5, queue: .main) - - init(call: PresentationGroupCallImpl) { - self.call = call - - if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { - self.isPictureInPictureSupported = true - } else { - self.isPictureInPictureSupported = AVPictureInPictureController.isPictureInPictureSupported() - } - - super.init() - - self.stateDisposable = (call.state - |> map { state -> Bool in - switch state.networkState { - case .connected: - return true - default: - return false - } - } - |> filter { $0 } - |> take(1)).start(next: { [weak self] _ in - guard let strongSelf = self else { - return - } - strongSelf.hasVideo = true - strongSelf.updated(transition: .immediate) - }) - - // TODO: retest to uncomment or delete. Relying only on video frames - /*self.networkStateDisposable = (call.account.networkState |> deliverOnMainQueue).start(next: { [weak self] state in - guard let strongSelf = self else { return } - switch state { - case .waitingForNetwork, .connecting: - print("[NEW] videoStalled") - strongSelf.videoStalled = true - default: - strongSelf.videoStalled = !strongSelf.hasVideo - } - strongSelf.updated(transition: .immediate) -// if let strongSelf = self, case .standard(previewing: false) = strongSelf.presentationInterfaceState.mode { -// strongSelf.chatTitleView?.networkState = state -// } - }) - - self.connectionDisposable = call.state.start(next: { [weak self] state in - let prev = self?.videoStalled - switch state.networkState { - case .connected: - self?.videoStalled = false - default: - print("[ALERT] video stalled") - self?.videoStalled = true - } - if prev != self?.videoStalled { - self?.updated(transition: .immediate) - } - })*/ - - let callPeer = call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: call.peerId)) - - self.infoDisposable = (combineLatest(queue: .mainQueue(), call.state, call.members, callPeer) - |> deliverOnMainQueue).start(next: { [weak self] state, members, callPeer in - guard let strongSelf = self, let members = members, let callPeer = callPeer else { - return - } - - var updated = false -// TODO: remove debug timer -// Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in - strongSelf.infoThrottler.publish(members.totalCount/*Int.random(in: 0..<10000000)*/) { [weak strongSelf] latestCount in - print(members.totalCount) - guard let strongSelf = strongSelf else { return } - var updated = false - let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: latestCount) - if strongSelf.originInfo != originInfo { - strongSelf.originInfo = originInfo - updated = true - } - if updated { - strongSelf.updated(transition: .immediate) - } - } -// }.fire() - if state.canManageCall != strongSelf.canManageCall { - strongSelf.canManageCall = state.canManageCall - updated = true - } - if strongSelf.peerTitle != callPeer.debugDisplayTitle { - strongSelf.peerTitle = callPeer.debugDisplayTitle - updated = true - } - strongSelf.chatPeer = callPeer._asPeer() - - if strongSelf.callTitle != state.title { - strongSelf.callTitle = state.title - updated = true - } - - if strongSelf.recordingStartTimestamp != state.recordingStartTimestamp { - strongSelf.recordingStartTimestamp = state.recordingStartTimestamp - updated = true - } - -// let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: members.totalCount) -// if strongSelf.originInfo != originInfo { -// strongSelf.originInfo = originInfo -// updated = true -// } -// - if updated { - strongSelf.updated(transition: .immediate) - } - }) - - self.isVisibleInHierarchyDisposable = (call.accountContext.sharedContext.applicationBindings.applicationInForeground - |> deliverOnMainQueue).start(next: { [weak self] inForeground in - guard let strongSelf = self else { - return - } - if strongSelf.isVisibleInHierarchy != inForeground { - strongSelf.isVisibleInHierarchy = inForeground - strongSelf.updated(transition: .immediate) - - if inForeground { - Queue.mainQueue().after(0.5, { - guard let strongSelf = self, strongSelf.isVisibleInHierarchy else { - return - } - - strongSelf.deactivatePictureInPictureIfVisible.invoke(Void()) - }) - } - } - }) - } - - deinit { - self.stateDisposable?.dispose() - self.infoDisposable?.dispose() - self.isVisibleInHierarchyDisposable?.dispose() - self.connectionDisposable?.dispose() - self.networkStateDisposable?.dispose() - } - - func toggleDisplayUI() { - self.displayUI = !self.displayUI - self.updated(transition: Transition(animation: .curve(duration: 0.4, curve: .easeInOut))) - } - - func cancelScheduledDismissUI() { - self.scheduledDismissUITimer?.invalidate() - self.scheduledDismissUITimer = nil - } - - func scheduleDismissUI() { - if self.scheduledDismissUITimer == nil { - self.scheduledDismissUITimer = SwiftSignalKit.Timer(timeout: 3.0, repeat: false, completion: { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.scheduledDismissUITimer = nil - if strongSelf.displayUI { - strongSelf.toggleDisplayUI() - } - }, queue: .mainQueue()) - self.scheduledDismissUITimer?.start() - } - } - - func updateDismissOffset(value: CGFloat, interactive: Bool) { - self.dismissOffset = value - if interactive { - self.updated(transition: .immediate) - } else { - self.updated(transition: Transition(animation: .curve(duration: 0.25, curve: .easeInOut))) - } - } - } - - public func makeState() -> State { - return State(call: self.call) - } - - public static var body: Body { - let background = Child(Rectangle.self) - let dismissTapComponent = Child(Rectangle.self) - let video = Child(MediaStreamVideoComponent.self) -// let navigationBar = Child(NavigationBarComponent.self) -// let toolbar = Child(ToolbarComponent.self) - - let sheet = Child(StreamSheetComponent.self) - let fullscreenOverlay = Child(StreamSheetComponent.self) - - let activatePictureInPicture = StoredActionSlot(Action.self) - let deactivatePictureInPicture = StoredActionSlot(Void.self) - let moreButtonTag = GenericComponentViewTag() - let moreAnimationTag = GenericComponentViewTag() - - return { context in - let canEnforceOrientation = UIDevice.current.model != "iPad" - var forceFullScreenInLandscape: Bool { canEnforceOrientation && true } - let environment = context.environment[ViewControllerComponentContainer.Environment.self].value - if environment.isVisible { - } else { - context.state.dismissOffset = 0.0 - } - - let background = background.update( - component: Rectangle(color: .black.withAlphaComponent(0.0)), - availableSize: context.availableSize, - transition: context.transition - ) - - let call = context.component.call - let state = context.state - let controller = environment.controller - //? - if environment.isVisible { - state.videoHiddenForPip = false - } - context.state.deactivatePictureInPictureIfVisible.connect { - guard let controller = controller() else { - return - } - if controller.view.window == nil { - return - } - state.videoHiddenForPip = false - state.updated(transition: .easeInOut(duration: 3)) - deactivatePictureInPicture.invoke(Void()) - } - let isFullscreen: Bool // = state.isFullscreen - let isLandscape = context.availableSize.width > context.availableSize.height - -// if let videoSize = context.state.videoSize { - // Always fullscreen in landscape - // TODO: support landscape sheet (wrap in scrollview, video size same as portrait) - if forceFullScreenInLandscape && /*videoSize.width > videoSize.height &&*/ isLandscape && !state.isFullscreen { - state.isFullscreen = true - isFullscreen = true - } else if /*let videoSize = context.state.videoSize, videoSize.width > videoSize.height &&*/ !isLandscape && state.isFullscreen && canEnforceOrientation { - state.isFullscreen = false - isFullscreen = false - } else { - isFullscreen = state.isFullscreen - } - // } - let videoInset: CGFloat - if !isFullscreen { - videoInset = 16 - } else { - videoInset = 0 - } - - let videoHeight: CGFloat = forceFullScreenInLandscape - ? (context.availableSize.width - videoInset * 2) / 16 * 9 - : context.state.videoSize?.height ?? (min(context.availableSize.width, context.availableSize.height) - videoInset * 2) / 16 * 9 - let bottomPadding = 40 + environment.safeInsets.bottom - let sheetHeight: CGFloat = isFullscreen - ? context.availableSize.height - : (44 + videoHeight + 40 + 69 + 16 + 32 + 70 + bottomPadding) - let isFullyDragged = context.availableSize.height - sheetHeight + state.dismissOffset - context.view.safeAreaInsets.top < 30 - - var dragOffset = context.state.dismissOffset - if isFullyDragged { - dragOffset = max(context.state.dismissOffset, sheetHeight - context.availableSize.height + context.view.safeAreaInsets.top)// sheetHeight - UIScreen.main.bounds.height - } - - let dismissTapAreaHeight = isFullscreen ? 0 : (context.availableSize.height - sheetHeight + dragOffset) - let dismissTapComponent = dismissTapComponent.update( - component: Rectangle(color: .red.withAlphaComponent(0)), - availableSize: CGSize(width: context.availableSize.width, height: dismissTapAreaHeight), - transition: context.transition - ) - - - let video = video.update( - component: MediaStreamVideoComponent( - call: context.component.call, - hasVideo: context.state.hasVideo, - isVisible: environment.isVisible && context.state.isVisibleInHierarchy, - isAdmin: context.state.canManageCall, - peerTitle: context.state.peerTitle, - // TODO: remove // find out how to get image - peerImage: nil, - isFullscreen: isFullscreen, - videoLoading: context.state.videoStalled, - callPeer: context.state.chatPeer, - activatePictureInPicture: activatePictureInPicture, - deactivatePictureInPicture: deactivatePictureInPicture, - bringBackControllerForPictureInPictureDeactivation: { [weak call] completed in - guard let call = call else { - completed() - return - } - - call.accountContext.sharedContext.mainWindow?.inCallNavigate?() - // TODO: bring up sheet - completed() - }, - pictureInPictureClosed: { [weak call] in - let _ = call?.leave(terminateIfPossible: false) - }, - onVideoSizeRetrieved: { [weak state] size in - state?.videoSize = size - }, - onVideoPlaybackLiveChange: { [weak state] isLive in - guard let state else { return } - let wasLive = !state.videoStalled - if isLive != wasLive { - state.videoStalled = !isLive - state.updated() - } - } - ), - availableSize: context.availableSize, - transition: context.transition - )// .opacity(state.videoHiddenForPip ? 0 : 1) - -// let height = context.availableSize.height - var navigationRightItems: [AnyComponentWithIdentity] = [] -// let contextView = context.view - if context.state.isPictureInPictureSupported, context.state.videoIsPlayable { - navigationRightItems.append(AnyComponentWithIdentity(id: "pip", component: AnyComponent(Button( - content: AnyComponent(ZStack([ - AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( - fillColor: .white.withAlphaComponent(0.08), - size: CGSize(width: 32.0, height: 32.0) - ))), - AnyComponentWithIdentity(id: "a", component: AnyComponent(BundleIconComponent( - name: "Call/pip", - tintColor: .white - ))) - ] - )), - action: { - activatePictureInPicture.invoke(Action { - guard let controller = controller() as? MediaStreamComponentController else { - return - } - state.videoHiddenForPip = true - - controller.dismiss(closing: false, manual: true) - }) - } - ).minSize(CGSize(width: 44.0, height: 44.0))))) - } - var topLeftButton: AnyComponent? - if context.state.canManageCall { - let whiteColor = UIColor(white: 1.0, alpha: 1.0) - /*navigationRightItems.append(*/ topLeftButton = //AnyComponentWithIdentity(id: "more", component: - AnyComponent(Button( - content: AnyComponent(ZStack([ - AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( - fillColor: .white.withAlphaComponent(0.08), - size: CGSize(width: 32.0, height: 32.0) - ))), - AnyComponentWithIdentity(id: "a", component: AnyComponent(LottieAnimationComponent( - animation: LottieAnimationComponent.AnimationItem( - name: "anim_profilemore", - mode: .still(position: .begin) - ), - colors: [ - "Point 2.Group 1.Fill 1": whiteColor, - "Point 3.Group 1.Fill 1": whiteColor, - "Point 1.Group 1.Fill 1": whiteColor - ], - size: CGSize(width: 32.0, height: 32.0) - ).tagged(moreAnimationTag))), - ])), - action: { [weak call, weak state] in - guard let call = call, let state = state else { - return - } - guard let controller = controller() as? MediaStreamComponentController else { - return - } - guard let anchorView = controller.node.hostView.findTaggedView(tag: moreButtonTag) else { - return - } - - if let animationView = controller.node.hostView.findTaggedView(tag: moreAnimationTag) as? LottieAnimationComponent.View { - animationView.playOnce() - } - - let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } - - var items: [ContextMenuItem] = [] - - items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.LiveStream_EditTitle, textColor: .primary, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Pencil"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak call, weak controller, weak state] _, a in - guard let call = call, let controller = controller, let state = state, let chatPeer = state.chatPeer else { - return - } - - let initialTitle = state.callTitle ?? "" - - let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } - - let title: String = presentationData.strings.LiveStream_EditTitle - let text: String = presentationData.strings.LiveStream_EditTitleText - - let editController = voiceChatTitleEditController(sharedContext: call.accountContext.sharedContext, account: call.accountContext.account, forceTheme: defaultDarkPresentationTheme, title: title, text: text, placeholder: EnginePeer(chatPeer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), value: initialTitle, maxLength: 40, apply: { [weak call] title in - guard let call = call else { - return - } - - let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } - - if let title = title, title != initialTitle { - call.updateTitle(title) - - let text: String = title.isEmpty ? presentationData.strings.LiveStream_EditTitleRemoveSuccess : presentationData.strings.LiveStream_EditTitleSuccess(title).string - - let _ = text - //strongSelf.presentUndoOverlay(content: .voiceChatFlag(text: text), action: { _ in return false }) - } - }) - controller.present(editController, in: .window(.root)) - - a(.default) - }))) - - if let recordingStartTimestamp = state.recordingStartTimestamp { - items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { [weak call, weak controller] _, f in - f(.dismissWithoutContent) - - guard let call = call, let controller = controller else { - return - } - - let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } - - let alertController = textAlertController(context: call.accountContext, forceTheme: defaultDarkPresentationTheme, title: nil, text: presentationData.strings.VoiceChat_StopRecordingTitle, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.VoiceChat_StopRecordingStop, action: { [weak call, weak controller] in - guard let call = call, let controller = controller else { - return - } - call.setShouldBeRecording(false, title: nil, videoOrientation: nil) - - let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } - let text = presentationData.strings.LiveStream_RecordingSaved - - let _ = text - let _ = controller - - /*strongSelf.presentUndoOverlay(content: .forward(savedMessages: true, text: text), action: { [weak self] value in - if case .info = value, let strongSelf = self, let navigationController = strongSelf.controller?.navigationController as? NavigationController { - let context = strongSelf.context - strongSelf.controller?.dismiss(completion: { - Queue.mainQueue().justDispatch { - context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(context.account.peerId), keepStack: .always, purposefulAction: {}, peekData: nil)) - } - }) - - return true - } - return false - })*/ - })]) - controller.present(alertController, in: .window(.root)) - }), false)) - } else { - let text = presentationData.strings.LiveStream_StartRecording - items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in - return generateStartRecordingIcon(color: theme.actionSheet.primaryTextColor) - }, action: { [weak call, weak state, weak controller] _, f in - f(.dismissWithoutContent) - - guard let call = call, let state = state, let _ = state.chatPeer, let controller = controller else { - return - } - - let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } - - let title: String - let text: String - let placeholder: String = presentationData.strings.VoiceChat_RecordingTitlePlaceholderVideo - - title = presentationData.strings.LiveStream_StartRecordingTitle - text = presentationData.strings.LiveStream_StartRecordingTextVideo - - let editController = voiceChatTitleEditController(sharedContext: call.accountContext.sharedContext, account: call.accountContext.account, forceTheme: defaultDarkPresentationTheme, title: title, text: text, placeholder: placeholder, value: nil, maxLength: 40, apply: { [weak call, weak controller] title in - guard let call = call, let controller = controller else { - return - } - - - let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } - - if let title = title { - call.setShouldBeRecording(true, title: title, videoOrientation: false) - - let text = presentationData.strings.LiveStream_RecordingStarted - let _ = text - - let _ = controller - - call.playTone(.recordingStarted) - } - }) - controller.present(editController, in: .window(.root)) - }))) - } - - let credentialsPromise = Promise() - credentialsPromise.set(call.accountContext.engine.calls.getGroupCallStreamCredentials(peerId: call.peerId, revokePreviousCredentials: false) |> `catch` { _ -> Signal in return .never() }) - - items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.LiveStream_ViewCredentials, textColor: .primary, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.contextMenu.primaryColor, backgroundColor: nil) - }, action: { [weak call, weak controller] _, a in - guard let call = call, let controller = controller else { - return - } - - controller.push(CreateExternalMediaStreamScreen(context: call.accountContext, peerId: call.peerId, credentialsPromise: credentialsPromise, mode: .view)) - - a(.default) - }))) - - items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.VoiceChat_StopRecordingStop, textColor: .destructive, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.contextMenu.destructiveColor, backgroundColor: nil) - }, action: { [weak call] _, a in - guard let call = call else { - return - } - - let _ = call.leave(terminateIfPossible: true).start() - - a(.default) - }))) - - final class ReferenceContentSource: ContextReferenceContentSource { - private let sourceView: UIView - - init(sourceView: UIView) { - self.sourceView = sourceView - } - - func transitionInfo() -> ContextControllerReferenceViewInfo? { - return ContextControllerReferenceViewInfo(referenceView: self.sourceView, contentAreaInScreenSpace: UIScreen.main.bounds) - } - } - - let contextController = ContextController(account: call.accountContext.account, presentationData: presentationData.withUpdated(theme: defaultDarkPresentationTheme), source: .reference(ReferenceContentSource(sourceView: anchorView)), items: .single(ContextController.Items(content: .list(items))), gesture: nil) - /*contextController.passthroughTouchEvent = { sourceView, point in - guard let strongSelf = self else { - return .ignore - } - - let localPoint = strongSelf.view.convert(sourceView.convert(point, to: nil), from: nil) - guard let localResult = strongSelf.hitTest(localPoint, with: nil) else { - return .dismiss(consume: true, result: nil) - } - - var testView: UIView? = localResult - while true { - if let testViewValue = testView { - if let node = testViewValue.asyncdisplaykit_node as? PeerInfoHeaderNavigationButton { - node.isUserInteractionEnabled = false - DispatchQueue.main.async { - node.isUserInteractionEnabled = true - } - return .dismiss(consume: false, result: nil) - } else if let node = testViewValue.asyncdisplaykit_node as? PeerInfoVisualMediaPaneNode { - node.brieflyDisableTouchActions() - return .dismiss(consume: false, result: nil) - } else { - testView = testViewValue.superview - } - } else { - break - } - } - - return .dismiss(consume: true, result: nil) - }*/ - controller.presentInGlobalOverlay(contextController) - } - ).minSize(CGSize(width: 44.0, height: 44.0)).tagged(moreButtonTag))//)//) - } - let navigationComponent = NavigationBarComponent( - topInset: environment.statusBarHeight, - sideInset: environment.safeInsets.left, - leftItem: topLeftButton/*AnyComponent(Button( - content: AnyComponent(Text(text: environment.strings.Common_Close, font: Font.regular(17.0), color: .white)), - action: { [weak call] in - let _ = call?.leave(terminateIfPossible: false) - }) - )*/, - rightItems: navigationRightItems, - centerItem: AnyComponent(StreamTitleComponent(text: state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isActive: context.state.videoIsPlayable)) - ) - -// let navigationBar = navigationBar.update( -// component: navigationComponent, -// availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), -// transition: context.transition -// ) - - if context.state.storedIsLandscape != isLandscape { - context.state.storedIsLandscape = isLandscape - if isLandscape { - context.state.scheduleDismissUI() - } else { - context.state.cancelScheduledDismissUI() - } - } - - var infoItem: AnyComponent? - if let originInfo = context.state.originInfo { - let memberCountString: String - if originInfo.memberCount == 0 { - memberCountString = environment.strings.LiveStream_NoViewers - } else { - memberCountString = environment.strings.LiveStream_ViewerCount(Int32(originInfo.memberCount)) - } - infoItem = AnyComponent(OriginInfoComponent( - title: state.callTitle ?? originInfo.title, - subtitle: memberCountString - )) - } - let availableSize = context.availableSize - let safeAreaTop = context.view.safeAreaInsets.top - - let onPanGesture: ((Gesture.PanGestureState) -> Void) = { [weak state] panState in - guard let state = state else { - return - } - switch panState { - case .began: - state.initialOffset = state.dismissOffset - case let .updated(offset): - state.updateDismissOffset(value: state.initialOffset + offset.y, interactive: true) - case let .ended(velocity): - // TODO: Dismiss sheet depending on velocity - if velocity.y > 200.0 { - if state.isFullscreen { - state.isFullscreen = false - state.updateDismissOffset(value: 0.0, interactive: false) - if let controller = controller() as? MediaStreamComponentController { - controller.updateOrientation(orientation: .portrait) - } - } else { - if isFullyDragged || state.initialOffset != 0 { - state.updateDismissOffset(value: 0.0, interactive: false) - } else { - let _ = call.leave(terminateIfPossible: false) - } - } - /*activatePictureInPicture.invoke(Action { [weak state] in - guard let state = state, let controller = controller() as? MediaStreamComponentController else { - return - } - state.updateDismissOffset(value: velocity.y < 0 ? -height : height, interactive: false) - controller.dismiss(closing: false, manual: true) - })*/ - } else { - if isFullyDragged { - state.updateDismissOffset(value: sheetHeight - availableSize.height + safeAreaTop, interactive: false) - } else { - if velocity.y < -200 { - // Expand - state.updateDismissOffset(value: sheetHeight - availableSize.height + safeAreaTop, interactive: false) - } else { - state.updateDismissOffset(value: 0.0, interactive: false) - } - } - } - } - } - - context.add(background - .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0)) - .gesture(.tap { [weak state] in - guard let state = state, state.isFullscreen else { - return - } - state.toggleDisplayUI() - }) - .gesture(.pan { panState in - onPanGesture(panState) - }) - ) -// var bottomComponent: AnyComponent? -// var fullScreenToolbarComponent: AnyComponent? - - context.add(dismissTapComponent - .position(CGPoint(x: context.availableSize.width / 2, y: dismissTapAreaHeight / 2)) - .gesture(.tap { - _ = call.leave(terminateIfPossible: false) - }) - .gesture(.pan(onPanGesture)) - ) - - if !isFullscreen { - let imageRenderScale = UIScreen.main.scale - let bottomComponent = AnyComponent(ButtonsRowComponent( - bottomInset: environment.safeInsets.bottom, - sideInset: environment.safeInsets.left, - leftItem: AnyComponent(Button( - content: AnyComponent(RoundGradientButtonComponent(// BundleIconComponent( - gradientColors: [UIColor(red: 0.18, green: 0.17, blue: 0.30, alpha: 1).cgColor, UIColor(red: 0.17, green: 0.16, blue: 0.30, alpha: 1).cgColor], - image: generateTintedImage(image: UIImage(bundleImageName: "Call/CallShareButton"), color: .white),// "Chat/Input/Accessory Panels/MessageSelectionForward" - // TODO: localize: - title: "share")), - action: { - guard let controller = controller() as? MediaStreamComponentController else { - return - } - controller.presentShare() - } - ).minSize(CGSize(width: 65, height: 80))), - // TODO: disable button instead of hiding - rightItem: AnyComponent(Button( - content: AnyComponent(RoundGradientButtonComponent( - gradientColors: [UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor, UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor], - image: generateImage(CGSize(width: 44.0 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in - context.translateBy(x: size.width / 2, y: size.height / 2) - context.scaleBy(x: 0.4, y: 0.4) - context.translateBy(x: -size.width / 2, y: -size.height / 2) - let imageColor = UIColor.white - let bounds = CGRect(origin: CGPoint(), size: size) - context.clear(bounds) - let lineWidth: CGFloat = size.width / 7 - context.setLineWidth(lineWidth - UIScreenPixel) - context.setLineCap(.round) - context.setStrokeColor(imageColor.cgColor) - - context.move(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: lineWidth / 2 + UIScreenPixel)) - context.addLine(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) - context.strokePath() - - context.move(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: lineWidth / 2 + UIScreenPixel)) - context.addLine(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) - context.strokePath() - }), - title: "leave" - )), - action: { [weak call] in - let _ = call?.leave(terminateIfPossible: false) - } - ).minSize(CGSize(width: 44.0, height: 44.0))), - centerItem: AnyComponent(Button( - content: AnyComponent(RoundGradientButtonComponent( - gradientColors: [UIColor(red: 0.23, green: 0.17, blue: 0.29, alpha: 1).cgColor, UIColor(red: 0.21, green: 0.16, blue: 0.29, alpha: 1).cgColor], - image: generateImage(CGSize(width: 44 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in - - let imageColor = UIColor.white - let bounds = CGRect(origin: CGPoint(), size: size) - context.clear(bounds) - - context.setLineWidth(2.4 * imageRenderScale - UIScreenPixel) - context.setLineCap(.round) - context.setStrokeColor(imageColor.cgColor) -// context.setLineJoin(.round) - - let lineSide = size.width / 5 - let centerOffset = size.width / 20 - context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - centerOffset / 2)) - context.addLine(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) - context.addLine(to: CGPoint(x: size.width / 2 + centerOffset / 2, y: size.height / 2 - lineSide)) - context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) - context.addLine(to: CGPoint(x: size.width / 2 + centerOffset, y: size.height / 2 - centerOffset)) - context.strokePath() - - context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + centerOffset / 2)) - context.addLine(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) - context.addLine(to: CGPoint(x: size.width / 2 - centerOffset / 2, y: size.height / 2 + lineSide)) - context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) - context.addLine(to: CGPoint(x: size.width / 2 - centerOffset, y: size.height / 2 + centerOffset)) - context.strokePath() - }), - title: "expand" - )), - action: { - guard state.videoIsPlayable else { - state.isFullscreen = false - return - } - if let controller = controller() as? MediaStreamComponentController { - guard let size = state.videoSize else { return } - state.isFullscreen.toggle() - if state.isFullscreen { - if size.width > size.height { - controller.updateOrientation(orientation: .landscapeRight) - } else { - controller.updateOrientation(orientation: .portrait) - // TODO: Update to portrait when open from landscape(?) - } - } else { - // TODO: Check and respect current device orientation - controller.updateOrientation(orientation: .portrait) - } - if !canEnforceOrientation { - state.updated() // updated(.easeInOut(duration: 0.3)) - } - // controller.updateOrientation(orientation: isLandscape ? .portrait : .landscapeRight) - } - } - ).minSize(CGSize(width: 44.0, height: 44.0))) - )) - - let sheet = sheet.update( - component: StreamSheetComponent( - topComponent: AnyComponent(navigationComponent), - bottomButtonsRow: bottomComponent, - topOffset: context.availableSize.height - sheetHeight + dragOffset, - sheetHeight: max(sheetHeight - dragOffset, sheetHeight), - backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), - bottomPadding: bottomPadding, - participantsCount: context.state.originInfo?.memberCount ?? 0, // Int.random(in: 0...999998)// [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! - // - isFullyExtended: isFullyDragged, - deviceCornerRadius: deviceCornerRadius ?? 0, - videoHeight: videoHeight - ), - availableSize: context.availableSize, - transition: context.transition - ) - - // TODO: calculate (although not necessary currently) - let sheetOffset: CGFloat = context.availableSize.height - sheetHeight + dragOffset - let sheetPosition = sheetOffset + sheetHeight / 2 - // Sheet underneath the video when in sheet -// if !isFullscreen { - // TODO: work with sheet here - context.add(sheet - .position(.init(x: context.availableSize.width / 2.0, y: /*isFullscreen ?*/ context.availableSize.height / 2)) //: sheetPosition)) - ) -// } - let videoPos: CGFloat - - if isFullscreen { - videoPos = context.availableSize.height / 2 + dragOffset - } else { - videoPos = sheetPosition - sheetHeight / 2 + videoHeight / 2 + 50 + 12 - } - context.add(video - .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)/*sheetPosition + videoHeight / 2 + 50 - context.availableSize.height / 2*/)// context.availableSize.height / 2.0 + context.state.dismissOffset)) - ) - } else { - context.add(video - .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2 + dragOffset) - )) - } - - if isFullscreen { - let fullScreenToolbarComponent = AnyComponent(ToolbarComponent( - bottomInset: environment.safeInsets.bottom, - sideInset: environment.safeInsets.left, - leftItem: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Chat/Input/Accessory Panels/MessageSelectionForward", - tintColor: .white - )), - action: { - guard let controller = controller() as? MediaStreamComponentController else { - return - } - controller.presentShare() - } - ).minSize(CGSize(width: 64.0, height: 80))), - // TODO: disable button instead of hiding - rightItem: state.hasVideo ? AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: isFullscreen ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", - tintColor: .white - )), - action: { - /*if let controller = controller() as? MediaStreamComponentController { - guard let size = state.videoSize else { return } - state.isFullscreen = false - if state.isFullscreen { - if size.width > size.height { - controller.updateOrientation(orientation: .landscapeRight) - } else { - controller.updateOrientation(orientation: .portrait) - // TODO: Update to portrait when open from landscape(?) - } - } else { - // TODO: Check and respect current device orientation - controller.updateOrientation(orientation: .portrait) - } - // controller.updateOrientation(orientation: isLandscape ? .portrait : .landscapeRight) - }*/ - state.isFullscreen = false - if let controller = controller() as? MediaStreamComponentController { - if canEnforceOrientation { - controller.updateOrientation(orientation: .portrait) - } else { - state.updated() // updated(.easeInOut(duration: 0.3)) - } - } - } - ).minSize(CGSize(width: 64.0, height: 80))) : nil, - centerItem: infoItem - )) - let fullScreenOverlayComponent = fullscreenOverlay.update( - component: StreamSheetComponent( - topComponent: AnyComponent(navigationComponent), - bottomButtonsRow: fullScreenToolbarComponent, - topOffset: context.availableSize.height - sheetHeight + context.state.dismissOffset, - sheetHeight: max(sheetHeight - context.state.dismissOffset, sheetHeight), - backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), - bottomPadding: 12, - participantsCount: -1, // context.state.originInfo?.memberCount ?? 0 - isFullyExtended: isFullyDragged, - deviceCornerRadius: deviceCornerRadius ?? 0, - videoHeight: videoHeight - ), - availableSize: context.availableSize, - transition: context.transition - ) - context.add(fullScreenOverlayComponent - .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) - .opacity(state.displayUI ? 1 : 0) - ) - } - -// context.add(navigationBar -// .position(CGPoint(x: context.availableSize.width / 2.0, y: navigationBar.size.height / 2.0)) -// .opacity(context.state.displayUI ? 1.0 : 0.0) -// ) - -// context.add(toolbar -// .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - toolbar.size.height / 2.0)) -// .opacity(context.state.displayUI ? 1.0 : 0.0) -// ) - - return context.availableSize - } - } -} - -// TODO: pass to component properly -var deviceCornerRadius: CGFloat? = nil - -public final class _MediaStreamComponentController: ViewControllerComponentContainer, VoiceChatController { - private let context: AccountContext - public let call: PresentationGroupCall - public private(set) var currentOverlayController: VoiceChatOverlayController? = nil - public var parentNavigationController: NavigationController? - - public var onViewDidAppear: (() -> Void)? - public var onViewDidDisappear: (() -> Void)? - - private var initialOrientation: UIInterfaceOrientation? - - private let inviteLinksPromise = Promise(nil) - - public convenience init(sharedContext: SharedAccountContext, accountContext: AccountContext, call: PresentationGroupCall) { - self.init(call: call) - } - - public init(call: PresentationGroupCall) { - self.context = call.accountContext - self.call = call - - super.init(context: call.accountContext, component: MediaStreamComponent(call: call as! PresentationGroupCallImpl), navigationBarAppearance: .none) - - self.statusBar.statusBarStyle = .White - self.view.disablesInteractiveModalDismiss = true - - self.inviteLinksPromise.set(.single(nil) - |> then(call.inviteLinks)) - } - - required public init(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override public func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - DispatchQueue.main.async { - self.onViewDidAppear?() - } - - if let view = self.node.hostView.findTaggedView(tag: MediaStreamVideoComponent.View.Tag()) as? MediaStreamVideoComponent.View { - view.expandFromPictureInPicture() - } - - if let validLayout = self.validLayout { - self.view.clipsToBounds = true - - // TODO: pass to component properly - deviceCornerRadius = validLayout.deviceMetrics.screenCornerRadius - 1// 0.5 -// self.view.layer.cornerRadius = validLayout.deviceMetrics.screenCornerRadius -// if #available(iOS 13.0, *) { -// self.view.layer.cornerCurve = .continuous -// } - - self.view.layer.animatePosition(from: CGPoint(x: self.view.frame.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), to: self.view.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in // [weak self] _ in -// self?.view.layer.cornerRadius = 0.0 - }) -// self.view.layer.animateScale(from: 0.001, to: 1.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) - } - - self.view.layer.allowsGroupOpacity = true - self.view.layer.animateAlpha(from: 1.0, to: 1.0, duration: 0.2, completion: { [weak self] _ in - guard let strongSelf = self else { - return - } - strongSelf.view.layer.allowsGroupOpacity = false - }) - self.backgroundDimView.layer.animateAlpha(from: 0, to: 1, duration: 0.3) - if backgroundDimView.superview == nil { - guard let superview = view.superview else { return } - superview.insertSubview(backgroundDimView, belowSubview: view) - } - // self.view.backgroundColor = .cyan - } - - override public func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - DispatchQueue.main.async { - self.onViewDidDisappear?() - } - -// if let initialOrientation = self.initialOrientation { -// self.initialOrientation = nil -// self.call.accountContext.sharedContext.applicationBindings.forceOrientation(initialOrientation) -// } - } - - override public func viewDidLoad() { - super.viewDidLoad() -// view.insertSubview(backgroundDimView, at: 0) - // TODO: replace with actual color - backgroundDimView.backgroundColor = .black.withAlphaComponent(0.3) - self.view.clipsToBounds = false - } - - override public func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - } - - override public func viewDidLayoutSubviews() { - super.viewDidLayoutSubviews() - backgroundDimView.frame = .init(x: 0, y: -view.bounds.height * 3, width: view.bounds.width, height: view.bounds.height * 4) - } - - public func dismiss(closing: Bool, manual: Bool) { - self.dismiss(completion: nil) - } - - let backgroundDimView = UIView() - - override public func dismiss(completion: (() -> Void)? = nil) { - self.view.layer.allowsGroupOpacity = true - self.view.layer.animateAlpha(from: 1.0, to: 1.0, duration: 0.25, removeOnCompletion: false, completion: { [weak self] _ in - guard let strongSelf = self else { - completion?() - return - } - strongSelf.view.layer.allowsGroupOpacity = false - strongSelf.dismissImpl(completion: completion) - }) - self.backgroundDimView.layer.animateAlpha(from: 1.0, to: 0, duration: 0.3, removeOnCompletion: false) - // if let validLayout = self.validLayout { - // self.view.clipsToBounds = true - // self.view.layer.cornerRadius = validLayout.deviceMetrics.screenCornerRadius - // if #available(iOS 13.0, *) { - // self.view.layer.cornerCurve = .continuous - // } - - self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 0.4, /*timingFunction: kCAMediaTimingFunctionSpring, */completion: { _ in - }) - // self.view.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) - // } - } - - private func dismissImpl(completion: (() -> Void)? = nil) { - super.dismiss(completion: completion) - } - - func updateOrientation(orientation: UIInterfaceOrientation) { - if self.initialOrientation == nil { - self.initialOrientation = orientation == .portrait ? .landscapeRight : .portrait - } else if self.initialOrientation == orientation { - self.initialOrientation = nil - } - self.call.accountContext.sharedContext.applicationBindings.forceOrientation(orientation) - } - - func presentShare() { - let _ = (self.inviteLinksPromise.get() - |> take(1) - |> deliverOnMainQueue).start(next: { [weak self] inviteLinks in - guard let strongSelf = self else { - return - } - - let _ = (strongSelf.context.engine.data.get( - TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.call.peerId), - TelegramEngine.EngineData.Item.Peer.ExportedInvitation(id: strongSelf.call.peerId) - ) - |> map { peer, exportedInvitation -> GroupCallInviteLinks? in - if let inviteLinks = inviteLinks { - return inviteLinks - } else if let peer = peer, let addressName = peer.addressName, !addressName.isEmpty { - return GroupCallInviteLinks(listenerLink: "https://t.me/\(addressName)?voicechat", speakerLink: nil) - } else if let link = exportedInvitation?.link { - return GroupCallInviteLinks(listenerLink: link, speakerLink: nil) - } - return nil - } - |> deliverOnMainQueue).start(next: { links in - guard let strongSelf = self else { - return - } - - if let links = links { - strongSelf.presentShare(links: links) - } - }) - }) - } - - func presentShare(links inviteLinks: GroupCallInviteLinks) { - let formatSendTitle: (String) -> String = { string in - var string = string - if string.contains("[") && string.contains("]") { - if let startIndex = string.firstIndex(of: "["), let endIndex = string.firstIndex(of: "]") { - string.removeSubrange(startIndex ... endIndex) - } - } else { - string = string.trimmingCharacters(in: CharacterSet(charactersIn: "0123456789-,.")) - } - return string - } - let _ = formatSendTitle - - let _ = (combineLatest(queue: .mainQueue(), self.context.account.postbox.loadedPeerWithId(self.call.peerId), self.call.state |> take(1)) - |> deliverOnMainQueue).start(next: { [weak self] peer, callState in - if let strongSelf = self { - var inviteLinks = inviteLinks - - if let peer = peer as? TelegramChannel, case .group = peer.info, !peer.flags.contains(.isGigagroup), !(peer.addressName ?? "").isEmpty, let defaultParticipantMuteState = callState.defaultParticipantMuteState { - let isMuted = defaultParticipantMuteState == .muted - - if !isMuted { - inviteLinks = GroupCallInviteLinks(listenerLink: inviteLinks.listenerLink, speakerLink: nil) - } - } - - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - - var segmentedValues: [ShareControllerSegmentedValue]? - segmentedValues = nil - let shareController = ShareController(context: strongSelf.context, subject: .url(inviteLinks.listenerLink), segmentedValues: segmentedValues, forceTheme: defaultDarkPresentationTheme, forcedActionTitle: presentationData.strings.VoiceChat_CopyInviteLink) - shareController.completed = { [weak self] peerIds in - if let strongSelf = self { - let _ = (strongSelf.context.engine.data.get( - EngineDataList( - peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init) - ) - ) - |> deliverOnMainQueue).start(next: { [weak self] peerList in - if let strongSelf = self { - let peers = peerList.compactMap { $0 } - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - - let text: String - var isSavedMessages = false - if peers.count == 1, let peer = peers.first { - isSavedMessages = peer.id == strongSelf.context.account.peerId - let peerName = peer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - text = presentationData.strings.VoiceChat_ForwardTooltip_Chat(peerName).string - } else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last { - let firstPeerName = firstPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - let secondPeerName = secondPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - text = presentationData.strings.VoiceChat_ForwardTooltip_TwoChats(firstPeerName, secondPeerName).string - } else if let peer = peers.first { - let peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - text = presentationData.strings.VoiceChat_ForwardTooltip_ManyChats(peerName, "\(peers.count - 1)").string - } else { - text = "" - } - - strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: isSavedMessages, text: text), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) - } - }) - } - } - shareController.actionCompleted = { - if let strongSelf = self { - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - strongSelf.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: presentationData.strings.VoiceChat_InviteLinkCopiedText), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) - } - } - strongSelf.present(shareController, in: .window(.root)) - } - }) - } -} - -public typealias MediaStreamComponent = _MediaStreamComponent -public typealias MediaStreamComponentController = _MediaStreamComponentController - public final class Throttler { public var duration: TimeInterval = 0.25 public var queue: DispatchQueue = .main diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 458c7ed2b1..1b51a10400 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -1,7 +1,6 @@ import Foundation import UIKit import ComponentFlow -import ActivityIndicatorComponent import AccountContext import AVKit import MultilineTextComponent @@ -13,59 +12,24 @@ import SwiftSignalKit import AvatarNode import Postbox -typealias MediaStreamVideoComponent = _MediaStreamVideoComponent - class CustomIntensityVisualEffectView: UIVisualEffectView { init(effect: UIVisualEffect, intensity: CGFloat) { super.init(effect: nil) animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [unowned self] in self.effect = effect } - self.animator?.startAnimation() - self.animator?.pauseAnimation() + animator.startAnimation() + animator.pauseAnimation() animator.fractionComplete = intensity animator.pausesOnCompletion = true -// subviews.forEach { -// if $0.backgroundColor != nil { -// $0.backgroundColor = $0.backgroundColor?.withAlphaComponent(0.5) -// } -// } } - override func didMoveToSuperview() { - super.didMoveToSuperview() -// let effect = self.effect -// self.effect = nil -// animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [unowned self] in self.effect = effect } -// animator.fractionComplete = 0.1// intensity -// animator.pausesOnCompletion = true - } - required init?(coder aDecoder: NSCoder) { fatalError() } var animator: UIViewPropertyAnimator! - -// private var displayLink: CADisplayLink? -// -// func setIntensity(_ intensity: CGFloat, animated: Bool) { -// self.displayLink?.invalidate() -// let displaylink = CADisplayLink( -// target: self, -// selector: #selector(displayLinkStep) -// ) -// self.displayLink = displaylink -// displaylink.add( -// to: .current, -// forMode: RunLoop.Mode.default -// ) -// } -// -// @objc func displayLinkStep(_:) { -// -// } } -final class _MediaStreamVideoComponent: Component { +final class MediaStreamVideoComponent: Component { let call: PresentationGroupCallImpl let hasVideo: Bool let isVisible: Bool @@ -75,7 +39,6 @@ final class _MediaStreamVideoComponent: Component { let deactivatePictureInPicture: ActionSlot let bringBackControllerForPictureInPictureDeactivation: (@escaping () -> Void) -> Void let pictureInPictureClosed: () -> Void - let peerImage: Any? let isFullscreen: Bool let onVideoSizeRetrieved: (CGSize) -> Void let videoLoading: Bool @@ -88,7 +51,6 @@ final class _MediaStreamVideoComponent: Component { isVisible: Bool, isAdmin: Bool, peerTitle: String, - peerImage: Any?, isFullscreen: Bool, videoLoading: Bool, callPeer: Peer?, @@ -112,12 +74,11 @@ final class _MediaStreamVideoComponent: Component { self.onVideoPlaybackLiveChange = onVideoPlaybackLiveChange self.callPeer = callPeer - self.peerImage = peerImage self.isFullscreen = isFullscreen self.onVideoSizeRetrieved = onVideoSizeRetrieved } - public static func ==(lhs: _MediaStreamVideoComponent, rhs: _MediaStreamVideoComponent) -> Bool { + public static func ==(lhs: MediaStreamVideoComponent, rhs: MediaStreamVideoComponent) -> Bool { if lhs.call !== rhs.call { return false } @@ -160,7 +121,6 @@ final class _MediaStreamVideoComponent: Component { private let blurTintView: UIView private var videoBlurView: VideoRenderingView? private var videoView: VideoRenderingView? - private var activityIndicatorView: ComponentHostView? private var loadingView: ComponentHostView? private var videoPlaceholderView: UIView? @@ -169,7 +129,7 @@ final class _MediaStreamVideoComponent: Component { private let shimmerOverlayView = CALayer() private var pictureInPictureController: AVPictureInPictureController? - private var component: _MediaStreamVideoComponent? + private var component: MediaStreamVideoComponent? private var hadVideo: Bool = false private var requestedExpansion: Bool = false @@ -209,9 +169,7 @@ final class _MediaStreamVideoComponent: Component { } let maskGradientLayer = CAGradientLayer() private var wasVisible = true - var shimmer = StandaloneShimmerEffect() var borderShimmer = StandaloneShimmerEffect() - let shimmerOverlayLayer = CALayer() let shimmerBorderLayer = CALayer() let placeholderView = UIImageView() @@ -230,24 +188,12 @@ final class _MediaStreamVideoComponent: Component { private func updateVideoStalled(isStalled: Bool) { if isStalled { guard let component = self.component else { return } -// let effect = UIBlurEffect(style: .light) -// let intensity: CGFloat = 0.4 -// self.loadingBlurView.effect = nil -// self.loadingBlurView.animator.stopAnimation(true) -// self.loadingBlurView.animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [unowned loadingBlurView] in loadingBlurView.effect = effect } -// self.loadingBlurView.animator.fractionComplete = intensity -// self.loadingBlurView.animator.fractionComplete = 0.4 -// self.loadingBlurView.effect = UIBlurEffect(style: .light) + if let frameView = lastFrame[component.call.peerId.id.description] { frameView.removeFromSuperview() placeholderView.subviews.forEach { $0.removeFromSuperview() } placeholderView.addSubview(frameView) frameView.frame = placeholderView.bounds - // placeholderView.backgroundColor = .green - } else { -// placeholderView.addSubview(avatarPlaceholderView) - // placeholderView.subviews.forEach { $0.removeFromSuperview() } - // placeholderView.backgroundColor = .red } if !hadVideo && placeholderView.superview == nil { @@ -273,19 +219,11 @@ final class _MediaStreamVideoComponent: Component { loadingBlurView.contentView.layer.addSublayer(shimmerBorderLayer) } loadingBlurView.clipsToBounds = true -// if shimmerOverlayLayer.mask == nil { -// shimmer = .init() -// shimmer.layer = shimmerOverlayLayer -// shimmerOverlayView.compositingFilter = "softLightBlendMode" -// shimmer.testUpdate(background: .clear, foreground: .white.withAlphaComponent(0.4)) -// } -// loadingBlurView.layer.cornerRadius = 10 let cornerRadius = loadingBlurView.layer.cornerRadius -// shimmerOverlayLayer.opacity = 0.6 - shimmerBorderLayer.cornerRadius = cornerRadius // TODO: check isFullScreeen + shimmerBorderLayer.cornerRadius = cornerRadius shimmerBorderLayer.masksToBounds = true - shimmerBorderLayer.compositingFilter = "overlayBlendMode"// "softLightBlendMode" + shimmerBorderLayer.compositingFilter = "softLightBlendMode" shimmerBorderLayer.frame = loadingBlurView.bounds let borderMask = CAShapeLayer() borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) @@ -293,27 +231,13 @@ final class _MediaStreamVideoComponent: Component { borderMask.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor borderMask.lineWidth = 3 shimmerBorderLayer.mask = borderMask -// borderMask.frame = shimmerBorderLayer.bounds -// let testBorder = CAShapeLayer() -// testBorder.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: 10, cornerHeight: 10, transform: nil) -// testBorder.fillColor = UIColor.white.withAlphaComponent(0.2).cgColor -// testBorder.strokeColor = UIColor.white.cgColor -// testBorder.lineWidth = 4 -// testBorder.frame = shimmerBorderLayer.bounds -// let borderMask = CALayer() -// shimmerBorderLayer.removeAllAnimations() - // if shimmerBorderLayer.mask == nil { borderShimmer = .init() borderShimmer.layer = shimmerBorderLayer -// shimmerBorderLayer.backgroundColor = UIColor.clear.cgColor - // shimmerBorderLayer.backgroundColor = UIColor.green.withAlphaComponent(0.4).cgColor borderShimmer.testUpdate(background: .clear, foreground: .white) - // } loadingBlurView.alpha = 1 } else { if hadVideo { - loadingBlurView.layer.removeAllAnimations() let anim = CABasicAnimation(keyPath: "opacity") anim.duration = 0.5 anim.fromValue = 1 @@ -324,6 +248,7 @@ final class _MediaStreamVideoComponent: Component { guard self?.videoStalled == false else { return } self?.loadingBlurView.removeFromSuperview() self?.placeholderView.removeFromSuperview() + self?.loadingBlurView.layer.removeAllAnimations() } loadingBlurView.layer.add(anim, forKey: "opacity") } else { @@ -376,7 +301,7 @@ final class _MediaStreamVideoComponent: Component { frameInputDisposable?.dispose() } - func update(component: _MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { + func update(component: MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { self.state = state // placeholderView.alpha = 0.7 // placeholderView.image = lastFrame[component.call.peerId.id.description] @@ -422,25 +347,12 @@ final class _MediaStreamVideoComponent: Component { // TODO: use mapToThrottled (?) frameInputDisposable = input.start(next: { [weak self] input in guard let strongSelf = self else { return } -// print("input") - // strongSelf.stallTimer?.invalidate() - // TODO: optimize with throttle + strongSelf.timeLastFrameReceived = CFAbsoluteTimeGetCurrent() -// DispatchQueue.main.async { - // strongSelf.stallTimer = _stallTimer - // DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - // print(strongSelf.videoStalled) - // if strongSelf.videoStalled { - // strongSelf.stallTimer?.fire() - // } - // RunLoop.main.add(strongSelf.stallTimer!, forMode: .common) strongSelf.videoLoadingThrottler.publish(false, includingLatest: true) { isStalled in strongSelf.videoStalled = isStalled strongSelf.onVideoPlaybackChange(!isStalled) } -// strongSelf.videoStalled = false -// strongSelf.onVideoPlaybackChange(true) -// } }) stallTimer = _stallTimer // RunLoop.main.add(stallTimer!, forMode: .common) @@ -542,9 +454,6 @@ final class _MediaStreamVideoComponent: Component { strongSelf.hadVideo = true - strongSelf.activityIndicatorView?.removeFromSuperview() - strongSelf.activityIndicatorView = nil - strongSelf.noSignalTimer?.invalidate() strongSelf.noSignalTimer = nil strongSelf.noSignalTimeout = false @@ -582,11 +491,10 @@ final class _MediaStreamVideoComponent: Component { let videoSize: CGSize let videoCornerRadius: CGFloat = component.isFullscreen ? 0 : 10 if let videoView = self.videoView { - // TODO: REMOVE FROM HERE and move to call end (or at least to background) -// if let presentation = videoView.snapshotView(afterScreenUpdates: false) { if videoView.bounds.size.width > 0, videoView.alpha > 0, self.hadVideo, + // TODO: remove from here and move to call end (or at least to background) let snapshot = videoView.snapshotView(afterScreenUpdates: false) ?? videoView.snapshotView(afterScreenUpdates: true) { lastFrame[component.call.peerId.id.description] = snapshot// ()! } @@ -632,14 +540,6 @@ final class _MediaStreamVideoComponent: Component { videoView.updateIsEnabled(isVideoVisible) videoView.clipsToBounds = true videoView.layer.cornerRadius = videoCornerRadius - // var aspect = videoView.getAspect() -// if aspect <= 0.01 { - // TODO: remove debug -// if component.videoLoading { -// videoView.alpha = 0.5 -// } else { -// videoView.alpha = 1 -// } transition.withAnimation(.none).setFrame(view: videoView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize), completion: nil) @@ -685,31 +585,6 @@ final class _MediaStreamVideoComponent: Component { // loadingBlurView.removeFromSuperview() } if !self.hadVideo { - // TODO: hide fullscreen button without video - let aspect: CGFloat = 16.0 / 9 - let videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) - // loadingpreview.frame = .init(, videoSize) - print(videoSize) - // TODO: remove activity indicator - var activityIndicatorTransition = transition - let activityIndicatorView: ComponentHostView - if let current = self.activityIndicatorView { - activityIndicatorView = current - } else { - activityIndicatorTransition = transition.withAnimation(.none) - activityIndicatorView = ComponentHostView() - self.activityIndicatorView = activityIndicatorView -// self.addSubview(activityIndicatorView) - } - - let activityIndicatorSize = activityIndicatorView.update( - transition: transition, - component: AnyComponent(ActivityIndicatorComponent(color: .white)), - environment: {}, - containerSize: CGSize(width: 100.0, height: 100.0) - ) - let activityIndicatorFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - activityIndicatorSize.width) / 2.0), y: floor((availableSize.height - activityIndicatorSize.height) / 2.0)), size: activityIndicatorSize) - activityIndicatorTransition.setFrame(view: activityIndicatorView, frame: activityIndicatorFrame, completion: nil) if self.noSignalTimer == nil { if #available(iOS 10.0, *) { @@ -750,7 +625,7 @@ final class _MediaStreamVideoComponent: Component { environment: {}, containerSize: CGSize(width: availableSize.width - 16.0 * 2.0, height: 1000.0) ) - noSignalTransition.setFrame(view: noSignalView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - noSignalSize.width) / 2.0), y: (availableSize.height - noSignalSize.height) / 2.0/*activityIndicatorFrame.maxY + 24.0*/), size: noSignalSize), completion: nil) + noSignalTransition.setFrame(view: noSignalView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - noSignalSize.width) / 2.0), y: (availableSize.height - noSignalSize.height) / 2.0), size: noSignalSize), completion: nil) } } @@ -825,7 +700,7 @@ final class _MediaStreamVideoComponent: Component { } else { self.component?.pictureInPictureClosed() } - // TODO: extract precise animation or observe window changes + // TODO: extract precise animation timing or observe window changes DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self.videoView?.alpha = 1 } @@ -850,23 +725,4 @@ final class _MediaStreamVideoComponent: Component { } // TODO: move to appropriate place -var lastFrame: [String: UIView] = [:] - -extension UIView { - func snapshot() -> UIImage? { - UIGraphicsBeginImageContextWithOptions(bounds.size, true, UIScreen.main.scale) - - guard let currentContext = UIGraphicsGetCurrentContext() else { - UIGraphicsEndImageContext() - return nil - } - - layer.render(in: currentContext) - - let image = UIGraphicsGetImageFromCurrentImageContext() - - UIGraphicsEndImageContext() - - return image - } -} +fileprivate var lastFrame: [String: UIView] = [:] diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index d15603a52e..486847bc73 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -8,10 +8,7 @@ import MultilineTextComponent import Display final class StreamSheetComponent: CombinedComponent { -// let color: UIColor -// let leftItem: AnyComponent? let topComponent: AnyComponent? -// let viewerCounter: AnyComponent? let bottomButtonsRow: AnyComponent? // TODO: sync let sheetHeight: CGFloat @@ -24,7 +21,6 @@ final class StreamSheetComponent: CombinedComponent { let videoHeight: CGFloat init( -// color: UIColor, topComponent: AnyComponent, bottomButtonsRow: AnyComponent, topOffset: CGFloat, @@ -36,9 +32,7 @@ final class StreamSheetComponent: CombinedComponent { deviceCornerRadius: CGFloat, videoHeight: CGFloat ) { -// self.leftItem = leftItem self.topComponent = topComponent -// self.viewerCounter = AnyComponent(ViewerCountComponent(count: 0)) self.bottomButtonsRow = bottomButtonsRow self.topOffset = topOffset self.sheetHeight = sheetHeight @@ -83,7 +77,7 @@ final class StreamSheetComponent: CombinedComponent { } return true } -// + final class View: UIView { var overlayComponentsFrames = [CGRect]() @@ -95,7 +89,6 @@ final class StreamSheetComponent: CombinedComponent { } func update(component: StreamSheetComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { -// self.backgroundColor = .purple.withAlphaComponent(0.6) return availableSize } diff --git a/submodules/TelegramCallsUI/Sources/MediaStreamingController.swift b/submodules/TelegramCallsUI/Sources/MediaStreamingController.swift deleted file mode 100644 index eb8caf9148..0000000000 --- a/submodules/TelegramCallsUI/Sources/MediaStreamingController.swift +++ /dev/null @@ -1,7118 +0,0 @@ -import Foundation -import UIKit -import Display -import AsyncDisplayKit -import SwiftSignalKit -import TelegramPresentationData -import TelegramUIPreferences -import TelegramStringFormatting -import TelegramVoip -import TelegramAudio -import AccountContext -import Postbox -import TelegramCore -import MergeLists -import ItemListUI -import AppBundle -import ContextUI -import ShareController -import DeleteChatPeerActionSheetItem -import UndoUI -import AlertUI -import PresentationDataUtils -import DirectionalPanGesture -import PeerInfoUI -import AvatarNode -import TooltipUI -import LegacyUI -import LegacyComponents -import LegacyMediaPickerUI -import WebSearchUI -import MapResourceToAvatarSizes -import SolidRoundedButtonNode -import AudioBlob -import DeviceAccess - -//let panelBackgroundColor = UIColor(rgb: 0x1c1c1e) -//let secondaryPanelBackgroundColor = UIColor(rgb: 0x2c2c2e) -//let fullscreenBackgroundColor = UIColor(rgb: 0x000000) -//private let smallButtonSize = CGSize(width: 36.0, height: 36.0) -//private let sideButtonSize = CGSize(width: 56.0, height: 56.0) -//private let topPanelHeight: CGFloat = 63.0 -//let bottomAreaHeight: CGFloat = 206.0 -//private let fullscreenBottomAreaHeight: CGFloat = 80.0 -//private let bottomGradientHeight: CGFloat = 70.0 - -/*func decorationCornersImage(top: Bool, bottom: Bool, dark: Bool) -> UIImage? { - if !top && !bottom { - return nil - } - return generateImage(CGSize(width: 50.0, height: 50.0), rotatedContext: { (size, context) in - let bounds = CGRect(origin: CGPoint(), size: size) - context.setFillColor((dark ? fullscreenBackgroundColor : panelBackgroundColor).cgColor) - context.fill(bounds) - - context.setBlendMode(.clear) - - var corners: UIRectCorner = [] - if top { - corners.insert(.topLeft) - corners.insert(.topRight) - } - if bottom { - corners.insert(.bottomLeft) - corners.insert(.bottomRight) - } - let path = UIBezierPath(roundedRect: bounds, byRoundingCorners: corners, cornerRadii: CGSize(width: 11.0, height: 11.0)) - context.addPath(path.cgPath) - context.fillPath() - })?.stretchableImage(withLeftCapWidth: 25, topCapHeight: 25) -} - -func decorationTopCornersImage(dark: Bool) -> UIImage? { - return generateImage(CGSize(width: 50.0, height: 110.0), rotatedContext: { (size, context) in - let bounds = CGRect(origin: CGPoint(), size: size) - context.setFillColor((dark ? fullscreenBackgroundColor : panelBackgroundColor).cgColor) - context.fill(bounds) - - context.setBlendMode(.clear) - - var corners: UIRectCorner = [] - corners.insert(.topLeft) - corners.insert(.topRight) - - let path = UIBezierPath(roundedRect: CGRect(x: 0.0, y: 60.0, width: 50.0, height: 50.0), byRoundingCorners: corners, cornerRadii: CGSize(width: 11.0, height: 11.0)) - context.addPath(path.cgPath) - context.fillPath() - })?.stretchableImage(withLeftCapWidth: 25, topCapHeight: 32) -} - -func decorationBottomCornersImage(dark: Bool) -> UIImage? { - return generateImage(CGSize(width: 50.0, height: 110.0), rotatedContext: { (size, context) in - let bounds = CGRect(origin: CGPoint(), size: size) - context.setFillColor((dark ? fullscreenBackgroundColor : panelBackgroundColor).cgColor) - context.fill(bounds) - - context.setBlendMode(.clear) - - var corners: UIRectCorner = [] - corners.insert(.bottomLeft) - corners.insert(.bottomRight) - - let path = UIBezierPath(roundedRect: CGRect(x: 0.0, y: 0.0, width: 50.0, height: 50.0), byRoundingCorners: corners, cornerRadii: CGSize(width: 11.0, height: 11.0)) - context.addPath(path.cgPath) - context.fillPath() - })?.resizableImage(withCapInsets: UIEdgeInsets(top: 25.0, left: 25.0, bottom: 0.0, right: 25.0), resizingMode: .stretch) -} -*/ -private func decorationBottomGradientImage(dark: Bool) -> UIImage? { - return generateImage(CGSize(width: 24.0, height: bottomGradientHeight), rotatedContext: { size, context in - let bounds = CGRect(origin: CGPoint(), size: size) - context.clear(bounds) - - let color = dark ? fullscreenBackgroundColor : panelBackgroundColor - let colorsArray = [color.withAlphaComponent(0.0).cgColor, color.cgColor] as CFArray - var locations: [CGFloat] = [1.0, 0.0] - let gradient = CGGradient(colorsSpace: deviceColorSpace, colors: colorsArray, locations: &locations)! - context.drawLinearGradient(gradient, start: CGPoint(), end: CGPoint(x: 0.0, y: size.height), options: CGGradientDrawingOptions()) - }) -} -/* -struct VoiceChatPeerEntry: Identifiable { - enum State { - case listening - case speaking - case invited - case raisedHand - } - - var peer: Peer - var about: String? - var isMyPeer: Bool - var videoEndpointId: String? - var videoPaused: Bool - var presentationEndpointId: String? - var presentationPaused: Bool - var effectiveSpeakerVideoEndpointId: String? - var state: State - var muteState: GroupCallParticipantsContext.Participant.MuteState? - var canManageCall: Bool - var volume: Int32? - var raisedHand: Bool - var displayRaisedHandStatus: Bool - var active: Bool - var isLandscape: Bool - - var effectiveVideoEndpointId: String? { - return self.presentationEndpointId ?? self.videoEndpointId - } - - init( - peer: Peer, - about: String?, - isMyPeer: Bool, - videoEndpointId: String?, - videoPaused: Bool, - presentationEndpointId: String?, - presentationPaused: Bool, - effectiveSpeakerVideoEndpointId: String?, - state: State, - muteState: GroupCallParticipantsContext.Participant.MuteState?, - canManageCall: Bool, - volume: Int32?, - raisedHand: Bool, - displayRaisedHandStatus: Bool, - active: Bool, - isLandscape: Bool - ) { - self.peer = peer - self.about = about - self.isMyPeer = isMyPeer - self.videoEndpointId = videoEndpointId - self.videoPaused = videoPaused - self.presentationEndpointId = presentationEndpointId - self.presentationPaused = presentationPaused - self.effectiveSpeakerVideoEndpointId = effectiveSpeakerVideoEndpointId - self.state = state - self.muteState = muteState - self.canManageCall = canManageCall - self.volume = volume - self.raisedHand = raisedHand - self.displayRaisedHandStatus = displayRaisedHandStatus - self.active = active - self.isLandscape = isLandscape - } - - var stableId: PeerId { - return self.peer.id - } - - static func ==(lhs: VoiceChatPeerEntry, rhs: VoiceChatPeerEntry) -> Bool { - if !lhs.peer.isEqual(rhs.peer) { - return false - } - if lhs.about != rhs.about { - return false - } - if lhs.isMyPeer != rhs.isMyPeer { - return false - } - if lhs.videoEndpointId != rhs.videoEndpointId { - return false - } - if lhs.videoPaused != rhs.videoPaused { - return false - } - if lhs.presentationEndpointId != rhs.presentationEndpointId { - return false - } - if lhs.presentationPaused != rhs.presentationPaused { - return false - } - if lhs.effectiveSpeakerVideoEndpointId != rhs.effectiveSpeakerVideoEndpointId { - return false - } - if lhs.state != rhs.state { - return false - } - if lhs.muteState != rhs.muteState { - return false - } - if lhs.canManageCall != rhs.canManageCall { - return false - } - if lhs.volume != rhs.volume { - return false - } - if lhs.raisedHand != rhs.raisedHand { - return false - } - if lhs.displayRaisedHandStatus != rhs.displayRaisedHandStatus { - return false - } - if lhs.active != rhs.active { - return false - } - if lhs.isLandscape != rhs.isLandscape { - return false - } - return true - } -} -*/ - -//public protocol VoiceChatController: ViewController { -// var call: PresentationGroupCall { get } -// var currentOverlayController: VoiceChatOverlayController? { get } -// var parentNavigationController: NavigationController? { get set } -// -// func dismiss(closing: Bool, manual: Bool) -//} - -public final class MediaStreamingControllerImpl: ViewController, VoiceChatController { - enum DisplayMode { - case modal(isExpanded: Bool, isFilled: Bool) - case fullscreen(controlsHidden: Bool) - } - - fileprivate final class Node: ViewControllerTracingNode, UIGestureRecognizerDelegate { - private struct ListTransition { - let deletions: [ListViewDeleteItem] - let insertions: [ListViewInsertItem] - let updates: [ListViewUpdateItem] - let isLoading: Bool - let isEmpty: Bool - let canInvite: Bool - let crossFade: Bool - let count: Int - let animated: Bool - } - - private final class Interaction { - let updateIsMuted: (PeerId, Bool) -> Void - let switchToPeer: (PeerId, String?, Bool) -> Void - let openInvite: () -> Void - let peerContextAction: (VoiceChatPeerEntry, ASDisplayNode, ContextGesture?, Bool) -> Void - let getPeerVideo: (String, GroupVideoNode.Position) -> GroupVideoNode? - var isExpanded: Bool = false - - private var audioLevels: [PeerId: ValuePipe] = [:] - - var updateAvatarPromise = Promise<(TelegramMediaImageRepresentation, Float)?>(nil) - - init( - updateIsMuted: @escaping (PeerId, Bool) -> Void, - switchToPeer: @escaping (PeerId, String?, Bool) -> Void, - openInvite: @escaping () -> Void, - peerContextAction: @escaping (VoiceChatPeerEntry, ASDisplayNode, ContextGesture?, Bool) -> Void, - getPeerVideo: @escaping (String, GroupVideoNode.Position) -> GroupVideoNode? - ) { - self.updateIsMuted = updateIsMuted - self.switchToPeer = switchToPeer - self.openInvite = openInvite - self.peerContextAction = peerContextAction - self.getPeerVideo = getPeerVideo - } - - func getAudioLevel(_ peerId: PeerId) -> Signal { - let signal: Signal - if let current = self.audioLevels[peerId] { - signal = current.signal() - } else { - let value = ValuePipe() - self.audioLevels[peerId] = value - signal = value.signal() - } - return signal - |> mapToSignal { value in - return .single(value) - } - } - - func updateAudioLevels(_ levels: [(PeerId, UInt32, Float, Bool)], reset: Bool = false) { - var updated = Set() - for (peerId, _, level, _) in levels { - if let pipe = self.audioLevels[peerId] { - if reset { - pipe.putNext(level) - } else { - pipe.putNext(max(0.001, level)) - } - updated.insert(peerId) - } - } - if !reset { - for (peerId, pipe) in self.audioLevels { - if !updated.contains(peerId) { - pipe.putNext(0.0) - } - } - } - } - } - - private enum EntryId: Hashable { - case tiles - case invite - case peerId(PeerId) - - static func <(lhs: EntryId, rhs: EntryId) -> Bool { - return lhs.hashValue < rhs.hashValue - } - - static func ==(lhs: EntryId, rhs: EntryId) -> Bool { - switch lhs { - case .tiles: - switch rhs { - case .tiles: - return true - default: - return false - } - case .invite: - switch rhs { - case .invite: - return true - default: - return false - } - case let .peerId(lhsId): - switch rhs { - case let .peerId(rhsId): - return lhsId == rhsId - default: - return false - } - } - } - } - - private enum ListEntry: Comparable, Identifiable { - case tiles([VoiceChatTileItem], VoiceChatTileLayoutMode, Int32, Bool) - case invite(PresentationTheme, PresentationStrings, String, Bool) - case peer(VoiceChatPeerEntry, Int32) - - var stableId: EntryId { - switch self { - case .tiles: - return .tiles - case .invite: - return .invite - case let .peer(peerEntry, _): - return .peerId(peerEntry.peer.id) - } - } - - static func ==(lhs: ListEntry, rhs: ListEntry) -> Bool { - switch lhs { - case let .tiles(lhsTiles, lhsLayoutMode, lhsVideoLimit, lhsReachedLimit): - if case let .tiles(rhsTiles, rhsLayoutMode, rhsVideoLimit, rhsReachedLimit) = rhs, lhsTiles == rhsTiles, lhsLayoutMode == rhsLayoutMode, lhsVideoLimit == rhsVideoLimit, lhsReachedLimit == rhsReachedLimit { - return true - } else { - return false - } - case let .invite(lhsTheme, lhsStrings, lhsText, lhsIsLink): - if case let .invite(rhsTheme, rhsStrings, rhsText, rhsIsLink) = rhs, lhsTheme === rhsTheme, lhsStrings === rhsStrings, lhsText == rhsText, lhsIsLink == rhsIsLink { - return true - } else { - return false - } - case let .peer(lhsPeerEntry, lhsIndex): - switch rhs { - case let .peer(rhsPeerEntry, rhsIndex): - return lhsPeerEntry == rhsPeerEntry && lhsIndex == rhsIndex - default: - return false - } - } - } - - static func <(lhs: ListEntry, rhs: ListEntry) -> Bool { - switch lhs { - case .tiles: - return true - case .invite: - return false - case let .peer(_, lhsIndex): - switch rhs { - case .tiles: - return false - case let .peer(_, rhsIndex): - return lhsIndex < rhsIndex - case .invite: - return true - } - } - } - - func tileItem(context: AccountContext, presentationData: PresentationData, interaction: Interaction, isTablet: Bool, videoEndpointId: String, videoReady: Bool, videoTimeouted: Bool, videoIsPaused: Bool, showAsPresentation: Bool, secondary: Bool) -> VoiceChatTileItem? { - guard case let .peer(peerEntry, _) = self else { - return nil - } - let peer = peerEntry.peer - - let icon: VoiceChatTileItem.Icon - var text: VoiceChatParticipantItem.ParticipantText - var additionalText: VoiceChatParticipantItem.ParticipantText? - var speaking = false - - var textIcon = VoiceChatParticipantItem.ParticipantText.TextIcon() - let yourText: String - if (peerEntry.about?.isEmpty ?? true) && peer.smallProfileImage == nil { - yourText = presentationData.strings.VoiceChat_TapToAddPhotoOrBio - } else if peer.smallProfileImage == nil { - yourText = presentationData.strings.VoiceChat_TapToAddPhoto - } else if (peerEntry.about?.isEmpty ?? true) { - yourText = presentationData.strings.VoiceChat_TapToAddBio - } else { - yourText = presentationData.strings.VoiceChat_You - } - - var state = peerEntry.state - if let muteState = peerEntry.muteState, case .speaking = state, muteState.mutedByYou || !muteState.canUnmute { - state = .listening - } - switch state { - case .listening: - if peerEntry.isMyPeer { - text = .text(yourText, textIcon, .accent) - } else if let muteState = peerEntry.muteState, muteState.mutedByYou { - text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) - } else if let about = peerEntry.about, !about.isEmpty { - text = .text(about, textIcon, .generic) - } else { - text = .text(presentationData.strings.VoiceChat_StatusListening, textIcon, .generic) - } - if let muteState = peerEntry.muteState, muteState.mutedByYou { - icon = .microphone(true) - additionalText = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) - } else { - icon = .microphone(peerEntry.muteState != nil) - } - case .speaking: - if let muteState = peerEntry.muteState, muteState.mutedByYou { - text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) - icon = .microphone(true) - additionalText = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) - } else { - if peerEntry.volume != nil { - textIcon.insert(.volume) - } - let volumeValue = peerEntry.volume.flatMap { $0 / 100 } - if let volume = volumeValue, volume != 100 { - text = .text( presentationData.strings.VoiceChat_StatusSpeakingVolume("\(volume)%").string, textIcon, .constructive) - } else { - text = .text(presentationData.strings.VoiceChat_StatusSpeaking, textIcon, .constructive) - } - icon = .microphone(false) - speaking = true - } - case .raisedHand, .invited: - text = .none - icon = .none - } - - if let about = peerEntry.about, !about.isEmpty { - textIcon = [] - text = .text(about, textIcon, .generic) - } - - return VoiceChatTileItem(account: context.account, peer: peerEntry.peer, videoEndpointId: videoEndpointId, videoReady: videoReady, videoTimeouted: videoTimeouted, isVideoLimit: false, videoLimit: 0, isPaused: videoIsPaused, isOwnScreencast: peerEntry.presentationEndpointId == videoEndpointId && peerEntry.isMyPeer, strings: presentationData.strings, nameDisplayOrder: presentationData.nameDisplayOrder, speaking: speaking, secondary: secondary, isTablet: isTablet, icon: showAsPresentation ? .presentation : icon, text: text, additionalText: additionalText, action: { - interaction.switchToPeer(peer.id, videoEndpointId, !secondary) - }, contextAction: { node, gesture in - interaction.peerContextAction(peerEntry, node, gesture, false) - }, getVideo: { position in - return interaction.getPeerVideo(videoEndpointId, position) - }, getAudioLevel: { - return interaction.getAudioLevel(peerEntry.peer.id) - }) - } - - func fullscreenItem(context: AccountContext, presentationData: PresentationData, interaction: Interaction) -> ListViewItem { - switch self { - case .tiles: - return VoiceChatActionItem(presentationData: ItemListPresentationData(presentationData), title: "", icon: .none, action: { - }) - case .invite: - return VoiceChatActionItem(presentationData: ItemListPresentationData(presentationData), title: "", icon: .generic(UIImage(bundleImageName: "Chat/Context Menu/AddUser")!), action: { - interaction.openInvite() - }) - case let .peer(peerEntry, _): - let peer = peerEntry.peer - var textColor: VoiceChatFullscreenParticipantItem.Color = .generic - var color: VoiceChatFullscreenParticipantItem.Color = .generic - let icon: VoiceChatFullscreenParticipantItem.Icon - var text: VoiceChatParticipantItem.ParticipantText - - var textIcon = VoiceChatParticipantItem.ParticipantText.TextIcon() - let yourText: String - if (peerEntry.about?.isEmpty ?? true) && peer.smallProfileImage == nil { - yourText = presentationData.strings.VoiceChat_TapToAddPhotoOrBio - } else if peer.smallProfileImage == nil { - yourText = presentationData.strings.VoiceChat_TapToAddPhoto - } else if (peerEntry.about?.isEmpty ?? true) { - yourText = presentationData.strings.VoiceChat_TapToAddBio - } else { - yourText = presentationData.strings.VoiceChat_You - } - - var state = peerEntry.state - if let muteState = peerEntry.muteState, case .speaking = state, muteState.mutedByYou || !muteState.canUnmute { - state = .listening - } - switch state { - case .listening: - if peerEntry.isMyPeer { - text = .text(yourText, textIcon, .accent) - } else if let muteState = peerEntry.muteState, muteState.mutedByYou { - text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) - } else if let about = peerEntry.about, !about.isEmpty { - text = .text(about, textIcon, .generic) - } else { - text = .text(presentationData.strings.VoiceChat_StatusListening, textIcon, .generic) - } - if let muteState = peerEntry.muteState, muteState.mutedByYou { - textColor = .destructive - color = .destructive - icon = .microphone(true, UIColor(rgb: 0xff3b30)) - } else { - icon = .microphone(peerEntry.muteState != nil, UIColor.white) - color = .accent - } - case .speaking: - if let muteState = peerEntry.muteState, muteState.mutedByYou { - text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) - textColor = .destructive - color = .destructive - icon = .microphone(true, UIColor(rgb: 0xff3b30)) - } else { - if peerEntry.volume != nil { - textIcon.insert(.volume) - } - let volumeValue = peerEntry.volume.flatMap { $0 / 100 } - if let volume = volumeValue, volume != 100 { - text = .text( presentationData.strings.VoiceChat_StatusSpeakingVolume("\(volume)%").string, textIcon, .constructive) - } else { - text = .text(presentationData.strings.VoiceChat_StatusSpeaking, textIcon, .constructive) - } - icon = .microphone(false, UIColor(rgb: 0x34c759)) - textColor = .constructive - color = .constructive - } - case .raisedHand: - text = .none - textColor = .accent - icon = .wantsToSpeak - case .invited: - text = .none - icon = .none - } - - if let about = peerEntry.about, !about.isEmpty { - textIcon = [] - text = .text(about, textIcon, .generic) - } - - var videoEndpointId = peerEntry.effectiveVideoEndpointId - var otherVideoEndpointId: String? - let hasBothVideos = peerEntry.presentationEndpointId != nil && peerEntry.videoEndpointId != nil - if hasBothVideos { - if let effectiveVideoEndpointId = peerEntry.effectiveSpeakerVideoEndpointId { - if effectiveVideoEndpointId == peerEntry.videoEndpointId { - videoEndpointId = peerEntry.presentationEndpointId - otherVideoEndpointId = videoEndpointId - } else if effectiveVideoEndpointId == peerEntry.presentationEndpointId { - videoEndpointId = peerEntry.videoEndpointId - otherVideoEndpointId = videoEndpointId - } - } - } - - var isPaused = false - if videoEndpointId == peerEntry.videoEndpointId { - isPaused = peerEntry.videoPaused - } else if videoEndpointId == peerEntry.presentationEndpointId { - isPaused = peerEntry.presentationPaused - } - - return VoiceChatFullscreenParticipantItem(presentationData: ItemListPresentationData(presentationData), nameDisplayOrder: presentationData.nameDisplayOrder, context: context, peer: peerEntry.peer, videoEndpointId: videoEndpointId, isPaused: isPaused, icon: icon, text: text, textColor: textColor, color: color, isLandscape: peerEntry.isLandscape, active: peerEntry.active, showVideoWhenActive: otherVideoEndpointId != nil, getAudioLevel: { return interaction.getAudioLevel(peerEntry.peer.id) }, getVideo: { - if let videoEndpointId = videoEndpointId { - return interaction.getPeerVideo(videoEndpointId, .list) - } else { - return nil - } - }, action: { _ in - interaction.switchToPeer(peerEntry.peer.id, otherVideoEndpointId, false) - }, contextAction: { node, gesture in - interaction.peerContextAction(peerEntry, node, gesture, true) - }, getUpdatingAvatar: { - return interaction.updateAvatarPromise.get() - }) - } - } - - func item(context: AccountContext, presentationData: PresentationData, interaction: Interaction) -> ListViewItem { - switch self { - case let .tiles(tiles, layoutMode, videoLimit, reachedLimit): - return VoiceChatTilesGridItem(context: context, tiles: tiles, layoutMode: layoutMode, videoLimit: videoLimit, reachedLimit: reachedLimit, getIsExpanded: { - return interaction.isExpanded - }) - case let .invite(_, _, text, isLink): - return VoiceChatActionItem(presentationData: ItemListPresentationData(presentationData), title: text, icon: .generic(UIImage(bundleImageName: isLink ? "Chat/Context Menu/Link" : "Chat/Context Menu/AddUser")!), action: { - interaction.openInvite() - }) - case let .peer(peerEntry, _): - let peer = peerEntry.peer - - var text: VoiceChatParticipantItem.ParticipantText - var expandedText: VoiceChatParticipantItem.ParticipantText? - let icon: VoiceChatParticipantItem.Icon - - var state = peerEntry.state - if let muteState = peerEntry.muteState, case .speaking = state, muteState.mutedByYou || !muteState.canUnmute { - state = .listening - } - - var textIcon = VoiceChatParticipantItem.ParticipantText.TextIcon() - let yourText: String - if (peerEntry.about?.isEmpty ?? true) && peer.smallProfileImage == nil { - yourText = presentationData.strings.VoiceChat_TapToAddPhotoOrBio - } else if peer.smallProfileImage == nil { - yourText = presentationData.strings.VoiceChat_TapToAddPhoto - } else if (peerEntry.about?.isEmpty ?? true) { - yourText = presentationData.strings.VoiceChat_TapToAddBio - } else { - yourText = presentationData.strings.VoiceChat_You - } - - switch state { - case .listening: - if peerEntry.isMyPeer { - text = .text(yourText, textIcon, .accent) - } else if let muteState = peerEntry.muteState, muteState.mutedByYou { - text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) - } else if let about = peerEntry.about, !about.isEmpty { - text = .text(about, textIcon, .generic) - } else { - text = .text(presentationData.strings.VoiceChat_StatusListening, textIcon, .generic) - } - let microphoneColor: UIColor - if let muteState = peerEntry.muteState, !muteState.canUnmute || muteState.mutedByYou { - microphoneColor = UIColor(rgb: 0xff3b30) - } else { - microphoneColor = UIColor(rgb: 0x979797) - } - icon = .microphone(peerEntry.muteState != nil, microphoneColor) - case .speaking: - if let muteState = peerEntry.muteState, muteState.mutedByYou { - text = .text(presentationData.strings.VoiceChat_StatusMutedForYou, textIcon, .destructive) - icon = .microphone(true, UIColor(rgb: 0xff3b30)) - } else { - if peerEntry.volume != nil { - textIcon.insert(.volume) - } - let volumeValue = peerEntry.volume.flatMap { $0 / 100 } - if let volume = volumeValue, volume != 100 { - text = .text( presentationData.strings.VoiceChat_StatusSpeakingVolume("\(volume)%").string, textIcon, .constructive) - } else { - text = .text(presentationData.strings.VoiceChat_StatusSpeaking, textIcon, .constructive) - } - icon = .microphone(false, UIColor(rgb: 0x34c759)) - } - case .invited: - text = .text(presentationData.strings.VoiceChat_StatusInvited, textIcon, .generic) - icon = .invite(true) - case .raisedHand: - if peerEntry.isMyPeer && !peerEntry.displayRaisedHandStatus { - text = .text(yourText, textIcon, .accent) - } else if let about = peerEntry.about, !about.isEmpty && !peerEntry.displayRaisedHandStatus { - text = .text(about, textIcon, .generic) - } else { - text = .text(presentationData.strings.VoiceChat_StatusWantsToSpeak, textIcon, .accent) - } - icon = .wantsToSpeak - } - - if let about = peerEntry.about, !about.isEmpty { - textIcon = [] - expandedText = .text(about, textIcon, .generic) - } - - return VoiceChatParticipantItem(presentationData: ItemListPresentationData(presentationData), dateTimeFormat: presentationData.dateTimeFormat, nameDisplayOrder: presentationData.nameDisplayOrder, context: context, peer: peer, text: text, expandedText: expandedText, icon: icon, getAudioLevel: { return interaction.getAudioLevel(peer.id) }, action: { node in - if let node = node { - interaction.peerContextAction(peerEntry, node, nil, false) - } - }, contextAction: { node, gesture in - interaction.peerContextAction(peerEntry, node, gesture, false) - }, getIsExpanded: { - return interaction.isExpanded - }, getUpdatingAvatar: { - return interaction.updateAvatarPromise.get() - }) - } - } - } - - private func preparedTransition(from fromEntries: [ListEntry], to toEntries: [ListEntry], isLoading: Bool, isEmpty: Bool, canInvite: Bool, crossFade: Bool, animated: Bool, context: AccountContext, presentationData: PresentationData, interaction: Interaction) -> ListTransition { - let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) - - let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } - let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } - let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.item(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } - - return ListTransition(deletions: deletions, insertions: insertions, updates: updates, isLoading: isLoading, isEmpty: isEmpty, canInvite: canInvite, crossFade: crossFade, count: toEntries.count, animated: animated) - } - - private func preparedFullscreenTransition(from fromEntries: [ListEntry], to toEntries: [ListEntry], isLoading: Bool, isEmpty: Bool, canInvite: Bool, crossFade: Bool, animated: Bool, context: AccountContext, presentationData: PresentationData, interaction: Interaction) -> ListTransition { - let (deleteIndices, indicesAndItems, updateIndices) = mergeListsStableWithUpdates(leftList: fromEntries, rightList: toEntries) - - let deletions = deleteIndices.map { ListViewDeleteItem(index: $0, directionHint: nil) } - let insertions = indicesAndItems.map { ListViewInsertItem(index: $0.0, previousIndex: $0.2, item: $0.1.fullscreenItem(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } - let updates = updateIndices.map { ListViewUpdateItem(index: $0.0, previousIndex: $0.2, item: $0.1.fullscreenItem(context: context, presentationData: presentationData, interaction: interaction), directionHint: nil) } - - return ListTransition(deletions: deletions, insertions: insertions, updates: updates, isLoading: isLoading, isEmpty: isEmpty, canInvite: canInvite, crossFade: crossFade, count: toEntries.count, animated: animated) - } - - private let currentAvatarMixin = Atomic(value: nil) - - private var configuration: VoiceChatConfiguration? - - private weak var controller: MediaStreamingControllerImpl? - private let sharedContext: SharedAccountContext - private let context: AccountContext - private let call: PresentationGroupCall - private var presentationData: PresentationData - private var presentationDataDisposable: Disposable? - private var darkTheme: PresentationTheme - - private let dimNode: ASDisplayNode - private let contentContainer: ASDisplayNode - private let backgroundNode: ASDisplayNode - private let listContainer: ASDisplayNode - private let listNode: ListView - private let fullscreenListContainer: ASDisplayNode - private let fullscreenListNode: ListView - private let tileGridNode: VoiceChatTileGridNode - private let topPanelNode: ASDisplayNode - private let topPanelEdgeNode: ASDisplayNode - private let topPanelBackgroundNode: ASDisplayNode - private let optionsButton: VoiceChatHeaderButton - private let closeButton: VoiceChatHeaderButton - private let panelButton: VoiceChatHeaderButton - private let topCornersNode: ASImageNode - fileprivate let bottomPanelNode: ASDisplayNode - private let bottomGradientNode: ASDisplayNode - private let bottomPanelBackgroundNode: ASDisplayNode - private let bottomCornersNode: ASImageNode - fileprivate let audioButton: CallControllerButtonItemNode - fileprivate let cameraButton: CallControllerButtonItemNode - fileprivate let switchCameraButton: CallControllerButtonItemNode - fileprivate let leaveButton: CallControllerButtonItemNode - fileprivate let actionButton: VoiceChatActionButton - private let leftBorderNode: ASDisplayNode - private let rightBorderNode: ASDisplayNode - private let mainStageContainerNode: ASDisplayNode - private let mainStageBackgroundNode: ASDisplayNode - private let mainStageNode: VoiceChatMainStageNode - - private let transitionMaskView: UIView - private let transitionMaskTopFillLayer: CALayer - private let transitionMaskFillLayer: CALayer - private let transitionMaskGradientLayer: CAGradientLayer - private let transitionMaskBottomFillLayer: CALayer - private let transitionContainerNode: ASDisplayNode - - private var isScheduling = false - private let timerNode: VoiceChatTimerNode - private var pickerView: UIDatePicker? - private let dateFormatter: DateFormatter - private let scheduleTextNode: ImmediateTextNode - private let scheduleCancelButton: SolidRoundedButtonNode - private var scheduleButtonTitle = "" - - private let titleNode: VoiceChatTitleNode - private let participantsNode: VoiceChatTimerNode - - private var enqueuedTransitions: [ListTransition] = [] - private var enqueuedFullscreenTransitions: [ListTransition] = [] - - private var validLayout: (ContainerViewLayout, CGFloat)? - private var didSetContentsReady: Bool = false - private var didSetDataReady: Bool = false - - private var isFirstTime = true - private var topInset: CGFloat? - - private var animatingInsertion = false - private var animatingExpansion = false - private var animatingAppearance = false - private var animatingButtonsSwap = false - private var animatingMainStage = false - private var animatingContextMenu = false - private var panGestureArguments: (topInset: CGFloat, offset: CGFloat)? - private var isPanning = false - - private var peer: Peer? - private var currentTitle: String = "" - private var currentTitleIsCustom = false - private var currentSubtitle: String = "" - private var currentSpeakingSubtitle: String? - private var currentCallMembers: ([GroupCallParticipantsContext.Participant], String?)? - private var currentTotalCount: Int32 = 0 - private var currentInvitedPeers: [EnginePeer]? - private var currentSpeakingPeers: Set? - private var currentContentOffset: CGFloat? - private var currentNormalButtonColor: UIColor? - private var currentActiveButtonColor: UIColor? - - private var myEntry: VoiceChatPeerEntry? - private var mainEntry: VoiceChatPeerEntry? - private var currentEntries: [ListEntry] = [] - private var currentFullscreenEntries: [ListEntry] = [] - private var currentTileItems: [VoiceChatTileItem] = [] - private var displayPanelVideos = false - private var joinedVideo: Bool? - - private var peerViewDisposable: Disposable? - private let leaveDisposable = MetaDisposable() - - private var isMutedDisposable: Disposable? - private var isNoiseSuppressionEnabled: Bool = true - private var isNoiseSuppressionEnabledDisposable: Disposable? - private var callStateDisposable: Disposable? - - private var pushingToTalk = false - private var temporaryPushingToTalk = false - private let hapticFeedback = HapticFeedback() - - private var callState: PresentationGroupCallState? - - private var currentLoadToken: String? - - private var scrollAtTop = true - - private var effectiveMuteState: GroupCallParticipantsContext.Participant.MuteState? { - if self.pushingToTalk { - return nil - } else { - return self.callState?.muteState - } - } - - private var audioOutputStateDisposable: Disposable? - private var audioOutputState: ([AudioSessionOutput], AudioSessionOutput?)? - - private var audioLevelsDisposable: Disposable? - private var myAudioLevelDisposable: Disposable? - private var isSpeakingDisposable: Disposable? - private var memberStatesDisposable: Disposable? - private var actionButtonColorDisposable: Disposable? - - private var itemInteraction: Interaction? - - private let inviteDisposable = MetaDisposable() - private let memberEventsDisposable = MetaDisposable() - private let reconnectedAsEventsDisposable = MetaDisposable() - private let stateVersionDisposable = MetaDisposable() - private var applicationStateDisposable: Disposable? - - private let displayAsPeersPromise = Promise<[FoundPeer]>([]) - private let inviteLinksPromise = Promise(nil) - - private var raisedHandDisplayDisposables: [PeerId: Disposable] = [:] - private var displayedRaisedHands = Set() { - didSet { - self.displayedRaisedHandsPromise.set(self.displayedRaisedHands) - } - } - private let displayedRaisedHandsPromise = ValuePromise>(Set()) - - private var requestedVideoSources = Set() - private var requestedVideoChannels: [PresentationGroupCallRequestedVideo] = [] - - private var videoRenderingContext: VideoRenderingContext - private var videoNodes: [String: GroupVideoNode] = [:] - private var wideVideoNodes = Set() - private var videoOrder: [String] = [] - private var readyVideoEndpointIds = Set() - private var readyVideoEndpointIdsPromise = ValuePromise>(Set()) - private var timeoutedEndpointIds = Set() - private var readyVideoDisposables = DisposableDict() - private var myPeerVideoReadyDisposable = MetaDisposable() - - private var peerIdToEndpointId: [PeerId: String] = [:] - - private var currentSpeakers: [PeerId] = [] - private var currentDominantSpeaker: (PeerId, String?, Double)? - private var currentForcedSpeaker: (PeerId, String?)? - private var effectiveSpeaker: (PeerId, String?, Bool, Bool, Bool)? - - private var updateAvatarDisposable = MetaDisposable() - private let updateAvatarPromise = Promise<(TelegramMediaImageRepresentation, Float)?>(nil) - private var currentUpdatingAvatar: TelegramMediaImageRepresentation? - - private var connectedOnce = false - private var ignoreConnecting = false - private var ignoreConnectingTimer: SwiftSignalKit.Timer? - - private var displayUnmuteTooltipTimer: SwiftSignalKit.Timer? - private var dismissUnmuteTooltipTimer: SwiftSignalKit.Timer? - private var lastUnmuteTooltipDisplayTimestamp: Double? - - private var panelHidden = false - private var displayMode: DisplayMode = .modal(isExpanded: false, isFilled: false) { - didSet { - if case let .modal(isExpanded, _) = self.displayMode { - self.itemInteraction?.isExpanded = isExpanded - } else { - self.itemInteraction?.isExpanded = true - } - } - } - - private var isExpanded: Bool { - switch self.displayMode { - case .modal(true, _), .fullscreen: - return true - default: - return false - } - } - - private var statsDisposable: Disposable? - - init(controller: MediaStreamingControllerImpl, sharedContext: SharedAccountContext, call: PresentationGroupCall) { - self.controller = controller - self.sharedContext = sharedContext - self.context = call.accountContext - self.call = call - - self.videoRenderingContext = VideoRenderingContext() - - self.isScheduling = call.schedulePending - - let presentationData = sharedContext.currentPresentationData.with { $0 } - self.presentationData = presentationData - - self.darkTheme = defaultDarkColorPresentationTheme - self.currentSubtitle = self.presentationData.strings.SocksProxySetup_ProxyStatusConnecting - - self.dimNode = ASDisplayNode() - self.dimNode.backgroundColor = UIColor(white: 0.0, alpha: 0.5) - - self.contentContainer = ASDisplayNode() - self.contentContainer.isHidden = true - - self.backgroundNode = ASDisplayNode() - self.backgroundNode.backgroundColor = self.isScheduling ? panelBackgroundColor : secondaryPanelBackgroundColor - self.backgroundNode.clipsToBounds = false - - self.listContainer = ASDisplayNode() - - self.listNode = ListView() - self.listNode.alpha = self.isScheduling ? 0.0 : 1.0 - self.listNode.isUserInteractionEnabled = !self.isScheduling - self.listNode.verticalScrollIndicatorColor = UIColor(white: 1.0, alpha: 0.3) - self.listNode.clipsToBounds = true - self.listNode.scroller.bounces = false - self.listNode.accessibilityPageScrolledString = { row, count in - return presentationData.strings.VoiceOver_ScrollStatus(row, count).string - } - - self.fullscreenListContainer = ASDisplayNode() - self.fullscreenListContainer.isHidden = true - - self.fullscreenListNode = ListView() - self.fullscreenListNode.transform = CATransform3DMakeRotation(-CGFloat(CGFloat.pi / 2.0), 0.0, 0.0, 1.0) - self.fullscreenListNode.clipsToBounds = true - self.fullscreenListNode.accessibilityPageScrolledString = { row, count in - return presentationData.strings.VoiceOver_ScrollStatus(row, count).string - } - - self.tileGridNode = VoiceChatTileGridNode(context: self.context) - - self.topPanelNode = ASDisplayNode() - self.topPanelNode.clipsToBounds = false - - self.topPanelBackgroundNode = ASDisplayNode() - self.topPanelBackgroundNode.backgroundColor = panelBackgroundColor - self.topPanelBackgroundNode.isUserInteractionEnabled = false - - self.topPanelEdgeNode = ASDisplayNode() - self.topPanelEdgeNode.backgroundColor = panelBackgroundColor - self.topPanelEdgeNode.cornerRadius = 12.0 - self.topPanelEdgeNode.isUserInteractionEnabled = false - if #available(iOS 11.0, *) { - self.topPanelEdgeNode.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] - } - - self.optionsButton = VoiceChatHeaderButton(context: self.context) - self.optionsButton.setContent(.more(optionsCircleImage(dark: false))) - self.closeButton = VoiceChatHeaderButton(context: self.context) - self.closeButton.setContent(.image(closeButtonImage(dark: false))) - self.panelButton = VoiceChatHeaderButton(context: self.context, wide: true) - self.panelButton.setContent(.image(panelButtonImage(dark: false))) - - self.titleNode = VoiceChatTitleNode(theme: self.presentationData.theme) - - self.topCornersNode = ASImageNode() - self.topCornersNode.displaysAsynchronously = false - self.topCornersNode.displayWithoutProcessing = true - self.topCornersNode.image = decorationTopCornersImage(dark: false) - self.topCornersNode.isUserInteractionEnabled = false - - self.bottomPanelNode = ASDisplayNode() - self.bottomPanelNode.clipsToBounds = false - - self.bottomPanelBackgroundNode = ASDisplayNode() - self.bottomPanelBackgroundNode.backgroundColor = panelBackgroundColor - self.bottomPanelBackgroundNode.isUserInteractionEnabled = false - - self.bottomGradientNode = ASDisplayNode() - self.bottomGradientNode.displaysAsynchronously = false - self.bottomGradientNode.backgroundColor = decorationBottomGradientImage(dark: false).flatMap { UIColor(patternImage: $0) } - - self.bottomCornersNode = ASImageNode() - self.bottomCornersNode.displaysAsynchronously = false - self.bottomCornersNode.displayWithoutProcessing = true - self.bottomCornersNode.image = decorationBottomCornersImage(dark: false) - self.bottomCornersNode.isUserInteractionEnabled = false - - self.audioButton = CallControllerButtonItemNode() - self.cameraButton = CallControllerButtonItemNode(largeButtonSize: sideButtonSize.width) - self.switchCameraButton = CallControllerButtonItemNode() - self.switchCameraButton.alpha = 0.0 - self.switchCameraButton.isUserInteractionEnabled = false - self.leaveButton = CallControllerButtonItemNode() - self.actionButton = VoiceChatActionButton() - - if self.isScheduling { - self.cameraButton.alpha = 0.0 - self.cameraButton.isUserInteractionEnabled = false - self.audioButton.alpha = 0.0 - self.audioButton.isUserInteractionEnabled = false - self.leaveButton.alpha = 0.0 - self.leaveButton.isUserInteractionEnabled = false - } - - self.leftBorderNode = ASDisplayNode() - self.leftBorderNode.backgroundColor = panelBackgroundColor - self.leftBorderNode.isUserInteractionEnabled = false - self.leftBorderNode.clipsToBounds = false - - self.rightBorderNode = ASDisplayNode() - self.rightBorderNode.backgroundColor = panelBackgroundColor - self.rightBorderNode.isUserInteractionEnabled = false - self.rightBorderNode.clipsToBounds = false - - self.mainStageContainerNode = ASDisplayNode() - self.mainStageContainerNode.clipsToBounds = true - self.mainStageContainerNode.isUserInteractionEnabled = false - self.mainStageContainerNode.isHidden = true - - self.mainStageBackgroundNode = ASDisplayNode() - self.mainStageBackgroundNode.backgroundColor = .black - self.mainStageBackgroundNode.alpha = 0.0 - self.mainStageBackgroundNode.isUserInteractionEnabled = false - - self.mainStageNode = VoiceChatMainStageNode(context: self.context, call: self.call) - - self.transitionMaskView = UIView() - self.transitionMaskTopFillLayer = CALayer() - self.transitionMaskTopFillLayer.backgroundColor = UIColor.white.cgColor - self.transitionMaskTopFillLayer.opacity = 0.0 - - self.transitionMaskFillLayer = CALayer() - self.transitionMaskFillLayer.backgroundColor = UIColor.white.cgColor - - self.transitionMaskGradientLayer = CAGradientLayer() - self.transitionMaskGradientLayer.colors = [UIColor.white.cgColor, UIColor.white.withAlphaComponent(0.0).cgColor] - self.transitionMaskGradientLayer.locations = [0.0, 1.0] - self.transitionMaskGradientLayer.startPoint = CGPoint(x: 0.0, y: 0.0) - self.transitionMaskGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) - - self.transitionMaskBottomFillLayer = CALayer() - self.transitionMaskBottomFillLayer.backgroundColor = UIColor.white.cgColor - self.transitionMaskBottomFillLayer.opacity = 0.0 - - self.transitionMaskView.layer.addSublayer(self.transitionMaskTopFillLayer) - self.transitionMaskView.layer.addSublayer(self.transitionMaskFillLayer) - self.transitionMaskView.layer.addSublayer(self.transitionMaskGradientLayer) - self.transitionMaskView.layer.addSublayer(self.transitionMaskBottomFillLayer) - - self.transitionContainerNode = ASDisplayNode() - self.transitionContainerNode.clipsToBounds = true - self.transitionContainerNode.isUserInteractionEnabled = false - self.transitionContainerNode.view.mask = self.transitionMaskView -// self.transitionContainerNode.view.addSubview(self.transitionMaskView) - - self.scheduleTextNode = ImmediateTextNode() - self.scheduleTextNode.isHidden = !self.isScheduling - self.scheduleTextNode.isUserInteractionEnabled = false - self.scheduleTextNode.textAlignment = .center - self.scheduleTextNode.maximumNumberOfLines = 4 - - self.scheduleCancelButton = SolidRoundedButtonNode(title: self.presentationData.strings.Common_Cancel, theme: SolidRoundedButtonTheme(backgroundColor: UIColor(rgb: 0x2b2b2f), foregroundColor: .white), height: 52.0, cornerRadius: 10.0) - self.scheduleCancelButton.isHidden = !self.isScheduling - - self.dateFormatter = DateFormatter() - self.dateFormatter.timeStyle = .none - self.dateFormatter.dateStyle = .short - self.dateFormatter.timeZone = TimeZone.current - - self.timerNode = VoiceChatTimerNode(strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat) - self.timerNode.isHidden = true - - self.participantsNode = VoiceChatTimerNode(strings: self.presentationData.strings, dateTimeFormat: self.presentationData.dateTimeFormat) - - super.init() - - let context = self.context - let currentAccountPeer = self.context.account.postbox.loadedPeerWithId(context.account.peerId) - |> map { peer in - return [FoundPeer(peer: peer, subscribers: nil)] - } - - self.isNoiseSuppressionEnabledDisposable = (call.isNoiseSuppressionEnabled - |> deliverOnMainQueue).start(next: { [weak self] value in - guard let strongSelf = self else { - return - } - strongSelf.isNoiseSuppressionEnabled = value - }) - - let displayAsPeers: Signal<[FoundPeer], NoError> = currentAccountPeer - |> then( - combineLatest(currentAccountPeer, context.engine.calls.cachedGroupCallDisplayAsAvailablePeers(peerId: call.peerId)) - |> map { currentAccountPeer, availablePeers -> [FoundPeer] in - var result = currentAccountPeer - result.append(contentsOf: availablePeers) - return result - } - ) - self.displayAsPeersPromise.set(displayAsPeers) - - self.inviteLinksPromise.set(.single(nil) - |> then(call.inviteLinks)) - - self.itemInteraction = Interaction(updateIsMuted: { [weak self] peerId, isMuted in - let _ = self?.call.updateMuteState(peerId: peerId, isMuted: isMuted) - }, switchToPeer: { [weak self] peerId, videoEndpointId, expand in - if let strongSelf = self, strongSelf.connectedOnce { - if expand, let videoEndpointId = videoEndpointId { - strongSelf.currentDominantSpeaker = (peerId, videoEndpointId, CACurrentMediaTime() + 3.0) - strongSelf.updateDisplayMode(.fullscreen(controlsHidden: false)) - } else { - strongSelf.currentForcedSpeaker = nil - if peerId != strongSelf.currentDominantSpeaker?.0 || (videoEndpointId != nil && videoEndpointId != strongSelf.currentDominantSpeaker?.1) { - strongSelf.currentDominantSpeaker = (peerId, videoEndpointId, CACurrentMediaTime()) - } - strongSelf.updateMainVideo(waitForFullSize: true, updateMembers: true, force: true) - } - } - }, openInvite: { [weak self] in - guard let strongSelf = self else { - return - } - - let groupPeer = strongSelf.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.call.peerId)) - let _ = combineLatest(queue: Queue.mainQueue(), groupPeer, strongSelf.inviteLinksPromise.get() |> take(1)).start(next: { groupPeer, inviteLinks in - guard let strongSelf = self else { - return - } - guard let groupPeer = groupPeer else { - return - } - - 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 = inviteLinks { - strongSelf.presentShare(inviteLinks) - } - return - } - } - - var filters: [ChannelMembersSearchFilter] = [] - if let (currentCallMembers, _) = strongSelf.currentCallMembers { - filters.append(.disable(Array(currentCallMembers.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: strongSelf.context, peerId: groupPeer.id, forceTheme: strongSelf.darkTheme, mode: .inviteToCall, filters: filters, openPeer: { peer, participant in - guard let strongSelf = self else { - dismissController?() - return - } - - let peer = EnginePeer(peer) - - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - if peer.id == strongSelf.callState?.myPeerId { - return - } - if let participant = participant { - dismissController?() - - if strongSelf.call.invitePeer(participant.peer.id) { - let text: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - text = strongSelf.presentationData.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string - } else { - text = strongSelf.presentationData.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string - } - strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: EnginePeer(participant.peer), text: text, action: nil), action: { _ in return false }) - } - } else { - if case let .channel(groupPeer) = groupPeer, let listenerLink = inviteLinks?.listenerLink, !groupPeer.hasPermission(.inviteMembers) { - let text = strongSelf.presentationData.strings.VoiceChat_SendPublicLinkText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string - - strongSelf.controller?.present(textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.VoiceChat_SendPublicLinkSend, action: { [weak self] in - dismissController?() - - if let strongSelf = self { - let _ = (enqueueMessages(account: strongSelf.context.account, peerId: peer.id, messages: [.message(text: listenerLink, attributes: [], inlineStickers: [:], mediaReference: nil, replyToMessageId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])]) - |> deliverOnMainQueue).start(next: { [weak self] _ in - if let strongSelf = self { - strongSelf.presentUndoOverlay(content: .forward(savedMessages: false, text: strongSelf.presentationData.strings.UserInfo_LinkForwardTooltip_Chat_One(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string), action: { _ in return true }) - } - }) - } - })]), in: .window(.root)) - } else { - let text: String - if case let .channel(groupPeer) = groupPeer, case .broadcast = groupPeer.info { - text = strongSelf.presentationData.strings.VoiceChat_InviteMemberToChannelFirstText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), EnginePeer(groupPeer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string - } else { - text = strongSelf.presentationData.strings.VoiceChat_InviteMemberToGroupFirstText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), groupPeer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string - } - - strongSelf.controller?.present(textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: text, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: presentationData.strings.VoiceChat_InviteMemberToGroupFirstAdd, action: { - guard let strongSelf = self else { - return - } - - if case let .channel(groupPeer) = groupPeer { - let selfController = strongSelf.controller - let inviteDisposable = strongSelf.inviteDisposable - var inviteSignal = strongSelf.context.peerChannelMemberCategoriesContextsManager.addMembers(engine: strongSelf.context.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: { error in - dismissController?() - guard let strongSelf = self else { - return - } - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - - let text: String - switch error { - case .limitExceeded: - text = presentationData.strings.Channel_ErrorAddTooMuch - case .tooMuchJoined: - text = presentationData.strings.Invite_ChannelsTooMuch - case .generic: - text = presentationData.strings.Login_UnknownError - case .restricted: - text = presentationData.strings.Channel_ErrorAddBlocked - case .notMutualContact: - if case .broadcast = groupPeer.info { - text = presentationData.strings.Channel_AddUserLeftError - } else { - text = presentationData.strings.GroupInfo_AddUserLeftError - } - case .botDoesntSupportGroups: - text = presentationData.strings.Channel_BotDoesntSupportGroups - case .tooMuchBots: - text = presentationData.strings.Channel_TooMuchBots - case .bot: - text = presentationData.strings.Login_UnknownError - case .kicked: - text = presentationData.strings.Channel_AddUserKickedError - } - strongSelf.controller?.present(textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: text, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) - }, completed: { - guard let strongSelf = self else { - dismissController?() - return - } - dismissController?() - - if strongSelf.call.invitePeer(peer.id) { - let text: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - text = strongSelf.presentationData.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string - } else { - text = strongSelf.presentationData.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string - } - strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: peer, text: text, action: nil), action: { _ in return false }) - } - })) - } else if case let .legacyGroup(groupPeer) = groupPeer { - let selfController = strongSelf.controller - let inviteDisposable = strongSelf.inviteDisposable - var inviteSignal = strongSelf.context.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: { error in - dismissController?() - guard let strongSelf = self else { - return - } - let context = strongSelf.context - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - - switch error { - case .privacy: - let _ = (strongSelf.context.account.postbox.loadedPeerWithId(peer.id) - |> deliverOnMainQueue).start(next: { peer in - self?.controller?.present(textAlertController(context: context, title: nil, text: presentationData.strings.Privacy_GroupsAndChannels_InviteToGroupError(EnginePeer(peer).compactDisplayTitle, EnginePeer(peer).compactDisplayTitle).string, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) - }) - case .notMutualContact: - strongSelf.controller?.present(textAlertController(context: context, title: nil, text: presentationData.strings.GroupInfo_AddUserLeftError, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) - case .tooManyChannels: - strongSelf.controller?.present(textAlertController(context: context, title: nil, text: presentationData.strings.Invite_ChannelsTooMuch, actions: [TextAlertAction(type: .genericAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) - case .groupFull, .generic: - strongSelf.controller?.present(textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: presentationData.strings.Login_UnknownError, actions: [TextAlertAction(type: .defaultAction, title: presentationData.strings.Common_OK, action: {})]), in: .window(.root)) - } - }, completed: { - guard let strongSelf = self else { - dismissController?() - return - } - dismissController?() - - if strongSelf.call.invitePeer(peer.id) { - let text: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - text = strongSelf.presentationData.strings.LiveStream_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string - } else { - text = strongSelf.presentationData.strings.VoiceChat_InvitedPeerText(peer.displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string - } - strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: peer, text: text, action: nil), action: { _ in return false }) - } - })) - } - })]), in: .window(.root)) - } - } - }) - controller.copyInviteLink = { - dismissController?() - - guard let strongSelf = self else { - return - } - let callPeerId = strongSelf.call.peerId - - let _ = (strongSelf.context.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: { link in - guard let strongSelf = self else { - return - } - - if let link = link { - UIPasteboard.general.string = link - - strongSelf.presentUndoOverlay(content: .linkCopied(text: strongSelf.presentationData.strings.VoiceChat_InviteLinkCopiedText), action: { _ in return false }) - } - }) - } - dismissController = { [weak controller] in - controller?.dismiss() - } - strongSelf.controller?.push(controller) - }) - }, peerContextAction: { [weak self] entry, sourceNode, gesture, fullscreen in - guard let strongSelf = self, let sourceNode = sourceNode as? ContextExtractedContentContainingNode else { - return - } - - let muteStatePromise = Promise(entry.muteState) - - let itemsForEntry: (VoiceChatPeerEntry, GroupCallParticipantsContext.Participant.MuteState?) -> [ContextMenuItem] = { entry, muteState in - var items: [ContextMenuItem] = [] - - var hasVolumeSlider = false - let peer = entry.peer - if let muteState = muteState, !muteState.canUnmute || muteState.mutedByYou { - } else { - if entry.canManageCall || !entry.isMyPeer { - hasVolumeSlider = true - - let minValue: CGFloat - if let callState = strongSelf.callState, callState.canManageCall && callState.adminIds.contains(peer.id) && muteState != nil { - minValue = 0.01 - } else { - minValue = 0.0 - } - items.append(.custom(VoiceChatVolumeContextItem(minValue: minValue, value: entry.volume.flatMap { CGFloat($0) / 10000.0 } ?? 1.0, valueChanged: { newValue, finished in - if finished && newValue.isZero { - let updatedMuteState = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: true) - muteStatePromise.set(.single(updatedMuteState)) - } else { - strongSelf.call.setVolume(peerId: peer.id, volume: Int32(newValue * 10000), sync: finished) - } - }), true)) - } - } - - if entry.isMyPeer && !hasVolumeSlider && ((entry.about?.isEmpty ?? true) || entry.peer.smallProfileImage == nil) { - items.append(.custom(VoiceChatInfoContextItem(text: strongSelf.presentationData.strings.VoiceChat_ImproveYourProfileText, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Tip"), color: theme.actionSheet.primaryTextColor) - }), true)) - } - - if peer.id == strongSelf.callState?.myPeerId { - if entry.raisedHand { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_CancelSpeakRequest, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/RevokeSpeak"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in - guard let strongSelf = self else { - return - } - - let _ = strongSelf.call.lowerHand() - f(.default) - }))) - } - items.append(.action(ContextMenuActionItem(text: peer.smallProfileImage == nil ? strongSelf.presentationData.strings.VoiceChat_AddPhoto : strongSelf.presentationData.strings.VoiceChat_ChangePhoto, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Camera"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in - guard let strongSelf = self else { - return - } - - f(.default) - Queue.mainQueue().after(0.1) { - strongSelf.openAvatarForEditing(fromGallery: false, completion: {}) - } - }))) - - items.append(.action(ContextMenuActionItem(text: (entry.about?.isEmpty ?? true) ? strongSelf.presentationData.strings.VoiceChat_AddBio : strongSelf.presentationData.strings.VoiceChat_EditBio, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in - guard let strongSelf = self else { - return - } - f(.default) - - Queue.mainQueue().after(0.1) { - let maxBioLength: Int - if peer.id.namespace == Namespaces.Peer.CloudUser { - maxBioLength = 70 - } else { - maxBioLength = 100 - } - let controller = voiceChatTitleEditController(sharedContext: strongSelf.context.sharedContext, account: strongSelf.context.account, forceTheme: strongSelf.darkTheme, title: presentationData.strings.VoiceChat_EditBioTitle, text: presentationData.strings.VoiceChat_EditBioText, placeholder: presentationData.strings.VoiceChat_EditBioPlaceholder, doneButtonTitle: presentationData.strings.VoiceChat_EditBioSave, value: entry.about, maxLength: maxBioLength, apply: { bio in - if let strongSelf = self, let bio = bio { - if peer.id.namespace == Namespaces.Peer.CloudUser { - let _ = (strongSelf.context.engine.accountData.updateAbout(about: bio) - |> `catch` { _ -> Signal in - return .complete() - }).start() - } else { - let _ = (strongSelf.context.engine.peers.updatePeerDescription(peerId: peer.id, description: bio) - |> `catch` { _ -> Signal in - return .complete() - }).start() - } - - strongSelf.presentUndoOverlay(content: .info(title: nil, text: strongSelf.presentationData.strings.VoiceChat_EditBioSuccess), action: { _ in return false }) - } - }) - self?.controller?.present(controller, in: .window(.root)) - } - }))) - - if let peer = peer as? TelegramUser { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_ChangeName, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ChangeName"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in - guard let strongSelf = self else { - return - } - f(.default) - - Queue.mainQueue().after(0.1) { - let controller = voiceChatUserNameController(sharedContext: strongSelf.context.sharedContext, account: strongSelf.context.account, forceTheme: strongSelf.darkTheme, title: presentationData.strings.VoiceChat_ChangeNameTitle, firstNamePlaceholder: presentationData.strings.UserInfo_FirstNamePlaceholder, lastNamePlaceholder: presentationData.strings.UserInfo_LastNamePlaceholder, doneButtonTitle: presentationData.strings.VoiceChat_EditBioSave, firstName: peer.firstName, lastName: peer.lastName, maxLength: 128, apply: { firstAndLastName in - if let strongSelf = self, let (firstName, lastName) = firstAndLastName { - let _ = context.engine.accountData.updateAccountPeerName(firstName: firstName, lastName: lastName).start() - - strongSelf.presentUndoOverlay(content: .info(title: nil, text: strongSelf.presentationData.strings.VoiceChat_EditNameSuccess), action: { _ in return false }) - } - }) - self?.controller?.present(controller, in: .window(.root)) - } - }))) - } - } else { - if let callState = strongSelf.callState, (callState.canManageCall || callState.adminIds.contains(strongSelf.context.account.peerId)) { - if callState.adminIds.contains(peer.id) { - if let _ = muteState { - } else { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_MutePeer, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in - guard let strongSelf = self else { - return - } - - let _ = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: true) - f(.default) - }))) - } - } else { - if let muteState = muteState, !muteState.canUnmute { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_UnmutePeer, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: entry.raisedHand ? "Call/Context Menu/AllowToSpeak" : "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in - guard let strongSelf = self else { - return - } - - let _ = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: false) - f(.default) - - strongSelf.presentUndoOverlay(content: .voiceChatCanSpeak(text: presentationData.strings.VoiceChat_UserCanNowSpeak(EnginePeer(entry.peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string), action: { _ in return true }) - }))) - } else { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_MutePeer, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in - guard let strongSelf = self else { - return - } - - let _ = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: true) - f(.default) - }))) - } - } - } else { - if let muteState = muteState, muteState.mutedByYou { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_UnmuteForMe, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Unmute"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in - guard let strongSelf = self else { - return - } - - let _ = strongSelf.call.updateMuteState(peerId: peer.id, isMuted: false) - f(.default) - }))) - } else { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_MuteForMe, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Mute"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in - guard let strongSelf = self else { - return - } - - let _ = strongSelf.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 = strongSelf.presentationData.strings.VoiceChat_OpenChannel - openIcon = UIImage(bundleImageName: "Chat/Context Menu/Channels") - } else { - openTitle = strongSelf.presentationData.strings.VoiceChat_OpenGroup - openIcon = UIImage(bundleImageName: "Chat/Context Menu/Groups") - } - } else { - openTitle = strongSelf.presentationData.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: { _, f in - guard let strongSelf = self, let navigationController = strongSelf.controller?.parentNavigationController else { - return - } - - let context = strongSelf.context - strongSelf.controller?.dismiss(completion: { - Queue.mainQueue().after(0.3) { - context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(EnginePeer(peer)), keepStack: .always, purposefulAction: {}, peekData: nil)) - } - }) - - f(.dismissWithoutContent) - }))) - - if let callState = strongSelf.callState, (callState.canManageCall && !callState.adminIds.contains(peer.id)), peer.id != strongSelf.call.peerId { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.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 strongSelf = self else { - return - } - - let _ = (strongSelf.context.account.postbox.loadedPeerWithId(strongSelf.call.peerId) - |> deliverOnMainQueue).start(next: { [weak self] chatPeer in - guard let strongSelf = self else { - return - } - - let actionSheet = ActionSheetController(presentationData: strongSelf.presentationData.withUpdated(theme: strongSelf.darkTheme)) - var items: [ActionSheetItem] = [] - - items.append(DeleteChatPeerActionSheetItem(context: strongSelf.context, peer: EnginePeer(peer), chatPeer: EnginePeer(chatPeer), action: .removeFromGroup, strings: strongSelf.presentationData.strings, nameDisplayOrder: strongSelf.presentationData.nameDisplayOrder)) - - items.append(ActionSheetButtonItem(title: strongSelf.presentationData.strings.VoiceChat_RemovePeerRemove, color: .destructive, action: { [weak actionSheet] in - actionSheet?.dismissAnimated() - - guard let strongSelf = self else { - return - } - - let _ = strongSelf.context.peerChannelMemberCategoriesContextsManager.updateMemberBannedRights(engine: strongSelf.context.engine, peerId: strongSelf.call.peerId, memberId: peer.id, bannedRights: TelegramChatBannedRights(flags: [.banReadMessages], untilDate: Int32.max)).start() - strongSelf.call.removedPeer(peer.id) - - strongSelf.presentUndoOverlay(content: .banned(text: strongSelf.presentationData.strings.VoiceChat_RemovedPeerText(EnginePeer(peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string), action: { _ in return false }) - })) - - actionSheet.setItemGroups([ - ActionSheetItemGroup(items: items), - ActionSheetItemGroup(items: [ - ActionSheetButtonItem(title: strongSelf.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in - actionSheet?.dismissAnimated() - }) - ]) - ]) - strongSelf.controller?.present(actionSheet, in: .window(.root)) - }) - }) - }))) - } - } - return items - } - - let items = muteStatePromise.get() - |> map { muteState -> [ContextMenuItem] in - return itemsForEntry(entry, muteState) - } - - var centerVertically = entry.peer.smallProfileImage != nil || (!fullscreen && entry.effectiveVideoEndpointId != nil) - if let (layout, _) = strongSelf.validLayout, case .regular = layout.metrics.widthClass { - centerVertically = false - } - - var useMaskView = true - if case .fullscreen = strongSelf.displayMode { - useMaskView = false - } - - let dismissPromise = ValuePromise(false) - let source = VoiceChatContextExtractedContentSource(sourceNode: sourceNode, maskView: useMaskView ? strongSelf.transitionMaskView : nil, keepInPlace: false, blurBackground: true, centerVertically: centerVertically, shouldBeDismissed: dismissPromise.get(), animateTransitionIn: { [weak self] in - if let strongSelf = self { - strongSelf.animatingContextMenu = true - strongSelf.updateDecorationsLayout(transition: .immediate) - if strongSelf.isLandscape { - strongSelf.transitionMaskTopFillLayer.opacity = 1.0 - } - strongSelf.transitionContainerNode.view.mask = nil - strongSelf.transitionMaskBottomFillLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.4, removeOnCompletion: false, completion: { [weak self] _ in - Queue.mainQueue().after(0.3) { - self?.transitionMaskTopFillLayer.opacity = 0.0 - self?.transitionMaskBottomFillLayer.removeAllAnimations() - self?.animatingContextMenu = false - self?.updateDecorationsLayout(transition: .immediate) - } - }) - } - }, animateTransitionOut: { [weak self] in - if let strongSelf = self { - strongSelf.animatingContextMenu = true - strongSelf.updateDecorationsLayout(transition: .immediate) - strongSelf.transitionMaskTopFillLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.4) - strongSelf.transitionMaskBottomFillLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.4, completion: { [weak self] _ in - self?.animatingContextMenu = false - self?.updateDecorationsLayout(transition: .immediate) - self?.transitionContainerNode.view.mask = self?.transitionMaskView - }) - } - }) - sourceNode.requestDismiss = { - dismissPromise.set(true) - } - - let contextController = ContextController(account: strongSelf.context.account, presentationData: strongSelf.presentationData.withUpdated(theme: strongSelf.darkTheme), source: .extracted(source), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) - contextController.useComplexItemsTransitionAnimation = true - strongSelf.controller?.presentInGlobalOverlay(contextController) - }, getPeerVideo: { [weak self] endpointId, position in - guard let strongSelf = self else { - return nil - } - var ignore = false - if case .mainstage = position { - ignore = false - } else if case .fullscreen = strongSelf.displayMode, !strongSelf.isPanning { - ignore = ![.mainstage, .list].contains(position) - } else { - ignore = position != .tile - } - if ignore { - return nil - } - if !strongSelf.readyVideoEndpointIds.contains(endpointId) { - return nil - } - for (listEndpointId, videoNode) in strongSelf.videoNodes { - if listEndpointId == endpointId { - if position != .mainstage && videoNode.isMainstageExclusive { - return nil - } - return videoNode - } - } - return nil - }) - self.itemInteraction?.updateAvatarPromise = self.updateAvatarPromise - - self.topPanelNode.addSubnode(self.topPanelEdgeNode) - self.topPanelNode.addSubnode(self.topPanelBackgroundNode) - self.topPanelNode.addSubnode(self.titleNode) - self.topPanelNode.addSubnode(self.optionsButton) - self.topPanelNode.addSubnode(self.closeButton) - self.topPanelNode.addSubnode(self.panelButton) - - self.bottomPanelNode.addSubnode(self.cameraButton) - self.bottomPanelNode.addSubnode(self.audioButton) - self.bottomPanelNode.addSubnode(self.switchCameraButton) - self.bottomPanelNode.addSubnode(self.leaveButton) - self.bottomPanelNode.addSubnode(self.actionButton) - self.bottomPanelNode.addSubnode(self.scheduleCancelButton) - - self.addSubnode(self.dimNode) - self.addSubnode(self.contentContainer) - - self.contentContainer.addSubnode(self.backgroundNode) - -// self.contentContainer.addSubnode(self.listContainer) -// self.contentContainer.addSubnode(self.topPanelNode) -// self.listContainer.addSubnode(self.listNode) -// self.listContainer.addSubnode(self.leftBorderNode) -// self.listContainer.addSubnode(self.rightBorderNode) -// self.listContainer.addSubnode(self.bottomCornersNode) - self.listContainer.addSubnode(self.topCornersNode) - self.contentContainer.addSubnode(self.bottomGradientNode) -// self.contentContainer.addSubnode(self.bottomPanelBackgroundNode) - self.contentContainer.addSubnode(self.participantsNode) - self.contentContainer.addSubnode(self.tileGridNode) - self.contentContainer.addSubnode(self.mainStageContainerNode) - self.contentContainer.addSubnode(self.transitionContainerNode) - self.contentContainer.addSubnode(self.bottomPanelNode) - self.contentContainer.addSubnode(self.timerNode) - self.contentContainer.addSubnode(self.scheduleTextNode) - self.contentContainer.addSubnode(self.fullscreenListContainer) - self.fullscreenListContainer.addSubnode(self.fullscreenListNode) - - self.mainStageContainerNode.addSubnode(self.mainStageBackgroundNode) - self.mainStageContainerNode.addSubnode(self.mainStageNode) - - self.updateDecorationsColors() - - let invitedPeers: Signal<[EnginePeer], NoError> = self.call.invitedPeers - |> mapToSignal { ids -> Signal<[EnginePeer], NoError> in - return context.engine.data.get(EngineDataList( - ids.map(TelegramEngine.EngineData.Item.Peer.Peer.init) - )) - |> map { itemList -> [EnginePeer] in - return itemList.compactMap { $0 } - } - } - - self.presentationDataDisposable = (sharedContext.presentationData - |> deliverOnMainQueue).start(next: { [weak self] presentationData in - if let strongSelf = self { - strongSelf.presentationData = presentationData - - let sourceColor = presentationData.theme.chatList.unreadBadgeInactiveBackgroundColor - let color: UIColor - if sourceColor.alpha < 1.0 { - color = presentationData.theme.chatList.unreadBadgeInactiveBackgroundColor.mixedWith(sourceColor.withAlphaComponent(1.0), alpha: sourceColor.alpha) - } else { - color = sourceColor - } - strongSelf.actionButton.connectingColor = color - } - }) - - self.memberStatesDisposable = (combineLatest(queue: .mainQueue(), - self.call.state, - self.call.members, - invitedPeers, - self.displayAsPeersPromise.get(), - self.context.account.postbox.preferencesView(keys: [PreferencesKeys.appConfiguration]) - ) - |> mapToThrottled { values in - return .single(values) - |> then(.complete() |> delay(0.1, queue: Queue.mainQueue())) - }).start(next: { [weak self] state, callMembers, invitedPeers, displayAsPeers, preferencesView in - guard let strongSelf = self else { - return - } - - let appConfiguration: AppConfiguration = preferencesView.values[PreferencesKeys.appConfiguration]?.get(AppConfiguration.self) ?? .defaultValue - let configuration = VoiceChatConfiguration.with(appConfiguration: appConfiguration) - strongSelf.configuration = configuration - - var animate = false - if strongSelf.callState != state { - if let previousCallState = strongSelf.callState { - var networkStateUpdated = false - if case .connecting = previousCallState.networkState, case .connected = state.networkState { - networkStateUpdated = true - strongSelf.connectedOnce = true - } - var canUnmuteUpdated = false - if previousCallState.muteState?.canUnmute != state.muteState?.canUnmute { - canUnmuteUpdated = true - } - if previousCallState.isVideoEnabled != state.isVideoEnabled || (state.isVideoEnabled && networkStateUpdated) || canUnmuteUpdated { - strongSelf.animatingButtonsSwap = true - animate = true - } - } - strongSelf.callState = state - strongSelf.mainStageNode.callState = state - - if let muteState = state.muteState, !muteState.canUnmute { - if strongSelf.pushingToTalk { - strongSelf.pushingToTalk = false - strongSelf.actionButton.pressing = false - strongSelf.actionButton.isUserInteractionEnabled = false - strongSelf.actionButton.isUserInteractionEnabled = true - } - } - } - - strongSelf.updateMembers(muteState: strongSelf.effectiveMuteState, callMembers: (callMembers?.participants ?? [], callMembers?.loadMoreToken), invitedPeers: invitedPeers, speakingPeers: callMembers?.speakingParticipants ?? []) - - let totalCount = Int32(max(1, callMembers?.totalCount ?? 0)) - strongSelf.currentTotalCount = totalCount - - let subtitle = strongSelf.presentationData.strings.VoiceChat_Panel_Members(totalCount) - strongSelf.currentSubtitle = subtitle - - if strongSelf.isScheduling { - strongSelf.optionsButton.isUserInteractionEnabled = false - strongSelf.optionsButton.alpha = 0.0 - strongSelf.closeButton.isUserInteractionEnabled = false - strongSelf.closeButton.alpha = 0.0 - strongSelf.panelButton.isUserInteractionEnabled = false - strongSelf.panelButton.alpha = 0.0 - } else { - if let (layout, _) = strongSelf.validLayout { - if case .regular = layout.metrics.widthClass, !strongSelf.peerIdToEndpointId.isEmpty { - strongSelf.panelButton.isUserInteractionEnabled = true - } else { - strongSelf.panelButton.isUserInteractionEnabled = false - } - } - if let callState = strongSelf.callState, callState.canManageCall { - strongSelf.optionsButton.isUserInteractionEnabled = true - } else if displayAsPeers.count > 1 { - strongSelf.optionsButton.isUserInteractionEnabled = true - } else { - strongSelf.optionsButton.isUserInteractionEnabled = true - } - } - - if let (layout, navigationHeight) = strongSelf.validLayout { - strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: animate ? .animated(duration: 0.4, curve: .spring) : .immediate) - } - }) - - let titleAndRecording: Signal<(String?, Bool), NoError> = self.call.state - |> map { state -> (String?, Bool) in - return (state.title, state.recordingStartTimestamp != nil) - } - self.peerViewDisposable = combineLatest(queue: Queue.mainQueue(), self.context.account.viewTracker.peerView(self.call.peerId), titleAndRecording).start(next: { [weak self] view, titleAndRecording in - guard let strongSelf = self else { - return - } - - let (title, isRecording) = titleAndRecording - if let peer = peerViewMainPeer(view) { - let isLivestream: Bool - if let channel = peer as? TelegramChannel, case .broadcast = channel.info { - isLivestream = true - } else { - isLivestream = false - } - strongSelf.participantsNode.isHidden = !isLivestream || strongSelf.isScheduled - - let hadPeer = strongSelf.peer != nil - strongSelf.peer = peer - strongSelf.currentTitleIsCustom = title != nil - strongSelf.currentTitle = title ?? EnginePeer(peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder) - - strongSelf.updateTitle(transition: .immediate) - strongSelf.titleNode.isRecording = isRecording - - if strongSelf.isScheduling && !hadPeer { - strongSelf.updateScheduleButtonTitle() - } - } - if !strongSelf.didSetDataReady { - strongSelf.didSetDataReady = true - strongSelf.updateMembers() - strongSelf.controller?.dataReady.set(true) - } - }) - - self.audioOutputStateDisposable = (self.call.audioOutputState - |> deliverOnMainQueue).start(next: { [weak self] state in - guard let strongSelf = self else { - return - } - - var existingOutputs = Set() - var filteredOutputs: [AudioSessionOutput] = [] - for output in state.0 { - if case let .port(port) = output { - if !existingOutputs.contains(port.name) { - existingOutputs.insert(port.name) - filteredOutputs.append(output) - } - } else { - filteredOutputs.append(output) - } - } - - let wasEmpty = strongSelf.audioOutputState == nil - strongSelf.audioOutputState = (filteredOutputs, state.1) - if let (layout, navigationHeight) = strongSelf.validLayout { - strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .immediate) - } - if wasEmpty { - strongSelf.controller?.audioOutputStateReady.set(true) - } - }) - - self.audioLevelsDisposable = (self.call.audioLevels - |> deliverOnMainQueue).start(next: { [weak self] levels in - guard let strongSelf = self else { - return - } - var levels = levels - if strongSelf.effectiveMuteState != nil { - levels = levels.filter { $0.0 != strongSelf.callState?.myPeerId } - } - - var maxLevelWithVideo: (PeerId, Float)? - for (peerId, source, level, hasSpeech) in levels { - let hasVideo = strongSelf.peerIdToEndpointId[peerId] != nil - if hasSpeech && source != 0 && hasVideo { - if let (_, currentLevel) = maxLevelWithVideo { - if currentLevel < level { - maxLevelWithVideo = (peerId, level) - } - } else { - maxLevelWithVideo = (peerId, level) - } - } - } - - if maxLevelWithVideo == nil { - if let (peerId, _, _) = strongSelf.currentDominantSpeaker { - maxLevelWithVideo = (peerId, 0.0) - } else if strongSelf.peerIdToEndpointId.count > 0 { - for entry in strongSelf.currentFullscreenEntries { - if case let .peer(peerEntry, _) = entry { - if let _ = peerEntry.effectiveVideoEndpointId { - maxLevelWithVideo = (peerEntry.peer.id, 0.0) - break - } - } - } - } - } - - if case .fullscreen = strongSelf.displayMode, !strongSelf.mainStageNode.animating && !strongSelf.animatingExpansion { - if let (peerId, _) = maxLevelWithVideo { - if let (currentPeerId, _, timestamp) = strongSelf.currentDominantSpeaker { - if CACurrentMediaTime() - timestamp > 2.5 && peerId != currentPeerId { - strongSelf.currentDominantSpeaker = (peerId, nil, CACurrentMediaTime()) - strongSelf.updateMainVideo(waitForFullSize: true) - } - } - } - } - - strongSelf.itemInteraction?.updateAudioLevels(levels) - }) - - self.myAudioLevelDisposable = (self.call.myAudioLevel - |> deliverOnMainQueue).start(next: { [weak self] level in - guard let strongSelf = self else { - return - } - var effectiveLevel: Float = 0.0 - if let state = strongSelf.callState, state.muteState == nil || strongSelf.pushingToTalk { - effectiveLevel = level - } else if level > 0.1 { - effectiveLevel = level * 0.5 - } - strongSelf.actionButton.updateLevel(CGFloat(effectiveLevel)) - }) - - self.isSpeakingDisposable = (self.call.isSpeaking - |> deliverOnMainQueue).start(next: { [weak self] isSpeaking in - guard let strongSelf = self else { - return - } - if let state = strongSelf.callState, state.muteState == nil || strongSelf.pushingToTalk { - strongSelf.displayUnmuteTooltipTimer?.invalidate() - strongSelf.displayUnmuteTooltipTimer = nil - strongSelf.dismissUnmuteTooltipTimer?.invalidate() - strongSelf.dismissUnmuteTooltipTimer = nil - } else { - if isSpeaking { - var shouldDisplayTooltip = false - if let previousTimstamp = strongSelf.lastUnmuteTooltipDisplayTimestamp, CACurrentMediaTime() > previousTimstamp + 45.0 { - shouldDisplayTooltip = true - } else if strongSelf.lastUnmuteTooltipDisplayTimestamp == nil { - shouldDisplayTooltip = true - } - if shouldDisplayTooltip { - strongSelf.dismissUnmuteTooltipTimer?.invalidate() - strongSelf.dismissUnmuteTooltipTimer = nil - - if strongSelf.displayUnmuteTooltipTimer == nil { - let timer = SwiftSignalKit.Timer(timeout: 1.0, repeat: false, completion: { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.lastUnmuteTooltipDisplayTimestamp = CACurrentMediaTime() - strongSelf.displayUnmuteTooltip() - strongSelf.displayUnmuteTooltipTimer?.invalidate() - strongSelf.displayUnmuteTooltipTimer = nil - strongSelf.dismissUnmuteTooltipTimer?.invalidate() - strongSelf.dismissUnmuteTooltipTimer = nil - }, queue: Queue.mainQueue()) - timer.start() - strongSelf.displayUnmuteTooltipTimer = timer - } - } - } else if strongSelf.dismissUnmuteTooltipTimer == nil && strongSelf.displayUnmuteTooltipTimer != nil { - let timer = SwiftSignalKit.Timer(timeout: 0.4, repeat: false, completion: { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.displayUnmuteTooltipTimer?.invalidate() - strongSelf.displayUnmuteTooltipTimer = nil - - strongSelf.dismissUnmuteTooltipTimer?.invalidate() - strongSelf.dismissUnmuteTooltipTimer = nil - }, queue: Queue.mainQueue()) - timer.start() - strongSelf.dismissUnmuteTooltipTimer = timer - } - } - }) - - self.leaveButton.addTarget(self, action: #selector(self.leavePressed), forControlEvents: .touchUpInside) - self.actionButton.addTarget(self, action: #selector(self.actionPressed), forControlEvents: .touchUpInside) - self.audioButton.addTarget(self, action: #selector(self.audioPressed), forControlEvents: .touchUpInside) - self.cameraButton.addTarget(self, action: #selector(self.cameraPressed), forControlEvents: .touchUpInside) - self.switchCameraButton.addTarget(self, action: #selector(self.switchCameraPressed), forControlEvents: .touchUpInside) - self.optionsButton.contextAction = { [weak self] sourceNode, gesture in - self?.openSettingsMenu(sourceNode: sourceNode, gesture: gesture) - } - self.optionsButton.addTarget(self, action: #selector(self.optionsPressed), forControlEvents: .touchUpInside) - self.closeButton.addTarget(self, action: #selector(self.closePressed), forControlEvents: .touchUpInside) - self.panelButton.addTarget(self, action: #selector(self.panelPressed), forControlEvents: .touchUpInside) - - self.actionButtonColorDisposable = (self.actionButton.outerColor - |> deliverOnMainQueue).start(next: { [weak self] normalColor, activeColor in - if let strongSelf = self { - let animated = strongSelf.currentNormalButtonColor != nil || strongSelf.currentActiveButtonColor == nil - strongSelf.currentNormalButtonColor = normalColor - strongSelf.currentActiveButtonColor = activeColor - strongSelf.updateButtons(transition: animated ? .animated(duration: 0.3, curve: .linear) : .immediate) - } - }) - - self.fullscreenListNode.updateFloatingHeaderOffset = { [weak self] _, _ in - guard let strongSelf = self else { - return - } - - var visiblePeerIds = Set() - strongSelf.fullscreenListNode.forEachVisibleItemNode { itemNode in - if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode, let item = itemNode.item { - if item.videoEndpointId == nil { - visiblePeerIds.insert(item.peer.id) - } - } - } - strongSelf.mainStageNode.update(visiblePeerIds: visiblePeerIds) - } - - self.listNode.updateFloatingHeaderOffset = { [weak self] offset, transition in - if let strongSelf = self { - strongSelf.currentContentOffset = offset - if !(strongSelf.animatingExpansion || strongSelf.animatingInsertion || strongSelf.animatingAppearance) && (strongSelf.panGestureArguments == nil || strongSelf.isExpanded) { - strongSelf.updateDecorationsLayout(transition: transition) - } - } - } - - self.listNode.visibleContentOffsetChanged = { [weak self] offset in - guard let strongSelf = self else { - return - } - var scrollAtTop = false - if case let .known(value) = offset, value < 180.0 { - scrollAtTop = true - } else { - scrollAtTop = false - } - if scrollAtTop != strongSelf.scrollAtTop { - strongSelf.scrollAtTop = scrollAtTop - strongSelf.updateTitle(transition: .immediate) - } - } - - self.listNode.visibleBottomContentOffsetChanged = { [weak self] offset in - guard let strongSelf = self else { - return - } - if case let .known(value) = offset, value < 200.0 { - if let loadMoreToken = strongSelf.currentCallMembers?.1 { - strongSelf.currentLoadToken = loadMoreToken - strongSelf.call.loadMoreMembers(token: loadMoreToken) - } - } - } - - self.memberEventsDisposable.set((self.call.memberEvents - |> deliverOnMainQueue).start(next: { [weak self] event in - guard let strongSelf = self else { - return - } - if event.joined { - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - return - } - let text = strongSelf.presentationData.strings.VoiceChat_PeerJoinedText(EnginePeer(event.peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string - strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: EnginePeer(event.peer), text: text, action: nil), action: { _ in return false }) - } - })) - - self.reconnectedAsEventsDisposable.set((self.call.reconnectedAsEvents - |> deliverOnMainQueue).start(next: { [weak self] peer in - guard let strongSelf = self else { - return - } - let text: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - text = strongSelf.presentationData.strings.LiveStream_DisplayAsSuccess(EnginePeer(peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string - } else { - text = strongSelf.presentationData.strings.VoiceChat_DisplayAsSuccess(EnginePeer(peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)).string - } - strongSelf.presentUndoOverlay(content: .invitedToVoiceChat(context: strongSelf.context, peer: EnginePeer(peer), text: text, action: nil), action: { _ in return false }) - })) - - self.stateVersionDisposable.set((self.call.stateVersion - |> distinctUntilChanged - |> deliverOnMainQueue).start(next: { [weak self] _ in - guard let strongSelf = self else { - return - } - strongSelf.callStateDidReset() - })) - - self.titleNode.tapped = { [weak self] in - if let strongSelf = self, !strongSelf.isScheduling { - if strongSelf.callState?.canManageCall ?? false { - strongSelf.openTitleEditing() - } else if !strongSelf.titleNode.recordingIconNode.isHidden { - var hasTooltipAlready = false - strongSelf.controller?.forEachController { controller -> Bool in - if controller is TooltipScreen { - hasTooltipAlready = true - } - return true - } - if !hasTooltipAlready { - let location = strongSelf.titleNode.recordingIconNode.convert(strongSelf.titleNode.recordingIconNode.bounds, to: nil) - let text: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - text = presentationData.strings.LiveStream_RecordingInProgress - } else { - text = presentationData.strings.VoiceChat_RecordingInProgress - } - strongSelf.controller?.present(TooltipScreen(account: strongSelf.context.account, text: text, icon: nil, location: .point(location.offsetBy(dx: 1.0, dy: 0.0), .top), displayDuration: .custom(3.0), shouldDismissOnTouch: { _ in - return .dismiss(consume: true) - }), in: .window(.root)) - } - } - } - } - - self.scheduleCancelButton.pressed = { [weak self] in - if let strongSelf = self { - strongSelf.dismissScheduled() - } - } - - self.mainStageNode.controlsHidden = { [weak self] hidden in - if let strongSelf = self { - if hidden { - strongSelf.fullscreenListNode.alpha = 0.0 - } else { - strongSelf.fullscreenListNode.alpha = 1.0 - strongSelf.fullscreenListNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) - } - } - } - - self.mainStageNode.tapped = { [weak self] in - if let strongSelf = self, let (layout, navigationHeight) = strongSelf.validLayout, !strongSelf.animatingExpansion && !strongSelf.animatingMainStage && !strongSelf.mainStageNode.animating { - if case .regular = layout.metrics.widthClass { - strongSelf.panelHidden = !strongSelf.panelHidden - - strongSelf.animatingExpansion = true - let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) - strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) - strongSelf.updateDecorationsLayout(transition: transition) - } else { - let effectiveDisplayMode = strongSelf.displayMode - let nextDisplayMode: DisplayMode - switch effectiveDisplayMode { - case .modal: - nextDisplayMode = effectiveDisplayMode - case let .fullscreen(controlsHidden): - if controlsHidden { - nextDisplayMode = .fullscreen(controlsHidden: false) - } else { - nextDisplayMode = .fullscreen(controlsHidden: true) - } - } - strongSelf.updateDisplayMode(nextDisplayMode) - } - } - } - - self.mainStageNode.stopScreencast = { [weak self] in - if let strongSelf = self { - strongSelf.call.disableScreencast() - } - } - - self.mainStageNode.back = { [weak self] in - if let strongSelf = self, !strongSelf.isPanning && !strongSelf.animatingExpansion && !strongSelf.mainStageNode.animating { - strongSelf.currentForcedSpeaker = nil - strongSelf.updateDisplayMode(.modal(isExpanded: true, isFilled: true), fromPan: true) - strongSelf.effectiveSpeaker = nil - } - } - - self.mainStageNode.togglePin = { [weak self] in - if let strongSelf = self { - if let (peerId, videoEndpointId, _, _, _) = strongSelf.effectiveSpeaker { - if let _ = strongSelf.currentForcedSpeaker { - strongSelf.currentDominantSpeaker = (peerId, videoEndpointId, CACurrentMediaTime()) - strongSelf.currentForcedSpeaker = nil - } else { - strongSelf.currentForcedSpeaker = (peerId, videoEndpointId) - } - } - strongSelf.updateMembers() - } - } - - self.mainStageNode.switchTo = { [weak self] peerId in - if let strongSelf = self, let interaction = strongSelf.itemInteraction { - interaction.switchToPeer(peerId, nil, false) - } - } - - self.mainStageNode.getAudioLevel = { [weak self] peerId in - return self?.itemInteraction?.getAudioLevel(peerId) ?? .single(0.0) - } - - self.mainStageNode.getVideo = { [weak self] endpointId, isMyPeer, completion in - if let strongSelf = self { - if isMyPeer { - if strongSelf.readyVideoEndpointIds.contains(endpointId) { - completion(strongSelf.itemInteraction?.getPeerVideo(endpointId, .mainstage)) - } else { - strongSelf.myPeerVideoReadyDisposable.set((strongSelf.readyVideoEndpointIdsPromise.get() - |> filter { $0.contains(endpointId) } - |> take(1) - |> deliverOnMainQueue).start(next: { [weak self] _ in - if let strongSelf = self { - completion(strongSelf.itemInteraction?.getPeerVideo(endpointId, .mainstage)) - } - })) - } - } else { - if let input = (strongSelf.call as! PresentationGroupCallImpl).video(endpointId: endpointId) { - if let videoView = strongSelf.videoRenderingContext.makeView(input: input, blur: false) { - completion(GroupVideoNode(videoView: videoView, backdropVideoView: strongSelf.videoRenderingContext.makeView(input: input, blur: true))) - } - } - - /*strongSelf.call.makeIncomingVideoView(endpointId: endpointId, requestClone: GroupVideoNode.useBlurTransparency, completion: { videoView, backdropVideoView in - if let videoView = videoView { - completion(GroupVideoNode(videoView: videoView, backdropVideoView: backdropVideoView)) - } else { - completion(nil) - } - })*/ - } - } - } - - self.applicationStateDisposable = (self.context.sharedContext.applicationBindings.applicationIsActive - |> deliverOnMainQueue).start(next: { [weak self] active in - guard let strongSelf = self else { - return - } - strongSelf.appIsActive = active - }) - - if self.context.sharedContext.immediateExperimentalUISettings.enableDebugDataDisplay { - self.statsDisposable = ((call as! PresentationGroupCallImpl).getStats() - |> deliverOnMainQueue - |> then(.complete() |> delay(1.0, queue: .mainQueue())) - |> restart).start(next: { [weak self] stats in - guard let strongSelf = self else { - return - } - for (endpointId, videoNode) in strongSelf.videoNodes { - if let incomingVideoStats = stats.incomingVideoStats[endpointId] { - videoNode.updateDebugInfo(text: "in: \(incomingVideoStats.receivingQuality)\n srv: \(incomingVideoStats.availableQuality)") - } - } - if let (_, maybeEndpointId, _, _, _) = strongSelf.mainStageNode.currentPeer, let endpointId = maybeEndpointId { - if let incomingVideoStats = stats.incomingVideoStats[endpointId] { - strongSelf.mainStageNode.currentVideoNode?.updateDebugInfo(text: "in: \(incomingVideoStats.receivingQuality)\n srv: \(incomingVideoStats.availableQuality)") - } - } - }) - } - } - - deinit { - self.presentationDataDisposable?.dispose() - self.peerViewDisposable?.dispose() - self.leaveDisposable.dispose() - self.isMutedDisposable?.dispose() - self.isNoiseSuppressionEnabledDisposable?.dispose() - self.callStateDisposable?.dispose() - self.audioOutputStateDisposable?.dispose() - self.memberStatesDisposable?.dispose() - self.audioLevelsDisposable?.dispose() - self.myAudioLevelDisposable?.dispose() - self.isSpeakingDisposable?.dispose() - self.inviteDisposable.dispose() - self.memberEventsDisposable.dispose() - self.reconnectedAsEventsDisposable.dispose() - self.stateVersionDisposable.dispose() - self.updateAvatarDisposable.dispose() - self.ignoreConnectingTimer?.invalidate() - self.readyVideoDisposables.dispose() - self.applicationStateDisposable?.dispose() - self.myPeerVideoReadyDisposable.dispose() - self.statsDisposable?.dispose() - } - - private func openSettingsMenu(sourceNode: ASDisplayNode, gesture: ContextGesture?) { - let items: Signal<[ContextMenuItem], NoError> = self.contextMenuMainItems() - if let controller = self.controller { - let contextController = ContextController(account: self.context.account, presentationData: self.presentationData.withUpdated(theme: self.darkTheme), source: .reference(VoiceChatContextReferenceContentSource(controller: controller, sourceNode: self.optionsButton.referenceNode)), items: items |> map { ContextController.Items(content: .list($0)) }, gesture: gesture) - controller.presentInGlobalOverlay(contextController) - } - } - - private func contextMenuMainItems() -> Signal<[ContextMenuItem], NoError> { - guard let myPeerId = self.callState?.myPeerId else { - return .single([]) - } - - let canManageCall = self.callState?.canManageCall == true - let avatarSize = CGSize(width: 28.0, height: 28.0) - return combineLatest(self.displayAsPeersPromise.get(), self.context.account.postbox.loadedPeerWithId(self.call.peerId), self.inviteLinksPromise.get()) - |> take(1) - |> deliverOnMainQueue - |> map { [weak self] peers, chatPeer, inviteLinks -> [ContextMenuItem] in - guard let strongSelf = self else { - return [] - } - - var items: [ContextMenuItem] = [] - - if peers.count > 1 { - for peer in peers { - if peer.peer.id == myPeerId { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_DisplayAs, textLayout: .secondLineWithValue(EnginePeer(peer.peer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder)), icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: avatarSize, signal: peerAvatarCompleteImage(account: strongSelf.context.account, peer: EnginePeer(peer.peer), size: avatarSize)), action: { c, _ in - guard let strongSelf = self else { - return - } - c.setItems(strongSelf.contextMenuDisplayAsItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) - }))) - items.append(.separator) - break - } - } - } - - if let (availableOutputs, currentOutput) = strongSelf.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 = strongSelf.presentationData.strings.Call_AudioRouteSpeaker - case .headphones: - title = strongSelf.presentationData.strings.Call_AudioRouteHeadphones - case let .port(port): - title = port.name - } - currentOutputTitle = title - break - } - } - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_ContextAudio, textLayout: .secondLineWithValue(currentOutputTitle), icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Audio"), color: theme.actionSheet.primaryTextColor) - }, action: { c, _ in - guard let strongSelf = self else { - return - } - c.setItems(strongSelf.contextMenuAudioItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) - }))) - } - - if canManageCall { - let text: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - text = strongSelf.presentationData.strings.LiveStream_EditTitle - } else { - text = strongSelf.presentationData.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: { _, f in - f(.default) - - guard let strongSelf = self else { - return - } - strongSelf.openTitleEditing() - }))) - - var hasPermissions = true - if let chatPeer = chatPeer as? TelegramChannel { - if case .broadcast = chatPeer.info { - hasPermissions = false - } else if chatPeer.flags.contains(.isGigagroup) { - hasPermissions = false - } - } - if hasPermissions { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_EditPermissions, icon: { theme -> UIImage? in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Restrict"), color: theme.actionSheet.primaryTextColor) - }, action: { c, _ in - guard let strongSelf = self else { - return - } - c.setItems(strongSelf.contextMenuPermissionItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) - }))) - } - } - - if let inviteLinks = inviteLinks { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_Share, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Link"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in - f(.default) - - self?.presentShare(inviteLinks) - }))) - } - - let isScheduled = strongSelf.isScheduled - - let canSpeak: Bool - if let callState = strongSelf.callState { - if let muteState = callState.muteState { - canSpeak = muteState.canUnmute - } else { - canSpeak = true - } - } else { - canSpeak = false - } - - if !isScheduled && canSpeak { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_NoiseSuppression, textColor: .primary, textLayout: .secondLineWithValue(strongSelf.isNoiseSuppressionEnabled ? strongSelf.presentationData.strings.VoiceChat_NoiseSuppressionEnabled : strongSelf.presentationData.strings.VoiceChat_NoiseSuppressionDisabled), icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Noise"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in - f(.dismissWithoutContent) - if let strongSelf = self { - strongSelf.call.setIsNoiseSuppressionEnabled(!strongSelf.isNoiseSuppressionEnabled) - } - }))) - } - - if let callState = strongSelf.callState, callState.isVideoEnabled && (callState.muteState?.canUnmute ?? true) { - if #available(iOS 12.0, *) { - if strongSelf.call.hasScreencast { - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.VoiceChat_StopScreenSharing, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/ShareScreen"), color: theme.actionSheet.primaryTextColor) - }, action: { _, f in - f(.default) - - self?.call.disableScreencast() - }))) - } else { - items.append(.custom(VoiceChatShareScreenContextItem(context: strongSelf.context, text: strongSelf.presentationData.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 = strongSelf.callState?.recordingStartTimestamp { - items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { _, f in - f(.dismissWithoutContent) - - guard let strongSelf = self else { - return - } - - let alertController = textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: nil, text: strongSelf.presentationData.strings.VoiceChat_StopRecordingTitle, actions: [TextAlertAction(type: .genericAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.VoiceChat_StopRecordingStop, action: { - if let strongSelf = self { - strongSelf.call.setShouldBeRecording(false, title: nil, videoOrientation: nil) - - - let text: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - text = strongSelf.presentationData.strings.LiveStream_RecordingSaved - } else { - text = strongSelf.presentationData.strings.VideoChat_RecordingSaved - } - - strongSelf.presentUndoOverlay(content: .forward(savedMessages: true, text: text), action: { [weak self] value in - if case .info = value, let strongSelf = self, let navigationController = strongSelf.controller?.navigationController as? NavigationController { - let context = strongSelf.context - strongSelf.controller?.dismiss(completion: { - 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 = peer else { - return - } - context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), keepStack: .always, purposefulAction: {}, peekData: nil)) - }) - } - }) - - return true - } - return false - }) - } - })]) - self?.controller?.present(alertController, in: .window(.root)) - }), false)) - } else { - let text: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - text = strongSelf.presentationData.strings.LiveStream_StartRecording - } else { - text = strongSelf.presentationData.strings.VoiceChat_StartRecording - } - if strongSelf.callState?.scheduleTimestamp == nil { - items.append(.action(ContextMenuActionItem(text: text, icon: { theme -> UIImage? in - return generateStartRecordingIcon(color: theme.actionSheet.primaryTextColor) - }, action: { _, f in - f(.dismissWithoutContent) - - guard let strongSelf = self, let peer = strongSelf.peer else { - return - } - - let controller = VoiceChatRecordingSetupController(context: strongSelf.context, peer: peer, completion: { [weak self] videoOrientation in - if let strongSelf = self { - let title: String - let text: String - let placeholder: String - if let _ = videoOrientation { - placeholder = strongSelf.presentationData.strings.VoiceChat_RecordingTitlePlaceholderVideo - } else { - placeholder = strongSelf.presentationData.strings.VoiceChat_RecordingTitlePlaceholder - } - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - title = strongSelf.presentationData.strings.LiveStream_StartRecordingTitle - if let _ = videoOrientation { - text = strongSelf.presentationData.strings.LiveStream_StartRecordingTextVideo - } else { - text = strongSelf.presentationData.strings.LiveStream_StartRecordingText - } - } else { - title = strongSelf.presentationData.strings.VoiceChat_StartRecordingTitle - if let _ = videoOrientation { - text = strongSelf.presentationData.strings.VoiceChat_StartRecordingTextVideo - } else { - text = strongSelf.presentationData.strings.VoiceChat_StartRecordingText - } - } - - let controller = voiceChatTitleEditController(sharedContext: strongSelf.context.sharedContext, account: strongSelf.context.account, forceTheme: strongSelf.darkTheme, title: title, text: text, placeholder: placeholder, value: nil, maxLength: 40, apply: { title in - if let strongSelf = self, let title = title { - strongSelf.call.setShouldBeRecording(true, title: title, videoOrientation: videoOrientation) - - let text: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - text = strongSelf.presentationData.strings.LiveStream_RecordingStarted - } else { - text = strongSelf.presentationData.strings.VoiceChat_RecordingStarted - } - - strongSelf.presentUndoOverlay(content: .voiceChatRecording(text: text), action: { _ in return false }) - strongSelf.call.playTone(.recordingStarted) - } - }) - strongSelf.controller?.present(controller, in: .window(.root)) - } - }) - self?.controller?.present(controller, in: .window(.root)) - }))) - } - } - } - - if canManageCall { - let text: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - text = isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelLiveStream : strongSelf.presentationData.strings.VoiceChat_EndLiveStream - } else { - text = isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelVoiceChat : strongSelf.presentationData.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: { _, f in - f(.dismissWithoutContent) - - guard let strongSelf = self else { - return - } - - let action: () -> Void = { - guard let strongSelf = self else { - return - } - - let _ = (strongSelf.call.leave(terminateIfPossible: true) - |> filter { $0 } - |> take(1) - |> deliverOnMainQueue).start(completed: { - self?.controller?.dismiss() - }) - } - - let title: String - let text: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - title = isScheduled ? strongSelf.presentationData.strings.LiveStream_CancelConfirmationTitle : strongSelf.presentationData.strings.LiveStream_EndConfirmationTitle - text = isScheduled ? strongSelf.presentationData.strings.LiveStream_CancelConfirmationText : strongSelf.presentationData.strings.LiveStream_EndConfirmationText - } else { - title = isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationTitle : strongSelf.presentationData.strings.VoiceChat_EndConfirmationTitle - text = isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationText : strongSelf.presentationData.strings.VoiceChat_EndConfirmationText - } - - let alertController = textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationEnd : strongSelf.presentationData.strings.VoiceChat_EndConfirmationEnd, action: { - action() - })]) - strongSelf.controller?.present(alertController, in: .window(.root)) - }))) - } else { - let leaveText: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - leaveText = strongSelf.presentationData.strings.LiveStream_LeaveVoiceChat - } else { - leaveText = strongSelf.presentationData.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: { _, f in - f(.dismissWithoutContent) - - guard let strongSelf = self else { - return - } - - let _ = (strongSelf.call.leave(terminateIfPossible: false) - |> filter { $0 } - |> take(1) - |> deliverOnMainQueue).start(completed: { - self?.controller?.dismiss() - }) - }))) - } - return items - } - } - - private func contextMenuAudioItems() -> Signal<[ContextMenuItem], NoError> { - guard let (availableOutputs, currentOutput) = self.audioOutputState else { - return .single([]) - } - - var items: [ContextMenuItem] = [] - for output in availableOutputs { - let title: String - switch output { - case .builtin: - title = UIDevice.current.model - case .speaker: - title = self.presentationData.strings.Call_AudioRouteSpeaker - case .headphones: - title = self.presentationData.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) - self?.call.setCurrentAudioOutput(output) - }))) - } - items.append(.separator) - 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) - }, action: { [weak self] (c, _) in - guard let strongSelf = self else { - return - } - c.setItems(strongSelf.contextMenuMainItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) - }))) - return .single(items) - } - - private func contextMenuDisplayAsItems() -> Signal<[ContextMenuItem], NoError> { - guard let myPeerId = self.callState?.myPeerId else { - return .single([]) - } - - let avatarSize = CGSize(width: 28.0, height: 28.0) - let darkTheme = self.darkTheme - - return self.displayAsPeersPromise.get() - |> take(1) - |> map { [weak self] peers -> [ContextMenuItem] in - guard let strongSelf = self else { - return [] - } - - var items: [ContextMenuItem] = [] - - var isGroup = false - for peer in peers { - 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 ? strongSelf.presentationData.strings.VoiceChat_DisplayAsInfoGroup : strongSelf.presentationData.strings.VoiceChat_DisplayAsInfo, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Call/Context Menu/Accounts"), color: theme.actionSheet.primaryTextColor) - }), true)) - - for peer in peers { - var subtitle: String? - if peer.peer.id.namespace == Namespaces.Peer.CloudUser { - subtitle = strongSelf.presentationData.strings.VoiceChat_PersonalAccount - } else if let subscribers = peer.subscribers { - if let peer = peer.peer as? TelegramChannel, case .broadcast = peer.info { - subtitle = strongSelf.presentationData.strings.Conversation_StatusSubscribers(subscribers) - } else { - subtitle = strongSelf.presentationData.strings.Conversation_StatusMembers(subscribers) - } - } - - let isSelected = peer.peer.id == myPeerId - let extendedAvatarSize = CGSize(width: 35.0, height: 35.0) - let avatarSignal = peerAvatarCompleteImage(account: strongSelf.context.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(darkTheme.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: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), textLayout: subtitle.flatMap { .secondLineWithValue($0) } ?? .singleLine, icon: { _ in nil }, iconSource: ContextMenuActionItemIconSource(size: isSelected ? extendedAvatarSize : avatarSize, signal: avatarSignal), action: { _, f in - f(.default) - - guard let strongSelf = self else { - return - } - - if peer.peer.id != myPeerId { - strongSelf.call.reconnect(as: peer.peer.id) - } - }))) - - if peer.peer.id.namespace == Namespaces.Peer.CloudUser { - items.append(.separator) - } - } - items.append(.separator) - items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Common_Back, icon: { theme in - return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor) - }, action: { (c, _) in - guard let strongSelf = self else { - return - } - c.setItems(strongSelf.contextMenuMainItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) - }))) - return items - } - } - - private func contextMenuPermissionItems() -> Signal<[ContextMenuItem], NoError> { - var items: [ContextMenuItem] = [] - if let callState = self.callState, callState.canManageCall, let defaultParticipantMuteState = callState.defaultParticipantMuteState { - let isMuted = defaultParticipantMuteState == .muted - - items.append(.action(ContextMenuActionItem(text: self.presentationData.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 strongSelf = self else { - return - } - strongSelf.call.updateDefaultParticipantsAreMuted(isMuted: false) - }))) - items.append(.action(ContextMenuActionItem(text: self.presentationData.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 strongSelf = self else { - return - } - strongSelf.call.updateDefaultParticipantsAreMuted(isMuted: true) - }))) - items.append(.separator) - 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) - }, action: { [weak self] (c, _) in - guard let strongSelf = self else { - return - } - c.setItems(strongSelf.contextMenuMainItems() |> map { ContextController.Items(content: .list($0)) }, minHeight: nil) - }))) - } - return .single(items) - } - - override func didLoad() { - super.didLoad() - - self.view.disablesInteractiveTransitionGestureRecognizer = true - self.view.disablesInteractiveModalDismiss = true - - self.dimNode.view.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.dimTapGesture(_:)))) - - let longTapRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(self.actionButtonPressGesture(_:))) - longTapRecognizer.minimumPressDuration = 0.001 - longTapRecognizer.delegate = self - self.actionButton.view.addGestureRecognizer(longTapRecognizer) - - let panRecognizer = DirectionalPanGestureRecognizer(target: self, action: #selector(self.panGesture(_:))) - panRecognizer.delegate = self - panRecognizer.delaysTouchesBegan = false - panRecognizer.cancelsTouchesInView = true - self.view.addGestureRecognizer(panRecognizer) - - if self.isScheduling { - self.setupSchedulePickerView() - self.updateScheduleButtonTitle() - } - } - - private func updateSchedulePickerLimits() { - let timeZone = TimeZone(secondsFromGMT: 0)! - var calendar = Calendar(identifier: .gregorian) - calendar.timeZone = timeZone - let currentDate = Date() - var components = calendar.dateComponents(Set([.era, .year, .month, .day, .hour, .minute, .second]), from: currentDate) - components.second = 0 - - let roundedDate = calendar.date(from: components)! - let next1MinDate = calendar.date(byAdding: .minute, value: 1, to: roundedDate) - - let minute = components.minute ?? 0 - components.minute = 0 - let roundedToHourDate = calendar.date(from: components)! - components.hour = 0 - - let roundedToMidnightDate = calendar.date(from: components)! - let nextTwoHourDate = calendar.date(byAdding: .hour, value: minute > 30 ? 4 : 3, to: roundedToHourDate) - let maxDate = calendar.date(byAdding: .day, value: 8, to: roundedToMidnightDate) - - if let date = calendar.date(byAdding: .day, value: 365, to: currentDate) { - self.pickerView?.maximumDate = date - } - if let next1MinDate = next1MinDate, let nextTwoHourDate = nextTwoHourDate { - self.pickerView?.minimumDate = next1MinDate - self.pickerView?.maximumDate = maxDate - self.pickerView?.date = nextTwoHourDate - } - } - - private func setupSchedulePickerView() { - var currentDate: Date? - if let pickerView = self.pickerView { - currentDate = pickerView.date - pickerView.removeFromSuperview() - } - - let textColor = UIColor.white - UILabel.setDateLabel(textColor) - - let pickerView = UIDatePicker() - pickerView.timeZone = TimeZone(secondsFromGMT: 0) - pickerView.datePickerMode = .countDownTimer - pickerView.datePickerMode = .dateAndTime - pickerView.locale = Locale.current - pickerView.timeZone = TimeZone.current - pickerView.minuteInterval = 1 - self.contentContainer.view.addSubview(pickerView) - pickerView.addTarget(self, action: #selector(self.scheduleDatePickerUpdated), for: .valueChanged) - if #available(iOS 13.4, *) { - pickerView.preferredDatePickerStyle = .wheels - } - pickerView.setValue(textColor, forKey: "textColor") - self.pickerView = pickerView - - self.updateSchedulePickerLimits() - if let currentDate = currentDate { - pickerView.date = currentDate - } - } - - private let calendar = Calendar(identifier: .gregorian) - private func updateScheduleButtonTitle() { - guard let date = self.pickerView?.date else { - return - } - - let calendar = Calendar(identifier: .gregorian) - let currentTimestamp = Int32(CFAbsoluteTimeGetCurrent() + kCFAbsoluteTimeIntervalSince1970) - let timestamp = Int32(date.timeIntervalSince1970) - let time = stringForMessageTimestamp(timestamp: timestamp, dateTimeFormat: self.presentationData.dateTimeFormat) - let buttonTitle: String - if calendar.isDateInToday(date) { - buttonTitle = self.presentationData.strings.ScheduleVoiceChat_ScheduleToday(time).string - } else if calendar.isDateInTomorrow(date) { - buttonTitle = self.presentationData.strings.ScheduleVoiceChat_ScheduleTomorrow(time).string - } else { - buttonTitle = self.presentationData.strings.ScheduleVoiceChat_ScheduleOn(self.dateFormatter.string(from: date), time).string - } - self.scheduleButtonTitle = buttonTitle - - let delta = timestamp - currentTimestamp - - var isGroup = true - if let peer = self.peer as? TelegramChannel, case .broadcast = peer.info { - isGroup = false - } - let intervalString = scheduledTimeIntervalString(strings: self.presentationData.strings, value: max(60, delta)) - self.scheduleTextNode.attributedText = NSAttributedString(string: isGroup ? self.presentationData.strings.ScheduleVoiceChat_GroupText(intervalString).string : self.presentationData.strings.ScheduleLiveStream_ChannelText(intervalString).string, font: Font.regular(14.0), textColor: UIColor(rgb: 0x8e8e93)) - - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) - } - } - - @objc private func scheduleDatePickerUpdated() { - self.updateScheduleButtonTitle() - } - - private func schedule() { - if let date = self.pickerView?.date, date > Date() { - self.call.schedule(timestamp: Int32(date.timeIntervalSince1970)) - - self.isScheduling = false - self.transitionToScheduled() - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) - } - } - } - - private func dismissScheduled() { - self.leaveDisposable.set((self.call.leave(terminateIfPossible: true) - |> deliverOnMainQueue).start(completed: { [weak self] in - self?.controller?.dismiss(closing: true) - })) - } - - private func transitionToScheduled() { - let springDuration: Double = 0.6 - let springDamping: CGFloat = 100.0 - - self.optionsButton.alpha = 1.0 - self.optionsButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) - self.optionsButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, damping: springDamping) - self.optionsButton.isUserInteractionEnabled = true - - self.closeButton.alpha = 1.0 - self.closeButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) - self.closeButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, damping: springDamping) - self.closeButton.isUserInteractionEnabled = true - - self.audioButton.alpha = 1.0 - self.audioButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) - self.audioButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, damping: springDamping) - self.audioButton.isUserInteractionEnabled = true - - self.leaveButton.alpha = 1.0 - self.leaveButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) - self.leaveButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: springDuration, damping: springDamping) - self.leaveButton.isUserInteractionEnabled = true - - self.scheduleCancelButton.alpha = 0.0 - self.scheduleCancelButton.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15) - self.scheduleCancelButton.layer.animatePosition(from: CGPoint(), to: CGPoint(x: 0.0, y: 26.0), duration: 0.2, removeOnCompletion: false, additive: true) - - self.actionButton.titleLabel.layer.animatePosition(from: CGPoint(x: 0.0, y: -26.0), to: CGPoint(), duration: 0.2, additive: true) - - if let pickerView = self.pickerView { - self.pickerView = nil - pickerView.alpha = 0.0 - pickerView.layer.animateScale(from: 1.0, to: 0.25, duration: 0.15, removeOnCompletion: false) - pickerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak pickerView] _ in - pickerView?.removeFromSuperview() - }) - pickerView.isUserInteractionEnabled = false - } - - self.timerNode.isHidden = false - self.timerNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25) - self.timerNode.animateIn() - - self.scheduleTextNode.alpha = 0.0 - self.scheduleTextNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25) - - self.updateTitle(slide: true, transition: .animated(duration: 0.2, curve: .easeInOut)) - } - - private func transitionToCall() { - self.updateDecorationsColors() - - self.listNode.alpha = 1.0 - self.listNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) - self.listNode.isUserInteractionEnabled = true - - self.timerNode.alpha = 0.0 - self.timerNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak self] _ in - self?.timerNode.isHidden = true - }) - - if self.audioButton.isHidden { - self.audioButton.isHidden = false - self.audioButton.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2) - self.audioButton.layer.animateSpring(from: 0.01 as NSNumber, to: 1.0 as NSNumber, keyPath: "transform.scale", duration: 0.6, damping: 100.0) - } - - self.updateTitle(transition: .animated(duration: 0.2, curve: .easeInOut)) - } - - @objc private func optionsPressed() { - self.optionsButton.play() - self.optionsButton.contextAction?(self.optionsButton.containerNode, nil) - } - - @objc private func closePressed() { - self.controller?.dismiss(closing: false) - self.controller?.dismissAllTooltips() - } - - @objc private func panelPressed() { - guard let (layout, navigationHeight) = self.validLayout, !self.animatingExpansion && !self.animatingMainStage && !self.mainStageNode.animating else { - return - } - self.panelHidden = !self.panelHidden - - self.animatingExpansion = true - let transition = ContainedViewLayoutTransition.animated(duration: 0.3, curve: .easeInOut) - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) - self.updateDecorationsLayout(transition: transition) - } - - @objc private func leavePressed() { - self.hapticFeedback.impact(.light) - self.controller?.dismissAllTooltips() - - if let callState = self.callState, callState.canManageCall { - let action: () -> Void = { [weak self] in - guard let strongSelf = self else { - return - } - - strongSelf.leaveDisposable.set((strongSelf.call.leave(terminateIfPossible: true) - |> deliverOnMainQueue).start(completed: { - self?.controller?.dismiss() - })) - } - - let actionSheet = ActionSheetController(presentationData: self.presentationData.withUpdated(theme: self.darkTheme)) - var items: [ActionSheetItem] = [] - - let leaveTitle: String - let leaveAndCancelTitle: String - - if let channel = self.peer as? TelegramChannel, case .broadcast = channel.info { - leaveTitle = self.presentationData.strings.LiveStream_LeaveConfirmation - leaveAndCancelTitle = self.isScheduled ? self.presentationData.strings.LiveStream_LeaveAndCancelVoiceChat : self.presentationData.strings.LiveStream_LeaveAndEndVoiceChat - } else { - leaveTitle = self.presentationData.strings.VoiceChat_LeaveConfirmation - leaveAndCancelTitle = self.isScheduled ? self.presentationData.strings.VoiceChat_LeaveAndCancelVoiceChat : self.presentationData.strings.VoiceChat_LeaveAndEndVoiceChat - } - - items.append(ActionSheetTextItem(title: leaveTitle)) - items.append(ActionSheetButtonItem(title: leaveAndCancelTitle, color: .destructive, action: { [weak self, weak actionSheet] in - actionSheet?.dismissAnimated() - - if let strongSelf = self { - let title: String - let text: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - title = strongSelf.isScheduled ? strongSelf.presentationData.strings.LiveStream_CancelConfirmationTitle : strongSelf.presentationData.strings.LiveStream_EndConfirmationTitle - text = strongSelf.isScheduled ? strongSelf.presentationData.strings.LiveStream_CancelConfirmationText : strongSelf.presentationData.strings.LiveStream_EndConfirmationText - } else { - title = strongSelf.isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationTitle : strongSelf.presentationData.strings.VoiceChat_EndConfirmationTitle - text = strongSelf.isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationText : strongSelf.presentationData.strings.VoiceChat_EndConfirmationText - } - - if let (members, _) = strongSelf.currentCallMembers, members.count >= 10 || true { - let alertController = textAlertController(context: strongSelf.context, forceTheme: strongSelf.darkTheme, title: title, text: text, actions: [TextAlertAction(type: .defaultAction, title: strongSelf.presentationData.strings.Common_Cancel, action: {}), TextAlertAction(type: .genericAction, title: strongSelf.isScheduled ? strongSelf.presentationData.strings.VoiceChat_CancelConfirmationEnd : strongSelf.presentationData.strings.VoiceChat_EndConfirmationEnd, action: { - action() - })]) - strongSelf.controller?.present(alertController, in: .window(.root)) - } else { - action() - } - } - })) - - let leaveText: String - if let channel = self.peer as? TelegramChannel, case .broadcast = channel.info { - leaveText = self.presentationData.strings.LiveStream_LeaveVoiceChat - } else { - leaveText = self.presentationData.strings.VoiceChat_LeaveVoiceChat - } - - items.append(ActionSheetButtonItem(title: leaveText, color: .accent, action: { [weak self, weak actionSheet] in - actionSheet?.dismissAnimated() - - guard let strongSelf = self else { - return - } - - strongSelf.leaveDisposable.set((strongSelf.call.leave(terminateIfPossible: false) - |> deliverOnMainQueue).start(completed: { [weak self] in - self?.controller?.dismiss(closing: true) - })) - })) - - actionSheet.setItemGroups([ - ActionSheetItemGroup(items: items), - ActionSheetItemGroup(items: [ - ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in - actionSheet?.dismissAnimated() - }) - ]) - ]) - self.controller?.present(actionSheet, in: .window(.root)) - } else { - self.leaveDisposable.set((self.call.leave(terminateIfPossible: false) - |> deliverOnMainQueue).start(completed: { [weak self] in - self?.controller?.dismiss(closing: true) - })) - } - } - - @objc func dimTapGesture(_ recognizer: UITapGestureRecognizer) { - if case .ended = recognizer.state { - if self.isScheduling { - self.dismissScheduled() - } else { - self.controller?.dismiss(closing: false) - self.controller?.dismissAllTooltips() - } - } - } - - private func presentUndoOverlay(content: UndoOverlayContent, action: @escaping (UndoOverlayAction) -> Bool) { - var animateInAsReplacement = false - self.controller?.forEachController { c in - if let c = c as? UndoOverlayController { - animateInAsReplacement = true - c.dismiss() - } - return true - } - self.controller?.present(UndoOverlayController(presentationData: self.presentationData, content: content, elevatedLayout: false, animateInAsReplacement: animateInAsReplacement, action: action), in: .current) - } - - private func presentShare(_ inviteLinks: GroupCallInviteLinks) { - let formatSendTitle: (String) -> String = { string in - var string = string - if string.contains("[") && string.contains("]") { - if let startIndex = string.firstIndex(of: "["), let endIndex = string.firstIndex(of: "]") { - string.removeSubrange(startIndex ... endIndex) - } - } else { - string = string.trimmingCharacters(in: CharacterSet(charactersIn: "0123456789-,.")) - } - return string - } - - let _ = (self.context.account.postbox.loadedPeerWithId(self.call.peerId) - |> deliverOnMainQueue).start(next: { [weak self] peer in - if let strongSelf = self { - var inviteLinks = inviteLinks - - if let peer = peer as? TelegramChannel, case .group = peer.info, !peer.flags.contains(.isGigagroup), !(peer.addressName ?? "").isEmpty, let callState = strongSelf.callState, let defaultParticipantMuteState = callState.defaultParticipantMuteState { - let isMuted = defaultParticipantMuteState == .muted - - if !isMuted { - inviteLinks = GroupCallInviteLinks(listenerLink: inviteLinks.listenerLink, speakerLink: nil) - } - } - - let presentationData = strongSelf.presentationData - - var segmentedValues: [ShareControllerSegmentedValue]? - if let speakerLink = inviteLinks.speakerLink { - segmentedValues = [ShareControllerSegmentedValue(title: presentationData.strings.VoiceChat_InviteLink_Speaker, subject: .url(speakerLink), actionTitle: presentationData.strings.VoiceChat_InviteLink_CopySpeakerLink, formatSendTitle: { count in - return formatSendTitle(presentationData.strings.VoiceChat_InviteLink_InviteSpeakers(Int32(count))) - }), ShareControllerSegmentedValue(title: presentationData.strings.VoiceChat_InviteLink_Listener, subject: .url(inviteLinks.listenerLink), actionTitle: presentationData.strings.VoiceChat_InviteLink_CopyListenerLink, formatSendTitle: { count in - return formatSendTitle(presentationData.strings.VoiceChat_InviteLink_InviteListeners(Int32(count))) - })] - } - let shareController = ShareController(context: strongSelf.context, subject: .url(inviteLinks.listenerLink), segmentedValues: segmentedValues, forceTheme: strongSelf.darkTheme, forcedActionTitle: presentationData.strings.VoiceChat_CopyInviteLink) - shareController.completed = { [weak self] peerIds in - if let strongSelf = self { - let _ = (strongSelf.context.engine.data.get( - EngineDataList( - peerIds.map(TelegramEngine.EngineData.Item.Peer.Peer.init) - ) - ) - |> deliverOnMainQueue).start(next: { [weak self] peerList in - if let strongSelf = self { - let peers = peerList.compactMap { $0 } - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - - let text: String - var isSavedMessages = false - if peers.count == 1, let peer = peers.first { - isSavedMessages = peer.id == strongSelf.context.account.peerId - let peerName = peer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - text = presentationData.strings.VoiceChat_ForwardTooltip_Chat(peerName).string - } else if peers.count == 2, let firstPeer = peers.first, let secondPeer = peers.last { - let firstPeerName = firstPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : firstPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - let secondPeerName = secondPeer.id == strongSelf.context.account.peerId ? presentationData.strings.DialogList_SavedMessages : secondPeer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - text = presentationData.strings.VoiceChat_ForwardTooltip_TwoChats(firstPeerName, secondPeerName).string - } else if let peer = peers.first { - let peerName = peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder) - text = presentationData.strings.VoiceChat_ForwardTooltip_ManyChats(peerName, "\(peers.count - 1)").string - } else { - text = "" - } - - strongSelf.controller?.present(UndoOverlayController(presentationData: presentationData, content: .forward(savedMessages: isSavedMessages, text: text), elevatedLayout: false, animateInAsReplacement: true, action: { _ in return false }), in: .current) - } - }) - } - } - shareController.actionCompleted = { [weak self] in - if let strongSelf = self { - let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } - strongSelf.controller?.present(UndoOverlayController(presentationData: presentationData, content: .linkCopied(text: presentationData.strings.VoiceChat_InviteLinkCopiedText), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root)) - } - } - strongSelf.controller?.present(shareController, in: .window(.root)) - } - }) - } - - private var actionButtonPressTimer: SwiftSignalKit.Timer? - private var actionButtonPressedTimestamp: Double? - private func startActionButtonPressTimer() { - self.actionButtonPressTimer?.invalidate() - let pressTimer = SwiftSignalKit.Timer(timeout: 0.185, repeat: false, completion: { [weak self] in - self?.actionButtonPressedTimestamp = CACurrentMediaTime() - self?.actionButtonPressTimerFired() - self?.actionButtonPressTimer = nil - }, queue: Queue.mainQueue()) - self.actionButtonPressTimer = pressTimer - pressTimer.start() - } - - private func stopActionButtonPressTimer() { - self.actionButtonPressTimer?.invalidate() - self.actionButtonPressTimer = nil - } - - private func actionButtonPressTimerFired() { - guard let callState = self.callState else { - return - } - if callState.muteState != nil { - self.pushingToTalk = true - self.call.setIsMuted(action: .muted(isPushToTalkActive: true)) - } - - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) - } - - self.updateMembers() - } - - @objc private func actionButtonPressGesture(_ gestureRecognizer: UILongPressGestureRecognizer) { - guard let callState = self.callState else { - return - } - if case .connecting = callState.networkState, callState.scheduleTimestamp == nil && !self.isScheduling { - return - } - if callState.scheduleTimestamp != nil || self.isScheduling { - switch gestureRecognizer.state { - case .began: - self.actionButton.pressing = true - self.hapticFeedback.impact(.light) - case .ended, .cancelled: - self.actionButton.pressing = false - - let location = gestureRecognizer.location(in: self.actionButton.view) - if self.actionButton.hitTest(location, with: nil) != nil { - if self.isScheduling { - self.schedule() - } else if callState.canManageCall { - self.call.startScheduled() - } else { - if !callState.subscribedToScheduled { - let location = self.actionButton.view.convert(self.actionButton.bounds, to: self.view).center - let point = CGRect(origin: CGPoint(x: location.x - 5.0, y: location.y - 5.0 - 68.0), size: CGSize(width: 10.0, height: 10.0)) - self.controller?.present(TooltipScreen(account: self.context.account, text: self.presentationData.strings.VoiceChat_ReminderNotify, style: .gradient(UIColor(rgb: 0x262c5a), UIColor(rgb: 0x5d2835)), icon: nil, location: .point(point, .bottom), displayDuration: .custom(3.0), shouldDismissOnTouch: { _ in - return .dismiss(consume: false) - }), in: .window(.root)) - } - self.call.toggleScheduledSubscription(!callState.subscribedToScheduled) - } - } - default: - break - } - return - } - if let muteState = callState.muteState { - if !muteState.canUnmute { - switch gestureRecognizer.state { - case .began: - self.actionButton.pressing = true - self.hapticFeedback.impact(.light) - case .ended, .cancelled: - self.actionButton.pressing = false - - let location = gestureRecognizer.location(in: self.actionButton.view) - if self.actionButton.hitTest(location, with: nil) != nil { - self.call.raiseHand() - self.actionButton.playAnimation() - } - default: - break - } - return - } - } - switch gestureRecognizer.state { - case .began: - self.actionButton.pressing = true - self.hapticFeedback.impact(.light) - self.actionButtonPressedTimestamp = nil - self.startActionButtonPressTimer() - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) - } - case .ended, .cancelled: - if self.actionButtonPressTimer != nil { - self.pushingToTalk = false - self.actionButton.pressing = false - - self.stopActionButtonPressTimer() - self.call.toggleIsMuted() - } else { - self.hapticFeedback.impact(.light) - if self.pushingToTalk, let timestamp = self.actionButtonPressedTimestamp, CACurrentMediaTime() < timestamp + 0.5 { - self.pushingToTalk = false - self.temporaryPushingToTalk = true - self.call.setIsMuted(action: .unmuted) - - Queue.mainQueue().after(0.1) { - self.temporaryPushingToTalk = false - self.actionButton.pressing = false - - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) - } - } - } else { - self.pushingToTalk = false - self.actionButton.pressing = false - - self.call.setIsMuted(action: .muted(isPushToTalkActive: false)) - } - } - - if let callState = self.callState { - self.itemInteraction?.updateAudioLevels([(callState.myPeerId, 0, 0.0, false)], reset: true) - } - - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .spring)) - } - self.updateMembers() - default: - break - } - } - - @objc private func actionPressed() { - if self.isScheduling { - self.schedule() - } - } - - @objc private func audioPressed() { - self.hapticFeedback.impact(.light) - - if let _ = self.callState?.scheduleTimestamp { - if let callState = self.callState, let peer = self.peer, !callState.canManageCall && (peer.addressName?.isEmpty ?? true) { - return - } - - let _ = (self.inviteLinksPromise.get() - |> take(1) - |> deliverOnMainQueue).start(next: { [weak self] inviteLinks in - guard let strongSelf = self else { - return - } - - let _ = (strongSelf.context.engine.data.get( - TelegramEngine.EngineData.Item.Peer.Peer(id: strongSelf.call.peerId), - TelegramEngine.EngineData.Item.Peer.ExportedInvitation(id: strongSelf.call.peerId) - ) - |> map { peer, exportedInvitation -> GroupCallInviteLinks? in - if let inviteLinks = inviteLinks { - return inviteLinks - } else if let peer = peer, let addressName = peer.addressName, !addressName.isEmpty { - return GroupCallInviteLinks(listenerLink: "https://t.me/\(addressName)?voicechat", speakerLink: nil) - } else if let link = exportedInvitation?.link { - return GroupCallInviteLinks(listenerLink: link, speakerLink: nil) - } - return nil - } - |> deliverOnMainQueue).start(next: { links in - guard let strongSelf = self else { - return - } - - if let links = links { - strongSelf.presentShare(links) - } - }) - }) - return - } - - 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 { - self.call.setCurrentAudioOutput(output) - break - } - } - } else { - let actionSheet = ActionSheetController(presentationData: self.presentationData.withUpdated(theme: self.darkTheme)) - var items: [ActionSheetItem] = [] - for output in availableOutputs { - let title: String - var icon: UIImage? - switch output { - case .builtin: - title = UIDevice.current.model - case .speaker: - title = self.presentationData.strings.Call_AudioRouteSpeaker - icon = generateScaledImage(image: UIImage(bundleImageName: "Call/CallSpeakerButton"), size: CGSize(width: 48.0, height: 48.0), opaque: false) - case .headphones: - title = self.presentationData.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() - self?.call.setCurrentAudioOutput(output) - })) - } - - actionSheet.setItemGroups([ActionSheetItemGroup(items: items), ActionSheetItemGroup(items: [ - ActionSheetButtonItem(title: self.presentationData.strings.Call_AudioRouteHide, color: .accent, font: .bold, action: { [weak actionSheet] in - actionSheet?.dismissAnimated() - }) - ]) - ]) - self.controller?.present(actionSheet, in: .window(.calls)) - } - } - - @objc private func cameraPressed() { - self.hapticFeedback.impact(.light) - if self.call.hasVideo { - self.call.disableVideo() - - if let (layout, navigationHeight) = self.validLayout { - self.animatingButtonsSwap = true - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.4, curve: .spring)) - } - } else { - DeviceAccess.authorizeAccess(to: .camera(.videoCall), onlyCheck: true, presentationData: self.presentationData.withUpdated(theme: self.darkTheme), present: { [weak self] c, a in - self?.controller?.present(c, in: .window(.root), with: a) - }, openSettings: { [weak self] in - self?.context.sharedContext.applicationBindings.openSettings() - }, _: { [weak self] ready in - guard let strongSelf = self, ready else { - return - } - var isFrontCamera = true - let videoCapturer = OngoingCallVideoCapturer() - let input = videoCapturer.video() - if let videoView = strongSelf.videoRenderingContext.makeView(input: input, blur: false) { - videoView.updateIsEnabled(true) - - let cameraNode = GroupVideoNode(videoView: videoView, backdropVideoView: nil) - let controller = VoiceChatCameraPreviewController(sharedContext: strongSelf.context.sharedContext, cameraNode: cameraNode, shareCamera: { [weak self] _, unmuted in - if let strongSelf = self { - strongSelf.call.setIsMuted(action: unmuted ? .unmuted : .muted(isPushToTalkActive: false)) - (strongSelf.call as! PresentationGroupCallImpl).requestVideo(capturer: videoCapturer, useFrontCamera: isFrontCamera) - - if let (layout, navigationHeight) = strongSelf.validLayout { - strongSelf.animatingButtonsSwap = true - strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.4, curve: .spring)) - } - } - }, switchCamera: { - Queue.mainQueue().after(0.1) { - isFrontCamera = !isFrontCamera - videoCapturer.switchVideoInput(isFront: isFrontCamera) - } - }) - strongSelf.controller?.present(controller, in: .window(.root)) - } - }) - } - } - - @objc private func switchCameraPressed() { - self.hapticFeedback.impact(.light) - Queue.mainQueue().after(0.1) { - self.call.switchVideoCamera() - } - - if let callState = self.callState { - for entry in self.currentFullscreenEntries { - if case let .peer(peerEntry, _) = entry { - if peerEntry.peer.id == callState.myPeerId { - if let videoEndpointId = peerEntry.videoEndpointId, let videoNode = self.videoNodes[videoEndpointId] { - videoNode.flip(withBackground: false) - } - break - } - } - } - } - self.mainStageNode.flipVideoIfNeeded() - - let springDuration: Double = 0.7 - let springDamping: CGFloat = 100.0 - self.switchCameraButton.isUserInteractionEnabled = false - self.switchCameraButton.layer.animateSpring(from: 0.0 as NSNumber, to: CGFloat.pi as NSNumber, keyPath: "transform.rotation.z", duration: springDuration, damping: springDamping, completion: { [weak self] _ in - self?.switchCameraButton.isUserInteractionEnabled = true - }) - } - - private var isLandscape: Bool { - if let (layout, _) = self.validLayout, layout.size.width > layout.size.height, case .compact = layout.metrics.widthClass { - return true - } else { - return false - } - } - - private var effectiveBottomAreaHeight: CGFloat { - if let (layout, _) = self.validLayout, case .regular = layout.metrics.widthClass { - return bottomAreaHeight - } - switch self.displayMode { - case .modal: - return bottomAreaHeight - case let .fullscreen(controlsHidden): - return controlsHidden ? 0.0 : fullscreenBottomAreaHeight - } - } - - private var isFullscreen: Bool { - switch self.displayMode { - case .fullscreen(_), .modal(_, true): - return true - default: - return false - } - } - - private func updateDecorationsLayout(transition: ContainedViewLayoutTransition, completion: (() -> Void)? = nil) { - guard let (layout, _) = self.validLayout else { - return - } - - let isLandscape = self.isLandscape - - let layoutTopInset: CGFloat = max(layout.statusBarHeight ?? 0.0, layout.safeInsets.top) - let listTopInset = isLandscape ? topPanelHeight : layoutTopInset + topPanelHeight - let bottomPanelHeight = isLandscape ? layout.intrinsicInsets.bottom : bottomAreaHeight + layout.intrinsicInsets.bottom - - let size = layout.size - let contentWidth: CGFloat - var contentLeftInset: CGFloat = 0.0 - var forceUpdate = false - if case .regular = layout.metrics.widthClass { - contentWidth = max(320.0, min(375.0, floor(size.width * 0.3))) - if self.peerIdToEndpointId.isEmpty { - contentLeftInset = 0.0 - } else { - contentLeftInset = self.panelHidden ? layout.size.width : layout.size.width - contentWidth - } - forceUpdate = true - } else { - contentWidth = isLandscape ? min(530.0, size.width - 210.0) : size.width - } - - let listSize = CGSize(width: contentWidth, height: layout.size.height - listTopInset - bottomPanelHeight + bottomGradientHeight) - let topInset: CGFloat - if let (panInitialTopInset, panOffset) = self.panGestureArguments { - if self.isExpanded { - topInset = min(self.topInset ?? listSize.height, panInitialTopInset + max(0.0, panOffset)) - } else { - topInset = max(0.0, panInitialTopInset + min(0.0, panOffset)) - } - } else if case .regular = layout.metrics.widthClass { - topInset = 0.0 - } else if let currentTopInset = self.topInset { - topInset = self.isExpanded ? 0.0 : currentTopInset - } else { - topInset = listSize.height - 46.0 - floor(56.0 * 3.5) - } - - var bottomEdge: CGFloat = 0.0 - if case .regular = layout.metrics.widthClass { - bottomEdge = size.height - } else { - self.listNode.forEachItemNode { itemNode in - if let itemNode = itemNode as? ListViewItemNode { - let convertedFrame = self.listNode.view.convert(itemNode.frame, to: self.contentContainer.view) - if convertedFrame.maxY > bottomEdge { - bottomEdge = convertedFrame.maxY - } - } - } - if bottomEdge.isZero { - bottomEdge = self.listNode.frame.minY + 46.0 + 56.0 - } - } - - let rawPanelOffset = topInset + listTopInset - topPanelHeight - let panelOffset = max(layoutTopInset, rawPanelOffset) - let topPanelFrame: CGRect - if isLandscape { - topPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: CGSize(width: size.width, height: topPanelHeight)) - } else { - topPanelFrame = CGRect(origin: CGPoint(x: 0.0, y: panelOffset), size: CGSize(width: size.width, height: topPanelHeight)) - } - - let sideInset: CGFloat = 14.0 - - let bottomPanelCoverHeight = bottomAreaHeight + layout.intrinsicInsets.bottom - var bottomGradientFrame = CGRect(origin: CGPoint(x: 0.0, y: layout.size.height - bottomPanelCoverHeight), size: CGSize(width: size.width, height: bottomGradientHeight)) - if isLandscape { - bottomGradientFrame.origin.y = layout.size.height - } - - let transitionContainerFrame = CGRect(x: 0.0, y: 0.0, width: layout.size.width, height: layout.size.height) - transition.updateFrame(node: self.transitionContainerNode, frame: transitionContainerFrame) - transition.updateFrame(view: self.transitionMaskView, frame: CGRect(x: 0.0, y: 0.0, width: transitionContainerFrame.width, height: transitionContainerFrame.height)) - let updateMaskLayers = { - var topPanelFrame = topPanelFrame - if self.animatingContextMenu { - topPanelFrame.origin.y = 0.0 - } - transition.updateFrame(layer: self.transitionMaskTopFillLayer, frame: CGRect(x: 0.0, y: 0.0, width: transitionContainerFrame.width, height: topPanelFrame.maxY)) - transition.updateFrame(layer: self.transitionMaskFillLayer, frame: CGRect(x: 0.0, y: topPanelFrame.maxY, width: transitionContainerFrame.width, height: bottomGradientFrame.minY - topPanelFrame.maxY)) - transition.updateFrame(layer: self.transitionMaskGradientLayer, frame: CGRect(x: 0.0, y: bottomGradientFrame.minY, width: transitionContainerFrame.width, height: bottomGradientFrame.height)) - transition.updateFrame(layer: self.transitionMaskBottomFillLayer, frame: CGRect(x: 0.0, y: bottomGradientFrame.minY, width: transitionContainerFrame.width, height: max(0.0, transitionContainerFrame.height - bottomGradientFrame.minY))) - } - if transition.isAnimated { - updateMaskLayers() - } else { - CATransaction.begin() - CATransaction.setDisableActions(true) - updateMaskLayers() - CATransaction.commit() - } - - var bottomInset: CGFloat = 0.0 - if case .compact = layout.metrics.widthClass, case let .fullscreen(controlsHidden) = self.displayMode { - if !controlsHidden { - bottomInset = 80.0 - } - } - transition.updateAlpha(node: self.bottomGradientNode, alpha: self.isLandscape ? 0.0 : 1.0) - - var isTablet = false - let videoFrame: CGRect - let videoContainerFrame: CGRect - if case .regular = layout.metrics.widthClass { - isTablet = true - let videoTopEdgeY = topPanelFrame.maxY - let videoBottomEdgeY = layout.size.height - layout.intrinsicInsets.bottom - videoFrame = CGRect(x: sideInset, y: 0.0, width: contentLeftInset - sideInset, height: videoBottomEdgeY - videoTopEdgeY) - videoContainerFrame = CGRect(origin: CGPoint(x: 0.0, y: videoTopEdgeY), size: CGSize(width: contentLeftInset, height: layout.size.height)) - } else { - let videoTopEdgeY = isLandscape ? 0.0 : layoutTopInset - let videoBottomEdgeY = self.isLandscape ? layout.size.height : layout.size.height - layout.intrinsicInsets.bottom - 92.0 - videoFrame = CGRect(x: 0.0, y: videoTopEdgeY, width: isLandscape ? max(0.0, layout.size.width - layout.safeInsets.right - 92.0) : layout.size.width, height: videoBottomEdgeY - videoTopEdgeY) - videoContainerFrame = CGRect(origin: CGPoint(), size: layout.size) - } - transition.updateFrame(node: self.mainStageContainerNode, frame: videoContainerFrame) - transition.updateFrame(node: self.mainStageBackgroundNode, frame: videoFrame) - if !self.mainStageNode.animating { - transition.updateFrame(node: self.mainStageNode, frame: videoFrame) - } - self.mainStageNode.update(size: videoFrame.size, sideInset: layout.safeInsets.left, bottomInset: self.isLandscape ? 0.0 : bottomInset, isLandscape: videoFrame.width > videoFrame.height, isTablet: isTablet, transition: transition) - - let backgroundFrame = CGRect(origin: CGPoint(x: 0.0, y: topPanelFrame.maxY), size: CGSize(width: size.width, height: layout.size.height)) - - let leftBorderFrame: CGRect - let rightBorderFrame: CGRect - let additionalInset: CGFloat = 60.0 - let additionalSideInset = (size.width - contentWidth) / 2.0 - let additionalLeftInset = size.width / 2.0 - if isLandscape { - leftBorderFrame = CGRect(origin: CGPoint(x: 0.0, y: topPanelFrame.maxY - additionalInset), size: CGSize(width: (size.width - contentWidth) / 2.0 + sideInset, height: layout.size.height)) - rightBorderFrame = CGRect(origin: CGPoint(x: size.width - (size.width - contentWidth) / 2.0 - sideInset, y: topPanelFrame.maxY - additionalInset), size: CGSize(width: layout.safeInsets.right + (size.width - contentWidth) / 2.0 + sideInset, height: layout.size.height)) - } else { - var isFullscreen = false - if case .fullscreen = self.displayMode { - isFullscreen = true - forceUpdate = true - } - leftBorderFrame = CGRect(origin: CGPoint(x: -additionalInset - additionalLeftInset, y: topPanelFrame.maxY - additionalInset * (isFullscreen ? 0.95 : 0.8)), size: CGSize(width: sideInset + additionalInset + additionalLeftInset + (contentLeftInset.isZero ? additionalSideInset : contentLeftInset), height: layout.size.height)) - rightBorderFrame = CGRect(origin: CGPoint(x: size.width - sideInset - (contentLeftInset.isZero ? additionalSideInset : 0.0), y: topPanelFrame.maxY - additionalInset * (isFullscreen ? 0.95 : 0.8)), size: CGSize(width: sideInset + additionalInset + additionalLeftInset + additionalSideInset, height: layout.size.height)) - } - - let topCornersFrame = CGRect(x: sideInset + (contentLeftInset.isZero ? floorToScreenPixels((size.width - contentWidth) / 2.0) : contentLeftInset), y: topPanelFrame.maxY - 60.0, width: contentWidth - sideInset * 2.0, height: 50.0 + 60.0) - - let previousTopPanelFrame = self.topPanelNode.frame - let previousBackgroundFrame = self.backgroundNode.frame - let previousLeftBorderFrame = self.leftBorderNode.frame - let previousRightBorderFrame = self.rightBorderNode.frame - - if !topPanelFrame.equalTo(previousTopPanelFrame) || forceUpdate { - if topPanelFrame.width != previousTopPanelFrame.width { - transition.updateFrame(node: self.topPanelNode, frame: topPanelFrame) - transition.updateFrame(node: self.topCornersNode, frame: topCornersFrame) - transition.updateFrame(node: self.backgroundNode, frame: backgroundFrame) - transition.updateFrame(node: self.leftBorderNode, frame: leftBorderFrame) - transition.updateFrame(node: self.rightBorderNode, frame: rightBorderFrame) - } else { - self.topPanelNode.frame = topPanelFrame - let positionDelta = CGPoint(x: 0.0, y: topPanelFrame.minY - previousTopPanelFrame.minY) - transition.animateOffsetAdditive(layer: self.topPanelNode.layer, offset: positionDelta.y, completion: completion) - - transition.updateFrame(node: self.topCornersNode, frame: topCornersFrame) - - self.backgroundNode.frame = backgroundFrame - let backgroundPositionDelta = CGPoint(x: 0.0, y: previousBackgroundFrame.minY - backgroundFrame.minY) - transition.animatePositionAdditive(node: self.backgroundNode, offset: backgroundPositionDelta) - - self.leftBorderNode.frame = leftBorderFrame - let leftBorderPositionDelta = CGPoint(x: previousLeftBorderFrame.maxX - leftBorderFrame.maxX, y: previousLeftBorderFrame.minY - leftBorderFrame.minY) - transition.animatePositionAdditive(node: self.leftBorderNode, offset: leftBorderPositionDelta) - - self.rightBorderNode.frame = rightBorderFrame - let rightBorderPositionDelta = CGPoint(x: previousRightBorderFrame.minX - rightBorderFrame.minX, y: previousRightBorderFrame.minY - rightBorderFrame.minY) - transition.animatePositionAdditive(node: self.rightBorderNode, offset: rightBorderPositionDelta) - } - } else { - completion?() - } - - self.topPanelBackgroundNode.frame = CGRect(x: 0.0, y: topPanelHeight - 24.0, width: size.width, height: min(topPanelFrame.height, 24.0)) - - let listMaxY = listTopInset + listSize.height - let bottomOffset = min(0.0, bottomEdge - listMaxY) + layout.size.height - bottomPanelHeight - - let bottomCornersFrame = CGRect(origin: CGPoint(x: sideInset + floorToScreenPixels((size.width - contentWidth) / 2.0), y: -50.0 + bottomOffset + bottomGradientHeight), size: CGSize(width: contentWidth - sideInset * 2.0, height: 50.0 + 60.0)) - let bottomPanelBackgroundFrame = CGRect(x: 0.0, y: bottomOffset + bottomGradientHeight, width: size.width, height: 2000.0) - let previousBottomCornersFrame = self.bottomCornersNode.frame - if !bottomCornersFrame.equalTo(previousBottomCornersFrame) { - if bottomCornersFrame.width != previousBottomCornersFrame.width { - transition.updateFrame(node: self.bottomCornersNode, frame: bottomCornersFrame) - transition.updateFrame(node: self.bottomPanelBackgroundNode, frame: bottomPanelBackgroundFrame) - } else { - self.bottomCornersNode.frame = bottomCornersFrame - self.bottomPanelBackgroundNode.frame = bottomPanelBackgroundFrame - - let positionDelta = CGPoint(x: 0.0, y: previousBottomCornersFrame.minY - bottomCornersFrame.minY) - transition.animatePositionAdditive(node: self.bottomCornersNode, offset: positionDelta) - transition.animatePositionAdditive(node: self.bottomPanelBackgroundNode, offset: positionDelta) - } - } - - let participantsFrame = CGRect(x: 0.0, y: bottomCornersFrame.maxY - 100.0, width: size.width, height: 216.0) - transition.updateFrame(node: self.participantsNode, frame: participantsFrame) - self.participantsNode.update(size: participantsFrame.size, participants: Int32.random(in: 0..<999999999)/*self.currentTotalCount*/, groupingSeparator: self.presentationData.dateTimeFormat.groupingSeparator, transition: .immediate) - } - - private var decorationsAreDark: Bool? - private var ignoreLayout = false - private func updateDecorationsColors() { - guard let (layout, _) = self.validLayout else { - return - } - - let isFullscreen = self.isFullscreen - let effectiveDisplayMode = self.displayMode - - self.ignoreLayout = true - self.controller?.statusBar.updateStatusBarStyle(isFullscreen ? .White : .Ignore, animated: true) - self.ignoreLayout = false - - let size = layout.size - let topEdgeFrame: CGRect - if isFullscreen { - let offset: CGFloat - if let statusBarHeight = layout.statusBarHeight { - offset = statusBarHeight - } else { - offset = 44.0 - } - topEdgeFrame = CGRect(x: 0.0, y: -offset, width: size.width, height: topPanelHeight + offset) - } else { - topEdgeFrame = CGRect(x: 0.0, y: 0.0, width: size.width, height: topPanelHeight) - } - - let backgroundColor: UIColor - if case .fullscreen = effectiveDisplayMode { - backgroundColor = isFullscreen ? panelBackgroundColor : secondaryPanelBackgroundColor - } else if self.isScheduling || self.callState?.scheduleTimestamp != nil { - backgroundColor = panelBackgroundColor - } else { - backgroundColor = isFullscreen ? panelBackgroundColor : secondaryPanelBackgroundColor - } - - let transition: ContainedViewLayoutTransition = .animated(duration: 0.3, curve: .linear) - transition.updateFrame(node: self.topPanelEdgeNode, frame: topEdgeFrame) - transition.updateCornerRadius(node: self.topPanelEdgeNode, cornerRadius: isFullscreen ? layout.deviceMetrics.screenCornerRadius - 0.5 : 12.0) - transition.updateBackgroundColor(node: self.topPanelBackgroundNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) - transition.updateBackgroundColor(node: self.topPanelEdgeNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) - transition.updateBackgroundColor(node: self.backgroundNode, color: backgroundColor) - transition.updateBackgroundColor(node: self.bottomPanelBackgroundNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) - transition.updateBackgroundColor(node: self.leftBorderNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) - transition.updateBackgroundColor(node: self.rightBorderNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) - - var gridNode: VoiceChatTilesGridItemNode? - self.listNode.forEachItemNode { itemNode in - if let itemNode = itemNode as? VoiceChatTilesGridItemNode { - gridNode = itemNode - } - } - if let gridNode = gridNode { - transition.updateBackgroundColor(node: gridNode.backgroundNode, color: isFullscreen ? fullscreenBackgroundColor : panelBackgroundColor) - } - - let previousDark = self.decorationsAreDark - self.decorationsAreDark = isFullscreen - if previousDark != self.decorationsAreDark { - if let snapshotView = self.topCornersNode.view.snapshotContentTree() { - snapshotView.frame = self.topCornersNode.bounds - self.topCornersNode.view.addSubview(snapshotView) - - snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in - snapshotView?.removeFromSuperview() - }) - } - self.topCornersNode.image = decorationTopCornersImage(dark: isFullscreen) - - if let snapshotView = self.bottomCornersNode.view.snapshotContentTree() { - snapshotView.frame = self.bottomCornersNode.bounds - self.bottomCornersNode.view.addSubview(snapshotView) - - snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in - snapshotView?.removeFromSuperview() - }) - } - self.bottomCornersNode.image = decorationBottomCornersImage(dark: isFullscreen) - - if let gridNode = gridNode { - if let snapshotView = gridNode.cornersNode.view.snapshotContentTree() { - snapshotView.frame = gridNode.cornersNode.bounds - gridNode.cornersNode.view.addSubview(snapshotView) - - snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, timingFunction: CAMediaTimingFunctionName.linear.rawValue, removeOnCompletion: false, completion: { [weak snapshotView] _ in - snapshotView?.removeFromSuperview() - }) - } - gridNode.cornersNode.image = decorationCornersImage(top: true, bottom: false, dark: isFullscreen) - gridNode.supernode?.addSubnode(gridNode) - } - - UIView.transition(with: self.bottomGradientNode.view, duration: 0.3, options: [.transitionCrossDissolve, .curveLinear]) { - self.bottomGradientNode.backgroundColor = decorationBottomGradientImage(dark: isFullscreen).flatMap { UIColor(patternImage: $0) } - } completion: { _ in - } - - self.closeButton.setContent(.image(closeButtonImage(dark: isFullscreen)), animated: transition.isAnimated) - self.optionsButton.setContent(.more(optionsCircleImage(dark: isFullscreen)), animated: transition.isAnimated) - self.panelButton.setContent(.image(panelButtonImage(dark: isFullscreen)), animated: transition.isAnimated) - } - - self.updateTitle(transition: transition) - } - - private func updateTitle(slide: Bool = false, transition: ContainedViewLayoutTransition) { - guard let _ = self.validLayout else { - return - } - - var title = self.currentTitle - if self.isScheduling { - if let peer = self.peer as? TelegramChannel, case .broadcast = peer.info { - title = self.presentationData.strings.ScheduleLiveStream_Title - } else { - title = self.presentationData.strings.ScheduleVoiceChat_Title - } - } else if case .modal(_, false) = self.displayMode, !self.currentTitleIsCustom { - if let navigationController = self.controller?.navigationController as? NavigationController { - for controller in navigationController.viewControllers.reversed() { - if let controller = controller as? ChatController, case let .peer(peerId) = controller.chatLocation, peerId == self.call.peerId { - if let peer = self.peer as? TelegramChannel, case .broadcast = peer.info { - title = self.presentationData.strings.VoiceChatChannel_Title - } else { - title = self.presentationData.strings.VoiceChat_Title - } - } - } - } - } - - var subtitle = "" - var speaking = false - if self.scrollAtTop { - subtitle = self.currentSubtitle - speaking = false - } else { - subtitle = self.currentSpeakingSubtitle ?? self.currentSubtitle - speaking = self.currentSpeakingSubtitle != nil - } - if self.isScheduling { - subtitle = "" - speaking = false - } else if self.callState?.scheduleTimestamp != nil { - if self.callState?.canManageCall ?? false { - subtitle = self.presentationData.strings.VoiceChat_TapToEditTitle - } else { - subtitle = self.presentationData.strings.VoiceChat_Scheduled - } - speaking = false - } - - self.titleNode.update(size: CGSize(width: self.titleNode.bounds.width, height: 44.0), title: title, subtitle: subtitle, speaking: speaking, slide: slide, transition: transition) - } - - private func updateButtons(transition: ContainedViewLayoutTransition) { - guard let (layout, _) = self.validLayout else { - return - } - var audioMode: CallControllerButtonsSpeakerMode = .none - //var hasAudioRouteMenu: Bool = false - if let (availableOutputs, maybeCurrentOutput) = self.audioOutputState, let currentOutput = maybeCurrentOutput { - //hasAudioRouteMenu = availableOutputs.count > 2 - switch currentOutput { - case .builtin: - audioMode = .builtin - case .speaker: - audioMode = .speaker - case .headphones: - audioMode = .headphones - case let .port(port): - var type: CallControllerButtonsSpeakerMode.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 - } - audioMode = .bluetooth(type) - } - if availableOutputs.count <= 1 { - audioMode = .none - } - } - - let normalButtonAppearance: CallControllerButtonItemNode.Content.Appearance - let activeButtonAppearance: CallControllerButtonItemNode.Content.Appearance - if let color = self.currentNormalButtonColor { - normalButtonAppearance = .color(.custom(color.rgb, 1.0)) - } else { - normalButtonAppearance = .color(.custom(self.isFullscreen ? 0x1c1c1e : 0x2c2c2e, 1.0)) - } - if let color = self.currentActiveButtonColor { - activeButtonAppearance = .color(.custom(color.rgb, 1.0)) - } else { - activeButtonAppearance = .color(.custom(self.isFullscreen ? 0x1c1c1e : 0x2c2c2e, 1.0)) - } - - var soundImage: CallControllerButtonItemNode.Content.Image - var soundAppearance: CallControllerButtonItemNode.Content.Appearance = normalButtonAppearance - var soundTitle: String = self.presentationData.strings.Call_Speaker - switch audioMode { - case .none, .builtin: - soundImage = .speaker - case .speaker: - soundImage = .speaker - soundAppearance = activeButtonAppearance - case .headphones: - soundImage = .headphones - soundTitle = self.presentationData.strings.Call_Audio - case let .bluetooth(type): - switch type { - case .generic: - soundImage = .bluetooth - case .airpods: - soundImage = .airpods - case .airpodsPro: - soundImage = .airpodsPro - case .airpodsMax: - soundImage = .airpodsMax - } - soundTitle = self.presentationData.strings.Call_Audio - } - - let isScheduled = self.isScheduling || self.callState?.scheduleTimestamp != nil - - var isSoundEnabled = true - if isScheduled { - if let callState = self.callState, let peer = self.peer, !callState.canManageCall && (peer.addressName?.isEmpty ?? true) { - isSoundEnabled = false - } else { - soundImage = .share - soundTitle = self.presentationData.strings.VoiceChat_ShareShort - soundAppearance = normalButtonAppearance - } - } - - let audioButtonSize: CGSize - var buttonsTitleAlpha: CGFloat - let effectiveDisplayMode = self.displayMode - - let hasCameraButton = self.cameraButton.isUserInteractionEnabled - let hasVideo = self.call.hasVideo - switch effectiveDisplayMode { - case .modal: - audioButtonSize = hasCameraButton ? smallButtonSize : sideButtonSize - buttonsTitleAlpha = 1.0 - case .fullscreen: - if case .regular = layout.metrics.widthClass { - audioButtonSize = hasCameraButton ? smallButtonSize : sideButtonSize - } else { - audioButtonSize = sideButtonSize - } - if case .regular = layout.metrics.widthClass { - buttonsTitleAlpha = 1.0 - } else { - buttonsTitleAlpha = 0.0 - } - } - - self.cameraButton.update(size: sideButtonSize, content: CallControllerButtonItemNode.Content(appearance: hasVideo ? activeButtonAppearance : normalButtonAppearance, image: hasVideo ? .cameraOn : .cameraOff), text: self.presentationData.strings.VoiceChat_Video, transition: transition) - - self.switchCameraButton.update(size: audioButtonSize, content: CallControllerButtonItemNode.Content(appearance: normalButtonAppearance, image: .flipCamera), text: "", transition: transition) - - transition.updateAlpha(node: self.switchCameraButton, alpha: hasCameraButton && hasVideo ? 1.0 : 0.0) - transition.updateTransformScale(node: self.switchCameraButton, scale: hasCameraButton && hasVideo ? 1.0 : 0.0) - - transition.updateTransformScale(node: self.cameraButton, scale: hasCameraButton ? 1.0 : 0.0) - - let hasAudioButton = !self.isScheduling - transition.updateAlpha(node: self.audioButton, alpha: hasCameraButton || !hasAudioButton ? 0.0 : 1.0) - transition.updateTransformScale(node: self.audioButton, scale: hasCameraButton || !hasAudioButton ? 0.0 : 1.0) - - self.audioButton.update(size: audioButtonSize, content: CallControllerButtonItemNode.Content(appearance: soundAppearance, image: soundImage, isEnabled: isSoundEnabled), text: soundTitle, transition: transition) - self.audioButton.isUserInteractionEnabled = isSoundEnabled - - self.leaveButton.update(size: sideButtonSize, content: CallControllerButtonItemNode.Content(appearance: .color(.custom(0xff3b30, 0.3)), image: .cancel), text: self.presentationData.strings.VoiceChat_Leave, transition: .immediate) - - transition.updateAlpha(node: self.cameraButton.textNode, alpha: buttonsTitleAlpha) - transition.updateAlpha(node: self.switchCameraButton.textNode, alpha: buttonsTitleAlpha) - transition.updateAlpha(node: self.audioButton.textNode, alpha: buttonsTitleAlpha) - transition.updateAlpha(node: self.leaveButton.textNode, alpha: buttonsTitleAlpha) - } - - func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationHeight: CGFloat, transition: ContainedViewLayoutTransition) { - guard !self.ignoreLayout else { - return - } - let isFirstTime = self.validLayout == nil - let previousLayout = self.validLayout?.0 - self.validLayout = (layout, navigationHeight) - - let size = layout.size - let contentWidth: CGFloat - let headerWidth: CGFloat - let contentLeftInset: CGFloat - if case .regular = layout.metrics.widthClass { - contentWidth = max(320.0, min(375.0, floor(size.width * 0.3))) - headerWidth = size.width - if self.peerIdToEndpointId.isEmpty { - contentLeftInset = 0.0 - } else { - contentLeftInset = self.panelHidden ? layout.size.width : layout.size.width - contentWidth - } - } else { - contentWidth = self.isLandscape ? min(530.0, size.width - 210.0) : size.width - headerWidth = contentWidth - contentLeftInset = 0.0 - } - - var previousIsLandscape = false - if let previousLayout = previousLayout, case .compact = previousLayout.metrics.widthClass, previousLayout.size.width > previousLayout.size.height { - previousIsLandscape = true - } - var shouldSwitchToExpanded = false - if case let .modal(isExpanded, _) = self.displayMode { - if previousIsLandscape != self.isLandscape && !isExpanded { - shouldSwitchToExpanded = true - } else if case .regular = layout.metrics.widthClass, !isExpanded { - shouldSwitchToExpanded = true - } - } - if shouldSwitchToExpanded { - self.displayMode = .modal(isExpanded: true, isFilled: true) - self.updateDecorationsColors() - self.updateDecorationsLayout(transition: transition) - self.updateMembers() - } else if case .fullscreen = self.displayMode, previousIsLandscape != self.isLandscape { - self.updateMembers() - } - - let effectiveDisplayMode = self.displayMode - - transition.updateFrame(node: self.titleNode, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - headerWidth) / 2.0), y: 10.0), size: CGSize(width: headerWidth, height: 44.0))) - self.updateTitle(transition: transition) - - transition.updateFrame(node: self.optionsButton, frame: CGRect(origin: CGPoint(x: 20.0 + floorToScreenPixels((size.width - headerWidth) / 2.0), y: 18.0), size: CGSize(width: 28.0, height: 28.0))) - transition.updateFrame(node: self.panelButton, frame: CGRect(origin: CGPoint(x: size.width - floorToScreenPixels((size.width - headerWidth) / 2.0) - 20.0 - 28.0 - 38.0 - 24.0, y: 18.0), size: CGSize(width: 38.0, height: 28.0))) - transition.updateFrame(node: self.closeButton, frame: CGRect(origin: CGPoint(x: size.width - floorToScreenPixels((size.width - headerWidth) / 2.0) - 20.0 - 28.0, y: 18.0), size: CGSize(width: 28.0, height: 28.0))) - - transition.updateAlpha(node: self.optionsButton, alpha: self.optionsButton.isUserInteractionEnabled ? 1.0 : 0.0) - transition.updateAlpha(node: self.panelButton, alpha: self.panelButton.isUserInteractionEnabled ? 1.0 : 0.0) - - transition.updateFrame(node: self.dimNode, frame: CGRect(origin: CGPoint(), size: layout.size)) - transition.updateFrame(node: self.contentContainer, frame: CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - size.width) / 2.0), y: 0.0), size: size)) - - let layoutTopInset: CGFloat = max(layout.statusBarHeight ?? 0.0, layout.safeInsets.top) - let sideInset: CGFloat = 14.0 - - var listInsets = UIEdgeInsets() - listInsets.left = sideInset + (self.isLandscape ? 0.0 : layout.safeInsets.left) - listInsets.right = sideInset + (self.isLandscape ? 0.0 : layout.safeInsets.right) - - let topEdgeOffset: CGFloat - if let statusBarHeight = layout.statusBarHeight { - topEdgeOffset = statusBarHeight - } else { - topEdgeOffset = 44.0 - } - - if self.isLandscape { - transition.updateFrame(node: self.topPanelEdgeNode, frame: CGRect(x: 0.0, y: -topEdgeOffset, width: size.width, height: topPanelHeight + topEdgeOffset)) - } else if let _ = self.panGestureArguments { - } else { - let topEdgeFrame: CGRect - if self.isFullscreen { - topEdgeFrame = CGRect(x: 0.0, y: -topEdgeOffset, width: size.width, height: topPanelHeight + topEdgeOffset) - } else { - topEdgeFrame = CGRect(x: 0.0, y: 0.0, width: size.width, height: topPanelHeight) - } - transition.updateFrame(node: self.topPanelEdgeNode, frame: topEdgeFrame) - } - - let bottomPanelHeight = self.effectiveBottomAreaHeight + layout.intrinsicInsets.bottom - var listTopInset = layoutTopInset + topPanelHeight - if self.isLandscape { - listTopInset = topPanelHeight - } - - let listSize = CGSize(width: contentWidth, height: layout.size.height - listTopInset - (self.isLandscape ? layout.intrinsicInsets.bottom : bottomPanelHeight) + bottomGradientHeight) - let topInset: CGFloat - if let (panInitialTopInset, panOffset) = self.panGestureArguments { - if self.isExpanded { - topInset = min(self.topInset ?? listSize.height, panInitialTopInset + max(0.0, panOffset)) - } else { - topInset = max(0.0, panInitialTopInset + min(0.0, panOffset)) - } - } else if case .regular = layout.metrics.widthClass { - topInset = 0.0 - } else if let currentTopInset = self.topInset { - topInset = self.isExpanded ? 0.0 : currentTopInset - } else { - topInset = listSize.height - 46.0 - floor(56.0 * 3.5) - bottomGradientHeight - } - - transition.updateFrameAsPositionAndBounds(node: self.listContainer, frame: CGRect(origin: CGPoint(), size: size)) - transition.updateFrame(node: self.listNode, frame: CGRect(origin: CGPoint(x: contentLeftInset.isZero ? floorToScreenPixels((size.width - contentWidth) / 2.0) : contentLeftInset, y: listTopInset + topInset), size: listSize)) - - let tileGridSize = CGSize(width: max(0.0, contentLeftInset - sideInset), height: size.height - layout.intrinsicInsets.bottom - listTopInset - topInset) - - if contentLeftInset > 0.0 { - self.tileGridNode.isHidden = false - } - if !self.tileGridNode.isHidden { - let _ = self.tileGridNode.update(size: tileGridSize, layoutMode: .grid, items: self.currentTileItems, transition: transition, completion: { [weak self] in - if contentLeftInset.isZero && transition.isAnimated { - self?.tileGridNode.isHidden = true - } - }) - } - transition.updateFrame(node: self.tileGridNode, frame: CGRect(origin: CGPoint(x: sideInset, y: listTopInset + topInset), size: tileGridSize)) - self.tileGridNode.updateAbsoluteRect(CGRect(origin: CGPoint(), size: tileGridSize), within: tileGridSize) - - listInsets.bottom = bottomGradientHeight - - let (duration, curve) = listViewAnimationDurationAndCurve(transition: transition) - self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: ListViewUpdateSizeAndInsets(size: listSize, insets: listInsets, duration: duration, curve: curve), stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) - - let fullscreenListWidth: CGFloat - let fullscreenListHeight: CGFloat = 84.0 - let fullscreenListTransform: CATransform3D - let fullscreenListInset: CGFloat = 14.0 - let fullscreenListUpdateSizeAndInsets: ListViewUpdateSizeAndInsets - let fullscreenListContainerFrame: CGRect - if self.isLandscape { - fullscreenListWidth = layout.size.height - fullscreenListTransform = CATransform3DIdentity - fullscreenListUpdateSizeAndInsets = ListViewUpdateSizeAndInsets(size: CGSize(width: fullscreenListHeight, height: layout.size.height), insets: UIEdgeInsets(top: fullscreenListInset, left: 0.0, bottom: fullscreenListInset, right: 0.0), duration: duration, curve: curve) - fullscreenListContainerFrame = CGRect(x: layout.size.width - min(self.effectiveBottomAreaHeight, fullscreenBottomAreaHeight) - layout.safeInsets.right - fullscreenListHeight - 4.0, y: 0.0, width: fullscreenListHeight, height: layout.size.height) - } else { - fullscreenListWidth = layout.size.width - fullscreenListTransform = CATransform3DMakeRotation(-CGFloat(CGFloat.pi / 2.0), 0.0, 0.0, 1.0) - fullscreenListUpdateSizeAndInsets = ListViewUpdateSizeAndInsets(size: CGSize(width: fullscreenListHeight, height: layout.size.width), insets: UIEdgeInsets(top: fullscreenListInset + layout.safeInsets.left, left: 0.0, bottom: fullscreenListInset + layout.safeInsets.left, right: 0.0), duration: duration, curve: curve) - fullscreenListContainerFrame = CGRect(x: 0.0, y: layout.size.height - min(bottomPanelHeight, fullscreenBottomAreaHeight + layout.intrinsicInsets.bottom) - fullscreenListHeight - 4.0, width: layout.size.width, height: fullscreenListHeight) - } - - transition.updateFrame(node: self.fullscreenListContainer, frame: fullscreenListContainerFrame) - - self.fullscreenListNode.bounds = CGRect(x: 0.0, y: 0.0, width: fullscreenListHeight, height: fullscreenListWidth) - transition.updatePosition(node: self.fullscreenListNode, position: CGPoint(x: fullscreenListContainerFrame.width / 2.0, y: fullscreenListContainerFrame.height / 2.0)) - - self.fullscreenListNode.transform = fullscreenListTransform - self.fullscreenListNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: nil, updateSizeAndInsets: fullscreenListUpdateSizeAndInsets, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) - - if case .regular = layout.metrics.widthClass { - self.transitionContainerNode.view.mask = nil - } else { - self.transitionContainerNode.view.mask = self.transitionMaskView - } - - var childrenLayout = layout - var childrenInsets = childrenLayout.intrinsicInsets - var childrenSafeInsets = childrenLayout.safeInsets - if case .regular = layout.metrics.widthClass { - let childrenLayoutWidth: CGFloat = 375.0 - if contentLeftInset.isZero { - childrenSafeInsets.left = floorToScreenPixels((size.width - childrenLayoutWidth) / 2.0) - childrenSafeInsets.right = floorToScreenPixels((size.width - childrenLayoutWidth) / 2.0) - } else { - childrenSafeInsets.left = floorToScreenPixels((contentLeftInset - childrenLayoutWidth) / 2.0) - childrenSafeInsets.right = childrenSafeInsets.left + (size.width - contentLeftInset) - } - } else if !self.isLandscape, case .fullscreen = effectiveDisplayMode { - childrenInsets.bottom += self.effectiveBottomAreaHeight + fullscreenListHeight + 36.0 - } - childrenLayout.safeInsets = childrenSafeInsets - childrenLayout.intrinsicInsets = childrenInsets - self.controller?.presentationContext.containerLayoutUpdated(childrenLayout, transition: transition) - - var bottomPanelLeftInset = contentLeftInset - var bottomPanelWidth = size.width - contentLeftInset - if case .regular = layout.metrics.widthClass, bottomPanelLeftInset.isZero { - bottomPanelLeftInset = floorToScreenPixels((layout.size.width - contentWidth) / 2.0) - bottomPanelWidth = contentWidth - } - - var bottomPanelFrame = CGRect(origin: CGPoint(x: bottomPanelLeftInset, y: layout.size.height - bottomPanelHeight), size: CGSize(width: bottomPanelWidth, height: bottomPanelHeight)) - let bottomPanelCoverHeight = bottomAreaHeight + layout.intrinsicInsets.bottom - if self.isLandscape { - bottomPanelFrame = CGRect(origin: CGPoint(x: layout.size.width - fullscreenBottomAreaHeight - layout.safeInsets.right, y: 0.0), size: CGSize(width: fullscreenBottomAreaHeight + layout.safeInsets.right, height: layout.size.height)) - } - let bottomGradientFrame = CGRect(origin: CGPoint(x: bottomPanelLeftInset, y: layout.size.height - bottomPanelCoverHeight), size: CGSize(width: bottomPanelWidth, height: bottomGradientHeight)) - transition.updateFrame(node: self.bottomGradientNode, frame: bottomGradientFrame) - transition.updateFrame(node: self.bottomPanelNode, frame: bottomPanelFrame) - - if let pickerView = self.pickerView { - transition.updateFrame(view: pickerView, frame: CGRect(x: 0.0, y: layout.size.height - bottomPanelHeight - 216.0, width: size.width, height: 216.0)) - } - - let timerFrame = CGRect(x: 0.0, y: layout.size.height - bottomPanelHeight - 216.0, width: size.width, height: 216.0) - transition.updateFrame(node: self.timerNode, frame: timerFrame) - self.timerNode.update(size: timerFrame.size, scheduleTime: self.callState?.scheduleTimestamp, transition: .immediate) - - let scheduleTextSize = self.scheduleTextNode.updateLayout(CGSize(width: size.width - sideInset * 2.0, height: CGFloat.greatestFiniteMagnitude)) - self.scheduleTextNode.frame = CGRect(origin: CGPoint(x: floor((size.width - scheduleTextSize.width) / 2.0), y: layout.size.height - layout.intrinsicInsets.bottom - scheduleTextSize.height - 145.0), size: scheduleTextSize) - - let centralButtonSide = min(contentWidth, size.height) - 32.0 - let centralButtonSize = CGSize(width: centralButtonSide, height: centralButtonSide) - let cameraButtonSize = smallButtonSize - let sideButtonMinimalInset: CGFloat = 16.0 - let sideButtonOffset = min(42.0, floor((((contentWidth - 112.0) / 2.0) - sideButtonSize.width) / 2.0)) - let sideButtonOrigin = max(sideButtonMinimalInset, floor((contentWidth - 112.0) / 2.0) - sideButtonOffset - sideButtonSize.width) - - let smallButtons: Bool - if case .regular = layout.metrics.widthClass { - smallButtons = false - } else { - switch effectiveDisplayMode { - case .modal: - smallButtons = self.isLandscape - case .fullscreen: - smallButtons = true - } - } - let actionButtonState: VoiceChatActionButton.State - let actionButtonTitle: String - let actionButtonSubtitle: String - var actionButtonEnabled = true - if let callState = self.callState, !self.isScheduling { - if callState.scheduleTimestamp != nil { - self.ignoreConnecting = true - if callState.canManageCall { - actionButtonState = .scheduled(state: .start) - actionButtonTitle = self.presentationData.strings.VoiceChat_StartNow - actionButtonSubtitle = "" - } else { - if callState.subscribedToScheduled { - actionButtonState = .scheduled(state: .unsubscribe) - actionButtonTitle = self.presentationData.strings.VoiceChat_CancelReminder - } else { - actionButtonState = .scheduled(state: .subscribe) - actionButtonTitle = self.presentationData.strings.VoiceChat_SetReminder - } - actionButtonSubtitle = "" - } - } else { - let connected = self.ignoreConnecting || callState.networkState == .connected - if case .connected = callState.networkState { - self.ignoreConnecting = false - self.ignoreConnectingTimer?.invalidate() - self.ignoreConnectingTimer = nil - } else if self.ignoreConnecting { - if self.ignoreConnectingTimer == nil { - let timer = SwiftSignalKit.Timer(timeout: 3.0, repeat: false, completion: { [weak self] in - if let strongSelf = self { - strongSelf.ignoreConnecting = false - strongSelf.ignoreConnectingTimer?.invalidate() - strongSelf.ignoreConnectingTimer = nil - - if let (layout, navigationHeight) = strongSelf.validLayout { - strongSelf.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .immediate) - } - } - }, queue: Queue.mainQueue()) - self.ignoreConnectingTimer = timer - timer.start() - } - } - - if connected { - if let muteState = callState.muteState, !self.pushingToTalk && !self.temporaryPushingToTalk { - if muteState.canUnmute { - actionButtonState = .active(state: .muted) - - actionButtonTitle = self.presentationData.strings.VoiceChat_Unmute - actionButtonSubtitle = "" - } else { - actionButtonState = .active(state: .cantSpeak) - - if callState.raisedHand { - actionButtonTitle = self.presentationData.strings.VoiceChat_AskedToSpeak - actionButtonSubtitle = self.presentationData.strings.VoiceChat_AskedToSpeakHelp - } else { - actionButtonTitle = self.presentationData.strings.VoiceChat_MutedByAdmin - actionButtonSubtitle = self.presentationData.strings.VoiceChat_MutedByAdminHelp - } - } - } else { - actionButtonState = .active(state: .on) - - actionButtonTitle = self.pushingToTalk ? self.presentationData.strings.VoiceChat_Live : self.presentationData.strings.VoiceChat_Mute - actionButtonSubtitle = "" - } - } else { - actionButtonState = .connecting - actionButtonTitle = self.presentationData.strings.VoiceChat_Connecting - actionButtonSubtitle = "" - actionButtonEnabled = false - } - } - } else { - if self.isScheduling { - actionButtonState = .button(text: self.scheduleButtonTitle) - actionButtonTitle = "" - actionButtonSubtitle = "" - actionButtonEnabled = true - } else { - actionButtonState = .connecting - actionButtonTitle = self.presentationData.strings.VoiceChat_Connecting - actionButtonSubtitle = "" - actionButtonEnabled = false - } - } - - self.actionButton.isDisabled = !actionButtonEnabled - self.actionButton.update(size: centralButtonSize, buttonSize: CGSize(width: 112.0, height: 112.0), state: actionButtonState, title: actionButtonTitle, subtitle: actionButtonSubtitle, dark: self.isFullscreen, small: smallButtons, animated: true) - - let isVideoEnabled = self.callState?.isVideoEnabled ?? false - var hasCameraButton = isVideoEnabled - if let joinedVideo = self.joinedVideo { - hasCameraButton = joinedVideo - } - if !isVideoEnabled { - hasCameraButton = false - } - switch actionButtonState { - case let .active(state): - switch state { - case .cantSpeak: - hasCameraButton = false - case .on, .muted: - break - } - case .connecting: - if !self.connectedOnce { - hasCameraButton = false - } - case .scheduled, .button: - hasCameraButton = false - } - let hasVideo = hasCameraButton && self.call.hasVideo - - let upperButtonDistance: CGFloat = 12.0 - let firstButtonFrame: CGRect - let secondButtonFrame: CGRect - let thirdButtonFrame: CGRect - let forthButtonFrame: CGRect - - let leftButtonFrame: CGRect - if self.isScheduled || !hasVideo { - leftButtonFrame = CGRect(origin: CGPoint(x: sideButtonOrigin, y: floor((self.effectiveBottomAreaHeight - sideButtonSize.height) / 2.0)), size: sideButtonSize) - } else { - leftButtonFrame = CGRect(origin: CGPoint(x: sideButtonOrigin, y: floor((self.effectiveBottomAreaHeight - sideButtonSize.height - upperButtonDistance - cameraButtonSize.height) / 2.0) + upperButtonDistance + cameraButtonSize.height), size: sideButtonSize) - } - let rightButtonFrame = CGRect(origin: CGPoint(x: contentWidth - sideButtonOrigin - sideButtonSize.width, y: floor((self.effectiveBottomAreaHeight - sideButtonSize.height) / 2.0)), size: sideButtonSize) - var centerButtonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - centralButtonSize.width) / 2.0), y: floor((self.effectiveBottomAreaHeight - centralButtonSize.height) / 2.0) - 3.0), size: centralButtonSize) - - if case .regular = layout.metrics.widthClass { - centerButtonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((contentWidth - centralButtonSize.width) / 2.0), y: floor((self.effectiveBottomAreaHeight - centralButtonSize.height) / 2.0) - 3.0), size: centralButtonSize) - - if hasCameraButton { - firstButtonFrame = CGRect(origin: CGPoint(x: floor(leftButtonFrame.midX - cameraButtonSize.width / 2.0), y: leftButtonFrame.minY - upperButtonDistance - cameraButtonSize.height), size: cameraButtonSize) - } else { - firstButtonFrame = CGRect(origin: CGPoint(x: leftButtonFrame.center.x - cameraButtonSize.width / 2.0, y: leftButtonFrame.center.y - cameraButtonSize.height / 2.0), size: cameraButtonSize) - } - secondButtonFrame = leftButtonFrame - thirdButtonFrame = centerButtonFrame - forthButtonFrame = rightButtonFrame - } else { - switch effectiveDisplayMode { - case .modal: - if self.isLandscape { - let sideInset: CGFloat - let buttonsCount: Int - if hasVideo { - sideInset = 26.0 - buttonsCount = 4 - } else { - sideInset = 42.0 - buttonsCount = 3 - } - let spacing = floor((layout.size.height - sideInset * 2.0 - sideButtonSize.height * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) - let x = floor((fullscreenBottomAreaHeight - sideButtonSize.width) / 2.0) - forthButtonFrame = CGRect(origin: CGPoint(x: x, y: sideInset), size: sideButtonSize) - let thirdButtonPreFrame = CGRect(origin: CGPoint(x: x, y: sideInset + sideButtonSize.height + spacing), size: sideButtonSize) - thirdButtonFrame = CGRect(origin: CGPoint(x: floor(thirdButtonPreFrame.midX - centralButtonSize.width / 2.0), y: floor(thirdButtonPreFrame.midY - centralButtonSize.height / 2.0)), size: centralButtonSize) - secondButtonFrame = CGRect(origin: CGPoint(x: x, y: thirdButtonPreFrame.maxY + spacing), size: sideButtonSize) - if hasCameraButton { - firstButtonFrame = CGRect(origin: CGPoint(x: x, y: layout.size.height - sideInset - sideButtonSize.height), size: sideButtonSize) - } else { - firstButtonFrame = secondButtonFrame - } - } else { - if hasCameraButton { - firstButtonFrame = CGRect(origin: CGPoint(x: floor(leftButtonFrame.midX - cameraButtonSize.width / 2.0), y: leftButtonFrame.minY - upperButtonDistance - cameraButtonSize.height), size: cameraButtonSize) - } else { - firstButtonFrame = CGRect(origin: CGPoint(x: leftButtonFrame.center.x - cameraButtonSize.width / 2.0, y: leftButtonFrame.center.y - cameraButtonSize.height / 2.0), size: cameraButtonSize) - } - secondButtonFrame = leftButtonFrame - thirdButtonFrame = centerButtonFrame - forthButtonFrame = rightButtonFrame - } - case let .fullscreen(controlsHidden): - if self.isLandscape { - let sideInset: CGFloat - let buttonsCount: Int - if hasVideo { - sideInset = 26.0 - buttonsCount = 4 - } else { - sideInset = 42.0 - buttonsCount = 3 - } - let spacing = floor((layout.size.height - sideInset * 2.0 - sideButtonSize.height * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) - let x = controlsHidden ? fullscreenBottomAreaHeight + layout.safeInsets.right + 30.0 : floor((fullscreenBottomAreaHeight - sideButtonSize.width) / 2.0) - forthButtonFrame = CGRect(origin: CGPoint(x: x, y: sideInset), size: sideButtonSize) - let thirdButtonPreFrame = CGRect(origin: CGPoint(x: x, y: sideInset + sideButtonSize.height + spacing), size: sideButtonSize) - thirdButtonFrame = CGRect(origin: CGPoint(x: floor(thirdButtonPreFrame.midX - centralButtonSize.width / 2.0), y: floor(thirdButtonPreFrame.midY - centralButtonSize.height / 2.0)), size: centralButtonSize) - secondButtonFrame = CGRect(origin: CGPoint(x: x, y: thirdButtonPreFrame.maxY + spacing), size: sideButtonSize) - if hasVideo { - firstButtonFrame = CGRect(origin: CGPoint(x: x, y: layout.size.height - sideInset - sideButtonSize.height), size: sideButtonSize) - } else { - firstButtonFrame = secondButtonFrame - } - } else { - let sideInset: CGFloat - let buttonsCount: Int - if hasVideo { - sideInset = 26.0 - buttonsCount = 4 - } else { - sideInset = 42.0 - buttonsCount = 3 - } - let spacing = floor((layout.size.width - sideInset * 2.0 - sideButtonSize.width * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) - let y = controlsHidden ? self.effectiveBottomAreaHeight + layout.intrinsicInsets.bottom + 30.0: floor((self.effectiveBottomAreaHeight - sideButtonSize.height) / 2.0) - if hasVideo { - firstButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: y), size: sideButtonSize) - secondButtonFrame = CGRect(origin: CGPoint(x: firstButtonFrame.maxX + spacing, y: y), size: sideButtonSize) - } else { - firstButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: y), size: sideButtonSize) - secondButtonFrame = firstButtonFrame - } - let thirdButtonPreFrame = CGRect(origin: CGPoint(x: secondButtonFrame.maxX + spacing, y: y), size: sideButtonSize) - thirdButtonFrame = CGRect(origin: CGPoint(x: floor(thirdButtonPreFrame.midX - centralButtonSize.width / 2.0), y: floor(thirdButtonPreFrame.midY - centralButtonSize.height / 2.0)), size: centralButtonSize) - forthButtonFrame = CGRect(origin: CGPoint(x: thirdButtonPreFrame.maxX + spacing, y: y), size: sideButtonSize) - } - } - } - - let buttonHeight = self.scheduleCancelButton.updateLayout(width: size.width - 32.0, transition: .immediate) - self.scheduleCancelButton.frame = CGRect(x: 16.0, y: 137.0, width: size.width - 32.0, height: buttonHeight) - - if self.actionButton.supernode === self.bottomPanelNode { - transition.updateFrame(node: self.actionButton, frame: thirdButtonFrame, completion: transition.isAnimated ? { [weak self] _ in - self?.animatingExpansion = false - } : nil) - } - - self.cameraButton.isUserInteractionEnabled = hasCameraButton - - var buttonsTransition: ContainedViewLayoutTransition = .immediate - if !isFirstTime { - if case .animated(_, .spring) = transition { - buttonsTransition = transition - } else { - buttonsTransition = .animated(duration: 0.3, curve: .linear) - } - } - self.updateButtons(transition: buttonsTransition) - - if self.audioButton.supernode === self.bottomPanelNode { - transition.updateAlpha(node: self.cameraButton, alpha: hasCameraButton ? 1.0 : 0.0) - transition.updateFrameAsPositionAndBounds(node: self.switchCameraButton, frame: firstButtonFrame) - - if !self.animatingButtonsSwap || transition.isAnimated { - transition.updateFrameAsPositionAndBounds(node: self.audioButton, frame: secondButtonFrame, completion: { [weak self] _ in - self?.animatingButtonsSwap = false - }) - transition.updateFrameAsPositionAndBounds(node: self.cameraButton, frame: secondButtonFrame) - } - transition.updateFrameAsPositionAndBounds(node: self.leaveButton, frame: forthButtonFrame) - } - if isFirstTime { - while !self.enqueuedTransitions.isEmpty { - self.dequeueTransition() - } - while !self.enqueuedFullscreenTransitions.isEmpty { - self.dequeueFullscreenTransition() - } - } - } - - private var appIsActive = true { - didSet { - if self.appIsActive != oldValue { - self.updateVisibility() - self.updateRequestedVideoChannels() - } - } - } - private var visibility = false { - didSet { - if self.visibility != oldValue { - self.updateVisibility() - self.updateRequestedVideoChannels() - } - } - } - - private func updateVisibility() { - let visible = self.appIsActive && self.visibility - if self.tileGridNode.isHidden { - self.tileGridNode.visibility = false - } else { - self.tileGridNode.visibility = visible - } - self.mainStageNode.visibility = visible - self.listNode.forEachItemNode { itemNode in - if let itemNode = itemNode as? VoiceChatTilesGridItemNode { - itemNode.gridVisibility = visible - } - } - self.fullscreenListNode.forEachItemNode { itemNode in - if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode { - itemNode.gridVisibility = visible - } - } - - self.videoRenderingContext.updateVisibility(isVisible: visible) - } - - func animateIn() { - guard let (layout, navigationHeight) = self.validLayout else { - return - } - - self.visibility = true - - self.updateDecorationsLayout(transition: .immediate) - - self.animatingAppearance = true - - let initialBounds = self.contentContainer.bounds - let topPanelFrame = self.topPanelNode.view.convert(self.topPanelNode.bounds, to: self.view) - self.contentContainer.bounds = initialBounds.offsetBy(dx: 0.0, dy: -(layout.size.height - topPanelFrame.minY)) - self.contentContainer.isHidden = false - - let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .spring) - transition.animateView({ - self.contentContainer.view.bounds = initialBounds - }, completion: { _ in - self.animatingAppearance = false - if self.actionButton.supernode !== self.bottomPanelNode { - self.actionButton.ignoreHierarchyChanges = true - self.audioButton.isHidden = false - self.cameraButton.isHidden = false - self.leaveButton.isHidden = false - self.audioButton.layer.removeAllAnimations() - self.cameraButton.layer.removeAllAnimations() - self.leaveButton.layer.removeAllAnimations() - self.bottomPanelNode.addSubnode(self.cameraButton) - self.bottomPanelNode.addSubnode(self.audioButton) - self.bottomPanelNode.addSubnode(self.leaveButton) - self.bottomPanelNode.addSubnode(self.actionButton) - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .immediate) - self.actionButton.ignoreHierarchyChanges = false - } - - self.controller?.currentOverlayController?.dismiss() - self.controller?.currentOverlayController = nil - }) - self.dimNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) - } - - func animateOut(completion: (() -> Void)?) { - guard let (layout, _) = self.validLayout else { - return - } - var offsetCompleted = false - let internalCompletion: () -> Void = { [weak self] in - if offsetCompleted { - if let strongSelf = self { - strongSelf.contentContainer.layer.removeAllAnimations() - strongSelf.dimNode.layer.removeAllAnimations() - - var bounds = strongSelf.contentContainer.bounds - bounds.origin.y = 0.0 - strongSelf.contentContainer.bounds = bounds - - strongSelf.visibility = false - } - completion?() - } - } - - let topPanelFrame = self.topPanelNode.view.convert(self.topPanelNode.bounds, to: self.view) - self.contentContainer.layer.animateBoundsOriginYAdditive(from: self.contentContainer.bounds.origin.y, to: -(layout.size.height - topPanelFrame.minY) - 44.0, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in - offsetCompleted = true - internalCompletion() - }) - self.dimNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false) - } - - private func enqueueTransition(_ transition: ListTransition) { - self.enqueuedTransitions.append(transition) - - if let _ = self.validLayout { - while !self.enqueuedTransitions.isEmpty { - self.dequeueTransition() - } - } - } - - private func enqueueFullscreenTransition(_ transition: ListTransition) { - self.enqueuedFullscreenTransitions.append(transition) - - if let _ = self.validLayout { - while !self.enqueuedFullscreenTransitions.isEmpty { - self.dequeueFullscreenTransition() - } - } - } - - private func dequeueTransition() { - guard let (layout, _) = self.validLayout, let transition = self.enqueuedTransitions.first else { - return - } - self.enqueuedTransitions.remove(at: 0) - - if let callState = self.callState { - if callState.scheduleTimestamp != nil && self.listNode.alpha > 0.0 { - self.timerNode.isHidden = false - self.cameraButton.alpha = 0.0 - self.cameraButton.isUserInteractionEnabled = false - self.listNode.alpha = 0.0 - self.listNode.isUserInteractionEnabled = false - self.backgroundNode.backgroundColor = panelBackgroundColor - self.updateDecorationsColors() - } else if callState.scheduleTimestamp == nil && !self.isScheduling && self.listNode.alpha == 0.0 { - self.transitionToCall() - } - } - - var options = ListViewDeleteAndInsertOptions() - let isFirstTime = self.isFirstTime - if isFirstTime { - self.isFirstTime = false - } else { - if transition.crossFade { - options.insert(.AnimateCrossfade) - } - if transition.animated { - options.insert(.AnimateInsertion) - } - } - options.insert(.LowLatency) - options.insert(.PreferSynchronousResourceLoading) - - var size = layout.size - if case .regular = layout.metrics.widthClass { - size.width = floor(min(size.width, size.height) * 0.5) - } - - let bottomPanelHeight = self.isLandscape ? layout.intrinsicInsets.bottom : bottomAreaHeight + layout.intrinsicInsets.bottom - let layoutTopInset: CGFloat = max(layout.statusBarHeight ?? 0.0, layout.safeInsets.top) - let listTopInset = layoutTopInset + topPanelHeight - let listSize = CGSize(width: size.width, height: layout.size.height - listTopInset - bottomPanelHeight + bottomGradientHeight) - - self.topInset = listSize.height - 46.0 - floor(56.0 * 3.5) - bottomGradientHeight - - if transition.animated { - self.animatingInsertion = true - } - self.listNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, scrollToItem: nil, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { [weak self] _ in - guard let strongSelf = self else { - return - } - if isFirstTime { - strongSelf.updateDecorationsLayout(transition: .immediate) - } else if strongSelf.animatingInsertion { - strongSelf.updateDecorationsLayout(transition: .animated(duration: 0.2, curve: .easeInOut)) - } - strongSelf.animatingInsertion = false - if !strongSelf.didSetContentsReady { - strongSelf.didSetContentsReady = true - strongSelf.controller?.contentsReady.set(true) - } - strongSelf.updateVisibility() - }) - } - - private func dequeueFullscreenTransition() { - guard let _ = self.validLayout, let transition = self.enqueuedFullscreenTransitions.first else { - return - } - self.enqueuedFullscreenTransitions.remove(at: 0) - - var options = ListViewDeleteAndInsertOptions() - let isFirstTime = self.isFirstTime - if !isFirstTime { - if transition.animated { - options.insert(.AnimateInsertion) - } - } - - self.fullscreenListNode.transaction(deleteIndices: transition.deletions, insertIndicesAndItems: transition.insertions, updateIndicesAndItems: transition.updates, options: options, scrollToItem: nil, updateSizeAndInsets: nil, updateOpaqueState: nil, completion: { _ in - }) - } - - private func updateMembers(maybeUpdateVideo: Bool = true, force: Bool = false) { - self.updateMembers(muteState: self.effectiveMuteState, callMembers: self.currentCallMembers ?? ([], nil), invitedPeers: self.currentInvitedPeers ?? [], speakingPeers: self.currentSpeakingPeers ?? Set(), maybeUpdateVideo: maybeUpdateVideo, force: force) - } - - private func updateMembers(muteState: GroupCallParticipantsContext.Participant.MuteState?, callMembers: ([GroupCallParticipantsContext.Participant], String?), invitedPeers: [EnginePeer], speakingPeers: Set, maybeUpdateVideo: Bool = true, force: Bool = false) { - var disableAnimation = false - if self.currentCallMembers?.1 != callMembers.1 { - disableAnimation = true - } - - let speakingPeersUpdated = self.currentSpeakingPeers != speakingPeers - self.currentCallMembers = callMembers - self.currentInvitedPeers = invitedPeers - - var entries: [ListEntry] = [] - var fullscreenEntries: [ListEntry] = [] - var index: Int32 = 0 - var fullscreenIndex: Int32 = 0 - var processedPeerIds = Set() - var processedFullscreenPeerIds = Set() - - var peerIdToCameraEndpointId: [PeerId: String] = [:] - var peerIdToEndpointId: [PeerId: String] = [:] - - var requestedVideoChannels: [PresentationGroupCallRequestedVideo] = [] - var gridTileItems: [VoiceChatTileItem] = [] - var tileItems: [VoiceChatTileItem] = [] - var gridTileByVideoEndpoint: [String: VoiceChatTileItem] = [:] - var tileByVideoEndpoint: [String: VoiceChatTileItem] = [:] - var entryByPeerId: [PeerId: VoiceChatPeerEntry] = [:] - var latestWideVideo: String? = nil - - var isTablet = false - var displayPanelVideos = false - if let (layout, _) = self.validLayout, case .regular = layout.metrics.widthClass { - isTablet = true - displayPanelVideos = self.displayPanelVideos - } - -// let isLivestream: Bool -// if let channel = self.peer as? TelegramChannel, case .broadcast = channel.info { -// isLivestream = true -// } else { -// isLivestream = false -// } - - let canManageCall = self.callState?.canManageCall ?? false - - var joinedVideo = self.joinedVideo ?? true - - var myEntry: VoiceChatPeerEntry? - var mainEntry: VoiceChatPeerEntry? - for member in callMembers.0 { - if processedPeerIds.contains(member.peer.id) { - continue - } - processedPeerIds.insert(member.peer.id) - - let memberState: VoiceChatPeerEntry.State - var memberMuteState: GroupCallParticipantsContext.Participant.MuteState? - if member.hasRaiseHand && !(member.muteState?.canUnmute ?? true) { -// if isLivestream && !canManageCall { -// continue -// } - memberState = .raisedHand - memberMuteState = member.muteState - - if self.raisedHandDisplayDisposables[member.peer.id] == nil { - var displayedRaisedHands = self.displayedRaisedHands - displayedRaisedHands.insert(member.peer.id) - self.displayedRaisedHands = displayedRaisedHands - - let signal: Signal = Signal.complete() |> delay(3.0, queue: Queue.mainQueue()) - self.raisedHandDisplayDisposables[member.peer.id] = signal.start(completed: { [weak self] in - if let strongSelf = self { - var displayedRaisedHands = strongSelf.displayedRaisedHands - displayedRaisedHands.remove(member.peer.id) - strongSelf.displayedRaisedHands = displayedRaisedHands - strongSelf.updateMembers() - } - }) - } - } else { - if member.peer.id == self.callState?.myPeerId { - if muteState == nil { - memberState = speakingPeers.contains(member.peer.id) ? .speaking : .listening - } else { - memberState = .listening - memberMuteState = member.muteState - } - } else { - memberState = speakingPeers.contains(member.peer.id) ? .speaking : .listening - memberMuteState = member.muteState - } - - if let disposable = self.raisedHandDisplayDisposables[member.peer.id] { - disposable.dispose() - self.raisedHandDisplayDisposables[member.peer.id] = nil - } - -// if isLivestream && !(memberMuteState?.canUnmute ?? true) { -// continue -// } - } - - var memberPeer = member.peer - if member.peer.id == self.callState?.myPeerId { - joinedVideo = member.joinedVideo - if let user = memberPeer as? TelegramUser, let photo = self.currentUpdatingAvatar { - memberPeer = user.withUpdatedPhoto([photo]) - } - } - - joinedVideo = true - - if let videoEndpointId = member.videoEndpointId { - peerIdToCameraEndpointId[member.peer.id] = videoEndpointId - } - if let anyEndpointId = member.presentationEndpointId ?? member.videoEndpointId { - peerIdToEndpointId[member.peer.id] = anyEndpointId - } - - let peerEntry = VoiceChatPeerEntry( - peer: memberPeer, - about: member.about, - isMyPeer: self.callState?.myPeerId == member.peer.id, - videoEndpointId: member.videoEndpointId, - videoPaused: member.videoDescription?.isPaused ?? false, - presentationEndpointId: member.presentationEndpointId, - presentationPaused: member.presentationDescription?.isPaused ?? false, - effectiveSpeakerVideoEndpointId: self.effectiveSpeaker?.1, - state: memberState, - muteState: memberMuteState, - canManageCall: canManageCall, - volume: member.volume, - raisedHand: member.hasRaiseHand, - displayRaisedHandStatus: self.displayedRaisedHands.contains(member.peer.id), - active: memberPeer.id == self.effectiveSpeaker?.0, - isLandscape: self.isLandscape - ) - if peerEntry.isMyPeer { - myEntry = peerEntry - } - if peerEntry.active { - mainEntry = peerEntry - } - entryByPeerId[peerEntry.peer.id] = peerEntry - - var isTile = false - if let interaction = self.itemInteraction { - if let videoEndpointId = member.presentationEndpointId { - if !self.videoOrder.contains(videoEndpointId) { - if peerEntry.isMyPeer { - self.videoOrder.insert(videoEndpointId, at: 0) - } else { - self.videoOrder.append(videoEndpointId) - } - } - if isTablet { - if let tileItem = ListEntry.peer(peerEntry, 0).tileItem(context: self.context, presentationData: self.presentationData, interaction: interaction, isTablet: isTablet, videoEndpointId: videoEndpointId, videoReady: self.readyVideoEndpointIds.contains(videoEndpointId), videoTimeouted: self.timeoutedEndpointIds.contains(videoEndpointId), videoIsPaused: member.presentationDescription?.isPaused ?? false, showAsPresentation: peerIdToCameraEndpointId[peerEntry.peer.id] != nil, secondary: false) { - isTile = true - gridTileByVideoEndpoint[videoEndpointId] = tileItem - } - } - if let tileItem = ListEntry.peer(peerEntry, 0).tileItem(context: self.context, presentationData: self.presentationData, interaction: interaction, isTablet: isTablet, videoEndpointId: videoEndpointId, videoReady: self.readyVideoEndpointIds.contains(videoEndpointId), videoTimeouted: self.timeoutedEndpointIds.contains(videoEndpointId), videoIsPaused: member.presentationDescription?.isPaused ?? false, showAsPresentation: peerIdToCameraEndpointId[peerEntry.peer.id] != nil, secondary: displayPanelVideos) { - isTile = true - tileByVideoEndpoint[videoEndpointId] = tileItem - } - if self.wideVideoNodes.contains(videoEndpointId) { - latestWideVideo = videoEndpointId - } - } - if let videoEndpointId = member.videoEndpointId { - if !self.videoOrder.contains(videoEndpointId) { - if peerEntry.isMyPeer { - self.videoOrder.insert(videoEndpointId, at: 0) - } else { - self.videoOrder.append(videoEndpointId) - } - } - if isTablet { - if let tileItem = ListEntry.peer(peerEntry, 0).tileItem(context: self.context, presentationData: self.presentationData, interaction: interaction, isTablet: isTablet, videoEndpointId: videoEndpointId, videoReady: self.readyVideoEndpointIds.contains(videoEndpointId), videoTimeouted: self.timeoutedEndpointIds.contains(videoEndpointId), videoIsPaused: member.videoDescription?.isPaused ?? false, showAsPresentation: false, secondary: false) { - isTile = true - gridTileByVideoEndpoint[videoEndpointId] = tileItem - } - } - if let tileItem = ListEntry.peer(peerEntry, 0).tileItem(context: self.context, presentationData: self.presentationData, interaction: interaction, isTablet: isTablet, videoEndpointId: videoEndpointId, videoReady: self.readyVideoEndpointIds.contains(videoEndpointId), videoTimeouted: self.timeoutedEndpointIds.contains(videoEndpointId), videoIsPaused: member.videoDescription?.isPaused ?? false, showAsPresentation: false, secondary: displayPanelVideos) { - isTile = true - tileByVideoEndpoint[videoEndpointId] = tileItem - } - if self.wideVideoNodes.contains(videoEndpointId) { - latestWideVideo = videoEndpointId - } - } - } - - if !isTile || isTablet || !joinedVideo { - entries.append(.peer(peerEntry, index)) - } - - index += 1 - - if self.callState?.networkState == .connecting { - } else { - if var videoChannel = member.requestedVideoChannel(minQuality: .thumbnail, maxQuality: .medium) { - if self.effectiveSpeaker?.1 == videoChannel.endpointId { - videoChannel.maxQuality = .full - } - requestedVideoChannels.append(videoChannel) - } - if member.peer.id != self.callState?.myPeerId { - if var presentationChannel = member.requestedPresentationVideoChannel(minQuality: .thumbnail, maxQuality: .thumbnail) { - if self.effectiveSpeaker?.1 == presentationChannel.endpointId { - presentationChannel.minQuality = .full - presentationChannel.maxQuality = .full - } - requestedVideoChannels.append(presentationChannel) - } - } - } - } - - var temporaryList: [String] = [] - for tileVideoEndpoint in self.videoOrder { - if let _ = tileByVideoEndpoint[tileVideoEndpoint] { - temporaryList.append(tileVideoEndpoint) - } - } - - if (tileByVideoEndpoint.count % 2) != 0, let last = temporaryList.last, !self.wideVideoNodes.contains(last), let latestWide = latestWideVideo { - self.videoOrder.removeAll(where: { $0 == latestWide }) - self.videoOrder.append(latestWide) - } - - for tileVideoEndpoint in self.videoOrder { - if let tileItem = gridTileByVideoEndpoint[tileVideoEndpoint] { - gridTileItems.append(tileItem) - } - if let tileItem = tileByVideoEndpoint[tileVideoEndpoint] { - if displayPanelVideos && tileItem.peer.id == self.effectiveSpeaker?.0 { - } else { - tileItems.append(tileItem) - } - if let fullscreenEntry = entryByPeerId[tileItem.peer.id] { - if processedFullscreenPeerIds.contains(tileItem.peer.id) { - continue - } - fullscreenEntries.append(.peer(fullscreenEntry, fullscreenIndex)) - processedFullscreenPeerIds.insert(fullscreenEntry.peer.id) - fullscreenIndex += 1 - } - } - } - - self.joinedVideo = joinedVideo - - let configuration = self.configuration ?? VoiceChatConfiguration.defaultValue - var reachedLimit = false - - if !joinedVideo && (!tileItems.isEmpty || !gridTileItems.isEmpty), let peer = self.peer { - tileItems.removeAll() - gridTileItems.removeAll() - - tileItems.append(VoiceChatTileItem(account: self.context.account, peer: peer, videoEndpointId: "", videoReady: false, videoTimeouted: true, isVideoLimit: true, videoLimit: configuration.videoParticipantsMaxCount, isPaused: false, isOwnScreencast: false, strings: self.presentationData.strings, nameDisplayOrder: self.presentationData.nameDisplayOrder, speaking: false, secondary: false, isTablet: false, icon: .none, text: .none, additionalText: nil, action: {}, contextAction: nil, getVideo: { _ in return nil }, getAudioLevel: nil)) - } else if let callState = self.callState, !tileItems.isEmpty && callState.isVideoWatchersLimitReached && self.connectedOnce && (callState.canManageCall || callState.adminIds.contains(self.context.account.peerId)) { - reachedLimit = true - } - - for member in callMembers.0 { - if processedFullscreenPeerIds.contains(member.peer.id) { - continue - } - processedFullscreenPeerIds.insert(member.peer.id) - if let peerEntry = entryByPeerId[member.peer.id] { - fullscreenEntries.append(.peer(peerEntry, fullscreenIndex)) - fullscreenIndex += 1 - } - } - - for peer in invitedPeers { - if processedPeerIds.contains(peer.id) { - continue - } - processedPeerIds.insert(peer.id) - - entries.append(.peer(VoiceChatPeerEntry( - peer: peer._asPeer(), - about: nil, - isMyPeer: false, - videoEndpointId: nil, - videoPaused: false, - presentationEndpointId: nil, - presentationPaused: false, - effectiveSpeakerVideoEndpointId: nil, - state: .invited, - muteState: nil, - canManageCall: false, - volume: nil, - raisedHand: false, - displayRaisedHandStatus: false, - active: false, - isLandscape: false - ), index)) - index += 1 - } - - self.requestedVideoChannels = requestedVideoChannels - - var myVideoUpdated = false - if let previousMyEntry = self.myEntry, let myEntry = myEntry, previousMyEntry.effectiveVideoEndpointId == nil && myEntry.effectiveVideoEndpointId != nil && self.currentForcedSpeaker == nil { - self.currentDominantSpeaker = (myEntry.peer.id, myEntry.effectiveVideoEndpointId, CACurrentMediaTime()) - myVideoUpdated = true - } - self.myEntry = myEntry - - guard self.didSetDataReady && (force || (!self.isPanning && !self.animatingExpansion && !self.animatingMainStage)) else { - return - } - - let previousMainEntry = self.mainEntry - self.mainEntry = mainEntry - if let mainEntry = mainEntry { - self.mainStageNode.update(peerEntry: mainEntry, pinned: self.currentForcedSpeaker != nil) - - if let previousMainEntry = previousMainEntry, maybeUpdateVideo { - if previousMainEntry.effectiveVideoEndpointId != mainEntry.effectiveVideoEndpointId || previousMainEntry.videoPaused != mainEntry.videoPaused || myVideoUpdated { - self.updateMainVideo(waitForFullSize: true, entries: fullscreenEntries, force: true) - return - } - } - } else if self.effectiveSpeaker != nil, !fullscreenEntries.isEmpty { - self.updateMainVideo(waitForFullSize: true, entries: fullscreenEntries, force: true) - return - } - - self.updateRequestedVideoChannels() - - self.currentSpeakingPeers = speakingPeers - self.peerIdToEndpointId = peerIdToEndpointId - - var updateLayout = false - var animatingLayout = false - if self.currentTileItems.isEmpty != gridTileItems.isEmpty { - animatingLayout = true - updateLayout = true - } - if isTablet { - updateLayout = true - self.currentTileItems = gridTileItems - if displayPanelVideos && !tileItems.isEmpty { - entries.insert(.tiles(tileItems, .pairs, configuration.videoParticipantsMaxCount, reachedLimit), at: 0) - } - } else { - if !tileItems.isEmpty { - entries.insert(.tiles(tileItems, .pairs, configuration.videoParticipantsMaxCount, reachedLimit), at: 0) - } - } - - var canInvite = true - var inviteIsLink = false - if let peer = self.peer as? TelegramChannel { - 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 - } - } - if canInvite { - entries.append(.invite(self.presentationData.theme, self.presentationData.strings, inviteIsLink ? self.presentationData.strings.VoiceChat_Share : self.presentationData.strings.VoiceChat_InviteMember, inviteIsLink)) - } - - let previousEntries = self.currentEntries - let previousFullscreenEntries = self.currentFullscreenEntries - self.currentEntries = entries - self.currentFullscreenEntries = fullscreenEntries - - if previousEntries.count == entries.count { - var allEqual = true - for i in 0 ..< previousEntries.count { - if previousEntries[i].stableId != entries[i].stableId { - if case let .peer(lhsPeer, _) = previousEntries[i], case let .peer(rhsPeer, _) = entries[i] { - if lhsPeer.isMyPeer != rhsPeer.isMyPeer { - allEqual = false - break - } - } else { - allEqual = false - break - } - } - } - if allEqual { - disableAnimation = true - } - } else if abs(previousEntries.count - entries.count) > 10 { - disableAnimation = true - } - - let presentationData = self.presentationData.withUpdated(theme: self.darkTheme) - let transition = self.preparedTransition(from: previousEntries, to: entries, isLoading: false, isEmpty: false, canInvite: canInvite, crossFade: false, animated: !disableAnimation, context: self.context, presentationData: presentationData, interaction: self.itemInteraction!) - self.enqueueTransition(transition) - - let fullscreenTransition = self.preparedFullscreenTransition(from: previousFullscreenEntries, to: fullscreenEntries, isLoading: false, isEmpty: false, canInvite: canInvite, crossFade: false, animated: true, context: self.context, presentationData: presentationData, interaction: self.itemInteraction!) - if !isTablet { - self.enqueueFullscreenTransition(fullscreenTransition) - } - - if speakingPeersUpdated { - var speakingPeers = speakingPeers - var updatedSpeakers: [PeerId] = [] - for peerId in self.currentSpeakers { - if speakingPeers.contains(peerId) { - updatedSpeakers.append(peerId) - speakingPeers.remove(peerId) - } - } - - var currentSpeakingSubtitle = "" - for peerId in Array(speakingPeers) { - updatedSpeakers.append(peerId) - if let peer = entryByPeerId[peerId]?.peer { - let displayName = speakingPeers.count == 1 ? EnginePeer(peer).displayTitle(strings: self.presentationData.strings, displayOrder: self.presentationData.nameDisplayOrder) : EnginePeer(peer).compactDisplayTitle - if currentSpeakingSubtitle.isEmpty { - currentSpeakingSubtitle.append(displayName) - } else { - currentSpeakingSubtitle.append(", \(displayName)") - } - } - } - self.currentSpeakers = updatedSpeakers - self.currentSpeakingSubtitle = currentSpeakingSubtitle.isEmpty ? nil : currentSpeakingSubtitle - self.updateTitle(transition: .immediate) - } - - if case .fullscreen = self.displayMode, !self.mainStageNode.animating { - if speakingPeersUpdated { - self.mainStageNode.update(speakingPeerId: self.currentSpeakers.first) - } - } else { - self.mainStageNode.update(speakingPeerId: nil) - } - - if updateLayout, let (layout, navigationHeight) = self.validLayout { - let transition = ContainedViewLayoutTransition.animated(duration: 0.4, curve: .easeInOut) - if animatingLayout { - self.animatingExpansion = true - } - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) - self.updateDecorationsLayout(transition: transition) - } - } - - private func callStateDidReset() { - self.requestedVideoSources.removeAll() - self.filterRequestedVideoChannels(channels: []) - self.updateRequestedVideoChannels() - } - - private func filterRequestedVideoChannels(channels: [PresentationGroupCallRequestedVideo]) { - var validSources = Set() - for channel in channels { - validSources.insert(channel.endpointId) - - if !self.requestedVideoSources.contains(channel.endpointId) { - self.requestedVideoSources.insert(channel.endpointId) - - let input = (self.call as! PresentationGroupCallImpl).video(endpointId: channel.endpointId) - if let input = input, let videoView = self.videoRenderingContext.makeView(input: input, blur: false) { - let videoNode = GroupVideoNode(videoView: videoView, backdropVideoView: self.videoRenderingContext.makeView(input: input, blur: true)) - - self.readyVideoDisposables.set((combineLatest(videoNode.ready, .single(false) |> then(.single(true) |> delay(10.0, queue: Queue.mainQueue()))) - |> deliverOnMainQueue - ).start(next: { [weak self, weak videoNode] ready, timeouted in - if let strongSelf = self, let videoNode = videoNode { - Queue.mainQueue().after(0.1) { - if timeouted && !ready { - strongSelf.timeoutedEndpointIds.insert(channel.endpointId) - strongSelf.readyVideoEndpointIds.remove(channel.endpointId) - strongSelf.readyVideoEndpointIdsPromise.set(strongSelf.readyVideoEndpointIds) - strongSelf.wideVideoNodes.remove(channel.endpointId) - - strongSelf.updateMembers() - } else if ready { - strongSelf.readyVideoEndpointIds.insert(channel.endpointId) - strongSelf.readyVideoEndpointIdsPromise.set(strongSelf.readyVideoEndpointIds) - strongSelf.timeoutedEndpointIds.remove(channel.endpointId) - if videoNode.aspectRatio <= 0.77 { - strongSelf.wideVideoNodes.insert(channel.endpointId) - } else { - strongSelf.wideVideoNodes.remove(channel.endpointId) - } - strongSelf.updateMembers() - - if let (layout, _) = strongSelf.validLayout, case .compact = layout.metrics.widthClass { - if let interaction = strongSelf.itemInteraction { - loop: for i in 0 ..< strongSelf.currentFullscreenEntries.count { - let entry = strongSelf.currentFullscreenEntries[i] - switch entry { - case let .peer(peerEntry, _): - if peerEntry.effectiveVideoEndpointId == channel.endpointId { - let presentationData = strongSelf.presentationData.withUpdated(theme: strongSelf.darkTheme) - strongSelf.fullscreenListNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [ListViewUpdateItem(index: i, previousIndex: i, item: entry.fullscreenItem(context: strongSelf.context, presentationData: presentationData, interaction: interaction), directionHint: nil)], options: [.Synchronous], updateOpaqueState: nil) - break loop - } - default: - break - } - } - } - } - } - } - } - }), forKey: channel.endpointId) - self.videoNodes[channel.endpointId] = videoNode - - if let _ = self.validLayout { - self.updateMembers() - } - } - - /*self.call.makeIncomingVideoView(endpointId: channel.endpointId, requestClone: GroupVideoNode.useBlurTransparency, completion: { [weak self] videoView, backdropVideoView in - Queue.mainQueue().async { - guard let strongSelf = self, let videoView = videoView else { - return - } - let videoNode = GroupVideoNode(videoView: videoView, backdropVideoView: backdropVideoView) - - strongSelf.readyVideoDisposables.set((combineLatest(videoNode.ready, .single(false) |> then(.single(true) |> delay(10.0, queue: Queue.mainQueue()))) - |> deliverOnMainQueue - ).start(next: { [weak self, weak videoNode] ready, timeouted in - if let strongSelf = self, let videoNode = videoNode { - Queue.mainQueue().after(0.1) { - if timeouted && !ready { - strongSelf.timeoutedEndpointIds.insert(channel.endpointId) - strongSelf.readyVideoEndpointIds.remove(channel.endpointId) - strongSelf.readyVideoEndpointIdsPromise.set(strongSelf.readyVideoEndpointIds) - strongSelf.wideVideoNodes.remove(channel.endpointId) - - strongSelf.updateMembers() - } else if ready { - strongSelf.readyVideoEndpointIds.insert(channel.endpointId) - strongSelf.readyVideoEndpointIdsPromise.set(strongSelf.readyVideoEndpointIds) - strongSelf.timeoutedEndpointIds.remove(channel.endpointId) - if videoNode.aspectRatio <= 0.77 { - strongSelf.wideVideoNodes.insert(channel.endpointId) - } else { - strongSelf.wideVideoNodes.remove(channel.endpointId) - } - strongSelf.updateMembers() - - if let (layout, _) = strongSelf.validLayout, case .compact = layout.metrics.widthClass { - if let interaction = strongSelf.itemInteraction { - loop: for i in 0 ..< strongSelf.currentFullscreenEntries.count { - let entry = strongSelf.currentFullscreenEntries[i] - switch entry { - case let .peer(peerEntry, _): - if peerEntry.effectiveVideoEndpointId == channel.endpointId { - let presentationData = strongSelf.presentationData.withUpdated(theme: strongSelf.darkTheme) - strongSelf.fullscreenListNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [ListViewUpdateItem(index: i, previousIndex: i, item: entry.fullscreenItem(context: strongSelf.context, presentationData: presentationData, interaction: interaction), directionHint: nil)], options: [.Synchronous], updateOpaqueState: nil) - break loop - } - default: - break - } - } - } - } - } - } - } - }), forKey: channel.endpointId) - strongSelf.videoNodes[channel.endpointId] = videoNode - - if let _ = strongSelf.validLayout { - strongSelf.updateMembers() - } - } - })*/ - } - } - - var removeRequestedVideoSources: [String] = [] - for source in self.requestedVideoSources { - if !validSources.contains(source) { - removeRequestedVideoSources.append(source) - } - } - for source in removeRequestedVideoSources { - self.requestedVideoSources.remove(source) - } - - for (videoEndpointId, _) in self.videoNodes { - if !validSources.contains(videoEndpointId) { - self.videoNodes[videoEndpointId] = nil - self.videoOrder.removeAll(where: { $0 == videoEndpointId }) - self.readyVideoEndpointIds.remove(videoEndpointId) - self.readyVideoEndpointIdsPromise.set(self.readyVideoEndpointIds) - self.readyVideoDisposables.set(nil, forKey: videoEndpointId) - } - } - } - - private func updateMainVideo(waitForFullSize: Bool, entries: [ListEntry]? = nil, updateMembers: Bool = true, force: Bool = false, completion: (() -> Void)? = nil) { - let effectiveMainSpeaker = self.currentForcedSpeaker ?? self.currentDominantSpeaker.flatMap { ($0.0, $0.1) } - guard effectiveMainSpeaker?.0 != self.effectiveSpeaker?.0 || effectiveMainSpeaker?.1 != self.effectiveSpeaker?.1 || force else { - return - } - - let currentEntries = entries ?? self.currentFullscreenEntries - var effectiveSpeaker: (PeerId, String?, Bool, Bool, Bool)? = nil - var anySpeakerWithVideo: (PeerId, String?, Bool, Bool, Bool)? = nil - var anySpeaker: (PeerId, Bool)? = nil - if let (peerId, preferredVideoEndpointId) = effectiveMainSpeaker { - for entry in currentEntries { - switch entry { - case let .peer(peer, _): - if peer.peer.id == peerId { - if let preferredVideoEndpointId = preferredVideoEndpointId, peer.videoEndpointId == preferredVideoEndpointId || peer.presentationEndpointId == preferredVideoEndpointId { - var isPaused = false - if peer.presentationEndpointId != nil && preferredVideoEndpointId == peer.presentationEndpointId { - isPaused = peer.presentationPaused - } else if peer.videoEndpointId != nil && preferredVideoEndpointId == peer.videoEndpointId { - isPaused = peer.videoPaused - } - effectiveSpeaker = (peerId, preferredVideoEndpointId, peer.isMyPeer, peer.presentationEndpointId != nil && preferredVideoEndpointId == peer.presentationEndpointId, isPaused) - } else { - var isPaused = false - if peer.effectiveVideoEndpointId != nil && peer.effectiveVideoEndpointId == peer.presentationEndpointId { - isPaused = peer.presentationPaused - } else if peer.effectiveVideoEndpointId != nil && peer.effectiveVideoEndpointId == peer.videoEndpointId { - isPaused = peer.videoPaused - } - effectiveSpeaker = (peerId, peer.effectiveVideoEndpointId, peer.isMyPeer, peer.presentationEndpointId != nil && peer.effectiveVideoEndpointId == peer.presentationEndpointId, isPaused) - } - } else if anySpeakerWithVideo == nil, let videoEndpointId = peer.effectiveVideoEndpointId { - var isPaused = false - if videoEndpointId == peer.presentationEndpointId { - isPaused = peer.presentationPaused - } else if videoEndpointId == peer.videoEndpointId { - isPaused = peer.videoPaused - } - anySpeakerWithVideo = (peer.peer.id, videoEndpointId, peer.isMyPeer, peer.presentationEndpointId != nil && videoEndpointId == peer.presentationEndpointId, isPaused) - } else if anySpeaker == nil { - anySpeaker = (peer.peer.id, peer.isMyPeer) - } - default: - break - } - } - } - - if effectiveSpeaker == nil { - self.currentForcedSpeaker = nil - effectiveSpeaker = anySpeakerWithVideo ?? anySpeaker.flatMap { ($0.0, nil, $0.1, false, false) } - if let (peerId, videoEndpointId, _, _, _) = effectiveSpeaker { - self.currentDominantSpeaker = (peerId, videoEndpointId, CACurrentMediaTime()) - } else { - self.currentDominantSpeaker = nil - } - } - - self.effectiveSpeaker = effectiveSpeaker - if updateMembers { - self.updateMembers(maybeUpdateVideo: false, force: force) - } - - var waitForFullSize = waitForFullSize - var isReady = false - if let (_, maybeVideoEndpointId, _, _, _) = effectiveSpeaker, let videoEndpointId = maybeVideoEndpointId { - isReady = true - if !self.readyVideoEndpointIds.contains(videoEndpointId) { - isReady = false - if entries == nil { - waitForFullSize = false - } - } - } - - self.mainStageNode.update(peer: effectiveSpeaker, isReady: isReady, waitForFullSize: waitForFullSize, completion: { - completion?() - }) - } - - private func updateRequestedVideoChannels() { - Queue.mainQueue().after(0.3) { - let enableVideo = self.appIsActive && self.visibility - - self.call.setRequestedVideoList(items: enableVideo ? self.requestedVideoChannels : []) - self.filterRequestedVideoChannels(channels: self.requestedVideoChannels) - } - } - - override func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if gestureRecognizer is UILongPressGestureRecognizer { - return !self.isScheduling - } else if gestureRecognizer is DirectionalPanGestureRecognizer { - if self.mainStageNode.animating || self.animatingMainStage { - return false - } - - let bottomPanelLocation = gestureRecognizer.location(in: self.bottomPanelNode.view) - let containerLocation = gestureRecognizer.location(in: self.contentContainer.view) - let mainStageLocation = gestureRecognizer.location(in: self.mainStageNode.view) - - if self.isLandscape && self.mainStageContainerNode.isUserInteractionEnabled && mainStageLocation.x > self.mainStageNode.frame.width - 80.0 { - return false - } - - if self.audioButton.frame.contains(bottomPanelLocation) || (!self.cameraButton.isHidden && self.cameraButton.frame.contains(bottomPanelLocation)) || self.leaveButton.frame.contains(bottomPanelLocation) || self.pickerView?.frame.contains(containerLocation) == true || (self.mainStageContainerNode.isUserInteractionEnabled && (mainStageLocation.y < 44.0 || mainStageLocation.y > self.mainStageNode.frame.height - 100.0)) { - return false - } - } - return true - } - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - if gestureRecognizer is UIPanGestureRecognizer && otherGestureRecognizer is UIPanGestureRecognizer { - return true - } - return false - } - - @objc func panGesture(_ recognizer: UIPanGestureRecognizer) { - guard let (layout, _) = self.validLayout else { - return - } - let contentOffset = self.listNode.visibleContentOffset() - switch recognizer.state { - case .began: - let topInset: CGFloat - if case .regular = layout.metrics.widthClass { - topInset = 0.0 - } else if self.isExpanded { - topInset = 0.0 - } else if let currentTopInset = self.topInset { - topInset = currentTopInset - } else { - topInset = self.listNode.frame.height - } - self.panGestureArguments = (topInset, 0.0) - - self.controller?.dismissAllTooltips() - - if case .fullscreen = self.displayMode, case .compact = layout.metrics.widthClass { - self.isPanning = true - - self.mainStageBackgroundNode.alpha = 0.0 - self.mainStageBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.4) - self.mainStageNode.setControlsHidden(true, animated: true) - - self.fullscreenListNode.alpha = 0.0 - self.fullscreenListNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, completion: { [weak self] finished in - self?.attachTileVideos() - - self?.fullscreenListContainer.subnodeTransform = CATransform3DIdentity - }) - - self.listContainer.transform = CATransform3DMakeScale(0.86, 0.86, 1.0) - - self.contentContainer.insertSubnode(self.mainStageContainerNode, aboveSubnode: self.bottomPanelNode) - } - case .changed: - var translation = recognizer.translation(in: self.contentContainer.view).y - if self.isScheduled && translation < 0.0 { - return - } - - let translateBounds: Bool - if case .regular = layout.metrics.widthClass { - translateBounds = true - } else { - switch self.displayMode { - case let .modal(isExpanded, previousIsFilled): - var topInset: CGFloat = 0.0 - if let (currentTopInset, currentPanOffset) = self.panGestureArguments { - topInset = currentTopInset - - if case let .known(value) = contentOffset, value <= 0.5 { - } else { - translation = currentPanOffset - if self.isExpanded { - recognizer.setTranslation(CGPoint(), in: self.contentContainer.view) - } - } - - self.panGestureArguments = (currentTopInset, translation) - } - - let currentOffset = topInset + translation - - var isFilled = previousIsFilled - if currentOffset < 20.0 { - isFilled = true - } else if currentOffset > 40.0 { - isFilled = false - } - if isFilled != previousIsFilled { - self.displayMode = .modal(isExpanded: isExpanded, isFilled: isFilled) - self.updateDecorationsColors() - } - - if self.isExpanded { - } else { - if currentOffset > 0.0 { - self.listNode.scroller.panGestureRecognizer.setTranslation(CGPoint(), in: self.listNode.scroller) - } - } - case .fullscreen: - if abs(translation) > 32.0 { - if self.fullscreenListNode.layer.animationKeys()?.contains("opacity") == true { - self.fullscreenListNode.layer.removeAllAnimations() - } - } - var bounds = self.mainStageContainerNode.bounds - bounds.origin.y = -translation - self.mainStageContainerNode.bounds = bounds - - var backgroundFrame = self.mainStageNode.frame - backgroundFrame.origin.y += -translation - self.mainStageBackgroundNode.frame = backgroundFrame - - self.fullscreenListContainer.subnodeTransform = CATransform3DMakeTranslation(0.0, translation, 0.0) - } - - translateBounds = !self.isExpanded - } - - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .immediate) - self.updateDecorationsLayout(transition: .immediate) - } - - if translateBounds { - var bounds = self.contentContainer.bounds - bounds.origin.y = -translation - bounds.origin.y = min(0.0, bounds.origin.y) - self.contentContainer.bounds = bounds - } - case .ended: - let translation = recognizer.translation(in: self.contentContainer.view) - var velocity = recognizer.velocity(in: self.contentContainer.view) - - if self.isScheduled && (translation.y < 0.0 || velocity.y < 0.0) { - return - } - - if case let .known(value) = contentOffset, value > 0.0 { - velocity = CGPoint() - } else if case .unknown = contentOffset { - velocity = CGPoint() - } - - var bounds = self.contentContainer.bounds - bounds.origin.y = -translation.y - bounds.origin.y = min(0.0, bounds.origin.y) - - let offset: CGFloat - if let (inset, panOffset) = self.panGestureArguments { - offset = inset + panOffset - } else { - offset = 0.0 - } - - let topInset: CGFloat - if let currentTopInset = self.topInset { - topInset = currentTopInset - } else { - topInset = self.listNode.frame.height - } - - if case .fullscreen = self.displayMode, case .compact = layout.metrics.widthClass { - self.panGestureArguments = nil - self.fullscreenListContainer.subnodeTransform = CATransform3DIdentity - if abs(translation.y) > 100.0 || abs(velocity.y) > 300.0 { - self.mainStageBackgroundNode.layer.removeAllAnimations() - self.currentForcedSpeaker = nil - self.updateDisplayMode(.modal(isExpanded: true, isFilled: true), fromPan: true) - self.effectiveSpeaker = nil - } else { - self.isPanning = false - self.mainStageBackgroundNode.alpha = 1.0 - self.mainStageBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, completion: { [weak self] _ in - self?.attachFullscreenVideos() - }) - self.mainStageNode.setControlsHidden(false, animated: true, delay: 0.15) - - self.fullscreenListNode.alpha = 1.0 - self.fullscreenListNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: 0.15) - - var bounds = self.mainStageContainerNode.bounds - let previousBounds = bounds - bounds.origin.y = 0.0 - self.mainStageContainerNode.bounds = bounds - self.mainStageContainerNode.layer.animateBounds(from: previousBounds, to: self.mainStageContainerNode.bounds, duration: 0.2, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak self] _ in - if let strongSelf = self { - strongSelf.listContainer.transform = CATransform3DIdentity - strongSelf.contentContainer.insertSubnode(strongSelf.mainStageContainerNode, belowSubnode: strongSelf.transitionContainerNode) - strongSelf.updateMembers() - } - }) - } - } else if case .modal(true, _) = self.displayMode, case .compact = layout.metrics.widthClass { - self.panGestureArguments = nil - if velocity.y > 300.0 || offset > topInset / 2.0 { - self.displayMode = .modal(isExpanded: false, isFilled: false) - self.updateDecorationsColors() - self.animatingExpansion = true - self.listNode.scroller.setContentOffset(CGPoint(), animated: false) - - let distance: CGFloat - if let topInset = self.topInset { - distance = topInset - offset - } else { - distance = 0.0 - } - let initialVelocity: CGFloat = distance.isZero ? 0.0 : abs(velocity.y / distance) - let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) - } - self.updateDecorationsLayout(transition: transition, completion: { - self.animatingExpansion = false - }) - } else { - self.displayMode = .modal(isExpanded: true, isFilled: true) - self.updateDecorationsColors() - self.animatingExpansion = true - - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) - } - self.updateDecorationsLayout(transition: .animated(duration: 0.3, curve: .easeInOut), completion: { - self.animatingExpansion = false - }) - } - } else { - self.panGestureArguments = nil - var dismissing = false - if bounds.minY < -60 || (bounds.minY < 0.0 && velocity.y > 300.0) { - if self.isScheduling { - self.dismissScheduled() - dismissing = true - } else if case .regular = layout.metrics.widthClass { - self.controller?.dismiss(closing: false, manual: true) - dismissing = true - } else { - if case .fullscreen = self.displayMode { - } else { - self.controller?.dismiss(closing: false, manual: true) - dismissing = true - } - } - } else if !self.isScheduling && (velocity.y < -300.0 || offset < topInset / 2.0) { - if velocity.y > -2200.0 && !self.isFullscreen { - DispatchQueue.main.async { - self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) - } - } - - let initialVelocity: CGFloat = offset.isZero ? 0.0 : abs(velocity.y / offset) - let transition = ContainedViewLayoutTransition.animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) - if case .modal = self.displayMode { - self.displayMode = .modal(isExpanded: true, isFilled: true) - } - self.updateDecorationsColors() - self.animatingExpansion = true - - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) - } - self.updateDecorationsLayout(transition: transition, completion: { - self.animatingExpansion = false - }) - } else if !self.isScheduling { - self.updateDecorationsColors() - self.animatingExpansion = true - self.listNode.scroller.setContentOffset(CGPoint(), animated: false) - - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) - } - self.updateDecorationsLayout(transition: .animated(duration: 0.3, curve: .easeInOut), completion: { - self.animatingExpansion = false - }) - } - if !dismissing { - var bounds = self.contentContainer.bounds - let previousBounds = bounds - bounds.origin.y = 0.0 - self.contentContainer.bounds = bounds - self.contentContainer.layer.animateBounds(from: previousBounds, to: self.contentContainer.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) - } - } - case .cancelled: - self.panGestureArguments = nil - - let previousBounds = self.contentContainer.bounds - var bounds = self.contentContainer.bounds - bounds.origin.y = 0.0 - self.contentContainer.bounds = bounds - self.contentContainer.layer.animateBounds(from: previousBounds, to: self.contentContainer.bounds, duration: 0.3, timingFunction: CAMediaTimingFunctionName.easeInEaseOut.rawValue) - - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: .animated(duration: 0.3, curve: .easeInOut)) - } - self.updateDecorationsLayout(transition: .animated(duration: 0.3, curve: .easeInOut), completion: { - self.animatingExpansion = false - }) - - if case .fullscreen = self.displayMode, case .regular = layout.metrics.widthClass { - self.fullscreenListContainer.subnodeTransform = CATransform3DIdentity - self.isPanning = false - self.mainStageBackgroundNode.alpha = 1.0 - self.mainStageBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15, completion: { [weak self] _ in - self?.attachFullscreenVideos() - }) - self.mainStageNode.setControlsHidden(false, animated: true, delay: 0.15) - - self.fullscreenListNode.alpha = 1.0 - self.fullscreenListNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, delay: 0.15) - - var bounds = self.mainStageContainerNode.bounds - let previousBounds = bounds - bounds.origin.y = 0.0 - self.mainStageContainerNode.bounds = bounds - self.mainStageContainerNode.layer.animateBounds(from: previousBounds, to: self.mainStageContainerNode.bounds, duration: 0.2, timingFunction: kCAMediaTimingFunctionSpring, completion: { [weak self] _ in - if let strongSelf = self { - strongSelf.contentContainer.insertSubnode(strongSelf.mainStageContainerNode, belowSubnode: strongSelf.transitionContainerNode) - strongSelf.updateMembers() - - strongSelf.listContainer.transform = CATransform3DIdentity - } - }) - } - default: - break - } - } - - override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { - let result = super.hitTest(point, with: event) - if result === self.topPanelNode.view { - return self.view - } - if result === self.bottomPanelNode.view { - return self.view - } - if !self.bounds.contains(point) { - return nil - } - if point.y < self.topPanelNode.frame.minY { - return self.dimNode.view - } - return result - } - - fileprivate func scrollToTop() { - if self.isExpanded { - self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: 0, position: .top(0.0), animated: true, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in }) - } - } - - private func openTitleEditing() { - let _ = (self.context.account.postbox.loadedPeerWithId(self.call.peerId) - |> deliverOnMainQueue).start(next: { [weak self] chatPeer in - guard let strongSelf = self else { - return - } - - let initialTitle = strongSelf.callState?.title ?? "" - - let title: String - let text: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - title = strongSelf.presentationData.strings.LiveStream_EditTitle - text = strongSelf.presentationData.strings.LiveStream_EditTitleText - } else { - title = strongSelf.presentationData.strings.VoiceChat_EditTitle - text = strongSelf.presentationData.strings.VoiceChat_EditTitleText - } - - let controller = voiceChatTitleEditController(sharedContext: strongSelf.context.sharedContext, account: strongSelf.context.account, forceTheme: strongSelf.darkTheme, title: title, text: text, placeholder: EnginePeer(chatPeer).displayTitle(strings: strongSelf.presentationData.strings, displayOrder: strongSelf.presentationData.nameDisplayOrder), value: initialTitle, maxLength: 40, apply: { title in - if let strongSelf = self, let title = title, title != initialTitle { - strongSelf.call.updateTitle(title) - - let text: String - if let channel = strongSelf.peer as? TelegramChannel, case .broadcast = channel.info { - text = title.isEmpty ? strongSelf.presentationData.strings.LiveStream_EditTitleRemoveSuccess : strongSelf.presentationData.strings.LiveStream_EditTitleSuccess(title).string - } else { - text = title.isEmpty ? strongSelf.presentationData.strings.VoiceChat_EditTitleRemoveSuccess : strongSelf.presentationData.strings.VoiceChat_EditTitleSuccess(title).string - } - - strongSelf.presentUndoOverlay(content: .voiceChatFlag(text: text), action: { _ in return false }) - } - }) - strongSelf.controller?.present(controller, in: .window(.root)) - }) - } - - private func openAvatarForEditing(fromGallery: Bool = false, completion: @escaping () -> Void = {}) { - guard let peerId = self.callState?.myPeerId else { - return - } - - let _ = (self.context.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 strongSelf = self, let peer = peer else { - return - } - - let presentationData = strongSelf.presentationData - - let legacyController = LegacyController(presentation: .custom, theme: strongSelf.darkTheme) - 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) - - strongSelf.view.endEditing(true) - strongSelf.controller?.present(legacyController, in: .window(.root)) - - var hasPhotos = false - if !peer.profileImageRepresentations.isEmpty { - hasPhotos = true - } - - let paintStickersContext = LegacyPaintStickersContext(context: strongSelf.context) -// paintStickersContext.presentStickersController = { completion in -// let controller = DrawingStickersScreen(context: strongSelf.context, selectSticker: { fileReference, node, rect in -// let coder = PostboxEncoder() -// coder.encodeRootObject(fileReference.media) -// completion?(coder.makeData(), fileReference.media.isAnimatedSticker, node.view, rect) -// return true -// }) -// strongSelf.controller?.present(controller, in: .window(.root)) -// return controller -// } - - 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)! - mixin.forceDark = true - mixin.stickersContext = paintStickersContext - let _ = strongSelf.currentAvatarMixin.swap(mixin) - mixin.requestSearchController = { [weak self] assetsController in - guard let strongSelf = self else { - return - } - let controller = WebSearchController(context: strongSelf.context, peer: peer, chatLocation: nil, configuration: searchBotsConfiguration, mode: .avatar(initialQuery: peer.id.namespace == Namespaces.Peer.CloudUser ? nil : peer.displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), completion: { [weak self] result in - assetsController?.dismiss() - self?.updateProfilePhoto(result) - })) - controller.navigationPresentation = .modal - strongSelf.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 = { - guard let strongSelf = self else { - return - } - - let proceed = { - let _ = strongSelf.currentAvatarMixin.swap(nil) - let postbox = strongSelf.context.account.postbox - strongSelf.updateAvatarDisposable.set((strongSelf.context.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.withUpdated(theme: strongSelf.darkTheme)) - let items: [ActionSheetItem] = [ - ActionSheetButtonItem(title: presentationData.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() - }) - ]) - ]) - strongSelf.controller?.present(actionSheet, in: .window(.root)) - } - mixin.didDismiss = { [weak legacyController] in - guard let strongSelf = self else { - return - } - let _ = strongSelf.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 data = image.jpegData(compressionQuality: 0.6), let peerId = self.callState?.myPeerId else { - return - } - - let resource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) - self.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) - - self.currentUpdatingAvatar = representation - self.updateAvatarPromise.set(.single((representation, 0.0))) - - let postbox = self.call.account.postbox - let signal = peerId.namespace == Namespaces.Peer.CloudUser ? self.call.accountContext.engine.accountData.updateAccountPhoto(resource: resource, videoResource: nil, videoStartTimestamp: nil, mapResourceToAvatarSizes: { resource, representations in - return mapResourceToAvatarSizes(postbox: postbox, resource: resource, representations: representations) - }) : self.call.accountContext.engine.peers.updatePeerPhoto(peerId: peerId, photo: self.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 strongSelf = self else { - return - } - switch result { - case .complete: - strongSelf.updateAvatarPromise.set(.single(nil)) - case let .progress(value): - strongSelf.updateAvatarPromise.set(.single((representation, value))) - } - })) - - self.updateMembers() - } - - private func updateProfileVideo(_ image: UIImage, asset: Any?, adjustments: TGVideoEditAdjustments?) { - guard let data = image.jpegData(compressionQuality: 0.6), let peerId = self.callState?.myPeerId else { - return - } - - let photoResource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max)) - self.context.account.postbox.mediaBox.storeResourceData(photoResource.id, data: data) - let representation = TelegramMediaImageRepresentation(dimensions: PixelDimensions(width: 640, height: 640), resource: photoResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false) - - self.currentUpdatingAvatar = representation - self.updateAvatarPromise.set(.single((representation, 0.0))) - - var videoStartTimestamp: Double? = nil - if let adjustments = adjustments, adjustments.videoStartValue > 0.0 { - videoStartTimestamp = adjustments.videoStartValue - adjustments.trimStartValue - } - - let context = self.context - let account = self.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(account: account, adjustments: adjustments) - } else { - return nil - } - } - let uploadInterface = LegacyLiveUploadInterface(context: context) - let signal: SSignal - if let asset = asset as? AVAsset { - signal = TGMediaVideoConverter.convert(asset, adjustments: adjustments, watcher: uploadInterface, entityRenderer: entityRenderer)! - } else if let url = asset as? URL, 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, watcher: nil, entityRenderer: entityRenderer)! - } else { - return SSignal.single(nil) - } - }) - - } 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) - } - } - subscriber.putCompletion() - } else if let strongSelf = self, let progress = next as? NSNumber { - Queue.mainQueue().async { - strongSelf.updateAvatarPromise.set(.single((representation, Float(truncating: progress) * 0.25))) - } - } - }, 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, 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 strongSelf = self else { - return - } - switch result { - case .complete: - strongSelf.updateAvatarPromise.set(.single(nil)) - case let .progress(value): - strongSelf.updateAvatarPromise.set(.single((representation, 0.25 + value * 0.75))) - } - })) - } - - private func displayUnmuteTooltip() { - guard let (layout, _) = self.validLayout else { - return - } - let location = self.actionButton.view.convert(self.actionButton.bounds, to: self.view).center - var point = CGRect(origin: CGPoint(x: location.x - 5.0, y: location.y - 5.0 - 68.0), size: CGSize(width: 10.0, height: 10.0)) - var position: TooltipScreen.ArrowPosition = .bottom - if case .compact = layout.metrics.widthClass { - if self.isLandscape { - point.origin.x = location.x - 5.0 - 36.0 - point.origin.y = location.y - 5.0 - position = .right - } else if case .fullscreen = self.displayMode { - point.origin.y += 32.0 - } - } - self.controller?.present(TooltipScreen(account: self.context.account, text: self.presentationData.strings.VoiceChat_UnmuteSuggestion, style: .gradient(UIColor(rgb: 0x1d446c), UIColor(rgb: 0x193e63)), icon: nil, location: .point(point, position), displayDuration: .custom(8.0), shouldDismissOnTouch: { _ in - return .dismiss(consume: false) - }), in: .window(.root)) - } - - private var isScheduled: Bool { - return self.isScheduling || self.callState?.scheduleTimestamp != nil - } - - private func attachFullscreenVideos() { - guard let (layout, _) = self.validLayout, case .compact = layout.metrics.widthClass else { - return - } - var verticalItemNodes: [String: ASDisplayNode] = [:] - self.listNode.forEachItemNode { itemNode in - if let itemNode = itemNode as? VoiceChatTilesGridItemNode { - for tileNode in itemNode.tileNodes { - if let item = tileNode.item { - verticalItemNodes[String(item.peer.id.toInt64()) + "_" + item.videoEndpointId] = tileNode - } - - if tileNode.item?.peer.id == self.effectiveSpeaker?.0 && tileNode.item?.videoEndpointId == self.effectiveSpeaker?.1 { - tileNode.isHidden = false - } - } - } - } - - self.fullscreenListNode.forEachItemNode { itemNode in - if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode, let item = itemNode.item { - let otherItemNode = verticalItemNodes[String(item.peer.id.toInt64()) + "_" + (item.videoEndpointId ?? "")] - itemNode.transitionIn(from: otherItemNode) - } - } - } - - private func attachTileVideos() { - var fullscreenItemNodes: [String: VoiceChatFullscreenParticipantItemNode] = [:] - var tileNodes: [VoiceChatTileItemNode] = [] - if !self.tileGridNode.isHidden { - tileNodes = self.tileGridNode.tileNodes - } else { - self.fullscreenListNode.forEachItemNode { itemNode in - if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode, let item = itemNode.item { - fullscreenItemNodes[String(item.peer.id.toInt64()) + "_" + (item.videoEndpointId ?? "")] = itemNode - } - } - self.listNode.forEachItemNode { itemNode in - if let itemNode = itemNode as? VoiceChatTilesGridItemNode { - tileNodes = itemNode.tileNodes - } - } - } - - for tileNode in tileNodes { - if let item = tileNode.item { - let otherItemNode = fullscreenItemNodes[String(item.peer.id.toInt64()) + "_" + item.videoEndpointId] - tileNode.transitionIn(from: otherItemNode) - - if tileNode.item?.peer.id == self.effectiveSpeaker?.0 && tileNode.item?.videoEndpointId == self.effectiveSpeaker?.1 { - tileNode.isHidden = true - } - } - } - } - - private func updateDisplayMode(_ displayMode: DisplayMode, fromPan: Bool = false) { - guard !self.animatingExpansion && !self.animatingMainStage && !self.mainStageNode.animating else { - return - } - self.updateMembers() - - let previousDisplayMode = self.displayMode - var isFullscreen = false - if case .fullscreen = displayMode { - isFullscreen = true - } - - if case .fullscreen = previousDisplayMode, case .fullscreen = displayMode { - self.animatingExpansion = true - } else { - self.animatingMainStage = true - } - - var hasFullscreenList = false - if let (layout, _) = self.validLayout, case .compact = layout.metrics.widthClass { - hasFullscreenList = true - } - - let completion = { - self.displayMode = displayMode - self.updateDecorationsColors() - - self.mainStageContainerNode.isHidden = false - self.mainStageContainerNode.isUserInteractionEnabled = isFullscreen - - let transition: ContainedViewLayoutTransition = .animated(duration: 0.55, curve: .spring) - if case .modal = previousDisplayMode, case .fullscreen = self.displayMode { - self.mainStageNode.alpha = 0.0 - self.updateDecorationsLayout(transition: .immediate) - - var verticalItemNodes: [String: ASDisplayNode] = [:] - - var tileNodes: [VoiceChatTileItemNode] = [] - if !self.tileGridNode.isHidden { - tileNodes = self.tileGridNode.tileNodes - } else { - self.listNode.forEachItemNode { itemNode in - if let itemNode = itemNode as? VoiceChatTilesGridItemNode { - tileNodes = itemNode.tileNodes - } - } - } - for tileNode in tileNodes { - if let item = tileNode.item { - verticalItemNodes[String(item.peer.id.toInt64()) + "_" + item.videoEndpointId] = tileNode - } - } - - let completion = { - let effectiveSpeakerPeerId = self.effectiveSpeaker?.0 - - if hasFullscreenList { - self.fullscreenListContainer.isHidden = false - self.fullscreenListNode.alpha = 0.0 - } - - var gridSnapshotView: UIView? - if !hasFullscreenList, let snapshotView = self.tileGridNode.view.snapshotView(afterScreenUpdates: false) { - gridSnapshotView = snapshotView - self.tileGridNode.view.addSubview(snapshotView) - self.displayPanelVideos = true - self.updateMembers(maybeUpdateVideo: false, force: true) - } - - let completion = { - if hasFullscreenList { - self.attachFullscreenVideos() - - self.fullscreenListNode.alpha = 1.0 - self.fullscreenListNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.15) - } - } - if let effectiveSpeakerPeerId = effectiveSpeakerPeerId, let otherItemNode = verticalItemNodes[String(effectiveSpeakerPeerId.toInt64()) + "_" + (self.effectiveSpeaker?.1 ?? "")] { - - if hasFullscreenList { - let transitionStartPosition = otherItemNode.view.convert(CGPoint(x: otherItemNode.frame.width / 2.0, y: otherItemNode.frame.height), to: self.fullscreenListContainer.view.superview) - self.fullscreenListContainer.layer.animatePosition(from: transitionStartPosition, to: self.fullscreenListContainer.position, duration: 0.55, timingFunction: kCAMediaTimingFunctionSpring) - } - - self.mainStageNode.animateTransitionIn(from: otherItemNode, transition: transition, completion: { [weak self] in - self?.animatingMainStage = false - }) - self.mainStageNode.alpha = 1.0 - - self.mainStageBackgroundNode.alpha = 1.0 - self.mainStageBackgroundNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: hasFullscreenList ? 0.13 : 0.3, completion: { [weak otherItemNode] _ in - otherItemNode?.alpha = 0.0 - gridSnapshotView?.removeFromSuperview() - completion() - }) - } else { - completion() - } - - if hasFullscreenList { - self.listContainer.layer.animateScale(from: 1.0, to: 0.86, duration: 0.55, timingFunction: kCAMediaTimingFunctionSpring) - } - - if self.isLandscape { - self.transitionMaskTopFillLayer.opacity = 1.0 - } - self.transitionMaskBottomFillLayer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3, removeOnCompletion: false, completion: { [weak self] _ in - Queue.mainQueue().after(0.3) { - self?.transitionMaskTopFillLayer.opacity = 0.0 - self?.transitionMaskBottomFillLayer.removeAllAnimations() - } - }) - - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) - self.updateDecorationsLayout(transition: transition) - } - } - let effectiveSpeakerPeerId = self.effectiveSpeaker?.0 - var index = 0 - for item in self.currentFullscreenEntries { - if case let .peer(entry, _) = item, entry.peer.id == effectiveSpeakerPeerId { - break - } else { - index += 1 - } - } - let position: ListViewScrollPosition - if index > self.currentFullscreenEntries.count - 3 { - index = self.currentFullscreenEntries.count - 1 - position = .bottom(0.0) - } else { - position = .center(.bottom) - } - self.fullscreenListNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: index, position: position, animated: false, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in - completion() - }) - } else if case .fullscreen = previousDisplayMode, case .modal = self.displayMode { - var minimalVisiblePeerid: (PeerId, CGFloat)? - var fullscreenItemNodes: [String: VoiceChatFullscreenParticipantItemNode] = [:] - self.fullscreenListNode.forEachItemNode { itemNode in - if let itemNode = itemNode as? VoiceChatFullscreenParticipantItemNode, let item = itemNode.item { - let convertedFrame = itemNode.view.convert(itemNode.bounds, to: self.transitionContainerNode.view) - if let (_, x) = minimalVisiblePeerid { - if convertedFrame.minX >= 0.0 && convertedFrame.minX < x { - minimalVisiblePeerid = (item.peer.id, convertedFrame.minX) - } - } else if convertedFrame.minX >= 0.0 { - minimalVisiblePeerid = (item.peer.id, convertedFrame.minX) - } - fullscreenItemNodes[String(item.peer.id.toInt64()) + "_" + (item.videoEndpointId ?? "")] = itemNode - } - } - - let completion = { - let effectiveSpeakerPeerId = self.effectiveSpeaker?.0 - var targetTileNode: VoiceChatTileItemNode? - - self.transitionContainerNode.addSubnode(self.mainStageNode) - - self.listContainer.transform = CATransform3DIdentity - - var tileNodes: [VoiceChatTileItemNode] = [] - if !self.tileGridNode.isHidden { - tileNodes = self.tileGridNode.tileNodes - } else { - self.listNode.forEachItemNode { itemNode in - if let itemNode = itemNode as? VoiceChatTilesGridItemNode { - tileNodes = itemNode.tileNodes - } - } - } - for tileNode in tileNodes { - if let item = tileNode.item { - if item.peer.id == effectiveSpeakerPeerId, item.videoEndpointId == self.effectiveSpeaker?.1 { - targetTileNode = tileNode - } - } - } - - var transitionOffset = -self.mainStageContainerNode.bounds.minY - if transitionOffset.isZero, let (layout, _) = self.validLayout { - if case .regular = layout.metrics.widthClass { - transitionOffset += 87.0 - } - if let targetTileNode = targetTileNode { - let transitionTargetPosition = targetTileNode.view.convert(CGPoint(x: targetTileNode.frame.width / 2.0, y: targetTileNode.frame.height), to: self.fullscreenListContainer.view.superview) - self.fullscreenListContainer.layer.animatePosition(from: self.fullscreenListContainer.position, to: transitionTargetPosition, duration: 0.55, timingFunction: kCAMediaTimingFunctionSpring) - } - - if !hasFullscreenList { - self.displayPanelVideos = false - self.listNode.forEachItemNode { itemNode in - if let itemNode = itemNode as? VoiceChatTilesGridItemNode { - itemNode.snapshotForDismissal() - } - } - self.updateMembers(maybeUpdateVideo: false, force: true) - self.attachTileVideos() - - self.mainStageBackgroundNode.alpha = 0.0 - self.mainStageBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25) - } else { - self.fullscreenListNode.alpha = 0.0 - self.mainStageBackgroundNode.alpha = 1.0 - self.fullscreenListNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.1, completion: { [weak self] _ in - if let strongSelf = self { - strongSelf.fullscreenListContainer.isHidden = true - strongSelf.fullscreenListNode.alpha = 1.0 - strongSelf.attachTileVideos() - - strongSelf.mainStageBackgroundNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25) - strongSelf.mainStageBackgroundNode.alpha = 0.0 - } - }) - } - } - self.mainStageNode.animateTransitionOut(to: targetTileNode, offset: transitionOffset, transition: transition, completion: { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.effectiveSpeaker = nil - strongSelf.mainStageNode.update(peer: nil, waitForFullSize: false) - strongSelf.mainStageNode.setControlsHidden(false, animated: false) - strongSelf.fullscreenListContainer.isHidden = true - strongSelf.mainStageContainerNode.isHidden = true - strongSelf.mainStageContainerNode.addSubnode(strongSelf.mainStageNode) - - var bounds = strongSelf.mainStageContainerNode.bounds - bounds.origin.y = 0.0 - strongSelf.mainStageContainerNode.bounds = bounds - - strongSelf.contentContainer.insertSubnode(strongSelf.mainStageContainerNode, belowSubnode: strongSelf.transitionContainerNode) - - strongSelf.isPanning = false - strongSelf.animatingMainStage = false - }) - - if hasFullscreenList { - self.listContainer.layer.animateScale(from: 0.86, to: 1.0, duration: 0.55, timingFunction: kCAMediaTimingFunctionSpring) - } - - self.transitionMaskTopFillLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3) - if !transitionOffset.isZero { - self.transitionMaskBottomFillLayer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3) - } - - if let (layout, navigationHeight) = self.validLayout { - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) - self.updateDecorationsLayout(transition: transition) - } - } - if false, let (peerId, _) = minimalVisiblePeerid { - var index = 0 - for item in self.currentEntries { - if case let .peer(entry, _) = item, entry.peer.id == peerId { - break - } else { - index += 1 - } - } - self.listNode.transaction(deleteIndices: [], insertIndicesAndItems: [], updateIndicesAndItems: [], options: [.Synchronous, .LowLatency], scrollToItem: ListViewScrollToItem(index: index, position: .top(0.0), animated: false, curve: .Default(duration: nil), directionHint: .Up), updateSizeAndInsets: nil, stationaryItemRange: nil, updateOpaqueState: nil, completion: { _ in - completion() - }) - } else { - completion() - } - } else if case .fullscreen = self.displayMode { - if let (layout, navigationHeight) = self.validLayout { - let transition: ContainedViewLayoutTransition = .animated(duration: 0.4, curve: .spring) - self.containerLayoutUpdated(layout, navigationHeight: navigationHeight, transition: transition) - self.updateDecorationsLayout(transition: transition) - } - } - } - - if case .fullscreen(false) = displayMode, case .modal = previousDisplayMode { - self.updateMainVideo(waitForFullSize: true, updateMembers: true, force: true, completion: { - completion() - }) - } else { - completion() - } - } - - fileprivate var actionButtonPosition: CGPoint { - guard let (layout, _) = self.validLayout else { - return CGPoint() - } - let size = layout.size - let hasCameraButton = self.cameraButton.isUserInteractionEnabled - let centralButtonSide = min(size.width, size.height) - 32.0 - let centralButtonSize = CGSize(width: centralButtonSide, height: centralButtonSide) - - if case .regular = layout.metrics.widthClass { - let contentWidth: CGFloat = max(320.0, min(375.0, floor(size.width * 0.3))) - let contentLeftInset: CGFloat - if self.peerIdToEndpointId.isEmpty { - contentLeftInset = floorToScreenPixels((layout.size.width - contentWidth) / 2.0) - } else { - contentLeftInset = self.panelHidden ? layout.size.width : layout.size.width - contentWidth - } - return CGPoint(x: contentLeftInset + floorToScreenPixels(contentWidth / 2.0), y: layout.size.height - self.effectiveBottomAreaHeight - layout.intrinsicInsets.bottom + floor(self.effectiveBottomAreaHeight / 2.0) - 3.0) - } else { - switch self.displayMode { - case .modal: - if self.isLandscape { - let sideInset: CGFloat - let buttonsCount: Int - if hasCameraButton { - sideInset = 26.0 - buttonsCount = 4 - } else { - sideInset = 42.0 - buttonsCount = 3 - } - let spacing = floor((layout.size.height - sideInset * 2.0 - sideButtonSize.height * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) - let x = layout.size.width - fullscreenBottomAreaHeight - layout.safeInsets.right + floor((fullscreenBottomAreaHeight - sideButtonSize.width) / 2.0) - let actionButtonFrame = CGRect(origin: CGPoint(x: x, y: sideInset + sideButtonSize.height + spacing), size: sideButtonSize) - return actionButtonFrame.center - } else { - let actionButtonFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((size.width - centralButtonSize.width) / 2.0), y: layout.size.height - self.effectiveBottomAreaHeight - layout.intrinsicInsets.bottom + floor((self.effectiveBottomAreaHeight - centralButtonSize.height) / 2.0) - 3.0), size: centralButtonSize) - return actionButtonFrame.center - } - case let .fullscreen(controlsHidden): - if self.isLandscape { - let sideInset: CGFloat - let buttonsCount: Int - if hasCameraButton { - sideInset = 26.0 - buttonsCount = 4 - } else { - sideInset = 42.0 - buttonsCount = 3 - } - let spacing = floor((layout.size.height - sideInset * 2.0 - sideButtonSize.height * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) - let x = layout.size.width - fullscreenBottomAreaHeight - layout.safeInsets.right + (controlsHidden ? fullscreenBottomAreaHeight + layout.safeInsets.right + 30.0 : floor((fullscreenBottomAreaHeight - sideButtonSize.width) / 2.0)) - let actionButtonFrame = CGRect(origin: CGPoint(x: x, y: sideInset + sideButtonSize.height + spacing), size: sideButtonSize) - return actionButtonFrame.center - } else { - let sideInset: CGFloat - let buttonsCount: Int - if hasCameraButton { - sideInset = 26.0 - buttonsCount = 4 - } else { - sideInset = 42.0 - buttonsCount = 3 - } - let spacing = floor((layout.size.width - sideInset * 2.0 - sideButtonSize.width * CGFloat(buttonsCount)) / (CGFloat(buttonsCount - 1))) - let y = layout.size.height - self.effectiveBottomAreaHeight - layout.intrinsicInsets.bottom + (controlsHidden ? self.effectiveBottomAreaHeight + layout.intrinsicInsets.bottom + 30.0: floor((self.effectiveBottomAreaHeight - sideButtonSize.height) / 2.0)) - let secondButtonFrame: CGRect - if hasCameraButton { - let firstButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: y), size: sideButtonSize) - secondButtonFrame = CGRect(origin: CGPoint(x: firstButtonFrame.maxX + spacing, y: y), size: sideButtonSize) - } else { - secondButtonFrame = CGRect(origin: CGPoint(x: sideInset, y: y), size: sideButtonSize) - } - let actionButtonFrame = CGRect(origin: CGPoint(x: secondButtonFrame.maxX + spacing, y: y), size: sideButtonSize) - return actionButtonFrame.center - } - } - } - } - } - - private let sharedContext: SharedAccountContext - public let call: PresentationGroupCall - private let presentationData: PresentationData - public var parentNavigationController: NavigationController? - - fileprivate let contentsReady = ValuePromise(false, ignoreRepeated: true) - fileprivate let dataReady = ValuePromise(false, ignoreRepeated: true) - fileprivate let audioOutputStateReady = ValuePromise(false, ignoreRepeated: true) - private let _ready = Promise(false) - override public var ready: Promise { - return self._ready - } - - public var onViewDidAppear: (() -> Void)? - public var onViewDidDisappear: (() -> Void)? - private var reclaimActionButton: (() -> Void)? - - private var didAppearOnce: Bool = false - private var isDismissed: Bool = false - private var isDisconnected: Bool = false - - private var controllerNode: Node { - return self.displayNode as! Node - } - - private let idleTimerExtensionDisposable = MetaDisposable() - - public weak var currentOverlayController: VoiceChatOverlayController? - - private var validLayout: ContainerViewLayout? - - public init(sharedContext: SharedAccountContext, accountContext: AccountContext, call: PresentationGroupCall) { - self.sharedContext = sharedContext - self.call = call - self.presentationData = sharedContext.currentPresentationData.with { $0 } - - super.init(navigationBarPresentationData: nil) - - self.automaticallyControlPresentationContextLayout = false - self.blocksBackgroundWhenInOverlay = true - - self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .all) - - self.statusBar.statusBarStyle = .Ignore - - self._ready.set(combineLatest([ - self.contentsReady.get(), - self.dataReady.get(), - self.audioOutputStateReady.get() - ]) - |> map { values -> Bool in - for value in values { - if !value { - return false - } - } - return true - } - |> filter { $0 }) - - self.scrollToTop = { [weak self] in - self?.controllerNode.scrollToTop() - } - } - - required init(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - self.idleTimerExtensionDisposable.dispose() - - if let currentOverlayController = self.currentOverlayController { - currentOverlayController.animateOut(reclaim: false, targetPosition: CGPoint(), completion: { _ in }) - } - } - - override public func loadDisplayNode() { - self.displayNode = Node(controller: self, sharedContext: self.sharedContext, call: self.call) - - self.displayNodeDidLoad() - } - - override public func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - self.isDismissed = false - - if !self.didAppearOnce { - self.didAppearOnce = true - - self.reclaimActionButton?() - self.controllerNode.animateIn() - - self.idleTimerExtensionDisposable.set(self.sharedContext.applicationBindings.pushIdleTimerExtension()) - } - - DispatchQueue.main.async { - self.onViewDidAppear?() - } - } - - override public func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - self.idleTimerExtensionDisposable.set(nil) - - DispatchQueue.main.async { - self.didAppearOnce = false - self.isDismissed = true - self.detachActionButton() - self.onViewDidDisappear?() - } - } - - private var dismissedManually: Bool = false - public func dismiss(closing: Bool, manual: Bool = false) { - if closing { - self.isDisconnected = true - } else { - if let navigationController = self.navigationController as? NavigationController { - let count = navigationController.viewControllers.count - if count == 2 || navigationController.viewControllers[count - 2] is ChatController { - if case .active(.cantSpeak) = self.controllerNode.actionButton.stateValue { - } else if case .button = self.controllerNode.actionButton.stateValue { - } else if case .scheduled = self.controllerNode.actionButton.stateValue { - } else if let chatController = navigationController.viewControllers[count - 2] as? ChatController, chatController.isSendButtonVisible { - } else if let tabBarController = navigationController.viewControllers[count - 2] as? TabBarController, let chatListController = tabBarController.controllers[tabBarController.selectedIndex] as? ChatListController, chatListController.isSearchActive { - } else { - if manual { - self.dismissedManually = true - Queue.mainQueue().after(0.05) { - self.detachActionButton() - } - } else { - self.detachActionButton() - } - } - } - } - } - - self.dismiss() - } - - private func dismissAllTooltips() { - self.window?.forEachController({ controller in - if let controller = controller as? UndoOverlayController { - controller.dismissWithCommitAction() - } - }) - self.forEachController({ controller in - if let controller = controller as? UndoOverlayController { - controller.dismissWithCommitAction() - } - if let controller = controller as? TooltipScreen { - controller.dismiss() - } - return true - }) - } - - private func detachActionButton() { - guard self.currentOverlayController == nil && !self.isDisconnected else { - return - } - - let overlayController = VoiceChatOverlayController(actionButton: self.controllerNode.actionButton, audioOutputNode: self.controllerNode.audioButton, cameraNode: self.controllerNode.cameraButton, leaveNode: self.controllerNode.leaveButton, navigationController: self.navigationController as? NavigationController, initiallyHidden: self.dismissedManually) - if let navigationController = self.navigationController as? NavigationController { - navigationController.presentOverlay(controller: overlayController, inGlobal: true, blockInteraction: false) - } - - self.currentOverlayController = overlayController - self.dismissedManually = false - - self.reclaimActionButton = { [weak self, weak overlayController] in - if let strongSelf = self { - overlayController?.animateOut(reclaim: true, targetPosition: strongSelf.controllerNode.actionButtonPosition, completion: { immediate in - if let strongSelf = self, immediate { - strongSelf.controllerNode.actionButton.ignoreHierarchyChanges = true - strongSelf.controllerNode.bottomPanelNode.addSubnode(strongSelf.controllerNode.cameraButton) - strongSelf.controllerNode.bottomPanelNode.addSubnode(strongSelf.controllerNode.audioButton) - strongSelf.controllerNode.bottomPanelNode.addSubnode(strongSelf.controllerNode.leaveButton) - strongSelf.controllerNode.bottomPanelNode.addSubnode(strongSelf.controllerNode.actionButton) - - if immediate, let layout = strongSelf.validLayout { - strongSelf.containerLayoutUpdated(layout, transition: .immediate) - } - strongSelf.controllerNode.actionButton.ignoreHierarchyChanges = false - } - }) - strongSelf.reclaimActionButton = nil - } - } - } - - override public func dismiss(completion: (() -> Void)? = nil) { - if !self.isDismissed { - self.isDismissed = true - self.didAppearOnce = false - - self.controllerNode.animateOut(completion: { [weak self] in - completion?() - self?.dismiss(animated: false) - }) - - DispatchQueue.main.async { - self.onViewDidDisappear?() - } - } - } - - override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) { - super.containerLayoutUpdated(layout, transition: transition) - self.validLayout = layout - self.controllerNode.containerLayoutUpdated(layout, navigationHeight: self.navigationLayout(layout: layout).navigationFrame.maxY, transition: transition) - } -} - -private final class VoiceChatContextExtractedContentSource: ContextExtractedContentSource { - var keepInPlace: Bool - let ignoreContentTouches: Bool = false - let blurBackground: Bool - let maskView: UIView? - - private var animateTransitionIn: () -> Void - private var animateTransitionOut: () -> Void - - private let sourceNode: ContextExtractedContentContainingNode - - var centerVertically: Bool - var shouldBeDismissed: Signal - - init(sourceNode: ContextExtractedContentContainingNode, maskView: UIView?, keepInPlace: Bool, blurBackground: Bool, centerVertically: Bool, shouldBeDismissed: Signal, animateTransitionIn: @escaping () -> Void, animateTransitionOut: @escaping () -> Void) { - self.sourceNode = sourceNode - self.maskView = maskView - self.keepInPlace = keepInPlace - self.blurBackground = blurBackground - self.centerVertically = centerVertically - self.shouldBeDismissed = shouldBeDismissed - self.animateTransitionIn = animateTransitionIn - self.animateTransitionOut = animateTransitionOut - } - - func takeView() -> ContextControllerTakeViewInfo? { - self.animateTransitionIn() - return ContextControllerTakeViewInfo(containingItem: .node(self.sourceNode), contentAreaInScreenSpace: UIScreen.main.bounds, maskView: self.maskView) - } - - func putBack() -> ContextControllerPutBackViewInfo? { - self.animateTransitionOut() - return ContextControllerPutBackViewInfo(contentAreaInScreenSpace: UIScreen.main.bounds, maskView: self.maskView) - } -} - -private final class VoiceChatContextReferenceContentSource: ContextReferenceContentSource { - private let controller: ViewController - private let sourceNode: ContextReferenceContentNode - - init(controller: ViewController, sourceNode: ContextReferenceContentNode) { - self.controller = controller - self.sourceNode = sourceNode - } - - func transitionInfo() -> ContextControllerReferenceViewInfo? { - return ContextControllerReferenceViewInfo(referenceView: self.sourceNode.view, contentAreaInScreenSpace: UIScreen.main.bounds) - } -} diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 19544e5667..79f576f190 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -665,8 +665,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { if call.isStream { strongSelf.hasGroupCallOnScreenPromise.set(true) - // TODO: remove sharedContext and accountContext from init - let groupCallController = _MediaStreamComponentController(sharedContext: strongSelf, accountContext: call.accountContext, call: call) // MediaStreamComponentController(call: call)ue + let groupCallController = MediaStreamComponentController(call: call) groupCallController.onViewDidAppear = { [weak self] in if let strongSelf = self { strongSelf.hasGroupCallOnScreenPromise.set(true) From 197128c3feace3936e21a1f0acfdc801b6acfb30 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 8 Dec 2022 09:41:39 +0400 Subject: [PATCH 30/50] Refactoring --- .../ShimmerEffect/Sources/ShimmerEffect.swift | 6 +- .../Components/AnimatedCounterView.swift | 168 ++---------- .../Components/MediaStreamComponent.swift | 123 ++------- .../MediaStreamVideoComponent.swift | 239 +++++++----------- 4 files changed, 135 insertions(+), 401 deletions(-) diff --git a/submodules/ShimmerEffect/Sources/ShimmerEffect.swift b/submodules/ShimmerEffect/Sources/ShimmerEffect.swift index 65c86728dd..1a35997e17 100644 --- a/submodules/ShimmerEffect/Sources/ShimmerEffect.swift +++ b/submodules/ShimmerEffect/Sources/ShimmerEffect.swift @@ -477,7 +477,7 @@ public final class StandaloneShimmerEffect { self.updateLayer() } - public func testUpdate(background: UIColor, foreground: UIColor) { + public func updateHorizontal(background: UIColor, foreground: UIColor) { if self.background == background && self.foreground == foreground { return } @@ -503,7 +503,7 @@ public final class StandaloneShimmerEffect { context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: 0.3), options: CGGradientDrawingOptions()) }) - self.testUpdateLayer() + self.updateHorizontalLayer() } public func updateLayer() { @@ -525,7 +525,7 @@ public final class StandaloneShimmerEffect { } } - private func testUpdateLayer() { + private func updateHorizontalLayer() { guard let layer = self.layer, let image = self.image else { return } diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index 21484ebcc9..969baac579 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -10,7 +10,6 @@ private let latePink = UIColor(rgb: 0xf0436c) public final class AnimatedCountView: UIView { let countLabel = AnimatedCountLabel() -// let titleLabel = UILabel() let subtitleLabel = UILabel() private let foregroundView = UIView() @@ -31,7 +30,6 @@ public final class AnimatedCountView: UIView { self.foregroundView.layer.addSublayer(self.foregroundGradientLayer) self.addSubview(self.foregroundView) -// self.addSubview(self.titleLabel) self.addSubview(self.subtitleLabel) self.maskingView.addSubview(countLabel) @@ -40,7 +38,6 @@ public final class AnimatedCountView: UIView { self.clipsToBounds = false subtitleLabel.textColor = .white -// self.backgroundColor = UIColor.white.withAlphaComponent(0.1) } override public func layoutSubviews() { @@ -56,34 +53,12 @@ public final class AnimatedCountView: UIView { func update(countString: String, subtitle: String) { self.setupGradientAnimations() - let text: String = countString// presentationStringsFormattedNumber(Int32(count), ",") - - // self.titleNode.attributedText = NSAttributedString(string: "", font: Font.with(size: 23.0, design: .round, weight: .semibold, traits: []), textColor: .white) - // let titleSize = self.titleNode.updateLayout(size) - // self.titleNode.frame = CGRect(x: floor((size.width - titleSize.width) / 2.0), y: 48.0, width: titleSize.width, height: titleSize.height) -// if CGFloat(text.count * 40) < bounds.width - 32 { -// self.countLabel.attributedText = NSAttributedString(string: text, attributes: [.font: UIFont.systemFont(ofSize: 60, weight: .semibold)]) -// } else { -// self.countLabel.attributedText = NSAttributedString(string: text, attributes: [.font: UIFont.systemFont(ofSize: 54, weight: .semibold)]) -// + let text: String = countString self.countLabel.fontSize = 48 self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: 48, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) -// self.countLabel.attributedText = NSAttributedString(string: text, attributes: [.font: UIFont.systemFont(ofSize: 60, weight: .semibold)]) -// var timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) -// if timerSize.width > size.width - 32.0 { -// self.timerNode.attributedText = NSAttributedString(string: text, font: Font.with(size: 60.0, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) -// timerSize = self.timerNode.updateLayout(CGSize(width: size.width + 100.0, height: size.height)) -// } - -// self.timerNode.frame = CGRect(x: floor((size.width - timerSize.width) / 2.0), y: 78.0, width: timerSize.width, height: timerSize.height) self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, attributes: [.font: UIFont.systemFont(ofSize: 16, weight: .semibold)]) self.subtitleLabel.isHidden = subtitle.isEmpty -// let subtitleSize = self.subtitleNode.updateLayout(size) -// self.subtitleNode.frame = CGRect(x: floor((size.width - subtitleSize.width) / 2.0), y: 164.0, width: subtitleSize.width, height: subtitleSize.height) - -// self.foregroundView.frame = CGRect(origin: CGPoint(), size: size) - // self.setNeedsLayout() } required init?(coder: NSCoder) { @@ -105,9 +80,7 @@ public final class AnimatedCountView: UIView { animation.toValue = newValue CATransaction.setCompletionBlock { [weak self] in -// if let isCurrentlyInHierarchy = self?.isCurrentlyInHierarchy, isCurrentlyInHierarchy { - self?.setupGradientAnimations() -// } + self?.setupGradientAnimations() } self.foregroundGradientLayer.add(animation, forKey: "movement") CATransaction.commit() @@ -126,7 +99,7 @@ class AnimatedCharLayer: CATextLayer { } var attributedText: NSAttributedString? { get { - self.string as? NSAttributedString //?? (self.string as? String).map { NSAttributed.init + self.string as? NSAttributedString } set { self.string = newValue @@ -214,37 +187,17 @@ class AnimatedCountLabel: UILabel { } return offset } else { - var offset = self.chars[0.. index && self.chars[index].attributedText?.string == "," { - if index > 0, let prevChar = self.chars[index - 1].attributedText?.string, ["1", "7"].contains(prevChar) { - offset -= commaWidthForSpacing * 0.7 - } else { - offset -= commaWidthForSpacing / 3 - } - } - return offset + return offsetForChar(at: index, within: self.chars.compactMap(\.attributedText)) } } override func layoutSubviews() { super.layoutSubviews() - let countWidth = offsetForChar(at: chars.count) /*chars.reduce(0) { - if $1.attributedText?.string == "," { - return $0 + commaWidth + interItemSpacing - } - return $0 + itemWidth + interItemSpacing - }*/ - interItemSpacing + let countWidth = offsetForChar(at: chars.count) - interItemSpacing containerView.frame = .init(x: bounds.midX - countWidth / 2 * scaleFactor, y: 0, width: countWidth * scaleFactor, height: bounds.height) chars.enumerated().forEach { (index, char) in let offset = offsetForChar(at: index) -// char.frame.size.width = char.attributedText?.string == "," ? commaFrameWidth : itemWidth char.frame.origin.x = offset -// char.frame.origin.x = CGFloat(chars.count - 1 - index) * (40 + interItemSpacing) char.frame.origin.y = 0 } } @@ -274,10 +227,7 @@ class AnimatedCountLabel: UILabel { } } - let initialDuration: TimeInterval = min(0.25, maxAnimationDuration / Double(numberOfChanges)) /// 0.25 - -// let currentWidth = itemWidth * CGFloat(currentChars.count) -// let newWidth = itemWidth * CGFloat(newChars.count) + let initialDuration: TimeInterval = min(0.25, maxAnimationDuration / Double(numberOfChanges)) let interItemDelay: TimeInterval = 0.08 var changeIndex = 0 @@ -288,11 +238,7 @@ class AnimatedCountLabel: UILabel { let newCharIndex = newChars.count - 1 - index let currCharIndex = currentChars.count - 1 - index - if true || newChars[newCharIndex] != currentChars[currCharIndex] { -// if newChars[newCharIndex].string != "," { -// continue -// } - + if newChars[newCharIndex] != currentChars[currCharIndex] { let initialDuration = newChars[newCharIndex] != currentChars[currCharIndex] ? initialDuration : 0 if !isInitialSet && newChars[newCharIndex] != currentChars[currCharIndex] { @@ -302,19 +248,14 @@ class AnimatedCountLabel: UILabel { } let newLayer = AnimatedCharLayer() newLayer.attributedText = newChars[newCharIndex] - let offset = offsetForChar(at: newCharIndex, within: newChars)/* newChars[0.. self.bounds.width { let scale = (self.bounds.width - 32) / (countWidth * scaleFactor) @@ -380,71 +309,31 @@ class AnimatedCountLabel: UILabel { } else { containerView.transform = .init(scaleX: scaleFactor, y: scaleFactor) } - // containerView.backgroundColor = .red.withAlphaComponent(0.3) } } else if countWidth > 0 { containerView.frame = .init(x: self.bounds.midX - countWidth / 2 * scaleFactor, y: 0, width: countWidth * scaleFactor, height: self.bounds.height) didBegin = true } -// self.backgroundColor = .green.withAlphaComponent(0.2) self.clipsToBounds = false } func animateOut(for layer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) { -// let animation = CAKeyframeAnimation() -// animation.keyPath = "opacity" -// animation.values = [layer.presentation()?.value(forKey: "opacity") ?? 1, 0.0] -// animation.keyTimes = [0, 1] -// animation.duration = duration -// animation.beginTime = CACurrentMediaTime() + beginTime -//// animation.isAdditive = true -// animation.isRemovedOnCompletion = false -// animation.fillMode = .backwards -// layer.opacity = 0 -// layer.add(animation, forKey: "opacity") -// -// let beginTimeOffset: CFTimeInterval = 0/*beginTime == .zero ? 0 :*/ // CFTimeInterval(DispatchTime.now().uptimeNanoseconds / 1000000000) /*layer.convertTime(*/// CACurrentMediaTime()//, to: nil) DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) { - let currentTime = CFTimeInterval(DispatchTime.now().uptimeNanoseconds / 1000000000) let beginTime: CFTimeInterval = 0 - print("[DIFF-out] \(currentTime - beginTimeOffset)") + let opacityInAnimation = CABasicAnimation(keyPath: "opacity") opacityInAnimation.fromValue = 1 opacityInAnimation.toValue = 0 opacityInAnimation.fillMode = .forwards opacityInAnimation.isRemovedOnCompletion = false - // opacityInAnimation.duration = duration - // opacityInAnimation.beginTime = beginTimeOffset + beginTime - // opacityInAnimation.completion = { _ in - // layer.removeFromSuperlayer() - // } - // layer.add(opacityInAnimation, forKey: "opacity") - - // let timer = Timer.scheduledTimer(withTimeInterval: duration + beginTime, repeats: false) { timer in - // DispatchQueue.main.asyncAfter(deadline: .now() + duration + beginTime) { - // DispatchQueue.main.async { - // layer.backgroundColor = UIColor.red.withAlphaComponent(0.3).cgColor - // } - // DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - // layer.removeFromSuperlayer() - // } - // timer.invalidate() - // } - // RunLoop.current.add(timer, forMode: .common) let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale") - scaleOutAnimation.fromValue = 1 // layer.presentation()?.value(forKey: "transform.scale") ?? 1 + scaleOutAnimation.fromValue = 1 scaleOutAnimation.toValue = 0.0 - // scaleOutAnimation.duration = duration - // scaleOutAnimation.beginTime = beginTimeOffset + beginTime - // layer.add(scaleOutAnimation, forKey: "scaleout") let translate = CABasicAnimation(keyPath: "transform.translation") translate.fromValue = CGPoint.zero - translate.toValue = CGPoint(x: 0, y: -layer.bounds.height * 0.3)// -layer.bounds.height + 3.0) - // translate.duration = duration - // translate.beginTime = beginTimeOffset + beginTime - // layer.add(translate, forKey: "translate") + translate.toValue = CGPoint(x: 0, y: -layer.bounds.height * 0.3) let group = CAAnimationGroup() group.animations = [opacityInAnimation, scaleOutAnimation, translate] @@ -455,38 +344,31 @@ class AnimatedCountLabel: UILabel { group.completion = { _ in layer.removeFromSuperlayer() } - // layer.opacity = 0 layer.add(group, forKey: "out") } } func animateIn(for newLayer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) { - let beginTimeOffset: CFTimeInterval = 0// CFTimeInterval(DispatchTime.now().uptimeNanoseconds / 1000000000)// CACurrentMediaTime() + let beginTimeOffset: CFTimeInterval = 0 // CACurrentMediaTime() DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) { [self] in - let currentTime = CFTimeInterval(DispatchTime.now().uptimeNanoseconds / 1000000000) let beginTime: CFTimeInterval = 0 - print("[DIFF-in] \(currentTime - beginTimeOffset)") newLayer.opacity = 0 - // newLayer.backgroundColor = UIColor.red.cgColor let opacityInAnimation = CABasicAnimation(keyPath: "opacity") opacityInAnimation.fromValue = 0 opacityInAnimation.toValue = 1 opacityInAnimation.duration = duration opacityInAnimation.beginTime = beginTimeOffset + beginTime - // opacityInAnimation.isAdditive = true opacityInAnimation.fillMode = .backwards newLayer.opacity = 1 newLayer.add(opacityInAnimation, forKey: "opacity") - // newLayer.opacity = 1 let scaleOutAnimation = CABasicAnimation(keyPath: "transform.scale") scaleOutAnimation.fromValue = 0 scaleOutAnimation.toValue = 1 scaleOutAnimation.duration = duration scaleOutAnimation.beginTime = beginTimeOffset + beginTime - // scaleOutAnimation.isAdditive = true newLayer.add(scaleOutAnimation, forKey: "scalein") let animation = CAKeyframeAnimation() diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index b9cf8f6db2..0347c1f1d9 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -48,8 +48,6 @@ public final class MediaStreamComponent: CombinedComponent { private(set) var hasVideo: Bool = false private var stateDisposable: Disposable? private var infoDisposable: Disposable? - private var connectionDisposable: Disposable? - private var networkStateDisposable: Disposable? private(set) var originInfo: OriginInfo? @@ -113,36 +111,6 @@ public final class MediaStreamComponent: CombinedComponent { strongSelf.updated(transition: .immediate) }) - // TODO: retest to uncomment or delete. Relying only on video frames - /*self.networkStateDisposable = (call.account.networkState |> deliverOnMainQueue).start(next: { [weak self] state in - guard let strongSelf = self else { return } - switch state { - case .waitingForNetwork, .connecting: - print("[NEW] videoStalled") - strongSelf.videoStalled = true - default: - strongSelf.videoStalled = !strongSelf.hasVideo - } - strongSelf.updated(transition: .immediate) -// if let strongSelf = self, case .standard(previewing: false) = strongSelf.presentationInterfaceState.mode { -// strongSelf.chatTitleView?.networkState = state -// } - }) - - self.connectionDisposable = call.state.start(next: { [weak self] state in - let prev = self?.videoStalled - switch state.networkState { - case .connected: - self?.videoStalled = false - default: - print("[ALERT] video stalled") - self?.videoStalled = true - } - if prev != self?.videoStalled { - self?.updated(transition: .immediate) - } - })*/ - let callPeer = call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: call.peerId)) self.infoDisposable = (combineLatest(queue: .mainQueue(), call.state, call.members, callPeer) @@ -153,8 +121,8 @@ public final class MediaStreamComponent: CombinedComponent { var updated = false // TODO: remove debug timer -// Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in - strongSelf.infoThrottler.publish(members.totalCount/*Int.random(in: 0..<10000000)*/) { [weak strongSelf] latestCount in + Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + strongSelf.infoThrottler.publish(/*members.totalCount*/Int.random(in: 0..<1000000000)) { [weak strongSelf] latestCount in print(members.totalCount) guard let strongSelf = strongSelf else { return } var updated = false @@ -167,7 +135,7 @@ public final class MediaStreamComponent: CombinedComponent { strongSelf.updated(transition: .immediate) } } -// }.fire() + }.fire() if state.canManageCall != strongSelf.canManageCall { strongSelf.canManageCall = state.canManageCall updated = true @@ -188,12 +156,6 @@ public final class MediaStreamComponent: CombinedComponent { updated = true } -// let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: members.totalCount) -// if strongSelf.originInfo != originInfo { -// strongSelf.originInfo = originInfo -// updated = true -// } -// if updated { strongSelf.updated(transition: .immediate) } @@ -225,8 +187,6 @@ public final class MediaStreamComponent: CombinedComponent { self.stateDisposable?.dispose() self.infoDisposable?.dispose() self.isVisibleInHierarchyDisposable?.dispose() - self.connectionDisposable?.dispose() - self.networkStateDisposable?.dispose() } func toggleDisplayUI() { @@ -272,9 +232,6 @@ public final class MediaStreamComponent: CombinedComponent { let background = Child(Rectangle.self) let dismissTapComponent = Child(Rectangle.self) let video = Child(MediaStreamVideoComponent.self) -// let navigationBar = Child(NavigationBarComponent.self) -// let toolbar = Child(ToolbarComponent.self) - let sheet = Child(StreamSheetComponent.self) let fullscreenOverlay = Child(StreamSheetComponent.self) @@ -312,11 +269,10 @@ public final class MediaStreamComponent: CombinedComponent { state.updated(transition: .easeInOut(duration: 3)) deactivatePictureInPicture.invoke(Void()) } - let isFullscreen: Bool // = state.isFullscreen + let isFullscreen: Bool let isLandscape = context.availableSize.width > context.availableSize.height -// if let videoSize = context.state.videoSize { - // Always fullscreen in landscape + // Always fullscreen in landscape // TODO: support landscape sheet (wrap in scrollview, video size same as portrait) if forceFullScreenInLandscape && isLandscape && !state.isFullscreen { state.isFullscreen = true @@ -327,7 +283,7 @@ public final class MediaStreamComponent: CombinedComponent { } else { isFullscreen = state.isFullscreen } - // } + let videoInset: CGFloat if !isFullscreen { videoInset = 16 @@ -346,7 +302,7 @@ public final class MediaStreamComponent: CombinedComponent { var dragOffset = context.state.dismissOffset if isFullyDragged { - dragOffset = max(context.state.dismissOffset, sheetHeight - context.availableSize.height + context.view.safeAreaInsets.top)// sheetHeight - UIScreen.main.bounds.height + dragOffset = max(context.state.dismissOffset, sheetHeight - context.availableSize.height + context.view.safeAreaInsets.top) } let dismissTapAreaHeight = isFullscreen ? 0 : (context.availableSize.height - sheetHeight + dragOffset) @@ -356,7 +312,6 @@ public final class MediaStreamComponent: CombinedComponent { transition: context.transition ) - let video = video.update( component: MediaStreamVideoComponent( call: context.component.call, @@ -425,8 +380,7 @@ public final class MediaStreamComponent: CombinedComponent { var topLeftButton: AnyComponent? if context.state.canManageCall { let whiteColor = UIColor(white: 1.0, alpha: 1.0) - /*navigationRightItems.append(*/ topLeftButton = //AnyComponentWithIdentity(id: "more", component: - AnyComponent(Button( + topLeftButton = AnyComponent(Button( content: AnyComponent(ZStack([ AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( fillColor: .white.withAlphaComponent(0.08), @@ -562,7 +516,6 @@ public final class MediaStreamComponent: CombinedComponent { return } - let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } if let title = title { @@ -659,22 +612,11 @@ public final class MediaStreamComponent: CombinedComponent { let navigationComponent = NavigationBarComponent( topInset: environment.statusBarHeight, sideInset: environment.safeInsets.left, - leftItem: topLeftButton/*AnyComponent(Button( - content: AnyComponent(Text(text: environment.strings.Common_Close, font: Font.regular(17.0), color: .white)), - action: { [weak call] in - let _ = call?.leave(terminateIfPossible: false) - }) - )*/, + leftItem: topLeftButton, rightItems: navigationRightItems, centerItem: AnyComponent(StreamTitleComponent(text: state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isActive: context.state.videoIsPlayable)) ) -// let navigationBar = navigationBar.update( -// component: navigationComponent, -// availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), -// transition: context.transition -// ) - if context.state.storedIsFullscreen != isFullscreen { context.state.storedIsFullscreen = isFullscreen if isFullscreen { @@ -751,8 +693,6 @@ public final class MediaStreamComponent: CombinedComponent { onPanGesture(panState) }) ) -// var bottomComponent: AnyComponent? -// var fullScreenToolbarComponent: AnyComponent? context.add(dismissTapComponent .position(CGPoint(x: context.availableSize.width / 2, y: dismissTapAreaHeight / 2)) @@ -821,7 +761,6 @@ public final class MediaStreamComponent: CombinedComponent { context.setLineWidth(2.4 * imageRenderScale - UIScreenPixel) context.setLineCap(.round) context.setStrokeColor(imageColor.cgColor) -// context.setLineJoin(.round) let lineSide = size.width / 5 let centerOffset = size.width / 20 @@ -860,9 +799,8 @@ public final class MediaStreamComponent: CombinedComponent { controller.updateOrientation(orientation: .portrait) } if !canEnforceOrientation { - state.updated() // updated(.easeInOut(duration: 0.3)) + state.updated() } - // controller.updateOrientation(orientation: isLandscape ? .portrait : .landscapeRight) } } ).minSize(CGSize(width: 44.0, height: 44.0))) @@ -877,7 +815,6 @@ public final class MediaStreamComponent: CombinedComponent { backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), bottomPadding: bottomPadding, participantsCount: context.state.originInfo?.memberCount ?? 0, // Int.random(in: 0...999998)// [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! - // isFullyExtended: isFullyDragged, deviceCornerRadius: (controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 0, videoHeight: videoHeight @@ -888,7 +825,7 @@ public final class MediaStreamComponent: CombinedComponent { let sheetOffset: CGFloat = context.availableSize.height - sheetHeight + dragOffset let sheetPosition = sheetOffset + sheetHeight / 2 - // Sheet underneath the video when in sheet + // Sheet underneath the video when in modal sheet context.add(sheet .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) ) @@ -900,7 +837,7 @@ public final class MediaStreamComponent: CombinedComponent { videoPos = sheetPosition - sheetHeight / 2 + videoHeight / 2 + 50 + 12 } context.add(video - .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)/*sheetPosition + videoHeight / 2 + 50 - context.availableSize.height / 2*/)// context.availableSize.height / 2.0 + context.state.dismissOffset)) + .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)) ) } else { context.add(video @@ -950,7 +887,7 @@ public final class MediaStreamComponent: CombinedComponent { sheetHeight: max(sheetHeight - context.state.dismissOffset, sheetHeight), backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), bottomPadding: 12, - participantsCount: -1, // context.state.originInfo?.memberCount ?? 0 + participantsCount: -1, isFullyExtended: isFullyDragged, deviceCornerRadius: (controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 0, videoHeight: videoHeight @@ -964,24 +901,11 @@ public final class MediaStreamComponent: CombinedComponent { ) } -// context.add(navigationBar -// .position(CGPoint(x: context.availableSize.width / 2.0, y: navigationBar.size.height / 2.0)) -// .opacity(context.state.displayUI ? 1.0 : 0.0) -// ) - -// context.add(toolbar -// .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height - toolbar.size.height / 2.0)) -// .opacity(context.state.displayUI ? 1.0 : 0.0) -// ) - return context.availableSize } } } -// TODO: pass to component properly -//internal var deviceCornerRadius: CGFloat? = nil - public final class MediaStreamComponentController: ViewControllerComponentContainer, VoiceChatController { private let context: AccountContext public let call: PresentationGroupCall @@ -1048,11 +972,6 @@ public final class MediaStreamComponentController: ViewControllerComponentContai DispatchQueue.main.async { self.onViewDidDisappear?() } - -// if let initialOrientation = self.initialOrientation { -// self.initialOrientation = nil -// self.call.accountContext.sharedContext.applicationBindings.forceOrientation(initialOrientation) -// } } override public func viewDidLoad() { @@ -1088,17 +1007,8 @@ public final class MediaStreamComponentController: ViewControllerComponentContai strongSelf.dismissImpl(completion: completion) }) self.backgroundDimView.layer.animateAlpha(from: 1.0, to: 0, duration: 0.3, removeOnCompletion: false) - // if let validLayout = self.validLayout { - // self.view.clipsToBounds = true - // self.view.layer.cornerRadius = validLayout.deviceMetrics.screenCornerRadius - // if #available(iOS 13.0, *) { - // self.view.layer.cornerCurve = .continuous - // } - - self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 0.4, /*timingFunction: kCAMediaTimingFunctionSpring, */completion: { _ in + self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 0.4, completion: { _ in }) - // self.view.layer.animateScale(from: 1.0, to: 0.001, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring) - // } } private func dismissImpl(completion: (() -> Void)? = nil) { @@ -1505,21 +1415,18 @@ private final class NavigationBarComponent: CombinedComponent { centerLeftInset += leftItem.size.width + 4.0 } -// var centerRightInset = sideInset var rightItemX = context.availableSize.width - sideInset for item in rightItemList.reversed() { context.add(item .position(CGPoint(x: rightItemX - item.size.width / 2.0, y: context.component.topInset + contentHeight / 2.0)) ) rightItemX -= item.size.width + 8.0 -// centerRightInset += item.size.width + 8.0 } -// let maxCenterInset = max(centerLeftInset, centerRightInset) let someUndesiredOffset: CGFloat = 16 if let centerItem = centerItem { context.add(centerItem - .position(CGPoint(x: context.availableSize.width / 2 - someUndesiredOffset /*maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0*/, y: context.component.topInset + contentHeight / 2.0)) + .position(CGPoint(x: context.availableSize.width / 2 - someUndesiredOffset, y: context.component.topInset + contentHeight / 2.0)) ) } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 1b51a10400..f5bb89be04 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -12,23 +12,6 @@ import SwiftSignalKit import AvatarNode import Postbox -class CustomIntensityVisualEffectView: UIVisualEffectView { - init(effect: UIVisualEffect, intensity: CGFloat) { - super.init(effect: nil) - animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [unowned self] in self.effect = effect } - animator.startAnimation() - animator.pauseAnimation() - animator.fractionComplete = intensity - animator.pausesOnCompletion = true - } - - required init?(coder aDecoder: NSCoder) { - fatalError() - } - - var animator: UIViewPropertyAnimator! -} - final class MediaStreamVideoComponent: Component { let call: PresentationGroupCallImpl let hasVideo: Bool @@ -137,6 +120,34 @@ final class MediaStreamVideoComponent: Component { private var noSignalTimer: Foundation.Timer? private var noSignalTimeout: Bool = false + private let maskGradientLayer = CAGradientLayer() + private var wasVisible = true + private var borderShimmer = StandaloneShimmerEffect() + private let shimmerBorderLayer = CALayer() + private let placeholderView = UIImageView() + + private var videoStalled = false { + didSet { + if videoStalled != oldValue { + self.updateVideoStalled(isStalled: self.videoStalled) +// state?.updated() + } + } + } + var onVideoPlaybackChange: ((Bool) -> Void) = { _ in } + + private var frameInputDisposable: Disposable? + + private var stallTimer: Foundation.Timer? + private let fullScreenBackgroundPlaceholder = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) + + private var avatarDisposable: Disposable? + private var didBeginLoadingAvatar = false + private var timeLastFrameReceived: CFAbsoluteTime? + + private var isFullscreen: Bool = false + private let videoLoadingThrottler = Throttler(duration: 1, queue: .main) + private weak var state: State? override init(frame: CGRect) { @@ -154,6 +165,11 @@ final class MediaStreamVideoComponent: Component { fatalError("init(coder:) has not been implemented") } + deinit { + avatarDisposable?.dispose() + frameInputDisposable?.dispose() + } + public func matches(tag: Any) -> Bool { if let _ = tag as? Tag { return true @@ -167,23 +183,6 @@ final class MediaStreamVideoComponent: Component { self.pictureInPictureController?.stopPictureInPicture() } } - let maskGradientLayer = CAGradientLayer() - private var wasVisible = true - var borderShimmer = StandaloneShimmerEffect() - let shimmerBorderLayer = CALayer() - let placeholderView = UIImageView() - - var videoStalled = false { - didSet { - if videoStalled != oldValue { - self.updateVideoStalled(isStalled: self.videoStalled) -// state?.updated() - } - } - } - var onVideoPlaybackChange: ((Bool) -> Void) = { _ in } - - private var frameInputDisposable: Disposable? private func updateVideoStalled(isStalled: Bool) { if isStalled { @@ -215,7 +214,6 @@ final class MediaStreamVideoComponent: Component { } } if shimmerBorderLayer.superlayer == nil { -// loadingBlurView.contentView.layer.addSublayer(shimmerOverlayLayer) loadingBlurView.contentView.layer.addSublayer(shimmerBorderLayer) } loadingBlurView.clipsToBounds = true @@ -234,7 +232,7 @@ final class MediaStreamVideoComponent: Component { borderShimmer = .init() borderShimmer.layer = shimmerBorderLayer - borderShimmer.testUpdate(background: .clear, foreground: .white) + borderShimmer.updateHorizontal(background: .clear, foreground: .white) loadingBlurView.alpha = 1 } else { if hadVideo { @@ -251,60 +249,12 @@ final class MediaStreamVideoComponent: Component { self?.loadingBlurView.layer.removeAllAnimations() } loadingBlurView.layer.add(anim, forKey: "opacity") - } else { - // Wait for state to update with first frame - // Accounting for delay in first frame received - /*DispatchQueue.main.asyncAfter(deadline: .now() + 2) { [weak self] in - guard self?.videoStalled == false else { return } - - // TODO: animate blur intesity with UIPropertyAnimator - self?.loadingBlurView.layer.removeAllAnimations() - let anim = CABasicAnimation(keyPath: "opacity") - anim.duration = 0.5 - anim.fromValue = 1 - anim.toValue = 0 - anim.fillMode = .forwards - anim.isRemovedOnCompletion = false - anim.completion = { [weak self] _ in - guard self?.videoStalled == false else { return } -// DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [self] in - self?.loadingBlurView.removeFromSuperview() - self?.placeholderView.removeFromSuperview() - } - self?.loadingBlurView.layer.add(anim, forKey: "opacity") -// UIView.transition(with: self, duration: 0.2, animations: { -//// self.loadingBlurView.animator.fractionComplete = 0 -//// self.loadingBlurView.effect = nil -//// self.loadingBlurView.alpha = 0 -// }, completion: { _ in -// self.loadingBlurView = .init(effect: UIBlurEffect(style: .light), intensity: 0.4) -// }) - }*/ } -// loadingBlurView.backgroundColor = .yellow.withAlphaComponent(0.4) } } - var stallTimer: Foundation.Timer? - let fullScreenBackgroundPlaceholder = UIVisualEffectView(effect: UIBlurEffect(style: .regular)) - - var avatarDisposable: Disposable? - var didBeginLoadingAvatar = false -// let avatarPlaceholderView = UIImageView() - var timeLastFrameReceived: CFAbsoluteTime? - - var isFullscreen: Bool = false - let videoLoadingThrottler = Throttler(duration: 1, queue: .main) - - deinit { - avatarDisposable?.dispose() - frameInputDisposable?.dispose() - } - func update(component: MediaStreamVideoComponent, availableSize: CGSize, state: State, transition: Transition) -> CGSize { self.state = state -// placeholderView.alpha = 0.7 -// placeholderView.image = lastFrame[component.call.peerId.id.description] self.component = component self.onVideoPlaybackChange = component.onVideoPlaybackLiveChange self.isFullscreen = component.isFullscreen @@ -331,19 +281,16 @@ final class MediaStreamVideoComponent: Component { var _stallTimer: Foundation.Timer { Foundation.Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] timer in guard let strongSelf = self else { return timer.invalidate() } -// print("Timer emitting \(timer)") let currentTime = CFAbsoluteTimeGetCurrent() if let lastFrameTime = strongSelf.timeLastFrameReceived, currentTime - lastFrameTime > 0.5 { -// DispatchQueue.main.async { strongSelf.videoLoadingThrottler.publish(true, includingLatest: true) { isStalled in strongSelf.videoStalled = isStalled strongSelf.onVideoPlaybackChange(!isStalled) } - - // } } } } + // TODO: use mapToThrottled (?) frameInputDisposable = input.start(next: { [weak self] input in guard let strongSelf = self else { return } @@ -355,7 +302,6 @@ final class MediaStreamVideoComponent: Component { } }) stallTimer = _stallTimer - // RunLoop.main.add(stallTimer!, forMode: .common) if let videoBlurView = self.videoRenderingContext.makeView(input: input, blur: true) { self.videoBlurView = videoBlurView @@ -373,75 +319,68 @@ final class MediaStreamVideoComponent: Component { if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) { self.videoView = videoView - self/*.insertSubview(videoView, belowSubview: loadingBlurView)*/.addSubview(videoView) + self.addSubview(videoView) videoView.alpha = 0 UIView.animate(withDuration: 0.3) { videoView.alpha = 1 } if let sampleBufferVideoView = videoView as? SampleBufferVideoRenderingView { sampleBufferVideoView.sampleBufferLayer.masksToBounds = true -// sampleBufferVideoView.sampleBufferLayer.cornerRadius = 10 if #available(iOS 13.0, *) { sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true } -// if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { - final class PlaybackDelegateImpl: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate { - var onTransitionFinished: (() -> Void)? - func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) { - - } - - func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange { - return CMTimeRange(start: .zero, duration: .positiveInfinity) - } - - func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool { - return false - } - - func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) { - onTransitionFinished?() - print("pip finished") - } - - func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) { - completionHandler() - } - - public func pictureInPictureControllerShouldProhibitBackgroundAudioPlayback(_ pictureInPictureController: AVPictureInPictureController) -> Bool { - return false - } + // if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { + final class PlaybackDelegateImpl: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate { + var onTransitionFinished: (() -> Void)? + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) { + } + + func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange { + return CMTimeRange(start: .zero, duration: .positiveInfinity) + } + + func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool { + return false + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) { + onTransitionFinished?() + } + + func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) { + completionHandler() + } + + public func pictureInPictureControllerShouldProhibitBackgroundAudioPlayback(_ pictureInPictureController: AVPictureInPictureController) -> Bool { + return false + } + } var pictureInPictureController: AVPictureInPictureController? = nil if #available(iOS 15.0, *) { pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: sampleBufferVideoView.sampleBufferLayer, playbackDelegate: { let delegate = PlaybackDelegateImpl() - delegate.onTransitionFinished = { [weak self] in - if self?.videoView?.alpha == 0 { -// self?.videoView?.alpha = 1 - } + delegate.onTransitionFinished = { } return delegate }())) pictureInPictureController?.playerLayer.masksToBounds = false pictureInPictureController?.playerLayer.cornerRadius = 10 } else if AVPictureInPictureController.isPictureInPictureSupported() { - // TODO: support PiP for iOS < 15.0 - // sampleBufferVideoView.sampleBufferLayer pictureInPictureController = AVPictureInPictureController.init(playerLayer: AVPlayerLayer(player: AVPlayer())) } - - pictureInPictureController?.delegate = self + + pictureInPictureController?.delegate = self if #available(iOS 14.2, *) { pictureInPictureController?.canStartPictureInPictureAutomaticallyFromInline = true } if #available(iOS 14.0, *) { pictureInPictureController?.requiresLinearPlayback = true } - - self.pictureInPictureController = pictureInPictureController -// } + + self.pictureInPictureController = pictureInPictureController + // } } videoView.setOnOrientationUpdated { [weak state] _, _ in @@ -464,7 +403,6 @@ final class MediaStreamVideoComponent: Component { } } } -// fullScreenBackgroundPlaceholder.removeFromSuperview() } else if component.isFullscreen { if fullScreenBackgroundPlaceholder.superview == nil { insertSubview(fullScreenBackgroundPlaceholder, at: 0) @@ -475,12 +413,6 @@ final class MediaStreamVideoComponent: Component { } fullScreenBackgroundPlaceholder.frame = .init(origin: .zero, size: availableSize) -// sheetView.frame = .init(x: 0, y: sheetTop, width: availableSize.width, height: sheetHeight) - // var aspect = videoView.getAspect() -// if aspect <= 0.01 { - // let aspect = !component.isFullscreen ? 16.0 / 9.0 : // 3.0 / 4.0 -// } - let videoInset: CGFloat if !component.isFullscreen { videoInset = 16 @@ -498,9 +430,8 @@ final class MediaStreamVideoComponent: Component { let snapshot = videoView.snapshotView(afterScreenUpdates: false) ?? videoView.snapshotView(afterScreenUpdates: true) { lastFrame[component.call.peerId.id.description] = snapshot// ()! } -// } + var aspect = videoView.getAspect() - // aspect == 1 the first run if component.isFullscreen && self.hadVideo { if aspect <= 0.01 { aspect = 16.0 / 9 @@ -545,7 +476,6 @@ final class MediaStreamVideoComponent: Component { if let videoBlurView = self.videoBlurView { videoBlurView.updateIsEnabled(component.isVisible) -// videoBlurView.isHidden = component.isFullscreen if component.isFullscreen { transition.withAnimation(.none).setFrame(view: videoBlurView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - blurredVideoSize.width) / 2.0), y: floor((availableSize.height - blurredVideoSize.height) / 2.0)), size: blurredVideoSize), completion: nil) } else { @@ -564,15 +494,15 @@ final class MediaStreamVideoComponent: Component { videoSize = CGSize(width: 16 / 9 * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) } loadingBlurView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) - print("[LBVFrame] \(loadingBlurView.frame)") + loadingBlurView.layer.cornerRadius = videoCornerRadius placeholderView.frame = loadingBlurView.frame placeholderView.layer.cornerRadius = videoCornerRadius placeholderView.clipsToBounds = true -// shimmerOverlayLayer.frame = loadingBlurView.bounds shimmerBorderLayer.frame = loadingBlurView.bounds + let borderMask = CAShapeLayer() borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil) borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor @@ -581,9 +511,6 @@ final class MediaStreamVideoComponent: Component { shimmerBorderLayer.mask = borderMask shimmerBorderLayer.cornerRadius = videoCornerRadius - if component.isFullscreen { -// loadingBlurView.removeFromSuperview() - } if !self.hadVideo { if self.noSignalTimer == nil { @@ -668,9 +595,6 @@ final class MediaStreamVideoComponent: Component { presentation.removeFromSuperview() }) } -// DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { -// presentation.removeFromSuperlayer() -// } UIView.animate(withDuration: 0.1) { [self] in videoBlurView?.alpha = 0 } @@ -726,3 +650,24 @@ final class MediaStreamVideoComponent: Component { // TODO: move to appropriate place fileprivate var lastFrame: [String: UIView] = [:] + +class CustomIntensityVisualEffectView: UIVisualEffectView { + private var animator: UIViewPropertyAnimator! + + init(effect: UIVisualEffect, intensity: CGFloat) { + super.init(effect: nil) + animator = UIViewPropertyAnimator(duration: 1, curve: .linear) { [weak self] in self?.effect = effect } + animator.startAnimation() + animator.pauseAnimation() + animator.fractionComplete = intensity + animator.pausesOnCompletion = true + } + + required init?(coder aDecoder: NSCoder) { + fatalError() + } + + deinit { + animator.stopAnimation(true) + } +} From 81f69b801da8adee4001bb10c12e8e61730167ed Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 8 Dec 2022 09:53:30 +0400 Subject: [PATCH 31/50] Removing debug timer --- .../Sources/Components/MediaStreamComponent.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 0347c1f1d9..84a86f86df 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -121,9 +121,9 @@ public final class MediaStreamComponent: CombinedComponent { var updated = false // TODO: remove debug timer - Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in - strongSelf.infoThrottler.publish(/*members.totalCount*/Int.random(in: 0..<1000000000)) { [weak strongSelf] latestCount in - print(members.totalCount) +// Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + strongSelf.infoThrottler.publish(members.totalCount/*Int.random(in: 0..<10000000)*/) { [weak strongSelf] latestCount in +// let _ = members.totalCount guard let strongSelf = strongSelf else { return } var updated = false let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: latestCount) @@ -135,7 +135,7 @@ public final class MediaStreamComponent: CombinedComponent { strongSelf.updated(transition: .immediate) } } - }.fire() +// }.fire() if state.canManageCall != strongSelf.canManageCall { strongSelf.canManageCall = state.canManageCall updated = true From 55e2276e43d13dd7d51521bfa4056f37c8f47b14 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 8 Dec 2022 10:14:34 +0400 Subject: [PATCH 32/50] Refactoring --- .../Components/MediaStreamComponent.swift | 5 +-- .../MediaStreamVideoComponent.swift | 3 +- .../Components/StreamSheetComponent.swift | 33 +++---------------- 3 files changed, 9 insertions(+), 32 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 84a86f86df..0cab10ba04 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -1200,8 +1200,9 @@ final class StreamTitleComponent: Component { if !wasLive { wasLive = true let anim = CAKeyframeAnimation(keyPath: "transform.scale") - anim.values = [1.0, 1.4, 1.0] - anim.keyTimes = [0, 0.5, 1] + anim.values = [1.0, 1.4, 0.9, 1.0] + anim.keyTimes = [0, 0.5, 0.8, 1] + anim.duration = 0.4 self.layer.add(anim, forKey: "transform") UIView.animate(withDuration: 0.15, animations: { diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index f5bb89be04..ca65bb423f 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -428,7 +428,7 @@ final class MediaStreamVideoComponent: Component { self.hadVideo, // TODO: remove from here and move to call end (or at least to background) let snapshot = videoView.snapshotView(afterScreenUpdates: false) ?? videoView.snapshotView(afterScreenUpdates: true) { - lastFrame[component.call.peerId.id.description] = snapshot// ()! + lastFrame[component.call.peerId.id.description] = snapshot } var aspect = videoView.getAspect() @@ -500,6 +500,7 @@ final class MediaStreamVideoComponent: Component { placeholderView.frame = loadingBlurView.frame placeholderView.layer.cornerRadius = videoCornerRadius placeholderView.clipsToBounds = true + placeholderView.subviews.forEach { $0.frame = placeholderView.bounds } shimmerBorderLayer.frame = loadingBlurView.bounds diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 486847bc73..1cff46379c 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -119,29 +119,17 @@ final class StreamSheetComponent: CombinedComponent { } private weak var state: State? -// func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { -// view.isUserInteractionEnabled = false -// return availableSize -// } - /*public func update(view: View, availableSize: CGSize, state: State, environment: Environment, transition: Transition) -> CGSize { - return view.update(component: self, availableSize: availableSize, state: state, transition: transition) - }*/ static var body: Body { let background = Child(SheetBackgroundComponent.self) -// let leftItem = Child(environment: Empty.self) let topItem = Child(environment: Empty.self) let viewerCounter = Child(ParticipantsComponent.self) let bottomButtonsRow = Child(environment: Empty.self) -// let bottomButtons = Child(environment: Empty.self) -// let rightItems = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) -// let centerItem = Child(environment: Empty.self) return { context in let availableWidth = context.availableSize.width -// let sideInset: CGFloat = 16.0 + context.component.sideInset let contentHeight: CGFloat = 44.0 - let size = context.availableSize// CGSize(width: context.availableSize.width, height:44)// context.component.topInset + contentHeight) + let size = context.availableSize let topOffset = context.component.topOffset let backgroundExtraOffset = context.component.isFullyExtended ? -context.view.safeAreaInsets.top : 0 @@ -180,7 +168,6 @@ final class StreamSheetComponent: CombinedComponent { context.add(background .position(CGPoint(x: size.width / 2.0, y: topOffset + context.component.sheetHeight / 2)) - // .position(CGPoint(x: size.width / 2.0, y: context.component.topOffset + context.component.sheetHeight / 2 + backgroundExtraOffset)) ) (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames = [] @@ -192,7 +179,7 @@ final class StreamSheetComponent: CombinedComponent { ) (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: topOffset, width: topItem.size.width, height: topItem.size.height)) } - let videoHeight = context.component.videoHeight // ?? (min(availableWidth, context.availableSize.height) - 32) / 16 * 9 + let videoHeight = context.component.videoHeight let sheetHeight = context.component.sheetHeight let animatedParticipantsVisible = context.component.participantsCount != -1 if true { @@ -239,12 +226,9 @@ final class SheetBackgroundComponent: Component { let extraBottom: CGFloat = 500 if backgroundView.backgroundColor != color && backgroundView.backgroundColor != nil { -// let initialVelocity: CGFloat = 0 -// let xtransition = ComponentFlow.Transition(animation: .curve(duration: 0.45, curve: .spring))// .animated(duration: 0.45, curve: .customSpring(damping: 124.0, initialVelocity: initialVelocity)) - UIView.animate(withDuration: 0.4) { [self] in backgroundView.backgroundColor = color - // TODO: determine if animation is needed (with facts and logic, not color) + // TODO: determine if animation is needed (with logic, not color) backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottom)) } @@ -261,9 +245,6 @@ final class SheetBackgroundComponent: Component { } backgroundView.isUserInteractionEnabled = false backgroundView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] -// let currentRadius = backgroundView.layer.cornerRadius -// backgroundView.layer.cornerRadius = cornerRadius -// transition.animateCornerRadius(layer: backgroundView.layer, from: currentRadius, to: cornerRadius) backgroundView.clipsToBounds = true backgroundView.layer.masksToBounds = true } @@ -283,12 +264,6 @@ final class SheetBackgroundComponent: Component { if lhs.offset != rhs.offset { return false } -// if lhs.width != rhs.width { -// return false -// } -// if lhs.height != rhs.height { -// return false -// } return true } @@ -328,7 +303,7 @@ final class ParticipantsComponent: Component { } final class View: UIView { - let counter = AnimatedCountView()// VoiceChatTimerNode.init(strings: .init(), dateTimeFormat: .init()) + let counter = AnimatedCountView() override init(frame: CGRect) { super.init(frame: frame) From ea8022eb4f49fcd71faa58a695a1d1aab8f5b15a Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 8 Dec 2022 10:36:09 +0400 Subject: [PATCH 33/50] Adapting landscape modal for iPad --- .../Sources/Components/MediaStreamVideoComponent.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index ca65bb423f..ef4bea6e89 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -445,7 +445,8 @@ final class MediaStreamVideoComponent: Component { if component.isFullscreen { videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) } else { - let availableVideoWidth = availableSize.width - videoInset * 2 + // Limiting by smallest side -- redundant if passing precalculated availableSize + let availableVideoWidth = min(availableSize.width, availableSize.height) - videoInset * 2 let availableVideoHeight = availableVideoWidth * 9.0 / 16 videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableVideoWidth, height: availableVideoHeight)) From a9977a5bd673f25ed63617197f57f1789f9fc449 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 8 Dec 2022 13:38:21 +0400 Subject: [PATCH 34/50] Device corner radius --- .../Sources/Components/MediaStreamComponent.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 0cab10ba04..f02af69d98 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -816,7 +816,7 @@ public final class MediaStreamComponent: CombinedComponent { bottomPadding: bottomPadding, participantsCount: context.state.originInfo?.memberCount ?? 0, // Int.random(in: 0...999998)// [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! isFullyExtended: isFullyDragged, - deviceCornerRadius: (controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 0, + deviceCornerRadius: ((controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 1) - 1, videoHeight: videoHeight ), availableSize: context.availableSize, @@ -889,7 +889,7 @@ public final class MediaStreamComponent: CombinedComponent { bottomPadding: 12, participantsCount: -1, isFullyExtended: isFullyDragged, - deviceCornerRadius: (controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 0, + deviceCornerRadius: ((controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 1) - 1, videoHeight: videoHeight ), availableSize: context.availableSize, From 4ead717431e8cd26df23e540cdddc100387333e7 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Fri, 9 Dec 2022 23:45:57 +0400 Subject: [PATCH 35/50] Fixing safearea detection in media stream sheet (iOS 16) --- .../Sources/Components/MediaStreamComponent.swift | 14 +++++++++++--- .../Sources/Components/StreamSheetComponent.swift | 9 ++++++++- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 4e01d3207a..d7f5147ec6 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -345,11 +345,19 @@ public final class MediaStreamComponent: CombinedComponent { let sheetHeight: CGFloat = isFullscreen ? context.availableSize.height : (44 + videoHeight + 40 + 69 + 16 + 32 + 70 + bottomPadding) - let isFullyDragged = context.availableSize.height - sheetHeight + state.dismissOffset - context.view.safeAreaInsets.top < 30 + + let safeAreaTopInView: CGFloat + if #available(iOS 16.0, *) { + safeAreaTopInView = context.view.window.flatMap { $0.convert(CGPoint(x: 0, y: $0.safeAreaInsets.top), to: context.view).y } ?? 0 + } else { + safeAreaTopInView = context.view.safeAreaInsets.top + } + + let isFullyDragged = context.availableSize.height - sheetHeight + state.dismissOffset - safeAreaTopInView < 30 var dragOffset = context.state.dismissOffset if isFullyDragged { - dragOffset = max(context.state.dismissOffset, sheetHeight - context.availableSize.height + context.view.safeAreaInsets.top) + dragOffset = max(context.state.dismissOffset, sheetHeight - context.availableSize.height + safeAreaTopInView) } let dismissTapAreaHeight = isFullscreen ? 0 : (context.availableSize.height - sheetHeight + dragOffset) @@ -687,7 +695,7 @@ public final class MediaStreamComponent: CombinedComponent { )) } let availableSize = context.availableSize - let safeAreaTop = context.view.safeAreaInsets.top + let safeAreaTop = safeAreaTopInView let onPanGesture: ((Gesture.PanGestureState) -> Void) = { [weak state] panState in guard let state = state else { diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 1cff46379c..3e248c8da6 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -132,7 +132,14 @@ final class StreamSheetComponent: CombinedComponent { let size = context.availableSize let topOffset = context.component.topOffset - let backgroundExtraOffset = context.component.isFullyExtended ? -context.view.safeAreaInsets.top : 0 + let backgroundExtraOffset: CGFloat + if #available(iOS 16.0, *) { + // In iOS context.view does not inherit safeAreaInsets, quick fix until figure out how to deal properly: + let safeAreaTopInView = context.view.window.flatMap { $0.convert(CGPoint(x: 0, y: $0.safeAreaInsets.top), to: context.view).y } ?? 0 + backgroundExtraOffset = context.component.isFullyExtended ? -safeAreaTopInView : 0 + } else { + backgroundExtraOffset = context.component.isFullyExtended ? -context.view.safeAreaInsets.top : 0 + } let background = background.update( component: SheetBackgroundComponent( From 6c2cd20146d2c6da56a4989e72918568c500c83d Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Tue, 13 Dec 2022 13:29:48 +0400 Subject: [PATCH 36/50] Fixing stall out animation, orientation and fullscreen insets --- .../Components/MediaStreamComponent.swift | 36 +++++++++++++------ .../MediaStreamVideoComponent.swift | 29 +++++++++------ .../GroupCallNavigationAccessoryPanel.swift | 2 ++ 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index d7f5147ec6..53e0bf0b3e 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -57,6 +57,7 @@ public final class MediaStreamComponent: CombinedComponent { var storedIsFullscreen: Bool? var isFullscreen: Bool = false var videoSize: CGSize? + var prevFullscreenOrientation: UIDeviceOrientation? private(set) var canManageCall: Bool = false // TODO: also handle pictureInPicturePossible @@ -325,6 +326,7 @@ public final class MediaStreamComponent: CombinedComponent { state.isFullscreen = true isFullscreen = true } else if !isLandscape && state.isFullscreen && canEnforceOrientation { + state.prevFullscreenOrientation = nil state.isFullscreen = false isFullscreen = false } else { @@ -710,9 +712,12 @@ public final class MediaStreamComponent: CombinedComponent { if velocity.y > 200.0 { if state.isFullscreen { state.isFullscreen = false - state.updateDismissOffset(value: 0.0, interactive: false) - if let controller = controller() as? MediaStreamComponentController { + state.prevFullscreenOrientation = UIDevice.current.orientation + state.dismissOffset = 0.0// updateDismissOffset(value: 0.0, interactive: false) + if canEnforceOrientation, let controller = controller() as? MediaStreamComponentController { controller.updateOrientation(orientation: .portrait) + } else { + state.updated(transition: .easeInOut(duration: 0.25)) } } else { if isFullyDragged || state.initialOffset != 0 { @@ -835,21 +840,31 @@ public final class MediaStreamComponent: CombinedComponent { }), title: "expand" )), - action: { + action: { [weak state] in + guard let state = state else { return } guard state.videoIsPlayable else { - state.isFullscreen = false + state.isFullscreen = false return } if let controller = controller() as? MediaStreamComponentController { - guard let size = state.videoSize else { return } +// guard let _ = state.videoSize else { return } state.isFullscreen.toggle() if state.isFullscreen { - if size.width > size.height { +// if size.width > size.height { + let currentOrientation = state.prevFullscreenOrientation ?? UIDevice.current.orientation + switch currentOrientation { + case .landscapeLeft: + controller.updateOrientation(orientation: .landscapeRight) + case .landscapeRight: + controller.updateOrientation(orientation: .landscapeLeft) + default: controller.updateOrientation(orientation: .landscapeRight) - } else { - controller.updateOrientation(orientation: .portrait) } +// } else { +// controller.updateOrientation(orientation: .portrait) +// } } else { + state.prevFullscreenOrientation = UIDevice.current.orientation // TODO: Check and mind current device orientation controller.updateOrientation(orientation: .portrait) } @@ -916,13 +931,14 @@ public final class MediaStreamComponent: CombinedComponent { controller.presentShare() } ).minSize(CGSize(width: 64.0, height: 80))), - rightItem: state.hasVideo ? AnyComponent(Button( + rightItem: /*state.hasVideo ?*/ AnyComponent(Button( content: AnyComponent(BundleIconComponent( name: isFullscreen ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", tintColor: .white )), action: { state.isFullscreen = false + state.prevFullscreenOrientation = UIDevice.current.orientation if let controller = controller() as? MediaStreamComponentController { if canEnforceOrientation { controller.updateOrientation(orientation: .portrait) @@ -931,7 +947,7 @@ public final class MediaStreamComponent: CombinedComponent { } } } - ).minSize(CGSize(width: 64.0, height: 80))) : nil, + ).minSize(CGSize(width: 64.0, height: 80)))/* : nil*/, centerItem: infoItem )) let fullScreenOverlayComponent = fullscreenOverlay.update( diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index ef4bea6e89..535937b5e8 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -183,7 +183,7 @@ final class MediaStreamVideoComponent: Component { self.pictureInPictureController?.stopPictureInPicture() } } - + private var isAnimating = false private func updateVideoStalled(isStalled: Bool) { if isStalled { guard let component = self.component else { return } @@ -205,14 +205,16 @@ final class MediaStreamVideoComponent: Component { addSubview(loadingBlurView) if needsFadeInAnimation { let anim = CABasicAnimation(keyPath: "opacity") - anim.duration = 0.5 + anim.duration = 0.4 anim.fromValue = 0 anim.toValue = 1 + loadingBlurView.layer.opacity = 1 anim.fillMode = .forwards anim.isRemovedOnCompletion = false loadingBlurView.layer.add(anim, forKey: "opacity") } } + loadingBlurView.layer.zPosition = 999 if shimmerBorderLayer.superlayer == nil { loadingBlurView.contentView.layer.addSublayer(shimmerBorderLayer) } @@ -235,18 +237,20 @@ final class MediaStreamVideoComponent: Component { borderShimmer.updateHorizontal(background: .clear, foreground: .white) loadingBlurView.alpha = 1 } else { - if hadVideo { + if hadVideo && !isAnimating && loadingBlurView.layer.opacity == 1 { let anim = CABasicAnimation(keyPath: "opacity") - anim.duration = 0.5 - anim.fromValue = 1 - anim.toValue = 0 + anim.duration = 0.25 + anim.fromValue = 1.0 + anim.toValue = 0.0 + self.loadingBlurView.layer.opacity = 0 anim.fillMode = .forwards anim.isRemovedOnCompletion = false + isAnimating = true anim.completion = { [weak self] _ in guard self?.videoStalled == false else { return } self?.loadingBlurView.removeFromSuperview() self?.placeholderView.removeFromSuperview() - self?.loadingBlurView.layer.removeAllAnimations() + self?.isAnimating = false } loadingBlurView.layer.add(anim, forKey: "opacity") } @@ -302,7 +306,7 @@ final class MediaStreamVideoComponent: Component { } }) stallTimer = _stallTimer - + self.clipsToBounds = component.isFullscreen // or just true if let videoBlurView = self.videoRenderingContext.makeView(input: input, blur: true) { self.videoBlurView = videoBlurView self.insertSubview(videoBlurView, belowSubview: self.blurTintView) @@ -378,7 +382,6 @@ final class MediaStreamVideoComponent: Component { if #available(iOS 14.0, *) { pictureInPictureController?.requiresLinearPlayback = true } - self.pictureInPictureController = pictureInPictureController // } } @@ -451,7 +454,7 @@ final class MediaStreamVideoComponent: Component { videoSize = CGSize(width: aspect * 100.0, height: 100.0).aspectFitted(.init(width: availableVideoWidth, height: availableVideoHeight)) } - let blurredVideoSize = videoSize.aspectFilled(availableSize) + let blurredVideoSize = component.isFullscreen ? availableSize : videoSize.aspectFilled(availableSize) component.onVideoSizeRetrieved(videoSize) @@ -478,7 +481,10 @@ final class MediaStreamVideoComponent: Component { if let videoBlurView = self.videoBlurView { videoBlurView.updateIsEnabled(component.isVisible) if component.isFullscreen { - transition.withAnimation(.none).setFrame(view: videoBlurView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - blurredVideoSize.width) / 2.0), y: floor((availableSize.height - blurredVideoSize.height) / 2.0)), size: blurredVideoSize), completion: nil) + transition.withAnimation(.none).setFrame(view: videoBlurView, frame: CGRect( + origin: CGPoint(x: floor((availableSize.width - blurredVideoSize.width) / 2.0), y: floor((availableSize.height - blurredVideoSize.height) / 2.0)), + size: blurredVideoSize + ), completion: nil) } else { videoBlurView.frame = videoView.frame.insetBy(dx: -69 * aspect, dy: -69) } @@ -627,6 +633,7 @@ final class MediaStreamVideoComponent: Component { self.component?.pictureInPictureClosed() } // TODO: extract precise animation timing or observe window changes + // Handle minimized case separatelly (can we detect minimized?) DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { self.videoView?.alpha = 1 } diff --git a/submodules/TelegramCallsUI/Sources/GroupCallNavigationAccessoryPanel.swift b/submodules/TelegramCallsUI/Sources/GroupCallNavigationAccessoryPanel.swift index 07ca7617ec..f14d5bba7d 100644 --- a/submodules/TelegramCallsUI/Sources/GroupCallNavigationAccessoryPanel.swift +++ b/submodules/TelegramCallsUI/Sources/GroupCallNavigationAccessoryPanel.swift @@ -691,7 +691,9 @@ public final class GroupCallNavigationAccessoryPanel: ASDisplayNode { previewImageNode.cornerRadius = 8.0 previewImageNode.contentMode = .scaleAspectFill self.previewImageNode = previewImageNode + previewImageNode.alpha = 0 self.addSubnode(previewImageNode) + transition.updateAlpha(node: previewImageNode, alpha: 1) } previewImageNode.image = previewImage let previewSize = CGSize(width: 40.0, height: 40.0) From 4af39874279afc7a1f0132e858385b55125ff9d2 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Sun, 18 Dec 2022 21:44:32 +0400 Subject: [PATCH 37/50] Limiting title width, animating fullscreen transition, resetting offset on fullscreen --- .../Components/MediaStreamComponent.swift | 46 ++++++++++++------- .../MediaStreamVideoComponent.swift | 22 ++++++--- 2 files changed, 46 insertions(+), 22 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 53e0bf0b3e..99a888fe66 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -850,6 +850,7 @@ public final class MediaStreamComponent: CombinedComponent { // guard let _ = state.videoSize else { return } state.isFullscreen.toggle() if state.isFullscreen { + state.dismissOffset = 0.0 // if size.width > size.height { let currentOrientation = state.prevFullscreenOrientation ?? UIDevice.current.orientation switch currentOrientation { @@ -869,7 +870,7 @@ public final class MediaStreamComponent: CombinedComponent { controller.updateOrientation(orientation: .portrait) } if !canEnforceOrientation { - state.updated() + state.updated(transition: .easeInOut(duration: 0.25)) } } } @@ -943,7 +944,7 @@ public final class MediaStreamComponent: CombinedComponent { if canEnforceOrientation { controller.updateOrientation(orientation: .portrait) } else { - state.updated() // updated(.easeInOut(duration: 0.3)) + state.updated(transition: .easeInOut(duration: 0.25)) // updated(.easeInOut(duration: 0.3)) } } } @@ -1302,6 +1303,7 @@ final class StreamTitleComponent: Component { private let textView: ComponentHostView private var indicatorView: UIImageView? let liveIndicatorView = LiveIndicatorView() + let titleLabel = UILabel() private let trackingLayer: HierarchyTrackingLayer @@ -1312,7 +1314,8 @@ final class StreamTitleComponent: Component { super.init(frame: frame) - self.addSubview(self.textView) +// self.addSubview(self.textView) + self.addSubview(self.titleLabel) self.addSubview(self.liveIndicatorView) self.trackingLayer.didEnterHierarchy = { [weak self] in @@ -1343,16 +1346,26 @@ final class StreamTitleComponent: Component { } func update(component: StreamTitleComponent, availableSize: CGSize, transition: Transition) -> CGSize { - let textSize = self.textView.update( - transition: .immediate, - component: AnyComponent(Text( - text: component.text, - font: Font.semibold(17.0), - color: .white - )), - environment: {}, - containerSize: availableSize - ) + let liveIndicatorWidth: CGFloat = 40 + self.titleLabel.text = component.text + self.titleLabel.font = Font.semibold(17.0) + self.titleLabel.textColor = .white + self.titleLabel.textAlignment = .center + self.titleLabel.numberOfLines = 1 + self.titleLabel.invalidateIntrinsicContentSize() + + let textSize = CGSize(width: min(availableSize.width - 4 - liveIndicatorWidth, self.titleLabel.intrinsicContentSize.width), height: availableSize.height) + +// let textSize = self.textView.update( +// transition: .immediate, +// component: AnyComponent(Text( +// text: component.text, +// font: Font.semibold(17.0), +// color: .white +// )), +// environment: {}, +// containerSize: CGSize(width: availableSize.width - 4 - liveIndicatorWidth, height: availableSize.height) +// ) if component.isRecording { if self.indicatorView == nil { @@ -1371,9 +1384,10 @@ final class StreamTitleComponent: Component { let sideInset: CGFloat = 20.0 let size = CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height) let textFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) - self.textView.frame = textFrame +// self.textView.frame = textFrame + self.titleLabel.frame = textFrame - liveIndicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: floorToScreenPixels((size.height - textSize.height) / 2.0 - 2) + 1.0), size: .init(width: 40, height: 22)) + liveIndicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: /*floorToScreenPixels((size.height - textSize.height) / 2.0 - 2) + 1.0*/textFrame.midY - 22 / 2), size: .init(width: 40, height: 22)) self.liveIndicatorView.toggle(isLive: component.isActive) if let indicatorView = self.indicatorView, let image = indicatorView.image { @@ -1471,7 +1485,7 @@ private final class NavigationBarComponent: CombinedComponent { let centerItem = context.component.centerItem.flatMap { centerItemComponent in return centerItem.update( component: centerItemComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), + availableSize: CGSize(width: availableWidth - 44 - 44, height: contentHeight), transition: context.transition ) } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 535937b5e8..744c10ebad 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -147,7 +147,7 @@ final class MediaStreamVideoComponent: Component { private var isFullscreen: Bool = false private let videoLoadingThrottler = Throttler(duration: 1, queue: .main) - + private var wasFullscreen: Bool = false private weak var state: State? override init(frame: CGRect) { @@ -205,7 +205,7 @@ final class MediaStreamVideoComponent: Component { addSubview(loadingBlurView) if needsFadeInAnimation { let anim = CABasicAnimation(keyPath: "opacity") - anim.duration = 0.4 + anim.duration = 0.5 anim.fromValue = 0 anim.toValue = 1 loadingBlurView.layer.opacity = 1 @@ -239,7 +239,7 @@ final class MediaStreamVideoComponent: Component { } else { if hadVideo && !isAnimating && loadingBlurView.layer.opacity == 1 { let anim = CABasicAnimation(keyPath: "opacity") - anim.duration = 0.25 + anim.duration = 0.35 anim.fromValue = 1.0 anim.toValue = 0.0 self.loadingBlurView.layer.opacity = 0 @@ -476,17 +476,27 @@ final class MediaStreamVideoComponent: Component { videoView.clipsToBounds = true videoView.layer.cornerRadius = videoCornerRadius - transition.withAnimation(.none).setFrame(view: videoView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize), completion: nil) + let videoFrameUpdateTransition: Transition + if self.wasFullscreen != component.isFullscreen { + videoFrameUpdateTransition = transition + } else { + videoFrameUpdateTransition = transition.withAnimation(.none) + } + self.wasFullscreen = component.isFullscreen + + videoFrameUpdateTransition.setFrame(view: videoView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize), completion: nil) if let videoBlurView = self.videoBlurView { + videoBlurView.updateIsEnabled(component.isVisible) if component.isFullscreen { - transition.withAnimation(.none).setFrame(view: videoBlurView, frame: CGRect( + videoFrameUpdateTransition.setFrame(view: videoBlurView, frame: CGRect( origin: CGPoint(x: floor((availableSize.width - blurredVideoSize.width) / 2.0), y: floor((availableSize.height - blurredVideoSize.height) / 2.0)), size: blurredVideoSize ), completion: nil) } else { - videoBlurView.frame = videoView.frame.insetBy(dx: -69 * aspect, dy: -69) + videoFrameUpdateTransition.setFrame(view: videoBlurView, frame: videoView.frame.insetBy(dx: -69 * aspect, dy: -69)) +// videoBlurView.frame = videoView.frame.insetBy(dx: -69 * aspect, dy: -69) } if !component.isFullscreen { From 1424e7135d5aa62a7d75b8b99bcc0109ac232559 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Wed, 4 Jan 2023 22:07:05 +0400 Subject: [PATCH 38/50] Fixing streaming error message, dismiss animation, 1 member, stream title, context menu dismiss on recording, shimmer animation, adding animated counter to toolbar, --- .../Telegram-iOS/en.lproj/Localizable.strings | 2 +- .../ContextUI/Sources/ContextController.swift | 3 + .../ContextControllerActionsStackNode.swift | 15 +- .../ShimmerEffect/Sources/ShimmerEffect.swift | 24 +- .../Components/AnimatedCounterView.swift | 8 +- .../Components/MediaStreamComponent.swift | 293 +++++++++++++----- .../MediaStreamVideoComponent.swift | 18 +- .../Components/StreamSheetComponent.swift | 27 +- 8 files changed, 281 insertions(+), 109 deletions(-) diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 8c8b5d7293..075a3a5af3 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -5956,7 +5956,7 @@ Sorry for the inconvenience."; "LiveStream.RecordingInProgress" = "Live stream is being recorded"; "VoiceChat.StopRecordingTitle" = "Stop Recording?"; -"VoiceChat.StopRecordingStop" = "Stop"; +"VoiceChat.StopRecordingStop" = "Stop Recording"; "VoiceChat.RecordingSaved" = "Audio saved to **Saved Messages**."; diff --git a/submodules/ContextUI/Sources/ContextController.swift b/submodules/ContextUI/Sources/ContextController.swift index dd197c7e08..a70d214822 100644 --- a/submodules/ContextUI/Sources/ContextController.swift +++ b/submodules/ContextUI/Sources/ContextController.swift @@ -46,6 +46,9 @@ public enum ContextMenuActionItemTextColor { public enum ContextMenuActionResult { case `default` case dismissWithoutContent + /// Temporary + static var safeStreamRecordingDismissWithoutContent: ContextMenuActionResult { .dismissWithoutContent } + case custom(ContainedViewLayoutTransition) } diff --git a/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift b/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift index e1cb4444da..2d7528c7a4 100644 --- a/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift +++ b/submodules/ContextUI/Sources/ContextControllerActionsStackNode.swift @@ -403,16 +403,19 @@ private final class ContextControllerActionsListCustomItemNode: ASDisplayNode, C private let getController: () -> ContextControllerProtocol? private let item: ContextMenuCustomItem + private let requestDismiss: (ContextMenuActionResult) -> Void private var presentationData: PresentationData? private var itemNode: ContextMenuCustomNode? init( getController: @escaping () -> ContextControllerProtocol?, - item: ContextMenuCustomItem + item: ContextMenuCustomItem, + requestDismiss: @escaping (ContextMenuActionResult) -> Void ) { self.getController = getController self.item = item + self.requestDismiss = requestDismiss super.init() } @@ -433,7 +436,12 @@ private final class ContextControllerActionsListCustomItemNode: ASDisplayNode, C presentationData: presentationData, getController: self.getController, actionSelected: { result in - let _ = result + switch result { + case .dismissWithoutContent/* where ContextMenuActionResult.safeStreamRecordingDismissWithoutContent == .dismissWithoutContent*/: + self.requestDismiss(result) + + default: break + } } ) self.itemNode = itemNode @@ -505,7 +513,8 @@ final class ContextControllerActionsListStackItem: ContextControllerActionsStack return Item( node: ContextControllerActionsListCustomItemNode( getController: getController, - item: customItem + item: customItem, + requestDismiss: requestDismiss ), separatorNode: ASDisplayNode() ) diff --git a/submodules/ShimmerEffect/Sources/ShimmerEffect.swift b/submodules/ShimmerEffect/Sources/ShimmerEffect.swift index 1a35997e17..f094ffa613 100644 --- a/submodules/ShimmerEffect/Sources/ShimmerEffect.swift +++ b/submodules/ShimmerEffect/Sources/ShimmerEffect.swift @@ -500,7 +500,7 @@ public final class StandaloneShimmerEffect { let colorSpace = CGColorSpaceCreateDeviceRGB() guard let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations) else { return } - context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.0), end: CGPoint(x: size.width, y: 0.3), options: CGGradientDrawingOptions()) + context.drawLinearGradient(gradient, start: CGPoint(x: 0.0, y: 0.2), end: CGPoint(x: size.width, y: 0.8), options: CGGradientDrawingOptions()) }) self.updateHorizontalLayer() @@ -533,14 +533,28 @@ public final class StandaloneShimmerEffect { layer.contents = image.cgImage if layer.animation(forKey: "shimmer") == nil { + var delay: TimeInterval { 1.6 } let animation = CABasicAnimation(keyPath: "contentsRect.origin.x") - animation.fromValue = 1.0 as NSNumber - animation.toValue = -1.0 as NSNumber + animation.fromValue = NSNumber(floatLiteral: delay) + animation.toValue = NSNumber(floatLiteral: -delay) animation.isAdditive = true animation.repeatCount = .infinity - animation.duration = 0.8 - animation.beginTime = layer.convertTime(1.0, from: nil) + animation.duration = 0.8 * delay + animation.timingFunction = .init(name: .easeInEaseOut) +// animation.beginTime = layer.convertTime(1.0, from: nil) layer.add(animation, forKey: "shimmer") + /*let opacityAnimation = CAKeyframeAnimation(keyPath: "opacity") + opacityAnimation.values = [0.0, 1.0, 0.0] + opacityAnimation.keyTimes = [0, 0.5, 0] + opacityAnimation.calculationMode = .linear +// opacityAnimation.fromValue = 2.0 as NSNumber +// opacityAnimation.toValue = -2.0 as NSNumber +// opacityAnimation.isAdditive = true + opacityAnimation.repeatCount = .infinity + opacityAnimation.duration = 1.6 + opacityAnimation.timingFunctions = [.init(name: .easeInEaseOut)] +// opacityAnimation.beginTime = layer.convertTime(1.0, from: nil) + layer.add(opacityAnimation, forKey: "opacity")*/ } } } diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index 969baac579..10bc86ecb5 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -50,14 +50,14 @@ public final class AnimatedCountView: UIView { subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - 8 : bounds.height - 12, width: subtitleLabel.intrinsicContentSize.width + 20, height: 20) } - func update(countString: String, subtitle: String) { + func update(countString: String, subtitle: String, fontSize: CGFloat = 48.0) { self.setupGradientAnimations() let text: String = countString - self.countLabel.fontSize = 48 - self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: 48, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) + self.countLabel.fontSize = fontSize + self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: fontSize, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) - self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, attributes: [.font: UIFont.systemFont(ofSize: 16, weight: .semibold)]) + self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, attributes: [.font: UIFont.systemFont(ofSize: max(floor(fontSize / 3), 12), weight: .semibold)]) self.subtitleLabel.isHidden = subtitle.isEmpty } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 99a888fe66..a607300f84 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -170,7 +170,9 @@ public final class MediaStreamComponent: CombinedComponent { var updated = false // TODO: remove debug timer // Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in - strongSelf.infoThrottler.publish(members.totalCount/*Int.random(in: 0..<10000000)*/) { [weak strongSelf] latestCount in + var shouldReplaceNoViewersWithOne: Bool { true } + + strongSelf.infoThrottler.publish(shouldReplaceNoViewersWithOne ? max(members.totalCount, 1) : members.totalCount /*Int.random(in: 0..<10000000)*/) { [weak strongSelf] latestCount in // let _ = members.totalCount guard let strongSelf = strongSelf else { return } var updated = false @@ -411,7 +413,9 @@ public final class MediaStreamComponent: CombinedComponent { var navigationRightItems: [AnyComponentWithIdentity] = [] - if context.state.isPictureInPictureSupported, context.state.videoIsPlayable { +// let videoIsPlayable = context.state.videoIsPlayable + + if context.state.isPictureInPictureSupported /*, context.state.videoIsPlayable*/ { navigationRightItems.append(AnyComponentWithIdentity(id: "pip", component: AnyComponent(Button( content: AnyComponent(ZStack([ AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( @@ -420,7 +424,7 @@ public final class MediaStreamComponent: CombinedComponent { ))), AnyComponentWithIdentity(id: "a", component: AnyComponent(BundleIconComponent( name: "Call/pip", - tintColor: .white + tintColor: .white // .withAlphaComponent(context.state.videoIsPlayable ? 1.0 : 0.6) ))) ] )), @@ -435,6 +439,7 @@ public final class MediaStreamComponent: CombinedComponent { ).minSize(CGSize(width: 44.0, height: 44.0))))) } var topLeftButton: AnyComponent? + if context.state.canManageCall { let whiteColor = UIColor(white: 1.0, alpha: 1.0) topLeftButton = AnyComponent(Button( @@ -477,7 +482,7 @@ public final class MediaStreamComponent: CombinedComponent { items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.LiveStream_EditTitle, textColor: .primary, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Pencil"), color: theme.actionSheet.primaryTextColor) - }, action: { [weak call, weak controller, weak state] _, a in + }, action: { [weak call, weak controller, weak state] _, dismissWithResult in guard let call = call, let controller = controller, let state = state, let chatPeer = state.chatPeer else { return } @@ -507,12 +512,11 @@ public final class MediaStreamComponent: CombinedComponent { }) controller.present(editController, in: .window(.root)) - a(.default) + dismissWithResult(.default) }))) if let recordingStartTimestamp = state.recordingStartTimestamp { - items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { [weak call, weak controller] _, f in - f(.dismissWithoutContent) + items.append(.custom(VoiceChatRecordingContextItem(timestamp: recordingStartTimestamp, action: { [weak call, weak controller] _, dismissWithResult in guard let call = call, let controller = controller else { return @@ -547,6 +551,8 @@ public final class MediaStreamComponent: CombinedComponent { })*/ })]) controller.present(alertController, in: .window(.root)) + // TODO: спросить про dismissWithoutContent и default + dismissWithResult(.dismissWithoutContent) }), false)) } else { let text = presentationData.strings.LiveStream_StartRecording @@ -605,14 +611,34 @@ public final class MediaStreamComponent: CombinedComponent { a(.default) }))) - items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.VoiceChat_StopRecordingStop, textColor: .destructive, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in + items.append(.action(ContextMenuActionItem(id: nil, text: /*presentationData.strings.VoiceChat_StopRecordingStop*/"Stop Live Stream", textColor: .destructive, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.contextMenu.destructiveColor, backgroundColor: nil) }, action: { [weak call] _, a in guard let call = call else { return } - - let _ = call.leave(terminateIfPossible: true).start() + let alertController = textAlertController( + context: call.accountContext, + forceTheme: defaultDarkPresentationTheme, + title: nil, + text: presentationData.strings.VoiceChat_StopRecordingTitle, + actions: [ + TextAlertAction( + type: .genericAction, + title: presentationData.strings.Common_Cancel, + action: {} + ), + TextAlertAction( + type: .defaultAction, + title: presentationData.strings.VoiceChat_StopRecordingStop, + action: { [weak call] in + guard let call = call else { + return + } + let _ = call.leave(terminateIfPossible: true).start() + }) + ]) + controller.present(alertController, in: .window(.root)) a(.default) }))) @@ -669,9 +695,10 @@ public final class MediaStreamComponent: CombinedComponent { let navigationComponent = NavigationBarComponent( topInset: environment.statusBarHeight, sideInset: environment.safeInsets.left, + backgroundVisible: isFullscreen, leftItem: topLeftButton, rightItems: navigationRightItems, - centerItem: AnyComponent(StreamTitleComponent(text: state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isActive: context.state.videoIsPlayable)) + centerItem: AnyComponent(StreamTitleComponent(text: state.callTitle ?? state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isActive: context.state.videoIsPlayable)) ) if context.state.storedIsFullscreen != isFullscreen { @@ -685,15 +712,8 @@ public final class MediaStreamComponent: CombinedComponent { var infoItem: AnyComponent? if let originInfo = context.state.originInfo { - let memberCountString: String - if originInfo.memberCount == 0 { - memberCountString = environment.strings.LiveStream_NoViewers - } else { - memberCountString = environment.strings.LiveStream_ViewerCount(Int32(originInfo.memberCount)) - } infoItem = AnyComponent(OriginInfoComponent( - title: state.callTitle ?? originInfo.title, - subtitle: memberCountString + memberCount: originInfo.memberCount )) } let availableSize = context.availableSize @@ -723,7 +743,13 @@ public final class MediaStreamComponent: CombinedComponent { if isFullyDragged || state.initialOffset != 0 { state.updateDismissOffset(value: 0.0, interactive: false) } else { - let _ = call.leave(terminateIfPossible: false) + activatePictureInPicture.invoke(Action { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.dismiss(closing: false, manual: true) + }) +// let _ = call.leave(terminateIfPossible: false) } } } else { @@ -757,7 +783,11 @@ public final class MediaStreamComponent: CombinedComponent { context.add(dismissTapComponent .position(CGPoint(x: context.availableSize.width / 2, y: dismissTapAreaHeight / 2)) .gesture(.tap { - _ = call.leave(terminateIfPossible: false) + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.dismiss(closing: false, manual: true) + // _ = call.leave(terminateIfPossible: false) }) .gesture(.pan(onPanGesture)) ) @@ -955,10 +985,10 @@ public final class MediaStreamComponent: CombinedComponent { component: StreamSheetComponent( topComponent: AnyComponent(navigationComponent), bottomButtonsRow: fullScreenToolbarComponent, - topOffset: context.availableSize.height - sheetHeight + context.state.dismissOffset, - sheetHeight: max(sheetHeight - context.state.dismissOffset, sheetHeight), - backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), - bottomPadding: 12, + topOffset: /*context.availableSize.height - sheetHeight +*/ max(context.state.dismissOffset, 0), + sheetHeight: context.availableSize.height,// max(sheetHeight - context.state.dismissOffset, sheetHeight), + backgroundColor: /*isFullscreen ? .clear : */ (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), + bottomPadding: 0, participantsCount: -1, isFullyExtended: isFullyDragged, deviceCornerRadius: ((controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 1) - 1, @@ -1053,13 +1083,15 @@ public final class MediaStreamComponentController: ViewControllerComponentContai self.view.clipsToBounds = false } + override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) } override public func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - backgroundDimView.frame = .init(x: 0, y: -view.bounds.height * 3, width: view.bounds.width, height: view.bounds.height * 4) + let dimViewSide: CGFloat = max(view.bounds.width, view.bounds.height) + backgroundDimView.frame = .init(x: view.bounds.midX - dimViewSide / 2, y: -view.bounds.height * 3, width: dimViewSide, height: view.bounds.height * 4) } public func dismiss(closing: Bool, manual: Bool) { @@ -1070,7 +1102,11 @@ public final class MediaStreamComponentController: ViewControllerComponentContai override public func dismiss(completion: (() -> Void)? = nil) { self.view.layer.allowsGroupOpacity = true - self.view.layer.animateAlpha(from: 1.0, to: 1.0, duration: 0.25, removeOnCompletion: false, completion: { [weak self] _ in +// self.view.layer.animateAlpha(from: 1.0, to: 1.0, duration: 0.4, removeOnCompletion: false, completion: { [weak self] _ in +// +// }) + self.backgroundDimView.layer.animateAlpha(from: 1.0, to: 0, duration: 0.3, removeOnCompletion: false) + self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 0.4, removeOnCompletion: false, completion: { [weak self] _ in guard let strongSelf = self else { completion?() return @@ -1078,9 +1114,6 @@ public final class MediaStreamComponentController: ViewControllerComponentContai strongSelf.view.layer.allowsGroupOpacity = false strongSelf.dismissImpl(completion: completion) }) - self.backgroundDimView.layer.animateAlpha(from: 1.0, to: 0, duration: 0.3, removeOnCompletion: false) - self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 0.4, completion: { _ in - }) } private func dismissImpl(completion: (() -> Void)? = nil) { @@ -1272,7 +1305,7 @@ final class StreamTitleComponent: Component { if !wasLive { wasLive = true let anim = CAKeyframeAnimation(keyPath: "transform.scale") - anim.values = [1.0, 1.4, 0.9, 1.0] + anim.values = [1.0, 1.12, 0.9, 1.0] anim.keyTimes = [0, 0.5, 0.8, 1] anim.duration = 0.4 self.layer.add(anim, forKey: "transform") @@ -1281,7 +1314,7 @@ final class StreamTitleComponent: Component { self.toggle(isLive: true) }) return } - self.backgroundColor = UIColor(red: 0.82, green: 0.26, blue: 0.37, alpha: 1) + self.backgroundColor = UIColor(red: 1, green: 0.176, blue: 0.333, alpha: 1) stalledAnimatedGradient.opacity = 0 stalledAnimatedGradient.removeAllAnimations() } else { @@ -1300,21 +1333,87 @@ final class StreamTitleComponent: Component { } public final class View: UIView { - private let textView: ComponentHostView private var indicatorView: UIImageView? let liveIndicatorView = LiveIndicatorView() let titleLabel = UILabel() + private let titleFadeLayer = CALayer() + private let trackingLayer: HierarchyTrackingLayer - override init(frame: CGRect) { - self.textView = ComponentHostView() + private func updateTitleFadeLayer(textFrame: CGRect) { + // titleLabel.backgroundColor = .red + guard let string = titleLabel.attributedText, + string.boundingRect(with: .init(width: .max, height: .max), context: nil).width > textFrame.width + else { + titleLabel.layer.mask = nil + titleLabel.frame = textFrame + self.titleLabel.textAlignment = .center + return + } + + var isRTL: Bool = false + if let string = titleLabel.attributedText { + let coreTextLine = CTLineCreateWithAttributedString(string) + let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray + if glyphRuns.count > 0 { + let run = glyphRuns[0] as! CTRun + if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) { + isRTL = true + } + } + } + let gradientInset: CGFloat = 0 + let gradientRadius: CGFloat = 50 + + let solidPartLayer = CALayer() + solidPartLayer.backgroundColor = UIColor.black.cgColor + + let containerWidth: CGFloat = textFrame.width + let availableWidth: CGFloat = textFrame.width - gradientRadius + + let extraSpace: CGFloat = 100 + if isRTL { + let adjustForRTL: CGFloat = 12 + + let safeSolidWidth: CGFloat = containerWidth + adjustForRTL + solidPartLayer.frame = CGRect( + origin: CGPoint(x: max(containerWidth - availableWidth, gradientRadius), y: 0), + size: CGSize(width: safeSolidWidth, height: textFrame.height)) + titleLabel.frame = CGRect(x: textFrame.minX - extraSpace, y: textFrame.minY, width: textFrame.width + extraSpace, height: textFrame.height) + } else { + solidPartLayer.frame = CGRect( + origin: .zero, + size: CGSize(width: availableWidth, height: textFrame.height)) + titleLabel.frame = CGRect(origin: textFrame.origin, size: CGSize(width: textFrame.width + extraSpace, height: textFrame.height)) + } + self.titleLabel.textAlignment = .natural + titleFadeLayer.addSublayer(solidPartLayer) + + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [UIColor.black.cgColor, UIColor.clear.cgColor] + if isRTL { + gradientLayer.startPoint = CGPoint(x: 1, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.frame = CGRect(x: solidPartLayer.frame.minX - gradientRadius, y: 0, width: gradientRadius, height: textFrame.height) + } else { + gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + gradientLayer.frame = CGRect(x: availableWidth + gradientInset, y: 0, width: gradientRadius, height: textFrame.height) + } + titleFadeLayer.addSublayer(gradientLayer) + titleFadeLayer.masksToBounds = false + + titleFadeLayer.frame = titleLabel.bounds + titleLabel.layer.mask = titleFadeLayer + } + + override init(frame: CGRect) { self.trackingLayer = HierarchyTrackingLayer() super.init(frame: frame) -// self.addSubview(self.textView) self.addSubview(self.titleLabel) self.addSubview(self.liveIndicatorView) @@ -1350,7 +1449,6 @@ final class StreamTitleComponent: Component { self.titleLabel.text = component.text self.titleLabel.font = Font.semibold(17.0) self.titleLabel.textColor = .white - self.titleLabel.textAlignment = .center self.titleLabel.numberOfLines = 1 self.titleLabel.invalidateIntrinsicContentSize() @@ -1385,7 +1483,7 @@ final class StreamTitleComponent: Component { let size = CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height) let textFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) // self.textView.frame = textFrame - self.titleLabel.frame = textFrame + self.updateTitleFadeLayer(textFrame: textFrame) liveIndicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: /*floorToScreenPixels((size.height - textSize.height) / 2.0 - 2) + 1.0*/textFrame.midY - 22 / 2), size: .init(width: 40, height: 22)) self.liveIndicatorView.toggle(isLive: component.isActive) @@ -1413,16 +1511,20 @@ private final class NavigationBarComponent: CombinedComponent { let leftItem: AnyComponent? let rightItems: [AnyComponentWithIdentity] let centerItem: AnyComponent? + let backgroundVisible: Bool init( topInset: CGFloat, sideInset: CGFloat, + backgroundVisible: Bool, leftItem: AnyComponent?, rightItems: [AnyComponentWithIdentity], centerItem: AnyComponent? ) { self.topInset = 0 // topInset self.sideInset = sideInset + self.backgroundVisible = backgroundVisible + self.leftItem = leftItem self.rightItems = rightItems self.centerItem = centerItem @@ -1449,6 +1551,7 @@ private final class NavigationBarComponent: CombinedComponent { } static var body: Body { + let background = Child(Rectangle.self) let leftItem = Child(environment: Empty.self) let rightItems = ChildMap(environment: Empty.self, keyedBy: AnyHashable.self) let centerItem = Child(environment: Empty.self) @@ -1460,6 +1563,8 @@ private final class NavigationBarComponent: CombinedComponent { let contentHeight: CGFloat = 44.0 let size = CGSize(width: context.availableSize.width, height: context.component.topInset + contentHeight) + let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: context.component.backgroundVisible ? 0.5 : 0)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) + let leftItem = context.component.leftItem.flatMap { leftItemComponent in return leftItem.update( component: leftItemComponent, @@ -1493,6 +1598,10 @@ private final class NavigationBarComponent: CombinedComponent { availableWidth -= centerItem.size.width } + context.add(background + .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) + ) + var centerLeftInset = sideInset if let leftItem = leftItem { context.add(leftItem @@ -1522,22 +1631,18 @@ private final class NavigationBarComponent: CombinedComponent { } private final class OriginInfoComponent: CombinedComponent { - let title: String - let subtitle: String + let participantsCount: Int + + private static var usingAnimatedCounter: Bool { true } init( - title: String, - subtitle: String + memberCount: Int ) { - self.title = title - self.subtitle = subtitle + self.participantsCount = memberCount } static func ==(lhs: OriginInfoComponent, rhs: OriginInfoComponent) -> Bool { - if lhs.title != rhs.title { - return false - } - if lhs.subtitle != rhs.subtitle { + if lhs.participantsCount != rhs.participantsCount { return false } @@ -1545,38 +1650,63 @@ private final class OriginInfoComponent: CombinedComponent { } static var body: Body { - let title = Child(Text.self) - let subtitle = Child(Text.self) - - return { context in - let spacing: CGFloat = 0.0 + if usingAnimatedCounter { + let viewerCounter = Child(ParticipantsComponent.self) - let title = title.update( - component: Text( - text: context.component.title, font: Font.semibold(17.0), color: .white), - availableSize: context.availableSize, - transition: context.transition - ) + return { context in +// let spacing: CGFloat = 0.0 + + let viewerCounter = viewerCounter.update( + component: ParticipantsComponent( + count: context.component.participantsCount, + showsSubtitle: true, + fontSize: 24 + ), + availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), + transition: context.transition + ) + + var size = CGSize(width: viewerCounter.size.width, height: viewerCounter.size.height) + size.width = min(size.width, context.availableSize.width) + size.height = min(size.height, context.availableSize.height) + + context.add(viewerCounter + .position(CGPoint(x: size.width / 2.0, y: viewerCounter.size.height / 2.0)) + ) + + return size + } + } else { + let subtitle = Child(Text.self) - let subtitle = subtitle.update( - component: Text( - text: context.component.subtitle, font: Font.regular(14.0), color: .white), - availableSize: context.availableSize, - transition: context.transition - ) - - var size = CGSize(width: max(title.size.width, subtitle.size.width), height: title.size.height + spacing + subtitle.size.height) - size.width = min(size.width, context.availableSize.width) - size.height = min(size.height, context.availableSize.height) - - context.add(title - .position(CGPoint(x: size.width / 2.0, y: title.size.height / 2.0)) - ) - context.add(subtitle - .position(CGPoint(x: size.width / 2.0, y: title.size.height + spacing + subtitle.size.height / 2.0)) - ) - - return size + return { context in +// let spacing: CGFloat = 0.0 + + let memberCount = context.component.participantsCount + let memberCountString: String + if memberCount == 0 { + memberCountString = "no viewers" + } else { + memberCountString = memberCount > 0 ? presentationStringsFormattedNumber(Int32(memberCount), ",") : "" + } + + let subtitle = subtitle.update( + component: Text( + text: memberCountString, font: Font.regular(14.0), color: .white), + availableSize: context.availableSize, + transition: context.transition + ) + + var size = CGSize(width: subtitle.size.width, height: subtitle.size.height) + size.width = min(size.width, context.availableSize.width) + size.height = min(size.height, context.availableSize.height) + + context.add(subtitle + .position(CGPoint(x: size.width / 2.0, y: subtitle.size.height / 2.0)) + ) + + return size + } } } } @@ -1635,7 +1765,7 @@ private final class ToolbarComponent: CombinedComponent { let contentHeight: CGFloat = 44.0 let size = CGSize(width: context.availableSize.width, height: contentHeight + context.component.bottomInset) - let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: 0)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) + let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) let leftItem = context.component.leftItem.flatMap { leftItemComponent in return leftItem.update( @@ -1659,10 +1789,11 @@ private final class ToolbarComponent: CombinedComponent { availableWidth -= rightItem.size.width } + let temporaryOffsetForSmallerSubtitle: CGFloat = 12 let centerItem = context.component.centerItem.flatMap { centerItemComponent in return centerItem.update( component: centerItemComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), + availableSize: CGSize(width: availableWidth, height: contentHeight - temporaryOffsetForSmallerSubtitle / 2), transition: context.transition ) } @@ -1693,7 +1824,7 @@ private final class ToolbarComponent: CombinedComponent { let maxCenterInset = max(centerLeftInset, centerRightInset) if let centerItem = centerItem { context.add(centerItem - .position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: contentHeight / 2.0)) + .position(CGPoint(x: maxCenterInset + (context.availableSize.width - maxCenterInset - maxCenterInset) / 2.0, y: contentHeight / 2.0 - temporaryOffsetForSmallerSubtitle)) ) } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 744c10ebad..9b4195c509 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -214,7 +214,8 @@ final class MediaStreamVideoComponent: Component { loadingBlurView.layer.add(anim, forKey: "opacity") } } - loadingBlurView.layer.zPosition = 999 + loadingBlurView.layer.zPosition = 998 + self.noSignalView?.layer.zPosition = loadingBlurView.layer.zPosition + 1 if shimmerBorderLayer.superlayer == nil { loadingBlurView.contentView.layer.addSublayer(shimmerBorderLayer) } @@ -230,9 +231,10 @@ final class MediaStreamVideoComponent: Component { borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor borderMask.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor borderMask.lineWidth = 3 + borderMask.compositingFilter = "softLightBlendMode" shimmerBorderLayer.mask = borderMask - borderShimmer = .init() + borderShimmer = StandaloneShimmerEffect() borderShimmer.layer = shimmerBorderLayer borderShimmer.updateHorizontal(background: .clear, foreground: .white) loadingBlurView.alpha = 1 @@ -314,7 +316,6 @@ final class MediaStreamVideoComponent: Component { UIView.animate(withDuration: 0.3) { videoBlurView.alpha = 1 } - self.maskGradientLayer.type = .radial self.maskGradientLayer.colors = [UIColor(rgb: 0x000000, alpha: 0.5).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor] self.maskGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5) @@ -409,13 +410,18 @@ final class MediaStreamVideoComponent: Component { } else if component.isFullscreen { if fullScreenBackgroundPlaceholder.superview == nil { insertSubview(fullScreenBackgroundPlaceholder, at: 0) + transition.animateAlpha(view: fullScreenBackgroundPlaceholder, from: 0, to: 1) } fullScreenBackgroundPlaceholder.backgroundColor = UIColor.black.withAlphaComponent(0.5) } else { - fullScreenBackgroundPlaceholder.removeFromSuperview() + transition.animateAlpha(view: fullScreenBackgroundPlaceholder, from: 1, to: 0, completion: { didComplete in + if didComplete { + self.fullScreenBackgroundPlaceholder.removeFromSuperview() + } + }) } fullScreenBackgroundPlaceholder.frame = .init(origin: .zero, size: availableSize) - +// fullScreenBackgroundPlaceholder.isHidden = true let videoInset: CGFloat if !component.isFullscreen { videoInset = 16 @@ -556,6 +562,8 @@ final class MediaStreamVideoComponent: Component { self.noSignalView = noSignalView // TODO: above blurred animation self.addSubview(noSignalView) + noSignalView.layer.zPosition = loadingBlurView.layer.zPosition + 1 + noSignalView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3) } diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 3e248c8da6..af82c8bb55 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -95,12 +95,12 @@ final class StreamSheetComponent: CombinedComponent { override func draw(_ rect: CGRect) { super.draw(rect) // Debug interactive area -// guard let context = UIGraphicsGetCurrentContext() else { return } -// context.setFillColor(UIColor.red.cgColor) -// overlayComponentsFrames.forEach { frame in -// context.addRect(frame) -// context.fillPath() -// } + guard let context = UIGraphicsGetCurrentContext() else { return } + context.setFillColor(UIColor.red.withAlphaComponent(0.3).cgColor) + overlayComponentsFrames.forEach { frame in + context.addRect(frame) + context.fillPath() + } } } @@ -172,6 +172,8 @@ final class StreamSheetComponent: CombinedComponent { transition: context.transition ) } + // TODO: replace + let isFullscreen = context.component.participantsCount == -1 context.add(background .position(CGPoint(x: size.width / 2.0, y: topOffset + context.component.sheetHeight / 2)) @@ -182,7 +184,7 @@ final class StreamSheetComponent: CombinedComponent { if let topItem = topItem { context.add(topItem - .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + 32)) + .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + (isFullscreen ? topItem.size.height / 2.0 : 32))) ) (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: topOffset, width: topItem.size.width, height: topItem.size.height)) } @@ -297,16 +299,21 @@ final class ParticipantsComponent: Component { func update(view: View, availableSize: CGSize, state: ComponentFlow.EmptyComponentState, environment: ComponentFlow.Environment, transition: ComponentFlow.Transition) -> CGSize { view.counter.update( - countString: count > 0 ? presentationStringsFormattedNumber(Int32(count), ",") : "", - subtitle: count > 0 ? "watching" : "no viewers" + countString: self.count > 0 ? presentationStringsFormattedNumber(Int32(count), ",") : "", + subtitle: self.showsSubtitle ? (self.count > 0 ? "watching" : "no viewers") : "", + fontSize: self.fontSize )// environment.strings.LiveStream_NoViewers) return availableSize } private let count: Int + private let showsSubtitle: Bool + private let fontSize: CGFloat - init(count: Int) { + init(count: Int, showsSubtitle: Bool = true, fontSize: CGFloat = 48) { self.count = count + self.showsSubtitle = showsSubtitle + self.fontSize = fontSize } final class View: UIView { From c8ab1b89711b76a22940d1244f097d3b5f5680d7 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Thu, 5 Jan 2023 04:13:26 +0400 Subject: [PATCH 39/50] Smooth fullscreen transitions and tweaking PiP placeholder visibility --- .../Source/Base/CombinedComponent.swift | 26 ++- .../Components/MediaStreamComponent.swift | 198 ++++++++++++++---- .../MediaStreamVideoComponent.swift | 131 +++++++++--- .../Components/StreamSheetComponent.swift | 32 ++- 4 files changed, 314 insertions(+), 73 deletions(-) diff --git a/submodules/ComponentFlow/Source/Base/CombinedComponent.swift b/submodules/ComponentFlow/Source/Base/CombinedComponent.swift index df3d4fae55..4873b14caf 100644 --- a/submodules/ComponentFlow/Source/Base/CombinedComponent.swift +++ b/submodules/ComponentFlow/Source/Base/CombinedComponent.swift @@ -180,7 +180,15 @@ public final class _UpdatedChildComponent { var _opacity: CGFloat? var _cornerRadius: CGFloat? var _clipsToBounds: Bool? - + /// Quick animation addition + var _animations: [AnimationKey: AnimationType] = [:] + + public typealias AnimationKey = String + public enum AnimationType { + case transition + case custom(duration: TimeInterval, curveAndOtherParams: Any) + } + fileprivate var transitionAppear: Transition.Appear? fileprivate var transitionAppearWithGuide: (Transition.AppearWithGuide, _AnyChildComponent.Id)? fileprivate var transitionDisappear: Transition.Disappear? @@ -240,6 +248,11 @@ public final class _UpdatedChildComponent { self._position = position return self } + + @discardableResult public func animation(key: AnimationKey) -> _UpdatedChildComponent { + self._animations[key] = .transition + return self + } @discardableResult public func scale(_ scale: CGFloat) -> _UpdatedChildComponent { self._scale = scale @@ -695,6 +708,8 @@ public extension CombinedComponent { view.insertSubview(updatedChild.view, at: index) + let currentPosition = updatedChild.view.center + if let scale = updatedChild._scale { updatedChild.view.bounds = CGRect(origin: CGPoint(), size: updatedChild.size) updatedChild.view.center = updatedChild._position ?? CGPoint() @@ -702,6 +717,15 @@ public extension CombinedComponent { } else { updatedChild.view.frame = updatedChild.size.centered(around: updatedChild._position ?? CGPoint()) } +// for animation in updatedChild._animations { } + + if updatedChild._animations["position"] != nil, let position = updatedChild._position { + transition.animatePosition(view: updatedChild.view, from: currentPosition, to: position) + } + if updatedChild._animations["opacity"] != nil, let opacity = updatedChild._opacity { + transition.animateAlpha(view: updatedChild.view, from: updatedChild.view.alpha, to: opacity) + } + updatedChild.view.alpha = updatedChild._opacity ?? 1.0 updatedChild.view.clipsToBounds = updatedChild._clipsToBounds ?? false updatedChild.view.layer.cornerRadius = updatedChild._cornerRadius ?? 0.0 diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index a607300f84..862d7835d4 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -283,7 +283,11 @@ public final class MediaStreamComponent: CombinedComponent { let dismissTapComponent = Child(Rectangle.self) let video = Child(MediaStreamVideoComponent.self) let sheet = Child(StreamSheetComponent.self) - let fullscreenOverlay = Child(StreamSheetComponent.self) +// let fullscreenOverlay = Child(StreamSheetComponent.self) + let topItem = Child(environment: Empty.self) +// let viewerCounter = Child(ParticipantsComponent.self) + let fullscreenBottomItem = Child(environment: Empty.self) + let buttonsRow = Child(environment: Empty.self) let activatePictureInPicture = StoredActionSlot(Action.self) let deactivatePictureInPicture = StoredActionSlot(Void.self) @@ -346,7 +350,7 @@ public final class MediaStreamComponent: CombinedComponent { ? (context.availableSize.width - videoInset * 2) / 16 * 9 : context.state.videoSize?.height ?? (min(context.availableSize.width, context.availableSize.height) - videoInset * 2) / 16 * 9 let bottomPadding = 40 + environment.safeInsets.bottom - let sheetHeight: CGFloat = isFullscreen + let requiredSheetHeight: CGFloat = isFullscreen ? context.availableSize.height : (44 + videoHeight + 40 + 69 + 16 + 32 + 70 + bottomPadding) @@ -357,14 +361,14 @@ public final class MediaStreamComponent: CombinedComponent { safeAreaTopInView = context.view.safeAreaInsets.top } - let isFullyDragged = context.availableSize.height - sheetHeight + state.dismissOffset - safeAreaTopInView < 30 + let isFullyDragged = context.availableSize.height - requiredSheetHeight + state.dismissOffset - safeAreaTopInView < 30 var dragOffset = context.state.dismissOffset if isFullyDragged { - dragOffset = max(context.state.dismissOffset, sheetHeight - context.availableSize.height + safeAreaTopInView) + dragOffset = max(context.state.dismissOffset, requiredSheetHeight - context.availableSize.height + safeAreaTopInView) } - let dismissTapAreaHeight = isFullscreen ? 0 : (context.availableSize.height - sheetHeight + dragOffset) + let dismissTapAreaHeight = isFullscreen ? 0 : (context.availableSize.height - requiredSheetHeight + dragOffset) let dismissTapComponent = dismissTapComponent.update( component: Rectangle(color: .red.withAlphaComponent(0)), availableSize: CGSize(width: context.availableSize.width, height: dismissTapAreaHeight), @@ -742,23 +746,31 @@ public final class MediaStreamComponent: CombinedComponent { } else { if isFullyDragged || state.initialOffset != 0 { state.updateDismissOffset(value: 0.0, interactive: false) + state.updateDismissOffset(value: 0.0, interactive: false) } else { - activatePictureInPicture.invoke(Action { + if state.isPictureInPictureSupported { + activatePictureInPicture.invoke(Action { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.dismiss(closing: false, manual: true) + }) + } else { guard let controller = controller() as? MediaStreamComponentController else { return } controller.dismiss(closing: false, manual: true) - }) + } // let _ = call.leave(terminateIfPossible: false) } } } else { if isFullyDragged { - state.updateDismissOffset(value: sheetHeight - availableSize.height + safeAreaTop, interactive: false) + state.updateDismissOffset(value: requiredSheetHeight - availableSize.height + safeAreaTop, interactive: false) } else { if velocity.y < -200 { // Expand - state.updateDismissOffset(value: sheetHeight - availableSize.height + safeAreaTop, interactive: false) + state.updateDismissOffset(value: requiredSheetHeight - availableSize.height + safeAreaTop, interactive: false) } else { state.updateDismissOffset(value: 0.0, interactive: false) } @@ -792,7 +804,7 @@ public final class MediaStreamComponent: CombinedComponent { .gesture(.pan(onPanGesture)) ) - if !isFullscreen { + if !isFullscreen || state.isFullscreen { let imageRenderScale = UIScreen.main.scale let bottomComponent = AnyComponent(ButtonsRowComponent( bottomInset: environment.safeInsets.bottom, @@ -872,10 +884,10 @@ public final class MediaStreamComponent: CombinedComponent { )), action: { [weak state] in guard let state = state else { return } - guard state.videoIsPlayable else { - state.isFullscreen = false - return - } +// guard state.videoIsPlayable else { +// state.isFullscreen = false +// return +// } if let controller = controller() as? MediaStreamComponentController { // guard let _ = state.videoSize else { return } state.isFullscreen.toggle() @@ -907,25 +919,33 @@ public final class MediaStreamComponent: CombinedComponent { ).minSize(CGSize(width: 44.0, height: 44.0))) )) + let sheetHeight: CGFloat = max(requiredSheetHeight - dragOffset, requiredSheetHeight) + let topOffset: CGFloat = isFullscreen + ? max(context.state.dismissOffset, 0) + : (context.availableSize.height - requiredSheetHeight + dragOffset) + let sheet = sheet.update( component: StreamSheetComponent( topComponent: AnyComponent(navigationComponent), bottomButtonsRow: bottomComponent, - topOffset: context.availableSize.height - sheetHeight + dragOffset, - sheetHeight: max(sheetHeight - dragOffset, sheetHeight), + topOffset: topOffset, + sheetHeight: sheetHeight, backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), bottomPadding: bottomPadding, participantsCount: context.state.originInfo?.memberCount ?? 0, // Int.random(in: 0...999998)// [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! isFullyExtended: isFullyDragged, deviceCornerRadius: ((controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 1) - 1, - videoHeight: videoHeight + videoHeight: videoHeight, + isFullscreen: isFullscreen, + fullscreenTopComponent: AnyComponent(navigationComponent), + fullscreenBottomComponent: bottomComponent ), availableSize: context.availableSize, transition: context.transition ) - let sheetOffset: CGFloat = context.availableSize.height - sheetHeight + dragOffset - let sheetPosition = sheetOffset + sheetHeight / 2 + let sheetOffset: CGFloat = context.availableSize.height - requiredSheetHeight + dragOffset + let sheetPosition = sheetOffset + requiredSheetHeight / 2 // Sheet underneath the video when in modal sheet context.add(sheet .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) @@ -935,18 +955,26 @@ public final class MediaStreamComponent: CombinedComponent { if isFullscreen { videoPos = context.availableSize.height / 2 + dragOffset } else { - videoPos = sheetPosition - sheetHeight / 2 + videoHeight / 2 + 50 + 12 + videoPos = sheetPosition - requiredSheetHeight / 2 + videoHeight / 2 + 50 + 12 } context.add(video .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)) ) - } else { - context.add(video - .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2 + dragOffset) - )) - } - - if isFullscreen { + + // + // + // + var availableWidth: CGFloat { context.availableSize.width } + var contentHeight: CGFloat { 44.0 } +// print(topItem) + // let size = context.availableSize + + let topItem = topItem.update( + component: AnyComponent(navigationComponent), + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + let fullScreenToolbarComponent = AnyComponent(ToolbarComponent( bottomInset: environment.safeInsets.bottom, sideInset: environment.safeInsets.left, @@ -981,26 +1009,102 @@ public final class MediaStreamComponent: CombinedComponent { ).minSize(CGSize(width: 64.0, height: 80)))/* : nil*/, centerItem: infoItem )) - let fullScreenOverlayComponent = fullscreenOverlay.update( + + let buttonsRow = buttonsRow.update( + component: bottomComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + + let fullscreenBottomItem = fullscreenBottomItem.update( + component: fullScreenToolbarComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + + context.add(topItem + .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + (isFullscreen ? topItem.size.height / 2.0 : 32))) + .opacity((!isFullscreen || state.displayUI) ? 1 : 0) +// .animation(key: "position") + ) + + context.add(buttonsRow + .opacity(isFullscreen ? 0 : 1) +// .animation(key: "opacity") + .position(CGPoint(x: buttonsRow.size.width / 2, y: sheetHeight - 50 / 2 + topOffset - bottomPadding)) + ) + + context.add(fullscreenBottomItem + .opacity((isFullscreen && state.displayUI) ? 1 : 0) +// .animation(key: "opacity") + .position(CGPoint(x: fullscreenBottomItem.size.width / 2, y: context.availableSize.height - fullscreenBottomItem.size.height / 2 + topOffset - 0.0)) + ) + // + // + // + } else { + let fullScreenToolbarComponent = AnyComponent(ToolbarComponent( + bottomInset: environment.safeInsets.bottom, + sideInset: environment.safeInsets.left, + leftItem: AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: "Chat/Input/Accessory Panels/MessageSelectionForward", + tintColor: .white + )), + action: { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + controller.presentShare() + } + ).minSize(CGSize(width: 64.0, height: 80))), + rightItem: /*state.hasVideo ?*/ AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: isFullscreen ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", + tintColor: .white + )), + action: { + state.isFullscreen = false + state.prevFullscreenOrientation = UIDevice.current.orientation + if let controller = controller() as? MediaStreamComponentController { + if canEnforceOrientation { + controller.updateOrientation(orientation: .portrait) + } else { + state.updated(transition: .easeInOut(duration: 0.25)) // updated(.easeInOut(duration: 0.3)) + } + } + } + ).minSize(CGSize(width: 64.0, height: 80)))/* : nil*/, + centerItem: infoItem + )) + let fullScreenOverlayComponent = sheet.update( component: StreamSheetComponent( topComponent: AnyComponent(navigationComponent), bottomButtonsRow: fullScreenToolbarComponent, topOffset: /*context.availableSize.height - sheetHeight +*/ max(context.state.dismissOffset, 0), sheetHeight: context.availableSize.height,// max(sheetHeight - context.state.dismissOffset, sheetHeight), - backgroundColor: /*isFullscreen ? .clear : */ (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), + backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), bottomPadding: 0, participantsCount: -1, isFullyExtended: isFullyDragged, deviceCornerRadius: ((controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 1) - 1, - videoHeight: videoHeight + videoHeight: videoHeight, + isFullscreen: isFullscreen, + fullscreenTopComponent: AnyComponent(navigationComponent), + fullscreenBottomComponent: fullScreenToolbarComponent ), availableSize: context.availableSize, transition: context.transition ) + context.add(fullScreenOverlayComponent .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) .opacity(state.displayUI ? 1 : 0) ) + + context.add(video + .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2 + dragOffset) + )) } return context.availableSize @@ -1446,11 +1550,21 @@ final class StreamTitleComponent: Component { func update(component: StreamTitleComponent, availableSize: CGSize, transition: Transition) -> CGSize { let liveIndicatorWidth: CGFloat = 40 - self.titleLabel.text = component.text + let currentText = self.titleLabel.text + if currentText != component.text { + if currentText?.isEmpty == false { + UIView.transition(with: self.titleLabel, duration: 0.2) { + self.titleLabel.text = component.text + self.titleLabel.invalidateIntrinsicContentSize() + } + } else { + self.titleLabel.text = component.text + self.titleLabel.invalidateIntrinsicContentSize() + } + } self.titleLabel.font = Font.semibold(17.0) self.titleLabel.textColor = .white self.titleLabel.numberOfLines = 1 - self.titleLabel.invalidateIntrinsicContentSize() let textSize = CGSize(width: min(availableSize.width - 4 - liveIndicatorWidth, self.titleLabel.intrinsicContentSize.width), height: availableSize.height) @@ -1483,7 +1597,13 @@ final class StreamTitleComponent: Component { let size = CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height) let textFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) // self.textView.frame = textFrame - self.updateTitleFadeLayer(textFrame: textFrame) + if currentText?.isEmpty == false { + UIView.transition(with: self.titleLabel, duration: 0.2) { + self.updateTitleFadeLayer(textFrame: textFrame) + } + } else { + self.updateTitleFadeLayer(textFrame: textFrame) + } liveIndicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: /*floorToScreenPixels((size.height - textSize.height) / 2.0 - 2) + 1.0*/textFrame.midY - 22 / 2), size: .init(width: 40, height: 22)) self.liveIndicatorView.toggle(isLive: component.isActive) @@ -1563,7 +1683,11 @@ private final class NavigationBarComponent: CombinedComponent { let contentHeight: CGFloat = 44.0 let size = CGSize(width: context.availableSize.width, height: context.component.topInset + contentHeight) - let background = background.update(component: Rectangle(color: UIColor(white: 0.0, alpha: context.component.backgroundVisible ? 0.5 : 0)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition) + let background = background.update( + component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5/*context.component.backgroundVisible ? 0.5 : 0*/)), + availableSize: CGSize(width: size.width, height: size.height), + transition: context.transition + ) let leftItem = context.component.leftItem.flatMap { leftItemComponent in return leftItem.update( @@ -1600,6 +1724,8 @@ private final class NavigationBarComponent: CombinedComponent { context.add(background .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) + .opacity(context.component.backgroundVisible ? 1 : 0) + .animation(key: "opacity") ) var centerLeftInset = sideInset @@ -1660,7 +1786,7 @@ private final class OriginInfoComponent: CombinedComponent { component: ParticipantsComponent( count: context.component.participantsCount, showsSubtitle: true, - fontSize: 24 + fontSize: 18.0 ), availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), transition: context.transition @@ -1671,7 +1797,7 @@ private final class OriginInfoComponent: CombinedComponent { size.height = min(size.height, context.availableSize.height) context.add(viewerCounter - .position(CGPoint(x: size.width / 2.0, y: viewerCounter.size.height / 2.0)) + .position(CGPoint(x: size.width / 2.0, y: (context.availableSize.height - viewerCounter.size.height) / 2.0)) ) return size diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 9b4195c509..805892ee38 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -120,7 +120,9 @@ final class MediaStreamVideoComponent: Component { private var noSignalTimer: Foundation.Timer? private var noSignalTimeout: Bool = false - private let maskGradientLayer = CAGradientLayer() + private let videoBlurGradientMask = CAGradientLayer() + private let videoBlurSolidMask = CALayer() + private var wasVisible = true private var borderShimmer = StandaloneShimmerEffect() private let shimmerBorderLayer = CALayer() @@ -168,6 +170,8 @@ final class MediaStreamVideoComponent: Component { deinit { avatarDisposable?.dispose() frameInputDisposable?.dispose() + self.x?.invalidate() + self.x = nil } public func matches(tag: Any) -> Bool { @@ -316,10 +320,14 @@ final class MediaStreamVideoComponent: Component { UIView.animate(withDuration: 0.3) { videoBlurView.alpha = 1 } - self.maskGradientLayer.type = .radial - self.maskGradientLayer.colors = [UIColor(rgb: 0x000000, alpha: 0.5).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor] - self.maskGradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5) - self.maskGradientLayer.endPoint = CGPoint(x: 1.0, y: 1.0) + self.videoBlurGradientMask.type = .radial + self.videoBlurGradientMask.colors = [UIColor(rgb: 0x000000, alpha: 0.5).cgColor, UIColor(rgb: 0xffffff, alpha: 0.0).cgColor] + self.videoBlurGradientMask.startPoint = CGPoint(x: 0.5, y: 0.5) + self.videoBlurGradientMask.endPoint = CGPoint(x: 1.0, y: 1.0) + + self.videoBlurSolidMask.backgroundColor = UIColor.black.cgColor + self.videoBlurGradientMask.addSublayer(videoBlurSolidMask) + } if let videoView = self.videoRenderingContext.makeView(input: input, blur: false, forceSampleBufferDisplayLayer: true) { @@ -431,6 +439,14 @@ final class MediaStreamVideoComponent: Component { let videoSize: CGSize let videoCornerRadius: CGFloat = component.isFullscreen ? 0 : 10 + + let videoFrameUpdateTransition: Transition + if self.wasFullscreen != component.isFullscreen { + videoFrameUpdateTransition = transition + } else { + videoFrameUpdateTransition = transition.withAnimation(.none) + } + if let videoView = self.videoView { if videoView.bounds.size.width > 0, videoView.alpha > 0, @@ -482,15 +498,10 @@ final class MediaStreamVideoComponent: Component { videoView.clipsToBounds = true videoView.layer.cornerRadius = videoCornerRadius - let videoFrameUpdateTransition: Transition - if self.wasFullscreen != component.isFullscreen { - videoFrameUpdateTransition = transition - } else { - videoFrameUpdateTransition = transition.withAnimation(.none) - } self.wasFullscreen = component.isFullscreen + let newVideoFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) - videoFrameUpdateTransition.setFrame(view: videoView, frame: CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize), completion: nil) + videoFrameUpdateTransition.setFrame(view: videoView, frame: newVideoFrame, completion: nil) if let videoBlurView = self.videoBlurView { @@ -505,27 +516,51 @@ final class MediaStreamVideoComponent: Component { // videoBlurView.frame = videoView.frame.insetBy(dx: -69 * aspect, dy: -69) } - if !component.isFullscreen { - videoBlurView.layer.mask = maskGradientLayer - } else { - videoBlurView.layer.mask = nil - } + videoBlurView.layer.mask = videoBlurGradientMask - self.maskGradientLayer.frame = videoBlurView.bounds + if !component.isFullscreen { + transition.setAlpha(layer: videoBlurSolidMask, alpha: 0) +// if videoBlurView.layer.mask !== videoBlurGradientMask { +// UIView.transition(with: videoBlurView, duration: transition.animation.isImmediate ? 0.0 : 0.3) { [self] in +// videoBlurView.layer.mask = videoBlurGradientMask +// } +// } +// videoBlurView.layer.mask = maskGradientLayer + } else { + transition.setAlpha(layer: videoBlurSolidMask, alpha: 1) +// if videoBlurView.layer.mask != nil { +// UIView.transition(with: videoBlurView, duration: transition.animation.isImmediate ? 0.0 : 0.3) { +// videoBlurView.layer.mask = nil +// } +// } + } +// + videoFrameUpdateTransition.setFrame(layer: self.videoBlurGradientMask, frame: videoBlurView.bounds) + videoFrameUpdateTransition.setFrame(layer: self.videoBlurSolidMask, frame: self.videoBlurGradientMask.bounds) } } else { videoSize = CGSize(width: 16 / 9 * 100.0, height: 100.0).aspectFitted(.init(width: availableSize.width - videoInset * 2, height: availableSize.height)) } - loadingBlurView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) - loadingBlurView.layer.cornerRadius = videoCornerRadius + let loadingBlurViewFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) + videoFrameUpdateTransition.setFrame(view: loadingBlurView, frame: loadingBlurViewFrame) +// loadingBlurView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) - placeholderView.frame = loadingBlurView.frame - placeholderView.layer.cornerRadius = videoCornerRadius + videoFrameUpdateTransition.setCornerRadius(layer: loadingBlurView.layer, cornerRadius: videoCornerRadius) +// loadingBlurView.layer.cornerRadius = videoCornerRadius + + videoFrameUpdateTransition.setFrame(view: placeholderView, frame: loadingBlurViewFrame) +// placeholderView.frame = loadingBlurView.frame + videoFrameUpdateTransition.setCornerRadius(layer: placeholderView.layer, cornerRadius: videoCornerRadius) +// placeholderView.layer.cornerRadius = videoCornerRadius placeholderView.clipsToBounds = true - placeholderView.subviews.forEach { $0.frame = placeholderView.bounds } + placeholderView.subviews.forEach { + videoFrameUpdateTransition.setFrame(view: $0, frame: placeholderView.bounds) +// $0.frame = placeholderView.bounds + } - shimmerBorderLayer.frame = loadingBlurView.bounds + videoFrameUpdateTransition.setFrame(layer: shimmerBorderLayer, frame: loadingBlurView.bounds) +// shimmerBorderLayer.frame = loadingBlurView.bounds let borderMask = CAShapeLayer() borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil) @@ -607,26 +642,58 @@ final class MediaStreamVideoComponent: Component { func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { if let videoView = self.videoView, let presentation = videoView.snapshotView(afterScreenUpdates: false) { - self.addSubview(presentation) - presentation.frame = videoView.frame + let presentationParent = self.window ?? self + presentationParent.addSubview(presentation) + presentation.frame = presentationParent.convert(videoView.frame, from: self) + if let callId = self.component?.call.peerId.id.description { lastFrame[callId] = presentation } videoView.alpha = 0 - - UIView.animate(withDuration: 0.07, delay: 0.07, animations: { - presentation.alpha = 0 - }, completion: { _ in - presentation.removeFromSuperview() - }) + lastPresentation?.removeFromSuperview() + lastPresentation = presentation +// UIView.animate(withDuration: 0.04, delay: 0.04, animations: { +// presentation.alpha = 0 +// }, completion: { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { + self.lastPresentation?.removeFromSuperview() + self.lastPresentation = nil + self.x?.invalidate() + self.x = nil + } +// }) } UIView.animate(withDuration: 0.1) { [self] in videoBlurView?.alpha = 0 } + // UIApplication.shared.windows.first?.windowLevel == .normal // TODO: make safe UIApplication.shared.windows.first?/*(where: { $0.layer !== (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.keyWindow?.layer })?*/.layer.cornerRadius = 10// (where: { !($0 is NativeWindow)*/ }) UIApplication.shared.windows.first?.layer.masksToBounds = true + +// UIApplication.shared.windows.first?.subviews[0].subviews[0].subviews[1].subviews[0].subviews[0].backgroundColor = .red +// UIApplication.shared.windows.first?.subviews[0].subviews[0].subviews[1].subviews[0].subviews[0].setNeedsDisplay() +// UIApplication.shared.windows.first?.subviews[0].subviews[0].subviews[1].backgroundColor = .red +// UIApplication.shared.windows.first?.subviews[0].subviews[0].subviews[1].setNeedsDisplay() + + self.x?.invalidate() + let x = CADisplayLink(target: self, selector: #selector(observePiPWindow)) + x.add(to: .main, forMode: .default) + self.x = x + } + + var lastPresentation: UIView? + var x: CADisplayLink? + + @objc func observePiPWindow() { + let pipViewDidBecomeVisible = (UIApplication.shared.windows.first?.layer.animationKeys()?.count ?? 0) > 0 + if pipViewDidBecomeVisible { + lastPresentation?.removeFromSuperview() + lastPresentation = nil + self.x?.invalidate() + self.x = nil + } } public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) { diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index af82c8bb55..4b02cef84b 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -20,6 +20,10 @@ final class StreamSheetComponent: CombinedComponent { let deviceCornerRadius: CGFloat let videoHeight: CGFloat + let isFullscreen: Bool + let fullscreenTopComponent: AnyComponent + let fullscreenBottomComponent: AnyComponent + init( topComponent: AnyComponent, bottomButtonsRow: AnyComponent, @@ -30,10 +34,13 @@ final class StreamSheetComponent: CombinedComponent { participantsCount: Int, isFullyExtended: Bool, deviceCornerRadius: CGFloat, - videoHeight: CGFloat + videoHeight: CGFloat, + isFullscreen: Bool, + fullscreenTopComponent: AnyComponent, + fullscreenBottomComponent: AnyComponent ) { - self.topComponent = topComponent - self.bottomButtonsRow = bottomButtonsRow + self.topComponent = nil // topComponent + self.bottomButtonsRow = nil // bottomButtonsRow self.topOffset = topOffset self.sheetHeight = sheetHeight self.backgroundColor = backgroundColor @@ -42,6 +49,10 @@ final class StreamSheetComponent: CombinedComponent { self.isFullyExtended = isFullyExtended self.deviceCornerRadius = deviceCornerRadius self.videoHeight = videoHeight + + self.isFullscreen = isFullscreen + self.fullscreenTopComponent = fullscreenTopComponent + self.fullscreenBottomComponent = fullscreenBottomComponent } static func ==(lhs: StreamSheetComponent, rhs: StreamSheetComponent) -> Bool { @@ -75,6 +86,19 @@ final class StreamSheetComponent: CombinedComponent { if lhs.videoHeight != rhs.videoHeight { return false } + + if lhs.isFullscreen != rhs.isFullscreen { + return false + } + + if lhs.fullscreenTopComponent != rhs.fullscreenTopComponent { + return false + } + + if lhs.fullscreenBottomComponent != rhs.fullscreenBottomComponent { + return false + } + return true } @@ -173,7 +197,7 @@ final class StreamSheetComponent: CombinedComponent { ) } // TODO: replace - let isFullscreen = context.component.participantsCount == -1 + let isFullscreen = context.component.isFullscreen // context.component.participantsCount == -1 context.add(background .position(CGPoint(x: size.width / 2.0, y: topOffset + context.component.sheetHeight / 2)) From f1108af23862ea56185f591109cbfe2ae78fa46d Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Fri, 6 Jan 2023 00:14:38 +0400 Subject: [PATCH 40/50] Adjusting layout and design, fixing fullscreen PiP and other tweaks --- .../Components/AnimatedCounterView.swift | 33 +++- .../Components/MediaStreamComponent.swift | 165 ++++++++++++------ .../MediaStreamVideoComponent.swift | 67 +++++-- .../Components/StreamSheetComponent.swift | 46 +++-- 4 files changed, 222 insertions(+), 89 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index 10bc86ecb5..717073b63c 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -21,7 +21,7 @@ public final class AnimatedCountView: UIView { super.init(frame: frame) self.foregroundGradientLayer.type = .radial - self.foregroundGradientLayer.colors = [pink.cgColor, purple.cgColor, purple.cgColor] +// self.foregroundGradientLayer.colors = [pink.cgColor, purple.cgColor, purple.cgColor] self.foregroundGradientLayer.locations = [0.0, 0.85, 1.0] self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0) self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) @@ -46,18 +46,34 @@ public final class AnimatedCountView: UIView { self.foregroundView.frame = CGRect(origin: CGPoint.zero, size: bounds.size)// .insetBy(dx: -40, dy: -40) self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60) self.maskingView.frame = CGRect(origin: .zero, size: bounds.size) - countLabel.frame = CGRect(origin: .zero, size: bounds.size) - subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - 8 : bounds.height - 12, width: subtitleLabel.intrinsicContentSize.width + 20, height: 20) + + let subtitleHeight: CGFloat = subtitleLabel.intrinsicContentSize.height// 18 +// let counterInset: CGFloat = 8 +// let counterBottomOffset: CGFloat = subtitleHeight + counterInset + + countLabel.frame = CGRect(origin: .zero, size: CGSize(width: bounds.width, height: bounds.height)) + subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - subtitleHeight / 2 : bounds.height - subtitleHeight, width: subtitleLabel.intrinsicContentSize.width + 20, height: subtitleHeight) +// backgroundColor = .white.withAlphaComponent(0.3) +// countLabel.backgroundColor = .red.withAlphaComponent(0.2) +// subtitleLabel.backgroundColor = .blue.withAlphaComponent(0.2) } - func update(countString: String, subtitle: String, fontSize: CGFloat = 48.0) { + func update(countString: String, subtitle: String, fontSize: CGFloat = 48.0, gradientColors: [CGColor] = [pink.cgColor, purple.cgColor, purple.cgColor]) { self.setupGradientAnimations() + let backgroundGradientColors: [CGColor] + if gradientColors.count == 1 { + backgroundGradientColors = [gradientColors[0], gradientColors[0]] + } else { + backgroundGradientColors = gradientColors + } + self.foregroundGradientLayer.colors = backgroundGradientColors + let text: String = countString self.countLabel.fontSize = fontSize self.countLabel.attributedText = NSAttributedString(string: text, font: Font.with(size: fontSize, design: .round, weight: .semibold, traits: [.monospacedNumbers]), textColor: .white) - self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, attributes: [.font: UIFont.systemFont(ofSize: max(floor(fontSize / 3), 12), weight: .semibold)]) + self.subtitleLabel.attributedText = NSAttributedString(string: subtitle, attributes: [.font: UIFont.systemFont(ofSize: max(floor((fontSize + 4.0) / 3.0), 12.0), weight: .semibold)]) self.subtitleLabel.isHidden = subtitle.isEmpty } @@ -152,7 +168,7 @@ class AnimatedCountLabel: UILabel { private let containerView = UIView() var itemWidth: CGFloat { 36 * fontSize / 60 } - var commaWidthForSpacing: CGFloat { 8 * fontSize / 60 } + var commaWidthForSpacing: CGFloat { 12 * fontSize / 60 } var commaFrameWidth: CGFloat { 36 * fontSize / 60 } var interItemSpacing: CGFloat { 0 * fontSize / 60 } var didBegin = false @@ -180,9 +196,9 @@ class AnimatedCountLabel: UILabel { } if characters.count > index && characters[index].string == "," { if index > 0, ["1", "7"].contains(characters[index - 1].string) { - offset -= commaWidthForSpacing * 0.7 + offset -= commaWidthForSpacing * 0.5 } else { - offset -= commaWidthForSpacing / 3 + offset -= commaWidthForSpacing / 6// 3 } } return offset @@ -199,6 +215,7 @@ class AnimatedCountLabel: UILabel { let offset = offsetForChar(at: index) char.frame.origin.x = offset char.frame.origin.y = 0 + char.frame.size.height = containerView.bounds.height } } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 862d7835d4..6ebaf6942d 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -58,6 +58,7 @@ public final class MediaStreamComponent: CombinedComponent { var isFullscreen: Bool = false var videoSize: CGSize? var prevFullscreenOrientation: UIDeviceOrientation? + var didAutoDismissForPiP: Bool = false private(set) var canManageCall: Bool = false // TODO: also handle pictureInPicturePossible @@ -169,11 +170,11 @@ public final class MediaStreamComponent: CombinedComponent { var updated = false // TODO: remove debug timer -// Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in var shouldReplaceNoViewersWithOne: Bool { true } - - strongSelf.infoThrottler.publish(shouldReplaceNoViewersWithOne ? max(members.totalCount, 1) : members.totalCount /*Int.random(in: 0..<10000000)*/) { [weak strongSelf] latestCount in -// let _ = members.totalCount + let membersCount = Int.random(in: 0..<10000000) // members.totalCount + strongSelf.infoThrottler.publish(shouldReplaceNoViewersWithOne ? max(membersCount, 1) : membersCount) { [weak strongSelf] latestCount in + let _ = members.totalCount guard let strongSelf = strongSelf else { return } var updated = false let originInfo = OriginInfo(title: callPeer.debugDisplayTitle, memberCount: latestCount) @@ -185,7 +186,7 @@ public final class MediaStreamComponent: CombinedComponent { strongSelf.updated(transition: .immediate) } } -// }.fire() + }.fire() if state.canManageCall != strongSelf.canManageCall { strongSelf.canManageCall = state.canManageCall updated = true @@ -228,6 +229,8 @@ public final class MediaStreamComponent: CombinedComponent { strongSelf.deactivatePictureInPictureIfVisible.invoke(Void()) }) + } else { + // MARK: TODO: fullscreen ui toggle } } }) @@ -318,6 +321,11 @@ public final class MediaStreamComponent: CombinedComponent { return } if controller.view.window == nil { + if state.didAutoDismissForPiP { + state.updated(transition: .easeInOut(duration: 3)) + deactivatePictureInPicture.invoke(Void()) +// call.accountContext.sharedContext.mainWindow?.inCallNavigate?() + } return } state.updated(transition: .easeInOut(duration: 3)) @@ -349,10 +357,10 @@ public final class MediaStreamComponent: CombinedComponent { let videoHeight: CGFloat = forceFullScreenInLandscape ? (context.availableSize.width - videoInset * 2) / 16 * 9 : context.state.videoSize?.height ?? (min(context.availableSize.width, context.availableSize.height) - videoInset * 2) / 16 * 9 - let bottomPadding = 40 + environment.safeInsets.bottom + let bottomPadding = 32.0 + environment.safeInsets.bottom let requiredSheetHeight: CGFloat = isFullscreen ? context.availableSize.height - : (44 + videoHeight + 40 + 69 + 16 + 32 + 70 + bottomPadding) + : (44 + videoHeight + 40 + 69 + 16 + 32 + 70 + bottomPadding + 8) let safeAreaTopInView: CGFloat if #available(iOS 16.0, *) { @@ -374,7 +382,8 @@ public final class MediaStreamComponent: CombinedComponent { availableSize: CGSize(width: context.availableSize.width, height: dismissTapAreaHeight), transition: context.transition ) - +// (controller() as? MediaStreamComponentController)?.prefersOnScreenNavigationHidden = isFullscreen +// (controller() as? MediaStreamComponentController)?.window?.invalidatePrefersOnScreenNavigationHidden() let video = video.update( component: MediaStreamVideoComponent( call: context.component.call, @@ -432,12 +441,17 @@ public final class MediaStreamComponent: CombinedComponent { ))) ] )), - action: { + action: { [weak state] in + guard let state, state.videoIsPlayable else { return } + activatePictureInPicture.invoke(Action { guard let controller = controller() as? MediaStreamComponentController else { return } controller.dismiss(closing: false, manual: true) + if state.displayUI { + state.toggleDisplayUI() + } }) } ).minSize(CGSize(width: 44.0, height: 44.0))))) @@ -624,8 +638,8 @@ public final class MediaStreamComponent: CombinedComponent { let alertController = textAlertController( context: call.accountContext, forceTheme: defaultDarkPresentationTheme, - title: nil, - text: presentationData.strings.VoiceChat_StopRecordingTitle, + title: presentationData.strings.LiveStream_EndConfirmationTitle, + text: presentationData.strings.LiveStream_EndConfirmationText, actions: [ TextAlertAction( type: .genericAction, @@ -633,8 +647,8 @@ public final class MediaStreamComponent: CombinedComponent { action: {} ), TextAlertAction( - type: .defaultAction, - title: presentationData.strings.VoiceChat_StopRecordingStop, + type: .destructiveAction, + title: presentationData.strings.VoiceChat_EndConfirmationEnd, action: { [weak call] in guard let call = call else { return @@ -754,6 +768,9 @@ public final class MediaStreamComponent: CombinedComponent { return } controller.dismiss(closing: false, manual: true) + if state.displayUI { + state.toggleDisplayUI() + } }) } else { guard let controller = controller() as? MediaStreamComponentController else { @@ -811,7 +828,11 @@ public final class MediaStreamComponent: CombinedComponent { sideInset: environment.safeInsets.left, leftItem: AnyComponent(Button( content: AnyComponent(RoundGradientButtonComponent(// BundleIconComponent( - gradientColors: [UIColor(red: 0.18, green: 0.17, blue: 0.30, alpha: 1).cgColor, UIColor(red: 0.17, green: 0.16, blue: 0.30, alpha: 1).cgColor], + gradientColors: [ + UIColor(red: 0.165, green: 0.173, blue: 0.357, alpha: 1).cgColor +// UIColor(red: 0.18, green: 0.17, blue: 0.30, alpha: 1).cgColor, +// UIColor(red: 0.17, green: 0.16, blue: 0.30, alpha: 1).cgColor + ], image: generateTintedImage(image: UIImage(bundleImageName: "Call/CallShareButton"), color: .white), // TODO: localize: title: "share")), @@ -824,7 +845,11 @@ public final class MediaStreamComponent: CombinedComponent { ).minSize(CGSize(width: 65, height: 80))), rightItem: AnyComponent(Button( content: AnyComponent(RoundGradientButtonComponent( - gradientColors: [UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor, UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor], + gradientColors: [ + UIColor(red: 0.314, green: 0.161, blue: 0.197, alpha: 1).cgColor +// UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor, +// UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor + ], image: generateImage(CGSize(width: 44.0 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in context.translateBy(x: size.width / 2, y: size.height / 2) context.scaleBy(x: 0.4, y: 0.4) @@ -853,7 +878,11 @@ public final class MediaStreamComponent: CombinedComponent { ).minSize(CGSize(width: 44.0, height: 44.0))), centerItem: AnyComponent(Button( content: AnyComponent(RoundGradientButtonComponent( - gradientColors: [UIColor(red: 0.23, green: 0.17, blue: 0.29, alpha: 1).cgColor, UIColor(red: 0.21, green: 0.16, blue: 0.29, alpha: 1).cgColor], + gradientColors: [ + UIColor(red: 0.165, green: 0.173, blue: 0.357, alpha: 1).cgColor +// UIColor(red: 0.23, green: 0.17, blue: 0.29, alpha: 1).cgColor, +// UIColor(red: 0.21, green: 0.16, blue: 0.29, alpha: 1).cgColor + ], image: generateImage(CGSize(width: 44 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in let imageColor = UIColor.white @@ -930,7 +959,7 @@ public final class MediaStreamComponent: CombinedComponent { bottomButtonsRow: bottomComponent, topOffset: topOffset, sheetHeight: sheetHeight, - backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), + backgroundColor: (isFullscreen && !state.hasVideo) ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), bottomPadding: bottomPadding, participantsCount: context.state.originInfo?.memberCount ?? 0, // Int.random(in: 0...999998)// [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! isFullyExtended: isFullyDragged, @@ -944,22 +973,12 @@ public final class MediaStreamComponent: CombinedComponent { transition: context.transition ) - let sheetOffset: CGFloat = context.availableSize.height - requiredSheetHeight + dragOffset - let sheetPosition = sheetOffset + requiredSheetHeight / 2 + // let sheetOffset: CGFloat = context.availableSize.height - requiredSheetHeight + dragOffset + // let sheetPosition = sheetOffset + requiredSheetHeight / 2 // Sheet underneath the video when in modal sheet context.add(sheet .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) ) - let videoPos: CGFloat - - if isFullscreen { - videoPos = context.availableSize.height / 2 + dragOffset - } else { - videoPos = sheetPosition - requiredSheetHeight / 2 + videoHeight / 2 + 50 + 12 - } - context.add(video - .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)) - ) // // @@ -1022,9 +1041,23 @@ public final class MediaStreamComponent: CombinedComponent { transition: context.transition ) + let videoPos: CGFloat + + if isFullscreen { + videoPos = context.availableSize.height / 2 + dragOffset + } else { + videoPos = /*sheetPosition - requiredSheetHeight / 2*/topOffset + 28.0 + 28.0 + videoHeight / 2 // + 50 + 12 + } + context.add(video + .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)) + ) + context.add(topItem - .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + (isFullscreen ? topItem.size.height / 2.0 : 32))) + .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + (isFullscreen ? topItem.size.height / 2.0 : 28.0))) .opacity((!isFullscreen || state.displayUI) ? 1 : 0) + .gesture(.pan { panState in + onPanGesture(panState) + }) // .animation(key: "position") ) @@ -1043,7 +1076,7 @@ public final class MediaStreamComponent: CombinedComponent { // // } else { - let fullScreenToolbarComponent = AnyComponent(ToolbarComponent( + /*let fullScreenToolbarComponent = AnyComponent(ToolbarComponent( bottomInset: environment.safeInsets.bottom, sideInset: environment.safeInsets.left, leftItem: AnyComponent(Button( @@ -1104,9 +1137,18 @@ public final class MediaStreamComponent: CombinedComponent { context.add(video .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2 + dragOffset) - )) + ))*/ } - + // TODO: add variable isPictureInPictureActive +// let isPictureInPictureActive = state.isPictureInPictureSupported && state.videoIsPlayable && state.hasVideo +// if !state.isVisibleInHierarchy && isPictureInPictureActive && state.isFullscreen { +// if !state.didAutoDismissForPiP { +// state.didAutoDismissForPiP = true +// (controller() as? MediaStreamComponentController)?.dismiss(closing: false, manual: true) +// } +// } else { +// state.didAutoDismissForPiP = false +// } return context.availableSize } } @@ -1155,7 +1197,7 @@ public final class MediaStreamComponentController: ViewControllerComponentContai self.view.clipsToBounds = true - self.view.layer.animatePosition(from: CGPoint(x: self.view.frame.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), to: self.view.center, duration: 0.3, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in + self.view.layer.animatePosition(from: CGPoint(x: self.view.frame.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), to: self.view.center, duration: 0.5, timingFunction: kCAMediaTimingFunctionSpring, completion: { _ in }) self.view.layer.allowsGroupOpacity = true @@ -1376,19 +1418,35 @@ final class StreamTitleComponent: Component { private let stalledAnimatedGradient = CAGradientLayer() private var wasLive = false + var desiredWidth: CGFloat { label.intrinsicContentSize.width + 6.0 + 6.0 } + override init(frame: CGRect = .zero) { super.init(frame: frame) addSubview(label) - label.text = "LIVE" - label.font = .systemFont(ofSize: 12, weight: .semibold) - label.textAlignment = .center - label.textColor = .white + let liveString = NSAttributedString( + string: "LIVE", + attributes: [ + .font: Font.with(size: 11.0, design: .round, weight: .bold), + .paragraphStyle: { + let style = NSMutableParagraphStyle() + style.alignment = .center + return style + }(), + .foregroundColor: UIColor.white, + .kern: -0.6 + ] + ) + label.attributedText = liveString +// label.text = "LIVE" +// label.font = Font.with(size: 11.0, design: .round, weight: .bold)// .systemFont(ofSize: 12, weight: .semibold) +// label.textAlignment = .center +// label.textColor = .white layer.addSublayer(stalledAnimatedGradient) self.clipsToBounds = true - if #available(iOS 13.0, *) { - self.layer.cornerCurve = .continuous - } +// if #available(iOS 13.0, *) { +// self.layer.cornerCurve = .continuous +// } toggle(isLive: false) } @@ -1549,7 +1607,9 @@ final class StreamTitleComponent: Component { } func update(component: StreamTitleComponent, availableSize: CGSize, transition: Transition) -> CGSize { - let liveIndicatorWidth: CGFloat = 40 + let liveIndicatorWidth: CGFloat = self.liveIndicatorView.desiredWidth + let liveIndicatorHeight: CGFloat = 20.0 + let currentText = self.titleLabel.text if currentText != component.text { if currentText?.isEmpty == false { @@ -1605,7 +1665,7 @@ final class StreamTitleComponent: Component { self.updateTitleFadeLayer(textFrame: textFrame) } - liveIndicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: /*floorToScreenPixels((size.height - textSize.height) / 2.0 - 2) + 1.0*/textFrame.midY - 22 / 2), size: .init(width: 40, height: 22)) + liveIndicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: /*floorToScreenPixels((size.height - textSize.height) / 2.0 - 2) + 1.0*/textFrame.midY - liveIndicatorHeight / 2), size: .init(width: liveIndicatorWidth, height: liveIndicatorHeight)) self.liveIndicatorView.toggle(isLive: component.isActive) if let indicatorView = self.indicatorView, let image = indicatorView.image { @@ -1786,18 +1846,19 @@ private final class OriginInfoComponent: CombinedComponent { component: ParticipantsComponent( count: context.component.participantsCount, showsSubtitle: true, - fontSize: 18.0 + fontSize: 18.0, + gradientColors: [UIColor.white.cgColor] ), availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), transition: context.transition ) - - var size = CGSize(width: viewerCounter.size.width, height: viewerCounter.size.height) + let heightReduction: CGFloat = 16.0 + var size = CGSize(width: viewerCounter.size.width, height: viewerCounter.size.height - heightReduction) size.width = min(size.width, context.availableSize.width) size.height = min(size.height, context.availableSize.height) context.add(viewerCounter - .position(CGPoint(x: size.width / 2.0, y: (context.availableSize.height - viewerCounter.size.height) / 2.0)) + .position(CGPoint(x: size.width / 2.0, y: /*(context.availableSize.height - viewerCounter.size.height)*/context.availableSize.height / 2.0 + 16.0 - heightReduction / 2)) ) return size @@ -2007,7 +2068,7 @@ private final class ButtonsRowComponent: CombinedComponent { return { context in var availableWidth = context.availableSize.width - let sideInset: CGFloat = 40 + context.component.sideInset + let sideInset: CGFloat = 48.0 + context.component.sideInset let contentHeight: CGFloat = 80 // 44 let size = CGSize(width: context.availableSize.width, height: contentHeight + context.component.bottomInset) @@ -2126,7 +2187,7 @@ final class RoundGradientButtonComponent: Component { override func layoutSubviews() { super.layoutSubviews() titleLabel.invalidateIntrinsicContentSize() - let heightForIcon = bounds.height - max(titleLabel.intrinsicContentSize.height, 12) - 6 + let heightForIcon = bounds.height - max(round(titleLabel.intrinsicContentSize.height), 12) - 8.0 iconView.frame = .init(x: bounds.midX - heightForIcon / 2, y: 0, width: heightForIcon, height: heightForIcon) gradientLayer.masksToBounds = true gradientLayer.cornerRadius = min(iconView.frame.width, iconView.frame.height) / 2 @@ -2141,6 +2202,12 @@ final class RoundGradientButtonComponent: Component { func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { view.iconView.image = image ?? icon.flatMap { UIImage(bundleImageName: $0) } + let gradientColors: [CGColor] + if self.gradientColors.count == 1 { + gradientColors = [self.gradientColors[0], self.gradientColors[0]] + } else { + gradientColors = self.gradientColors + } view.gradientLayer.colors = gradientColors view.titleLabel.text = title view.setNeedsLayout() diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 805892ee38..1f14b7503b 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -131,7 +131,7 @@ final class MediaStreamVideoComponent: Component { private var videoStalled = false { didSet { if videoStalled != oldValue { - self.updateVideoStalled(isStalled: self.videoStalled) + self.updateVideoStalled(isStalled: self.videoStalled, transition: nil) // state?.updated() } } @@ -181,14 +181,17 @@ final class MediaStreamVideoComponent: Component { return false } + var didPassExpandFromPiP = false + func expandFromPictureInPicture() { + didPassExpandFromPiP = true if let pictureInPictureController = self.pictureInPictureController, pictureInPictureController.isPictureInPictureActive { self.requestedExpansion = true self.pictureInPictureController?.stopPictureInPicture() } } private var isAnimating = false - private func updateVideoStalled(isStalled: Bool) { + private func updateVideoStalled(isStalled: Bool, transition: Transition?) { if isStalled { guard let component = self.component else { return } @@ -229,14 +232,30 @@ final class MediaStreamVideoComponent: Component { shimmerBorderLayer.cornerRadius = cornerRadius shimmerBorderLayer.masksToBounds = true shimmerBorderLayer.compositingFilter = "softLightBlendMode" - shimmerBorderLayer.frame = loadingBlurView.bounds + + + let borderMask = CAShapeLayer() - borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + + shimmerBorderLayer.mask = borderMask + + if let transition, shimmerBorderLayer.mask != nil { + let initialPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + borderMask.path = initialPath + transition.setFrame(layer: shimmerBorderLayer, frame: loadingBlurView.bounds) + + let borderMaskPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + transition.setShapeLayerPath(layer: borderMask, path: borderMaskPath) + } else { + shimmerBorderLayer.frame = loadingBlurView.bounds + let borderMaskPath = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: cornerRadius, cornerHeight: cornerRadius, transform: nil) + borderMask.path = borderMaskPath + } + borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor borderMask.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor borderMask.lineWidth = 3 borderMask.compositingFilter = "softLightBlendMode" - shimmerBorderLayer.mask = borderMask borderShimmer = StandaloneShimmerEffect() borderShimmer.layer = shimmerBorderLayer @@ -281,9 +300,9 @@ final class MediaStreamVideoComponent: Component { } if !component.hasVideo || component.videoLoading || self.videoStalled { - updateVideoStalled(isStalled: true) + updateVideoStalled(isStalled: true, transition: transition) } else { - updateVideoStalled(isStalled: false) + updateVideoStalled(isStalled: false, transition: transition) } if component.hasVideo, self.videoView == nil { @@ -543,7 +562,14 @@ final class MediaStreamVideoComponent: Component { } let loadingBlurViewFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) - videoFrameUpdateTransition.setFrame(view: loadingBlurView, frame: loadingBlurViewFrame) +// UIView.animate(withDuration: 0.5) { +// self.loadingBlurView.frame = loadingBlurViewFrame +// } + if loadingBlurView.frame == .zero { + loadingBlurView.frame = loadingBlurViewFrame + } else { + transition.setFrame(view: loadingBlurView, frame: loadingBlurViewFrame) + } // loadingBlurView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) videoFrameUpdateTransition.setCornerRadius(layer: loadingBlurView.layer, cornerRadius: videoCornerRadius) @@ -559,17 +585,28 @@ final class MediaStreamVideoComponent: Component { // $0.frame = placeholderView.bounds } + let initialShimmerBounds = shimmerBorderLayer.bounds videoFrameUpdateTransition.setFrame(layer: shimmerBorderLayer, frame: loadingBlurView.bounds) // shimmerBorderLayer.frame = loadingBlurView.bounds let borderMask = CAShapeLayer() - borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil) + let initialPath = CGPath(roundedRect: .init(x: 0, y: 0, width: initialShimmerBounds.width, height: initialShimmerBounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil) + borderMask.path = initialPath +// borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil) + videoFrameUpdateTransition.setShapeLayerPath(layer: borderMask, path: CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil)) + borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor borderMask.strokeColor = UIColor.white.withAlphaComponent(0.7).cgColor borderMask.lineWidth = 3 shimmerBorderLayer.mask = borderMask shimmerBorderLayer.cornerRadius = videoCornerRadius +// if component.isAdmin { +// shimmerBorderLayer.isHidden = true +// } else { +// shimmerBorderLayer.isHidden = false +// } +// if !self.hadVideo { if self.noSignalTimer == nil { @@ -623,7 +660,7 @@ final class MediaStreamVideoComponent: Component { guard let strongSelf = self, let pictureInPictureController = strongSelf.pictureInPictureController else { return } - + print("[pip] started") pictureInPictureController.startPictureInPicture() completion(Void()) @@ -701,25 +738,27 @@ final class MediaStreamVideoComponent: Component { completionHandler(false) return } - + didRequestBringBack = true component.bringBackControllerForPictureInPictureDeactivation { completionHandler(true) } } - + var didRequestBringBack = false func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { + self.didRequestBringBack = false self.state?.updated(transition: .immediate) } func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { if self.requestedExpansion { self.requestedExpansion = false - } else { + } else if !didRequestBringBack { self.component?.pictureInPictureClosed() } + didRequestBringBack = false // TODO: extract precise animation timing or observe window changes // Handle minimized case separatelly (can we detect minimized?) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { self.videoView?.alpha = 1 } UIView.animate(withDuration: 0.3) { [self] in diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 4b02cef84b..7328d718bd 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -168,7 +168,7 @@ final class StreamSheetComponent: CombinedComponent { let background = background.update( component: SheetBackgroundComponent( color: context.component.backgroundColor, - radius: context.component.isFullyExtended ? context.component.deviceCornerRadius : 16, + radius: context.component.isFullyExtended ? context.component.deviceCornerRadius : 10.0, offset: backgroundExtraOffset ), availableSize: CGSize(width: size.width, height: context.component.sheetHeight), @@ -184,7 +184,7 @@ final class StreamSheetComponent: CombinedComponent { } let viewerCounter = viewerCounter.update( - component: ParticipantsComponent(count: context.component.participantsCount), + component: ParticipantsComponent(count: context.component.participantsCount, fontSize: 44.0), availableSize: CGSize(width: context.availableSize.width, height: 70), transition: context.transition ) @@ -208,17 +208,18 @@ final class StreamSheetComponent: CombinedComponent { if let topItem = topItem { context.add(topItem - .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + (isFullscreen ? topItem.size.height / 2.0 : 32))) + .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + (isFullscreen ? topItem.size.height / 2.0 : 28))) ) (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: topOffset, width: topItem.size.width, height: topItem.size.height)) } let videoHeight = context.component.videoHeight let sheetHeight = context.component.sheetHeight - let animatedParticipantsVisible = context.component.participantsCount != -1 + let animatedParticipantsVisible = !isFullscreen// context.component.participantsCount != -1 if true { context.add(viewerCounter - .position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50 + videoHeight + (sheetHeight - 69 - videoHeight - 50 - context.component.bottomPadding) / 2 - 12)) + .position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50 + videoHeight + (sheetHeight - 69 - videoHeight - 50 - context.component.bottomPadding) / 2 - 10)) .opacity(animatedParticipantsVisible ? 1 : 0) +// .animation(key: "position") ) } @@ -259,18 +260,24 @@ final class SheetBackgroundComponent: Component { let extraBottom: CGFloat = 500 if backgroundView.backgroundColor != color && backgroundView.backgroundColor != nil { - UIView.animate(withDuration: 0.4) { [self] in - backgroundView.backgroundColor = color - // TODO: determine if animation is needed (with logic, not color) - backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottom)) + if transition.animation.isImmediate { + UIView.animate(withDuration: 0.4) { [self] in + backgroundView.backgroundColor = color + // TODO: determine if animation is needed (with logic, not color) + backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottom)) + } + + let anim = CABasicAnimation(keyPath: "cornerRadius") + anim.fromValue = backgroundView.layer.cornerRadius + backgroundView.layer.cornerRadius = cornerRadius + anim.toValue = cornerRadius + anim.duration = 0.4 + backgroundView.layer.add(anim, forKey: "cornerRadius") + } else { + transition.setBackgroundColor(view: backgroundView, color: color) + transition.setFrame(view: backgroundView, frame: CGRect(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottom))) + transition.setCornerRadius(layer: backgroundView.layer, cornerRadius: cornerRadius) } - - let anim = CABasicAnimation(keyPath: "cornerRadius") - anim.fromValue = backgroundView.layer.cornerRadius - backgroundView.layer.cornerRadius = cornerRadius - anim.toValue = cornerRadius - anim.duration = 0.4 - backgroundView.layer.add(anim, forKey: "cornerRadius") } else { backgroundView.backgroundColor = color backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottom)) @@ -325,7 +332,8 @@ final class ParticipantsComponent: Component { view.counter.update( countString: self.count > 0 ? presentationStringsFormattedNumber(Int32(count), ",") : "", subtitle: self.showsSubtitle ? (self.count > 0 ? "watching" : "no viewers") : "", - fontSize: self.fontSize + fontSize: self.fontSize, + gradientColors: self.gradientColors )// environment.strings.LiveStream_NoViewers) return availableSize } @@ -333,11 +341,13 @@ final class ParticipantsComponent: Component { private let count: Int private let showsSubtitle: Bool private let fontSize: CGFloat + private let gradientColors: [CGColor] - init(count: Int, showsSubtitle: Bool = true, fontSize: CGFloat = 48) { + init(count: Int, showsSubtitle: Bool = true, fontSize: CGFloat = 48.0, gradientColors: [CGColor] = [pink.cgColor, purple.cgColor, purple.cgColor]) { self.count = count self.showsSubtitle = showsSubtitle self.fontSize = fontSize + self.gradientColors = gradientColors } final class View: UIView { From 286352453049714b5f38f268bdc4b7ecb9b95fb8 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Fri, 6 Jan 2023 15:26:11 +0400 Subject: [PATCH 41/50] Fixing transition animations and delaying activityIndicator when starting live stream --- .../CreateExternalMediaStreamScreen.swift | 10 +++++++- .../Components/AnimatedCounterView.swift | 4 ++-- .../Components/MediaStreamComponent.swift | 2 +- .../MediaStreamVideoComponent.swift | 24 ++++++++++++------- 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift b/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift index 8f5178f2b7..4984ac9cd0 100644 --- a/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift +++ b/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift @@ -50,6 +50,7 @@ private final class CreateExternalMediaStreamScreenComponent: CombinedComponent let peerId: EnginePeer.Id private(set) var credentials: GroupCallStreamCredentials? + var isDelayingLoadingIndication: Bool = true private var credentialsDisposable: Disposable? private let activeActionDisposable = MetaDisposable() @@ -100,6 +101,13 @@ private final class CreateExternalMediaStreamScreenComponent: CombinedComponent return } + strongSelf.isDelayingLoadingIndication = true + Timer(timeout: 0.3, repeat: false, completion: { [weak strongSelf] in + guard let strongSelf else { return } + strongSelf.isDelayingLoadingIndication = false + strongSelf.updated(transition: .easeInOut(duration: 0.3)) + }, queue: .mainQueue()).start() + var cancelImpl: (() -> Void)? let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } let progressSignal = Signal { [weak baseController] subscriber in @@ -397,7 +405,7 @@ private final class CreateExternalMediaStreamScreenComponent: CombinedComponent context.add(credentialsCopyKeyButton .position(CGPoint(x: credentialsFrame.maxX - 12.0 - credentialsCopyKeyButton.size.width / 2.0, y: credentialsFrame.minY + credentialsItemHeight + credentialsItemHeight / 2.0)) ) - } else { + } else if !context.state.isDelayingLoadingIndication { let activityIndicator = activityIndicator.update( component: ActivityIndicatorComponent(color: environment.theme.list.controlSecondaryColor), availableSize: CGSize(width: 100.0, height: 100.0), diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index 717073b63c..4d58e85b6b 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -2,8 +2,8 @@ import Foundation import UIKit import Display -private let purple = UIColor(rgb: 0x3252ef) -private let pink = UIColor(rgb: 0xe4436c) +private let purple = UIColor(rgb: 0xdf44b8) // 0x3252ef) +private let pink = UIColor(rgb: 0x3851eb) // 0xe4436c) private let latePurple = UIColor(rgb: 0x974aa9) private let latePink = UIColor(rgb: 0xf0436c) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index 6ebaf6942d..a88a8874ad 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -760,7 +760,7 @@ public final class MediaStreamComponent: CombinedComponent { } else { if isFullyDragged || state.initialOffset != 0 { state.updateDismissOffset(value: 0.0, interactive: false) - state.updateDismissOffset(value: 0.0, interactive: false) +// state.updateDismissOffset(value: 0.0, interactive: false) } else { if state.isPictureInPictureSupported { activatePictureInPicture.invoke(Action { diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 1f14b7503b..070ddfb52a 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -104,7 +104,6 @@ final class MediaStreamVideoComponent: Component { private let blurTintView: UIView private var videoBlurView: VideoRenderingView? private var videoView: VideoRenderingView? - private var loadingView: ComponentHostView? private var videoPlaceholderView: UIView? private var noSignalView: ComponentHostView? @@ -233,8 +232,6 @@ final class MediaStreamVideoComponent: Component { shimmerBorderLayer.masksToBounds = true shimmerBorderLayer.compositingFilter = "softLightBlendMode" - - let borderMask = CAShapeLayer() shimmerBorderLayer.mask = borderMask @@ -437,11 +434,11 @@ final class MediaStreamVideoComponent: Component { } else if component.isFullscreen { if fullScreenBackgroundPlaceholder.superview == nil { insertSubview(fullScreenBackgroundPlaceholder, at: 0) - transition.animateAlpha(view: fullScreenBackgroundPlaceholder, from: 0, to: 1) + transition.setAlpha(view: self.fullScreenBackgroundPlaceholder, alpha: 1) } fullScreenBackgroundPlaceholder.backgroundColor = UIColor.black.withAlphaComponent(0.5) } else { - transition.animateAlpha(view: fullScreenBackgroundPlaceholder, from: 1, to: 0, completion: { didComplete in + transition.setAlpha(view: self.fullScreenBackgroundPlaceholder, alpha: 0, completion: { didComplete in if didComplete { self.fullScreenBackgroundPlaceholder.removeFromSuperview() } @@ -480,9 +477,7 @@ final class MediaStreamVideoComponent: Component { if aspect <= 0.01 { aspect = 16.0 / 9 } - } else if self.hadVideo { - // aspect = aspect - } else { + } else if !self.hadVideo && !component.isFullscreen { aspect = 16.0 / 9 } @@ -568,7 +563,18 @@ final class MediaStreamVideoComponent: Component { if loadingBlurView.frame == .zero { loadingBlurView.frame = loadingBlurViewFrame } else { - transition.setFrame(view: loadingBlurView, frame: loadingBlurViewFrame) + // Using Transition.setFrame UIVisualEffectView causes instant update of subviews + switch videoFrameUpdateTransition.animation { + case let .curve(duration, curve): + UIView.animate(withDuration: duration, delay: 0, options: curve.containedViewLayoutTransitionCurve.viewAnimationOptions, animations: { [self] in + loadingBlurView.frame = loadingBlurViewFrame + }) + + default: + loadingBlurView.frame = loadingBlurViewFrame + + } +// transition.setFrame(view: loadingBlurView, frame: loadingBlurViewFrame) } // loadingBlurView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) From 79e6a2420fc0afb6994441db99b3a5a589fd5e82 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Fri, 6 Jan 2023 16:54:01 +0400 Subject: [PATCH 42/50] Fixing video aspect ratio --- .../Sources/Components/MediaStreamVideoComponent.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 070ddfb52a..f0ffb847a6 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -477,7 +477,7 @@ final class MediaStreamVideoComponent: Component { if aspect <= 0.01 { aspect = 16.0 / 9 } - } else if !self.hadVideo && !component.isFullscreen { + } else if !self.hadVideo { aspect = 16.0 / 9 } From 21edd2b46e0ab1470662a5f5414ff371f68ddb7f Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Sun, 8 Jan 2023 14:51:16 +0400 Subject: [PATCH 43/50] Fixing long RTL title --- .../Components/AnimatedCounterView.swift | 41 ++++++--- .../Components/MediaStreamComponent.swift | 86 +++++++++++++------ .../MediaStreamVideoComponent.swift | 4 +- .../Components/StreamSheetComponent.swift | 42 ++++++--- 4 files changed, 122 insertions(+), 51 deletions(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index 4d58e85b6b..2edf898512 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -1,6 +1,7 @@ import Foundation import UIKit import Display +import ComponentFlow private let purple = UIColor(rgb: 0xdf44b8) // 0x3252ef) private let pink = UIColor(rgb: 0x3851eb) // 0xe4436c) @@ -43,19 +44,33 @@ public final class AnimatedCountView: UIView { override public func layoutSubviews() { super.layoutSubviews() - self.foregroundView.frame = CGRect(origin: CGPoint.zero, size: bounds.size)// .insetBy(dx: -40, dy: -40) - self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60) - self.maskingView.frame = CGRect(origin: .zero, size: bounds.size) - - let subtitleHeight: CGFloat = subtitleLabel.intrinsicContentSize.height// 18 -// let counterInset: CGFloat = 8 -// let counterBottomOffset: CGFloat = subtitleHeight + counterInset - - countLabel.frame = CGRect(origin: .zero, size: CGSize(width: bounds.width, height: bounds.height)) - subtitleLabel.frame = .init(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - subtitleHeight / 2 : bounds.height - subtitleHeight, width: subtitleLabel.intrinsicContentSize.width + 20, height: subtitleHeight) -// backgroundColor = .white.withAlphaComponent(0.3) -// countLabel.backgroundColor = .red.withAlphaComponent(0.2) -// subtitleLabel.backgroundColor = .blue.withAlphaComponent(0.2) + self.updateFrames() + } + + func updateFrames(transition: ComponentFlow.Transition? = nil) { + let subtitleHeight: CGFloat = subtitleLabel.intrinsicContentSize.height + let subtitleFrame = CGRect(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - subtitleHeight / 2 : bounds.height - subtitleHeight, width: subtitleLabel.intrinsicContentSize.width + 20, height: subtitleHeight) + if let transition { + transition.setFrame(view: self.foregroundView, frame: CGRect(origin: CGPoint.zero, size: bounds.size)) + transition.setFrame(layer: self.foregroundGradientLayer, frame: CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60)) + transition.setFrame(view: self.maskingView, frame: CGRect(origin: CGPoint.zero, size: bounds.size)) + transition.setFrame(view: self.countLabel, frame: CGRect(origin: CGPoint.zero, size: bounds.size)) + transition.setFrame(view: self.subtitleLabel, frame: subtitleFrame) + } else { + self.foregroundView.frame = CGRect(origin: CGPoint.zero, size: bounds.size)// .insetBy(dx: -40, dy: -40) + self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60) + self.maskingView.frame = CGRect(origin: .zero, size: bounds.size) + // 18 + // let counterInset: CGFloat = 8 + // let counterBottomOffset: CGFloat = subtitleHeight + counterInset + + countLabel.frame = CGRect(origin: .zero, size: CGSize(width: bounds.width, height: bounds.height)) + subtitleLabel.frame = subtitleFrame + // backgroundColor = .white.withAlphaComponent(0.3) + // countLabel.backgroundColor = .red.withAlphaComponent(0.2) + // subtitleLabel.backgroundColor = .blue.withAlphaComponent(0.2) + } + } func update(countString: String, subtitle: String, fontSize: CGFloat = 48.0, gradientColors: [CGColor] = [pink.cgColor, purple.cgColor, purple.cgColor]) { diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index a88a8874ad..d0b929a2d1 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -79,6 +79,7 @@ public final class MediaStreamComponent: CombinedComponent { var videoIsPlayable: Bool { !videoStalled && hasVideo } + var wantsPiP: Bool = false let deactivatePictureInPictureIfVisible = StoredActionSlot(Void.self) @@ -170,9 +171,9 @@ public final class MediaStreamComponent: CombinedComponent { var updated = false // TODO: remove debug timer - Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in +// Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in var shouldReplaceNoViewersWithOne: Bool { true } - let membersCount = Int.random(in: 0..<10000000) // members.totalCount + let membersCount = members.totalCount // Int.random(in: 0..<10000000) // strongSelf.infoThrottler.publish(shouldReplaceNoViewersWithOne ? max(membersCount, 1) : membersCount) { [weak strongSelf] latestCount in let _ = members.totalCount guard let strongSelf = strongSelf else { return } @@ -186,7 +187,7 @@ public final class MediaStreamComponent: CombinedComponent { strongSelf.updated(transition: .immediate) } } - }.fire() +// }.fire() if state.canManageCall != strongSelf.canManageCall { strongSelf.canManageCall = state.canManageCall updated = true @@ -427,6 +428,17 @@ public final class MediaStreamComponent: CombinedComponent { var navigationRightItems: [AnyComponentWithIdentity] = [] // let videoIsPlayable = context.state.videoIsPlayable +// if state.wantsPiP && state.hasVideo { +// state.wantsPiP = false +// DispatchQueue.main.asyncAfter(deadline: .now() + 4) { +// activatePictureInPicture.invoke(Action { +// guard let controller = controller() as? MediaStreamComponentController else { +// return +// } +// controller.dismiss(closing: false, manual: true) +// }) +// } +// } if context.state.isPictureInPictureSupported /*, context.state.videoIsPlayable*/ { navigationRightItems.append(AnyComponentWithIdentity(id: "pip", component: AnyComponent(Button( @@ -442,7 +454,14 @@ public final class MediaStreamComponent: CombinedComponent { ] )), action: { [weak state] in - guard let state, state.videoIsPlayable else { return } + guard let state, state.hasVideo else { + guard let controller = controller() as? MediaStreamComponentController else { + return + } + state?.wantsPiP = true + controller.dismiss(closing: false, manual: true) + return + } activatePictureInPicture.invoke(Action { guard let controller = controller() as? MediaStreamComponentController else { @@ -569,7 +588,7 @@ public final class MediaStreamComponent: CombinedComponent { })*/ })]) controller.present(alertController, in: .window(.root)) - // TODO: спросить про dismissWithoutContent и default + dismissWithResult(.dismissWithoutContent) }), false)) } else { @@ -763,15 +782,20 @@ public final class MediaStreamComponent: CombinedComponent { // state.updateDismissOffset(value: 0.0, interactive: false) } else { if state.isPictureInPictureSupported { - activatePictureInPicture.invoke(Action { - guard let controller = controller() as? MediaStreamComponentController else { - return - } + guard let controller = controller() as? MediaStreamComponentController else { + return + } + if state.hasVideo { + activatePictureInPicture.invoke(Action { + controller.dismiss(closing: false, manual: true) + if state.displayUI { + state.toggleDisplayUI() + } + }) + } else { + state.wantsPiP = true controller.dismiss(closing: false, manual: true) - if state.displayUI { - state.toggleDisplayUI() - } - }) + } } else { guard let controller = controller() as? MediaStreamComponentController else { return @@ -1499,14 +1523,14 @@ final class StreamTitleComponent: Component { let liveIndicatorView = LiveIndicatorView() let titleLabel = UILabel() - private let titleFadeLayer = CALayer() + private var titleFadeLayer = CALayer() private let trackingLayer: HierarchyTrackingLayer private func updateTitleFadeLayer(textFrame: CGRect) { // titleLabel.backgroundColor = .red - guard let string = titleLabel.attributedText, - string.boundingRect(with: .init(width: .max, height: .max), context: nil).width > textFrame.width + guard let textBounds = titleLabel.attributedText.flatMap({ $0.boundingRect(with: CGSize(width: .max, height: .max), context: nil) }), + textBounds.width > textFrame.width else { titleLabel.layer.mask = nil titleLabel.frame = textFrame @@ -1530,31 +1554,38 @@ final class StreamTitleComponent: Component { let gradientRadius: CGFloat = 50 let solidPartLayer = CALayer() - solidPartLayer.backgroundColor = UIColor.black.cgColor + solidPartLayer.backgroundColor = UIColor.blue.cgColor - let containerWidth: CGFloat = textFrame.width +// let containerWidth: CGFloat = textFrame.width let availableWidth: CGFloat = textFrame.width - gradientRadius - let extraSpace: CGFloat = 100 if isRTL { - let adjustForRTL: CGFloat = 12 +// let adjustForRTL: CGFloat = 12 + +// let safeSolidWidth: CGFloat = containerWidth + adjustForRTL +// let widthDiff = min(textFrame.width - containerWidth) - let safeSolidWidth: CGFloat = containerWidth + adjustForRTL solidPartLayer.frame = CGRect( - origin: CGPoint(x: max(containerWidth - availableWidth, gradientRadius), y: 0), - size: CGSize(width: safeSolidWidth, height: textFrame.height)) + origin: CGPoint(x: textFrame.width + extraSpace - availableWidth, y: 0), + size: CGSize(width: availableWidth, height: textFrame.height)) + + self.titleLabel.textAlignment = .right + titleLabel.frame = CGRect(x: textFrame.minX - extraSpace, y: textFrame.minY, width: textFrame.width + extraSpace, height: textFrame.height) } else { + self.titleLabel.textAlignment = .left solidPartLayer.frame = CGRect( origin: .zero, size: CGSize(width: availableWidth, height: textFrame.height)) titleLabel.frame = CGRect(origin: textFrame.origin, size: CGSize(width: textFrame.width + extraSpace, height: textFrame.height)) } - self.titleLabel.textAlignment = .natural + titleFadeLayer.removeFromSuperlayer() + + titleFadeLayer = CALayer() titleFadeLayer.addSublayer(solidPartLayer) let gradientLayer = CAGradientLayer() - gradientLayer.colors = [UIColor.black.cgColor, UIColor.clear.cgColor] + gradientLayer.colors = [UIColor.red.cgColor, UIColor.clear.cgColor] if isRTL { gradientLayer.startPoint = CGPoint(x: 1, y: 0.5) gradientLayer.endPoint = CGPoint(x: 0, y: 0.5) @@ -1568,7 +1599,12 @@ final class StreamTitleComponent: Component { titleFadeLayer.masksToBounds = false titleFadeLayer.frame = titleLabel.bounds + +// titleLabel.layer.addSublayer(titleFadeLayer) +// titleFadeLayer.opacity = 0.4 + titleLabel.layer.mask = titleFadeLayer +// titleLabel.backgroundColor = .green } override init(frame: CGRect) { diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index f0ffb847a6..795a596d74 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -261,7 +261,7 @@ final class MediaStreamVideoComponent: Component { } else { if hadVideo && !isAnimating && loadingBlurView.layer.opacity == 1 { let anim = CABasicAnimation(keyPath: "opacity") - anim.duration = 0.35 + anim.duration = 0.4 anim.fromValue = 1.0 anim.toValue = 0.0 self.loadingBlurView.layer.opacity = 0 @@ -572,9 +572,7 @@ final class MediaStreamVideoComponent: Component { default: loadingBlurView.frame = loadingBlurViewFrame - } -// transition.setFrame(view: loadingBlurView, frame: loadingBlurViewFrame) } // loadingBlurView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 7328d718bd..8dd321d352 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -215,13 +215,12 @@ final class StreamSheetComponent: CombinedComponent { let videoHeight = context.component.videoHeight let sheetHeight = context.component.sheetHeight let animatedParticipantsVisible = !isFullscreen// context.component.participantsCount != -1 - if true { - context.add(viewerCounter - .position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50 + videoHeight + (sheetHeight - 69 - videoHeight - 50 - context.component.bottomPadding) / 2 - 10)) - .opacity(animatedParticipantsVisible ? 1 : 0) -// .animation(key: "position") - ) - } + + context.add(viewerCounter + .position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50 + videoHeight + (sheetHeight - 69 - videoHeight - 50 - context.component.bottomPadding) / 2 - 10)) + .opacity(animatedParticipantsVisible ? 1 : 0) + // .animation(key: "position") + ) if let bottomButtonsRow = bottomButtonsRow { context.add(bottomButtonsRow @@ -321,7 +320,16 @@ final class SheetBackgroundComponent: Component { final class ParticipantsComponent: Component { static func == (lhs: ParticipantsComponent, rhs: ParticipantsComponent) -> Bool { - lhs.count == rhs.count + if lhs.count != rhs.count { + return false + } + if lhs.showsSubtitle != rhs.showsSubtitle { + return false + } + if lhs.fontSize != rhs.fontSize { + return false + } + return true } func makeView() -> View { @@ -335,6 +343,21 @@ final class ParticipantsComponent: Component { fontSize: self.fontSize, gradientColors: self.gradientColors )// environment.strings.LiveStream_NoViewers) + switch transition.animation { + case let .curve(duration, curve): + UIView.animate(withDuration: duration, delay: 0, options: curve.containedViewLayoutTransitionCurve.viewAnimationOptions, animations: { + view.bounds.size = availableSize + view.counter.frame.size = availableSize + view.counter.updateFrames(transition: transition) +// view.counter.setNeedsLayout() +// view.counter.setNeedsDisplay() + }) + + default: + view.bounds.size = availableSize + view.counter.frame.size = availableSize + view.counter.updateFrames() + } return availableSize } @@ -356,12 +379,11 @@ final class ParticipantsComponent: Component { override init(frame: CGRect) { super.init(frame: frame) self.addSubview(counter) - counter.clipsToBounds = false } override func layoutSubviews() { super.layoutSubviews() - self.counter.frame = self.bounds +// self.counter.frame = self.bounds } required init?(coder: NSCoder) { From cd4dc4b1c076089dd3b7a19d01402931d496bddb Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Sun, 8 Jan 2023 17:07:03 +0400 Subject: [PATCH 44/50] Cleanup --- .../Telegram-iOS/en.lproj/Localizable.strings | 5 + .../Components/AnimatedCounterView.swift | 3 +- .../Components/MediaStreamComponent.swift | 1319 +++++++---------- .../Components/ParticipantsComponent.swift | 78 + .../Components/StreamSheetComponent.swift | 156 +- 5 files changed, 674 insertions(+), 887 deletions(-) create mode 100644 submodules/TelegramCallsUI/Sources/Components/ParticipantsComponent.swift diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 075a3a5af3..fa9675efb6 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -5820,6 +5820,8 @@ Sorry for the inconvenience."; "VoiceChat.Audio" = "audio"; "VoiceChat.Leave" = "leave"; +"LiveStream.Expand" = "expand"; + "VoiceChat.SpeakPermissionEveryone" = "New participants can speak"; "VoiceChat.SpeakPermissionAdmin" = "New paricipants are muted"; "VoiceChat.Share" = "Share Invite Link"; @@ -5958,6 +5960,8 @@ Sorry for the inconvenience."; "VoiceChat.StopRecordingTitle" = "Stop Recording?"; "VoiceChat.StopRecordingStop" = "Stop Recording"; +"LiveStream.StopLiveStream" = "Stop Live Stream"; + "VoiceChat.RecordingSaved" = "Audio saved to **Saved Messages**."; "VoiceChat.StatusMutedForYou" = "muted for you"; @@ -7415,6 +7419,7 @@ Sorry for the inconvenience."; "LiveStream.NoViewers" = "No viewers"; "LiveStream.ViewerCount_1" = "1 viewer"; "LiveStream.ViewerCount_any" = "%@ viewers"; +"LiveStream.Watching" = "watching"; "LiveStream.NoSignalAdminText" = "Oops! Telegram doesn't see any stream\ncoming from your streaming app.\n\nPlease make sure you entered the right Server\nURL and Stream Key in your app."; "LiveStream.NoSignalUserText" = "%@ is currently not broadcasting live\nstream data to Telegram."; diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index 2edf898512..5d75dcd03b 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -22,7 +22,6 @@ public final class AnimatedCountView: UIView { super.init(frame: frame) self.foregroundGradientLayer.type = .radial -// self.foregroundGradientLayer.colors = [pink.cgColor, purple.cgColor, purple.cgColor] self.foregroundGradientLayer.locations = [0.0, 0.85, 1.0] self.foregroundGradientLayer.startPoint = CGPoint(x: 1.0, y: 0.0) self.foregroundGradientLayer.endPoint = CGPoint(x: 0.0, y: 1.0) @@ -49,7 +48,7 @@ public final class AnimatedCountView: UIView { func updateFrames(transition: ComponentFlow.Transition? = nil) { let subtitleHeight: CGFloat = subtitleLabel.intrinsicContentSize.height - let subtitleFrame = CGRect(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: subtitleLabel.text == "No viewers" ? bounds.midY - subtitleHeight / 2 : bounds.height - subtitleHeight, width: subtitleLabel.intrinsicContentSize.width + 20, height: subtitleHeight) + let subtitleFrame = CGRect(x: bounds.midX - subtitleLabel.intrinsicContentSize.width / 2 - 10, y: self.countLabel.attributedText?.length == 0 ? bounds.midY - subtitleHeight / 2 : bounds.height - subtitleHeight, width: subtitleLabel.intrinsicContentSize.width + 20, height: subtitleHeight) if let transition { transition.setFrame(view: self.foregroundView, frame: CGRect(origin: CGPoint.zero, size: bounds.size)) transition.setFrame(layer: self.foregroundGradientLayer, frame: CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60)) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index d0b929a2d1..df0efb8f55 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -58,10 +58,8 @@ public final class MediaStreamComponent: CombinedComponent { var isFullscreen: Bool = false var videoSize: CGSize? var prevFullscreenOrientation: UIDeviceOrientation? - var didAutoDismissForPiP: Bool = false private(set) var canManageCall: Bool = false - // TODO: also handle pictureInPicturePossible let isPictureInPictureSupported: Bool private(set) var callTitle: String? @@ -79,7 +77,7 @@ public final class MediaStreamComponent: CombinedComponent { var videoIsPlayable: Bool { !videoStalled && hasVideo } - var wantsPiP: Bool = false +// var wantsPiP: Bool = false let deactivatePictureInPictureIfVisible = StoredActionSlot(Void.self) @@ -112,53 +110,6 @@ public final class MediaStreamComponent: CombinedComponent { } strongSelf.hasVideo = true strongSelf.updated(transition: .immediate) - - /*let engine = strongSelf.call.accountContext.engine - guard let info = strongSelf.call.initialCall else { - return - } - let _ = (engine.calls.getAudioBroadcastDataSource(callId: info.id, accessHash: info.accessHash) - |> mapToSignal { source -> Signal in - guard let source else { - return .single(nil) - } - - let time = engine.calls.requestStreamState(dataSource: source, callId: info.id, accessHash: info.accessHash) - |> map { state -> Int64? in - guard let state else { - return nil - } - return state.channels.first?.latestTimestamp - } - - return time - |> mapToSignal { latestTimestamp -> Signal in - guard let latestTimestamp else { - return .single(nil) - } - - let durationMilliseconds: Int64 = 32000 - let bufferOffset: Int64 = 1 * durationMilliseconds - let timestampId = latestTimestamp - bufferOffset - - return engine.calls.getVideoBroadcastPart(dataSource: source, callId: info.id, accessHash: info.accessHash, timestampIdMilliseconds: timestampId, durationMilliseconds: durationMilliseconds, channelId: 2, quality: 0) - |> mapToSignal { result -> Signal in - switch result.status { - case let .data(data): - return .single(data) - case .notReady, .resyncNeeded, .rejoinNeeded: - return .single(nil) - } - } - } - } - |> deliverOnMainQueue).start(next: { [weak self] data in - guard let self, let data else { - return - } - let _ = self - let _ = data - })*/ }) let callPeer = call.accountContext.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: call.peerId)) @@ -282,14 +233,12 @@ public final class MediaStreamComponent: CombinedComponent { return State(call: self.call) } - public static var body: Body { + class Local { let background = Child(Rectangle.self) let dismissTapComponent = Child(Rectangle.self) let video = Child(MediaStreamVideoComponent.self) let sheet = Child(StreamSheetComponent.self) -// let fullscreenOverlay = Child(StreamSheetComponent.self) let topItem = Child(environment: Empty.self) -// let viewerCounter = Child(ParticipantsComponent.self) let fullscreenBottomItem = Child(environment: Empty.self) let buttonsRow = Child(environment: Empty.self) @@ -297,8 +246,31 @@ public final class MediaStreamComponent: CombinedComponent { let deactivatePictureInPicture = StoredActionSlot(Void.self) let moreButtonTag = GenericComponentViewTag() let moreAnimationTag = GenericComponentViewTag() + } + + public static var body: Body { + let local = Local() return { context in + _body(context, local) // { context in + } + } + + private static func _body(_ context: CombinedComponentContext, _ local: Local) -> CGSize { + let background = local.background + let dismissTapComponent = local.dismissTapComponent + let video = local.video + let sheet = local.sheet + let topItem = local.topItem + let fullscreenBottomItem = local.fullscreenBottomItem + let buttonsRow = local.buttonsRow + + let activatePictureInPicture = local.activatePictureInPicture + let deactivatePictureInPicture = local.deactivatePictureInPicture + let moreButtonTag = local.moreButtonTag + let moreAnimationTag = local.moreAnimationTag + + func makeBody() -> CGSize { let canEnforceOrientation = UIDevice.current.model != "iPad" var forceFullScreenInLandscape: Bool { canEnforceOrientation && true } let environment = context.environment[ViewControllerComponentContainer.Environment.self].value @@ -318,17 +290,10 @@ public final class MediaStreamComponent: CombinedComponent { let controller = environment.controller context.state.deactivatePictureInPictureIfVisible.connect { - guard let controller = controller() else { - return - } - if controller.view.window == nil { - if state.didAutoDismissForPiP { - state.updated(transition: .easeInOut(duration: 3)) - deactivatePictureInPicture.invoke(Void()) -// call.accountContext.sharedContext.mainWindow?.inCallNavigate?() - } + guard let controller = controller(), controller.view.window != nil else { return } + state.updated(transition: .easeInOut(duration: 3)) deactivatePictureInPicture.invoke(Void()) } @@ -336,7 +301,6 @@ public final class MediaStreamComponent: CombinedComponent { let isLandscape = context.availableSize.width > context.availableSize.height // Always fullscreen in landscape - // TODO: support landscape sheet (wrap in scrollview, video size same as portrait) if forceFullScreenInLandscape && isLandscape && !state.isFullscreen { state.isFullscreen = true isFullscreen = true @@ -350,18 +314,18 @@ public final class MediaStreamComponent: CombinedComponent { let videoInset: CGFloat if !isFullscreen { - videoInset = 16 + videoInset = 16.0 } else { - videoInset = 0 + videoInset = 0.0 } let videoHeight: CGFloat = forceFullScreenInLandscape - ? (context.availableSize.width - videoInset * 2) / 16 * 9 - : context.state.videoSize?.height ?? (min(context.availableSize.width, context.availableSize.height) - videoInset * 2) / 16 * 9 + ? (context.availableSize.width - videoInset * 2) / 16 * 9 + : context.state.videoSize?.height ?? (min(context.availableSize.width, context.availableSize.height) - videoInset * 2) / 16.0 * 9.0 let bottomPadding = 32.0 + environment.safeInsets.bottom let requiredSheetHeight: CGFloat = isFullscreen - ? context.availableSize.height - : (44 + videoHeight + 40 + 69 + 16 + 32 + 70 + bottomPadding + 8) + ? context.availableSize.height + : (44.0 + videoHeight + 40.0 + 69.0 + 16.0 + 32.0 + 70.0 + bottomPadding + 8.0) let safeAreaTopInView: CGFloat if #available(iOS 16.0, *) { @@ -370,7 +334,7 @@ public final class MediaStreamComponent: CombinedComponent { safeAreaTopInView = context.view.safeAreaInsets.top } - let isFullyDragged = context.availableSize.height - requiredSheetHeight + state.dismissOffset - safeAreaTopInView < 30 + let isFullyDragged = context.availableSize.height - requiredSheetHeight + state.dismissOffset - safeAreaTopInView < 30.0 var dragOffset = context.state.dismissOffset if isFullyDragged { @@ -383,8 +347,8 @@ public final class MediaStreamComponent: CombinedComponent { availableSize: CGSize(width: context.availableSize.width, height: dismissTapAreaHeight), transition: context.transition ) -// (controller() as? MediaStreamComponentController)?.prefersOnScreenNavigationHidden = isFullscreen -// (controller() as? MediaStreamComponentController)?.window?.invalidatePrefersOnScreenNavigationHidden() + // (controller() as? MediaStreamComponentController)?.prefersOnScreenNavigationHidden = isFullscreen + // (controller() as? MediaStreamComponentController)?.window?.invalidatePrefersOnScreenNavigationHidden() let video = video.update( component: MediaStreamVideoComponent( call: context.component.call, @@ -427,38 +391,38 @@ public final class MediaStreamComponent: CombinedComponent { var navigationRightItems: [AnyComponentWithIdentity] = [] -// let videoIsPlayable = context.state.videoIsPlayable -// if state.wantsPiP && state.hasVideo { -// state.wantsPiP = false -// DispatchQueue.main.asyncAfter(deadline: .now() + 4) { -// activatePictureInPicture.invoke(Action { -// guard let controller = controller() as? MediaStreamComponentController else { -// return -// } -// controller.dismiss(closing: false, manual: true) -// }) -// } -// } + // let videoIsPlayable = context.state.videoIsPlayable + // if state.wantsPiP && state.hasVideo { + // state.wantsPiP = false + // DispatchQueue.main.asyncAfter(deadline: .now() + 4) { + // activatePictureInPicture.invoke(Action { + // guard let controller = controller() as? MediaStreamComponentController else { + // return + // } + // controller.dismiss(closing: false, manual: true) + // }) + // } + // } - if context.state.isPictureInPictureSupported /*, context.state.videoIsPlayable*/ { + if context.state.isPictureInPictureSupported { navigationRightItems.append(AnyComponentWithIdentity(id: "pip", component: AnyComponent(Button( content: AnyComponent(ZStack([ - AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( - fillColor: .white.withAlphaComponent(0.08), - size: CGSize(width: 32.0, height: 32.0) - ))), - AnyComponentWithIdentity(id: "a", component: AnyComponent(BundleIconComponent( - name: "Call/pip", - tintColor: .white // .withAlphaComponent(context.state.videoIsPlayable ? 1.0 : 0.6) - ))) - ] - )), + AnyComponentWithIdentity(id: "b", component: AnyComponent(Circle( + fillColor: .white.withAlphaComponent(0.08), + size: CGSize(width: 32.0, height: 32.0) + ))), + AnyComponentWithIdentity(id: "a", component: AnyComponent(BundleIconComponent( + name: "Call/pip", + tintColor: .white // .withAlphaComponent(context.state.videoIsPlayable ? 1.0 : 0.6) + ))) + ] + )), action: { [weak state] in guard let state, state.hasVideo else { guard let controller = controller() as? MediaStreamComponentController else { return } - state?.wantsPiP = true + // state?.wantsPiP = true controller.dismiss(closing: false, manual: true) return } @@ -527,10 +491,10 @@ public final class MediaStreamComponent: CombinedComponent { let initialTitle = state.callTitle ?? "" let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } - + let title: String = presentationData.strings.LiveStream_EditTitle let text: String = presentationData.strings.LiveStream_EditTitleText - + let editController = voiceChatTitleEditController(sharedContext: call.accountContext.sharedContext, account: call.accountContext.account, forceTheme: defaultDarkPresentationTheme, title: title, text: text, placeholder: EnginePeer(chatPeer).displayTitle(strings: presentationData.strings, displayOrder: presentationData.nameDisplayOrder), value: initialTitle, maxLength: 40, apply: { [weak call] title in guard let call = call else { return @@ -540,9 +504,9 @@ public final class MediaStreamComponent: CombinedComponent { if let title = title, title != initialTitle { call.updateTitle(title) - + let text: String = title.isEmpty ? presentationData.strings.LiveStream_EditTitleRemoveSuccess : presentationData.strings.LiveStream_EditTitleSuccess(title).string - + let _ = text //strongSelf.presentUndoOverlay(content: .voiceChatFlag(text: text), action: { _ in return false }) } @@ -648,7 +612,7 @@ public final class MediaStreamComponent: CombinedComponent { a(.default) }))) - items.append(.action(ContextMenuActionItem(id: nil, text: /*presentationData.strings.VoiceChat_StopRecordingStop*/"Stop Live Stream", textColor: .destructive, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in + items.append(.action(ContextMenuActionItem(id: nil, text: presentationData.strings.LiveStream_StopLiveStream, textColor: .destructive, textLayout: .singleLine, textFont: .regular, badge: nil, icon: { theme in return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.contextMenu.destructiveColor, backgroundColor: nil) }, action: { [weak call] _, a in guard let call = call else { @@ -727,15 +691,16 @@ public final class MediaStreamComponent: CombinedComponent { }*/ controller.presentInGlobalOverlay(contextController) } - ).minSize(CGSize(width: 44.0, height: 44.0)).tagged(moreButtonTag))//)//) + ).minSize(CGSize(width: 44.0, height: 44.0)).tagged(moreButtonTag)) } + let navigationComponent = NavigationBarComponent( topInset: environment.statusBarHeight, sideInset: environment.safeInsets.left, backgroundVisible: isFullscreen, leftItem: topLeftButton, rightItems: navigationRightItems, - centerItem: AnyComponent(StreamTitleComponent(text: state.callTitle ?? state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isActive: context.state.videoIsPlayable)) + centerItem: AnyComponent(StreamTitleComponent(text: state.callTitle ?? state.peerTitle, isRecording: state.recordingStartTimestamp != nil, isLive: context.state.videoIsPlayable)) ) if context.state.storedIsFullscreen != isFullscreen { @@ -770,7 +735,7 @@ public final class MediaStreamComponent: CombinedComponent { if state.isFullscreen { state.isFullscreen = false state.prevFullscreenOrientation = UIDevice.current.orientation - state.dismissOffset = 0.0// updateDismissOffset(value: 0.0, interactive: false) + state.dismissOffset = 0.0 if canEnforceOrientation, let controller = controller() as? MediaStreamComponentController { controller.updateOrientation(orientation: .portrait) } else { @@ -779,7 +744,6 @@ public final class MediaStreamComponent: CombinedComponent { } else { if isFullyDragged || state.initialOffset != 0 { state.updateDismissOffset(value: 0.0, interactive: false) -// state.updateDismissOffset(value: 0.0, interactive: false) } else { if state.isPictureInPictureSupported { guard let controller = controller() as? MediaStreamComponentController else { @@ -793,7 +757,7 @@ public final class MediaStreamComponent: CombinedComponent { } }) } else { - state.wantsPiP = true + // state.wantsPiP = true controller.dismiss(closing: false, manual: true) } } else { @@ -802,7 +766,6 @@ public final class MediaStreamComponent: CombinedComponent { } controller.dismiss(closing: false, manual: true) } -// let _ = call.leave(terminateIfPossible: false) } } } else { @@ -828,9 +791,9 @@ public final class MediaStreamComponent: CombinedComponent { } state.toggleDisplayUI() }) - .gesture(.pan { panState in - onPanGesture(panState) - }) + .gesture(.pan { panState in + onPanGesture(panState) + }) ) context.add(dismissTapComponent @@ -840,342 +803,237 @@ public final class MediaStreamComponent: CombinedComponent { return } controller.dismiss(closing: false, manual: true) - // _ = call.leave(terminateIfPossible: false) }) - .gesture(.pan(onPanGesture)) + .gesture(.pan(onPanGesture)) ) - if !isFullscreen || state.isFullscreen { - let imageRenderScale = UIScreen.main.scale - let bottomComponent = AnyComponent(ButtonsRowComponent( - bottomInset: environment.safeInsets.bottom, - sideInset: environment.safeInsets.left, - leftItem: AnyComponent(Button( - content: AnyComponent(RoundGradientButtonComponent(// BundleIconComponent( - gradientColors: [ - UIColor(red: 0.165, green: 0.173, blue: 0.357, alpha: 1).cgColor -// UIColor(red: 0.18, green: 0.17, blue: 0.30, alpha: 1).cgColor, -// UIColor(red: 0.17, green: 0.16, blue: 0.30, alpha: 1).cgColor - ], - image: generateTintedImage(image: UIImage(bundleImageName: "Call/CallShareButton"), color: .white), - // TODO: localize: - title: "share")), - action: { - guard let controller = controller() as? MediaStreamComponentController else { - return - } - controller.presentShare() + let presentationData = call.accountContext.sharedContext.currentPresentationData.with { $0 } + + let imageRenderScale = UIScreen.main.scale + let bottomComponent = AnyComponent(ButtonsRowComponent( + bottomInset: environment.safeInsets.bottom, + sideInset: environment.safeInsets.left, + leftItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent( + gradientColors: [UIColor(red: 0.165, green: 0.173, blue: 0.357, alpha: 1).cgColor], + image: generateTintedImage(image: UIImage(bundleImageName: "Call/CallShareButton"), color: .white), + // TODO: localize: + title: presentationData.strings.VoiceChat_ShareShort)), + action: { + guard let controller = controller() as? MediaStreamComponentController else { + return } - ).minSize(CGSize(width: 65, height: 80))), - rightItem: AnyComponent(Button( - content: AnyComponent(RoundGradientButtonComponent( - gradientColors: [ - UIColor(red: 0.314, green: 0.161, blue: 0.197, alpha: 1).cgColor -// UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor, -// UIColor(red: 0.44, green: 0.18, blue: 0.22, alpha: 1).cgColor - ], - image: generateImage(CGSize(width: 44.0 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in - context.translateBy(x: size.width / 2, y: size.height / 2) - context.scaleBy(x: 0.4, y: 0.4) - context.translateBy(x: -size.width / 2, y: -size.height / 2) - let imageColor = UIColor.white - let bounds = CGRect(origin: CGPoint(), size: size) - context.clear(bounds) - let lineWidth: CGFloat = size.width / 7 - context.setLineWidth(lineWidth - UIScreenPixel) - context.setLineCap(.round) - context.setStrokeColor(imageColor.cgColor) - - context.move(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: lineWidth / 2 + UIScreenPixel)) - context.addLine(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) - context.strokePath() - - context.move(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: lineWidth / 2 + UIScreenPixel)) - context.addLine(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) - context.strokePath() - }), - title: "leave" - )), - action: { [weak call] in - let _ = call?.leave(terminateIfPossible: false) - } - ).minSize(CGSize(width: 44.0, height: 44.0))), - centerItem: AnyComponent(Button( - content: AnyComponent(RoundGradientButtonComponent( - gradientColors: [ - UIColor(red: 0.165, green: 0.173, blue: 0.357, alpha: 1).cgColor -// UIColor(red: 0.23, green: 0.17, blue: 0.29, alpha: 1).cgColor, -// UIColor(red: 0.21, green: 0.16, blue: 0.29, alpha: 1).cgColor - ], - image: generateImage(CGSize(width: 44 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in - - let imageColor = UIColor.white - let bounds = CGRect(origin: CGPoint(), size: size) - context.clear(bounds) - - context.setLineWidth(2.4 * imageRenderScale - UIScreenPixel) - context.setLineCap(.round) - context.setStrokeColor(imageColor.cgColor) - - let lineSide = size.width / 5 - let centerOffset = size.width / 20 - context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - centerOffset / 2)) - context.addLine(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) - context.addLine(to: CGPoint(x: size.width / 2 + centerOffset / 2, y: size.height / 2 - lineSide)) - context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) - context.addLine(to: CGPoint(x: size.width / 2 + centerOffset, y: size.height / 2 - centerOffset)) - context.strokePath() - - context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + centerOffset / 2)) - context.addLine(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) - context.addLine(to: CGPoint(x: size.width / 2 - centerOffset / 2, y: size.height / 2 + lineSide)) - context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) - context.addLine(to: CGPoint(x: size.width / 2 - centerOffset, y: size.height / 2 + centerOffset)) - context.strokePath() - }), - title: "expand" - )), - action: { [weak state] in - guard let state = state else { return } -// guard state.videoIsPlayable else { -// state.isFullscreen = false -// return -// } - if let controller = controller() as? MediaStreamComponentController { -// guard let _ = state.videoSize else { return } - state.isFullscreen.toggle() - if state.isFullscreen { - state.dismissOffset = 0.0 -// if size.width > size.height { - let currentOrientation = state.prevFullscreenOrientation ?? UIDevice.current.orientation - switch currentOrientation { - case .landscapeLeft: - controller.updateOrientation(orientation: .landscapeRight) - case .landscapeRight: - controller.updateOrientation(orientation: .landscapeLeft) - default: - controller.updateOrientation(orientation: .landscapeRight) - } -// } else { -// controller.updateOrientation(orientation: .portrait) -// } - } else { - state.prevFullscreenOrientation = UIDevice.current.orientation - // TODO: Check and mind current device orientation - controller.updateOrientation(orientation: .portrait) - } - if !canEnforceOrientation { - state.updated(transition: .easeInOut(duration: 0.25)) + controller.presentShare() + } + ).minSize(CGSize(width: 65, height: 80))), + rightItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent( + gradientColors: [ + UIColor(red: 0.314, green: 0.161, blue: 0.197, alpha: 1).cgColor + ], + image: generateImage(CGSize(width: 44.0 * imageRenderScale, height: 44 * imageRenderScale), opaque: false, rotatedContext: { size, context in + context.translateBy(x: size.width / 2, y: size.height / 2) + context.scaleBy(x: 0.4, y: 0.4) + context.translateBy(x: -size.width / 2, y: -size.height / 2) + let imageColor = UIColor.white + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + let lineWidth: CGFloat = size.width / 7 + context.setLineWidth(lineWidth - UIScreenPixel) + context.setLineCap(.round) + context.setStrokeColor(imageColor.cgColor) + + context.move(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: lineWidth / 2 + UIScreenPixel)) + context.addLine(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) + context.strokePath() + + context.move(to: CGPoint(x: size.width - lineWidth / 2 - UIScreenPixel, y: lineWidth / 2 + UIScreenPixel)) + context.addLine(to: CGPoint(x: lineWidth / 2 + UIScreenPixel, y: size.height - lineWidth / 2 - UIScreenPixel)) + context.strokePath() + }), + title: presentationData.strings.VoiceChat_Leave + )), + action: { [weak call] in + let _ = call?.leave(terminateIfPossible: false) + } + ).minSize(CGSize(width: 44.0, height: 44.0))), + centerItem: AnyComponent(Button( + content: AnyComponent(RoundGradientButtonComponent( + gradientColors: [ + UIColor(red: 0.165, green: 0.173, blue: 0.357, alpha: 1).cgColor + ], + image: generateImage(CGSize(width: 44 * imageRenderScale, height: 44.0 * imageRenderScale), opaque: false, rotatedContext: { size, context in + + let imageColor = UIColor.white + let bounds = CGRect(origin: CGPoint(), size: size) + context.clear(bounds) + + context.setLineWidth(2.4 * imageRenderScale - UIScreenPixel) + context.setLineCap(.round) + context.setStrokeColor(imageColor.cgColor) + + let lineSide = size.width / 5 + let centerOffset = size.width / 20 + context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - centerOffset / 2)) + context.addLine(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 + centerOffset / 2, y: size.height / 2 - lineSide)) + context.move(to: CGPoint(x: size.width / 2 + lineSide, y: size.height / 2 - lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 + centerOffset, y: size.height / 2 - centerOffset)) + context.strokePath() + + context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + centerOffset / 2)) + context.addLine(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 - centerOffset / 2, y: size.height / 2 + lineSide)) + context.move(to: CGPoint(x: size.width / 2 - lineSide, y: size.height / 2 + lineSide)) + context.addLine(to: CGPoint(x: size.width / 2 - centerOffset, y: size.height / 2 + centerOffset)) + context.strokePath() + }), + title: presentationData.strings.LiveStream_Expand + )), + action: { [weak state] in + guard let state = state else { return } + + if let controller = controller() as? MediaStreamComponentController { + state.isFullscreen.toggle() + if state.isFullscreen { + state.dismissOffset = 0.0 + let currentOrientation = state.prevFullscreenOrientation ?? UIDevice.current.orientation + switch currentOrientation { + case .landscapeLeft: + controller.updateOrientation(orientation: .landscapeRight) + case .landscapeRight: + controller.updateOrientation(orientation: .landscapeLeft) + default: + controller.updateOrientation(orientation: .landscapeRight) } + } else { + state.prevFullscreenOrientation = UIDevice.current.orientation + controller.updateOrientation(orientation: .portrait) + } + if !canEnforceOrientation { + state.updated(transition: .easeInOut(duration: 0.25)) } } - ).minSize(CGSize(width: 44.0, height: 44.0))) - )) - - let sheetHeight: CGFloat = max(requiredSheetHeight - dragOffset, requiredSheetHeight) - let topOffset: CGFloat = isFullscreen - ? max(context.state.dismissOffset, 0) - : (context.availableSize.height - requiredSheetHeight + dragOffset) - - let sheet = sheet.update( - component: StreamSheetComponent( - topComponent: AnyComponent(navigationComponent), - bottomButtonsRow: bottomComponent, - topOffset: topOffset, - sheetHeight: sheetHeight, - backgroundColor: (isFullscreen && !state.hasVideo) ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), - bottomPadding: bottomPadding, - participantsCount: context.state.originInfo?.memberCount ?? 0, // Int.random(in: 0...999998)// [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! - isFullyExtended: isFullyDragged, - deviceCornerRadius: ((controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 1) - 1, - videoHeight: videoHeight, - isFullscreen: isFullscreen, - fullscreenTopComponent: AnyComponent(navigationComponent), - fullscreenBottomComponent: bottomComponent - ), - availableSize: context.availableSize, - transition: context.transition - ) - - // let sheetOffset: CGFloat = context.availableSize.height - requiredSheetHeight + dragOffset - // let sheetPosition = sheetOffset + requiredSheetHeight / 2 - // Sheet underneath the video when in modal sheet - context.add(sheet - .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) - ) - - // - // - // - var availableWidth: CGFloat { context.availableSize.width } - var contentHeight: CGFloat { 44.0 } -// print(topItem) - // let size = context.availableSize - - let topItem = topItem.update( - component: AnyComponent(navigationComponent), - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - - let fullScreenToolbarComponent = AnyComponent(ToolbarComponent( - bottomInset: environment.safeInsets.bottom, - sideInset: environment.safeInsets.left, - leftItem: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Chat/Input/Accessory Panels/MessageSelectionForward", - tintColor: .white - )), - action: { - guard let controller = controller() as? MediaStreamComponentController else { - return - } - controller.presentShare() + } + ).minSize(CGSize(width: 44.0, height: 44.0))) + )) + + let sheetHeight: CGFloat = max(requiredSheetHeight - dragOffset, requiredSheetHeight) + let topOffset: CGFloat = isFullscreen + ? max(context.state.dismissOffset, 0) + : (context.availableSize.height - requiredSheetHeight + dragOffset) + + let sheet = sheet.update( + component: StreamSheetComponent( + topOffset: topOffset, + sheetHeight: sheetHeight, + backgroundColor: (isFullscreen && !state.hasVideo) ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), + bottomPadding: bottomPadding, + participantsCount: context.state.originInfo?.memberCount ?? 0, // Int.random(in: 0...999998) // [0, 5, 15, 16, 95, 100, 16042, 942539].randomElement()! + isFullyExtended: isFullyDragged, + deviceCornerRadius: ((controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 1) - 1, + videoHeight: videoHeight, + isFullscreen: isFullscreen, + fullscreenTopComponent: AnyComponent(navigationComponent), + fullscreenBottomComponent: bottomComponent + ), + availableSize: context.availableSize, + transition: context.transition + ) + + context.add(sheet + .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) + ) + + var availableWidth: CGFloat { context.availableSize.width } + var contentHeight: CGFloat { 44.0 } + + let topItem = topItem.update( + component: AnyComponent(navigationComponent), + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + + let fullScreenToolbarComponent = AnyComponent(ToolbarComponent( + bottomInset: environment.safeInsets.bottom, + sideInset: environment.safeInsets.left, + leftItem: AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: "Chat/Input/Accessory Panels/MessageSelectionForward", + tintColor: .white + )), + action: { + guard let controller = controller() as? MediaStreamComponentController else { + return } - ).minSize(CGSize(width: 64.0, height: 80))), - rightItem: /*state.hasVideo ?*/ AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: isFullscreen ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", - tintColor: .white - )), - action: { - state.isFullscreen = false - state.prevFullscreenOrientation = UIDevice.current.orientation - if let controller = controller() as? MediaStreamComponentController { - if canEnforceOrientation { - controller.updateOrientation(orientation: .portrait) - } else { - state.updated(transition: .easeInOut(duration: 0.25)) // updated(.easeInOut(duration: 0.3)) - } + controller.presentShare() + } + ).minSize(CGSize(width: 64.0, height: 80))), + rightItem: /*state.hasVideo ?*/ AnyComponent(Button( + content: AnyComponent(BundleIconComponent( + name: isFullscreen ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", + tintColor: .white + )), + action: { + state.isFullscreen = false + state.prevFullscreenOrientation = UIDevice.current.orientation + if let controller = controller() as? MediaStreamComponentController { + if canEnforceOrientation { + controller.updateOrientation(orientation: .portrait) + } else { + state.updated(transition: .easeInOut(duration: 0.25)) } } - ).minSize(CGSize(width: 64.0, height: 80)))/* : nil*/, - centerItem: infoItem - )) - - let buttonsRow = buttonsRow.update( - component: bottomComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - - let fullscreenBottomItem = fullscreenBottomItem.update( - component: fullScreenToolbarComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - - let videoPos: CGFloat - - if isFullscreen { - videoPos = context.availableSize.height / 2 + dragOffset - } else { - videoPos = /*sheetPosition - requiredSheetHeight / 2*/topOffset + 28.0 + 28.0 + videoHeight / 2 // + 50 + 12 - } - context.add(video - .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)) - ) - - context.add(topItem - .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + (isFullscreen ? topItem.size.height / 2.0 : 28.0))) - .opacity((!isFullscreen || state.displayUI) ? 1 : 0) - .gesture(.pan { panState in - onPanGesture(panState) - }) -// .animation(key: "position") - ) - - context.add(buttonsRow - .opacity(isFullscreen ? 0 : 1) -// .animation(key: "opacity") - .position(CGPoint(x: buttonsRow.size.width / 2, y: sheetHeight - 50 / 2 + topOffset - bottomPadding)) - ) - - context.add(fullscreenBottomItem - .opacity((isFullscreen && state.displayUI) ? 1 : 0) -// .animation(key: "opacity") - .position(CGPoint(x: fullscreenBottomItem.size.width / 2, y: context.availableSize.height - fullscreenBottomItem.size.height / 2 + topOffset - 0.0)) - ) - // - // - // + } + ).minSize(CGSize(width: 64.0, height: 80.0))), + centerItem: infoItem + )) + + let buttonsRow = buttonsRow.update( + component: bottomComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + + let fullscreenBottomItem = fullscreenBottomItem.update( + component: fullScreenToolbarComponent, + availableSize: CGSize(width: availableWidth, height: contentHeight), + transition: context.transition + ) + + let videoPos: CGFloat + + if isFullscreen { + videoPos = context.availableSize.height / 2 + dragOffset } else { - /*let fullScreenToolbarComponent = AnyComponent(ToolbarComponent( - bottomInset: environment.safeInsets.bottom, - sideInset: environment.safeInsets.left, - leftItem: AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: "Chat/Input/Accessory Panels/MessageSelectionForward", - tintColor: .white - )), - action: { - guard let controller = controller() as? MediaStreamComponentController else { - return - } - controller.presentShare() - } - ).minSize(CGSize(width: 64.0, height: 80))), - rightItem: /*state.hasVideo ?*/ AnyComponent(Button( - content: AnyComponent(BundleIconComponent( - name: isFullscreen ? "Media Gallery/Minimize" : "Media Gallery/Fullscreen", - tintColor: .white - )), - action: { - state.isFullscreen = false - state.prevFullscreenOrientation = UIDevice.current.orientation - if let controller = controller() as? MediaStreamComponentController { - if canEnforceOrientation { - controller.updateOrientation(orientation: .portrait) - } else { - state.updated(transition: .easeInOut(duration: 0.25)) // updated(.easeInOut(duration: 0.3)) - } - } - } - ).minSize(CGSize(width: 64.0, height: 80)))/* : nil*/, - centerItem: infoItem - )) - let fullScreenOverlayComponent = sheet.update( - component: StreamSheetComponent( - topComponent: AnyComponent(navigationComponent), - bottomButtonsRow: fullScreenToolbarComponent, - topOffset: /*context.availableSize.height - sheetHeight +*/ max(context.state.dismissOffset, 0), - sheetHeight: context.availableSize.height,// max(sheetHeight - context.state.dismissOffset, sheetHeight), - backgroundColor: isFullscreen ? .clear : (isFullyDragged ? fullscreenBackgroundColor : panelBackgroundColor), - bottomPadding: 0, - participantsCount: -1, - isFullyExtended: isFullyDragged, - deviceCornerRadius: ((controller() as? MediaStreamComponentController)?.validLayout?.deviceMetrics.screenCornerRadius ?? 1) - 1, - videoHeight: videoHeight, - isFullscreen: isFullscreen, - fullscreenTopComponent: AnyComponent(navigationComponent), - fullscreenBottomComponent: fullScreenToolbarComponent - ), - availableSize: context.availableSize, - transition: context.transition - ) - - context.add(fullScreenOverlayComponent - .position(.init(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2)) - .opacity(state.displayUI ? 1 : 0) - ) - - context.add(video - .position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2 + dragOffset) - ))*/ + videoPos = topOffset + 28.0 + 28.0 + videoHeight / 2 } - // TODO: add variable isPictureInPictureActive -// let isPictureInPictureActive = state.isPictureInPictureSupported && state.videoIsPlayable && state.hasVideo -// if !state.isVisibleInHierarchy && isPictureInPictureActive && state.isFullscreen { -// if !state.didAutoDismissForPiP { -// state.didAutoDismissForPiP = true -// (controller() as? MediaStreamComponentController)?.dismiss(closing: false, manual: true) -// } -// } else { -// state.didAutoDismissForPiP = false -// } + context.add(video + .position(CGPoint(x: context.availableSize.width / 2.0, y: videoPos)) + ) + + context.add(topItem + .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + (isFullscreen ? topItem.size.height / 2.0 : 28.0))) + .opacity((!isFullscreen || state.displayUI) ? 1.0 : 0.0) + .gesture(.pan { panState in + onPanGesture(panState) + }) + ) + + context.add(buttonsRow + .opacity(isFullscreen ? 0.0 : 1.0) + .position(CGPoint(x: buttonsRow.size.width / 2, y: sheetHeight - 50.0 / 2 + topOffset - bottomPadding)) + ) + + context.add(fullscreenBottomItem + .opacity((isFullscreen && state.displayUI) ? 1.0 : 0.0) + .position(CGPoint(x: fullscreenBottomItem.size.width / 2, y: context.availableSize.height - fullscreenBottomItem.size.height / 2 + topOffset - 0.0)) + ) return context.availableSize } + return makeBody() } + } public final class MediaStreamComponentController: ViewControllerComponentContainer, VoiceChatController { @@ -1413,314 +1271,6 @@ public final class MediaStreamComponentController: ViewControllerComponentContai // MARK: - Subcomponents -final class StreamTitleComponent: Component { - let text: String - let isRecording: Bool - let isActive: Bool - - init(text: String, isRecording: Bool, isActive: Bool) { - self.text = text - self.isRecording = isRecording - self.isActive = isActive - } - - static func ==(lhs: StreamTitleComponent, rhs: StreamTitleComponent) -> Bool { - if lhs.text != rhs.text { - return false - } - if lhs.isRecording != rhs.isRecording { - return false - } - if lhs.isActive != rhs.isActive { - return false - } - return false - } - - final class LiveIndicatorView: UIView { - private let label = UILabel() - private let stalledAnimatedGradient = CAGradientLayer() - private var wasLive = false - - var desiredWidth: CGFloat { label.intrinsicContentSize.width + 6.0 + 6.0 } - - override init(frame: CGRect = .zero) { - super.init(frame: frame) - - addSubview(label) - let liveString = NSAttributedString( - string: "LIVE", - attributes: [ - .font: Font.with(size: 11.0, design: .round, weight: .bold), - .paragraphStyle: { - let style = NSMutableParagraphStyle() - style.alignment = .center - return style - }(), - .foregroundColor: UIColor.white, - .kern: -0.6 - ] - ) - label.attributedText = liveString -// label.text = "LIVE" -// label.font = Font.with(size: 11.0, design: .round, weight: .bold)// .systemFont(ofSize: 12, weight: .semibold) -// label.textAlignment = .center -// label.textColor = .white - layer.addSublayer(stalledAnimatedGradient) - self.clipsToBounds = true -// if #available(iOS 13.0, *) { -// self.layer.cornerCurve = .continuous -// } - toggle(isLive: false) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - override func layoutSubviews() { - super.layoutSubviews() - - label.frame = bounds - stalledAnimatedGradient.frame = bounds - self.layer.cornerRadius = min(bounds.width, bounds.height) / 2 - } - - func toggle(isLive: Bool) { - if isLive { - if !wasLive { - wasLive = true - let anim = CAKeyframeAnimation(keyPath: "transform.scale") - anim.values = [1.0, 1.12, 0.9, 1.0] - anim.keyTimes = [0, 0.5, 0.8, 1] - anim.duration = 0.4 - self.layer.add(anim, forKey: "transform") - - UIView.animate(withDuration: 0.15, animations: { - self.toggle(isLive: true) }) - return - } - self.backgroundColor = UIColor(red: 1, green: 0.176, blue: 0.333, alpha: 1) - stalledAnimatedGradient.opacity = 0 - stalledAnimatedGradient.removeAllAnimations() - } else { - if wasLive { - wasLive = false - UIView.animate(withDuration: 0.3) { - self.toggle(isLive: false) - } - return - } - self.backgroundColor = UIColor(white: 0.36, alpha: 1) - stalledAnimatedGradient.opacity = 1 - } - wasLive = isLive - } - } - - public final class View: UIView { - private var indicatorView: UIImageView? - let liveIndicatorView = LiveIndicatorView() - let titleLabel = UILabel() - - private var titleFadeLayer = CALayer() - - private let trackingLayer: HierarchyTrackingLayer - - private func updateTitleFadeLayer(textFrame: CGRect) { - // titleLabel.backgroundColor = .red - guard let textBounds = titleLabel.attributedText.flatMap({ $0.boundingRect(with: CGSize(width: .max, height: .max), context: nil) }), - textBounds.width > textFrame.width - else { - titleLabel.layer.mask = nil - titleLabel.frame = textFrame - self.titleLabel.textAlignment = .center - return - } - - var isRTL: Bool = false - if let string = titleLabel.attributedText { - let coreTextLine = CTLineCreateWithAttributedString(string) - let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray - if glyphRuns.count > 0 { - let run = glyphRuns[0] as! CTRun - if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) { - isRTL = true - } - } - } - - let gradientInset: CGFloat = 0 - let gradientRadius: CGFloat = 50 - - let solidPartLayer = CALayer() - solidPartLayer.backgroundColor = UIColor.blue.cgColor - -// let containerWidth: CGFloat = textFrame.width - let availableWidth: CGFloat = textFrame.width - gradientRadius - let extraSpace: CGFloat = 100 - if isRTL { -// let adjustForRTL: CGFloat = 12 - -// let safeSolidWidth: CGFloat = containerWidth + adjustForRTL -// let widthDiff = min(textFrame.width - containerWidth) - - solidPartLayer.frame = CGRect( - origin: CGPoint(x: textFrame.width + extraSpace - availableWidth, y: 0), - size: CGSize(width: availableWidth, height: textFrame.height)) - - self.titleLabel.textAlignment = .right - - titleLabel.frame = CGRect(x: textFrame.minX - extraSpace, y: textFrame.minY, width: textFrame.width + extraSpace, height: textFrame.height) - } else { - self.titleLabel.textAlignment = .left - solidPartLayer.frame = CGRect( - origin: .zero, - size: CGSize(width: availableWidth, height: textFrame.height)) - titleLabel.frame = CGRect(origin: textFrame.origin, size: CGSize(width: textFrame.width + extraSpace, height: textFrame.height)) - } - titleFadeLayer.removeFromSuperlayer() - - titleFadeLayer = CALayer() - titleFadeLayer.addSublayer(solidPartLayer) - - let gradientLayer = CAGradientLayer() - gradientLayer.colors = [UIColor.red.cgColor, UIColor.clear.cgColor] - if isRTL { - gradientLayer.startPoint = CGPoint(x: 1, y: 0.5) - gradientLayer.endPoint = CGPoint(x: 0, y: 0.5) - gradientLayer.frame = CGRect(x: solidPartLayer.frame.minX - gradientRadius, y: 0, width: gradientRadius, height: textFrame.height) - } else { - gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) - gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) - gradientLayer.frame = CGRect(x: availableWidth + gradientInset, y: 0, width: gradientRadius, height: textFrame.height) - } - titleFadeLayer.addSublayer(gradientLayer) - titleFadeLayer.masksToBounds = false - - titleFadeLayer.frame = titleLabel.bounds - -// titleLabel.layer.addSublayer(titleFadeLayer) -// titleFadeLayer.opacity = 0.4 - - titleLabel.layer.mask = titleFadeLayer -// titleLabel.backgroundColor = .green - } - - override init(frame: CGRect) { - self.trackingLayer = HierarchyTrackingLayer() - - super.init(frame: frame) - - self.addSubview(self.titleLabel) - self.addSubview(self.liveIndicatorView) - - self.trackingLayer.didEnterHierarchy = { [weak self] in - guard let strongSelf = self else { - return - } - strongSelf.updateIndicatorAnimation() - } - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - private func updateIndicatorAnimation() { - guard let indicatorView = self.indicatorView else { - return - } - if indicatorView.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 - indicatorView.layer.add(animation, forKey: "recording") - } - } - - func update(component: StreamTitleComponent, availableSize: CGSize, transition: Transition) -> CGSize { - let liveIndicatorWidth: CGFloat = self.liveIndicatorView.desiredWidth - let liveIndicatorHeight: CGFloat = 20.0 - - let currentText = self.titleLabel.text - if currentText != component.text { - if currentText?.isEmpty == false { - UIView.transition(with: self.titleLabel, duration: 0.2) { - self.titleLabel.text = component.text - self.titleLabel.invalidateIntrinsicContentSize() - } - } else { - self.titleLabel.text = component.text - self.titleLabel.invalidateIntrinsicContentSize() - } - } - self.titleLabel.font = Font.semibold(17.0) - self.titleLabel.textColor = .white - self.titleLabel.numberOfLines = 1 - - let textSize = CGSize(width: min(availableSize.width - 4 - liveIndicatorWidth, self.titleLabel.intrinsicContentSize.width), height: availableSize.height) - -// let textSize = self.textView.update( -// transition: .immediate, -// component: AnyComponent(Text( -// text: component.text, -// font: Font.semibold(17.0), -// color: .white -// )), -// environment: {}, -// containerSize: CGSize(width: availableSize.width - 4 - liveIndicatorWidth, height: availableSize.height) -// ) - - if component.isRecording { - if self.indicatorView == nil { - let indicatorView = UIImageView(image: generateFilledCircleImage(diameter: 8.0, color: .red, strokeColor: nil, strokeWidth: nil, backgroundColor: nil)) - self.addSubview(indicatorView) - self.indicatorView = indicatorView - - self.updateIndicatorAnimation() - } - } else { - if let indicatorView = self.indicatorView { - self.indicatorView = nil - indicatorView.removeFromSuperview() - } - } - let sideInset: CGFloat = 20.0 - let size = CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height) - let textFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) -// self.textView.frame = textFrame - if currentText?.isEmpty == false { - UIView.transition(with: self.titleLabel, duration: 0.2) { - self.updateTitleFadeLayer(textFrame: textFrame) - } - } else { - self.updateTitleFadeLayer(textFrame: textFrame) - } - - liveIndicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: /*floorToScreenPixels((size.height - textSize.height) / 2.0 - 2) + 1.0*/textFrame.midY - liveIndicatorHeight / 2), size: .init(width: liveIndicatorWidth, height: liveIndicatorHeight)) - self.liveIndicatorView.toggle(isLive: component.isActive) - - if let indicatorView = self.indicatorView, let image = indicatorView.image { - indicatorView.frame = CGRect(origin: CGPoint(x: liveIndicatorView.frame.maxX + 6.0, y: floorToScreenPixels((size.height - image.size.height) / 2.0) + 1.0), size: image.size) - } - - return size - } - } - - public func makeView() -> View { - return View(frame: CGRect()) - } - - public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { - return view.update(component: self, availableSize: availableSize, transition: transition) - } -} - private final class NavigationBarComponent: CombinedComponent { let topInset: CGFloat let sideInset: CGFloat @@ -1780,7 +1330,7 @@ private final class NavigationBarComponent: CombinedComponent { let size = CGSize(width: context.availableSize.width, height: context.component.topInset + contentHeight) let background = background.update( - component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5/*context.component.backgroundVisible ? 0.5 : 0*/)), + component: Rectangle(color: UIColor(white: 0.0, alpha: 0.5)), availableSize: CGSize(width: size.width, height: size.height), transition: context.transition ) @@ -1810,7 +1360,7 @@ private final class NavigationBarComponent: CombinedComponent { let centerItem = context.component.centerItem.flatMap { centerItemComponent in return centerItem.update( component: centerItemComponent, - availableSize: CGSize(width: availableWidth - 44 - 44, height: contentHeight), + availableSize: CGSize(width: availableWidth - 44.0 - 44.0, height: contentHeight), transition: context.transition ) } @@ -1840,10 +1390,10 @@ private final class NavigationBarComponent: CombinedComponent { rightItemX -= item.size.width + 8.0 } - let someUndesiredOffset: CGFloat = 16 + let accumulatedOffset: CGFloat = 16.0 if let centerItem = centerItem { context.add(centerItem - .position(CGPoint(x: context.availableSize.width / 2 - someUndesiredOffset, y: context.component.topInset + contentHeight / 2.0)) + .position(CGPoint(x: context.availableSize.width / 2 - accumulatedOffset, y: context.component.topInset + contentHeight / 2.0)) ) } @@ -1852,6 +1402,289 @@ private final class NavigationBarComponent: CombinedComponent { } } +private final class StreamTitleComponent: Component { + private final class LiveIndicatorView: UIView { + private let label = UILabel() + private let stalledAnimatedGradient = CAGradientLayer() + private var wasLive = false + + var desiredWidth: CGFloat { label.intrinsicContentSize.width + 6.0 + 6.0 } + + override init(frame: CGRect = .zero) { + super.init(frame: frame) + + self.addSubview(label) + + let liveString = NSAttributedString( + string: "LIVE", + attributes: [ + .font: Font.with(size: 11.0, design: .round, weight: .bold), + .paragraphStyle: { + let style = NSMutableParagraphStyle() + style.alignment = .center + return style + }(), + .foregroundColor: UIColor.white, + .kern: -0.6 + ] + ) + self.label.attributedText = liveString + + self.layer.addSublayer(stalledAnimatedGradient) + self.clipsToBounds = true + self.toggle(isLive: false) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + label.frame = bounds + stalledAnimatedGradient.frame = bounds + self.layer.cornerRadius = min(bounds.width, bounds.height) / 2 + } + + func toggle(isLive: Bool) { + if isLive { + if !self.wasLive { + self.wasLive = true + let anim = CAKeyframeAnimation(keyPath: "transform.scale") + anim.values = [1.0, 1.12, 0.9, 1.0] + anim.keyTimes = [0, 0.5, 0.8, 1] + anim.duration = 0.4 + self.layer.add(anim, forKey: "transform") + + UIView.animate(withDuration: 0.15, animations: { + self.toggle(isLive: true) }) + return + } + self.backgroundColor = UIColor(red: 1, green: 0.176, blue: 0.333, alpha: 1) + self.stalledAnimatedGradient.opacity = 0 + self.stalledAnimatedGradient.removeAllAnimations() + } else { + if wasLive { + wasLive = false + UIView.animate(withDuration: 0.3) { + self.toggle(isLive: false) + } + return + } + self.backgroundColor = UIColor(white: 0.36, alpha: 1) + stalledAnimatedGradient.opacity = 1 + } + wasLive = isLive + } + } + + private let text: String + private let isRecording: Bool + private let isLive: Bool + + init(text: String, isRecording: Bool, isLive: Bool) { + self.text = text + self.isRecording = isRecording + self.isLive = isLive + } + + static func ==(lhs: StreamTitleComponent, rhs: StreamTitleComponent) -> Bool { + if lhs.text != rhs.text { + return false + } + if lhs.isRecording != rhs.isRecording { + return false + } + if lhs.isLive != rhs.isLive { + return false + } + return false + } + + public final class View: UIView { + private var indicatorView: UIImageView? + private let liveIndicatorView = LiveIndicatorView() + private let titleLabel = UILabel() + private var titleFadeLayer = CALayer() + + private let trackingLayer: HierarchyTrackingLayer + + override init(frame: CGRect) { + self.trackingLayer = HierarchyTrackingLayer() + + super.init(frame: frame) + + self.addSubview(self.titleLabel) + self.addSubview(self.liveIndicatorView) + + self.trackingLayer.didEnterHierarchy = { [weak self] in + guard let strongSelf = self else { + return + } + strongSelf.updateIndicatorAnimation() + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func updateIndicatorAnimation() { + guard let indicatorView = self.indicatorView else { + return + } + if indicatorView.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 + indicatorView.layer.add(animation, forKey: "recording") + } + } + + func update(component: StreamTitleComponent, availableSize: CGSize, transition: Transition) -> CGSize { + let liveIndicatorWidth: CGFloat = self.liveIndicatorView.desiredWidth + let liveIndicatorHeight: CGFloat = 20.0 + + let currentText = self.titleLabel.text + if currentText != component.text { + if currentText?.isEmpty == false { + UIView.transition(with: self.titleLabel, duration: 0.2) { + self.titleLabel.text = component.text + self.titleLabel.invalidateIntrinsicContentSize() + } + } else { + self.titleLabel.text = component.text + self.titleLabel.invalidateIntrinsicContentSize() + } + } + self.titleLabel.font = Font.semibold(17.0) + self.titleLabel.textColor = .white + self.titleLabel.numberOfLines = 1 + + let textSize = CGSize(width: min(availableSize.width - 4 - liveIndicatorWidth, self.titleLabel.intrinsicContentSize.width), height: availableSize.height) + + if component.isRecording { + if self.indicatorView == nil { + let indicatorView = UIImageView(image: generateFilledCircleImage(diameter: 8.0, color: .red, strokeColor: nil, strokeWidth: nil, backgroundColor: nil)) + self.addSubview(indicatorView) + self.indicatorView = indicatorView + + self.updateIndicatorAnimation() + } + } else { + if let indicatorView = self.indicatorView { + self.indicatorView = nil + indicatorView.removeFromSuperview() + } + } + let sideInset: CGFloat = 20.0 + let size = CGSize(width: textSize.width + sideInset * 2.0, height: textSize.height) + let textFrame = CGRect(origin: CGPoint(x: sideInset, y: floor((size.height - textSize.height) / 2.0)), size: textSize) + + if currentText?.isEmpty == false { + UIView.transition(with: self.titleLabel, duration: 0.2, options: .transitionCrossDissolve) { + self.updateTitleFadeLayer(constrainedTextFrame: textFrame) + } + } else { + self.updateTitleFadeLayer(constrainedTextFrame: textFrame) + } + + liveIndicatorView.frame = CGRect(origin: CGPoint(x: textFrame.maxX + 6.0, y: textFrame.midY - liveIndicatorHeight / 2), size: .init(width: liveIndicatorWidth, height: liveIndicatorHeight)) + self.liveIndicatorView.toggle(isLive: component.isLive) + + if let indicatorView = self.indicatorView, let image = indicatorView.image { + indicatorView.frame = CGRect(origin: CGPoint(x: liveIndicatorView.frame.maxX + 6.0, y: floorToScreenPixels((size.height - image.size.height) / 2.0) + 1.0), size: image.size) + } + + return size + } + + private func updateTitleFadeLayer(constrainedTextFrame: CGRect) { + guard let textBounds = titleLabel.attributedText.flatMap({ $0.boundingRect(with: CGSize(width: .max, height: .max), context: nil) }), + textBounds.width > constrainedTextFrame.width + else { + titleLabel.layer.mask = nil + titleLabel.frame = constrainedTextFrame + self.titleLabel.textAlignment = .center + return + } + + var isRTL: Bool = false + if let string = titleLabel.attributedText { + let coreTextLine = CTLineCreateWithAttributedString(string) + let glyphRuns = CTLineGetGlyphRuns(coreTextLine) as NSArray + if glyphRuns.count > 0 { + let run = glyphRuns[0] as! CTRun + if CTRunGetStatus(run).contains(CTRunStatus.rightToLeft) { + isRTL = true + } + } + } + + let gradientInset: CGFloat = 0.0 + let gradientRadius: CGFloat = 50.0 + let extraSpaceToFitTruncation: CGFloat = 100.0 + + let solidPartLayer = CALayer() + solidPartLayer.backgroundColor = UIColor.black.cgColor + + let availableWidth: CGFloat = constrainedTextFrame.width - gradientRadius + + if isRTL { + solidPartLayer.frame = CGRect( + origin: CGPoint(x: constrainedTextFrame.width + extraSpaceToFitTruncation - availableWidth, y: 0), + size: CGSize(width: availableWidth, height: constrainedTextFrame.height)) + + self.titleLabel.textAlignment = .right + + titleLabel.frame = CGRect(x: constrainedTextFrame.minX - extraSpaceToFitTruncation, y: constrainedTextFrame.minY, width: constrainedTextFrame.width + extraSpaceToFitTruncation, height: constrainedTextFrame.height) + } else { + self.titleLabel.textAlignment = .left + + solidPartLayer.frame = CGRect( + origin: .zero, + size: CGSize(width: availableWidth, height: constrainedTextFrame.height)) + titleLabel.frame = CGRect(origin: constrainedTextFrame.origin, size: CGSize(width: constrainedTextFrame.width + extraSpaceToFitTruncation, height: constrainedTextFrame.height)) + } + + titleFadeLayer = CALayer() + titleFadeLayer.addSublayer(solidPartLayer) + + let gradientLayer = CAGradientLayer() + gradientLayer.colors = [UIColor.red.cgColor, UIColor.clear.cgColor] + if isRTL { + gradientLayer.startPoint = CGPoint(x: 1, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.frame = CGRect(x: solidPartLayer.frame.minX - gradientRadius, y: 0, width: gradientRadius, height: constrainedTextFrame.height) + } else { + gradientLayer.startPoint = CGPoint(x: 0, y: 0.5) + gradientLayer.endPoint = CGPoint(x: 1, y: 0.5) + gradientLayer.frame = CGRect(x: availableWidth + gradientInset, y: 0, width: gradientRadius, height: constrainedTextFrame.height) + } + titleFadeLayer.addSublayer(gradientLayer) + titleFadeLayer.masksToBounds = false + + titleFadeLayer.frame = titleLabel.bounds + + titleLabel.layer.mask = titleFadeLayer + } + + } + + public func makeView() -> View { + return View(frame: CGRect()) + } + + public func update(view: View, availableSize: CGSize, state: EmptyComponentState, environment: Environment, transition: Transition) -> CGSize { + return view.update(component: self, availableSize: availableSize, transition: transition) + } +} + + private final class OriginInfoComponent: CombinedComponent { let participantsCount: Int diff --git a/submodules/TelegramCallsUI/Sources/Components/ParticipantsComponent.swift b/submodules/TelegramCallsUI/Sources/Components/ParticipantsComponent.swift new file mode 100644 index 0000000000..a2aefbe5cb --- /dev/null +++ b/submodules/TelegramCallsUI/Sources/Components/ParticipantsComponent.swift @@ -0,0 +1,78 @@ +import Foundation +import Display +import UIKit +import ComponentFlow +import TelegramPresentationData +import TelegramStringFormatting + +private let purple = UIColor(rgb: 0x3252ef) +private let pink = UIColor(rgb: 0xe4436c) + +final class ParticipantsComponent: Component { + private let count: Int + private let showsSubtitle: Bool + private let fontSize: CGFloat + private let gradientColors: [CGColor] + + init(count: Int, showsSubtitle: Bool = true, fontSize: CGFloat = 48.0, gradientColors: [CGColor] = [pink.cgColor, purple.cgColor, purple.cgColor]) { + self.count = count + self.showsSubtitle = showsSubtitle + self.fontSize = fontSize + self.gradientColors = gradientColors + } + + static func == (lhs: ParticipantsComponent, rhs: ParticipantsComponent) -> Bool { + if lhs.count != rhs.count { + return false + } + if lhs.showsSubtitle != rhs.showsSubtitle { + return false + } + if lhs.fontSize != rhs.fontSize { + return false + } + return true + } + + func makeView() -> View { + View(frame: .zero) + } + + func update(view: View, availableSize: CGSize, state: ComponentFlow.EmptyComponentState, environment: ComponentFlow.Environment, transition: ComponentFlow.Transition) -> CGSize { + view.counter.update( + countString: self.count > 0 ? presentationStringsFormattedNumber(Int32(count), ",") : "", + // TODO: localize + subtitle: self.showsSubtitle ? (self.count > 0 ? /*environment.strings.LiveStream_Watching*/"watching" : /*environment.strings.LiveStream_NoViewers.lowercased()*/"no viewers") : "", + fontSize: self.fontSize, + gradientColors: self.gradientColors + ) + switch transition.animation { + case let .curve(duration, curve): + UIView.animate(withDuration: duration, delay: 0, options: curve.containedViewLayoutTransitionCurve.viewAnimationOptions, animations: { + view.bounds.size = availableSize + view.counter.frame.size = availableSize + view.counter.updateFrames(transition: transition) + }) + + default: + view.bounds.size = availableSize + view.counter.frame.size = availableSize + view.counter.updateFrames() + } + return availableSize + } + + final class View: UIView { + let counter = AnimatedCountView() + + override init(frame: CGRect) { + super.init(frame: frame) + self.addSubview(counter) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + } + +} diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 8dd321d352..818aa3376f 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -8,9 +8,6 @@ import MultilineTextComponent import Display final class StreamSheetComponent: CombinedComponent { - let topComponent: AnyComponent? - let bottomButtonsRow: AnyComponent? - // TODO: sync let sheetHeight: CGFloat let topOffset: CGFloat let backgroundColor: UIColor @@ -25,8 +22,6 @@ final class StreamSheetComponent: CombinedComponent { let fullscreenBottomComponent: AnyComponent init( - topComponent: AnyComponent, - bottomButtonsRow: AnyComponent, topOffset: CGFloat, sheetHeight: CGFloat, backgroundColor: UIColor, @@ -39,8 +34,6 @@ final class StreamSheetComponent: CombinedComponent { fullscreenTopComponent: AnyComponent, fullscreenBottomComponent: AnyComponent ) { - self.topComponent = nil // topComponent - self.bottomButtonsRow = nil // bottomButtonsRow self.topOffset = topOffset self.sheetHeight = sheetHeight self.backgroundColor = backgroundColor @@ -56,12 +49,6 @@ final class StreamSheetComponent: CombinedComponent { } static func ==(lhs: StreamSheetComponent, rhs: StreamSheetComponent) -> Bool { - if lhs.topComponent != rhs.topComponent { - return false - } - if lhs.bottomButtonsRow != rhs.bottomButtonsRow { - return false - } if lhs.topOffset != rhs.topOffset { return false } @@ -119,12 +106,12 @@ final class StreamSheetComponent: CombinedComponent { override func draw(_ rect: CGRect) { super.draw(rect) // Debug interactive area - guard let context = UIGraphicsGetCurrentContext() else { return } - context.setFillColor(UIColor.red.withAlphaComponent(0.3).cgColor) - overlayComponentsFrames.forEach { frame in - context.addRect(frame) - context.fillPath() - } +// guard let context = UIGraphicsGetCurrentContext() else { return } +// context.setFillColor(UIColor.red.withAlphaComponent(0.3).cgColor) +// overlayComponentsFrames.forEach { frame in +// context.addRect(frame) +// context.fillPath() +// } } } @@ -146,19 +133,15 @@ final class StreamSheetComponent: CombinedComponent { static var body: Body { let background = Child(SheetBackgroundComponent.self) - let topItem = Child(environment: Empty.self) let viewerCounter = Child(ParticipantsComponent.self) - let bottomButtonsRow = Child(environment: Empty.self) return { context in - let availableWidth = context.availableSize.width - let contentHeight: CGFloat = 44.0 let size = context.availableSize let topOffset = context.component.topOffset let backgroundExtraOffset: CGFloat if #available(iOS 16.0, *) { - // In iOS context.view does not inherit safeAreaInsets, quick fix until figure out how to deal properly: + // In iOS 16 context.view does not inherit safeAreaInsets, quick fix: let safeAreaTopInView = context.view.window.flatMap { $0.convert(CGPoint(x: 0, y: $0.safeAreaInsets.top), to: context.view).y } ?? 0 backgroundExtraOffset = context.component.isFullyExtended ? -safeAreaTopInView : 0 } else { @@ -175,29 +158,13 @@ final class StreamSheetComponent: CombinedComponent { transition: context.transition ) - let topItem = context.component.topComponent.flatMap { topItemComponent in - return topItem.update( - component: topItemComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - } - let viewerCounter = viewerCounter.update( component: ParticipantsComponent(count: context.component.participantsCount, fontSize: 44.0), availableSize: CGSize(width: context.availableSize.width, height: 70), transition: context.transition ) - let bottomButtonsRow = context.component.bottomButtonsRow.flatMap { bottomButtonsRowComponent in - return bottomButtonsRow.update( - component: bottomButtonsRowComponent, - availableSize: CGSize(width: availableWidth, height: contentHeight), - transition: context.transition - ) - } - // TODO: replace - let isFullscreen = context.component.isFullscreen // context.component.participantsCount == -1 + let isFullscreen = context.component.isFullscreen context.add(background .position(CGPoint(x: size.width / 2.0, y: topOffset + context.component.sheetHeight / 2)) @@ -206,39 +173,20 @@ final class StreamSheetComponent: CombinedComponent { (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames = [] context.view.backgroundColor = .clear - if let topItem = topItem { - context.add(topItem - .position(CGPoint(x: topItem.size.width / 2.0, y: topOffset + (isFullscreen ? topItem.size.height / 2.0 : 28))) - ) - (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: topOffset, width: topItem.size.width, height: topItem.size.height)) - } let videoHeight = context.component.videoHeight let sheetHeight = context.component.sheetHeight let animatedParticipantsVisible = !isFullscreen// context.component.participantsCount != -1 context.add(viewerCounter - .position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50 + videoHeight + (sheetHeight - 69 - videoHeight - 50 - context.component.bottomPadding) / 2 - 10)) + .position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50.0 + videoHeight + (sheetHeight - 69.0 - videoHeight - 50.0 - context.component.bottomPadding) / 2 - 10.0)) .opacity(animatedParticipantsVisible ? 1 : 0) - // .animation(key: "position") ) - if let bottomButtonsRow = bottomButtonsRow { - context.add(bottomButtonsRow - .position(CGPoint(x: bottomButtonsRow.size.width / 2, y: context.component.sheetHeight - 50 / 2 + topOffset - context.component.bottomPadding)) - ) - (context.view as? StreamSheetComponent.View)?.overlayComponentsFrames.append(.init(x: 0, y: context.component.sheetHeight - 50 - 20 + topOffset - context.component.bottomPadding, width: bottomButtonsRow.size.width, height: bottomButtonsRow.size.height )) - } - return size } } } -import TelegramPresentationData -import TelegramStringFormatting - -private let purple = UIColor(rgb: 0x3252ef) -private let pink = UIColor(rgb: 0xe4436c) private let latePurple = UIColor(rgb: 0x974aa9) private let latePink = UIColor(rgb: 0xf0436c) @@ -255,15 +203,14 @@ final class SheetBackgroundComponent: Component { if backgroundView.superview == nil { self.addSubview(backgroundView) } - // To fix release animation - let extraBottom: CGFloat = 500 + + let extraBottomForReleaseAnimation: CGFloat = 500 if backgroundView.backgroundColor != color && backgroundView.backgroundColor != nil { if transition.animation.isImmediate { UIView.animate(withDuration: 0.4) { [self] in backgroundView.backgroundColor = color - // TODO: determine if animation is needed (with logic, not color) - backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottom)) + backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation)) } let anim = CABasicAnimation(keyPath: "cornerRadius") @@ -274,12 +221,12 @@ final class SheetBackgroundComponent: Component { backgroundView.layer.add(anim, forKey: "cornerRadius") } else { transition.setBackgroundColor(view: backgroundView, color: color) - transition.setFrame(view: backgroundView, frame: CGRect(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottom))) + transition.setFrame(view: backgroundView, frame: CGRect(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation))) transition.setCornerRadius(layer: backgroundView.layer, cornerRadius: cornerRadius) } } else { backgroundView.backgroundColor = color - backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottom)) + backgroundView.frame = .init(origin: .init(x: 0, y: offset), size: .init(width: availableSize.width, height: availableSize.height + extraBottomForReleaseAnimation)) backgroundView.layer.cornerRadius = cornerRadius } backgroundView.isUserInteractionEnabled = false @@ -317,78 +264,3 @@ final class SheetBackgroundComponent: Component { return availableSize } } - -final class ParticipantsComponent: Component { - static func == (lhs: ParticipantsComponent, rhs: ParticipantsComponent) -> Bool { - if lhs.count != rhs.count { - return false - } - if lhs.showsSubtitle != rhs.showsSubtitle { - return false - } - if lhs.fontSize != rhs.fontSize { - return false - } - return true - } - - func makeView() -> View { - View(frame: .zero) - } - - func update(view: View, availableSize: CGSize, state: ComponentFlow.EmptyComponentState, environment: ComponentFlow.Environment, transition: ComponentFlow.Transition) -> CGSize { - view.counter.update( - countString: self.count > 0 ? presentationStringsFormattedNumber(Int32(count), ",") : "", - subtitle: self.showsSubtitle ? (self.count > 0 ? "watching" : "no viewers") : "", - fontSize: self.fontSize, - gradientColors: self.gradientColors - )// environment.strings.LiveStream_NoViewers) - switch transition.animation { - case let .curve(duration, curve): - UIView.animate(withDuration: duration, delay: 0, options: curve.containedViewLayoutTransitionCurve.viewAnimationOptions, animations: { - view.bounds.size = availableSize - view.counter.frame.size = availableSize - view.counter.updateFrames(transition: transition) -// view.counter.setNeedsLayout() -// view.counter.setNeedsDisplay() - }) - - default: - view.bounds.size = availableSize - view.counter.frame.size = availableSize - view.counter.updateFrames() - } - return availableSize - } - - private let count: Int - private let showsSubtitle: Bool - private let fontSize: CGFloat - private let gradientColors: [CGColor] - - init(count: Int, showsSubtitle: Bool = true, fontSize: CGFloat = 48.0, gradientColors: [CGColor] = [pink.cgColor, purple.cgColor, purple.cgColor]) { - self.count = count - self.showsSubtitle = showsSubtitle - self.fontSize = fontSize - self.gradientColors = gradientColors - } - - final class View: UIView { - let counter = AnimatedCountView() - - override init(frame: CGRect) { - super.init(frame: frame) - self.addSubview(counter) - } - - override func layoutSubviews() { - super.layoutSubviews() -// self.counter.frame = self.bounds - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - } - -} From e84b49e081a702868aa64b1164493677968fc8be Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Sun, 8 Jan 2023 17:44:35 +0400 Subject: [PATCH 45/50] Cleanup --- .../Source/Base/CombinedComponent.swift | 23 ----- .../Source/Base/Transition.swift | 19 ---- .../CreateExternalMediaStreamScreen.swift | 4 +- .../ShimmerEffect/Sources/ShimmerEffect.swift | 13 --- .../Components/AnimatedCounterView.swift | 15 +-- .../Components/MediaStreamComponent.swift | 96 ++++++------------ .../MediaStreamVideoComponent.swift | 99 ++++++------------- .../Components/StreamSheetComponent.swift | 6 +- .../Sources/SharedAccountContext.swift | 2 +- 9 files changed, 65 insertions(+), 212 deletions(-) diff --git a/submodules/ComponentFlow/Source/Base/CombinedComponent.swift b/submodules/ComponentFlow/Source/Base/CombinedComponent.swift index 4873b14caf..af917c84f2 100644 --- a/submodules/ComponentFlow/Source/Base/CombinedComponent.swift +++ b/submodules/ComponentFlow/Source/Base/CombinedComponent.swift @@ -180,14 +180,6 @@ public final class _UpdatedChildComponent { var _opacity: CGFloat? var _cornerRadius: CGFloat? var _clipsToBounds: Bool? - /// Quick animation addition - var _animations: [AnimationKey: AnimationType] = [:] - - public typealias AnimationKey = String - public enum AnimationType { - case transition - case custom(duration: TimeInterval, curveAndOtherParams: Any) - } fileprivate var transitionAppear: Transition.Appear? fileprivate var transitionAppearWithGuide: (Transition.AppearWithGuide, _AnyChildComponent.Id)? @@ -249,11 +241,6 @@ public final class _UpdatedChildComponent { return self } - @discardableResult public func animation(key: AnimationKey) -> _UpdatedChildComponent { - self._animations[key] = .transition - return self - } - @discardableResult public func scale(_ scale: CGFloat) -> _UpdatedChildComponent { self._scale = scale return self @@ -708,8 +695,6 @@ public extension CombinedComponent { view.insertSubview(updatedChild.view, at: index) - let currentPosition = updatedChild.view.center - if let scale = updatedChild._scale { updatedChild.view.bounds = CGRect(origin: CGPoint(), size: updatedChild.size) updatedChild.view.center = updatedChild._position ?? CGPoint() @@ -717,14 +702,6 @@ public extension CombinedComponent { } else { updatedChild.view.frame = updatedChild.size.centered(around: updatedChild._position ?? CGPoint()) } -// for animation in updatedChild._animations { } - - if updatedChild._animations["position"] != nil, let position = updatedChild._position { - transition.animatePosition(view: updatedChild.view, from: currentPosition, to: position) - } - if updatedChild._animations["opacity"] != nil, let opacity = updatedChild._opacity { - transition.animateAlpha(view: updatedChild.view, from: updatedChild.view.alpha, to: opacity) - } updatedChild.view.alpha = updatedChild._opacity ?? 1.0 updatedChild.view.clipsToBounds = updatedChild._clipsToBounds ?? false diff --git a/submodules/ComponentFlow/Source/Base/Transition.swift b/submodules/ComponentFlow/Source/Base/Transition.swift index f1ceb4ce3f..a6679fc7ba 100644 --- a/submodules/ComponentFlow/Source/Base/Transition.swift +++ b/submodules/ComponentFlow/Source/Base/Transition.swift @@ -548,25 +548,6 @@ public struct Transition { } } - public func animateCornerRadius(layer: CALayer, from fromValue: CGFloat, to toValue: CGFloat) { - switch self.animation { - case .none: - break - case let .curve(duration, curve): - layer.animate( - from: fromValue as NSNumber, - to: toValue as NSNumber, - keyPath: "cornerRadius", - duration: duration, - delay: 0.0, - curve: curve, - removeOnCompletion: true, - additive: false, - completion: nil - ) - } - } - public func animateBoundsOrigin(layer: CALayer, from fromValue: CGPoint, to toValue: CGPoint, additive: Bool = false, completion: ((Bool) -> Void)? = nil) { switch self.animation { case .none: diff --git a/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift b/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift index 4984ac9cd0..57b977e836 100644 --- a/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift +++ b/submodules/PeerInfoUI/CreateExternalMediaStreamScreen/Sources/CreateExternalMediaStreamScreen.swift @@ -102,11 +102,11 @@ private final class CreateExternalMediaStreamScreenComponent: CombinedComponent } strongSelf.isDelayingLoadingIndication = true - Timer(timeout: 0.3, repeat: false, completion: { [weak strongSelf] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.7) { [weak strongSelf] in guard let strongSelf else { return } strongSelf.isDelayingLoadingIndication = false strongSelf.updated(transition: .easeInOut(duration: 0.3)) - }, queue: .mainQueue()).start() + } var cancelImpl: (() -> Void)? let presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 } diff --git a/submodules/ShimmerEffect/Sources/ShimmerEffect.swift b/submodules/ShimmerEffect/Sources/ShimmerEffect.swift index f094ffa613..37163a13ff 100644 --- a/submodules/ShimmerEffect/Sources/ShimmerEffect.swift +++ b/submodules/ShimmerEffect/Sources/ShimmerEffect.swift @@ -541,20 +541,7 @@ public final class StandaloneShimmerEffect { animation.repeatCount = .infinity animation.duration = 0.8 * delay animation.timingFunction = .init(name: .easeInEaseOut) -// animation.beginTime = layer.convertTime(1.0, from: nil) layer.add(animation, forKey: "shimmer") - /*let opacityAnimation = CAKeyframeAnimation(keyPath: "opacity") - opacityAnimation.values = [0.0, 1.0, 0.0] - opacityAnimation.keyTimes = [0, 0.5, 0] - opacityAnimation.calculationMode = .linear -// opacityAnimation.fromValue = 2.0 as NSNumber -// opacityAnimation.toValue = -2.0 as NSNumber -// opacityAnimation.isAdditive = true - opacityAnimation.repeatCount = .infinity - opacityAnimation.duration = 1.6 - opacityAnimation.timingFunctions = [.init(name: .easeInEaseOut)] -// opacityAnimation.beginTime = layer.convertTime(1.0, from: nil) - layer.add(opacityAnimation, forKey: "opacity")*/ } } } diff --git a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift index 5d75dcd03b..8ebe446403 100644 --- a/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift +++ b/submodules/TelegramCallsUI/Sources/Components/AnimatedCounterView.swift @@ -3,11 +3,8 @@ import UIKit import Display import ComponentFlow -private let purple = UIColor(rgb: 0xdf44b8) // 0x3252ef) -private let pink = UIColor(rgb: 0x3851eb) // 0xe4436c) - -private let latePurple = UIColor(rgb: 0x974aa9) -private let latePink = UIColor(rgb: 0xf0436c) +private let purple = UIColor(rgb: 0xdf44b8) +private let pink = UIColor(rgb: 0x3851eb) public final class AnimatedCountView: UIView { let countLabel = AnimatedCountLabel() @@ -59,15 +56,9 @@ public final class AnimatedCountView: UIView { self.foregroundView.frame = CGRect(origin: CGPoint.zero, size: bounds.size)// .insetBy(dx: -40, dy: -40) self.foregroundGradientLayer.frame = CGRect(origin: .zero, size: bounds.size).insetBy(dx: -60, dy: -60) self.maskingView.frame = CGRect(origin: .zero, size: bounds.size) - // 18 - // let counterInset: CGFloat = 8 - // let counterBottomOffset: CGFloat = subtitleHeight + counterInset countLabel.frame = CGRect(origin: .zero, size: CGSize(width: bounds.width, height: bounds.height)) subtitleLabel.frame = subtitleFrame - // backgroundColor = .white.withAlphaComponent(0.3) - // countLabel.backgroundColor = .red.withAlphaComponent(0.2) - // subtitleLabel.backgroundColor = .blue.withAlphaComponent(0.2) } } @@ -348,7 +339,7 @@ class AnimatedCountLabel: UILabel { self.clipsToBounds = false } func animateOut(for layer: CALayer, duration: CFTimeInterval, beginTime: CFTimeInterval) { - let beginTimeOffset: CFTimeInterval = 0/*beginTime == .zero ? 0 :*/ // CFTimeInterval(DispatchTime.now().uptimeNanoseconds / 1000000000) /*layer.convertTime(*/// CACurrentMediaTime()//, to: nil) + let beginTimeOffset: CFTimeInterval = 0 DispatchQueue.main.asyncAfter(deadline: .now() + beginTime) { let beginTime: CFTimeInterval = 0 diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift index df0efb8f55..4ab979bc37 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamComponent.swift @@ -1083,13 +1083,13 @@ public final class MediaStreamComponentController: ViewControllerComponentContai }) self.view.layer.allowsGroupOpacity = true - self.view.layer.animateAlpha(from: 1.0, to: 1.0, duration: 0.2, completion: { [weak self] _ in + + self.backgroundDimView.layer.animateAlpha(from: 0, to: 1, duration: 0.3, completion: { [weak self] _ in guard let strongSelf = self else { return } strongSelf.view.layer.allowsGroupOpacity = false }) - self.backgroundDimView.layer.animateAlpha(from: 0, to: 1, duration: 0.3) if backgroundDimView.superview == nil { guard let superview = view.superview else { return } superview.insertSubview(backgroundDimView, belowSubview: view) @@ -1130,9 +1130,7 @@ public final class MediaStreamComponentController: ViewControllerComponentContai override public func dismiss(completion: (() -> Void)? = nil) { self.view.layer.allowsGroupOpacity = true -// self.view.layer.animateAlpha(from: 1.0, to: 1.0, duration: 0.4, removeOnCompletion: false, completion: { [weak self] _ in -// -// }) + self.backgroundDimView.layer.animateAlpha(from: 1.0, to: 0, duration: 0.3, removeOnCompletion: false) self.view.layer.animatePosition(from: self.view.center, to: CGPoint(x: self.view.center.x, y: self.view.bounds.maxY + self.view.bounds.height / 2), duration: 0.4, removeOnCompletion: false, completion: { [weak self] _ in guard let strongSelf = self else { @@ -1371,7 +1369,6 @@ private final class NavigationBarComponent: CombinedComponent { context.add(background .position(CGPoint(x: size.width / 2.0, y: size.height / 2.0)) .opacity(context.component.backgroundVisible ? 1 : 0) - .animation(key: "opacity") ) var centerLeftInset = sideInset @@ -1688,8 +1685,6 @@ private final class StreamTitleComponent: Component { private final class OriginInfoComponent: CombinedComponent { let participantsCount: Int - private static var usingAnimatedCounter: Bool { true } - init( memberCount: Int ) { @@ -1705,64 +1700,29 @@ private final class OriginInfoComponent: CombinedComponent { } static var body: Body { - if usingAnimatedCounter { - let viewerCounter = Child(ParticipantsComponent.self) + let viewerCounter = Child(ParticipantsComponent.self) + + return { context in + let viewerCounter = viewerCounter.update( + component: ParticipantsComponent( + count: context.component.participantsCount, + showsSubtitle: true, + fontSize: 18.0, + gradientColors: [UIColor.white.cgColor] + ), + availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), + transition: context.transition + ) + let heightReduction: CGFloat = 16.0 + var size = CGSize(width: viewerCounter.size.width, height: viewerCounter.size.height - heightReduction) + size.width = min(size.width, context.availableSize.width) + size.height = min(size.height, context.availableSize.height) - return { context in -// let spacing: CGFloat = 0.0 - - let viewerCounter = viewerCounter.update( - component: ParticipantsComponent( - count: context.component.participantsCount, - showsSubtitle: true, - fontSize: 18.0, - gradientColors: [UIColor.white.cgColor] - ), - availableSize: CGSize(width: context.availableSize.width, height: context.availableSize.height), - transition: context.transition - ) - let heightReduction: CGFloat = 16.0 - var size = CGSize(width: viewerCounter.size.width, height: viewerCounter.size.height - heightReduction) - size.width = min(size.width, context.availableSize.width) - size.height = min(size.height, context.availableSize.height) - - context.add(viewerCounter - .position(CGPoint(x: size.width / 2.0, y: /*(context.availableSize.height - viewerCounter.size.height)*/context.availableSize.height / 2.0 + 16.0 - heightReduction / 2)) - ) - - return size - } - } else { - let subtitle = Child(Text.self) + context.add(viewerCounter + .position(CGPoint(x: size.width / 2.0, y: context.availableSize.height / 2.0 + 16.0 - heightReduction / 2)) + ) - return { context in -// let spacing: CGFloat = 0.0 - - let memberCount = context.component.participantsCount - let memberCountString: String - if memberCount == 0 { - memberCountString = "no viewers" - } else { - memberCountString = memberCount > 0 ? presentationStringsFormattedNumber(Int32(memberCount), ",") : "" - } - - let subtitle = subtitle.update( - component: Text( - text: memberCountString, font: Font.regular(14.0), color: .white), - availableSize: context.availableSize, - transition: context.transition - ) - - var size = CGSize(width: subtitle.size.width, height: subtitle.size.height) - size.width = min(size.width, context.availableSize.width) - size.height = min(size.height, context.availableSize.height) - - context.add(subtitle - .position(CGPoint(x: size.width / 2.0, y: subtitle.size.height / 2.0)) - ) - - return size - } + return size } } } @@ -1939,13 +1899,13 @@ private final class ButtonsRowComponent: CombinedComponent { var availableWidth = context.availableSize.width let sideInset: CGFloat = 48.0 + context.component.sideInset - let contentHeight: CGFloat = 80 // 44 + let contentHeight: CGFloat = 80.0 let size = CGSize(width: context.availableSize.width, height: contentHeight + context.component.bottomInset) let leftItem = context.component.leftItem.flatMap { leftItemComponent in return leftItem.update( component: leftItemComponent, - availableSize: CGSize(width: 50, height: contentHeight), + availableSize: CGSize(width: 50.0, height: contentHeight), transition: context.transition ) } @@ -1956,7 +1916,7 @@ private final class ButtonsRowComponent: CombinedComponent { let rightItem = context.component.rightItem.flatMap { rightItemComponent in return rightItem.update( component: rightItemComponent, - availableSize: CGSize(width: 50, height: contentHeight), + availableSize: CGSize(width: 50.0, height: contentHeight), transition: context.transition ) } @@ -1967,7 +1927,7 @@ private final class ButtonsRowComponent: CombinedComponent { let centerItem = context.component.centerItem.flatMap { centerItemComponent in return centerItem.update( component: centerItemComponent, - availableSize: CGSize(width: 50, height: contentHeight), + availableSize: CGSize(width: 50.0, height: contentHeight), transition: context.transition ) } diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index 795a596d74..f9f58e98bc 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -149,8 +149,14 @@ final class MediaStreamVideoComponent: Component { private var isFullscreen: Bool = false private let videoLoadingThrottler = Throttler(duration: 1, queue: .main) private var wasFullscreen: Bool = false + private var isAnimating = false + private var didRequestBringBack = false + private weak var state: State? + private var lastPresentation: UIView? + private var pipTrackDisplayLink: CADisplayLink? + override init(frame: CGRect) { self.blurTintView = UIView() self.blurTintView.backgroundColor = UIColor(white: 0.0, alpha: 0.55) @@ -169,8 +175,8 @@ final class MediaStreamVideoComponent: Component { deinit { avatarDisposable?.dispose() frameInputDisposable?.dispose() - self.x?.invalidate() - self.x = nil + self.pipTrackDisplayLink?.invalidate() + self.pipTrackDisplayLink = nil } public func matches(tag: Any) -> Bool { @@ -180,16 +186,13 @@ final class MediaStreamVideoComponent: Component { return false } - var didPassExpandFromPiP = false - func expandFromPictureInPicture() { - didPassExpandFromPiP = true if let pictureInPictureController = self.pictureInPictureController, pictureInPictureController.isPictureInPictureActive { self.requestedExpansion = true self.pictureInPictureController?.stopPictureInPicture() } } - private var isAnimating = false + private func updateVideoStalled(isStalled: Bool, transition: Transition?) { if isStalled { guard let component = self.component else { return } @@ -359,7 +362,7 @@ final class MediaStreamVideoComponent: Component { if #available(iOS 13.0, *) { sampleBufferVideoView.sampleBufferLayer.preventsDisplaySleepDuringVideoPlayback = true } - // if #available(iOSApplicationExtension 15.0, iOS 15.0, *), AVPictureInPictureController.isPictureInPictureSupported() { + final class PlaybackDelegateImpl: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate { var onTransitionFinished: (() -> Void)? func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) { @@ -408,7 +411,6 @@ final class MediaStreamVideoComponent: Component { pictureInPictureController?.requiresLinearPlayback = true } self.pictureInPictureController = pictureInPictureController - // } } videoView.setOnOrientationUpdated { [weak state] _, _ in @@ -445,7 +447,7 @@ final class MediaStreamVideoComponent: Component { }) } fullScreenBackgroundPlaceholder.frame = .init(origin: .zero, size: availableSize) -// fullScreenBackgroundPlaceholder.isHidden = true + let videoInset: CGFloat if !component.isFullscreen { videoInset = 16 @@ -526,29 +528,17 @@ final class MediaStreamVideoComponent: Component { size: blurredVideoSize ), completion: nil) } else { - videoFrameUpdateTransition.setFrame(view: videoBlurView, frame: videoView.frame.insetBy(dx: -69 * aspect, dy: -69)) -// videoBlurView.frame = videoView.frame.insetBy(dx: -69 * aspect, dy: -69) + videoFrameUpdateTransition.setFrame(view: videoBlurView, frame: videoView.frame.insetBy(dx: -70.0 * aspect, dy: -70.0)) } videoBlurView.layer.mask = videoBlurGradientMask if !component.isFullscreen { transition.setAlpha(layer: videoBlurSolidMask, alpha: 0) -// if videoBlurView.layer.mask !== videoBlurGradientMask { -// UIView.transition(with: videoBlurView, duration: transition.animation.isImmediate ? 0.0 : 0.3) { [self] in -// videoBlurView.layer.mask = videoBlurGradientMask -// } -// } -// videoBlurView.layer.mask = maskGradientLayer } else { transition.setAlpha(layer: videoBlurSolidMask, alpha: 1) -// if videoBlurView.layer.mask != nil { -// UIView.transition(with: videoBlurView, duration: transition.animation.isImmediate ? 0.0 : 0.3) { -// videoBlurView.layer.mask = nil -// } -// } } -// + videoFrameUpdateTransition.setFrame(layer: self.videoBlurGradientMask, frame: videoBlurView.bounds) videoFrameUpdateTransition.setFrame(layer: self.videoBlurSolidMask, frame: self.videoBlurGradientMask.bounds) } @@ -557,13 +547,11 @@ final class MediaStreamVideoComponent: Component { } let loadingBlurViewFrame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) -// UIView.animate(withDuration: 0.5) { -// self.loadingBlurView.frame = loadingBlurViewFrame -// } + if loadingBlurView.frame == .zero { loadingBlurView.frame = loadingBlurViewFrame } else { - // Using Transition.setFrame UIVisualEffectView causes instant update of subviews + // Using Transition.setFrame on UIVisualEffectView causes instant update of sublayers switch videoFrameUpdateTransition.animation { case let .curve(duration, curve): UIView.animate(withDuration: duration, delay: 0, options: curve.containedViewLayoutTransitionCurve.viewAnimationOptions, animations: { [self] in @@ -574,29 +562,21 @@ final class MediaStreamVideoComponent: Component { loadingBlurView.frame = loadingBlurViewFrame } } -// loadingBlurView.frame = CGRect(origin: CGPoint(x: floor((availableSize.width - videoSize.width) / 2.0), y: floor((availableSize.height - videoSize.height) / 2.0)), size: videoSize) - videoFrameUpdateTransition.setCornerRadius(layer: loadingBlurView.layer, cornerRadius: videoCornerRadius) -// loadingBlurView.layer.cornerRadius = videoCornerRadius - videoFrameUpdateTransition.setFrame(view: placeholderView, frame: loadingBlurViewFrame) -// placeholderView.frame = loadingBlurView.frame videoFrameUpdateTransition.setCornerRadius(layer: placeholderView.layer, cornerRadius: videoCornerRadius) -// placeholderView.layer.cornerRadius = videoCornerRadius placeholderView.clipsToBounds = true placeholderView.subviews.forEach { videoFrameUpdateTransition.setFrame(view: $0, frame: placeholderView.bounds) -// $0.frame = placeholderView.bounds } let initialShimmerBounds = shimmerBorderLayer.bounds videoFrameUpdateTransition.setFrame(layer: shimmerBorderLayer, frame: loadingBlurView.bounds) -// shimmerBorderLayer.frame = loadingBlurView.bounds let borderMask = CAShapeLayer() let initialPath = CGPath(roundedRect: .init(x: 0, y: 0, width: initialShimmerBounds.width, height: initialShimmerBounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil) borderMask.path = initialPath -// borderMask.path = CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil) + videoFrameUpdateTransition.setShapeLayerPath(layer: borderMask, path: CGPath(roundedRect: .init(x: 0, y: 0, width: shimmerBorderLayer.bounds.width, height: shimmerBorderLayer.bounds.height), cornerWidth: videoCornerRadius, cornerHeight: videoCornerRadius, transform: nil)) borderMask.fillColor = UIColor.white.withAlphaComponent(0.4).cgColor @@ -605,12 +585,6 @@ final class MediaStreamVideoComponent: Component { shimmerBorderLayer.mask = borderMask shimmerBorderLayer.cornerRadius = videoCornerRadius -// if component.isAdmin { -// shimmerBorderLayer.isHidden = true -// } else { -// shimmerBorderLayer.isHidden = false -// } -// if !self.hadVideo { if self.noSignalTimer == nil { @@ -636,7 +610,7 @@ final class MediaStreamVideoComponent: Component { noSignalTransition = transition.withAnimation(.none) noSignalView = ComponentHostView() self.noSignalView = noSignalView - // TODO: above blurred animation + self.addSubview(noSignalView) noSignalView.layer.zPosition = loadingBlurView.layer.zPosition + 1 @@ -664,7 +638,7 @@ final class MediaStreamVideoComponent: Component { guard let strongSelf = self, let pictureInPictureController = strongSelf.pictureInPictureController else { return } - print("[pip] started") + pictureInPictureController.startPictureInPicture() completion(Void()) @@ -694,46 +668,33 @@ final class MediaStreamVideoComponent: Component { videoView.alpha = 0 lastPresentation?.removeFromSuperview() lastPresentation = presentation -// UIView.animate(withDuration: 0.04, delay: 0.04, animations: { -// presentation.alpha = 0 -// }, completion: { _ in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) { self.lastPresentation?.removeFromSuperview() self.lastPresentation = nil - self.x?.invalidate() - self.x = nil + self.pipTrackDisplayLink?.invalidate() + self.pipTrackDisplayLink = nil } -// }) } UIView.animate(withDuration: 0.1) { [self] in videoBlurView?.alpha = 0 } - // UIApplication.shared.windows.first?.windowLevel == .normal - // TODO: make safe - UIApplication.shared.windows.first?/*(where: { $0.layer !== (UIApplication.shared.connectedScenes.first as? UIWindowScene)?.keyWindow?.layer })?*/.layer.cornerRadius = 10// (where: { !($0 is NativeWindow)*/ }) + // TODO: assure player window + UIApplication.shared.windows.first?.layer.cornerRadius = 10.0 UIApplication.shared.windows.first?.layer.masksToBounds = true -// UIApplication.shared.windows.first?.subviews[0].subviews[0].subviews[1].subviews[0].subviews[0].backgroundColor = .red -// UIApplication.shared.windows.first?.subviews[0].subviews[0].subviews[1].subviews[0].subviews[0].setNeedsDisplay() -// UIApplication.shared.windows.first?.subviews[0].subviews[0].subviews[1].backgroundColor = .red -// UIApplication.shared.windows.first?.subviews[0].subviews[0].subviews[1].setNeedsDisplay() - - self.x?.invalidate() - let x = CADisplayLink(target: self, selector: #selector(observePiPWindow)) - x.add(to: .main, forMode: .default) - self.x = x + self.pipTrackDisplayLink?.invalidate() + self.pipTrackDisplayLink = CADisplayLink(target: self, selector: #selector(observePiPWindow)) + self.pipTrackDisplayLink?.add(to: .main, forMode: .default) } - var lastPresentation: UIView? - var x: CADisplayLink? - @objc func observePiPWindow() { let pipViewDidBecomeVisible = (UIApplication.shared.windows.first?.layer.animationKeys()?.count ?? 0) > 0 if pipViewDidBecomeVisible { lastPresentation?.removeFromSuperview() lastPresentation = nil - self.x?.invalidate() - self.x = nil + self.pipTrackDisplayLink?.invalidate() + self.pipTrackDisplayLink = nil } } @@ -747,7 +708,7 @@ final class MediaStreamVideoComponent: Component { completionHandler(true) } } - var didRequestBringBack = false + func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) { self.didRequestBringBack = false self.state?.updated(transition: .immediate) @@ -788,7 +749,7 @@ final class MediaStreamVideoComponent: Component { // TODO: move to appropriate place fileprivate var lastFrame: [String: UIView] = [:] -class CustomIntensityVisualEffectView: UIVisualEffectView { +private final class CustomIntensityVisualEffectView: UIVisualEffectView { private var animator: UIViewPropertyAnimator! init(effect: UIVisualEffect, intensity: CGFloat) { diff --git a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift index 818aa3376f..c9735c3967 100644 --- a/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/StreamSheetComponent.swift @@ -175,7 +175,7 @@ final class StreamSheetComponent: CombinedComponent { let videoHeight = context.component.videoHeight let sheetHeight = context.component.sheetHeight - let animatedParticipantsVisible = !isFullscreen// context.component.participantsCount != -1 + let animatedParticipantsVisible = !isFullscreen context.add(viewerCounter .position(CGPoint(x: context.availableSize.width / 2, y: topOffset + 50.0 + videoHeight + (sheetHeight - 69.0 - videoHeight - 50.0 - context.component.bottomPadding) / 2 - 10.0)) @@ -187,10 +187,6 @@ final class StreamSheetComponent: CombinedComponent { } } - -private let latePurple = UIColor(rgb: 0x974aa9) -private let latePink = UIColor(rgb: 0xf0436c) - final class SheetBackgroundComponent: Component { private let color: UIColor private let radius: CGFloat diff --git a/submodules/TelegramUI/Sources/SharedAccountContext.swift b/submodules/TelegramUI/Sources/SharedAccountContext.swift index 2e3a02a4f8..802a86a466 100644 --- a/submodules/TelegramUI/Sources/SharedAccountContext.swift +++ b/submodules/TelegramUI/Sources/SharedAccountContext.swift @@ -105,7 +105,7 @@ public final class SharedAccountContextImpl: SharedAccountContext { private var callController: CallController? public let hasOngoingCall = ValuePromise(false) private let callState = Promise(nil) - // Rename to LiveStreamingController + private var groupCallController: VoiceChatController? public var currentGroupCallController: ViewController? { return self.groupCallController From fc35f2e2fed54d431d80734dd55251dc29e726f5 Mon Sep 17 00:00:00 2001 From: Ilya Yelagov Date: Sun, 8 Jan 2023 17:54:44 +0400 Subject: [PATCH 46/50] Cleanup --- .../Sources/Components/MediaStreamVideoComponent.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift index f9f58e98bc..4bda7c0fca 100644 --- a/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift +++ b/submodules/TelegramCallsUI/Sources/Components/MediaStreamVideoComponent.swift @@ -469,7 +469,6 @@ final class MediaStreamVideoComponent: Component { if videoView.bounds.size.width > 0, videoView.alpha > 0, self.hadVideo, - // TODO: remove from here and move to call end (or at least to background) let snapshot = videoView.snapshotView(afterScreenUpdates: false) ?? videoView.snapshotView(afterScreenUpdates: true) { lastFrame[component.call.peerId.id.description] = snapshot } From 008b2c4d3efe3c83f7a5f7b3d21f4633e2090630 Mon Sep 17 00:00:00 2001 From: Ali <> Date: Fri, 14 Apr 2023 11:21:56 +0400 Subject: [PATCH 47/50] Don't display initial archive notice if there are already chats in the archive --- .../Sources/ChatListController.swift | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index 087002b3b6..73613c7845 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -4213,9 +4213,19 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController return } let engine = self.context.engine + + let hasArchived = engine.messages.chatList(group: .archive, count: 10) + |> take(1) + |> map { list -> Bool in + return !list.items.isEmpty + } + self.chatListDisplayNode.mainContainerNode.currentItemNode.setCurrentRemovingItemId(ChatListNodeState.ItemId(peerId: peerIds[0], threadId: nil)) - let _ = (ApplicationSpecificNotice.incrementArchiveChatTips(accountManager: self.context.sharedContext.accountManager, count: 1) - |> deliverOnMainQueue).start(next: { [weak self] previousHintCount in + let _ = (combineLatest( + ApplicationSpecificNotice.incrementArchiveChatTips(accountManager: self.context.sharedContext.accountManager, count: 1), + hasArchived + ) + |> deliverOnMainQueue).start(next: { [weak self] previousHintCount, hasArchived in let _ = (engine.peers.updatePeersGroupIdInteractively(peerIds: peerIds, groupId: .archive) |> deliverOnMainQueue).start(completed: { guard let strongSelf = self else { @@ -4256,14 +4266,13 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController var title = peerIds.count == 1 ? strongSelf.presentationData.strings.ChatList_UndoArchiveTitle : strongSelf.presentationData.strings.ChatList_UndoArchiveMultipleTitle let text: String let undo: Bool - switch previousHintCount { - case 0: - text = strongSelf.presentationData.strings.ChatList_UndoArchiveText1 - undo = false - default: - text = title - title = "" - undo = true + if hasArchived || previousHintCount != 0 { + text = title + title = "" + undo = true + } else { + text = strongSelf.presentationData.strings.ChatList_UndoArchiveText1 + undo = false } let controller = UndoOverlayController(presentationData: strongSelf.context.sharedContext.currentPresentationData.with { $0 }, content: .archivedChat(peerId: peerIds[0].toInt64(), title: title, text: text, undo: undo), elevatedLayout: false, animateInAsReplacement: true, action: action) strongSelf.present(controller, in: .current) From 014ee47a95ba0f10afa41bcd3d293b1d723a1384 Mon Sep 17 00:00:00 2001 From: Ali <> Date: Fri, 14 Apr 2023 11:22:18 +0400 Subject: [PATCH 48/50] Don't cache ManagedAnimationNode lottie animations --- .../ManagedAnimationNode/Sources/ManagedAnimationNode.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/submodules/ManagedAnimationNode/Sources/ManagedAnimationNode.swift b/submodules/ManagedAnimationNode/Sources/ManagedAnimationNode.swift index 82bacf44f3..6720f7df92 100644 --- a/submodules/ManagedAnimationNode/Sources/ManagedAnimationNode.swift +++ b/submodules/ManagedAnimationNode/Sources/ManagedAnimationNode.swift @@ -46,7 +46,7 @@ public final class ManagedAnimationState { } else if let unpackedData = TGGUnzipData(data, 5 * 1024 * 1024) { data = unpackedData } - guard let instance = LottieInstance(data: data, fitzModifier: .none, colorReplacements: item.replaceColors, cacheKey: item.source.cacheKey) else { + guard let instance = LottieInstance(data: data, fitzModifier: .none, colorReplacements: item.replaceColors, cacheKey: "") else { return nil } resolvedInstance = instance From cfe7f43e0dfe6a46c010b5882050322ff67671fb Mon Sep 17 00:00:00 2001 From: Ali <> Date: Fri, 14 Apr 2023 18:59:33 +0400 Subject: [PATCH 49/50] Various improvements --- .../Sources/NotificationService.swift | 58 ++++++++- .../Telegram-iOS/en.lproj/Localizable.strings | 3 + .../Sources/ChatListController.swift | 30 +++-- .../Sources/ChatListControllerNode.swift | 112 ++++++++++++++++-- .../Sources/Node/ChatListNode.swift | 2 +- ...tControllerExtractedPresentationNode.swift | 3 + .../Sources/StickerPickerScreen.swift | 9 +- submodules/Postbox/Sources/ChatListView.swift | 1 + .../Postbox/Sources/ChatListViewState.swift | 31 +++++ .../Postbox/Sources/SeedConfiguration.swift | 5 +- .../Sources/ReactionContextNode.swift | 3 +- .../InstalledStickerPacksController.swift | 4 +- submodules/TelegramCore/BUILD | 1 + .../Sources/ApiUtils/TelegramChannel.swift | 3 +- .../Sources/ApiUtils/TelegramGroup.swift | 3 +- .../PendingMessages/EnqueueMessage.swift | 20 +++- ...yncCore_StandaloneAccountTransaction.swift | 13 ++ .../Sources/MessageContentKind.swift | 4 +- .../Sources/ActionPanelComponent.swift | 21 +++- .../Sources/AvatarEditorScreen.swift | 6 +- .../Sources/ChatEntityKeyboardInputNode.swift | 44 +++++-- .../EmojiStatusSelectionComponent.swift | 3 +- .../Sources/EmojiPagerContentComponent.swift | 45 +++++-- .../Sources/EmojiSearchContent.swift | 3 +- .../Sources/ForumCreateTopicScreen.swift | 3 +- .../TelegramUI/Sources/AccountContext.swift | 12 +- .../TelegramUI/Sources/AppDelegate.swift | 10 +- .../Sources/ChatControllerNode.swift | 6 +- .../TelegramUI/Sources/ChatLoadingNode.swift | 9 +- .../Sources/ChatMessageBubbleItemNode.swift | 7 +- .../Sources/ChatMessageReplyInfoNode.swift | 2 +- .../Sources/PeerInfo/PeerInfoScreen.swift | 37 +++--- .../Sources/PeerSelectionControllerNode.swift | 2 +- 33 files changed, 430 insertions(+), 85 deletions(-) diff --git a/Telegram/NotificationService/Sources/NotificationService.swift b/Telegram/NotificationService/Sources/NotificationService.swift index 0a067efc1c..ed0b9eec2c 100644 --- a/Telegram/NotificationService/Sources/NotificationService.swift +++ b/Telegram/NotificationService/Sources/NotificationService.swift @@ -918,6 +918,7 @@ private final class NotificationServiceHandler { case logout case poll(peerId: PeerId, content: NotificationContent, messageId: MessageId?) case deleteMessage([MessageId]) + case readReactions([MessageId]) case readMessage(MessageId) case call(CallData) } @@ -948,6 +949,20 @@ private final class NotificationServiceHandler { action = .deleteMessage(messagesDeleted) } } + case "READ_REACTION": + if let peerId { + if let messageId = messageId { + action = .readReactions([MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: messageId)]) + } else if let messageIds = payloadJson["messages"] as? String { + var messages: [MessageId] = [] + for messageId in messageIds.split(separator: ",") { + if let messageIdValue = Int32(messageId) { + messages.append(MessageId(peerId: peerId, namespace: Namespaces.Message.Cloud, id: messageIdValue)) + } + } + action = .readReactions(messages) + } + } case "READ_HISTORY": if let peerId = peerId { if let messageIdString = payloadJson["max_id"] as? String { @@ -1055,13 +1070,15 @@ private final class NotificationServiceHandler { } if let category = aps["category"] as? String { + if let _ = aps["r"] { + content.category = "r" + } if peerId.isGroupOrChannel && ["r", "m"].contains(category) { content.category = "g\(category)" } else { content.category = category } - let _ = messageId /*if (peerId != 0 && messageId != 0 && parsedAttachment != nil && attachmentData != nil) { @@ -1588,6 +1605,45 @@ private final class NotificationServiceHandler { } }) }) + case let .readReactions(ids): + Logger.shared.log("NotificationService \(episode)", "Will read reactions \(ids)") + UNUserNotificationCenter.current().getDeliveredNotifications(completionHandler: { notifications in + var removeIdentifiers: [String] = [] + for notification in notifications { + if notification.request.content.categoryIdentifier != "r" { + continue + } + if let peerIdString = notification.request.content.userInfo["peerId"] as? String, let peerIdValue = Int64(peerIdString), let messageIdString = notification.request.content.userInfo["msg_id"] as? String, let messageIdValue = Int32(messageIdString) { + for id in ids { + if PeerId(peerIdValue) == id.peerId && messageIdValue == id.id { + removeIdentifiers.append(notification.request.identifier) + } + } + } + } + + let completeRemoval: () -> Void = { + guard let strongSelf = self else { + return + } + var content = NotificationContent(isLockedMessage: nil) + Logger.shared.log("NotificationService \(episode)", "Updating content to \(content)") + + updateCurrentContent(content) + + completed() + } + + if !removeIdentifiers.isEmpty { + Logger.shared.log("NotificationService \(episode)", "Will try to remove \(removeIdentifiers.count) notifications") + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: removeIdentifiers) + queue.after(1.0, { + completeRemoval() + }) + } else { + completeRemoval() + } + }) case let .readMessage(id): Logger.shared.log("NotificationService \(episode)", "Will read message \(id)") let _ = (stateManager.postbox.transaction { transaction -> Void in diff --git a/Telegram/Telegram-iOS/en.lproj/Localizable.strings b/Telegram/Telegram-iOS/en.lproj/Localizable.strings index 32f52ea744..512728e03d 100644 --- a/Telegram/Telegram-iOS/en.lproj/Localizable.strings +++ b/Telegram/Telegram-iOS/en.lproj/Localizable.strings @@ -7541,6 +7541,7 @@ Sorry for the inconvenience."; "PeerInfo.AutoDeleteSettingOther" = "Other..."; "PeerInfo.AutoDeleteDisable" = "Disable"; "PeerInfo.AutoDeleteInfo" = "Automatically delete messages sent in this chat after a certain period of time."; +"PeerInfo.ChannelAutoDeleteInfo" = "Automatically delete messages sent in this channel after a certain period of time."; "PeerInfo.ClearMessages" = "Clear Messages"; "PeerInfo.ClearConfirmationUser" = "Are you sure you want to delete all messages with %@?"; @@ -9339,3 +9340,5 @@ Sorry for the inconvenience."; "ChatList.EmptyListTooltip" = "Send a message or\nstart a group here."; "Username.BotTitle" = "Public Links"; + +"Notification.LockScreenReactionPlaceholder" = "Reaction"; diff --git a/submodules/ChatListUI/Sources/ChatListController.swift b/submodules/ChatListUI/Sources/ChatListController.swift index 73613c7845..94832b4542 100644 --- a/submodules/ChatListUI/Sources/ChatListController.swift +++ b/submodules/ChatListUI/Sources/ChatListController.swift @@ -3561,17 +3561,29 @@ public class ChatListControllerImpl: TelegramBaseController, ChatListController } } case let .forum(peerId): - self.joinForumDisposable.set((self.context.peerChannelMemberCategoriesContextsManager.join(engine: context.engine, peerId: peerId, hash: nil) - |> afterDisposed { [weak self] in - Queue.mainQueue().async { - if let strongSelf = self { - let _ = strongSelf - /*strongSelf.activityIndicator.isHidden = true - strongSelf.activityIndicator.stopAnimating() - strongSelf.isJoining = false*/ + let presentationData = self.presentationData + let progressSignal = Signal { [weak self] subscriber in + let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil)) + self?.present(controller, in: .window(.root)) + return ActionDisposable { [weak controller] in + Queue.mainQueue().async() { + controller?.dismiss() } } - }).start(error: { [weak self] error in + } + |> runOn(Queue.mainQueue()) + |> delay(0.8, queue: Queue.mainQueue()) + let progressDisposable = progressSignal.start() + + let signal: Signal = self.context.peerChannelMemberCategoriesContextsManager.join(engine: self.context.engine, peerId: peerId, hash: nil) + |> afterDisposed { + Queue.mainQueue().async { + progressDisposable.dispose() + } + } + + self.joinForumDisposable.set((signal + |> deliverOnMainQueue).start(error: { [weak self] error in guard let strongSelf = self else { return } diff --git a/submodules/ChatListUI/Sources/ChatListControllerNode.swift b/submodules/ChatListUI/Sources/ChatListControllerNode.swift index e05badfeb4..7169a163e8 100644 --- a/submodules/ChatListUI/Sources/ChatListControllerNode.swift +++ b/submodules/ChatListUI/Sources/ChatListControllerNode.swift @@ -329,6 +329,8 @@ private final class ChatListContainerItemNode: ASDisplayNode { } private let context: AccountContext + private weak var controller: ChatListControllerImpl? + private let location: ChatListControllerLocation private let animationCache: AnimationCache private let animationRenderer: MultiAnimationRenderer private var presentationData: PresentationData @@ -348,13 +350,18 @@ private final class ChatListContainerItemNode: ASDisplayNode { private var pollFilterUpdatesDisposable: Disposable? private var chatFilterUpdatesDisposable: Disposable? + private var peerDataDisposable: Disposable? private var chatFolderUpdates: ChatFolderUpdates? + private var canReportPeer: Bool = false + private(set) var validLayout: (size: CGSize, insets: UIEdgeInsets, visualNavigationHeight: CGFloat, originalNavigationHeight: CGFloat, inlineNavigationLocation: ChatListControllerLocation?, inlineNavigationTransitionFraction: CGFloat)? - init(context: AccountContext, location: ChatListControllerLocation, filter: ChatListFilter?, chatListMode: ChatListNodeMode, previewing: Bool, isInlineMode: Bool, controlsHistoryPreload: Bool, presentationData: PresentationData, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, becameEmpty: @escaping (ChatListFilter?) -> Void, emptyAction: @escaping (ChatListFilter?) -> Void, secondaryEmptyAction: @escaping () -> Void) { + init(context: AccountContext, controller: ChatListControllerImpl?, location: ChatListControllerLocation, filter: ChatListFilter?, chatListMode: ChatListNodeMode, previewing: Bool, isInlineMode: Bool, controlsHistoryPreload: Bool, presentationData: PresentationData, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, becameEmpty: @escaping (ChatListFilter?) -> Void, emptyAction: @escaping (ChatListFilter?) -> Void, secondaryEmptyAction: @escaping () -> Void) { self.context = context + self.controller = controller + self.location = location self.animationCache = animationCache self.animationRenderer = animationRenderer self.presentationData = presentationData @@ -504,11 +511,33 @@ private final class ChatListContainerItemNode: ASDisplayNode { } }) } + + if case let .forum(peerId) = location { + self.peerDataDisposable = (context.engine.data.subscribe( + TelegramEngine.EngineData.Item.Peer.StatusSettings(id: peerId) + ) + |> deliverOnMainQueue).start(next: { [weak self] statusSettings in + guard let self else { + return + } + var canReportPeer = false + if let statusSettings, statusSettings.flags.contains(.canReport) { + canReportPeer = true + } + if self.canReportPeer != canReportPeer { + self.canReportPeer = canReportPeer + if let (size, insets, visualNavigationHeight, originalNavigationHeight, inlineNavigationLocation, inlineNavigationTransitionFraction) = self.validLayout { + self.updateLayout(size: size, insets: insets, visualNavigationHeight: visualNavigationHeight, originalNavigationHeight: originalNavigationHeight, inlineNavigationLocation: inlineNavigationLocation, inlineNavigationTransitionFraction: inlineNavigationTransitionFraction, transition: .animated(duration: 0.4, curve: .spring)) + } + } + }) + } } deinit { self.pollFilterUpdatesDisposable?.dispose() self.chatFilterUpdatesDisposable?.dispose() + self.peerDataDisposable?.dispose() } private func layoutEmptyShimmerEffectNode(node: ChatListShimmerNode, size: CGSize, insets: UIEdgeInsets, verticalOffset: CGFloat, transition: ContainedViewLayoutTransition) { @@ -580,6 +609,7 @@ private final class ChatListContainerItemNode: ASDisplayNode { component: AnyComponent(ActionPanelComponent( theme: self.presentationData.theme, title: title, + color: .accent, action: { [weak self] in guard let self, let chatFolderUpdates = self.chatFolderUpdates else { return @@ -603,6 +633,72 @@ private final class ChatListContainerItemNode: ASDisplayNode { } } + topPanel.size = CGSize(width: size.width, height: topPanelHeight) + listInsets.top += topPanelHeight + additionalTopInset += topPanelHeight + } else if self.canReportPeer { + let topPanel: TopPanelItem + var topPanelTransition = Transition(transition) + if let current = self.topPanel { + topPanel = current + } else { + topPanelTransition = .immediate + topPanel = TopPanelItem() + self.topPanel = topPanel + } + + let title: String = self.presentationData.strings.Conversation_ReportSpamAndLeave + + let topPanelHeight: CGFloat = 44.0 + + let _ = topPanel.view.update( + transition: topPanelTransition, + component: AnyComponent(ActionPanelComponent( + theme: self.presentationData.theme, + title: title, + color: .destructive, + action: { [weak self] in + guard let self, case let .forum(peerId) = self.location else { + return + } + + let actionSheet = ActionSheetController(presentationData: self.presentationData) + actionSheet.setItemGroups([ + ActionSheetItemGroup(items: [ + ActionSheetTextItem(title: self.presentationData.strings.Conversation_ReportSpamGroupConfirmation), + ActionSheetButtonItem(title: self.presentationData.strings.Conversation_ReportSpamAndLeave, color: .destructive, action: { [weak self, weak actionSheet] in + actionSheet?.dismissAnimated() + + if let self { + self.controller?.setInlineChatList(location: nil) + let _ = self.context.engine.peers.removePeerChat(peerId: peerId, reportChatSpam: true).start() + } + }) + ]), + ActionSheetItemGroup(items: [ + ActionSheetButtonItem(title: self.presentationData.strings.Common_Cancel, color: .accent, font: .bold, action: { [weak actionSheet] in + actionSheet?.dismissAnimated() + }) + ]) + ]) + self.listNode.present?(actionSheet) + }, + dismissAction: { [weak self] in + guard let self, case let .forum(peerId) = self.location else { + return + } + let _ = self.context.engine.peers.dismissPeerStatusOptions(peerId: peerId).start() + } + )), + environment: {}, + containerSize: CGSize(width: size.width, height: topPanelHeight) + ) + if let topPanelView = topPanel.view.view { + if topPanelView.superview == nil { + self.view.addSubview(topPanelView) + } + } + topPanel.size = CGSize(width: size.width, height: topPanelHeight) listInsets.top += topPanelHeight additionalTopInset += topPanelHeight @@ -635,6 +731,7 @@ private final class ChatListContainerItemNode: ASDisplayNode { public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDelegate { private let context: AccountContext + private weak var controller: ChatListControllerImpl? let location: ChatListControllerLocation private let chatListMode: ChatListNodeMode private let previewing: Bool @@ -847,8 +944,9 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele var didBeginSelectingChats: (() -> Void)? public var displayFilterLimit: (() -> Void)? - public init(context: AccountContext, location: ChatListControllerLocation, chatListMode: ChatListNodeMode = .chatList(appendContacts: true), previewing: Bool, controlsHistoryPreload: Bool, isInlineMode: Bool, presentationData: PresentationData, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, filterBecameEmpty: @escaping (ChatListFilter?) -> Void, filterEmptyAction: @escaping (ChatListFilter?) -> Void, secondaryEmptyAction: @escaping () -> Void) { + public init(context: AccountContext, controller: ChatListControllerImpl?, location: ChatListControllerLocation, chatListMode: ChatListNodeMode = .chatList(appendContacts: true), previewing: Bool, controlsHistoryPreload: Bool, isInlineMode: Bool, presentationData: PresentationData, animationCache: AnimationCache, animationRenderer: MultiAnimationRenderer, filterBecameEmpty: @escaping (ChatListFilter?) -> Void, filterEmptyAction: @escaping (ChatListFilter?) -> Void, secondaryEmptyAction: @escaping () -> Void) { self.context = context + self.controller = controller self.location = location self.chatListMode = chatListMode self.previewing = previewing @@ -872,7 +970,7 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele self.backgroundColor = presentationData.theme.chatList.backgroundColor - let itemNode = ChatListContainerItemNode(context: self.context, location: self.location, filter: nil, chatListMode: chatListMode, previewing: self.previewing, isInlineMode: self.isInlineMode, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, becameEmpty: { [weak self] filter in + let itemNode = ChatListContainerItemNode(context: self.context, controller: self.controller, location: self.location, filter: nil, chatListMode: chatListMode, previewing: self.previewing, isInlineMode: self.isInlineMode, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, becameEmpty: { [weak self] filter in self?.filterBecameEmpty(filter) }, emptyAction: { [weak self] filter in self?.filterEmptyAction(filter) @@ -1170,7 +1268,7 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele itemNode.emptyNode?.restartAnimation() completion?() } else if self.pendingItemNode == nil { - let itemNode = ChatListContainerItemNode(context: self.context, location: self.location, filter: self.availableFilters[index].filter, chatListMode: self.chatListMode, previewing: self.previewing, isInlineMode: self.isInlineMode, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, becameEmpty: { [weak self] filter in + let itemNode = ChatListContainerItemNode(context: self.context, controller: self.controller, location: self.location, filter: self.availableFilters[index].filter, chatListMode: self.chatListMode, previewing: self.previewing, isInlineMode: self.isInlineMode, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, becameEmpty: { [weak self] filter in self?.filterBecameEmpty(filter) }, emptyAction: { [weak self] filter in self?.filterEmptyAction(filter) @@ -1288,7 +1386,7 @@ public final class ChatListContainerNode: ASDisplayNode, UIGestureRecognizerDele validNodeIds.append(id) if self.itemNodes[id] == nil && self.enableAdjacentFilterLoading && !self.disableItemNodeOperationsWhileAnimating { - let itemNode = ChatListContainerItemNode(context: self.context, location: self.location, filter: self.availableFilters[i].filter, chatListMode: self.chatListMode, previewing: self.previewing, isInlineMode: self.isInlineMode, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, becameEmpty: { [weak self] filter in + let itemNode = ChatListContainerItemNode(context: self.context, controller: self.controller, location: self.location, filter: self.availableFilters[i].filter, chatListMode: self.chatListMode, previewing: self.previewing, isInlineMode: self.isInlineMode, controlsHistoryPreload: self.controlsHistoryPreload, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, becameEmpty: { [weak self] filter in self?.filterBecameEmpty(filter) }, emptyAction: { [weak self] filter in self?.filterEmptyAction(filter) @@ -1421,7 +1519,7 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { var filterBecameEmpty: ((ChatListFilter?) -> Void)? var filterEmptyAction: ((ChatListFilter?) -> Void)? var secondaryEmptyAction: (() -> Void)? - self.mainContainerNode = ChatListContainerNode(context: context, location: location, previewing: previewing, controlsHistoryPreload: controlsHistoryPreload, isInlineMode: false, presentationData: presentationData, animationCache: animationCache, animationRenderer: animationRenderer, filterBecameEmpty: { filter in + self.mainContainerNode = ChatListContainerNode(context: context, controller: controller, location: location, previewing: previewing, controlsHistoryPreload: controlsHistoryPreload, isInlineMode: false, presentationData: presentationData, animationCache: animationCache, animationRenderer: animationRenderer, filterBecameEmpty: { filter in filterBecameEmpty?(filter) }, filterEmptyAction: { filter in filterEmptyAction?(filter) @@ -1849,7 +1947,7 @@ final class ChatListControllerNode: ASDisplayNode, UIGestureRecognizerDelegate { forumPeerId = peerId } - let inlineStackContainerNode = ChatListContainerNode(context: self.context, location: location, previewing: false, controlsHistoryPreload: false, isInlineMode: true, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, filterBecameEmpty: { _ in }, filterEmptyAction: { [weak self] _ in self?.emptyListAction?(forumPeerId) }, secondaryEmptyAction: {}) + let inlineStackContainerNode = ChatListContainerNode(context: self.context, controller: self.controller, location: location, previewing: false, controlsHistoryPreload: false, isInlineMode: true, presentationData: self.presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, filterBecameEmpty: { _ in }, filterEmptyAction: { [weak self] _ in self?.emptyListAction?(forumPeerId) }, secondaryEmptyAction: {}) return inlineStackContainerNode } diff --git a/submodules/ChatListUI/Sources/Node/ChatListNode.swift b/submodules/ChatListUI/Sources/Node/ChatListNode.swift index 6cdfc5f62c..fd358cff29 100644 --- a/submodules/ChatListUI/Sources/Node/ChatListNode.swift +++ b/submodules/ChatListUI/Sources/Node/ChatListNode.swift @@ -1775,7 +1775,7 @@ public final class ChatListNode: ListView { })*/ let contacts: Signal<[ChatListContactPeer], NoError> - if case .chatList(groupId: .root) = location, chatListFilter == nil { + if case .chatList(groupId: .root) = location, chatListFilter == nil, case .chatList = mode { contacts = ApplicationSpecificNotice.displayChatListContacts(accountManager: context.sharedContext.accountManager) |> distinctUntilChanged |> mapToSignal { value -> Signal<[ChatListContactPeer], NoError> in diff --git a/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift b/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift index 099518966b..6a31d7403d 100644 --- a/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift +++ b/submodules/ContextUI/Sources/ContextControllerExtractedPresentationNode.swift @@ -269,6 +269,9 @@ final class ContextControllerExtractedPresentationNode: ASDisplayNode, ContextCo super.init() + self.view.addSubview(self.scroller) + self.scroller.isHidden = true + self.addSubnode(self.backgroundNode) self.addSubnode(self.clippingNode) self.clippingNode.addSubnode(self.scrollNode) diff --git a/submodules/DrawingUI/Sources/StickerPickerScreen.swift b/submodules/DrawingUI/Sources/StickerPickerScreen.swift index 7e5720995d..85e9b74f03 100644 --- a/submodules/DrawingUI/Sources/StickerPickerScreen.swift +++ b/submodules/DrawingUI/Sources/StickerPickerScreen.swift @@ -377,7 +377,8 @@ class StickerPickerScreen: ViewController { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: false, - hideBackground: true + hideBackground: true, + stateContext: nil ) content.masks?.inputInteractionHolder.inputInteraction = EmojiPagerContentComponent.InputInteraction( @@ -455,7 +456,8 @@ class StickerPickerScreen: ViewController { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: false, - hideBackground: true + hideBackground: true, + stateContext: nil ) var stickerPeekBehavior: EmojiContentPeekBehaviorImpl? @@ -580,7 +582,8 @@ class StickerPickerScreen: ViewController { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: false, - hideBackground: true + hideBackground: true, + stateContext: nil ) if let (layout, navigationHeight) = self.currentLayout { diff --git a/submodules/Postbox/Sources/ChatListView.swift b/submodules/Postbox/Sources/ChatListView.swift index f6e82fa722..56a37baaaa 100644 --- a/submodules/Postbox/Sources/ChatListView.swift +++ b/submodules/Postbox/Sources/ChatListView.swift @@ -677,6 +677,7 @@ final class MutableChatListView { switch entry { case let .IntermediateMessageEntry(index, messageIndex): var renderedMessages: [Message] = [] + if let messageIndex = messageIndex { if let messageGroup = postbox.messageHistoryTable.getMessageGroup(at: messageIndex, limit: 10) { renderedMessages.append(contentsOf: messageGroup.compactMap(postbox.renderIntermediateMessage)) diff --git a/submodules/Postbox/Sources/ChatListViewState.swift b/submodules/Postbox/Sources/ChatListViewState.swift index 20eca6a4a3..1551b9a486 100644 --- a/submodules/Postbox/Sources/ChatListViewState.swift +++ b/submodules/Postbox/Sources/ChatListViewState.swift @@ -1599,6 +1599,37 @@ struct ChatListViewState { } } + var needsMigrationMerge = false + for message in renderedMessages { + if postbox.seedConfiguration.isPeerUpgradeMessage(message) { + if renderedMessages.count == 1 { + needsMigrationMerge = true + } + } + } + + if needsMigrationMerge, let associatedMessageId = postbox.cachedPeerDataTable.get(index.messageIndex.id.peerId)?.associatedHistoryMessageId { + let innerMessages = postbox.messageHistoryTable.fetch( + peerId: associatedMessageId.peerId, + namespace: associatedMessageId.namespace, + tag: nil, + threadId: nil, + from: .absoluteUpperBound().withPeerId(associatedMessageId.peerId).withNamespace(associatedMessageId.namespace), + includeFrom: true, + to: .absoluteLowerBound().withPeerId(associatedMessageId.peerId).withNamespace(associatedMessageId.namespace), + ignoreMessagesInTimestampRange: nil, + limit: 2 + ) + for innerMessage in innerMessages { + let message = postbox.renderIntermediateMessage(innerMessage) + if !postbox.seedConfiguration.isPeerUpgradeMessage(message) { + renderedMessages.removeAll() + renderedMessages.append(message) + break + } + } + } + var autoremoveTimeout: Int32? if let cachedData = postbox.cachedPeerDataTable.get(index.messageIndex.id.peerId) { autoremoveTimeout = postbox.seedConfiguration.decodeAutoremoveTimeout(cachedData) diff --git a/submodules/Postbox/Sources/SeedConfiguration.swift b/submodules/Postbox/Sources/SeedConfiguration.swift index f39902dd77..704eb78956 100644 --- a/submodules/Postbox/Sources/SeedConfiguration.swift +++ b/submodules/Postbox/Sources/SeedConfiguration.swift @@ -76,6 +76,7 @@ public final class SeedConfiguration { public let mergeMessageAttributes: ([MessageAttribute], inout [MessageAttribute]) -> Void public let decodeMessageThreadInfo: (CodableEntry) -> Message.AssociatedThreadInfo? public let decodeAutoremoveTimeout: (CachedPeerData) -> Int32? + public let isPeerUpgradeMessage: (Message) -> Bool public init( globalMessageIdsPeerIdNamespaces: Set, @@ -101,7 +102,8 @@ public final class SeedConfiguration { defaultGlobalNotificationSettings: PostboxGlobalNotificationSettings, mergeMessageAttributes: @escaping ([MessageAttribute], inout [MessageAttribute]) -> Void, decodeMessageThreadInfo: @escaping (CodableEntry) -> Message.AssociatedThreadInfo?, - decodeAutoremoveTimeout: @escaping (CachedPeerData) -> Int32? + decodeAutoremoveTimeout: @escaping (CachedPeerData) -> Int32?, + isPeerUpgradeMessage: @escaping (Message) -> Bool ) { self.globalMessageIdsPeerIdNamespaces = globalMessageIdsPeerIdNamespaces self.initializeChatListWithHole = initializeChatListWithHole @@ -123,5 +125,6 @@ public final class SeedConfiguration { self.mergeMessageAttributes = mergeMessageAttributes self.decodeMessageThreadInfo = decodeMessageThreadInfo self.decodeAutoremoveTimeout = decodeAutoremoveTimeout + self.isPeerUpgradeMessage = isPeerUpgradeMessage } } diff --git a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift index d2ff3e60cc..1ea0d48d3b 100644 --- a/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift +++ b/submodules/ReactionSelectionNode/Sources/ReactionContextNode.swift @@ -1583,7 +1583,8 @@ public final class ReactionContextNode: ASDisplayNode, UIScrollViewDelegate { ), externalExpansionView: self.view, useOpaqueTheme: false, - hideBackground: false + hideBackground: false, + stateContext: nil ) } diff --git a/submodules/SettingsUI/Sources/Stickers/InstalledStickerPacksController.swift b/submodules/SettingsUI/Sources/Stickers/InstalledStickerPacksController.swift index cfcecf8841..3df985500b 100644 --- a/submodules/SettingsUI/Sources/Stickers/InstalledStickerPacksController.swift +++ b/submodules/SettingsUI/Sources/Stickers/InstalledStickerPacksController.swift @@ -519,7 +519,9 @@ private func installedStickerPacksControllerEntries(context: AccountContext, pre if let archived = archived, !archived.isEmpty { entries.append(.archived(presentationData.theme, presentationData.strings.StickerPacksSettings_ArchivedPacks, Int32(archived.count), archived)) } - entries.append(.emoji(presentationData.theme, presentationData.strings.StickerPacksSettings_Emoji, emojiCount)) + if emojiCount != 0 { + entries.append(.emoji(presentationData.theme, presentationData.strings.StickerPacksSettings_Emoji, emojiCount)) + } if let quickReaction = quickReaction, let availableReactions = availableReactions { entries.append(.quickReaction(presentationData.strings.Settings_QuickReactionSetup_NavigationTitle, quickReaction, availableReactions)) } diff --git a/submodules/TelegramCore/BUILD b/submodules/TelegramCore/BUILD index 5a883c9356..980b3eff65 100644 --- a/submodules/TelegramCore/BUILD +++ b/submodules/TelegramCore/BUILD @@ -48,6 +48,7 @@ swift_library( "//submodules/ManagedFile:ManagedFile", "//submodules/Utils/RangeSet:RangeSet", "//submodules/Utils/DarwinDirStat", + "//submodules/Emoji", ], visibility = [ "//visibility:public", diff --git a/submodules/TelegramCore/Sources/ApiUtils/TelegramChannel.swift b/submodules/TelegramCore/Sources/ApiUtils/TelegramChannel.swift index 90d653e163..5fbc467e5a 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/TelegramChannel.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/TelegramChannel.swift @@ -111,7 +111,8 @@ public extension TelegramChannel { .banSendStickers, .banSendPolls, .banSendFiles, - .banSendInline + .banSendInline, + .banSendMusic ] if let bannedRights = self.bannedRights, bannedRights.flags.intersection(flags) == flags { diff --git a/submodules/TelegramCore/Sources/ApiUtils/TelegramGroup.swift b/submodules/TelegramCore/Sources/ApiUtils/TelegramGroup.swift index d0a85c23cd..2408b3f899 100644 --- a/submodules/TelegramCore/Sources/ApiUtils/TelegramGroup.swift +++ b/submodules/TelegramCore/Sources/ApiUtils/TelegramGroup.swift @@ -30,7 +30,8 @@ public extension TelegramGroup { .banSendStickers, .banSendPolls, .banSendFiles, - .banSendInline + .banSendInline, + .banSendMusic ] if let defaultBannedRights = self.defaultBannedRights, defaultBannedRights.flags.intersection(flags) == flags { return false diff --git a/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift b/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift index 176b04e711..4330fb5ab9 100644 --- a/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift +++ b/submodules/TelegramCore/Sources/PendingMessages/EnqueueMessage.swift @@ -2,6 +2,7 @@ import Foundation import Postbox import TelegramApi import SwiftSignalKit +import Emoji public enum EnqueueMessageGrouping { case none @@ -287,18 +288,25 @@ public func resendMessages(account: Account, messageIds: [MessageId]) -> Signal< var filteredAttributes: [MessageAttribute] = [] var replyToMessageId: MessageId? var bubbleUpEmojiOrStickersets: [ItemCollectionId] = [] + var forwardSource: MessageId? inner: for attribute in message.attributes { if let attribute = attribute as? ReplyMessageAttribute { replyToMessageId = attribute.messageId } else if let attribute = attribute as? OutgoingMessageInfoAttribute { bubbleUpEmojiOrStickersets = attribute.bubbleUpEmojiOrStickersets continue inner + } else if let attribute = attribute as? ForwardSourceInfoAttribute { + forwardSource = attribute.messageId } else { filteredAttributes.append(attribute) } } - - messages.append(.message(text: message.text, attributes: filteredAttributes, inlineStickers: [:], mediaReference: message.media.first.flatMap(AnyMediaReference.standalone), replyToMessageId: replyToMessageId, localGroupingKey: message.groupingKey, correlationId: nil, bubbleUpEmojiOrStickersets: bubbleUpEmojiOrStickersets)) + + if let forwardSource { + messages.append(.forward(source: forwardSource, threadId: nil, grouping: .auto, attributes: filteredAttributes, correlationId: nil)) + } else { + messages.append(.message(text: message.text, attributes: filteredAttributes, inlineStickers: [:], mediaReference: message.media.first.flatMap(AnyMediaReference.standalone), replyToMessageId: replyToMessageId, localGroupingKey: message.groupingKey, correlationId: nil, bubbleUpEmojiOrStickersets: bubbleUpEmojiOrStickersets)) + } } } let _ = enqueueMessages(transaction: transaction, account: account, peerId: peerId, messages: messages.map { (false, $0) }) @@ -399,6 +407,14 @@ func enqueueMessages(transaction: Transaction, account: Account, peerId: PeerId, transaction.storeMediaIfNotPresent(media: file) } + for emoji in text.emojis { + if emoji.isSingleEmoji { + if !emojiItems.contains(where: { $0.content == .text(emoji) }) { + emojiItems.append(RecentEmojiItem(.text(emoji))) + } + } + } + var peerAutoremoveTimeout: Int32? if let peer = peer as? TelegramSecretChat { var isAction = false diff --git a/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift b/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift index e050fbcbcd..d8195e20b6 100644 --- a/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift +++ b/submodules/TelegramCore/Sources/SyncCore/SyncCore_StandaloneAccountTransaction.swift @@ -149,6 +149,19 @@ public let telegramPostboxSeedConfiguration: SeedConfiguration = { } } return nil + }, + isPeerUpgradeMessage: { message in + for media in message.media { + if let action = media as? TelegramMediaAction { + switch action.action { + case .groupMigratedToChannel, .channelMigratedFromGroup: + return true + default: + break + } + } + } + return false } ) }() diff --git a/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift b/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift index 990b07d8b0..e306922d14 100644 --- a/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift +++ b/submodules/TelegramStringFormatting/Sources/MessageContentKind.swift @@ -414,11 +414,13 @@ public func foldLineBreaks(_ text: String) -> String { public func foldLineBreaks(_ text: NSAttributedString) -> NSAttributedString { let remainingString = NSMutableAttributedString(attributedString: text) + var lines: [NSAttributedString] = [] while true { if let range = remainingString.string.range(of: "\n") { let mappedRange = NSRange(range, in: remainingString.string) - lines.append(remainingString.attributedSubstring(from: NSRange(location: 0, length: mappedRange.upperBound - 1))) + let restString = remainingString.attributedSubstring(from: NSRange(location: 0, length: mappedRange.upperBound - 1)) + lines.append(restString) remainingString.replaceCharacters(in: NSRange(location: 0, length: mappedRange.upperBound), with: "") } else { if lines.isEmpty { diff --git a/submodules/TelegramUI/Components/ActionPanelComponent/Sources/ActionPanelComponent.swift b/submodules/TelegramUI/Components/ActionPanelComponent/Sources/ActionPanelComponent.swift index e827d85d98..2e946f37db 100644 --- a/submodules/TelegramUI/Components/ActionPanelComponent/Sources/ActionPanelComponent.swift +++ b/submodules/TelegramUI/Components/ActionPanelComponent/Sources/ActionPanelComponent.swift @@ -7,19 +7,27 @@ import ComponentDisplayAdapters import AppBundle public final class ActionPanelComponent: Component { + public enum Color { + case accent + case destructive + } + public let theme: PresentationTheme public let title: String + public let color: Color public let action: () -> Void public let dismissAction: () -> Void public init( theme: PresentationTheme, title: String, + color: Color, action: @escaping () -> Void, dismissAction: @escaping () -> Void ) { self.theme = theme self.title = title + self.color = color self.action = action self.dismissAction = dismissAction } @@ -31,6 +39,9 @@ public final class ActionPanelComponent: Component { if lhs.title != rhs.title { return false } + if lhs.color != rhs.color { + return false + } return true } @@ -136,9 +147,17 @@ public final class ActionPanelComponent: Component { let rightInset: CGFloat = 44.0 + let resolvedColor: UIColor + switch component.color { + case .accent: + resolvedColor = component.theme.rootController.navigationBar.accentTextColor + case .destructive: + resolvedColor = component.theme.list.itemDestructiveColor + } + let titleSize = self.title.update( transition: .immediate, - component: AnyComponent(Text(text: component.title, font: Font.regular(17.0), color: component.theme.rootController.navigationBar.accentTextColor)), + component: AnyComponent(Text(text: component.title, font: Font.regular(17.0), color: resolvedColor)), environment: {}, containerSize: CGSize(width: availableSize.width - rightInset, height: availableSize.height) ) diff --git a/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift b/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift index 3d57ab5d24..fd58e86a12 100644 --- a/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift +++ b/submodules/TelegramUI/Components/AvatarEditorScreen/Sources/AvatarEditorScreen.swift @@ -686,7 +686,8 @@ final class AvatarEditorScreenComponent: Component { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: true, - hideBackground: true + hideBackground: true, + stateContext: nil ) data.stickers?.inputInteractionHolder.inputInteraction = EmojiPagerContentComponent.InputInteraction( @@ -813,7 +814,8 @@ final class AvatarEditorScreenComponent: Component { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: true, - hideBackground: true + hideBackground: true, + stateContext: nil ) self.state?.updated(transition: .immediate) diff --git a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift index 0e4f25ab67..3ddeb72b0b 100644 --- a/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift +++ b/submodules/TelegramUI/Components/ChatEntityKeyboardInputNode/Sources/ChatEntityKeyboardInputNode.swift @@ -87,6 +87,13 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } } + public final class StateContext { + let emojiState = EmojiPagerContentComponent.StateContext() + + public init() { + } + } + public static func hasPremium(context: AccountContext, chatPeerId: EnginePeer.Id?, premiumIfSavedMessages: Bool) -> Signal { let hasPremium: Signal if premiumIfSavedMessages, let chatPeerId = chatPeerId, chatPeerId == context.account.peerId { @@ -237,6 +244,7 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { } private let context: AccountContext + private let stateContext: StateContext? private let entityKeyboardView: ComponentHostView private let defaultToEmojiTab: Bool @@ -581,11 +589,12 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { |> distinctUntilChanged } - public init(context: AccountContext, currentInputData: InputData, updatedInputData: Signal, defaultToEmojiTab: Bool, opaqueTopPanelBackground: Bool = false, controllerInteraction: ChatControllerInteraction?, interfaceInteraction: ChatPanelInterfaceInteraction?, chatPeerId: PeerId?) { + public init(context: AccountContext, currentInputData: InputData, updatedInputData: Signal, defaultToEmojiTab: Bool, opaqueTopPanelBackground: Bool = false, controllerInteraction: ChatControllerInteraction?, interfaceInteraction: ChatPanelInterfaceInteraction?, chatPeerId: PeerId?, stateContext: StateContext?) { self.context = context self.currentInputData = currentInputData self.defaultToEmojiTab = defaultToEmojiTab self.opaqueTopPanelBackground = opaqueTopPanelBackground + self.stateContext = stateContext self.controllerInteraction = controllerInteraction @@ -1217,7 +1226,8 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: false, - hideBackground: false + hideBackground: false, + stateContext: self.stateContext?.emojiState ) self.stickerInputInteraction = EmojiPagerContentComponent.InputInteraction( @@ -1510,7 +1520,8 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: false, - hideBackground: false + hideBackground: false, + stateContext: nil ) self.inputDataDisposable = (combineLatest(queue: .mainQueue(), @@ -1950,8 +1961,25 @@ public final class ChatEntityKeyboardInputNode: ChatInputNode { var updatedGroups: [EmojiPagerContentComponent.ItemGroup] = [] var staticIsFirst = false - if let first = itemGroups.first, first.groupId == AnyHashable("static") { - staticIsFirst = true + let topStaticGroups: [String] = [ + "static", + "recent", + "featuredTop" + ] + for group in itemGroups { + var found = false + for topStaticGroup in topStaticGroups { + if group.groupId == AnyHashable(topStaticGroup) { + if group.groupId == AnyHashable("static") { + staticIsFirst = true + } + found = true + break + } + } + if !found { + break + } } for group in itemGroups { @@ -2379,7 +2407,8 @@ public final class EntityInputView: UIInputView, AttachmentTextInputPanelInputVi externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: false, - hideBackground: hideBackground + hideBackground: hideBackground, + stateContext: nil ) let semaphore = DispatchSemaphore(value: 0) @@ -2411,7 +2440,8 @@ public final class EntityInputView: UIInputView, AttachmentTextInputPanelInputVi opaqueTopPanelBackground: true, controllerInteraction: nil, interfaceInteraction: nil, - chatPeerId: nil + chatPeerId: nil, + stateContext: nil ) self.inputNode = inputNode inputNode.clipContentToTopPanel = true diff --git a/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift b/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift index ef73e6e853..68f6e7504c 100644 --- a/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift +++ b/submodules/TelegramUI/Components/EmojiStatusSelectionComponent/Sources/EmojiStatusSelectionComponent.swift @@ -674,7 +674,8 @@ public final class EmojiStatusSelectionController: ViewController { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: true, - hideBackground: false + hideBackground: false, + stateContext: nil ) strongSelf.refreshLayout(transition: .immediate) diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift index 0a9b72b465..560d832e04 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiPagerContentComponent.swift @@ -2244,6 +2244,13 @@ public final class EmojiPagerContentComponent: Component { } } + public final class StateContext { + var scrollPosition: CGFloat = 0.0 + + public init() { + } + } + public final class SynchronousLoadBehavior { public let isDisabled: Bool @@ -2313,6 +2320,7 @@ public final class EmojiPagerContentComponent: Component { public let useOpaqueTheme: Bool public let hideBackground: Bool public let scrollingStickersGridPromise = ValuePromise(false) + public let stateContext: StateContext? public init( performItemAction: @escaping (AnyHashable, Item, UIView, CGRect, CALayer, Bool) -> Void, @@ -2337,7 +2345,8 @@ public final class EmojiPagerContentComponent: Component { externalBackground: ExternalBackground?, externalExpansionView: UIView?, useOpaqueTheme: Bool, - hideBackground: Bool + hideBackground: Bool, + stateContext: StateContext? ) { self.performItemAction = performItemAction self.deleteBackwards = deleteBackwards @@ -2362,6 +2371,7 @@ public final class EmojiPagerContentComponent: Component { self.externalExpansionView = externalExpansionView self.useOpaqueTheme = useOpaqueTheme self.hideBackground = hideBackground + self.stateContext = stateContext } } @@ -5209,6 +5219,10 @@ public final class EmojiPagerContentComponent: Component { self.updateVisibleItems(transition: .immediate, attemptSynchronousLoads: false, previousItemPositions: nil, updatedItemPositions: nil) self.updateScrollingOffset(isReset: false, transition: .immediate) + + if let stateContext = self.component?.inputInteractionHolder.inputInteraction?.stateContext { + stateContext.scrollPosition = scrollView.bounds.minY + } } public func scrollViewWillEndDragging(_ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer) { @@ -6493,9 +6507,13 @@ public final class EmojiPagerContentComponent: Component { let previousSize = self.scrollView.bounds.size var resetScrolling = false + var isFirstUpdate = false if self.scrollView.bounds.isEmpty && component.displaySearchWithPlaceholder != nil { resetScrolling = true } + if previousComponent == nil { + isFirstUpdate = true + } if previousComponent?.itemContentUniqueId != component.itemContentUniqueId { resetScrolling = true } @@ -6601,7 +6619,11 @@ public final class EmojiPagerContentComponent: Component { } if resetScrolling { - self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: scrollSize) + var resetScrollY: CGFloat = 0.0 + if isFirstUpdate, let stateContext = component.inputInteractionHolder.inputInteraction?.stateContext { + resetScrollY = stateContext.scrollPosition + } + self.scrollView.bounds = CGRect(origin: CGPoint(x: 0.0, y: resetScrollY), size: scrollSize) } self.ignoreScrolling = false @@ -7072,10 +7094,15 @@ public final class EmojiPagerContentComponent: Component { var itemGroups: [ItemGroup] = [] var itemGroupIndexById: [AnyHashable: Int] = [:] - let appendUnicodeEmoji = { + let maybeAppendUnicodeEmoji = { + let groupId: AnyHashable = "static" + + if itemGroupIndexById[groupId] != nil { + return + } + if areUnicodeEmojiEnabled { for (subgroupId, list) in staticEmojiMapping { - let groupId: AnyHashable = "static" for emojiString in list { let resultItem = EmojiPagerContentComponent.Item( animationData: nil, @@ -7097,10 +7124,6 @@ public final class EmojiPagerContentComponent: Component { } } - if !hasPremium { - appendUnicodeEmoji() - } - var installedCollectionIds = Set() for (id, _, _) in view.collectionInfos { installedCollectionIds.insert(id) @@ -7744,6 +7767,10 @@ public final class EmojiPagerContentComponent: Component { } } + if !hasPremium { + maybeAppendUnicodeEmoji() + } + if areCustomEmojiEnabled { for entry in view.entries { guard let item = entry.item as? StickerPackItem else { @@ -7889,7 +7916,7 @@ public final class EmojiPagerContentComponent: Component { } if hasPremium { - appendUnicodeEmoji() + maybeAppendUnicodeEmoji() } var displaySearchWithPlaceholder: String? diff --git a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiSearchContent.swift b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiSearchContent.swift index 255699b257..bd542f9918 100644 --- a/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiSearchContent.swift +++ b/submodules/TelegramUI/Components/EntityKeyboard/Sources/EmojiSearchContent.swift @@ -401,7 +401,8 @@ public final class EmojiSearchContent: ASDisplayNode, EntitySearchContainerNode externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: true, - hideBackground: false + hideBackground: false, + stateContext: nil ) self.dataDisposable = ( diff --git a/submodules/TelegramUI/Components/ForumCreateTopicScreen/Sources/ForumCreateTopicScreen.swift b/submodules/TelegramUI/Components/ForumCreateTopicScreen/Sources/ForumCreateTopicScreen.swift index 7b92eacbbc..a36e244c2f 100644 --- a/submodules/TelegramUI/Components/ForumCreateTopicScreen/Sources/ForumCreateTopicScreen.swift +++ b/submodules/TelegramUI/Components/ForumCreateTopicScreen/Sources/ForumCreateTopicScreen.swift @@ -1013,7 +1013,8 @@ private final class ForumCreateTopicScreenComponent: CombinedComponent { externalBackground: nil, externalExpansionView: nil, useOpaqueTheme: true, - hideBackground: false + hideBackground: false, + stateContext: nil ) } } diff --git a/submodules/TelegramUI/Sources/AccountContext.swift b/submodules/TelegramUI/Sources/AccountContext.swift index fb828fb0a3..9db9edbbb1 100644 --- a/submodules/TelegramUI/Sources/AccountContext.swift +++ b/submodules/TelegramUI/Sources/AccountContext.swift @@ -328,11 +328,13 @@ public final class AccountContextImpl: AccountContext { }) self.currentCountriesConfiguration = Atomic(value: CountriesConfiguration(countries: loadCountryCodes())) - let currentCountriesConfiguration = self.currentCountriesConfiguration - self.countriesConfigurationDisposable = (self.engine.localization.getCountriesList(accountManager: sharedContext.accountManager, langCode: nil) - |> deliverOnMainQueue).start(next: { value in - let _ = currentCountriesConfiguration.swap(CountriesConfiguration(countries: value)) - }) + if !temp { + let currentCountriesConfiguration = self.currentCountriesConfiguration + self.countriesConfigurationDisposable = (self.engine.localization.getCountriesList(accountManager: sharedContext.accountManager, langCode: nil) + |> deliverOnMainQueue).start(next: { value in + let _ = currentCountriesConfiguration.swap(CountriesConfiguration(countries: value)) + }) + } let queue = Queue() self.deviceSpecificContactImportContexts = QueueLocalObject(queue: queue, generate: { diff --git a/submodules/TelegramUI/Sources/AppDelegate.swift b/submodules/TelegramUI/Sources/AppDelegate.swift index 78a5260652..27044decc3 100644 --- a/submodules/TelegramUI/Sources/AppDelegate.swift +++ b/submodules/TelegramUI/Sources/AppDelegate.swift @@ -2399,7 +2399,7 @@ private func extractAccountManagerState(records: AccountRecordsView deliverOnMainQueue).start(next: { displayNames in - self.registerForNotifications(replyString: presentationData.strings.Notification_Reply, messagePlaceholderString: presentationData.strings.Conversation_InputTextPlaceholder, hiddenContentString: presentationData.strings.Watch_MessageView_Title, includeNames: displayNames, authorize: authorize, completion: completion) + self.registerForNotifications(replyString: presentationData.strings.Notification_Reply, messagePlaceholderString: presentationData.strings.Conversation_InputTextPlaceholder, hiddenContentString: presentationData.strings.Watch_MessageView_Title, hiddenReactionContentString: presentationData.strings.Notification_LockScreenReactionPlaceholder, includeNames: displayNames, authorize: authorize, completion: completion) }) } - private func registerForNotifications(replyString: String, messagePlaceholderString: String, hiddenContentString: String, includeNames: Bool, authorize: Bool = true, completion: @escaping (Bool) -> Void = { _ in }) { + private func registerForNotifications(replyString: String, messagePlaceholderString: String, hiddenContentString: String, hiddenReactionContentString: String, includeNames: Bool, authorize: Bool = true, completion: @escaping (Bool) -> Void = { _ in }) { let notificationCenter = UNUserNotificationCenter.current() Logger.shared.log("App \(self.episodeId)", "register for notifications: get settings (authorize: \(authorize))") notificationCenter.getNotificationSettings(completionHandler: { settings in @@ -2525,6 +2525,7 @@ private func extractAccountManagerState(records: AccountRecordsView() private var didInitializeInputMediaNodeDataPromise: Bool = false private var inputMediaNodeDataDisposable: Disposable? + private var inputMediaNodeStateContext = ChatEntityKeyboardInputNode.StateContext() let navigateButtons: ChatHistoryNavigationButtons @@ -227,7 +228,7 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { private var isLoadingValue: Bool = false private var isLoadingEarlier: Bool = false private func updateIsLoading(isLoading: Bool, earlier: Bool, animated: Bool) { - let useLoadingPlaceholder = self.chatLocation.peerId?.namespace != Namespaces.Peer.CloudUser + let useLoadingPlaceholder = "".isEmpty let updated = isLoading != self.isLoadingValue || (isLoading && earlier && !self.isLoadingEarlier) @@ -2640,7 +2641,8 @@ class ChatControllerNode: ASDisplayNode, UIScrollViewDelegate { defaultToEmojiTab: !self.chatPresentationInterfaceState.interfaceState.effectiveInputState.inputText.string.isEmpty || self.chatPresentationInterfaceState.interfaceState.forwardMessageIds != nil || self.openStickersBeginWithEmoji, controllerInteraction: self.controllerInteraction, interfaceInteraction: self.interfaceInteraction, - chatPeerId: peerId + chatPeerId: peerId, + stateContext: self.inputMediaNodeStateContext ) self.openStickersBeginWithEmoji = false diff --git a/submodules/TelegramUI/Sources/ChatLoadingNode.swift b/submodules/TelegramUI/Sources/ChatLoadingNode.swift index bb015b4a8e..2bbea87b3a 100644 --- a/submodules/TelegramUI/Sources/ChatLoadingNode.swift +++ b/submodules/TelegramUI/Sources/ChatLoadingNode.swift @@ -360,7 +360,7 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { let messageContainer = self.messageContainers[k] let messageSize = messageContainer.frame.size - messageContainer.update(size: size, hasAvatar: self.chatType != .channel, rect: CGRect(origin: CGPoint(x: 0.0, y: offset - messageSize.height), size: messageSize), transition: transition) + messageContainer.update(size: size, hasAvatar: self.chatType != .channel && self.chatType != .user, rect: CGRect(origin: CGPoint(x: 0.0, y: offset - messageSize.height), size: messageSize), transition: transition) offset -= messageSize.height } } @@ -388,6 +388,7 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { enum ChatType: Equatable { case generic + case user case group case channel } @@ -395,7 +396,9 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { func updatePresentationInterfaceState(_ chatPresentationInterfaceState: ChatPresentationInterfaceState) { var chatType: ChatType = .channel if let peer = chatPresentationInterfaceState.renderedPeer?.peer { - if peer is TelegramGroup { + if peer is TelegramUser { + chatType = .user + } else if peer is TelegramGroup { chatType = .group } else if let channel = peer as? TelegramChannel { if case .group = channel.info { @@ -469,7 +472,7 @@ final class ChatLoadingPlaceholderNode: ASDisplayNode { for messageContainer in self.messageContainers { let messageSize = dimensions[index % 14] - messageContainer.update(size: bounds.size, hasAvatar: self.chatType != .channel, rect: CGRect(origin: CGPoint(x: 0.0, y: bounds.size.height - insets.bottom - offset - messageSize.height), size: messageSize), transition: transition) + messageContainer.update(size: bounds.size, hasAvatar: self.chatType != .channel && self.chatType != .user, rect: CGRect(origin: CGPoint(x: 0.0, y: bounds.size.height - insets.bottom - offset - messageSize.height), size: messageSize), transition: transition) offset += messageSize.height index += 1 } diff --git a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift index 8aed6eda5b..6a1ec04f8a 100644 --- a/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageBubbleItemNode.swift @@ -1431,8 +1431,13 @@ class ChatMessageBubbleItemNode: ChatMessageItemView, ChatMessagePreviewItemNode } else { if isCrosspostFromChannel, let sourceReference = sourceReference, let _ = firstMessage.peers[sourceReference.messageId.peerId] as? TelegramChannel { authorIsChannel = true + authorRank = attributes.rank + } else { + authorRank = attributes.rank + if authorRank == nil && message.author?.id == peer.id { + authorRank = .admin + } } - authorRank = attributes.rank } } else { if isCrosspostFromChannel, let _ = firstMessage.forwardInfo?.source as? TelegramChannel { diff --git a/submodules/TelegramUI/Sources/ChatMessageReplyInfoNode.swift b/submodules/TelegramUI/Sources/ChatMessageReplyInfoNode.swift index 2a6388245d..8341979f19 100644 --- a/submodules/TelegramUI/Sources/ChatMessageReplyInfoNode.swift +++ b/submodules/TelegramUI/Sources/ChatMessageReplyInfoNode.swift @@ -168,7 +168,7 @@ class ChatMessageReplyInfoNode: ASDisplayNode { let messageText: NSAttributedString if isText { - var text = arguments.message.text + var text = foldLineBreaks(arguments.message.text) var messageEntities = arguments.message.textEntitiesAttribute?.entities ?? [] if let translateToLanguage = arguments.associatedData.translateToLanguage, !text.isEmpty { diff --git a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift index 8c99cffcda..75015054fe 100644 --- a/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift +++ b/submodules/TelegramUI/Sources/PeerInfo/PeerInfoScreen.swift @@ -5171,27 +5171,28 @@ final class PeerInfoScreenNode: ViewControllerTracingNode, UIScrollViewDelegate subItems.append(.separator) - if case .group = channel.info { - subItems.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.PeerInfo_AutoDeleteInfo + "\n\n" + strongSelf.presentationData.strings.AutoremoveSetup_AdditionalGlobalSettingsInfo, textLayout: .multiline, textFont: .small, parseMarkdown: true, icon: { _ in - return nil - }, textLinkAction: { [weak c] in - c?.dismiss(completion: nil) - + let baseText: String + if case .broadcast = channel.info { + baseText = strongSelf.presentationData.strings.PeerInfo_ChannelAutoDeleteInfo + } else { + baseText = strongSelf.presentationData.strings.PeerInfo_AutoDeleteInfo + } + + subItems.append(.action(ContextMenuActionItem(text: baseText + "\n\n" + strongSelf.presentationData.strings.AutoremoveSetup_AdditionalGlobalSettingsInfo, textLayout: .multiline, textFont: .small, parseMarkdown: true, icon: { _ in + return nil + }, textLinkAction: { [weak c] in + c?.dismiss(completion: nil) + + guard let self else { + return + } + self.context.sharedContext.openResolvedUrl(.settings(.autoremoveMessages), context: self.context, urlContext: .generic, navigationController: self.controller?.navigationController as? NavigationController, forceExternal: false, openPeer: { _, _ in }, sendFile: nil, sendSticker: nil, requestMessageActionUrlAuth: nil, joinVoiceChat: nil, present: { _, _ in }, dismissInput: { [weak self] in guard let self else { return } - self.context.sharedContext.openResolvedUrl(.settings(.autoremoveMessages), context: self.context, urlContext: .generic, navigationController: self.controller?.navigationController as? NavigationController, forceExternal: false, openPeer: { _, _ in }, sendFile: nil, sendSticker: nil, requestMessageActionUrlAuth: nil, joinVoiceChat: nil, present: { _, _ in }, dismissInput: { [weak self] in - guard let self else { - return - } - self.controller?.view.endEditing(true) - }, contentContext: nil) - }, action: nil as ((ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void)?))) - } else { - subItems.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.PeerInfo_AutoDeleteInfo, textLayout: .multiline, textFont: .small, icon: { _ in - return nil - }, action: nil as ((ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void)?))) - } + self.controller?.view.endEditing(true) + }, contentContext: nil) + }, action: nil as ((ContextControllerProtocol, @escaping (ContextMenuActionResult) -> Void) -> Void)?))) c.pushItems(items: .single(ContextController.Items(content: .list(subItems)))) }))) diff --git a/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift b/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift index 4f144e12ad..9f19f80371 100644 --- a/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift +++ b/submodules/TelegramUI/Sources/PeerSelectionControllerNode.swift @@ -203,7 +203,7 @@ final class PeerSelectionControllerNode: ASDisplayNode { } if hasFilters { - self.mainContainerNode = ChatListContainerNode(context: context, location: chatListLocation, chatListMode: chatListMode, previewing: false, controlsHistoryPreload: false, isInlineMode: false, presentationData: presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, filterBecameEmpty: { _ in + self.mainContainerNode = ChatListContainerNode(context: context, controller: nil, location: chatListLocation, chatListMode: chatListMode, previewing: false, controlsHistoryPreload: false, isInlineMode: false, presentationData: presentationData, animationCache: self.animationCache, animationRenderer: self.animationRenderer, filterBecameEmpty: { _ in }, filterEmptyAction: { _ in }, secondaryEmptyAction: { }) From 4b415d87dd35dd18eb0fc3ae487708ffad3db61c Mon Sep 17 00:00:00 2001 From: Ali <> Date: Fri, 14 Apr 2023 21:32:41 +0400 Subject: [PATCH 50/50] Fix keys --- .../Sources/NotificationService.swift | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Telegram/NotificationService/Sources/NotificationService.swift b/Telegram/NotificationService/Sources/NotificationService.swift index ed0b9eec2c..552754ecfb 100644 --- a/Telegram/NotificationService/Sources/NotificationService.swift +++ b/Telegram/NotificationService/Sources/NotificationService.swift @@ -1070,14 +1070,17 @@ private final class NotificationServiceHandler { } if let category = aps["category"] as? String { - if let _ = aps["r"] { - content.category = "r" - } if peerId.isGroupOrChannel && ["r", "m"].contains(category) { content.category = "g\(category)" } else { content.category = category } + + if aps["r"] != nil || aps["react_emoji"] != nil { + content.category = "t" + } else if payloadJson["r"] != nil || payloadJson["react_emoji"] != nil { + content.category = "t" + } let _ = messageId @@ -1610,7 +1613,7 @@ private final class NotificationServiceHandler { UNUserNotificationCenter.current().getDeliveredNotifications(completionHandler: { notifications in var removeIdentifiers: [String] = [] for notification in notifications { - if notification.request.content.categoryIdentifier != "r" { + if notification.request.content.categoryIdentifier != "t" { continue } if let peerIdString = notification.request.content.userInfo["peerId"] as? String, let peerIdValue = Int64(peerIdString), let messageIdString = notification.request.content.userInfo["msg_id"] as? String, let messageIdValue = Int32(messageIdString) {