mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2025-06-15 13:35:19 +00:00
3903 lines
201 KiB
Swift
3903 lines
201 KiB
Swift
import Foundation
|
|
import UIKit
|
|
import AsyncDisplayKit
|
|
import SwiftSignalKit
|
|
import TelegramCore
|
|
import Display
|
|
import Postbox
|
|
import TelegramPresentationData
|
|
import UniversalMediaPlayer
|
|
import AccountContext
|
|
import RadialStatusNode
|
|
import TelegramUniversalVideoContent
|
|
import PresentationDataUtils
|
|
import OverlayStatusController
|
|
import StickerPackPreviewUI
|
|
import AppBundle
|
|
import AnimationUI
|
|
import ContextUI
|
|
import SaveToCameraRoll
|
|
import UndoUI
|
|
import TelegramUIPreferences
|
|
import OpenInExternalAppUI
|
|
import AVKit
|
|
import TextFormat
|
|
import SliderContextItem
|
|
import Pasteboard
|
|
import AdUI
|
|
import AdsInfoScreen
|
|
import AdsReportScreen
|
|
import SaveProgressScreen
|
|
import SectionTitleContextItem
|
|
import RasterizedCompositionComponent
|
|
import BadgeComponent
|
|
import ComponentFlow
|
|
import ComponentDisplayAdapters
|
|
|
|
public enum UniversalVideoGalleryItemContentInfo {
|
|
case message(Message, Int?)
|
|
case webPage(TelegramMediaWebpage, Media, ((@escaping () -> GalleryTransitionArguments?, NavigationController?, (ViewController, Any?) -> Void) -> Void)?)
|
|
}
|
|
|
|
public class UniversalVideoGalleryItem: GalleryItem {
|
|
public var id: AnyHashable {
|
|
return self.content.id
|
|
}
|
|
|
|
let context: AccountContext
|
|
let presentationData: PresentationData
|
|
let content: UniversalVideoContent
|
|
let originData: GalleryItemOriginData?
|
|
let indexData: GalleryItemIndexData?
|
|
let contentInfo: UniversalVideoGalleryItemContentInfo?
|
|
let caption: NSAttributedString
|
|
let description: NSAttributedString?
|
|
let credit: NSAttributedString?
|
|
let displayInfoOnTop: Bool
|
|
let hideControls: Bool
|
|
let fromPlayingVideo: Bool
|
|
let isSecret: Bool
|
|
let landscape: Bool
|
|
let timecode: Double?
|
|
let peerIsCopyProtected: Bool
|
|
let playbackRate: () -> Double?
|
|
let configuration: GalleryConfiguration?
|
|
let playbackCompleted: () -> Void
|
|
let performAction: (GalleryControllerInteractionTapAction) -> Void
|
|
let openActionOptions: (GalleryControllerInteractionTapAction, Message) -> Void
|
|
let storeMediaPlaybackState: (MessageId, Double?, Double) -> Void
|
|
let present: (ViewController, Any?) -> Void
|
|
|
|
public init(context: AccountContext, presentationData: PresentationData, content: UniversalVideoContent, originData: GalleryItemOriginData?, indexData: GalleryItemIndexData?, contentInfo: UniversalVideoGalleryItemContentInfo?, caption: NSAttributedString, description: NSAttributedString? = nil, credit: NSAttributedString? = nil, displayInfoOnTop: Bool = false, hideControls: Bool = false, fromPlayingVideo: Bool = false, isSecret: Bool = false, landscape: Bool = false, timecode: Double? = nil, peerIsCopyProtected: Bool = false, playbackRate: @escaping () -> Double?, configuration: GalleryConfiguration? = nil, playbackCompleted: @escaping () -> Void = {}, performAction: @escaping (GalleryControllerInteractionTapAction) -> Void, openActionOptions: @escaping (GalleryControllerInteractionTapAction, Message) -> Void, storeMediaPlaybackState: @escaping (MessageId, Double?, Double) -> Void, present: @escaping (ViewController, Any?) -> Void) {
|
|
self.context = context
|
|
self.presentationData = presentationData
|
|
self.content = content
|
|
self.originData = originData
|
|
self.indexData = indexData
|
|
self.contentInfo = contentInfo
|
|
self.caption = caption
|
|
self.description = description
|
|
self.credit = credit
|
|
self.displayInfoOnTop = displayInfoOnTop
|
|
self.hideControls = hideControls
|
|
self.fromPlayingVideo = fromPlayingVideo
|
|
self.isSecret = isSecret
|
|
self.landscape = landscape
|
|
self.timecode = timecode
|
|
self.peerIsCopyProtected = peerIsCopyProtected
|
|
self.playbackRate = playbackRate
|
|
self.configuration = configuration
|
|
self.playbackCompleted = playbackCompleted
|
|
self.performAction = performAction
|
|
self.openActionOptions = openActionOptions
|
|
self.storeMediaPlaybackState = storeMediaPlaybackState
|
|
self.present = present
|
|
}
|
|
|
|
public func node(synchronous: Bool) -> GalleryItemNode {
|
|
let node = UniversalVideoGalleryItemNode(context: self.context, presentationData: self.presentationData, performAction: self.performAction, openActionOptions: self.openActionOptions, present: self.present)
|
|
|
|
if let indexData = self.indexData {
|
|
node._title.set(.single(self.presentationData.strings.Items_NOfM("\(indexData.position + 1)", "\(indexData.totalCount)").string))
|
|
} else if case let .message(message, _) = self.contentInfo, let _ = message.adAttribute {
|
|
node._title.set(.single(self.presentationData.strings.Gallery_Ad))
|
|
}
|
|
|
|
node.setupItem(self)
|
|
|
|
if self.displayInfoOnTop, case let .message(message, _) = self.contentInfo {
|
|
node.titleContentView?.setMessage(message, presentationData: self.presentationData, accountPeerId: self.context.account.peerId)
|
|
}
|
|
|
|
return node
|
|
}
|
|
|
|
public func updateNode(node: GalleryItemNode, synchronous: Bool) {
|
|
if let node = node as? UniversalVideoGalleryItemNode {
|
|
if let indexData = self.indexData {
|
|
node._title.set(.single(self.presentationData.strings.Items_NOfM("\(indexData.position + 1)", "\(indexData.totalCount)").string))
|
|
}
|
|
|
|
node.setupItem(self)
|
|
|
|
if self.displayInfoOnTop, case let .message(message, _) = self.contentInfo {
|
|
node.titleContentView?.setMessage(message, presentationData: self.presentationData, accountPeerId: self.context.account.peerId)
|
|
}
|
|
}
|
|
}
|
|
|
|
public func thumbnailItem() -> (Int64, GalleryThumbnailItem)? {
|
|
guard let contentInfo = self.contentInfo else {
|
|
return nil
|
|
}
|
|
if case let .message(message, mediaIndex) = contentInfo {
|
|
if let paidContent = message.paidContent {
|
|
var mediaReference: AnyMediaReference?
|
|
let mediaIndex = mediaIndex ?? 0
|
|
if case let .full(fullMedia) = paidContent.extendedMedia[Int(mediaIndex)], let m = fullMedia as? TelegramMediaFile {
|
|
mediaReference = .message(message: MessageReference(message), media: m)
|
|
}
|
|
if let mediaReference = mediaReference {
|
|
if let item = ChatMediaGalleryThumbnailItem(account: self.context.account, userLocation: .peer(message.id.peerId), mediaReference: mediaReference) {
|
|
return (0, item)
|
|
}
|
|
}
|
|
} else if let id = message.groupInfo?.stableId {
|
|
var mediaReference: AnyMediaReference?
|
|
for m in message.media {
|
|
if let m = m as? TelegramMediaImage {
|
|
mediaReference = .message(message: MessageReference(message), media: m)
|
|
} else if let m = m as? TelegramMediaFile, m.isVideo {
|
|
mediaReference = .message(message: MessageReference(message), media: m)
|
|
}
|
|
}
|
|
if let mediaReference = mediaReference {
|
|
if let item = ChatMediaGalleryThumbnailItem(account: self.context.account, userLocation: .peer(message.id.peerId), mediaReference: mediaReference) {
|
|
return (Int64(id), item)
|
|
}
|
|
}
|
|
}
|
|
} else if case let .webPage(webPage, media, _) = contentInfo, let file = media as? TelegramMediaFile {
|
|
if let item = ChatMediaGalleryThumbnailItem(account: self.context.account, userLocation: .other, mediaReference: .webPage(webPage: WebpageReference(webPage), media: file)) {
|
|
return (0, item)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private let pictureInPictureImage = UIImage(bundleImageName: "Media Gallery/PictureInPictureIcon")?.precomposed()
|
|
private let pictureInPictureButtonImage = generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/PictureInPictureButton"), color: .white)
|
|
private let moreButtonImage = generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/More"), color: .white)
|
|
|
|
private let placeholderFont = Font.regular(16.0)
|
|
|
|
private final class UniversalVideoGalleryItemPictureInPictureNode: ASDisplayNode {
|
|
enum Mode {
|
|
case pictureInPicture
|
|
case airplay
|
|
}
|
|
|
|
private let iconNode: ASImageNode
|
|
private let textNode: ASTextNode
|
|
|
|
init(strings: PresentationStrings, mode: Mode) {
|
|
self.iconNode = ASImageNode()
|
|
self.iconNode.isLayerBacked = true
|
|
self.iconNode.displayWithoutProcessing = true
|
|
self.iconNode.displaysAsynchronously = false
|
|
self.iconNode.image = pictureInPictureImage
|
|
|
|
self.textNode = ASTextNode()
|
|
self.textNode.isUserInteractionEnabled = false
|
|
self.textNode.displaysAsynchronously = false
|
|
|
|
let text: String
|
|
switch mode {
|
|
case .pictureInPicture:
|
|
text = strings.Embed_PlayingInPIP
|
|
case .airplay:
|
|
text = strings.Gallery_AirPlayPlaceholder
|
|
}
|
|
self.textNode.attributedText = NSAttributedString(string: text, font: placeholderFont, textColor: UIColor(rgb: 0x8e8e93))
|
|
|
|
super.init()
|
|
|
|
self.backgroundColor = UIColor(rgb: 0x333335)
|
|
|
|
self.addSubnode(self.iconNode)
|
|
self.addSubnode(self.textNode)
|
|
}
|
|
|
|
func updateLayout(_ size: CGSize, transition: ContainedViewLayoutTransition) {
|
|
let iconSize = self.iconNode.image?.size ?? CGSize()
|
|
let textSize = self.textNode.measure(CGSize(width: max(0.0, size.width - 20.0), height: CGFloat.greatestFiniteMagnitude))
|
|
let spacing: CGFloat = 10.0
|
|
let contentHeight = iconSize.height + spacing + textSize.height
|
|
let contentVerticalOrigin = floor((size.height - contentHeight) / 2.0)
|
|
transition.updateFrame(node: self.iconNode, frame: CGRect(origin: CGPoint(x: floor((size.width - iconSize.width) / 2.0), y: contentVerticalOrigin), size: iconSize))
|
|
transition.updateFrame(node: self.textNode, frame: CGRect(origin: CGPoint(x: floor((size.width - textSize.width) / 2.0), y: contentVerticalOrigin + iconSize.height + spacing), size: textSize))
|
|
}
|
|
}
|
|
|
|
private let fullscreenImage = generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/Fullscreen"), color: .white)
|
|
private let minimizeImage = generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/Minimize"), color: .white)
|
|
|
|
private final class UniversalVideoGalleryItemOverlayNode: GalleryOverlayContentNode {
|
|
private let wrapperNode: ASDisplayNode
|
|
private let fullscreenNode: HighlightableButtonNode
|
|
private var validLayout: (CGSize, LayoutMetrics, UIEdgeInsets)?
|
|
|
|
var action: ((Bool) -> Void)?
|
|
|
|
override init() {
|
|
self.wrapperNode = ASDisplayNode()
|
|
self.wrapperNode.alpha = 0.0
|
|
|
|
self.fullscreenNode = HighlightableButtonNode()
|
|
self.fullscreenNode.setImage(fullscreenImage, for: .normal)
|
|
self.fullscreenNode.setImage(minimizeImage, for: .selected)
|
|
self.fullscreenNode.setImage(minimizeImage, for: [.selected, .highlighted])
|
|
|
|
super.init()
|
|
|
|
self.addSubnode(self.wrapperNode)
|
|
self.wrapperNode.addSubnode(self.fullscreenNode)
|
|
|
|
self.fullscreenNode.addTarget(self, action: #selector(self.toggleFullscreenPressed), forControlEvents: .touchUpInside)
|
|
}
|
|
|
|
override func updateLayout(size: CGSize, metrics: LayoutMetrics, insets: UIEdgeInsets, isHidden: Bool, transition: ContainedViewLayoutTransition) {
|
|
self.validLayout = (size, metrics, insets)
|
|
|
|
let isLandscape = size.width > size.height
|
|
self.fullscreenNode.isSelected = isLandscape
|
|
|
|
let iconSize: CGFloat = 42.0
|
|
let inset: CGFloat = 4.0
|
|
let buttonFrame = CGRect(origin: CGPoint(x: size.width - iconSize - inset - insets.right, y: size.height - iconSize - inset - insets.bottom), size: CGSize(width: iconSize, height: iconSize))
|
|
transition.updateFrame(node: self.wrapperNode, frame: buttonFrame)
|
|
transition.updateFrame(node: self.fullscreenNode, frame: CGRect(origin: CGPoint(), size: buttonFrame.size))
|
|
}
|
|
|
|
override func animateIn(previousContentNode: GalleryOverlayContentNode?, transition: ContainedViewLayoutTransition) {
|
|
if !self.visibilityAlpha.isZero {
|
|
transition.updateAlpha(node: self.wrapperNode, alpha: 1.0)
|
|
}
|
|
}
|
|
|
|
override func animateOut(nextContentNode: GalleryOverlayContentNode?, transition: ContainedViewLayoutTransition, completion: @escaping () -> Void) {
|
|
transition.updateAlpha(node: self.wrapperNode, alpha: 0.0)
|
|
}
|
|
|
|
override func setVisibilityAlpha(_ alpha: CGFloat) {
|
|
super.setVisibilityAlpha(alpha)
|
|
self.updateFullscreenButtonVisibility()
|
|
}
|
|
|
|
func updateFullscreenButtonVisibility() {
|
|
self.wrapperNode.alpha = self.visibilityAlpha
|
|
|
|
if let validLayout = self.validLayout {
|
|
self.updateLayout(size: validLayout.0, metrics: validLayout.1, insets: validLayout.2, isHidden: false, transition: .animated(duration: 0.3, curve: .easeInOut))
|
|
}
|
|
}
|
|
|
|
@objc func toggleFullscreenPressed() {
|
|
var toLandscape = false
|
|
if let (size, _, _) = self.validLayout, size.width < size.height {
|
|
toLandscape = true
|
|
}
|
|
if toLandscape {
|
|
self.wrapperNode.alpha = 0.0
|
|
}
|
|
self.action?(toLandscape)
|
|
}
|
|
|
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
if !self.wrapperNode.frame.contains(point) {
|
|
return nil
|
|
}
|
|
return super.hitTest(point, with: event)
|
|
}
|
|
}
|
|
|
|
private struct FetchControls {
|
|
let fetch: () -> Void
|
|
let cancel: () -> Void
|
|
}
|
|
|
|
func optionsBackgroundImage(dark: Bool) -> UIImage? {
|
|
return generateImage(CGSize(width: 28.0, height: 28.0), contextGenerator: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
context.setFillColor(UIColor(rgb: dark ? 0x1c1c1e : 0x2c2c2e).cgColor)
|
|
context.fillEllipse(in: CGRect(origin: CGPoint(), size: size))
|
|
})?.stretchableImage(withLeftCapWidth: 14, topCapHeight: 14)
|
|
}
|
|
|
|
func optionsCircleImage(dark: Bool) -> UIImage? {
|
|
return generateImage(CGSize(width: 22.0, height: 22.0), contextGenerator: { size, context in
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
context.setStrokeColor(UIColor.white.cgColor)
|
|
let lineWidth: CGFloat = 1.3
|
|
context.setLineWidth(lineWidth)
|
|
|
|
context.strokeEllipse(in: CGRect(origin: CGPoint(), size: size).insetBy(dx: lineWidth, dy: lineWidth))
|
|
})
|
|
}
|
|
|
|
private func optionsRateImage(rate: String, isLarge: Bool, color: UIColor = .white) -> UIImage? {
|
|
return generateImage(isLarge ? CGSize(width: 30.0, height: 30.0) : CGSize(width: 24.0, height: 24.0), rotatedContext: { size, context in
|
|
UIGraphicsPushContext(context)
|
|
|
|
context.clear(CGRect(origin: CGPoint(), size: size))
|
|
|
|
if let image = generateTintedImage(image: UIImage(bundleImageName: isLarge ? "Chat/Context Menu/Playspeed30" : "Chat/Context Menu/Playspeed24"), color: .white) {
|
|
image.draw(at: CGPoint(x: 0.0, y: 0.0))
|
|
}
|
|
|
|
let string = NSMutableAttributedString(string: rate, font: Font.with(size: isLarge ? 11.0 : 10.0, design: .round, weight: .semibold), textColor: color)
|
|
|
|
var offset = CGPoint(x: 1.0, y: 0.0)
|
|
if rate.count >= 3 {
|
|
if rate == "0.5x" {
|
|
string.addAttribute(.kern, value: -0.8 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string))
|
|
offset.x += -0.5
|
|
} else {
|
|
string.addAttribute(.kern, value: -0.5 as NSNumber, range: NSRange(string.string.startIndex ..< string.string.endIndex, in: string.string))
|
|
offset.x += -0.3
|
|
}
|
|
} else {
|
|
offset.x += -0.3
|
|
}
|
|
|
|
if !isLarge {
|
|
offset.x *= 0.5
|
|
offset.y *= 0.5
|
|
}
|
|
|
|
let boundingRect = string.boundingRect(with: size, options: [], context: nil)
|
|
string.draw(at: CGPoint(x: offset.x + floor((size.width - boundingRect.width) / 2.0), y: offset.y + floor((size.height - boundingRect.height) / 2.0)))
|
|
|
|
UIGraphicsPopContext()
|
|
})
|
|
}
|
|
|
|
final class MoreHeaderButton: HighlightableButtonNode {
|
|
enum Content {
|
|
case image(UIImage?)
|
|
case more(UIImage?)
|
|
}
|
|
|
|
let referenceNode: ContextReferenceContentNode
|
|
let containerNode: ContextControllerSourceNode
|
|
private let iconNode: ASImageNode
|
|
private var animationNode: AnimationNode?
|
|
|
|
var contextAction: ((ASDisplayNode, ContextGesture?) -> Void)?
|
|
|
|
private let wide: Bool
|
|
|
|
init(wide: Bool = false) {
|
|
self.wide = wide
|
|
|
|
self.referenceNode = ContextReferenceContentNode()
|
|
self.containerNode = ContextControllerSourceNode()
|
|
self.containerNode.animateScale = false
|
|
self.iconNode = ASImageNode()
|
|
self.iconNode.displaysAsynchronously = false
|
|
self.iconNode.displayWithoutProcessing = true
|
|
self.iconNode.contentMode = .scaleToFill
|
|
|
|
super.init()
|
|
|
|
self.containerNode.addSubnode(self.referenceNode)
|
|
self.referenceNode.addSubnode(self.iconNode)
|
|
self.addSubnode(self.containerNode)
|
|
|
|
self.containerNode.shouldBegin = { [weak self] location in
|
|
guard let strongSelf = self, let _ = strongSelf.contextAction else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
self.containerNode.activated = { [weak self] gesture, _ in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.contextAction?(strongSelf.containerNode, gesture)
|
|
}
|
|
|
|
self.containerNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 26.0, height: 44.0))
|
|
self.referenceNode.frame = self.containerNode.bounds
|
|
|
|
self.iconNode.image = optionsCircleImage(dark: false)
|
|
if let image = self.iconNode.image {
|
|
self.iconNode.frame = CGRect(origin: CGPoint(x: floor((self.containerNode.bounds.width - image.size.width) / 2.0), y: floor((self.containerNode.bounds.height - image.size.height) / 2.0)), size: image.size)
|
|
}
|
|
|
|
self.hitTestSlop = UIEdgeInsets(top: 0.0, left: -4.0, bottom: 0.0, right: -4.0)
|
|
}
|
|
|
|
private var content: Content?
|
|
func setContent(_ content: Content, animated: Bool = false) {
|
|
if case .more = content, self.animationNode == nil {
|
|
let iconColor = UIColor(rgb: 0xffffff)
|
|
let animationNode = AnimationNode(animation: "anim_profilemore", colors: ["Point 2.Group 1.Fill 1": iconColor,
|
|
"Point 3.Group 1.Fill 1": iconColor,
|
|
"Point 1.Group 1.Fill 1": iconColor], scale: 1.0)
|
|
let animationSize = CGSize(width: 22.0, height: 22.0)
|
|
animationNode.frame = CGRect(origin: CGPoint(x: floor((self.containerNode.bounds.width - animationSize.width) / 2.0), y: floor((self.containerNode.bounds.height - animationSize.height) / 2.0)), size: animationSize)
|
|
self.addSubnode(animationNode)
|
|
self.animationNode = animationNode
|
|
}
|
|
if animated {
|
|
if let snapshotView = self.referenceNode.view.snapshotContentTree() {
|
|
snapshotView.frame = self.referenceNode.frame
|
|
self.view.addSubview(snapshotView)
|
|
|
|
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak snapshotView] _ in
|
|
snapshotView?.removeFromSuperview()
|
|
})
|
|
snapshotView.layer.animateScale(from: 1.0, to: 0.1, duration: 0.3, removeOnCompletion: false)
|
|
|
|
self.iconNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
|
|
self.iconNode.layer.animateScale(from: 0.1, to: 1.0, duration: 0.3)
|
|
|
|
self.animationNode?.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.3)
|
|
self.animationNode?.layer.animateScale(from: 0.1, to: 1.0, duration: 0.3)
|
|
}
|
|
|
|
switch content {
|
|
case let .image(image):
|
|
if let image = image {
|
|
self.iconNode.frame = CGRect(origin: CGPoint(x: floor((self.containerNode.bounds.width - image.size.width) / 2.0), y: floor((self.containerNode.bounds.height - image.size.height) / 2.0)), size: image.size)
|
|
}
|
|
|
|
self.iconNode.image = image
|
|
self.iconNode.isHidden = false
|
|
self.animationNode?.isHidden = true
|
|
case let .more(image):
|
|
if let image = image {
|
|
self.iconNode.frame = CGRect(origin: CGPoint(x: floor((self.containerNode.bounds.width - image.size.width) / 2.0), y: floor((self.containerNode.bounds.height - image.size.height) / 2.0)), size: image.size)
|
|
}
|
|
|
|
self.iconNode.image = image
|
|
self.iconNode.isHidden = false
|
|
self.animationNode?.isHidden = false
|
|
}
|
|
} else {
|
|
self.content = content
|
|
switch content {
|
|
case let .image(image):
|
|
if let image = image {
|
|
self.iconNode.frame = CGRect(origin: CGPoint(x: floor((self.containerNode.bounds.width - image.size.width) / 2.0), y: floor((self.containerNode.bounds.height - image.size.height) / 2.0)), size: image.size)
|
|
}
|
|
|
|
self.iconNode.image = image
|
|
self.iconNode.isHidden = false
|
|
self.animationNode?.isHidden = true
|
|
case let .more(image):
|
|
if let image = image {
|
|
self.iconNode.frame = CGRect(origin: CGPoint(x: floor((self.containerNode.bounds.width - image.size.width) / 2.0), y: floor((self.containerNode.bounds.height - image.size.height) / 2.0)), size: image.size)
|
|
}
|
|
|
|
self.iconNode.image = image
|
|
self.iconNode.isHidden = false
|
|
self.animationNode?.isHidden = false
|
|
}
|
|
}
|
|
}
|
|
|
|
override func didLoad() {
|
|
super.didLoad()
|
|
self.view.isOpaque = false
|
|
}
|
|
|
|
override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize {
|
|
return CGSize(width: wide ? 32.0 : 22.0, height: 44.0)
|
|
}
|
|
|
|
func onLayout() {
|
|
}
|
|
|
|
func play() {
|
|
self.animationNode?.playOnce()
|
|
}
|
|
}
|
|
|
|
final class SettingsHeaderButton: HighlightableButtonNode {
|
|
let referenceNode: ContextReferenceContentNode
|
|
let containerNode: ContextControllerSourceNode
|
|
|
|
private let iconLayer: RasterizedCompositionMonochromeLayer
|
|
|
|
private let gearsLayer: RasterizedCompositionImageLayer
|
|
private let dotLayer: RasterizedCompositionImageLayer
|
|
|
|
private var speedBadge: ComponentView<Empty>?
|
|
private var qualityBadge: ComponentView<Empty>?
|
|
|
|
private var speedBadgeText: String?
|
|
private var qualityBadgeText: String?
|
|
|
|
private let badgeFont: UIFont
|
|
|
|
private var isMenuOpen: Bool = false
|
|
|
|
var contextAction: ((ASDisplayNode, ContextGesture?) -> Void)?
|
|
|
|
private let wide: Bool
|
|
|
|
init(wide: Bool = false) {
|
|
self.wide = wide
|
|
|
|
self.referenceNode = ContextReferenceContentNode()
|
|
self.containerNode = ContextControllerSourceNode()
|
|
self.containerNode.animateScale = false
|
|
|
|
self.iconLayer = RasterizedCompositionMonochromeLayer()
|
|
//self.iconLayer.backgroundColor = UIColor.green.cgColor
|
|
|
|
self.gearsLayer = RasterizedCompositionImageLayer()
|
|
self.gearsLayer.image = generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/NavigationSettingsNoDot"), color: .white)
|
|
|
|
self.dotLayer = RasterizedCompositionImageLayer()
|
|
self.dotLayer.image = generateFilledCircleImage(diameter: 4.0, color: .white)
|
|
|
|
self.iconLayer.contentsLayer.addSublayer(self.gearsLayer)
|
|
self.iconLayer.contentsLayer.addSublayer(self.dotLayer)
|
|
|
|
self.badgeFont = Font.with(size: 8.0, design: .round, weight: .bold)
|
|
|
|
super.init()
|
|
|
|
self.containerNode.addSubnode(self.referenceNode)
|
|
self.referenceNode.layer.addSublayer(self.iconLayer)
|
|
self.addSubnode(self.containerNode)
|
|
|
|
self.containerNode.shouldBegin = { [weak self] location in
|
|
guard let strongSelf = self, let _ = strongSelf.contextAction else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
self.containerNode.activated = { [weak self] gesture, _ in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.contextAction?(strongSelf.containerNode, gesture)
|
|
}
|
|
|
|
self.containerNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 26.0, height: 44.0))
|
|
self.referenceNode.frame = self.containerNode.bounds
|
|
|
|
self.hitTestSlop = UIEdgeInsets(top: 0.0, left: -4.0, bottom: 0.0, right: -4.0)
|
|
|
|
if let image = self.gearsLayer.image {
|
|
let iconInnerInsets = UIEdgeInsets(top: 4.0, left: 8.0, bottom: 4.0, right: 6.0)
|
|
let iconSize = CGSize(width: image.size.width + iconInnerInsets.left + iconInnerInsets.right, height: image.size.height + iconInnerInsets.top + iconInnerInsets.bottom)
|
|
let iconFrame = CGRect(origin: CGPoint(x: floor((self.containerNode.bounds.width - iconSize.width) / 2.0), y: floor((self.containerNode.bounds.height - iconSize.height) / 2.0)), size: iconSize)
|
|
self.iconLayer.position = iconFrame.center
|
|
self.iconLayer.bounds = CGRect(origin: CGPoint(), size: iconFrame.size)
|
|
|
|
self.iconLayer.contentsLayer.position = CGRect(origin: CGPoint(), size: iconFrame.size).center
|
|
self.iconLayer.contentsLayer.bounds = CGRect(origin: CGPoint(), size: iconFrame.size)
|
|
|
|
self.iconLayer.maskedLayer.position = CGRect(origin: CGPoint(), size: iconFrame.size).center
|
|
self.iconLayer.maskedLayer.bounds = CGRect(origin: CGPoint(), size: iconFrame.size)
|
|
self.iconLayer.maskedLayer.backgroundColor = UIColor.white.cgColor
|
|
|
|
let gearsFrame = CGRect(origin: CGPoint(x: floor((iconSize.width - image.size.width) * 0.5), y: floor((iconSize.height - image.size.height) * 0.5)), size: image.size)
|
|
self.gearsLayer.position = gearsFrame.center
|
|
self.gearsLayer.bounds = CGRect(origin: CGPoint(), size: gearsFrame.size)
|
|
|
|
if let dotImage = self.dotLayer.image {
|
|
let dotFrame = CGRect(origin: CGPoint(x: gearsFrame.minX + floorToScreenPixels((gearsFrame.width - dotImage.size.width) * 0.5), y: gearsFrame.minY + floorToScreenPixels((gearsFrame.height - dotImage.size.height) * 0.5)), size: dotImage.size)
|
|
self.dotLayer.position = dotFrame.center
|
|
self.dotLayer.bounds = CGRect(origin: CGPoint(), size: dotFrame.size)
|
|
}
|
|
}
|
|
}
|
|
|
|
override func didLoad() {
|
|
super.didLoad()
|
|
self.view.isOpaque = false
|
|
}
|
|
|
|
override func calculateSizeThatFits(_ constrainedSize: CGSize) -> CGSize {
|
|
return CGSize(width: wide ? 32.0 : 22.0, height: 44.0)
|
|
}
|
|
|
|
func onLayout() {
|
|
}
|
|
|
|
func setIsMenuOpen(isMenuOpen: Bool) {
|
|
if self.isMenuOpen == isMenuOpen {
|
|
return
|
|
}
|
|
self.isMenuOpen = isMenuOpen
|
|
|
|
let rotationTransition: ContainedViewLayoutTransition = .animated(duration: 0.35, curve: .spring)
|
|
rotationTransition.updateTransform(layer: self.gearsLayer, transform: CGAffineTransformMakeRotation(isMenuOpen ? (CGFloat.pi * 2.0 / 6.0) : 0.0))
|
|
self.gearsLayer.animateScale(from: 1.0, to: 1.07, duration: 0.1, removeOnCompletion: false, completion: { [weak self] finished in
|
|
guard let self, finished else {
|
|
return
|
|
}
|
|
self.gearsLayer.animateScale(from: 1.07, to: 1.0, duration: 0.1, removeOnCompletion: true)
|
|
})
|
|
|
|
self.dotLayer.animateScale(from: 1.0, to: 0.8, duration: 0.1, removeOnCompletion: false, completion: { [weak self] finished in
|
|
guard let self, finished else {
|
|
return
|
|
}
|
|
self.dotLayer.animateScale(from: 0.8, to: 1.0, duration: 0.1, removeOnCompletion: true)
|
|
})
|
|
}
|
|
|
|
func setBadges(speed: String?, quality: String?, transition: ComponentTransition) {
|
|
if self.speedBadgeText == speed && self.qualityBadgeText == quality {
|
|
return
|
|
}
|
|
self.speedBadgeText = speed
|
|
self.qualityBadgeText = quality
|
|
|
|
if let badgeText = speed {
|
|
var badgeTransition = transition
|
|
let speedBadge: ComponentView<Empty>
|
|
if let current = self.speedBadge {
|
|
speedBadge = current
|
|
} else {
|
|
speedBadge = ComponentView()
|
|
self.speedBadge = speedBadge
|
|
badgeTransition = badgeTransition.withAnimation(.none)
|
|
}
|
|
let badgeSize = speedBadge.update(
|
|
transition: badgeTransition,
|
|
component: AnyComponent(BadgeComponent(
|
|
text: badgeText,
|
|
font: self.badgeFont,
|
|
cornerRadius: 3.0,
|
|
insets: UIEdgeInsets(top: 1.33, left: 1.66, bottom: 1.33, right: 1.66),
|
|
outerInsets: UIEdgeInsets(top: 1.0, left: 1.0, bottom: 1.0, right: 1.0)
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: 100.0, height: 100.0)
|
|
)
|
|
if let speedBadgeView = speedBadge.view {
|
|
if speedBadgeView.layer.superlayer == nil {
|
|
self.iconLayer.contentsLayer.addSublayer(speedBadgeView.layer)
|
|
|
|
transition.animateAlpha(layer: speedBadgeView.layer, from: 0.0, to: 1.0)
|
|
transition.animateScale(layer: speedBadgeView.layer, from: 0.001, to: 1.0)
|
|
}
|
|
badgeTransition.setFrame(layer: speedBadgeView.layer, frame: CGRect(origin: CGPoint(x: 0.0, y: 0.0), size: badgeSize))
|
|
}
|
|
} else if let speedBadge = self.speedBadge {
|
|
self.speedBadge = nil
|
|
if let speedBadgeView = speedBadge.view {
|
|
let speedBadgeLayer = speedBadgeView.layer
|
|
transition.setAlpha(layer: speedBadgeLayer, alpha: 0.0, completion: { [weak speedBadgeLayer] _ in
|
|
speedBadgeLayer?.removeFromSuperlayer()
|
|
})
|
|
transition.setScale(layer: speedBadgeLayer, scale: 0.001)
|
|
}
|
|
}
|
|
|
|
if let badgeText = quality {
|
|
var badgeTransition = transition
|
|
let qualityBadge: ComponentView<Empty>
|
|
if let current = self.qualityBadge {
|
|
qualityBadge = current
|
|
} else {
|
|
qualityBadge = ComponentView()
|
|
self.qualityBadge = qualityBadge
|
|
badgeTransition = badgeTransition.withAnimation(.none)
|
|
}
|
|
let badgeSize = qualityBadge.update(
|
|
transition: badgeTransition,
|
|
component: AnyComponent(BadgeComponent(
|
|
text: badgeText,
|
|
font: self.badgeFont,
|
|
cornerRadius: 3.0,
|
|
insets: UIEdgeInsets(top: 1.33, left: 1.66, bottom: 1.33, right: 1.66),
|
|
outerInsets: UIEdgeInsets(top: 1.0, left: 1.0, bottom: 1.0, right: 1.0)
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: 100.0, height: 100.0)
|
|
)
|
|
if let qualityBadgeView = qualityBadge.view {
|
|
if qualityBadgeView.layer.superlayer == nil {
|
|
self.iconLayer.contentsLayer.addSublayer(qualityBadgeView.layer)
|
|
|
|
transition.animateAlpha(layer: qualityBadgeView.layer, from: 0.0, to: 1.0)
|
|
transition.animateScale(layer: qualityBadgeView.layer, from: 0.001, to: 1.0)
|
|
}
|
|
badgeTransition.setFrame(layer: qualityBadgeView.layer, frame: CGRect(origin: CGPoint(x: self.iconLayer.bounds.width - badgeSize.width, y: self.iconLayer.bounds.height - badgeSize.height), size: badgeSize))
|
|
}
|
|
} else if let qualityBadge = self.qualityBadge {
|
|
self.qualityBadge = nil
|
|
if let qualityBadgeView = qualityBadge.view {
|
|
let qualityBadgeLayer = qualityBadgeView.layer
|
|
transition.setAlpha(layer: qualityBadgeLayer, alpha: 0.0, completion: { [weak qualityBadgeLayer] _ in
|
|
qualityBadgeLayer?.removeFromSuperlayer()
|
|
})
|
|
transition.setScale(layer: qualityBadgeLayer, scale: 0.001)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 15.0, *)
|
|
private final class NativePictureInPictureContentImpl: NSObject, AVPictureInPictureControllerDelegate {
|
|
private final class PlaybackDelegate: NSObject, AVPictureInPictureSampleBufferPlaybackDelegate {
|
|
private let node: UniversalVideoNode
|
|
private var statusDisposable: Disposable?
|
|
private var status: MediaPlayerStatus?
|
|
weak var pictureInPictureController: AVPictureInPictureController?
|
|
|
|
private var previousIsPlaying = false
|
|
init(node: UniversalVideoNode) {
|
|
self.node = node
|
|
|
|
super.init()
|
|
|
|
var invalidatedStateOnce = false
|
|
self.statusDisposable = (self.node.status
|
|
|> deliverOnMainQueue).start(next: { [weak self] status in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
strongSelf.status = status
|
|
if let status {
|
|
let isPlaying = status.status == .playing
|
|
if !invalidatedStateOnce {
|
|
invalidatedStateOnce = true
|
|
strongSelf.pictureInPictureController?.invalidatePlaybackState()
|
|
} else if strongSelf.previousIsPlaying != isPlaying {
|
|
strongSelf.previousIsPlaying = isPlaying
|
|
strongSelf.pictureInPictureController?.invalidatePlaybackState()
|
|
}
|
|
}
|
|
}).strict()
|
|
}
|
|
|
|
deinit {
|
|
self.statusDisposable?.dispose()
|
|
}
|
|
|
|
public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) {
|
|
self.node.togglePlayPause()
|
|
}
|
|
|
|
public func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange {
|
|
guard let status = self.status else {
|
|
return CMTimeRange(start: CMTime(seconds: 0.0, preferredTimescale: CMTimeScale(30.0)), duration: CMTime(seconds: 0.0, preferredTimescale: CMTimeScale(30.0)))
|
|
}
|
|
return CMTimeRange(start: CMTime(seconds: 0.0, preferredTimescale: CMTimeScale(30.0)), duration: CMTime(seconds: status.duration, preferredTimescale: CMTimeScale(30.0)))
|
|
}
|
|
|
|
public func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
|
|
guard let status = self.status else {
|
|
return false
|
|
}
|
|
switch status.status {
|
|
case .playing:
|
|
return false
|
|
case .buffering, .paused:
|
|
return true
|
|
}
|
|
}
|
|
|
|
public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) {
|
|
}
|
|
|
|
public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) {
|
|
let node = self.node
|
|
let _ = (self.node.status
|
|
|> take(1)
|
|
|> deliverOnMainQueue).start(next: { [weak node] status in
|
|
if let node = node, let timestamp = status?.timestamp, let duration = status?.duration {
|
|
let nextTimestamp = timestamp + skipInterval.seconds
|
|
if nextTimestamp > duration {
|
|
node.seek(0.0)
|
|
node.pause()
|
|
} else {
|
|
node.seek(min(duration, nextTimestamp))
|
|
}
|
|
}
|
|
|
|
completionHandler()
|
|
})
|
|
}
|
|
|
|
public func pictureInPictureControllerShouldProhibitBackgroundAudioPlayback(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
|
|
return false
|
|
}
|
|
}
|
|
|
|
private let context: AccountContext
|
|
private let accountId: AccountRecordId
|
|
private let hiddenMedia: (MessageId, Media)?
|
|
private weak var mediaManager: MediaManager?
|
|
private var pictureInPictureController: AVPictureInPictureController?
|
|
private var contentDelegate: PlaybackDelegate?
|
|
private let node: UniversalVideoNode
|
|
private let willBegin: (NativePictureInPictureContentImpl) -> Void
|
|
private let didBegin: (NativePictureInPictureContentImpl) -> Void
|
|
private let didEnd: (NativePictureInPictureContentImpl) -> Void
|
|
private let expand: (@escaping () -> Void) -> Void
|
|
private var pictureInPictureTimer: SwiftSignalKit.Timer?
|
|
private var didExpand: Bool = false
|
|
|
|
private var hiddenMediaManagerIndex: Int?
|
|
|
|
private var messageRemovedDisposable: Disposable?
|
|
|
|
private var isNativePictureInPictureActiveDisposable: Disposable?
|
|
|
|
init(context: AccountContext, mediaManager: MediaManager, accountId: AccountRecordId, hiddenMedia: (MessageId, Media)?, videoNode: UniversalVideoNode, canSkip: Bool, willBegin: @escaping (NativePictureInPictureContentImpl) -> Void, didBegin: @escaping (NativePictureInPictureContentImpl) -> Void, didEnd: @escaping (NativePictureInPictureContentImpl) -> Void, expand: @escaping (@escaping () -> Void) -> Void) {
|
|
self.context = context
|
|
self.mediaManager = mediaManager
|
|
self.accountId = accountId
|
|
self.hiddenMedia = hiddenMedia
|
|
self.node = videoNode
|
|
self.willBegin = willBegin
|
|
self.didBegin = didBegin
|
|
self.didEnd = didEnd
|
|
self.expand = expand
|
|
|
|
super.init()
|
|
|
|
if let videoLayer = videoNode.getVideoLayer() {
|
|
let contentDelegate = PlaybackDelegate(node: self.node)
|
|
self.contentDelegate = contentDelegate
|
|
|
|
let pictureInPictureController = AVPictureInPictureController(contentSource: AVPictureInPictureController.ContentSource(sampleBufferDisplayLayer: videoLayer, playbackDelegate: contentDelegate))
|
|
self.pictureInPictureController = pictureInPictureController
|
|
contentDelegate.pictureInPictureController = pictureInPictureController
|
|
|
|
pictureInPictureController.canStartPictureInPictureAutomaticallyFromInline = false
|
|
pictureInPictureController.requiresLinearPlayback = !canSkip
|
|
pictureInPictureController.delegate = self
|
|
self.pictureInPictureController = pictureInPictureController
|
|
}
|
|
|
|
if let (messageId, _) = hiddenMedia {
|
|
var hadMessage: Bool?
|
|
self.messageRemovedDisposable = (context.engine.data.subscribe(TelegramEngine.EngineData.Item.Messages.Message(id: messageId))
|
|
|> map { message -> Bool in
|
|
if let _ = message {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|> deliverOnMainQueue).start(next: { [weak self] value in
|
|
guard let self else {
|
|
return
|
|
}
|
|
if let hadMessage, hadMessage {
|
|
if value {
|
|
} else {
|
|
if let pictureInPictureController = self.pictureInPictureController {
|
|
pictureInPictureController.stopPictureInPicture()
|
|
}
|
|
}
|
|
|
|
return
|
|
}
|
|
hadMessage = value
|
|
})
|
|
}
|
|
}
|
|
|
|
deinit {
|
|
self.messageRemovedDisposable?.dispose()
|
|
self.isNativePictureInPictureActiveDisposable?.dispose()
|
|
self.pictureInPictureTimer?.invalidate()
|
|
self.node.setCanPlaybackWithoutHierarchy(false)
|
|
|
|
if let hiddenMediaManagerIndex = self.hiddenMediaManagerIndex, let mediaManager = self.mediaManager {
|
|
mediaManager.galleryHiddenMediaManager.removeSource(hiddenMediaManagerIndex)
|
|
}
|
|
}
|
|
|
|
func updateIsCentral(isCentral: Bool) {
|
|
guard let pictureInPictureController = self.pictureInPictureController else {
|
|
return
|
|
}
|
|
|
|
if isCentral {
|
|
pictureInPictureController.canStartPictureInPictureAutomaticallyFromInline = true
|
|
} else {
|
|
pictureInPictureController.canStartPictureInPictureAutomaticallyFromInline = false
|
|
}
|
|
}
|
|
|
|
func beginPictureInPicture() {
|
|
guard let pictureInPictureController = self.pictureInPictureController else {
|
|
return
|
|
}
|
|
if pictureInPictureController.isPictureInPicturePossible {
|
|
pictureInPictureController.startPictureInPicture()
|
|
}
|
|
}
|
|
|
|
func invalidatePlaybackState() {
|
|
guard let pictureInPictureController = self.pictureInPictureController else {
|
|
return
|
|
}
|
|
if pictureInPictureController.isPictureInPictureActive {
|
|
pictureInPictureController.invalidatePlaybackState()
|
|
}
|
|
}
|
|
|
|
public func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
self.node.setCanPlaybackWithoutHierarchy(true)
|
|
|
|
if let hiddenMedia = self.hiddenMedia, let mediaManager = self.mediaManager, !"".isEmpty {
|
|
let accountId = self.accountId
|
|
self.hiddenMediaManagerIndex = mediaManager.galleryHiddenMediaManager.addSource(Signal<(MessageId, Media)?, NoError>.single(hiddenMedia)
|
|
|> map { messageIdAndMedia in
|
|
if let (messageId, media) = messageIdAndMedia {
|
|
return .chat(accountId, messageId, media)
|
|
} else {
|
|
return nil
|
|
}
|
|
})
|
|
}
|
|
|
|
self.willBegin(self)
|
|
}
|
|
|
|
public func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
self.didBegin(self)
|
|
}
|
|
|
|
public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
|
|
print(error)
|
|
}
|
|
|
|
public func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
}
|
|
|
|
public func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
self.node.setCanPlaybackWithoutHierarchy(false)
|
|
if let hiddenMediaManagerIndex = self.hiddenMediaManagerIndex, let mediaManager = self.mediaManager {
|
|
mediaManager.galleryHiddenMediaManager.removeSource(hiddenMediaManagerIndex)
|
|
self.hiddenMediaManagerIndex = nil
|
|
}
|
|
self.didEnd(self)
|
|
}
|
|
|
|
public func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
|
|
self.expand { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
|
|
strongSelf.didExpand = true
|
|
|
|
completionHandler(true)
|
|
}
|
|
}
|
|
|
|
public func requestExpand() {
|
|
self.pictureInPictureController?.stopPictureInPicture()
|
|
}
|
|
|
|
public func stop() {
|
|
self.pictureInPictureController?.stopPictureInPicture()
|
|
}
|
|
}
|
|
|
|
final class UniversalVideoGalleryItemNode: ZoomableContentGalleryItemNode {
|
|
private let context: AccountContext
|
|
private let presentationData: PresentationData
|
|
|
|
fileprivate let _ready = Promise<Void>()
|
|
fileprivate let _title = Promise<String>()
|
|
fileprivate let _titleView = Promise<UIView?>()
|
|
fileprivate let _rightBarButtonItems = Promise<[UIBarButtonItem]?>()
|
|
|
|
fileprivate var titleContentView: GalleryTitleView?
|
|
private var scrubberView: ChatVideoGalleryItemScrubberView?
|
|
private let footerContentNode: ChatItemGalleryFooterContentNode
|
|
private let overlayContentNode: UniversalVideoGalleryItemOverlayNode
|
|
|
|
private let moreBarButton: MoreHeaderButton
|
|
private var moreBarButtonRate: Double = 1.0
|
|
private var moreBarButtonRateTimestamp: Double?
|
|
|
|
private let settingsBarButton: SettingsHeaderButton
|
|
|
|
private var videoNode: UniversalVideoNode?
|
|
private var videoNodeUserInteractionEnabled: Bool = false
|
|
private var videoFramePreview: FramePreview?
|
|
private var pictureInPictureNode: UniversalVideoGalleryItemPictureInPictureNode?
|
|
private var disablePictureInPicturePlaceholder: Bool = false
|
|
private let statusButtonNode: HighlightableButtonNode
|
|
private let statusNode: RadialStatusNode
|
|
private var statusNodeShouldBeHidden = true
|
|
|
|
private var isCentral: Bool?
|
|
private var _isVisible: Bool?
|
|
private var initiallyActivated = false
|
|
private var hideStatusNodeUntilCentrality = false
|
|
private var playOnContentOwnership = false
|
|
private var skipInitialPause = false
|
|
private var ignorePauseStatus = false
|
|
private var validLayout: (layout: ContainerViewLayout, navigationBarHeight: CGFloat)?
|
|
private var didPause = false
|
|
private var isPaused = true
|
|
private var dismissOnOrientationChange = false
|
|
private var keepSoundOnDismiss = false
|
|
private var hasPictureInPicture = false
|
|
|
|
private var pictureInPictureButton: UIBarButtonItem?
|
|
|
|
private var requiresDownload = false
|
|
|
|
private(set) var item: UniversalVideoGalleryItem?
|
|
private var playbackRate: Double?
|
|
private var videoQuality: UniversalVideoContentVideoQuality = .auto
|
|
private let playbackRatePromise = ValuePromise<Double>()
|
|
private let videoQualityPromise = ValuePromise<UniversalVideoContentVideoQuality>()
|
|
|
|
private var playerStatusValue: MediaPlayerStatus?
|
|
private let statusDisposable = MetaDisposable()
|
|
|
|
private let moreButtonStateDisposable = MetaDisposable()
|
|
private let settingsButtonStateDisposable = MetaDisposable()
|
|
private let mediaPlaybackStateDisposable = MetaDisposable()
|
|
|
|
private let fetchDisposable = MetaDisposable()
|
|
private var fetchStatus: MediaResourceStatus?
|
|
private var fetchControls: FetchControls?
|
|
|
|
private var scrubbingFrame = Promise<FramePreviewResult?>(nil)
|
|
private var scrubbingFrames = false
|
|
private var scrubbingFrameDisposable: Disposable?
|
|
|
|
private var isPlaying = false
|
|
private let isPlayingPromise = ValuePromise<Bool>(false, ignoreRepeated: true)
|
|
private let isInteractingPromise = ValuePromise<Bool>(false, ignoreRepeated: true)
|
|
private let controlsVisiblePromise = ValuePromise<Bool>(true, ignoreRepeated: true)
|
|
private let isShowingContextMenuPromise = ValuePromise<Bool>(false, ignoreRepeated: true)
|
|
private let isShowingSettingsMenuPromise = ValuePromise<Bool>(false, ignoreRepeated: true)
|
|
private let hasExpandedCaptionPromise = Promise<Bool>()
|
|
private var hideControlsDisposable: Disposable?
|
|
private var automaticPictureInPictureDisposable: Disposable?
|
|
|
|
var playbackCompleted: (() -> Void)?
|
|
|
|
private var customUnembedWhenPortrait: ((OverlayMediaItemNode) -> Bool)?
|
|
|
|
private var nativePictureInPictureContent: AnyObject?
|
|
|
|
private var activePictureInPictureNavigationController: NavigationController?
|
|
private var activePictureInPictureController: ViewController?
|
|
|
|
private var activeEdgeRateState: (initialRate: Double, currentRate: Double)?
|
|
private var activeEdgeRateIndicator: ComponentView<Empty>?
|
|
|
|
private var isAnimatingOut: Bool = false
|
|
|
|
init(context: AccountContext, presentationData: PresentationData, performAction: @escaping (GalleryControllerInteractionTapAction) -> Void, openActionOptions: @escaping (GalleryControllerInteractionTapAction, Message) -> Void, present: @escaping (ViewController, Any?) -> Void) {
|
|
self.context = context
|
|
self.presentationData = presentationData
|
|
|
|
|
|
self.footerContentNode = ChatItemGalleryFooterContentNode(context: context, presentationData: presentationData, present: present)
|
|
self.hasExpandedCaptionPromise.set(self.footerContentNode.hasExpandedCaption)
|
|
|
|
self.footerContentNode.performAction = performAction
|
|
self.footerContentNode.openActionOptions = openActionOptions
|
|
|
|
self.overlayContentNode = UniversalVideoGalleryItemOverlayNode()
|
|
|
|
self.statusButtonNode = HighlightableButtonNode()
|
|
self.statusNode = RadialStatusNode(backgroundNodeColor: UIColor(white: 0.0, alpha: 0.5))
|
|
self.statusNode.frame = CGRect(origin: CGPoint(), size: CGSize(width: 50.0, height: 50.0))
|
|
|
|
self._title.set(.single(""))
|
|
|
|
self.moreBarButton = MoreHeaderButton()
|
|
self.moreBarButton.isUserInteractionEnabled = true
|
|
self.moreBarButton.setContent(.more(optionsCircleImage(dark: false)))
|
|
|
|
self.settingsBarButton = SettingsHeaderButton()
|
|
self.settingsBarButton.isUserInteractionEnabled = true
|
|
|
|
super.init()
|
|
|
|
self.clipsToBounds = true
|
|
|
|
self.footerContentNode.shareMediaParameters = { [weak self] in
|
|
guard let self, let playerStatusValue = self.playerStatusValue else {
|
|
return nil
|
|
}
|
|
|
|
if playerStatusValue.duration >= 60.0 * 10.0 {
|
|
var publicLinkPrefix: ShareControllerSubject.PublicLinkPrefix?
|
|
if case let .message(message, _) = self.item?.contentInfo, message.id.namespace == Namespaces.Message.Cloud, let peer = message.peers[message.id.peerId] as? TelegramChannel, let username = peer.username ?? peer.usernames.first?.username {
|
|
let visibleString = "t.me/\(username)/\(message.id.id)"
|
|
publicLinkPrefix = ShareControllerSubject.PublicLinkPrefix(
|
|
visibleString: visibleString,
|
|
actualString: "https://\(visibleString)"
|
|
)
|
|
}
|
|
|
|
return ShareControllerSubject.MediaParameters(
|
|
startAtTimestamp: Int32(playerStatusValue.timestamp),
|
|
publicLinkPrefix: publicLinkPrefix
|
|
)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
self.moreBarButton.addTarget(self, action: #selector(self.moreButtonPressed), forControlEvents: .touchUpInside)
|
|
self.settingsBarButton.addTarget(self, action: #selector(self.settingsButtonPressed), forControlEvents: .touchUpInside)
|
|
|
|
self.footerContentNode.interacting = { [weak self] value in
|
|
self?.isInteractingPromise.set(value)
|
|
}
|
|
|
|
self.overlayContentNode.action = { [weak self] toLandscape in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.updateControlsVisibility(!toLandscape)
|
|
self.updateOrientation(toLandscape ? .landscapeRight : .portrait)
|
|
}
|
|
|
|
self.statusButtonNode.addSubnode(self.statusNode)
|
|
self.statusButtonNode.addTarget(self, action: #selector(self.statusButtonPressed), forControlEvents: .touchUpInside)
|
|
|
|
self.addSubnode(self.statusButtonNode)
|
|
|
|
self.footerContentNode.playbackControl = { [weak self] in
|
|
if let strongSelf = self {
|
|
if !strongSelf.isPaused {
|
|
strongSelf.didPause = true
|
|
}
|
|
strongSelf.videoNode?.togglePlayPause()
|
|
}
|
|
}
|
|
self.footerContentNode.seekBackward = { [weak self] delta in
|
|
if let strongSelf = self, let videoNode = strongSelf.videoNode {
|
|
let _ = (videoNode.status |> take(1)).start(next: { [weak videoNode] status in
|
|
if let strongVideoNode = videoNode, let timestamp = status?.timestamp {
|
|
strongVideoNode.seek(max(0.0, timestamp - delta))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
self.footerContentNode.seekForward = { [weak self] delta in
|
|
if let strongSelf = self, let videoNode = strongSelf.videoNode {
|
|
let _ = (videoNode.status |> take(1)).start(next: { [weak videoNode] status in
|
|
if let strongVideoNode = videoNode, let timestamp = status?.timestamp, let duration = status?.duration {
|
|
let nextTimestamp = timestamp + delta
|
|
if nextTimestamp > duration {
|
|
strongVideoNode.seek(0.0)
|
|
strongVideoNode.pause()
|
|
} else {
|
|
strongVideoNode.seek(min(duration, timestamp + delta))
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
self.footerContentNode.setPlayRate = { [weak self] rate in
|
|
if let strongSelf = self, let videoNode = strongSelf.videoNode {
|
|
videoNode.setBaseRate(rate)
|
|
|
|
if let controller = strongSelf.galleryController() as? GalleryController {
|
|
controller.updateSharedPlaybackRate(rate)
|
|
}
|
|
}
|
|
}
|
|
|
|
self.footerContentNode.fetchControl = { [weak self] in
|
|
guard let strongSelf = self, let fetchStatus = strongSelf.fetchStatus, let fetchControls = strongSelf.fetchControls else {
|
|
return
|
|
}
|
|
switch fetchStatus {
|
|
case .Fetching:
|
|
fetchControls.cancel()
|
|
case .Remote, .Paused:
|
|
fetchControls.fetch()
|
|
case .Local:
|
|
break
|
|
}
|
|
}
|
|
|
|
self.footerContentNode.toggleFullscreen = { [weak self] in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
var toLandscape = false
|
|
let size = strongSelf.bounds.size
|
|
if size.width < size.height {
|
|
toLandscape = true
|
|
}
|
|
strongSelf.updateControlsVisibility(!toLandscape)
|
|
strongSelf.updateOrientation(toLandscape ? .landscapeRight : .portrait)
|
|
}
|
|
|
|
self.scrubbingFrameDisposable = (self.scrubbingFrame.get()
|
|
|> deliverOnMainQueue).start(next: { [weak self] result in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
if let result = result, strongSelf.scrubbingFrames {
|
|
switch result {
|
|
case .waitingForData:
|
|
strongSelf.footerContentNode.setFramePreviewImageIsLoading()
|
|
case let .image(image):
|
|
strongSelf.footerContentNode.setFramePreviewImage(image: image)
|
|
}
|
|
} else {
|
|
strongSelf.footerContentNode.setFramePreviewImage(image: nil)
|
|
}
|
|
}).strict()
|
|
|
|
self.alternativeDismiss = { [weak self] in
|
|
guard let strongSelf = self, strongSelf.hasPictureInPicture else {
|
|
return false
|
|
}
|
|
strongSelf.pictureInPictureButtonPressed()
|
|
return true
|
|
}
|
|
|
|
self.moreBarButton.contextAction = { [weak self] sourceNode, gesture in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.openMoreMenu(sourceNode: self.moreBarButton.referenceNode, gesture: gesture, isSettings: false)
|
|
}
|
|
|
|
self.titleContentView = GalleryTitleView(frame: CGRect())
|
|
self._titleView.set(.single(self.titleContentView))
|
|
|
|
let shouldHideControlsSignal: Signal<Void, NoError> = combineLatest(self.isPlayingPromise.get(), self.isInteractingPromise.get(), self.controlsVisiblePromise.get(), self.isShowingContextMenuPromise.get(), self.isShowingSettingsMenuPromise.get(), self.hasExpandedCaptionPromise.get())
|
|
|> mapToSignal { isPlaying, isInteracting, controlsVisible, isShowingContextMenu, isShowingSettingsMenu, hasExpandedCaptionPromise -> Signal<Void, NoError> in
|
|
if isShowingContextMenu || isShowingSettingsMenu || hasExpandedCaptionPromise {
|
|
return .complete()
|
|
}
|
|
if isPlaying && !isInteracting && controlsVisible {
|
|
return .single(Void())
|
|
|> delay(4.0, queue: Queue.mainQueue())
|
|
} else {
|
|
return .complete()
|
|
}
|
|
}
|
|
|
|
self.hideControlsDisposable = (shouldHideControlsSignal
|
|
|> deliverOnMainQueue).start(next: { [weak self] _ in
|
|
if let strongSelf = self, !strongSelf.isAnimatingOut {
|
|
strongSelf.updateControlsVisibility(false)
|
|
}
|
|
}).strict()
|
|
}
|
|
|
|
deinit {
|
|
self.statusDisposable.dispose()
|
|
self.moreButtonStateDisposable.dispose()
|
|
self.settingsButtonStateDisposable.dispose()
|
|
self.mediaPlaybackStateDisposable.dispose()
|
|
self.scrubbingFrameDisposable?.dispose()
|
|
self.hideControlsDisposable?.dispose()
|
|
self.automaticPictureInPictureDisposable?.dispose()
|
|
}
|
|
|
|
override func ready() -> Signal<Void, NoError> {
|
|
return self._ready.get()
|
|
}
|
|
|
|
|
|
override func contentTapAction() -> Bool {
|
|
if case let .message(message, _) = self.item?.contentInfo, let _ = message.adAttribute {
|
|
self.item?.performAction(.ad(message.id))
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
override func screenFrameUpdated(_ frame: CGRect) {
|
|
let center = frame.midX - self.frame.width / 2.0
|
|
self.subnodeTransform = CATransform3DMakeTranslation(-center * 0.16, 0.0, 0.0)
|
|
}
|
|
|
|
override func containerLayoutUpdated(_ layout: ContainerViewLayout, navigationBarHeight: CGFloat, transition: ContainedViewLayoutTransition) {
|
|
if let _ = self.customUnembedWhenPortrait, layout.size.width < layout.size.height {
|
|
self.expandIntoCustomPiP()
|
|
}
|
|
|
|
super.containerLayoutUpdated(layout, navigationBarHeight: navigationBarHeight, transition: transition)
|
|
|
|
var dismiss = false
|
|
if let (previousLayout, _) = self.validLayout, self.dismissOnOrientationChange, previousLayout.size.width > previousLayout.size.height && previousLayout.size.height == layout.size.width {
|
|
dismiss = true
|
|
}
|
|
let hadLayout = self.validLayout != nil
|
|
self.validLayout = (layout, navigationBarHeight)
|
|
|
|
if !hadLayout {
|
|
self.zoomableContent = zoomableContent
|
|
}
|
|
|
|
let statusDiameter: CGFloat = 50.0
|
|
let statusFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - statusDiameter) / 2.0), y: floor((layout.size.height - statusDiameter) / 2.0)), size: CGSize(width: statusDiameter, height: statusDiameter))
|
|
transition.updateFrame(node: self.statusButtonNode, frame: statusFrame)
|
|
transition.updateFrame(node: self.statusNode, frame: CGRect(origin: CGPoint(), size: statusFrame.size))
|
|
|
|
if let pictureInPictureNode = self.pictureInPictureNode {
|
|
if let item = self.item {
|
|
var placeholderSize = item.content.dimensions.fitted(layout.size)
|
|
placeholderSize.height += 2.0
|
|
transition.updateFrame(node: pictureInPictureNode, frame: CGRect(origin: CGPoint(x: floor((layout.size.width - placeholderSize.width) / 2.0), y: floor((layout.size.height - placeholderSize.height) / 2.0)), size: placeholderSize))
|
|
pictureInPictureNode.updateLayout(placeholderSize, transition: transition)
|
|
}
|
|
}
|
|
|
|
if let activeEdgeRateState = self.activeEdgeRateState {
|
|
var activeEdgeRateIndicatorTransition = transition
|
|
let activeEdgeRateIndicator: ComponentView<Empty>
|
|
if let current = self.activeEdgeRateIndicator {
|
|
activeEdgeRateIndicator = current
|
|
} else {
|
|
activeEdgeRateIndicator = ComponentView()
|
|
self.activeEdgeRateIndicator = activeEdgeRateIndicator
|
|
activeEdgeRateIndicatorTransition = .immediate
|
|
}
|
|
|
|
let activeEdgeRateIndicatorSize = activeEdgeRateIndicator.update(
|
|
transition: ComponentTransition(activeEdgeRateIndicatorTransition),
|
|
component: AnyComponent(GalleryRateToastComponent(
|
|
rate: activeEdgeRateState.currentRate,
|
|
displayTooltip: self.presentationData.strings.Gallery_ToastVideoSpeedSwipe
|
|
)),
|
|
environment: {},
|
|
containerSize: CGSize(width: layout.size.width - layout.safeInsets.left * 2.0, height: 100.0)
|
|
)
|
|
let activeEdgeRateIndicatorFrame = CGRect(origin: CGPoint(x: floor((layout.size.width - activeEdgeRateIndicatorSize.width) * 0.5), y: max(navigationBarHeight, layout.statusBarHeight ?? 0.0) + 8.0), size: activeEdgeRateIndicatorSize)
|
|
if let activeEdgeRateIndicatorView = activeEdgeRateIndicator.view {
|
|
if activeEdgeRateIndicatorView.superview == nil {
|
|
self.view.addSubview(activeEdgeRateIndicatorView)
|
|
transition.animateTransformScale(view: activeEdgeRateIndicatorView, from: 0.001)
|
|
if transition.isAnimated {
|
|
activeEdgeRateIndicatorView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
|
|
}
|
|
}
|
|
activeEdgeRateIndicatorTransition.updateFrame(view: activeEdgeRateIndicatorView, frame: activeEdgeRateIndicatorFrame)
|
|
}
|
|
} else if let activeEdgeRateIndicator = self.activeEdgeRateIndicator {
|
|
self.activeEdgeRateIndicator = nil
|
|
if let activeEdgeRateIndicatorView = activeEdgeRateIndicator.view {
|
|
transition.updateAlpha(layer: activeEdgeRateIndicatorView.layer, alpha: 0.0, completion: { [weak activeEdgeRateIndicatorView] _ in
|
|
activeEdgeRateIndicatorView?.removeFromSuperview()
|
|
})
|
|
transition.updateTransformScale(layer: activeEdgeRateIndicatorView.layer, scale: 0.001)
|
|
}
|
|
}
|
|
|
|
if dismiss {
|
|
self.dismiss()
|
|
}
|
|
}
|
|
|
|
func setupItem(_ item: UniversalVideoGalleryItem) {
|
|
if self.item?.content.id != item.content.id {
|
|
var chapters = parseMediaPlayerChapters(item.caption)
|
|
if chapters.isEmpty, let description = item.description {
|
|
chapters = parseMediaPlayerChapters(description)
|
|
}
|
|
let scrubberView = ChatVideoGalleryItemScrubberView(chapters: chapters)
|
|
self.scrubberView = scrubberView
|
|
scrubberView.seek = { [weak self] timecode in
|
|
self?.videoNode?.seek(timecode)
|
|
}
|
|
scrubberView.updateScrubbing = { [weak self] timecode in
|
|
guard let strongSelf = self else {
|
|
return
|
|
}
|
|
|
|
strongSelf.isInteractingPromise.set(timecode != nil)
|
|
|
|
if let videoFramePreview = strongSelf.videoFramePreview {
|
|
if let timecode = timecode {
|
|
if !strongSelf.scrubbingFrames {
|
|
strongSelf.scrubbingFrames = true
|
|
strongSelf.scrubbingFrame.set(videoFramePreview.generatedFrames
|
|
|> map(Optional.init))
|
|
}
|
|
videoFramePreview.generateFrame(at: timecode)
|
|
} else {
|
|
strongSelf.isInteractingPromise.set(false)
|
|
strongSelf.scrubbingFrame.set(.single(nil))
|
|
videoFramePreview.cancelPendingFrames()
|
|
strongSelf.scrubbingFrames = false
|
|
}
|
|
}
|
|
}
|
|
self.footerContentNode.scrubberView = scrubberView
|
|
|
|
self.isPlaying = false
|
|
self.isPlayingPromise.set(false)
|
|
|
|
if item.hideControls {
|
|
self.statusButtonNode.isHidden = true
|
|
}
|
|
|
|
self.dismissOnOrientationChange = item.landscape
|
|
|
|
var hasLinkedStickers = false
|
|
if let content = item.content as? NativeVideoContent {
|
|
hasLinkedStickers = content.fileReference.media.hasLinkedStickers
|
|
} else if let content = item.content as? HLSVideoContent {
|
|
hasLinkedStickers = content.fileReference.media.hasLinkedStickers
|
|
}
|
|
|
|
var disablePictureInPicture = false
|
|
var disablePlayerControls = false
|
|
var forceEnablePiP = false
|
|
var forceEnableUserInteraction = false
|
|
var isAnimated = false
|
|
var isEnhancedWebPlayer = false
|
|
var isAdaptive = false
|
|
if let content = item.content as? NativeVideoContent {
|
|
isAnimated = content.fileReference.media.isAnimated
|
|
self.videoFramePreview = MediaPlayerFramePreview(postbox: item.context.account.postbox, userLocation: content.userLocation, userContentType: .video, fileReference: content.fileReference)
|
|
} else if let _ = item.content as? SystemVideoContent {
|
|
self._title.set(.single(item.presentationData.strings.Message_Video))
|
|
} else if let content = item.content as? WebEmbedVideoContent {
|
|
let type = webEmbedType(content: content.webpageContent)
|
|
switch type {
|
|
case .youtube:
|
|
isEnhancedWebPlayer = true
|
|
forceEnableUserInteraction = true
|
|
disablePictureInPicture = !(item.configuration?.youtubePictureInPictureEnabled ?? false)
|
|
self.videoFramePreview = YoutubeEmbedFramePreview(context: item.context, content: content)
|
|
case .vimeo:
|
|
isEnhancedWebPlayer = true
|
|
case .iframe:
|
|
disablePlayerControls = true
|
|
default:
|
|
break
|
|
}
|
|
} else if let _ = item.content as? PlatformVideoContent {
|
|
disablePlayerControls = true
|
|
forceEnablePiP = true
|
|
} else if let content = item.content as? HLSVideoContent {
|
|
isAdaptive = true
|
|
|
|
if let qualitySet = HLSQualitySet(baseFile: content.fileReference, codecConfiguration: HLSCodecConfiguration(isHardwareAv1Supported: false, isSoftwareAv1Supported: true)), let (quality, playlistFile) = qualitySet.playlistFiles.sorted(by: { $0.key < $1.key }).first, let dataFile = qualitySet.qualityFiles[quality] {
|
|
var alternativeQualities: [(playlist: FileMediaReference, dataFile: FileMediaReference)] = []
|
|
for (otherQuality, otherPlaylistFile) in qualitySet.playlistFiles {
|
|
if otherQuality != quality, let otherDataFile = qualitySet.qualityFiles[otherQuality] {
|
|
alternativeQualities.append((otherPlaylistFile, dataFile: otherDataFile))
|
|
}
|
|
}
|
|
self.videoFramePreview = MediaPlayerFramePreviewHLS(
|
|
postbox: item.context.account.postbox,
|
|
userLocation: content.userLocation,
|
|
userContentType: .video,
|
|
playlistFile: playlistFile,
|
|
mainDataFile: dataFile,
|
|
alternativeQualities: alternativeQualities
|
|
)
|
|
}
|
|
}
|
|
|
|
let _ = isAdaptive
|
|
|
|
let dimensions = item.content.dimensions
|
|
if dimensions.height > 0.0 {
|
|
if dimensions.width / dimensions.height < 1.33 || isAnimated {
|
|
self.overlayContentNode.isHidden = true
|
|
}
|
|
}
|
|
|
|
if let videoNode = self.videoNode {
|
|
videoNode.canAttachContent = false
|
|
videoNode.removeFromSupernode()
|
|
}
|
|
|
|
if isAnimated || disablePlayerControls {
|
|
self.footerContentNode.scrubberView = nil
|
|
}
|
|
|
|
let mediaManager = item.context.sharedContext.mediaManager
|
|
|
|
let videoNode = UniversalVideoNode(context: item.context, postbox: item.context.account.postbox, audioSession: mediaManager.audioSession, manager: mediaManager.universalVideoManager, decoration: GalleryVideoDecoration(), content: item.content, priority: .gallery)
|
|
let videoScale: CGFloat
|
|
if item.content is WebEmbedVideoContent {
|
|
videoScale = 1.0
|
|
} else {
|
|
videoScale = 2.0
|
|
}
|
|
let videoSize = CGSize(width: item.content.dimensions.width * videoScale, height: item.content.dimensions.height * videoScale)
|
|
let actualVideoSize = CGSize(width: item.content.dimensions.width, height: item.content.dimensions.height)
|
|
videoNode.updateLayout(size: videoSize, actualSize: actualVideoSize, transition: .immediate)
|
|
videoNode.ownsContentNodeUpdated = { [weak self] value in
|
|
if let strongSelf = self {
|
|
strongSelf.updateDisplayPlaceholder(!value)
|
|
|
|
if strongSelf.playOnContentOwnership {
|
|
strongSelf.playOnContentOwnership = false
|
|
strongSelf.initiallyActivated = true
|
|
strongSelf.skipInitialPause = true
|
|
if let item = strongSelf.item, let _ = item.content as? PlatformVideoContent {
|
|
strongSelf.videoNode?.play()
|
|
} else {
|
|
strongSelf.videoNode?.playOnceWithSound(playAndRecord: false, seek: .none, actionAtEnd: isAnimated ? .loop : strongSelf.actionAtEnd)
|
|
}
|
|
|
|
Queue.mainQueue().after(0.1) {
|
|
if let playbackRate = strongSelf.playbackRate {
|
|
strongSelf.videoNode?.setBaseRate(playbackRate)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
self.videoNode = videoNode
|
|
self.videoNodeUserInteractionEnabled = disablePlayerControls || forceEnableUserInteraction
|
|
videoNode.isUserInteractionEnabled = disablePlayerControls || forceEnableUserInteraction
|
|
videoNode.backgroundColor = UIColor.black
|
|
if item.fromPlayingVideo {
|
|
videoNode.canAttachContent = false
|
|
} else {
|
|
self.updateDisplayPlaceholder()
|
|
}
|
|
|
|
scrubberView.setStatusSignal(videoNode.status |> map { value -> MediaPlayerStatus in
|
|
if let value = value, !value.duration.isZero {
|
|
return value
|
|
} else {
|
|
return MediaPlayerStatus(generationTimestamp: 0.0, duration: max(Double(item.content.duration), 0.01), dimensions: CGSize(), timestamp: 0.0, baseRate: 1.0, seekId: 0, status: .paused, soundEnabled: true)
|
|
}
|
|
})
|
|
|
|
scrubberView.setBufferingStatusSignal(videoNode.bufferingStatus)
|
|
|
|
self.requiresDownload = true
|
|
var mediaFileStatus: Signal<MediaResourceStatus?, NoError> = .single(nil)
|
|
|
|
var hintSeekable = false
|
|
if let contentInfo = item.contentInfo, case let .message(message, _) = contentInfo {
|
|
if message.paidContent != nil {
|
|
disablePictureInPicture = true
|
|
} else if Namespaces.Message.allNonRegular.contains(message.id.namespace) || message.id.namespace == Namespaces.Message.Local {
|
|
disablePictureInPicture = true
|
|
}
|
|
|
|
if message.paidContent == nil {
|
|
let throttledSignal = videoNode.status
|
|
|> mapToThrottled { next -> Signal<MediaPlayerStatus?, NoError> in
|
|
return .single(next) |> then(.complete() |> delay(0.5, queue: Queue.concurrentDefaultQueue()))
|
|
}
|
|
|
|
self.mediaPlaybackStateDisposable.set((throttledSignal
|
|
|> deliverOnMainQueue).start(next: { [weak self] status in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
if let status {
|
|
self.maybeStorePlaybackStatus(status: status)
|
|
}
|
|
}))
|
|
}
|
|
|
|
var file: TelegramMediaFile?
|
|
var isWebpage = false
|
|
for m in message.media {
|
|
if let m = m as? TelegramMediaFile, m.isVideo {
|
|
file = m
|
|
break
|
|
} else if let m = m as? TelegramMediaWebpage, case let .Loaded(content) = m.content, let f = content.file, f.isVideo {
|
|
file = f
|
|
isWebpage = true
|
|
break
|
|
}
|
|
}
|
|
if let file = file {
|
|
for attribute in file.attributes {
|
|
if case let .Video(duration, _, _, _, _, _) = attribute, duration >= 30 {
|
|
hintSeekable = true
|
|
break
|
|
}
|
|
}
|
|
let status = messageMediaFileStatus(context: item.context, messageId: message.id, file: file)
|
|
if !isWebpage && message.adAttribute == nil && !NativeVideoContent.isHLSVideo(file: file) {
|
|
scrubberView.setFetchStatusSignal(status, strings: self.presentationData.strings, decimalSeparator: self.presentationData.dateTimeFormat.decimalSeparator, fileSize: file.size)
|
|
}
|
|
|
|
self.requiresDownload = !isMediaStreamable(message: message, media: file)
|
|
mediaFileStatus = status |> map(Optional.init)
|
|
self.fetchControls = FetchControls(fetch: { [weak self] in
|
|
if let strongSelf = self {
|
|
strongSelf.fetchDisposable.set(messageMediaFileInteractiveFetched(context: item.context, message: message, file: file, userInitiated: true).start())
|
|
}
|
|
}, cancel: {
|
|
messageMediaFileCancelInteractiveFetch(context: item.context, messageId: message.id, file: file)
|
|
})
|
|
}
|
|
}
|
|
|
|
self.moreButtonStateDisposable.set(combineLatest(queue: .mainQueue(),
|
|
self.playbackRatePromise.get(),
|
|
self.videoQualityPromise.get()
|
|
).start(next: { [weak self] playbackRate, videoQuality in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
var rateString: String?
|
|
if abs(playbackRate - 1.0) > 0.05 {
|
|
var stringValue = String(format: "%.1fx", playbackRate)
|
|
if stringValue.hasSuffix(".0x") {
|
|
stringValue = stringValue.replacingOccurrences(of: ".0x", with: "x")
|
|
}
|
|
rateString = stringValue
|
|
}
|
|
|
|
var qualityString: String?
|
|
if case let .quality(quality) = videoQuality {
|
|
if quality <= 360 {
|
|
qualityString = self.presentationData.strings.Gallery_VideoSettings_IconQualityLow
|
|
} else if quality <= 480 {
|
|
qualityString = self.presentationData.strings.Gallery_VideoSettings_IconQualityMedium
|
|
} else if quality <= 720 {
|
|
qualityString = self.presentationData.strings.Gallery_VideoSettings_IconQualityHD
|
|
} else if quality <= 1080 {
|
|
qualityString = self.presentationData.strings.Gallery_VideoSettings_IconQualityFHD
|
|
} else {
|
|
qualityString = self.presentationData.strings.Gallery_VideoSettings_IconQualityQHD
|
|
}
|
|
}
|
|
|
|
self.settingsBarButton.setBadges(speed: rateString, quality: qualityString, transition: .spring(duration: 0.35))
|
|
}))
|
|
|
|
self.settingsButtonStateDisposable.set((self.isShowingSettingsMenuPromise.get()
|
|
|> deliverOnMainQueue).start(next: { [weak self] isShowingSettingsMenu in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.settingsBarButton.setIsMenuOpen(isMenuOpen: isShowingSettingsMenu)
|
|
}))
|
|
|
|
self.statusDisposable.set((combineLatest(queue: .mainQueue(), videoNode.status, mediaFileStatus)
|
|
|> deliverOnMainQueue).start(next: { [weak self] value, fetchStatus in
|
|
if let strongSelf = self {
|
|
strongSelf.playerStatusValue = value
|
|
|
|
var initialBuffering = false
|
|
var isPlaying = false
|
|
var isPaused = true
|
|
var seekable = hintSeekable
|
|
var hasStarted = false
|
|
var displayProgress = true
|
|
if let value = value {
|
|
hasStarted = value.timestamp > 0
|
|
|
|
if let zoomableContent = strongSelf.zoomableContent, !value.dimensions.width.isZero && !value.dimensions.height.isZero {
|
|
let videoSize = CGSize(width: value.dimensions.width * 2.0, height: value.dimensions.height * 2.0)
|
|
if !zoomableContent.0.equalTo(videoSize) {
|
|
strongSelf.zoomableContent = (videoSize, zoomableContent.1)
|
|
strongSelf.videoNode?.updateLayout(size: videoSize, transition: .immediate)
|
|
}
|
|
}
|
|
switch value.status {
|
|
case .playing:
|
|
isPaused = false
|
|
isPlaying = true
|
|
strongSelf.ignorePauseStatus = false
|
|
case let .buffering(_, whilePlaying, _, display):
|
|
displayProgress = display
|
|
initialBuffering = !whilePlaying
|
|
if item.content is HLSVideoContent && display {
|
|
initialBuffering = true
|
|
}
|
|
isPaused = !whilePlaying
|
|
var isStreaming = false
|
|
if let fetchStatus = strongSelf.fetchStatus {
|
|
switch fetchStatus {
|
|
case .Local:
|
|
break
|
|
default:
|
|
isStreaming = true
|
|
}
|
|
} else {
|
|
switch fetchStatus {
|
|
case .Local:
|
|
break
|
|
default:
|
|
isStreaming = true
|
|
}
|
|
}
|
|
if let content = item.content as? NativeVideoContent, !isStreaming {
|
|
initialBuffering = false
|
|
if !content.enableSound {
|
|
isPaused = false
|
|
}
|
|
}
|
|
default:
|
|
if let content = item.content as? NativeVideoContent, !content.streamVideo.enabled {
|
|
if !content.enableSound {
|
|
isPaused = false
|
|
}
|
|
} else if strongSelf.actionAtEnd == .stop {
|
|
strongSelf.isPlayingPromise.set(false)
|
|
strongSelf.isPlaying = false
|
|
if strongSelf.isCentral == true {
|
|
if !item.isSecret {
|
|
strongSelf.updateControlsVisibility(true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if !value.duration.isZero {
|
|
seekable = value.duration >= 30.0
|
|
}
|
|
}
|
|
|
|
if !disablePlayerControls && strongSelf.isCentral == true && isPlaying {
|
|
strongSelf.isPlayingPromise.set(true)
|
|
strongSelf.isPlaying = true
|
|
} else if !isPlaying {
|
|
strongSelf.isPlayingPromise.set(false)
|
|
strongSelf.isPlaying = false
|
|
}
|
|
|
|
var fetching = false
|
|
if initialBuffering {
|
|
if displayProgress {
|
|
strongSelf.statusNode.transitionToState(.progress(color: .white, lineWidth: nil, value: nil, cancelEnabled: false, animateRotation: true), animated: false, completion: {})
|
|
} else {
|
|
strongSelf.statusNode.transitionToState(.none, animated: false, completion: {})
|
|
}
|
|
} else {
|
|
var state: RadialStatusNodeState = .play(.white)
|
|
|
|
if let fetchStatus = fetchStatus {
|
|
if strongSelf.requiresDownload {
|
|
switch fetchStatus {
|
|
case .Remote:
|
|
state = .download(.white)
|
|
case let .Fetching(_, progress):
|
|
if !isPlaying {
|
|
fetching = true
|
|
isPaused = true
|
|
}
|
|
state = .progress(color: .white, lineWidth: nil, value: CGFloat(progress), cancelEnabled: true, animateRotation: true)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
strongSelf.statusNode.transitionToState(state, animated: false, completion: {})
|
|
}
|
|
|
|
strongSelf.isPaused = isPaused
|
|
strongSelf.fetchStatus = fetchStatus
|
|
|
|
if !item.hideControls {
|
|
strongSelf.statusNodeShouldBeHidden = strongSelf.ignorePauseStatus || (!initialBuffering && (strongSelf.didPause || !isPaused) && !fetching)
|
|
strongSelf.statusButtonNode.isHidden = strongSelf.hideStatusNodeUntilCentrality || strongSelf.statusNodeShouldBeHidden
|
|
}
|
|
|
|
if isAnimated || disablePlayerControls {
|
|
strongSelf.footerContentNode.content = .info
|
|
} else if isPaused && !strongSelf.ignorePauseStatus && strongSelf.isCentral == true {
|
|
if hasStarted || strongSelf.didPause {
|
|
strongSelf.footerContentNode.content = .playback(paused: true, seekable: seekable)
|
|
} else if let fetchStatus = fetchStatus, !strongSelf.requiresDownload {
|
|
if item.content is HLSVideoContent {
|
|
strongSelf.footerContentNode.content = .playback(paused: true, seekable: seekable)
|
|
} else {
|
|
strongSelf.footerContentNode.content = .fetch(status: fetchStatus, seekable: seekable)
|
|
}
|
|
}
|
|
} else {
|
|
strongSelf.footerContentNode.content = .playback(paused: false, seekable: seekable)
|
|
}
|
|
}
|
|
}))
|
|
|
|
self.zoomableContent = (videoSize, videoNode)
|
|
|
|
var barButtonItems: [UIBarButtonItem] = []
|
|
if hasLinkedStickers {
|
|
let rightBarButtonItem = UIBarButtonItem(image: generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/Stickers"), color: .white), style: .plain, target: self, action: #selector(self.openStickersButtonPressed))
|
|
rightBarButtonItem.accessibilityLabel = self.presentationData.strings.Gallery_VoiceOver_Stickers
|
|
barButtonItems.append(rightBarButtonItem)
|
|
}
|
|
|
|
if forceEnablePiP || (!isAnimated && !disablePlayerControls && !disablePictureInPicture) {
|
|
let rightBarButtonItem = UIBarButtonItem(image: pictureInPictureButtonImage, style: .plain, target: self, action: #selector(self.pictureInPictureButtonPressed))
|
|
rightBarButtonItem.accessibilityLabel = self.presentationData.strings.Gallery_VoiceOver_PictureInPicture
|
|
self.pictureInPictureButton = rightBarButtonItem
|
|
barButtonItems.append(rightBarButtonItem)
|
|
self.hasPictureInPicture = true
|
|
} else {
|
|
self.hasPictureInPicture = false
|
|
}
|
|
|
|
if let contentInfo = item.contentInfo, case let .message(message, mediaIndex) = contentInfo {
|
|
var file: TelegramMediaFile?
|
|
for m in message.media {
|
|
if let m = m as? TelegramMediaFile, m.isVideo {
|
|
file = m
|
|
break
|
|
} else if let m = m as? TelegramMediaWebpage, case let .Loaded(content) = m.content, let f = content.file, f.isVideo {
|
|
file = f
|
|
break
|
|
} else if let paidContent = message.paidContent {
|
|
let mediaIndex = mediaIndex ?? 0
|
|
let media = paidContent.extendedMedia[mediaIndex]
|
|
if case let .full(fullMedia) = media, let m = fullMedia as? TelegramMediaFile {
|
|
file = m
|
|
}
|
|
break
|
|
}
|
|
}
|
|
|
|
var hasMoreButton = false
|
|
if isEnhancedWebPlayer {
|
|
hasMoreButton = true
|
|
} else if let file = file, !file.isAnimated {
|
|
hasMoreButton = true
|
|
}
|
|
|
|
if let _ = message.paidContent, message.id.namespace == Namespaces.Message.Local {
|
|
hasMoreButton = false
|
|
}
|
|
|
|
if let _ = message.adAttribute {
|
|
hasMoreButton = true
|
|
}
|
|
|
|
if !isAnimated && !disablePlayerControls {
|
|
let settingsMenuItem = UIBarButtonItem(customDisplayNode: self.settingsBarButton)!
|
|
settingsMenuItem.accessibilityLabel = self.presentationData.strings.Settings_Title
|
|
barButtonItems.append(settingsMenuItem)
|
|
}
|
|
|
|
if hasMoreButton {
|
|
let moreMenuItem = UIBarButtonItem(customDisplayNode: self.moreBarButton)!
|
|
moreMenuItem.accessibilityLabel = self.presentationData.strings.Common_More
|
|
barButtonItems.append(moreMenuItem)
|
|
}
|
|
}
|
|
|
|
self._rightBarButtonItems.set(.single(barButtonItems))
|
|
|
|
videoNode.playbackCompleted = { [weak self, weak videoNode] in
|
|
Queue.mainQueue().async {
|
|
item.playbackCompleted()
|
|
|
|
if let strongSelf = self, !isAnimated {
|
|
if #available(iOS 15.0, *) {
|
|
if let nativePictureInPictureContent = strongSelf.nativePictureInPictureContent as? NativePictureInPictureContentImpl {
|
|
nativePictureInPictureContent.invalidatePlaybackState()
|
|
}
|
|
}
|
|
|
|
if let snapshotView = videoNode?.view.snapshotView(afterScreenUpdates: false) {
|
|
videoNode?.view.addSubview(snapshotView)
|
|
snapshotView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.3, removeOnCompletion: false, completion: { [weak snapshotView] _ in
|
|
snapshotView?.removeFromSuperview()
|
|
})
|
|
}
|
|
|
|
videoNode?.seek(0.0)
|
|
|
|
if strongSelf.actionAtEnd == .stop && strongSelf.isCentral == true {
|
|
strongSelf.isPlayingPromise.set(false)
|
|
strongSelf.isPlaying = false
|
|
if !item.isSecret {
|
|
strongSelf.updateControlsVisibility(true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
self._ready.set(videoNode.ready)
|
|
}
|
|
|
|
self.item = item
|
|
|
|
if let _ = item.content as? NativeVideoContent {
|
|
self.playbackRate = item.playbackRate()
|
|
} else if let _ = item.content as? HLSVideoContent {
|
|
self.playbackRate = item.playbackRate()
|
|
} else if let _ = item.content as? WebEmbedVideoContent {
|
|
self.playbackRate = item.playbackRate()
|
|
}
|
|
|
|
self.playbackRatePromise.set(self.playbackRate ?? 1.0)
|
|
self.videoQualityPromise.set(self.videoQuality)
|
|
|
|
var isAd = false
|
|
if let contentInfo = item.contentInfo {
|
|
switch contentInfo {
|
|
case let .message(message, _):
|
|
isAd = message.adAttribute != nil
|
|
self.footerContentNode.setMessage(message, displayInfo: !item.displayInfoOnTop, peerIsCopyProtected: item.peerIsCopyProtected)
|
|
case let .webPage(webPage, media, _):
|
|
self.footerContentNode.setWebPage(webPage, media: media)
|
|
}
|
|
}
|
|
self.footerContentNode.setup(origin: item.originData, caption: item.caption, isAd: isAd)
|
|
}
|
|
|
|
override func controlsVisibilityUpdated(isVisible: Bool) {
|
|
self.controlsVisiblePromise.set(isVisible)
|
|
|
|
self.videoNode?.isUserInteractionEnabled = isVisible ? self.videoNodeUserInteractionEnabled : false
|
|
self.videoNode?.notifyPlaybackControlsHidden(!isVisible)
|
|
}
|
|
|
|
private func updateDisplayPlaceholder() {
|
|
self.updateDisplayPlaceholder(!(self.videoNode?.ownsContentNode ?? true) || self.isAirPlayActive)
|
|
}
|
|
|
|
private func updateDisplayPlaceholder(_ displayPlaceholder: Bool) {
|
|
if displayPlaceholder && !self.disablePictureInPicturePlaceholder {
|
|
if self.pictureInPictureNode == nil {
|
|
let pictureInPictureNode = UniversalVideoGalleryItemPictureInPictureNode(strings: self.presentationData.strings, mode: self.isAirPlayActive ? .airplay : .pictureInPicture)
|
|
pictureInPictureNode.isUserInteractionEnabled = false
|
|
self.pictureInPictureNode = pictureInPictureNode
|
|
self.insertSubnode(pictureInPictureNode, aboveSubnode: self.scrollNode)
|
|
if let validLayout = self.validLayout {
|
|
if let item = self.item {
|
|
var placeholderSize = item.content.dimensions.fitted(validLayout.0.size)
|
|
placeholderSize.height += 2.0
|
|
pictureInPictureNode.frame = CGRect(origin: CGPoint(x: floor((validLayout.0.size.width - placeholderSize.width) / 2.0), y: floorToScreenPixels((validLayout.0.size.height - placeholderSize.height) / 2.0)), size: placeholderSize)
|
|
pictureInPictureNode.updateLayout(placeholderSize, transition: .immediate)
|
|
}
|
|
}
|
|
}
|
|
} else if let pictureInPictureNode = self.pictureInPictureNode {
|
|
self.pictureInPictureNode = nil
|
|
pictureInPictureNode.removeFromSupernode()
|
|
self.videoNode?.backgroundColor = .black
|
|
}
|
|
|
|
self.pictureInPictureButton?.isEnabled = self.pictureInPictureNode == nil
|
|
}
|
|
|
|
private func shouldAutoplayOnCentrality() -> Bool {
|
|
if let item = self.item, let content = item.content as? NativeVideoContent {
|
|
var isLocal = false
|
|
if let fetchStatus = self.fetchStatus, case .Local = fetchStatus {
|
|
isLocal = true
|
|
}
|
|
var isStreamable = false
|
|
if let contentInfo = item.contentInfo, case let .message(message, _) = contentInfo {
|
|
isStreamable = isMediaStreamable(message: message, media: content.fileReference.media)
|
|
} else {
|
|
isStreamable = isMediaStreamable(media: content.fileReference.media)
|
|
}
|
|
if isLocal || isStreamable {
|
|
return true
|
|
}
|
|
} else if let item = self.item, let _ = item.content as? HLSVideoContent {
|
|
return true
|
|
} else if let item = self.item, let _ = item.content as? PlatformVideoContent {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
override func centralityUpdated(isCentral: Bool) {
|
|
super.centralityUpdated(isCentral: isCentral)
|
|
|
|
if self.isCentral != isCentral {
|
|
self.isCentral = isCentral
|
|
|
|
if let videoNode = self.videoNode {
|
|
if isCentral {
|
|
var isAnimated = false
|
|
if let item = self.item, let content = item.content as? NativeVideoContent {
|
|
isAnimated = content.fileReference.media.isAnimated
|
|
} else if let item = self.item, let content = item.content as? HLSVideoContent {
|
|
isAnimated = content.fileReference.media.isAnimated
|
|
}
|
|
|
|
self.hideStatusNodeUntilCentrality = false
|
|
self.statusButtonNode.isHidden = self.hideStatusNodeUntilCentrality || self.statusNodeShouldBeHidden
|
|
|
|
if videoNode.ownsContentNode {
|
|
if isAnimated {
|
|
videoNode.seek(0.0)
|
|
videoNode.play()
|
|
} else if self.shouldAutoplayOnCentrality() {
|
|
self.initiallyActivated = true
|
|
videoNode.playOnceWithSound(playAndRecord: false, actionAtEnd: self.actionAtEnd)
|
|
|
|
videoNode.setBaseRate(self.playbackRate ?? 1.0)
|
|
}
|
|
} else {
|
|
if isAnimated {
|
|
self.playOnContentOwnership = true
|
|
} else if self.shouldAutoplayOnCentrality() {
|
|
self.playOnContentOwnership = true
|
|
}
|
|
}
|
|
} else {
|
|
self.isPlayingPromise.set(false)
|
|
self.isPlaying = false
|
|
|
|
self.dismissOnOrientationChange = false
|
|
if videoNode.ownsContentNode {
|
|
videoNode.pause()
|
|
}
|
|
}
|
|
}
|
|
|
|
if #available(iOS 15.0, *) {
|
|
if let nativePictureInPictureContent = self.nativePictureInPictureContent as? NativePictureInPictureContentImpl {
|
|
nativePictureInPictureContent.updateIsCentral(isCentral: isCentral)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override func visibilityUpdated(isVisible: Bool) {
|
|
super.visibilityUpdated(isVisible: isVisible)
|
|
|
|
if self._isVisible != isVisible {
|
|
let hadPreviousValue = self._isVisible != nil
|
|
self._isVisible = isVisible
|
|
|
|
if let item = self.item, let videoNode = self.videoNode {
|
|
if hadPreviousValue {
|
|
videoNode.canAttachContent = isVisible
|
|
if isVisible {
|
|
if let currentPictureInPictureNode = self.context.sharedContext.mediaManager.currentPictureInPictureNode as? UniversalVideoGalleryItemNode, let currentItem = currentPictureInPictureNode.item, case let .message(currentMessage, _) = currentItem.contentInfo, case let .message(message, _) = item.contentInfo, currentMessage.id == message.id {
|
|
self.skipInitialPause = true
|
|
}
|
|
|
|
if self.skipInitialPause {
|
|
self.skipInitialPause = false
|
|
} else {
|
|
self.ignorePauseStatus = true
|
|
videoNode.pause()
|
|
videoNode.seek(0.0)
|
|
}
|
|
} else {
|
|
if let status = self.playerStatusValue {
|
|
self.maybeStorePlaybackStatus(status: status)
|
|
}
|
|
videoNode.continuePlayingWithoutSound()
|
|
}
|
|
self.updateDisplayPlaceholder()
|
|
} else if !item.fromPlayingVideo {
|
|
videoNode.canAttachContent = isVisible
|
|
self.updateDisplayPlaceholder()
|
|
}
|
|
if self.shouldAutoplayOnCentrality() {
|
|
self.hideStatusNodeUntilCentrality = true
|
|
self.statusButtonNode.isHidden = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
override func processAction(_ action: GalleryControllerItemNodeAction) {
|
|
guard let videoNode = self.videoNode else {
|
|
return
|
|
}
|
|
|
|
switch action {
|
|
case let .timecode(timecode):
|
|
self.scrubberView?.animateTo(timecode)
|
|
videoNode.seek(timecode)
|
|
}
|
|
}
|
|
|
|
override func activateAsInitial() {
|
|
if let videoNode = self.videoNode, self.isCentral == true, !self.initiallyActivated {
|
|
self.initiallyActivated = true
|
|
|
|
var isAnimated = false
|
|
var seek = MediaPlayerSeek.start
|
|
if let item = self.item {
|
|
if let contentInfo = item.contentInfo, case let .message(message, _) = contentInfo {
|
|
for attribute in message.attributes {
|
|
if let attribute = attribute as? ForwardVideoTimestampAttribute {
|
|
seek = .timecode(Double(attribute.timestamp))
|
|
}
|
|
}
|
|
}
|
|
|
|
if let content = item.content as? NativeVideoContent {
|
|
isAnimated = content.fileReference.media.isAnimated
|
|
if let time = item.timecode {
|
|
seek = .timecode(time)
|
|
}
|
|
} else if let content = item.content as? HLSVideoContent {
|
|
isAnimated = content.fileReference.media.isAnimated
|
|
if let time = item.timecode {
|
|
seek = .timecode(time)
|
|
}
|
|
} else if let _ = item.content as? WebEmbedVideoContent {
|
|
if let time = item.timecode {
|
|
seek = .timecode(time)
|
|
}
|
|
}
|
|
}
|
|
|
|
videoNode.setBaseRate(self.playbackRate ?? 1.0)
|
|
|
|
if isAnimated {
|
|
videoNode.seek(0.0)
|
|
videoNode.play()
|
|
} else {
|
|
self.hideStatusNodeUntilCentrality = false
|
|
self.statusButtonNode.isHidden = self.hideStatusNodeUntilCentrality || self.statusNodeShouldBeHidden
|
|
videoNode.playOnceWithSound(playAndRecord: false, seek: seek, actionAtEnd: self.actionAtEnd)
|
|
|
|
Queue.mainQueue().after(1.0, {
|
|
if let item = self.item, item.isSecret, !self.isPlaying {
|
|
videoNode.playOnceWithSound(playAndRecord: false, seek: .start, actionAtEnd: self.actionAtEnd)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
private func maybeStorePlaybackStatus(status: MediaPlayerStatus) {
|
|
guard let item = self.item else {
|
|
return
|
|
}
|
|
guard let contentInfo = item.contentInfo, case let .message(message, _) = contentInfo else {
|
|
return
|
|
}
|
|
|
|
let shouldStorePlaybacksState: Bool
|
|
shouldStorePlaybacksState = status.duration >= 20.0
|
|
|
|
if shouldStorePlaybacksState {
|
|
var timestamp: Double?
|
|
if status.timestamp > 5.0 && status.timestamp < status.duration - 5.0 {
|
|
timestamp = status.timestamp
|
|
} else {
|
|
timestamp = 0.0
|
|
}
|
|
item.storeMediaPlaybackState(message.id, timestamp, status.baseRate)
|
|
} else {
|
|
item.storeMediaPlaybackState(message.id, nil, status.baseRate)
|
|
}
|
|
}
|
|
|
|
private var actionAtEnd: MediaPlayerPlayOnceWithSoundActionAtEnd {
|
|
if let item = self.item {
|
|
if !item.isSecret, let content = item.content as? NativeVideoContent, content.duration <= 30 {
|
|
return .loop
|
|
}
|
|
if !item.isSecret, let content = item.content as? HLSVideoContent, content.duration <= 30 {
|
|
return .loop
|
|
}
|
|
}
|
|
return .stop
|
|
}
|
|
|
|
override func animateIn(from node: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: (UIView) -> Void, completion: @escaping () -> Void) {
|
|
guard let videoNode = self.videoNode, let validLayout = self.validLayout else {
|
|
return
|
|
}
|
|
|
|
if let node = node.0 as? OverlayMediaItemNode {
|
|
self.customUnembedWhenPortrait = node.customUnembedWhenPortrait
|
|
node.customUnembedWhenPortrait = nil
|
|
}
|
|
|
|
if let node = node.0 as? OverlayMediaItemNode, self.context.sharedContext.mediaManager.hasOverlayVideoNode(node) {
|
|
if let scrubberView = self.scrubberView {
|
|
scrubberView.animateIn(from: nil, transition: .animated(duration: 0.25, curve: .spring))
|
|
}
|
|
|
|
var transformedFrame = node.view.convert(node.view.bounds, to: videoNode.view)
|
|
let transformedSuperFrame = node.view.convert(node.view.bounds, to: videoNode.view.superview)
|
|
|
|
videoNode.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: videoNode.layer.position, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
|
|
|
|
transformedFrame.origin = CGPoint()
|
|
|
|
let transform = CATransform3DScale(videoNode.layer.transform, transformedFrame.size.width / videoNode.layer.bounds.size.width, transformedFrame.size.height / videoNode.layer.bounds.size.height, 1.0)
|
|
videoNode.layer.animate(from: NSValue(caTransform3D: transform), to: NSValue(caTransform3D: videoNode.layer.transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25)
|
|
|
|
videoNode.canAttachContent = true
|
|
self.updateDisplayPlaceholder()
|
|
|
|
self.context.sharedContext.mediaManager.setOverlayVideoNode(nil)
|
|
} else {
|
|
var scrubberTransition = (node.0 as? GalleryItemTransitionNode)?.scrubberTransition()
|
|
|
|
if let data = self.context.currentAppConfiguration.with({ $0 }).data {
|
|
if let value = data["ios_gallery_scrubber_transition"] as? Double {
|
|
if value == 0.0 {
|
|
scrubberTransition = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if let scrubberView = self.scrubberView {
|
|
scrubberView.animateIn(from: scrubberTransition, transition: .animated(duration: 0.25, curve: .spring))
|
|
}
|
|
|
|
var transformedFrame = node.0.view.convert(node.0.view.bounds, to: videoNode.view)
|
|
var transformedSuperFrame = node.0.view.convert(node.0.view.bounds, to: videoNode.view.superview)
|
|
var transformedSelfFrame = node.0.view.convert(node.0.view.bounds, to: self.view)
|
|
let transformedCopyViewFinalFrame = videoNode.view.convert(videoNode.view.bounds, to: self.view)
|
|
|
|
let (maybeSurfaceCopyView, _) = node.2()
|
|
let (maybeCopyView, copyViewBackground) = node.2()
|
|
copyViewBackground?.alpha = 0.0
|
|
let surfaceCopyView = maybeSurfaceCopyView!
|
|
let copyView = maybeCopyView!
|
|
|
|
addToTransitionSurface(surfaceCopyView)
|
|
|
|
var transformedSurfaceFrame: CGRect?
|
|
var transformedSurfaceFinalFrame: CGRect?
|
|
if let contentSurface = surfaceCopyView.superview {
|
|
transformedSurfaceFrame = node.0.view.convert(node.0.view.bounds, to: contentSurface)
|
|
transformedSurfaceFinalFrame = videoNode.view.convert(videoNode.view.bounds, to: contentSurface)
|
|
|
|
if let frame = transformedSurfaceFrame, frame.minY < 0.0 {
|
|
transformedSurfaceFrame = CGRect(x: frame.minX, y: 0.0, width: frame.width, height: frame.height)
|
|
}
|
|
}
|
|
|
|
if transformedSelfFrame.maxY < 0.0 {
|
|
transformedSelfFrame = CGRect(x: transformedSelfFrame.minX, y: 0.0, width: transformedSelfFrame.width, height: transformedSelfFrame.height)
|
|
}
|
|
|
|
if transformedSuperFrame.maxY < 0.0 {
|
|
transformedSuperFrame = CGRect(x: transformedSuperFrame.minX, y: 0.0, width: transformedSuperFrame.width, height: transformedSuperFrame.height)
|
|
}
|
|
|
|
if let transformedSurfaceFrame = transformedSurfaceFrame {
|
|
surfaceCopyView.frame = transformedSurfaceFrame
|
|
}
|
|
|
|
self.view.insertSubview(copyView, belowSubview: self.scrollNode.view)
|
|
copyView.frame = transformedSelfFrame
|
|
|
|
copyView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, removeOnCompletion: false)
|
|
|
|
surfaceCopyView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false)
|
|
|
|
copyView.layer.animatePosition(from: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), to: CGPoint(x: transformedCopyViewFinalFrame.midX, y: transformedCopyViewFinalFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak copyView] _ in
|
|
copyView?.removeFromSuperview()
|
|
})
|
|
let scale = CGSize(width: transformedCopyViewFinalFrame.size.width / transformedSelfFrame.size.width, height: transformedCopyViewFinalFrame.size.height / transformedSelfFrame.size.height)
|
|
copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DIdentity), to: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false)
|
|
|
|
if let transformedSurfaceFrame = transformedSurfaceFrame, let transformedSurfaceFinalFrame = transformedSurfaceFinalFrame {
|
|
surfaceCopyView.layer.animatePosition(from: CGPoint(x: transformedSurfaceFrame.midX, y: transformedSurfaceFrame.midY), to: CGPoint(x: transformedSurfaceFinalFrame.midX, y: transformedSurfaceFinalFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { [weak surfaceCopyView] _ in
|
|
surfaceCopyView?.removeFromSuperview()
|
|
})
|
|
let scale = CGSize(width: transformedSurfaceFinalFrame.size.width / transformedSurfaceFrame.size.width, height: transformedSurfaceFinalFrame.size.height / transformedSurfaceFrame.size.height)
|
|
surfaceCopyView.layer.animate(from: NSValue(caTransform3D: CATransform3DIdentity), to: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false)
|
|
}
|
|
|
|
if surfaceCopyView.superview != nil {
|
|
videoNode.allowsGroupOpacity = true
|
|
videoNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, completion: { [weak videoNode] _ in
|
|
videoNode?.allowsGroupOpacity = false
|
|
})
|
|
}
|
|
videoNode.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: videoNode.layer.position, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
|
|
|
|
transformedFrame.origin = CGPoint()
|
|
|
|
let transform = CATransform3DScale(videoNode.layer.transform, transformedFrame.size.width / videoNode.layer.bounds.size.width, transformedFrame.size.height / videoNode.layer.bounds.size.height, 1.0)
|
|
|
|
videoNode.layer.animate(from: NSValue(caTransform3D: transform), to: NSValue(caTransform3D: videoNode.layer.transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25)
|
|
|
|
if let scrubberTransition, let contentTransition = scrubberTransition.content {
|
|
let transitionContentView = contentTransition.makeView()
|
|
let transitionSelfContentView = contentTransition.makeView()
|
|
|
|
addToTransitionSurface(transitionContentView)
|
|
self.view.insertSubview(transitionSelfContentView, at: 0)
|
|
transitionSelfContentView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2, removeOnCompletion: false)
|
|
|
|
if let transitionContentSuperview = transitionContentView.superview {
|
|
let transitionContentSourceFrame = contentTransition.sourceView.convert(contentTransition.sourceRect, to: transitionContentSuperview)
|
|
let transitionContentDestinationFrame = self.view.convert(self.view.bounds, to: transitionContentSuperview)
|
|
|
|
let transitionContentSelfSourceFrame = contentTransition.sourceView.convert(contentTransition.sourceRect, to: self.view)
|
|
let transitionContentSelfDestinationFrame = self.view.convert(self.view.bounds, to: self.view)
|
|
|
|
let screenCornerRadius: CGFloat = validLayout.layout.deviceMetrics.screenCornerRadius
|
|
|
|
transitionContentView.frame = transitionContentSourceFrame
|
|
contentTransition.updateView(transitionContentView, GalleryItemScrubberTransition.Content.TransitionState(sourceSize: transitionContentSourceFrame.size, destinationSize: transitionContentDestinationFrame.size, destinationCornerRadius: screenCornerRadius, progress: 0.0), .immediate)
|
|
|
|
transitionSelfContentView.frame = transitionContentSelfSourceFrame
|
|
contentTransition.updateView(transitionSelfContentView, GalleryItemScrubberTransition.Content.TransitionState(sourceSize: transitionContentSelfSourceFrame.size, destinationSize: transitionContentSelfDestinationFrame.size, destinationCornerRadius: screenCornerRadius, progress: 0.0), .immediate)
|
|
|
|
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
|
|
|
|
transition.updateFrame(view: transitionContentView, frame: transitionContentDestinationFrame, completion: { [weak transitionContentView] _ in
|
|
transitionContentView?.removeFromSuperview()
|
|
})
|
|
contentTransition.updateView(transitionContentView, GalleryItemScrubberTransition.Content.TransitionState(sourceSize: transitionContentSourceFrame.size, destinationSize: transitionContentDestinationFrame.size, destinationCornerRadius: screenCornerRadius, progress: 1.0), transition)
|
|
|
|
transition.updateFrame(view: transitionSelfContentView, frame: transitionContentSelfDestinationFrame, completion: { [weak transitionSelfContentView] _ in
|
|
transitionSelfContentView?.removeFromSuperview()
|
|
})
|
|
contentTransition.updateView(transitionSelfContentView, GalleryItemScrubberTransition.Content.TransitionState(sourceSize: transitionContentSelfSourceFrame.size, destinationSize: transitionContentSelfDestinationFrame.size, destinationCornerRadius: screenCornerRadius, progress: 1.0), transition)
|
|
}
|
|
}
|
|
|
|
if self.item?.fromPlayingVideo ?? false {
|
|
Queue.mainQueue().after(0.001) {
|
|
videoNode.canAttachContent = true
|
|
self.updateDisplayPlaceholder()
|
|
}
|
|
}
|
|
|
|
if let pictureInPictureNode = self.pictureInPictureNode {
|
|
let transformedPlaceholderFrame = node.0.view.convert(node.0.view.bounds, to: pictureInPictureNode.view)
|
|
let transform = CATransform3DScale(pictureInPictureNode.layer.transform, transformedPlaceholderFrame.size.width / pictureInPictureNode.layer.bounds.size.width, transformedPlaceholderFrame.size.height / pictureInPictureNode.layer.bounds.size.height, 1.0)
|
|
pictureInPictureNode.layer.animate(from: NSValue(caTransform3D: transform), to: NSValue(caTransform3D: pictureInPictureNode.layer.transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25)
|
|
|
|
pictureInPictureNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1)
|
|
pictureInPictureNode.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: pictureInPictureNode.layer.position, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
|
|
}
|
|
|
|
self.statusButtonNode.layer.animatePosition(from: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), to: self.statusButtonNode.position, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
|
|
self.statusButtonNode.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
|
|
self.statusButtonNode.layer.animateScale(from: 0.5, to: 1.0, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
|
|
}
|
|
}
|
|
|
|
override func animateOut(to node: (ASDisplayNode, CGRect, () -> (UIView?, UIView?)), addToTransitionSurface: (UIView) -> Void, completion: @escaping () -> Void) {
|
|
if let status = self.playerStatusValue {
|
|
self.maybeStorePlaybackStatus(status: status)
|
|
}
|
|
|
|
self.isAnimatingOut = true
|
|
|
|
guard let videoNode = self.videoNode else {
|
|
completion()
|
|
return
|
|
}
|
|
|
|
var scrubberTransition = (node.0 as? GalleryItemTransitionNode)?.scrubberTransition()
|
|
if let data = self.context.currentAppConfiguration.with({ $0 }).data {
|
|
if let value = data["ios_gallery_scrubber_transition"] as? Double {
|
|
if value == 0.0 {
|
|
scrubberTransition = nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if let scrubberView = self.scrubberView {
|
|
var scrubberEffectiveTransition = scrubberTransition
|
|
if !self.controlsVisibility() {
|
|
scrubberEffectiveTransition = nil
|
|
}
|
|
scrubberView.animateOut(to: scrubberEffectiveTransition, transition: .animated(duration: 0.25, curve: .spring))
|
|
}
|
|
|
|
let transformedFrame = node.0.view.convert(node.0.view.bounds, to: videoNode.view)
|
|
var transformedSuperFrame = node.0.view.convert(node.0.view.bounds, to: videoNode.view.superview)
|
|
let transformedSelfFrame = node.0.view.convert(node.0.view.bounds, to: self.view)
|
|
let transformedCopyViewInitialFrame = videoNode.view.convert(videoNode.view.bounds, to: self.view)
|
|
|
|
var positionCompleted = false
|
|
var transformCompleted = false
|
|
var boundsCompleted = true
|
|
var copyCompleted = false
|
|
|
|
let (maybeSurfaceCopyView, _) = node.2()
|
|
let (maybeCopyView, copyViewBackground) = node.2()
|
|
copyViewBackground?.alpha = 0.0
|
|
let surfaceCopyView = maybeSurfaceCopyView!
|
|
let copyView = maybeCopyView!
|
|
|
|
addToTransitionSurface(surfaceCopyView)
|
|
|
|
var transformedSurfaceFrame: CGRect?
|
|
var transformedSurfaceCopyViewInitialFrame: CGRect?
|
|
if let contentSurface = surfaceCopyView.superview {
|
|
transformedSurfaceFrame = node.0.view.convert(node.0.view.bounds, to: contentSurface)
|
|
transformedSurfaceCopyViewInitialFrame = videoNode.view.convert(videoNode.view.bounds, to: contentSurface)
|
|
}
|
|
|
|
self.view.insertSubview(copyView, belowSubview: self.scrollNode.view)
|
|
copyView.frame = transformedSelfFrame
|
|
|
|
let intermediateCompletion = { [weak copyView, weak surfaceCopyView] in
|
|
if positionCompleted && transformCompleted && boundsCompleted && copyCompleted {
|
|
copyView?.removeFromSuperview()
|
|
surfaceCopyView?.removeFromSuperview()
|
|
videoNode.canAttachContent = false
|
|
videoNode.removeFromSupernode()
|
|
completion()
|
|
}
|
|
}
|
|
|
|
copyView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false)
|
|
surfaceCopyView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, removeOnCompletion: false)
|
|
|
|
copyView.layer.animatePosition(from: CGPoint(x: transformedCopyViewInitialFrame.midX, y: transformedCopyViewInitialFrame.midY), to: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
|
|
let scale = CGSize(width: transformedCopyViewInitialFrame.size.width / transformedSelfFrame.size.width, height: transformedCopyViewInitialFrame.size.height / transformedSelfFrame.size.height)
|
|
copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), to: NSValue(caTransform3D: CATransform3DIdentity), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
|
|
copyCompleted = true
|
|
intermediateCompletion()
|
|
})
|
|
|
|
if let transformedSurfaceFrame = transformedSurfaceFrame, let transformedCopyViewInitialFrame = transformedSurfaceCopyViewInitialFrame {
|
|
surfaceCopyView.layer.animatePosition(from: CGPoint(x: transformedCopyViewInitialFrame.midX, y: transformedCopyViewInitialFrame.midY), to: CGPoint(x: transformedSurfaceFrame.midX, y: transformedSurfaceFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
|
|
let scale = CGSize(width: transformedCopyViewInitialFrame.size.width / transformedSurfaceFrame.size.width, height: transformedCopyViewInitialFrame.size.height / transformedSurfaceFrame.size.height)
|
|
surfaceCopyView.layer.animate(from: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), to: NSValue(caTransform3D: CATransform3DIdentity), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false)
|
|
}
|
|
|
|
self.statusButtonNode.layer.animatePosition(from: self.statusButtonNode.layer.position, to: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
|
|
})
|
|
self.statusButtonNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false)
|
|
self.statusButtonNode.layer.animateScale(from: 1.0, to: 0.2, duration: 0.25, removeOnCompletion: false)
|
|
|
|
let fromTransform: CATransform3D
|
|
let toTransform: CATransform3D
|
|
|
|
if let instantNode = node.0 as? GalleryItemTransitionNode, instantNode.isAvailableForInstantPageTransition(), videoNode.hasAttachedContext {
|
|
copyView.removeFromSuperview()
|
|
|
|
let previousFrame = videoNode.frame
|
|
let previousSuperview = videoNode.view.superview
|
|
addToTransitionSurface(videoNode.view)
|
|
videoNode.view.superview?.bringSubviewToFront(videoNode.view)
|
|
|
|
if let previousSuperview = previousSuperview {
|
|
videoNode.frame = previousSuperview.convert(previousFrame, to: videoNode.view.superview)
|
|
transformedSuperFrame = transformedSuperFrame.offsetBy(dx: videoNode.position.x - previousFrame.center.x, dy: videoNode.position.y - previousFrame.center.y)
|
|
}
|
|
|
|
let initialScale: CGFloat = 1.0
|
|
let targetScale = max(transformedFrame.size.width / videoNode.layer.bounds.size.width, transformedFrame.size.height / videoNode.layer.bounds.size.height)
|
|
|
|
videoNode.backgroundColor = .clear
|
|
|
|
let transformScale: CGFloat = initialScale * targetScale
|
|
fromTransform = CATransform3DScale(videoNode.layer.transform, initialScale, initialScale, 1.0)
|
|
toTransform = CATransform3DScale(videoNode.layer.transform, transformScale, transformScale, 1.0)
|
|
|
|
if videoNode.hasAttachedContext {
|
|
if self.isPaused || !self.keepSoundOnDismiss {
|
|
videoNode.continuePlayingWithoutSound()
|
|
}
|
|
}
|
|
} else if let interactiveMediaNode = node.0 as? GalleryItemTransitionNode, interactiveMediaNode.isAvailableForGalleryTransition(), videoNode.hasAttachedContext {
|
|
copyView.removeFromSuperview()
|
|
|
|
let previousFrame = videoNode.frame
|
|
let previousSuperview = videoNode.view.superview
|
|
addToTransitionSurface(videoNode.view)
|
|
videoNode.view.superview?.bringSubviewToFront(videoNode.view)
|
|
|
|
if let previousSuperview = previousSuperview {
|
|
videoNode.frame = previousSuperview.convert(previousFrame, to: videoNode.view.superview)
|
|
transformedSuperFrame = transformedSuperFrame.offsetBy(dx: videoNode.position.x - previousFrame.center.x, dy: videoNode.position.y - previousFrame.center.y)
|
|
}
|
|
|
|
let initialScale = min(videoNode.layer.bounds.width / node.0.view.bounds.width, videoNode.layer.bounds.height / node.0.view.bounds.height)
|
|
let targetScale = max(transformedFrame.size.width / videoNode.layer.bounds.size.width, transformedFrame.size.height / videoNode.layer.bounds.size.height)
|
|
|
|
videoNode.backgroundColor = .clear
|
|
if let bubbleDecoration = interactiveMediaNode.decoration as? ChatBubbleVideoDecoration, let decoration = videoNode.decoration as? GalleryVideoDecoration {
|
|
transformedSuperFrame = transformedSuperFrame.offsetBy(dx: bubbleDecoration.corners.extendedEdges.right / 2.0 - bubbleDecoration.corners.extendedEdges.left / 2.0, dy: 0.0)
|
|
if let item = self.item {
|
|
let size = item.content.dimensions.aspectFilled(bubbleDecoration.contentContainerNode.frame.size)
|
|
videoNode.updateLayout(size: size, transition: .immediate)
|
|
videoNode.bounds = CGRect(origin: CGPoint(), size: size)
|
|
|
|
boundsCompleted = false
|
|
decoration.updateCorners(bubbleDecoration.corners)
|
|
decoration.updateClippingFrame(bubbleDecoration.contentContainerNode.bounds, completion: {
|
|
boundsCompleted = true
|
|
intermediateCompletion()
|
|
})
|
|
}
|
|
}
|
|
|
|
let transformScale: CGFloat = initialScale * targetScale
|
|
fromTransform = CATransform3DScale(videoNode.layer.transform, initialScale, initialScale, 1.0)
|
|
toTransform = CATransform3DScale(videoNode.layer.transform, transformScale, transformScale, 1.0)
|
|
|
|
if videoNode.hasAttachedContext {
|
|
if let status = self.playerStatusValue {
|
|
self.maybeStorePlaybackStatus(status: status)
|
|
}
|
|
|
|
if self.isPaused || !self.keepSoundOnDismiss {
|
|
if let item = self.item, item.content is HLSVideoContent {
|
|
} else {
|
|
videoNode.continuePlayingWithoutSound()
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
videoNode.allowsGroupOpacity = true
|
|
videoNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false, completion: { [weak videoNode] _ in
|
|
videoNode?.allowsGroupOpacity = false
|
|
})
|
|
|
|
fromTransform = videoNode.layer.transform
|
|
toTransform = CATransform3DScale(videoNode.layer.transform, transformedFrame.size.width / videoNode.layer.bounds.size.width, transformedFrame.size.height / videoNode.layer.bounds.size.height, 1.0)
|
|
}
|
|
|
|
videoNode.layer.animatePosition(from: videoNode.layer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
|
|
positionCompleted = true
|
|
intermediateCompletion()
|
|
})
|
|
|
|
videoNode.layer.animate(from: NSValue(caTransform3D: fromTransform), to: NSValue(caTransform3D: toTransform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
|
|
transformCompleted = true
|
|
intermediateCompletion()
|
|
})
|
|
|
|
var scrubberContentTransition = scrubberTransition
|
|
if !self.controlsVisibility() {
|
|
scrubberContentTransition = nil
|
|
}
|
|
if let scrubberContentTransition, let contentTransition = scrubberContentTransition.content {
|
|
let transitionContentView = contentTransition.makeView()
|
|
let transitionSelfContentView = contentTransition.makeView()
|
|
|
|
addToTransitionSurface(transitionContentView)
|
|
//self.view.insertSubview(transitionSelfContentView, at: 0)
|
|
transitionSelfContentView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.18, removeOnCompletion: false)
|
|
|
|
if let validLayout = self.validLayout, let transitionContentSuperview = transitionContentView.superview {
|
|
let transitionContentSourceFrame = contentTransition.sourceView.convert(contentTransition.sourceRect, to: transitionContentSuperview)
|
|
let transitionContentDestinationFrame = self.view.convert(self.view.bounds, to: transitionContentSuperview)
|
|
|
|
let transitionContentSelfSourceFrame = contentTransition.sourceView.convert(contentTransition.sourceRect, to: self.view)
|
|
let transitionContentSelfDestinationFrame = self.view.convert(self.view.bounds, to: self.view)
|
|
|
|
let screenCornerRadius: CGFloat = validLayout.layout.deviceMetrics.screenCornerRadius
|
|
|
|
transitionContentView.frame = transitionContentDestinationFrame
|
|
contentTransition.updateView(transitionContentView, GalleryItemScrubberTransition.Content.TransitionState(sourceSize: transitionContentSourceFrame.size, destinationSize: transitionContentDestinationFrame.size, destinationCornerRadius: screenCornerRadius, progress: 1.0), .immediate)
|
|
|
|
transitionSelfContentView.frame = transitionContentSelfDestinationFrame
|
|
contentTransition.updateView(transitionSelfContentView, GalleryItemScrubberTransition.Content.TransitionState(sourceSize: transitionContentSelfSourceFrame.size, destinationSize: transitionContentSelfDestinationFrame.size, destinationCornerRadius: screenCornerRadius, progress: 1.0), .immediate)
|
|
|
|
let transition: ContainedViewLayoutTransition = .animated(duration: 0.25, curve: .spring)
|
|
|
|
transition.updateFrame(view: transitionContentView, frame: transitionContentSourceFrame, completion: { [weak transitionContentView] _ in
|
|
transitionContentView?.removeFromSuperview()
|
|
})
|
|
contentTransition.updateView(transitionContentView, GalleryItemScrubberTransition.Content.TransitionState(sourceSize: transitionContentSourceFrame.size, destinationSize: transitionContentDestinationFrame.size, destinationCornerRadius: screenCornerRadius, progress: 0.0), transition)
|
|
|
|
transition.updateFrame(view: transitionSelfContentView, frame: transitionContentSelfSourceFrame, completion: { [weak transitionSelfContentView] _ in
|
|
transitionSelfContentView?.removeFromSuperview()
|
|
})
|
|
contentTransition.updateView(transitionSelfContentView, GalleryItemScrubberTransition.Content.TransitionState(sourceSize: transitionContentSelfSourceFrame.size, destinationSize: transitionContentSelfDestinationFrame.size, destinationCornerRadius: screenCornerRadius, progress: 0.0), transition)
|
|
}
|
|
}
|
|
|
|
if let pictureInPictureNode = self.pictureInPictureNode {
|
|
let transformedPlaceholderFrame = node.0.view.convert(node.0.view.bounds, to: pictureInPictureNode.view)
|
|
let pictureInPictureTransform = CATransform3DScale(pictureInPictureNode.layer.transform, transformedPlaceholderFrame.size.width / pictureInPictureNode.layer.bounds.size.width, transformedPlaceholderFrame.size.height / pictureInPictureNode.layer.bounds.size.height, 1.0)
|
|
pictureInPictureNode.layer.animate(from: NSValue(caTransform3D: pictureInPictureNode.layer.transform), to: NSValue(caTransform3D: pictureInPictureTransform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
|
|
})
|
|
|
|
pictureInPictureNode.layer.animatePosition(from: pictureInPictureNode.layer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
|
|
positionCompleted = true
|
|
intermediateCompletion()
|
|
})
|
|
pictureInPictureNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, removeOnCompletion: false)
|
|
}
|
|
}
|
|
|
|
func animateOut(toOverlay node: ASDisplayNode, completion: @escaping () -> Void) {
|
|
guard let videoNode = self.videoNode else {
|
|
completion()
|
|
return
|
|
}
|
|
|
|
var transformedFrame = node.view.convert(node.view.bounds, to: videoNode.view)
|
|
let transformedSuperFrame = node.view.convert(node.view.bounds, to: videoNode.view.superview)
|
|
let transformedSelfFrame = node.view.convert(node.view.bounds, to: self.view)
|
|
let transformedCopyViewInitialFrame = videoNode.view.convert(videoNode.view.bounds, to: self.view)
|
|
let transformedSelfTargetSuperFrame = videoNode.view.convert(videoNode.view.bounds, to: node.view.superview)
|
|
|
|
var positionCompleted = false
|
|
var boundsCompleted = false
|
|
var copyCompleted = false
|
|
var nodeCompleted = false
|
|
|
|
let copyView = node.view.snapshotContentTree()!
|
|
|
|
videoNode.isHidden = true
|
|
copyView.frame = transformedSelfFrame
|
|
|
|
let intermediateCompletion = { [weak copyView] in
|
|
if positionCompleted && boundsCompleted && copyCompleted && nodeCompleted {
|
|
copyView?.removeFromSuperview()
|
|
completion()
|
|
}
|
|
}
|
|
|
|
copyView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.1, removeOnCompletion: false)
|
|
|
|
copyView.layer.animatePosition(from: CGPoint(x: transformedCopyViewInitialFrame.midX, y: transformedCopyViewInitialFrame.midY), to: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false)
|
|
let scale = CGSize(width: transformedCopyViewInitialFrame.size.width / transformedSelfFrame.size.width, height: transformedCopyViewInitialFrame.size.height / transformedSelfFrame.size.height)
|
|
copyView.layer.animate(from: NSValue(caTransform3D: CATransform3DMakeScale(scale.width, scale.height, 1.0)), to: NSValue(caTransform3D: CATransform3DIdentity), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
|
|
copyCompleted = true
|
|
intermediateCompletion()
|
|
})
|
|
|
|
videoNode.layer.animatePosition(from: videoNode.layer.position, to: CGPoint(x: transformedSuperFrame.midX, y: transformedSuperFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
|
|
positionCompleted = true
|
|
intermediateCompletion()
|
|
})
|
|
|
|
videoNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false)
|
|
|
|
self.statusButtonNode.layer.animatePosition(from: self.statusButtonNode.layer.position, to: CGPoint(x: transformedSelfFrame.midX, y: transformedSelfFrame.midY), duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring, removeOnCompletion: false, completion: { _ in
|
|
})
|
|
self.statusButtonNode.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false)
|
|
self.statusButtonNode.layer.animateScale(from: 1.0, to: 0.2, duration: 0.25, removeOnCompletion: false)
|
|
|
|
transformedFrame.origin = CGPoint()
|
|
|
|
let videoTransform = CATransform3DScale(videoNode.layer.transform, transformedFrame.size.width / videoNode.layer.bounds.size.width, transformedFrame.size.height / videoNode.layer.bounds.size.height, 1.0)
|
|
videoNode.layer.animate(from: NSValue(caTransform3D: videoNode.layer.transform), to: NSValue(caTransform3D: videoTransform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
|
|
boundsCompleted = true
|
|
intermediateCompletion()
|
|
})
|
|
|
|
if let pictureInPictureNode = self.pictureInPictureNode {
|
|
pictureInPictureNode.isHidden = true
|
|
}
|
|
|
|
let nodeTransform = CATransform3DScale(node.layer.transform, videoNode.layer.bounds.size.width / transformedFrame.size.width, videoNode.layer.bounds.size.height / transformedFrame.size.height, 1.0)
|
|
node.layer.animatePosition(from: CGPoint(x: transformedSelfTargetSuperFrame.midX, y: transformedSelfTargetSuperFrame.midY), to: node.layer.position, duration: 0.25, timingFunction: kCAMediaTimingFunctionSpring)
|
|
node.layer.animate(from: NSValue(caTransform3D: nodeTransform), to: NSValue(caTransform3D: node.layer.transform), keyPath: "transform", timingFunction: kCAMediaTimingFunctionSpring, duration: 0.25, removeOnCompletion: false, completion: { _ in
|
|
nodeCompleted = true
|
|
intermediateCompletion()
|
|
})
|
|
}
|
|
|
|
override func maybePerformActionForSwipeDismiss() -> Bool {
|
|
if let data = self.context.currentAppConfiguration.with({ $0 }).data {
|
|
if let _ = data["ios_killswitch_disable_swipe_pip"] {
|
|
return false
|
|
}
|
|
var swipeUpToClose = false
|
|
if let value = data["video_swipe_up_to_close"] as? Double, value == 1.0 {
|
|
swipeUpToClose = true
|
|
} else if let value = data["video_swipe_up_to_close"] as? Bool, value {
|
|
swipeUpToClose = true
|
|
}
|
|
|
|
if swipeUpToClose {
|
|
addAppLogEvent(postbox: self.context.account.postbox, type: "swipe_up_close", peerId: self.context.account.peerId)
|
|
|
|
return false
|
|
}
|
|
}
|
|
|
|
if #available(iOS 15.0, *) {
|
|
if let nativePictureInPictureContent = self.nativePictureInPictureContent as? NativePictureInPictureContentImpl {
|
|
addAppLogEvent(postbox: self.context.account.postbox, type: "swipe_up_pip", peerId: self.context.account.peerId)
|
|
nativePictureInPictureContent.beginPictureInPicture()
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
override func maybePerformActionForSwipeDownDismiss() -> Bool {
|
|
addAppLogEvent(postbox: self.context.account.postbox, type: "swipe_down_close", peerId: self.context.account.peerId)
|
|
return false
|
|
}
|
|
|
|
override func title() -> Signal<String, NoError> {
|
|
return self._title.get()
|
|
}
|
|
|
|
override func titleView() -> Signal<UIView?, NoError> {
|
|
return self._titleView.get()
|
|
}
|
|
|
|
override func rightBarButtonItems() -> Signal<[UIBarButtonItem]?, NoError> {
|
|
return self._rightBarButtonItems.get()
|
|
}
|
|
|
|
@objc func statusButtonPressed() {
|
|
if let videoNode = self.videoNode {
|
|
if let fetchStatus = self.fetchStatus, case .Local = fetchStatus {
|
|
self.toggleControlsVisibility()
|
|
}
|
|
|
|
if let fetchStatus = self.fetchStatus {
|
|
switch fetchStatus {
|
|
case .Local:
|
|
videoNode.playOnceWithSound(playAndRecord: false, seek: .none, actionAtEnd: self.actionAtEnd)
|
|
case .Remote, .Paused:
|
|
if self.requiresDownload {
|
|
self.fetchControls?.fetch()
|
|
} else {
|
|
videoNode.playOnceWithSound(playAndRecord: false, seek: .none, actionAtEnd: self.actionAtEnd)
|
|
}
|
|
case .Fetching:
|
|
self.fetchControls?.cancel()
|
|
}
|
|
} else {
|
|
videoNode.playOnceWithSound(playAndRecord: false, seek: .none, actionAtEnd: self.actionAtEnd)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func expandIntoCustomPiP() {
|
|
if let item = self.item, let videoNode = self.videoNode, let customUnembedWhenPortrait = customUnembedWhenPortrait {
|
|
self.customUnembedWhenPortrait = nil
|
|
videoNode.setContinuePlayingWithoutSoundOnLostAudioSession(false)
|
|
|
|
let context = self.context
|
|
let baseNavigationController = self.baseNavigationController()
|
|
let mediaManager = self.context.sharedContext.mediaManager
|
|
var expandImpl: (() -> Void)?
|
|
let overlayNode = OverlayUniversalVideoNode(context: self.context, postbox: self.context.account.postbox, audioSession: context.sharedContext.mediaManager.audioSession, manager: context.sharedContext.mediaManager.universalVideoManager, content: item.content, expand: {
|
|
expandImpl?()
|
|
}, close: { [weak mediaManager] in
|
|
mediaManager?.setOverlayVideoNode(nil)
|
|
})
|
|
|
|
let playbackRate = self.playbackRate
|
|
|
|
expandImpl = { [weak overlayNode] in
|
|
guard let contentInfo = item.contentInfo, let overlayNode = overlayNode else {
|
|
return
|
|
}
|
|
|
|
switch contentInfo {
|
|
case let .message(message, _):
|
|
let gallery = GalleryController(context: context, source: .peerMessagesAtId(messageId: message.id, chatLocation: .peer(id: message.id.peerId), customTag: nil, chatLocationContextHolder: Atomic<ChatLocationContextHolder?>(value: nil)), playbackRate: playbackRate, replaceRootController: { controller, ready in
|
|
if let baseNavigationController = baseNavigationController {
|
|
baseNavigationController.replaceTopController(controller, animated: false, ready: ready)
|
|
}
|
|
}, baseNavigationController: baseNavigationController)
|
|
gallery.temporaryDoNotWaitForReady = true
|
|
|
|
baseNavigationController?.view.endEditing(true)
|
|
|
|
(baseNavigationController?.topViewController as? ViewController)?.present(gallery, in: .window(.root), with: GalleryControllerPresentationArguments(transitionArguments: { [weak overlayNode] id, media in
|
|
if let overlayNode = overlayNode, let overlaySupernode = overlayNode.supernode {
|
|
return GalleryTransitionArguments(transitionNode: (overlayNode, overlayNode.bounds, { [weak overlayNode] in
|
|
return (overlayNode?.view.snapshotContentTree(), nil)
|
|
}), addToTransitionSurface: { [weak context, weak overlaySupernode, weak overlayNode] view in
|
|
guard let context = context, let overlayNode = overlayNode else {
|
|
return
|
|
}
|
|
if context.sharedContext.mediaManager.hasOverlayVideoNode(overlayNode) {
|
|
overlaySupernode?.view.addSubview(view)
|
|
}
|
|
overlayNode.canAttachContent = false
|
|
})
|
|
} else if let info = context.sharedContext.mediaManager.galleryHiddenMediaManager.findTarget(messageId: id, media: media) {
|
|
return GalleryTransitionArguments(transitionNode: (info.1, info.1.bounds, {
|
|
return info.2()
|
|
}), addToTransitionSurface: info.0)
|
|
}
|
|
return nil
|
|
}))
|
|
case let .webPage(_, _, expandFromPip):
|
|
if let expandFromPip = expandFromPip, let baseNavigationController = baseNavigationController {
|
|
expandFromPip({ [weak overlayNode] in
|
|
if let overlayNode = overlayNode, let overlaySupernode = overlayNode.supernode {
|
|
return GalleryTransitionArguments(transitionNode: (overlayNode, overlayNode.bounds, { [weak overlayNode] in
|
|
return (overlayNode?.view.snapshotContentTree(), nil)
|
|
}), addToTransitionSurface: { [weak context, weak overlaySupernode, weak overlayNode] view in
|
|
guard let context = context, let overlayNode = overlayNode else {
|
|
return
|
|
}
|
|
if context.sharedContext.mediaManager.hasOverlayVideoNode(overlayNode) {
|
|
overlaySupernode?.view.addSubview(view)
|
|
}
|
|
overlayNode.canAttachContent = false
|
|
})
|
|
}
|
|
return nil
|
|
}, baseNavigationController, { [weak baseNavigationController] c, a in
|
|
(baseNavigationController?.topViewController as? ViewController)?.present(c, in: .window(.root), with: a)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
if customUnembedWhenPortrait(overlayNode) {
|
|
self.beginCustomDismiss(.default)
|
|
self.statusNode.isHidden = true
|
|
self.animateOut(toOverlay: overlayNode, completion: { [weak self] in
|
|
self?.completeCustomDismiss(false)
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
private func setupNativePictureInPicture() {
|
|
guard let item = self.item, let videoNode = self.videoNode else {
|
|
return
|
|
}
|
|
|
|
if videoNode.getVideoLayer() == nil {
|
|
return
|
|
}
|
|
|
|
var useNative = true
|
|
if let data = self.context.currentAppConfiguration.with({ $0 }).data, let _ = data["ios_killswitch_disable_native_pip_v2"] {
|
|
useNative = false
|
|
}
|
|
var isAd = false
|
|
if let contentInfo = item.contentInfo {
|
|
switch contentInfo {
|
|
case let .message(message, _):
|
|
isAd = message.adAttribute != nil
|
|
self.footerContentNode.setMessage(message, displayInfo: !item.displayInfoOnTop, peerIsCopyProtected: item.peerIsCopyProtected)
|
|
case let .webPage(webPage, media, _):
|
|
self.footerContentNode.setWebPage(webPage, media: media)
|
|
}
|
|
}
|
|
if isAd {
|
|
useNative = false
|
|
}
|
|
if let content = item.content as? NativeVideoContent {
|
|
if content.fileReference.media.isAnimated {
|
|
useNative = false
|
|
}
|
|
}
|
|
if !useNative {
|
|
return
|
|
}
|
|
|
|
var hiddenMedia: (MessageId, Media)? = nil
|
|
switch item.contentInfo {
|
|
case let .message(message, _):
|
|
for media in message.media {
|
|
if let media = media as? TelegramMediaImage {
|
|
hiddenMedia = (message.id, media)
|
|
} else if let media = media as? TelegramMediaFile, media.isVideo {
|
|
hiddenMedia = (message.id, media)
|
|
}
|
|
}
|
|
default:
|
|
break
|
|
}
|
|
|
|
if #available(iOS 15.0, *) {
|
|
var didExpand = false
|
|
let content = NativePictureInPictureContentImpl(context: self.context, mediaManager: self.context.sharedContext.mediaManager, accountId: self.context.account.id, hiddenMedia: hiddenMedia, videoNode: videoNode, canSkip: true, willBegin: { [weak self] content in
|
|
guard let self, let controller = self.galleryController(), let navigationController = self.baseNavigationController() else {
|
|
return
|
|
}
|
|
|
|
self.activePictureInPictureNavigationController = navigationController
|
|
self.activePictureInPictureController = controller
|
|
self.context.sharedContext.mediaManager.currentPictureInPictureNode = self
|
|
|
|
self.beginCustomDismiss(.pip)
|
|
controller.view.alpha = 0.0
|
|
controller.view.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.2, completion: { [weak self] _ in
|
|
self?.completeCustomDismiss(true)
|
|
})
|
|
if let videoNode = self.videoNode {
|
|
videoNode.setNativePictureInPictureIsActive(false)
|
|
}
|
|
didExpand = false
|
|
}, didBegin: { [weak self] _ in
|
|
guard let self else {
|
|
return
|
|
}
|
|
let _ = self
|
|
}, didEnd: { [weak self] _ in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
if self.context.sharedContext.mediaManager.currentPictureInPictureNode === self {
|
|
self.context.sharedContext.mediaManager.currentPictureInPictureNode = nil
|
|
}
|
|
|
|
if let videoNode = self.videoNode {
|
|
videoNode.setNativePictureInPictureIsActive(false)
|
|
}
|
|
|
|
if !didExpand {
|
|
self.activePictureInPictureController = nil
|
|
self.activePictureInPictureNavigationController = nil
|
|
|
|
addAppLogEvent(postbox: self.context.account.postbox, type: "pip_close_btn", peerId: self.context.account.peerId)
|
|
}
|
|
}, expand: { [weak self] completion in
|
|
didExpand = true
|
|
|
|
guard let self, let activePictureInPictureController = self.activePictureInPictureController, let activePictureInPictureNavigationController = self.activePictureInPictureNavigationController else {
|
|
completion()
|
|
return
|
|
}
|
|
|
|
self.activePictureInPictureController = nil
|
|
self.activePictureInPictureNavigationController = nil
|
|
|
|
let previousPresentationArguments = activePictureInPictureController.presentationArguments
|
|
activePictureInPictureController.presentationArguments = nil
|
|
activePictureInPictureNavigationController.currentWindow?.present(activePictureInPictureController, on: .root, blockInteraction: false, completion: {
|
|
})
|
|
activePictureInPictureController.presentationArguments = previousPresentationArguments
|
|
self.updateControlsVisibility(false)
|
|
|
|
activePictureInPictureController.view.alpha = 1.0
|
|
activePictureInPictureController.view.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.35, completion: { _ in
|
|
})
|
|
|
|
completion()
|
|
})
|
|
|
|
self.nativePictureInPictureContent = content
|
|
}
|
|
}
|
|
|
|
@objc func pictureInPictureButtonPressed() {
|
|
if self.nativePictureInPictureContent == nil {
|
|
self.setupNativePictureInPicture()
|
|
}
|
|
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
if let currentPictureInPictureNode = self.context.sharedContext.mediaManager.currentPictureInPictureNode as? UniversalVideoGalleryItemNode, let currentItem = currentPictureInPictureNode.item, case let .message(currentMessage, _) = currentItem.contentInfo, case let .message(message, _) = self.item?.contentInfo, currentMessage.id == message.id {
|
|
if let controller = self.galleryController() as? GalleryController {
|
|
controller.dismiss(forceAway: true)
|
|
}
|
|
return
|
|
}
|
|
|
|
if #available(iOS 15.0, *) {
|
|
if let nativePictureInPictureContent = self.nativePictureInPictureContent as? NativePictureInPictureContentImpl {
|
|
addAppLogEvent(postbox: self.context.account.postbox, type: "pip_btn", peerId: self.context.account.peerId)
|
|
nativePictureInPictureContent.beginPictureInPicture()
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func expandPIP() {
|
|
if #available(iOS 15.0, *) {
|
|
if let nativePictureInPictureContent = self.nativePictureInPictureContent as? NativePictureInPictureContentImpl {
|
|
nativePictureInPictureContent.requestExpand()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func contentInfo() -> (message: Message, file: TelegramMediaFile?, isWebpage: Bool)? {
|
|
guard let item = self.item else {
|
|
return nil
|
|
}
|
|
if let contentInfo = item.contentInfo, case let .message(message, mediaIndex) = contentInfo {
|
|
var file: TelegramMediaFile?
|
|
var isWebpage = false
|
|
for m in message.media {
|
|
if let paidContent = m as? TelegramMediaPaidContent {
|
|
let media = paidContent.extendedMedia[mediaIndex ?? 0]
|
|
if case let .full(fullMedia) = media, let fullMedia = fullMedia as? TelegramMediaFile, fullMedia.isVideo {
|
|
file = fullMedia
|
|
}
|
|
break
|
|
} else if let m = m as? TelegramMediaFile, m.isVideo {
|
|
file = m
|
|
break
|
|
} else if let m = m as? TelegramMediaWebpage, case let .Loaded(content) = m.content {
|
|
if let f = content.file, f.isVideo {
|
|
file = f
|
|
}
|
|
isWebpage = true
|
|
break
|
|
}
|
|
}
|
|
return (message, file, isWebpage)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func canDelete() -> Bool {
|
|
guard let (message, _, _) = self.contentInfo() else {
|
|
return false
|
|
}
|
|
|
|
var canDelete = false
|
|
if let peer = message.peers[message.id.peerId] {
|
|
if peer is TelegramUser || peer is TelegramSecretChat {
|
|
canDelete = true
|
|
} else if let _ = peer as? TelegramGroup {
|
|
canDelete = true
|
|
} else if let channel = peer as? TelegramChannel {
|
|
if message.flags.contains(.Incoming) {
|
|
canDelete = channel.hasPermission(.deleteAllMessages)
|
|
} else {
|
|
canDelete = true
|
|
}
|
|
} else {
|
|
canDelete = false
|
|
}
|
|
} else {
|
|
canDelete = false
|
|
}
|
|
return canDelete
|
|
}
|
|
|
|
@objc private func moreButtonPressed() {
|
|
self.moreBarButton.play()
|
|
self.moreBarButton.contextAction?(self.moreBarButton.containerNode, nil)
|
|
}
|
|
|
|
private func openMoreMenu(sourceNode: ContextReferenceContentNode, gesture: ContextGesture?, isSettings: Bool) {
|
|
guard let controller = self.baseNavigationController()?.topViewController as? ViewController else {
|
|
return
|
|
}
|
|
|
|
var dismissImpl: (() -> Void)?
|
|
let items: Signal<(items: [ContextMenuItem], topItems: [ContextMenuItem]), NoError>
|
|
if case let .message(message, _) = self.item?.contentInfo, let _ = message.adAttribute {
|
|
items = self.adMenuMainItems() |> map { items in
|
|
return (items, [])
|
|
}
|
|
} else {
|
|
items = self.contextMenuMainItems(isSettings: isSettings, dismiss: {
|
|
dismissImpl?()
|
|
})
|
|
}
|
|
|
|
let contextController = ContextController(presentationData: self.presentationData.withUpdated(theme: defaultDarkColorPresentationTheme), source: .reference(HeaderContextReferenceContentSource(controller: controller, sourceNode: sourceNode)), items: items |> map { items in
|
|
if !items.topItems.isEmpty {
|
|
return ContextController.Items(id: AnyHashable(0), content: .twoLists(items.items, items.topItems))
|
|
} else {
|
|
return ContextController.Items(id: AnyHashable(0), content: .list(items.items))
|
|
}
|
|
}, gesture: gesture)
|
|
if isSettings {
|
|
self.isShowingSettingsMenuPromise.set(true)
|
|
} else {
|
|
self.isShowingContextMenuPromise.set(true)
|
|
}
|
|
controller.presentInGlobalOverlay(contextController)
|
|
dismissImpl = { [weak contextController] in
|
|
contextController?.dismiss()
|
|
}
|
|
contextController.dismissed = { [weak self] in
|
|
Queue.mainQueue().after(isSettings ? 0.0 : 0.1, {
|
|
if isSettings {
|
|
self?.isShowingSettingsMenuPromise.set(false)
|
|
} else {
|
|
self?.isShowingContextMenuPromise.set(false)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
private func speedList(strings: PresentationStrings) -> [(String, String, Double)] {
|
|
let speedList: [(String, String, Double)] = [
|
|
("0.5x", "0.5x", 0.5),
|
|
(strings.PlaybackSpeed_Normal, "1x", 1.0),
|
|
("1.5x", "1.5x", 1.5),
|
|
("2x", "2x", 2.0)
|
|
]
|
|
|
|
return speedList
|
|
}
|
|
|
|
private func adMenuMainItems() -> Signal<[ContextMenuItem], NoError> {
|
|
guard case let .message(message, _) = self.item?.contentInfo, let adAttribute = message.adAttribute else {
|
|
return .single([])
|
|
}
|
|
|
|
let context = self.context
|
|
let presentationData = self.presentationData
|
|
var actions: [ContextMenuItem] = []
|
|
if adAttribute.canReport {
|
|
actions.append(.action(ContextMenuActionItem(text: presentationData.strings.Chat_ContextMenu_AboutAd, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.actionSheet.primaryTextColor)
|
|
}, iconSource: nil, action: { [weak self] _, f in
|
|
f(.dismissWithoutContent)
|
|
if let navigationController = self?.baseNavigationController() as? NavigationController {
|
|
navigationController.pushViewController(AdsInfoScreen(context: context, mode: .channel, forceDark: true))
|
|
}
|
|
})))
|
|
|
|
actions.append(.action(ContextMenuActionItem(text: presentationData.strings.Chat_ContextMenu_ReportAd, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Restrict"), color: theme.actionSheet.primaryTextColor)
|
|
}, iconSource: nil, action: { [weak self] _, f in
|
|
f(.default)
|
|
|
|
let _ = (context.engine.messages.reportAdMessage(opaqueId: adAttribute.opaqueId, option: nil)
|
|
|> deliverOnMainQueue).start(next: { [weak self] result in
|
|
if case let .options(title, options) = result {
|
|
if let navigationController = self?.baseNavigationController() as? NavigationController {
|
|
navigationController.pushViewController(
|
|
AdsReportScreen(
|
|
context: context,
|
|
opaqueId: adAttribute.opaqueId,
|
|
title: title,
|
|
options: options,
|
|
forceDark: true,
|
|
completed: {
|
|
if let navigationController = self?.baseNavigationController() as? NavigationController, let chatController = navigationController.viewControllers.last as? ChatController {
|
|
chatController.removeAd(opaqueId: adAttribute.opaqueId)
|
|
}
|
|
}
|
|
)
|
|
)
|
|
}
|
|
}
|
|
})
|
|
})))
|
|
|
|
actions.append(.separator)
|
|
|
|
actions.append(.action(ContextMenuActionItem(text: presentationData.strings.Chat_ContextMenu_RemoveAd, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.primaryTextColor)
|
|
}, iconSource: nil, action: { [weak self] c, _ in
|
|
c?.dismiss(completion: {
|
|
var replaceImpl: ((ViewController) -> Void)?
|
|
let controller = context.sharedContext.makePremiumDemoController(context: context, subject: .noAds, forceDark: true, action: {
|
|
let controller = context.sharedContext.makePremiumIntroController(context: context, source: .ads, forceDark: true, dismissed: nil)
|
|
replaceImpl?(controller)
|
|
}, dismissed: nil)
|
|
replaceImpl = { [weak controller] c in
|
|
controller?.replace(with: c)
|
|
}
|
|
if let navigationController = self?.baseNavigationController() as? NavigationController {
|
|
navigationController.pushViewController(controller)
|
|
}
|
|
})
|
|
})))
|
|
} else {
|
|
actions.append(.action(ContextMenuActionItem(text: presentationData.strings.SponsoredMessageMenu_Info, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Info"), color: theme.actionSheet.primaryTextColor)
|
|
}, iconSource: nil, action: { [weak self] _, f in
|
|
f(.dismissWithoutContent)
|
|
if let navigationController = self?.baseNavigationController() as? NavigationController {
|
|
navigationController.pushViewController(AdInfoScreen(context: context, forceDark: true))
|
|
}
|
|
})))
|
|
|
|
let premiumConfiguration = PremiumConfiguration.with(appConfiguration: context.currentAppConfiguration.with { $0 })
|
|
if !context.isPremium && !premiumConfiguration.isPremiumDisabled {
|
|
actions.append(.action(ContextMenuActionItem(text: presentationData.strings.SponsoredMessageMenu_Hide, textColor: .primary, textLayout: .twoLinesMax, textFont: .custom(font: Font.regular(presentationData.listsFontSize.baseDisplaySize - 1.0), height: nil, verticalOffset: nil), badge: nil, icon: { theme in
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Clear"), color: theme.actionSheet.primaryTextColor)
|
|
}, iconSource: nil, action: { [weak self] c, _ in
|
|
c?.dismiss(completion: {
|
|
var replaceImpl: ((ViewController) -> Void)?
|
|
let controller = context.sharedContext.makePremiumDemoController(context: context, subject: .noAds, forceDark: true, action: {
|
|
let controller = context.sharedContext.makePremiumIntroController(context: context, source: .ads, forceDark: true, dismissed: nil)
|
|
replaceImpl?(controller)
|
|
}, dismissed: nil)
|
|
replaceImpl = { [weak controller] c in
|
|
controller?.replace(with: c)
|
|
}
|
|
if let navigationController = self?.baseNavigationController() as? NavigationController {
|
|
navigationController.pushViewController(controller)
|
|
}
|
|
})
|
|
})))
|
|
}
|
|
|
|
if !message.text.isEmpty {
|
|
actions.append(.separator)
|
|
actions.append(.action(ContextMenuActionItem(text: presentationData.strings.Conversation_ContextMenuCopy, icon: { theme in
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Copy"), color: theme.actionSheet.primaryTextColor)
|
|
}, action: { [weak self] _, f in
|
|
var messageEntities: [MessageTextEntity]?
|
|
for attribute in message.attributes {
|
|
if let attribute = attribute as? TextEntitiesMessageAttribute {
|
|
messageEntities = attribute.entities
|
|
}
|
|
}
|
|
|
|
storeMessageTextInPasteboard(message.text, entities: messageEntities)
|
|
|
|
Queue.mainQueue().after(0.2, {
|
|
guard let self, let controller = self.galleryController() else {
|
|
return
|
|
}
|
|
controller.present(UndoOverlayController(presentationData: self.presentationData, content: .copy(text: presentationData.strings.Conversation_MessageCopied), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root))
|
|
})
|
|
|
|
f(.default)
|
|
})))
|
|
}
|
|
}
|
|
|
|
return .single(actions)
|
|
}
|
|
|
|
|
|
private func contextMenuMainItems(isSettings: Bool, dismiss: @escaping () -> Void) -> Signal<(items: [ContextMenuItem], topItems: [ContextMenuItem]), NoError> {
|
|
guard let videoNode = self.videoNode, let item = self.item else {
|
|
return .single(([], []))
|
|
}
|
|
|
|
let peer: Signal<EnginePeer?, NoError>
|
|
if let (message, _, _) = self.contentInfo() {
|
|
peer = self.context.engine.data.get(TelegramEngine.EngineData.Item.Peer.Peer(id: message.id.peerId))
|
|
} else {
|
|
peer = .single(nil)
|
|
}
|
|
|
|
return combineLatest(queue: Queue.mainQueue(),
|
|
videoNode.status |> take(1),
|
|
peer,
|
|
videoNode.videoQualityStateSignal()
|
|
)
|
|
|> map { [weak self] status, peer, videoQualityState -> (items: [ContextMenuItem], topItems: [ContextMenuItem]) in
|
|
guard let status = status, let strongSelf = self else {
|
|
return ([], [])
|
|
}
|
|
|
|
var topItems: [ContextMenuItem] = []
|
|
var items: [ContextMenuItem] = []
|
|
|
|
if isSettings {
|
|
let sliderValuePromise = ValuePromise<Double?>(nil)
|
|
topItems.append(.custom(SliderContextItem(title: strongSelf.presentationData.strings.Gallery_VideoSettings_SpeedControlTitle, minValue: 0.2, maxValue: 2.5, value: status.baseRate, valueChanged: { [weak self] newValue, _ in
|
|
guard let strongSelf = self, let videoNode = strongSelf.videoNode else {
|
|
return
|
|
}
|
|
let newValue = normalizeValue(newValue)
|
|
videoNode.setBaseRate(newValue)
|
|
if let controller = strongSelf.galleryController() as? GalleryController {
|
|
controller.updateSharedPlaybackRate(newValue)
|
|
}
|
|
sliderValuePromise.set(newValue)
|
|
}), true))
|
|
|
|
if let videoQualityState, !videoQualityState.available.isEmpty {
|
|
} else {
|
|
items.append(.custom(SectionTitleContextItem(text: strongSelf.presentationData.strings.Gallery_VideoSettings_SpeedSectionTitle), false))
|
|
for (text, _, rate) in strongSelf.speedList(strings: strongSelf.presentationData.strings) {
|
|
let isSelected = abs(status.baseRate - rate) < 0.01
|
|
items.append(.action(ContextMenuActionItem(text: text, icon: { _ in return nil }, iconSource: ContextMenuActionItemIconSource(size: CGSize(width: 24.0, height: 24.0), signal: sliderValuePromise.get()
|
|
|> map { value in
|
|
if isSelected && value == nil {
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: .white)
|
|
} else {
|
|
return nil
|
|
}
|
|
}), action: { _, f in
|
|
f(.default)
|
|
|
|
guard let strongSelf = self, let videoNode = strongSelf.videoNode else {
|
|
return
|
|
}
|
|
|
|
videoNode.setBaseRate(rate)
|
|
if let controller = strongSelf.galleryController() as? GalleryController {
|
|
controller.updateSharedPlaybackRate(rate)
|
|
}
|
|
})))
|
|
}
|
|
}
|
|
|
|
if let videoQualityState, !videoQualityState.available.isEmpty {
|
|
items.append(.custom(SectionTitleContextItem(text: strongSelf.presentationData.strings.Gallery_VideoSettings_QualitySectionTitle), false))
|
|
|
|
do {
|
|
let isSelected = videoQualityState.preferred == .auto
|
|
let qualityText: String = strongSelf.presentationData.strings.Gallery_VideoSettings_QualityAuto
|
|
let textLayout: ContextMenuActionItemTextLayout
|
|
if videoQualityState.current != 0 {
|
|
textLayout = .secondLineWithValue("\(videoQualityState.current)p")
|
|
} else {
|
|
textLayout = .singleLine
|
|
}
|
|
items.append(.action(ContextMenuActionItem(id: AnyHashable("q"), text: qualityText, textLayout: textLayout, icon: { _ in
|
|
if isSelected {
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: .white)
|
|
} else {
|
|
return nil
|
|
}
|
|
}, action: { [weak strongSelf] _, f in
|
|
f(.default)
|
|
|
|
guard let strongSelf, let videoNode = strongSelf.videoNode else {
|
|
return
|
|
}
|
|
videoNode.setVideoQuality(.auto)
|
|
strongSelf.videoQualityPromise.set(.auto)
|
|
})))
|
|
}
|
|
|
|
if videoQualityState.available.count > 1 {
|
|
for quality in videoQualityState.available {
|
|
let isSelected = videoQualityState.preferred == .quality(quality)
|
|
let qualityTitle: String
|
|
if quality <= 360 {
|
|
qualityTitle = strongSelf.presentationData.strings.Gallery_VideoSettings_QualityLow
|
|
} else if quality <= 480 {
|
|
qualityTitle = strongSelf.presentationData.strings.Gallery_VideoSettings_QualityMedium
|
|
} else if quality <= 720 {
|
|
qualityTitle = strongSelf.presentationData.strings.Gallery_VideoSettings_QualityHD
|
|
} else if quality <= 1080 {
|
|
qualityTitle = strongSelf.presentationData.strings.Gallery_VideoSettings_QualityFHD
|
|
} else {
|
|
qualityTitle = strongSelf.presentationData.strings.Gallery_VideoSettings_QualityQHD
|
|
}
|
|
var qualityDebugText = ""
|
|
var displayDebugInfo = false
|
|
if strongSelf.context.sharedContext.applicationBindings.appBuildType == .internal {
|
|
displayDebugInfo = true
|
|
} else {
|
|
#if DEBUG
|
|
displayDebugInfo = true
|
|
#endif
|
|
}
|
|
if displayDebugInfo, let content = item.content as? HLSVideoContent, let qualitySet = HLSQualitySet(baseFile: content.fileReference, codecConfiguration: HLSCodecConfiguration(context: strongSelf.context)), let qualityFile = qualitySet.qualityFiles[quality] {
|
|
for attribute in qualityFile.media.attributes {
|
|
if case let .Video(_, _, _, _, _, videoCodec) = attribute, let videoCodec {
|
|
qualityDebugText += " \(videoCodec)"
|
|
if videoCodec == "av1" || videoCodec == "av01" {
|
|
qualityDebugText += internal_isHardwareAv1Supported ? " (HW)" : " (SW)"
|
|
}
|
|
}
|
|
}
|
|
if let size = qualityFile.media.size {
|
|
qualityDebugText += ", \(dataSizeString(size, formatting: DataSizeStringFormatting(presentationData: strongSelf.presentationData)))"
|
|
}
|
|
}
|
|
items.append(.action(ContextMenuActionItem(text: qualityTitle, textLayout: .secondLineWithValue("\(quality)p\(qualityDebugText)"), icon: { _ in
|
|
if isSelected {
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Check"), color: .white)
|
|
} else {
|
|
return nil
|
|
}
|
|
}, action: { [weak self] _, f in
|
|
f(.default)
|
|
|
|
guard let self, let videoNode = self.videoNode else {
|
|
return
|
|
}
|
|
videoNode.setVideoQuality(.quality(quality))
|
|
self.videoQualityPromise.set(.quality(quality))
|
|
|
|
/*if let controller = strongSelf.galleryController() as? GalleryController {
|
|
controller.updateSharedPlaybackRate(rate)
|
|
}*/
|
|
})))
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
if let (message, maybeFile, _) = strongSelf.contentInfo(), let file = maybeFile, !message.isCopyProtected() && !item.peerIsCopyProtected && message.paidContent == nil {
|
|
items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Gallery_MenuSaveToGallery, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Download"), color: theme.actionSheet.primaryTextColor) }, action: { c, _ in
|
|
guard let self else {
|
|
c?.dismiss(result: .default, completion: nil)
|
|
return
|
|
}
|
|
|
|
if let content = item.content as? HLSVideoContent {
|
|
guard let videoNode = self.videoNode, let qualityState = videoNode.videoQualityState(), !qualityState.available.isEmpty else {
|
|
return
|
|
}
|
|
if qualityState.available.isEmpty {
|
|
return
|
|
}
|
|
guard let qualitySet = HLSQualitySet(baseFile: content.fileReference, codecConfiguration: HLSCodecConfiguration(context: self.context)) else {
|
|
return
|
|
}
|
|
|
|
var allFiles: [FileMediaReference] = []
|
|
allFiles.append(content.fileReference)
|
|
allFiles.append(contentsOf: qualitySet.qualityFiles.values)
|
|
|
|
let qualitySignals = allFiles.map { file -> Signal<(fileId: MediaId, isCached: Bool), NoError> in
|
|
return self.context.account.postbox.mediaBox.resourceStatus(file.media.resource)
|
|
|> take(1)
|
|
|> map { status -> (fileId: MediaId, isCached: Bool) in
|
|
return (file.media.fileId, status == .Local)
|
|
}
|
|
}
|
|
let _ = (combineLatest(queue: .mainQueue(), qualitySignals)
|
|
|> deliverOnMainQueue).startStandalone(next: { [weak self, weak c] fileStatuses in
|
|
guard let self else {
|
|
return
|
|
}
|
|
|
|
var items: [ContextMenuItem] = []
|
|
|
|
items.append(.action(ContextMenuActionItem(text: self.presentationData.strings.Common_Back, icon: { theme in
|
|
return generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Back"), color: theme.actionSheet.primaryTextColor)
|
|
}, iconPosition: .left, action: { c, _ in
|
|
c?.popItems()
|
|
})))
|
|
|
|
let addItem: (Int?, FileMediaReference) -> Void = { quality, qualityFile in
|
|
guard let qualityFileSize = qualityFile.media.size else {
|
|
return
|
|
}
|
|
var fileSizeString = dataSizeString(qualityFileSize, formatting: DataSizeStringFormatting(presentationData: self.presentationData))
|
|
let title: String
|
|
if let quality {
|
|
title = self.presentationData.strings.Gallery_SaveToGallery_Quality("\(quality)").string
|
|
} else {
|
|
title = self.presentationData.strings.Gallery_SaveToGallery_Original
|
|
}
|
|
|
|
if let statusValue = fileStatuses.first(where: { $0.fileId == qualityFile.media.fileId }), statusValue.isCached {
|
|
fileSizeString.append(" • \(self.presentationData.strings.Gallery_SaveToGallery_cached)")
|
|
} else {
|
|
fileSizeString.insert(contentsOf: "↓ ", at: fileSizeString.startIndex)
|
|
}
|
|
|
|
items.append(.action(ContextMenuActionItem(text: title, textLayout: .secondLineWithValue(fileSizeString), icon: { _ in
|
|
return nil
|
|
}, action: { [weak self] c, _ in
|
|
c?.dismiss(result: .default, completion: nil)
|
|
|
|
guard let self else {
|
|
return
|
|
}
|
|
guard let controller = self.galleryController() else {
|
|
return
|
|
}
|
|
|
|
let saveScreen = SaveProgressScreen(context: self.context, content: .progress(self.presentationData.strings.Story_TooltipSaving, 0.0))
|
|
controller.present(saveScreen, in: .current)
|
|
|
|
let stringSaving = self.presentationData.strings.Story_TooltipSaving
|
|
let stringSaved = self.presentationData.strings.Story_TooltipSaved
|
|
|
|
let saveFileReference: AnyMediaReference = qualityFile.abstract
|
|
let saveSignal = SaveToCameraRoll.saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: saveFileReference)
|
|
|
|
let disposable = (saveSignal
|
|
|> deliverOnMainQueue).start(next: { [weak saveScreen] progress in
|
|
guard let saveScreen else {
|
|
return
|
|
}
|
|
saveScreen.content = .progress(stringSaving, progress)
|
|
}, completed: { [weak saveScreen] in
|
|
guard let saveScreen else {
|
|
return
|
|
}
|
|
saveScreen.content = .completion(stringSaved)
|
|
Queue.mainQueue().after(3.0, { [weak saveScreen] in
|
|
saveScreen?.dismiss()
|
|
})
|
|
})
|
|
|
|
saveScreen.cancelled = {
|
|
disposable.dispose()
|
|
}
|
|
})))
|
|
}
|
|
|
|
if self.context.isPremium {
|
|
addItem(nil, content.fileReference)
|
|
} else {
|
|
#if DEBUG
|
|
addItem(nil, content.fileReference)
|
|
#endif
|
|
}
|
|
|
|
for quality in qualityState.available {
|
|
guard let qualityFile = qualitySet.qualityFiles[quality] else {
|
|
continue
|
|
}
|
|
addItem(quality, qualityFile)
|
|
}
|
|
|
|
c?.pushItems(items: .single(ContextController.Items(content: .list(items))))
|
|
})
|
|
} else {
|
|
c?.dismiss(result: .default, completion: nil)
|
|
|
|
switch self.fetchStatus {
|
|
case .Local:
|
|
let _ = (SaveToCameraRoll.saveToCameraRoll(context: self.context, postbox: self.context.account.postbox, userLocation: .peer(message.id.peerId), mediaReference: .message(message: MessageReference(message), media: file))
|
|
|> deliverOnMainQueue).start(completed: { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
guard let controller = self.galleryController() else {
|
|
return
|
|
}
|
|
controller.present(UndoOverlayController(presentationData: self.presentationData, content: .mediaSaved(text: self.presentationData.strings.Gallery_VideoSaved), elevatedLayout: false, animateInAsReplacement: false, action: { _ in return false }), in: .window(.root))
|
|
})
|
|
default:
|
|
guard let controller = self.galleryController() else {
|
|
return
|
|
}
|
|
controller.present(textAlertController(context: self.context, title: nil, text: self.presentationData.strings.Gallery_WaitForVideoDownoad, actions: [TextAlertAction(type: .defaultAction, title: self.presentationData.strings.Common_OK, action: {
|
|
})]), in: .window(.root))
|
|
}
|
|
}
|
|
})))
|
|
}
|
|
|
|
if !items.isEmpty {
|
|
items.append(.separator)
|
|
}
|
|
if let (message, _, _) = strongSelf.contentInfo() {
|
|
let context = strongSelf.context
|
|
items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.SharedMedia_ViewInChat, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/GoToMessage"), color: theme.contextMenu.primaryColor)}, action: { [weak self] _, f in
|
|
guard let strongSelf = self, let peer = peer else {
|
|
return
|
|
}
|
|
if let navigationController = strongSelf.baseNavigationController() {
|
|
strongSelf.beginCustomDismiss(.simpleAnimation)
|
|
|
|
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), subject: .message(id: .id(message.id), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: false)))
|
|
|
|
Queue.mainQueue().after(0.3) {
|
|
strongSelf.completeCustomDismiss(false)
|
|
}
|
|
}
|
|
f(.default)
|
|
})))
|
|
}
|
|
|
|
// if #available(iOS 11.0, *) {
|
|
// items.append(.action(ContextMenuActionItem(text: "AirPlay", textColor: .primary, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Media Gallery/AirPlay"), color: theme.contextMenu.primaryColor) }, action: { [weak self] _, f in
|
|
// f(.default)
|
|
// guard let strongSelf = self else {
|
|
// return
|
|
// }
|
|
// strongSelf.beginAirPlaySetup()
|
|
// })))
|
|
// }
|
|
|
|
if let (message, _, _) = strongSelf.contentInfo() {
|
|
for media in message.media {
|
|
if let webpage = media as? TelegramMediaWebpage, case let .Loaded(content) = webpage.content {
|
|
let url = content.url
|
|
|
|
let item = OpenInItem.url(url: url)
|
|
let openText = strongSelf.presentationData.strings.Conversation_FileOpenIn
|
|
items.append(.action(ContextMenuActionItem(text: openText, textColor: .primary, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Share"), color: theme.contextMenu.primaryColor) }, action: { _, f in
|
|
f(.default)
|
|
|
|
if let strongSelf = self, let controller = strongSelf.galleryController() {
|
|
var presentationData = strongSelf.context.sharedContext.currentPresentationData.with { $0 }
|
|
if !presentationData.theme.overallDarkAppearance {
|
|
presentationData = presentationData.withUpdated(theme: defaultDarkColorPresentationTheme)
|
|
}
|
|
let actionSheet = OpenInActionSheetController(context: strongSelf.context, forceTheme: presentationData.theme, item: item, openUrl: { [weak self] url in
|
|
if let strongSelf = self {
|
|
strongSelf.context.sharedContext.openExternalUrl(context: strongSelf.context, urlContext: .generic, url: url, forceExternal: true, presentationData: presentationData, navigationController: strongSelf.baseNavigationController(), dismissInput: {})
|
|
}
|
|
})
|
|
controller.present(actionSheet, in: .window(.root))
|
|
}
|
|
})))
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
if let peer, let (message, _, _) = strongSelf.contentInfo(), canSendMessagesToPeer(peer._asPeer()) {
|
|
items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Conversation_ContextMenuReply, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Reply"), color: theme.contextMenu.primaryColor)}, action: { [weak self] _, f in
|
|
if let self, let navigationController = self.baseNavigationController() {
|
|
self.beginCustomDismiss(.simpleAnimation)
|
|
|
|
context.sharedContext.navigateToChatController(NavigateToChatControllerParams(navigationController: navigationController, context: context, chatLocation: .peer(peer), subject: .message(id: .id(message.id), highlight: ChatControllerSubject.MessageHighlight(quote: nil), timecode: nil, setupReply: true)))
|
|
|
|
Queue.mainQueue().after(0.3) {
|
|
self.completeCustomDismiss(false)
|
|
}
|
|
}
|
|
f(.default)
|
|
})))
|
|
}
|
|
|
|
if strongSelf.canDelete() {
|
|
items.append(.action(ContextMenuActionItem(text: strongSelf.presentationData.strings.Common_Delete, textColor: .destructive, icon: { theme in generateTintedImage(image: UIImage(bundleImageName: "Chat/Context Menu/Delete"), color: theme.contextMenu.destructiveColor) }, action: { _, f in
|
|
f(.default)
|
|
|
|
if let strongSelf = self {
|
|
strongSelf.footerContentNode.deleteButtonPressed()
|
|
}
|
|
})))
|
|
}
|
|
}
|
|
|
|
return (items, topItems)
|
|
}
|
|
}
|
|
|
|
private var isAirPlayActive = false
|
|
private var externalVideoPlayer: ExternalVideoPlayer?
|
|
func beginAirPlaySetup() {
|
|
guard let content = self.item?.content as? NativeVideoContent else {
|
|
return
|
|
}
|
|
if #available(iOS 11.0, *) {
|
|
self.externalVideoPlayer = ExternalVideoPlayer(context: self.context, content: content)
|
|
self.externalVideoPlayer?.openRouteSelection()
|
|
self.externalVideoPlayer?.isActiveUpdated = { [weak self] isActive in
|
|
if let strongSelf = self {
|
|
if strongSelf.isAirPlayActive && !isActive {
|
|
strongSelf.externalVideoPlayer = nil
|
|
}
|
|
strongSelf.isAirPlayActive = isActive
|
|
strongSelf.updateDisplayPlaceholder()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func openStickersButtonPressed() {
|
|
guard let content = self.item?.content as? NativeVideoContent else {
|
|
return
|
|
}
|
|
let context = self.context
|
|
let media = content.fileReference.abstract
|
|
|
|
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
|
|
let topController = (self.baseNavigationController()?.topViewController as? ViewController)
|
|
let progressSignal = Signal<Never, NoError> { subscriber in
|
|
let controller = OverlayStatusController(theme: presentationData.theme, type: .loading(cancelled: nil))
|
|
topController?.present(controller, in: .window(.root), with: nil)
|
|
return ActionDisposable { [weak controller] in
|
|
Queue.mainQueue().async() {
|
|
controller?.dismiss()
|
|
}
|
|
}
|
|
}
|
|
|> runOn(Queue.mainQueue())
|
|
|> delay(0.15, queue: Queue.mainQueue())
|
|
let progressDisposable = progressSignal.start()
|
|
|
|
self.isInteractingPromise.set(true)
|
|
|
|
let signal = self.context.engine.stickers.stickerPacksAttachedToMedia(media: media)
|
|
|> afterDisposed {
|
|
Queue.mainQueue().async {
|
|
progressDisposable.dispose()
|
|
}
|
|
}
|
|
let _ = (signal
|
|
|> deliverOnMainQueue).start(next: { [weak self] packs in
|
|
guard let strongSelf = self, !packs.isEmpty else {
|
|
return
|
|
}
|
|
let baseNavigationController = strongSelf.baseNavigationController()
|
|
baseNavigationController?.view.endEditing(true)
|
|
let controller = StickerPackScreen(context: strongSelf.context, mainStickerPack: packs[0], stickerPacks: packs, sendSticker: nil, actionPerformed: { actions in
|
|
if let (info, items, action) = actions.first {
|
|
let animateInAsReplacement = false
|
|
switch action {
|
|
case .add:
|
|
topController?.present(UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_AddedTitle, text: presentationData.strings.StickerPackActionInfo_AddedText(info.title).string, undo: false, info: info, topItem: items.first, context: context), elevatedLayout: true, animateInAsReplacement: animateInAsReplacement, action: { _ in
|
|
return true
|
|
}), in: .window(.root))
|
|
case let .remove(positionInList):
|
|
topController?.present(UndoOverlayController(presentationData: presentationData, content: .stickersModified(title: presentationData.strings.StickerPackActionInfo_RemovedTitle, text: presentationData.strings.StickerPackActionInfo_RemovedText(info.title).string, undo: true, info: info, topItem: items.first, context: context), elevatedLayout: true, animateInAsReplacement: animateInAsReplacement, action: { action in
|
|
if case .undo = action {
|
|
let _ = context.engine.stickers.addStickerPackInteractively(info: info, items: items, positionInList: positionInList).start()
|
|
}
|
|
return true
|
|
}), in: .window(.root))
|
|
}
|
|
}
|
|
}, dismissed: { [weak self] in
|
|
self?.isInteractingPromise.set(false)
|
|
})
|
|
(baseNavigationController?.topViewController as? ViewController)?.present(controller, in: .window(.root), with: nil)
|
|
})
|
|
}
|
|
|
|
@objc private func settingsButtonPressed() {
|
|
self.openMoreMenu(sourceNode: self.settingsBarButton.referenceNode, gesture: nil, isSettings: true)
|
|
}
|
|
|
|
override func adjustForPreviewing() {
|
|
super.adjustForPreviewing()
|
|
|
|
self.scrubberView?.isHidden = true
|
|
}
|
|
|
|
override func footerContent() -> Signal<(GalleryFooterContentNode?, GalleryOverlayContentNode?), NoError> {
|
|
return .single((self.footerContentNode, nil))
|
|
}
|
|
|
|
func updatePlaybackRate(_ playbackRate: Double?) {
|
|
self.playbackRate = playbackRate
|
|
|
|
if let playbackRate = self.playbackRate {
|
|
self.videoNode?.setBaseRate(playbackRate)
|
|
}
|
|
|
|
self.playbackRatePromise.set(self.playbackRate ?? 1.0)
|
|
}
|
|
|
|
func updateVideoQuality(_ videoQuality: UniversalVideoContentVideoQuality) {
|
|
self.videoQuality = videoQuality
|
|
self.videoQualityPromise.set(videoQuality)
|
|
|
|
self.videoNode?.setVideoQuality(videoQuality)
|
|
}
|
|
|
|
public func seekToStart() {
|
|
self.videoNode?.seek(0.0)
|
|
self.videoNode?.play()
|
|
}
|
|
|
|
override var keyShortcuts: [KeyShortcut] {
|
|
let strings = self.presentationData.strings
|
|
|
|
var keyShortcuts: [KeyShortcut] = []
|
|
keyShortcuts.append(
|
|
KeyShortcut(
|
|
title: self.isPlaying ? strings.KeyCommand_Pause : strings.KeyCommand_Play,
|
|
input: " ",
|
|
modifiers: [],
|
|
action: { [weak self] in
|
|
self?.footerContentNode.playbackControl?()
|
|
}
|
|
)
|
|
)
|
|
|
|
keyShortcuts.append(
|
|
KeyShortcut(
|
|
title: strings.KeyCommand_SeekBackward,
|
|
input: UIKeyCommand.inputLeftArrow,
|
|
modifiers: [.shift],
|
|
action: { [weak self] in
|
|
self?.footerContentNode.seekBackward?(5)
|
|
}
|
|
)
|
|
)
|
|
keyShortcuts.append(
|
|
KeyShortcut(
|
|
title: strings.KeyCommand_SeekForward,
|
|
input: UIKeyCommand.inputRightArrow,
|
|
modifiers: [.shift],
|
|
action: { [weak self] in
|
|
self?.footerContentNode.seekForward?(5)
|
|
}
|
|
)
|
|
)
|
|
|
|
keyShortcuts.append(
|
|
KeyShortcut(
|
|
title: strings.KeyCommand_Share,
|
|
input: "S",
|
|
modifiers: [.command],
|
|
action: { [weak self] in
|
|
self?.footerContentNode.actionButtonPressed()
|
|
}
|
|
)
|
|
)
|
|
if self.hasPictureInPicture {
|
|
keyShortcuts.append(
|
|
KeyShortcut(
|
|
title: strings.KeyCommand_SwitchToPIP,
|
|
input: "P",
|
|
modifiers: [.command],
|
|
action: { [weak self] in
|
|
self?.pictureInPictureButtonPressed()
|
|
}
|
|
)
|
|
)
|
|
}
|
|
if self.canDelete() {
|
|
keyShortcuts.append(
|
|
KeyShortcut(
|
|
input: "\u{8}",
|
|
modifiers: [],
|
|
action: { [weak self] in
|
|
self?.footerContentNode.deleteButtonPressed()
|
|
}
|
|
)
|
|
)
|
|
}
|
|
return keyShortcuts
|
|
}
|
|
|
|
override func hasActiveEdgeAction(edge: ActiveEdge) -> Bool {
|
|
if case .right = edge {
|
|
if let playerStatusValue = self.playerStatusValue, case .playing = playerStatusValue.status {
|
|
return true
|
|
} else {
|
|
return false
|
|
}
|
|
} else {
|
|
return false
|
|
}
|
|
}
|
|
|
|
override func setActiveEdgeAction(edge: ActiveEdge?) {
|
|
guard let videoNode = self.videoNode else {
|
|
return
|
|
}
|
|
if let edge, case .right = edge {
|
|
let effectiveRate: Double
|
|
if let current = self.activeEdgeRateState {
|
|
effectiveRate = min(4.0, current.initialRate + 1.0)
|
|
self.activeEdgeRateState = (current.initialRate, effectiveRate)
|
|
} else {
|
|
guard let playbackRate = self.playbackRate else {
|
|
return
|
|
}
|
|
effectiveRate = min(4.0, playbackRate + 1.0)
|
|
self.activeEdgeRateState = (playbackRate, effectiveRate)
|
|
}
|
|
videoNode.setBaseRate(effectiveRate)
|
|
} else if let (initialRate, _) = self.activeEdgeRateState {
|
|
self.activeEdgeRateState = nil
|
|
videoNode.setBaseRate(initialRate)
|
|
}
|
|
|
|
if let validLayout = self.validLayout {
|
|
self.containerLayoutUpdated(validLayout.layout, navigationBarHeight: validLayout.navigationBarHeight, transition: .animated(duration: 0.35, curve: .spring))
|
|
}
|
|
}
|
|
|
|
override func adjustActiveEdgeAction(distance: CGFloat) {
|
|
guard let videoNode = self.videoNode else {
|
|
return
|
|
}
|
|
if let current = self.activeEdgeRateState {
|
|
var rateFraction = Double(distance) / 100.0
|
|
rateFraction = max(-1.0, min(1.0, rateFraction))
|
|
|
|
let effectiveRate: Double
|
|
if rateFraction < 0.0 {
|
|
let rateDistance = (current.initialRate + 1.0) * (1.0 - (-rateFraction)) + 1.0 * (-rateFraction)
|
|
effectiveRate = max(1.0, min(4.0, rateDistance))
|
|
} else {
|
|
let rateDistance = (current.initialRate + 1.0) * (1.0 - rateFraction) + 3.0 * rateFraction
|
|
effectiveRate = max(1.0, min(4.0, rateDistance))
|
|
}
|
|
self.activeEdgeRateState = (current.initialRate, effectiveRate)
|
|
videoNode.setBaseRate(effectiveRate)
|
|
|
|
if let validLayout = self.validLayout {
|
|
self.containerLayoutUpdated(validLayout.layout, navigationBarHeight: validLayout.navigationBarHeight, transition: .animated(duration: 0.35, curve: .spring))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
final class HeaderContextReferenceContentSource: 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)
|
|
}
|
|
}
|
|
|
|
private func normalizeValue(_ value: CGFloat) -> CGFloat {
|
|
return round(value * 10.0) / 10.0
|
|
}
|