Files
ghostgram/submodules/TelegramUI/Components/VideoMessageCameraScreen/Sources/VideoMessageCameraScreen.swift
T
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

2483 lines
112 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
import LottieSettings
import UIKit
import Display
import AsyncDisplayKit
import ComponentFlow
import SwiftSignalKit
import ViewControllerComponent
import ComponentDisplayAdapters
import TelegramPresentationData
import AccountContext
import TelegramCore
import PresentationDataUtils
import Camera
import CameraLegacy
import CameraNeo
import MultilineTextComponent
import BlurredBackgroundComponent
import PlainButtonComponent
import Photos
import TooltipUI
import BundleIconComponent
import CameraButtonComponent
import TelegramNotices
import DeviceAccess
import MediaEditor
import MediaResources
import LocalMediaResources
import ImageCompression
import LegacyMediaPickerUI
import TelegramAudio
import ChatSendMessageActionUI
import ChatControllerInteraction
import LottieComponent
import GlassBackgroundComponent
// GHOST: Ghostgram round video settings and masks (video part; GGRoundCameraSupport.swift)
import GGCore
import GGCameraPipeline
struct CameraState: Equatable {
enum Recording: Equatable {
case none
case holding
case handsFree
}
enum FlashTint: Equatable {
case white
case yellow
case blue
var color: UIColor {
switch self {
case .white:
return .white
case .yellow:
return UIColor(rgb: 0xffed8c)
case .blue:
return UIColor(rgb: 0x8cdfff)
}
}
}
let position: Camera.Position
let flashMode: Camera.FlashMode
let flashModeDidChange: Bool
let flashTint: FlashTint
let flashTintSize: CGFloat
let recording: Recording
let duration: Double
let isDualCameraEnabled: Bool
let isViewOnceEnabled: Bool
func updatedPosition(_ position: Camera.Position) -> CameraState {
return CameraState(position: position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, flashTint: self.flashTint, flashTintSize: self.flashTintSize, recording: self.recording, duration: self.duration, isDualCameraEnabled: self.isDualCameraEnabled, isViewOnceEnabled: self.isViewOnceEnabled)
}
func updatedFlashMode(_ flashMode: Camera.FlashMode) -> CameraState {
return CameraState(position: self.position, flashMode: flashMode, flashModeDidChange: self.flashMode != flashMode, flashTint: self.flashTint, flashTintSize: self.flashTintSize, recording: self.recording, duration: self.duration, isDualCameraEnabled: self.isDualCameraEnabled, isViewOnceEnabled: self.isViewOnceEnabled)
}
func updatedFlashTint(_ flashTint: FlashTint) -> CameraState {
return CameraState(position: self.position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, flashTint: flashTint, flashTintSize: self.flashTintSize, recording: self.recording, duration: self.duration, isDualCameraEnabled: self.isDualCameraEnabled, isViewOnceEnabled: self.isViewOnceEnabled)
}
func updatedFlashTintSize(_ flashTintSize: CGFloat) -> CameraState {
return CameraState(position: self.position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, flashTint: self.flashTint, flashTintSize: flashTintSize, recording: self.recording, duration: self.duration, isDualCameraEnabled: self.isDualCameraEnabled, isViewOnceEnabled: self.isViewOnceEnabled)
}
func updatedRecording(_ recording: Recording) -> CameraState {
var flashModeDidChange = self.flashModeDidChange
if case .none = self.recording {
flashModeDidChange = false
}
return CameraState(position: self.position, flashMode: self.flashMode, flashModeDidChange: flashModeDidChange, flashTint: self.flashTint, flashTintSize: self.flashTintSize, recording: recording, duration: self.duration, isDualCameraEnabled: self.isDualCameraEnabled, isViewOnceEnabled: self.isViewOnceEnabled)
}
func updatedDuration(_ duration: Double) -> CameraState {
return CameraState(position: self.position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, flashTint: self.flashTint, flashTintSize: self.flashTintSize, recording: self.recording, duration: duration, isDualCameraEnabled: self.isDualCameraEnabled, isViewOnceEnabled: self.isViewOnceEnabled)
}
func updatedIsViewOnceEnabled(_ isViewOnceEnabled: Bool) -> CameraState {
return CameraState(position: self.position, flashMode: self.flashMode, flashModeDidChange: self.flashModeDidChange, flashTint: self.flashTint, flashTintSize: self.flashTintSize, recording: self.recording, duration: self.duration, isDualCameraEnabled: self.isDualCameraEnabled, isViewOnceEnabled: isViewOnceEnabled)
}
}
struct PreviewState: Equatable {
let composition: AVComposition
let trimRange: Range<Double>?
let isMuted: Bool
}
enum CameraScreenTransition {
case animateIn
case animateOut
case finishedAnimateIn
}
private let viewOnceButtonTag = GenericComponentViewTag()
private final class VideoMessageCameraScreenComponent: CombinedComponent {
typealias EnvironmentType = ViewControllerComponentContainer.Environment
let context: AccountContext
let cameraState: CameraState
let containerSize: CGSize
let previewFrame: CGRect
let safeInsets: UIEdgeInsets
let isPreviewing: Bool
let isMuted: Bool
let totalDuration: Double
let getController: () -> VideoMessageCameraScreen?
let present: (ViewController) -> Void
let push: (ViewController) -> Void
let startRecording: ActionSlot<Void>
let stopRecording: ActionSlot<Void>
let cancelRecording: ActionSlot<Void>
let completion: ActionSlot<VideoMessageCameraScreen.CaptureResult>
init(
context: AccountContext,
cameraState: CameraState,
containerSize: CGSize,
previewFrame: CGRect,
safeInsets: UIEdgeInsets,
isPreviewing: Bool,
isMuted: Bool,
totalDuration: Double,
getController: @escaping () -> VideoMessageCameraScreen?,
present: @escaping (ViewController) -> Void,
push: @escaping (ViewController) -> Void,
startRecording: ActionSlot<Void>,
stopRecording: ActionSlot<Void>,
cancelRecording: ActionSlot<Void>,
completion: ActionSlot<VideoMessageCameraScreen.CaptureResult>
) {
self.context = context
self.cameraState = cameraState
self.containerSize = containerSize
self.previewFrame = previewFrame
self.safeInsets = safeInsets
self.isPreviewing = isPreviewing
self.isMuted = isMuted
self.totalDuration = totalDuration
self.getController = getController
self.present = present
self.push = push
self.startRecording = startRecording
self.stopRecording = stopRecording
self.cancelRecording = cancelRecording
self.completion = completion
}
static func ==(lhs: VideoMessageCameraScreenComponent, rhs: VideoMessageCameraScreenComponent) -> Bool {
if lhs.context !== rhs.context {
return false
}
if lhs.previewFrame != rhs.previewFrame {
return false
}
if lhs.safeInsets != rhs.safeInsets {
return false
}
if lhs.cameraState != rhs.cameraState {
return false
}
if lhs.containerSize != rhs.containerSize {
return false
}
if lhs.isPreviewing != rhs.isPreviewing {
return false
}
if lhs.isMuted != rhs.isMuted {
return false
}
if lhs.totalDuration != rhs.totalDuration {
return false
}
return true
}
final class State: ComponentState {
enum ImageKey: Hashable {
case flip
case flash
case flashImage
}
private var cachedImages: [ImageKey: UIImage] = [:]
func image(_ key: ImageKey, theme: PresentationTheme) -> UIImage {
if let image = self.cachedImages[key] {
return image
} else {
var image: UIImage
switch key {
case .flip:
image = UIImage(bundleImageName: "Camera/VideoMessageFlip")!.withRenderingMode(.alwaysTemplate)
case .flash:
image = UIImage(bundleImageName: "Camera/VideoMessageFlash")!.withRenderingMode(.alwaysTemplate)
case .flashImage:
image = generateImage(CGSize(width: 393.0, height: 852.0), rotatedContext: { size, context in
context.clear(CGRect(origin: .zero, size: size))
var locations: [CGFloat] = [0.0, 0.2, 0.6, 1.0]
let colors: [CGColor] = [UIColor(rgb: 0xffffff, alpha: 0.25).cgColor, UIColor(rgb: 0xffffff, alpha: 0.25).cgColor, UIColor(rgb: 0xffffff, alpha: 1.0).cgColor, UIColor(rgb: 0xffffff, alpha: 1.0).cgColor]
let colorSpace = CGColorSpaceCreateDeviceRGB()
let gradient = CGGradient(colorsSpace: colorSpace, colors: colors as CFArray, locations: &locations)!
let center = CGPoint(x: size.width / 2.0, y: size.height / 2.0 - 10.0)
context.drawRadialGradient(gradient, startCenter: center, startRadius: 0.0, endCenter: center, endRadius: size.width, options: .drawsAfterEndLocation)
})!.withRenderingMode(.alwaysTemplate)
}
cachedImages[key] = image
return image
}
}
private let context: AccountContext
private let present: (ViewController) -> Void
private let startRecording: ActionSlot<Void>
private let stopRecording: ActionSlot<Void>
private let cancelRecording: ActionSlot<Void>
private let completion: ActionSlot<VideoMessageCameraScreen.CaptureResult>
private let getController: () -> VideoMessageCameraScreen?
private var resultDisposable = MetaDisposable()
var cameraState: CameraState?
var didDisplayViewOnce = false
var displayingFlashTint = false
private let hapticFeedback = HapticFeedback()
init(
context: AccountContext,
present: @escaping (ViewController) -> Void,
startRecording: ActionSlot<Void>,
stopRecording: ActionSlot<Void>,
cancelRecording: ActionSlot<Void>,
completion: ActionSlot<VideoMessageCameraScreen.CaptureResult>,
getController: @escaping () -> VideoMessageCameraScreen? = {
return nil
}
) {
self.context = context
self.present = present
self.startRecording = startRecording
self.stopRecording = stopRecording
self.cancelRecording = cancelRecording
self.completion = completion
self.getController = getController
super.init()
self.startRecording.connect({ [weak self] _ in
if let self, let controller = getController() {
self.startVideoRecording(pressing: !controller.scheduledLock)
controller.scheduledLock = false
if controller.recordingStartTime == nil {
controller.recordingStartTime = CACurrentMediaTime()
}
}
})
self.stopRecording.connect({ [weak self] _ in
self?.stopVideoRecording()
})
self.cancelRecording.connect({ [weak self] _ in
self?.cancelVideoRecording()
})
}
deinit {
self.resultDisposable.dispose()
}
func toggleViewOnce() {
guard let controller = self.getController() else {
return
}
controller.updateCameraState({ $0.updatedIsViewOnceEnabled(!$0.isViewOnceEnabled) }, transition: .easeInOut(duration: 0.2))
}
private var lastFlipTimestamp: Double?
func togglePosition() {
guard let controller = self.getController(), let camera = controller.camera else {
return
}
let currentTimestamp = CACurrentMediaTime()
if let lastFlipTimestamp = self.lastFlipTimestamp, currentTimestamp - lastFlipTimestamp < 1.0 {
return
}
self.lastFlipTimestamp = currentTimestamp
controller.node.ggSupport.cameraWillFlip() // GHOST: the flip animation starts on the tap
let isFrontCamera = controller.cameraState.position == .back
camera.togglePosition()
self.hapticFeedback.impact(.veryLight)
self.updateScreenBrightness(isFrontCamera: isFrontCamera)
if isFrontCamera {
camera.setTorchActive(false)
} else {
camera.setTorchActive(controller.cameraState.flashMode == .on)
}
}
func toggleFlashMode() {
guard let controller = self.getController(), let camera = controller.camera else {
return
}
var isFlashOn = false
switch controller.cameraState.flashMode {
case .off:
isFlashOn = true
camera.setFlashMode(.on)
case .on:
camera.setFlashMode(.off)
default:
camera.setFlashMode(.off)
}
self.hapticFeedback.impact(.light)
self.updateScreenBrightness(isFlashOn: isFlashOn)
if controller.cameraState.position == .back {
camera.setTorchActive(isFlashOn)
}
}
private var initialBrightness: CGFloat?
private var brightnessArguments: (Double, Double, CGFloat, CGFloat)?
private var brightnessAnimator: ConstantDisplayLinkAnimator?
func updateScreenBrightness(isFrontCamera: Bool? = nil, isFlashOn: Bool? = nil) {
guard let controller = self.getController() else {
return
}
let isFrontCamera = isFrontCamera ?? (controller.cameraState.position == .front)
let isFlashOn = isFlashOn ?? (controller.cameraState.flashMode == .on)
if isFrontCamera && isFlashOn {
if self.initialBrightness == nil {
self.initialBrightness = UIScreen.main.brightness
self.brightnessArguments = (CACurrentMediaTime(), 0.2, UIScreen.main.brightness, 1.0)
self.animateBrightnessChange()
}
} else {
if let initialBrightness = self.initialBrightness {
self.initialBrightness = nil
self.brightnessArguments = (CACurrentMediaTime(), 0.2, UIScreen.main.brightness, initialBrightness)
self.animateBrightnessChange()
}
}
}
private func animateBrightnessChange() {
if self.brightnessAnimator == nil {
self.brightnessAnimator = ConstantDisplayLinkAnimator(update: { [weak self] in
self?.animateBrightnessChange()
})
self.brightnessAnimator?.isPaused = true
}
if let (startTime, duration, initial, target) = self.brightnessArguments {
self.brightnessAnimator?.isPaused = false
let t = CGFloat(max(0.0, min(1.0, (CACurrentMediaTime() - startTime) / duration)))
let value = initial + (target - initial) * t
UIScreen.main.brightness = value
if t >= 1.0 {
self.brightnessArguments = nil
self.brightnessAnimator?.isPaused = true
self.brightnessAnimator?.invalidate()
self.brightnessAnimator = nil
}
} else {
self.brightnessAnimator?.isPaused = true
self.brightnessAnimator?.invalidate()
self.brightnessAnimator = nil
}
}
func startVideoRecording(pressing: Bool) {
guard let controller = self.getController(), let camera = controller.camera else {
return
}
guard case .none = controller.cameraState.recording else {
return
}
let currentTimestamp = CACurrentMediaTime()
if let lastActionTimestamp = controller.lastActionTimestamp, currentTimestamp - lastActionTimestamp < 0.5 {
return
}
controller.lastActionTimestamp = currentTimestamp
let initialDuration = controller.node.previewState?.composition.duration.seconds ?? 0.0
let isFirstRecording = initialDuration.isZero
controller.node.resumeCameraCapture()
controller.node.dismissAllTooltips()
controller.updateCameraState({ $0.updatedRecording(pressing ? .holding : .handsFree).updatedDuration(initialDuration) }, transition: .spring(duration: 0.4))
controller.updatePreviewState({ _ in return nil }, transition: .spring(duration: 0.4))
controller.node.withReadyCamera(isFirstTime: !controller.node.cameraIsActive) { [weak self] in
Queue.mainQueue().after(0.15) {
guard let self else {
return
}
self.resultDisposable.set((camera.startRecording()
|> deliverOnMainQueue).startStrict(next: { [weak self] recordingData in
let duration = initialDuration + recordingData.duration
if let self, let controller = self.getController() {
controller.updateCameraState({ $0.updatedDuration(duration) }, transition: .easeInOut(duration: 0.1))
if isFirstRecording {
controller.node.setupLiveUpload(filePath: recordingData.filePath)
}
if duration > 59.5 {
controller.onStop()
}
}
}, error: { [weak self] error in
if let self, let controller = self.getController() {
// GHOST: the Metal camera could not record: the screen goes on with the legacy camera
if controller.node.ggFallBackToLegacyCamera(error: error) {
return
}
controller.completion(nil, nil, nil, nil)
}
}))
}
}
if initialDuration > 0.0 {
controller.onResume()
}
if controller.cameraState.position == .front && controller.cameraState.flashMode == .on {
self.updateScreenBrightness()
}
}
func stopVideoRecording() {
guard let controller = self.getController(), let camera = controller.camera else {
return
}
let currentTimestamp = CACurrentMediaTime()
if let lastActionTimestamp = controller.lastActionTimestamp, currentTimestamp - lastActionTimestamp < 0.5 {
return
}
controller.lastActionTimestamp = currentTimestamp
self.resultDisposable.set((camera.stopRecording()
|> deliverOnMainQueue).startStrict(next: { [weak self] result in
if let self, let controller = self.getController(), case let .finished(mainResult, _, duration, _, _) = result {
self.completion.invoke(
.video(VideoMessageCameraScreen.CaptureResult.Video(
videoPath: mainResult.path,
dimensions: PixelDimensions(mainResult.dimensions),
duration: duration,
thumbnail: mainResult.thumbnail
))
)
controller.updateCameraState({ $0.updatedRecording(.none) }, transition: .spring(duration: 0.4))
}
}))
if let initialBrightness = self.initialBrightness {
self.initialBrightness = nil
self.brightnessArguments = (CACurrentMediaTime(), 0.2, UIScreen.main.brightness, initialBrightness)
self.animateBrightnessChange()
}
}
func lockVideoRecording() {
guard let controller = self.getController() else {
return
}
controller.updateCameraState({ $0.updatedRecording(.handsFree) }, transition: .spring(duration: 0.4))
}
func cancelVideoRecording() {
if let initialBrightness = self.initialBrightness {
self.initialBrightness = nil
self.brightnessArguments = (CACurrentMediaTime(), 0.2, UIScreen.main.brightness, initialBrightness)
self.animateBrightnessChange()
}
}
func updateZoom(fraction: CGFloat) {
guard let camera = self.getController()?.camera else {
return
}
camera.setZoomLevel(fraction)
}
}
func makeState() -> State {
return State(context: self.context, present: self.present, startRecording: self.startRecording, stopRecording: self.stopRecording, cancelRecording: self.cancelRecording, completion: self.completion, getController: self.getController)
}
static var body: Body {
let frontFlash = Child(Image.self)
let flipButtonBackground = Child(GlassBackgroundComponent.self)
let flipButton = Child(CameraButton.self)
let flashButtonBackground = Child(GlassBackgroundComponent.self)
let flashButton = Child(CameraButton.self)
let viewOnceButton = Child(PlainButtonComponent.self)
let recordMoreButton = Child(PlainButtonComponent.self)
let muteIcon = Child(ZStack<Empty>.self)
let flashAction = ActionSlot<Void>()
return { context in
let environment = context.environment[ViewControllerComponentContainer.Environment.self].value
let component = context.component
let state = context.state
let availableSize = context.component.containerSize
var sideInset: CGFloat = 8.0
if component.safeInsets.bottom <= 32.0 && environment.inputHeight == 0.0 {
sideInset += 18.0
}
state.cameraState = component.cameraState
var viewOnceOffset: CGFloat = 102.0
var showViewOnce = false
var showRecordMore = false
if component.isPreviewing {
showViewOnce = true
if component.totalDuration < 59.0 {
showRecordMore = true
viewOnceOffset = 67.0
} else {
viewOnceOffset = 14.0
}
} else if case .handsFree = component.cameraState.recording {
showViewOnce = true
}
if let controller = component.getController() {
if controller.scheduledLock {
showViewOnce = true
}
if !controller.viewOnceAvailable {
showViewOnce = false
}
}
if state.didDisplayViewOnce {
showViewOnce = true
} else if showViewOnce {
state.didDisplayViewOnce = true
}
if !component.isPreviewing {
if case .on = component.cameraState.flashMode, case .front = component.cameraState.position {
let frontFlashPosition = CGPoint(x: component.previewFrame.midX, y: component.previewFrame.midY)
let frontFlashSize = CGSize(
width: max(abs(frontFlashPosition.x), abs(context.availableSize.width - frontFlashPosition.x)) * 2.0,
height: max(abs(frontFlashPosition.y), abs(context.availableSize.height - frontFlashPosition.y)) * 2.0
)
let frontFlash = frontFlash.update(
component: Image(image: state.image(.flashImage, theme: environment.theme), tintColor: component.cameraState.flashTint.color, contentMode: .scaleAspectFill),
availableSize: frontFlashSize,
transition: .easeInOut(duration: 0.2)
)
context.add(frontFlash
.position(frontFlashPosition)
.scale(1.5 - component.cameraState.flashTintSize * 0.5)
.appear(.default(alpha: true))
.disappear(ComponentTransition.Disappear({ view, transition, completion in
view.superview?.sendSubviewToBack(view)
transition.setAlpha(view: view, alpha: 0.0, completion: { _ in
completion()
})
}))
)
}
let flipButtonBackground = flipButtonBackground.update(
component: GlassBackgroundComponent(
size: CGSize(width: 40.0, height: 40.0),
cornerRadius: 40.0 * 0.5,
isDark: environment.theme.overallDarkAppearance,
tintColor: .init(kind: .panel)
),
availableSize: CGSize(width: 40.0, height: 40.0),
transition: .immediate
)
let flipButton = flipButton.update(
component: CameraButton(
content: AnyComponentWithIdentity(
id: "flip",
component: AnyComponent(
Image(
image: state.image(.flip, theme: environment.theme),
tintColor: environment.theme.chat.inputPanel.panelControlColor,
size: CGSize(width: 30.0, height: 30.0)
)
)
),
minSize: CGSize(width: 40.0, height: 40.0),
isExclusive: false,
action: { [weak state] in
if let state {
state.togglePosition()
}
}
),
availableSize: availableSize,
transition: context.transition
)
context.add(flipButtonBackground
.position(CGPoint(x: flipButton.size.width / 2.0 + sideInset, y: availableSize.height - flipButton.size.height / 2.0 - 8.0))
.appear(.default(scale: true, alpha: true))
.disappear(.default(scale: true, alpha: true))
)
context.add(flipButton
.position(CGPoint(x: flipButton.size.width / 2.0 + sideInset, y: availableSize.height - flipButton.size.height / 2.0 - 8.0))
.appear(.default(scale: true, alpha: true))
.disappear(.default(scale: true, alpha: true))
)
let flashContentComponent: AnyComponentWithIdentity<Empty>
if "".isEmpty {
let flashIconName: String
switch component.cameraState.flashMode {
case .off:
flashIconName = "roundFlash_off"
case .on:
flashIconName = "roundFlash_on"
default:
flashIconName = "roundFlash_off"
}
flashContentComponent = AnyComponentWithIdentity(
id: "animatedIcon",
component: AnyComponent(
LottieComponent(
content: LottieComponent.AppBundleContent(name: flashIconName),
color: environment.theme.chat.inputPanel.panelControlColor,
startingPosition: !component.cameraState.flashModeDidChange ? .end : .begin,
size: CGSize(width: 40.0, height: 40.0),
loop: false,
playOnce: flashAction,
lottieSettings: component.context.lottieRenderingSettings
)
)
)
} else {
flashContentComponent = AnyComponentWithIdentity(
id: "staticIcon",
component: AnyComponent(
Image(
image: state.image(.flash, theme: environment.theme),
tintColor: environment.theme.chat.inputPanel.panelControlColor,
size: CGSize(width: 30.0, height: 30.0)
)
)
)
}
if !environment.metrics.isTablet {
let flashButton = flashButton.update(
component: CameraButton(
content: flashContentComponent,
minSize: CGSize(width: 40.0, height: 40.0),
isExclusive: false,
action: { [weak state] in
if let state {
state.toggleFlashMode()
Queue.mainQueue().justDispatch {
flashAction.invoke(Void())
}
}
}
),
availableSize: availableSize,
transition: context.transition
)
let flashButtonBackground = flashButtonBackground.update(
component: GlassBackgroundComponent(
size: CGSize(width: 40.0, height: 40.0),
cornerRadius: 40.0 * 0.5,
isDark: environment.theme.overallDarkAppearance,
tintColor: .init(kind: .panel)
),
availableSize: CGSize(width: 40.0, height: 40.0),
transition: .immediate
)
context.add(flashButtonBackground
.position(CGPoint(x: flipButton.size.width + sideInset + flashButton.size.width / 2.0 + 11.0, y: availableSize.height - flashButton.size.height / 2.0 - 8.0))
.appear(.default(scale: true, alpha: true))
.disappear(.default(scale: true, alpha: true))
)
context.add(flashButton
.position(CGPoint(x: flipButton.size.width + sideInset + flashButton.size.width / 2.0 + 11.0, y: availableSize.height - flashButton.size.height / 2.0 - 8.0))
.appear(.default(scale: true, alpha: true))
.disappear(.default(scale: true, alpha: true))
)
}
}
if showViewOnce {
let viewOnceButton = viewOnceButton.update(
component: PlainButtonComponent(
content: AnyComponent(
ZStack([
AnyComponentWithIdentity(
id: "background",
component: AnyComponent(
GlassBackgroundComponent(
size: CGSize(width: 40.0, height: 40.0),
cornerRadius: 40.0 * 0.5,
isDark: environment.theme.overallDarkAppearance,
tintColor: .init(kind: .panel)
)
)
),
AnyComponentWithIdentity(
id: "icon",
component: AnyComponent(
BundleIconComponent(
name: component.cameraState.isViewOnceEnabled ? "Media Gallery/ViewOnceEnabled" : "Media Gallery/ViewOnce",
tintColor: environment.theme.chat.inputPanel.panelControlColor
)
)
)
])
),
effectAlignment: .center,
action: { [weak state] in
if let state {
state.toggleViewOnce()
}
},
animateAlpha: false,
tag: viewOnceButtonTag
),
availableSize: availableSize,
transition: context.transition
)
context.add(viewOnceButton
.position(CGPoint(x: availableSize.width - viewOnceButton.size.width / 2.0 - sideInset, y: availableSize.height - viewOnceButton.size.height / 2.0 - 8.0 - viewOnceOffset))
.appear(.default(scale: true, alpha: true))
.disappear(.default(scale: true, alpha: true))
)
}
if showRecordMore {
let recordMoreButton = recordMoreButton.update(
component: PlainButtonComponent(
content: AnyComponent(
ZStack([
AnyComponentWithIdentity(
id: "background",
component: AnyComponent(GlassBackgroundComponent(
size: CGSize(width: 40.0, height: 40.0),
cornerRadius: 40.0 * 0.5,
isDark: environment.theme.overallDarkAppearance,
tintColor: .init(kind: .panel)
))
),
AnyComponentWithIdentity(
id: "icon",
component: AnyComponent(
BundleIconComponent(
name: "Chat/Input/Text/IconVideo",
tintColor: environment.theme.chat.inputPanel.panelControlColor
)
)
)
])
),
effectAlignment: .center,
action: { [weak state] in
state?.startVideoRecording(pressing: false)
}
),
availableSize: availableSize,
transition: context.transition
)
context.add(recordMoreButton
.position(CGPoint(x: availableSize.width - recordMoreButton.size.width / 2.0 - sideInset, y: availableSize.height - recordMoreButton.size.height / 2.0 - 22.0))
.appear(.default(scale: true, alpha: true))
.disappear(.default(scale: true, alpha: true))
)
}
if component.isPreviewing && component.isMuted {
let muteIcon = muteIcon.update(
component: ZStack([
AnyComponentWithIdentity(
id: "background",
component: AnyComponent(
RoundedRectangle(color: UIColor(rgb: 0x000000, alpha: 0.3), cornerRadius: 24.0)
)
),
AnyComponentWithIdentity(
id: "icon",
component: AnyComponent(
BundleIconComponent(
name: "Chat/Message/InstantVideoMute",
tintColor: .white
)
)
)
]),
availableSize: CGSize(width: 24.0, height: 24.0),
transition: context.transition
)
context.add(muteIcon
.position(CGPoint(x: component.previewFrame.midX, y: component.previewFrame.maxY - 24.0))
.appear(.default(scale: true, alpha: true))
.disappear(.default(scale: true, alpha: true))
)
}
return context.availableSize
}
}
}
public class VideoMessageCameraScreen: ViewController {
public enum CaptureResult {
public struct Video {
public let videoPath: String
public let dimensions: PixelDimensions
public let duration: Double
public let thumbnail: UIImage
}
case video(Video)
}
fileprivate final class Node: ViewControllerTracingNode, ASGestureRecognizerDelegate {
private weak var controller: VideoMessageCameraScreen?
private let context: AccountContext
private var cameraImpl: CameraImpl // GHOST: `var` — the legacy camera replaces the Metal one that failed (ggFallBackToLegacyCamera)
fileprivate var camera: CameraProtocol?
private var pinchStartTimestamp: Double?
private let updateState: ActionSlot<CameraState>
fileprivate var liveUploadInterface: LegacyLiveUploadInterface?
private var currentLiveUploadPath: String?
fileprivate var currentLiveUploadData: LegacyLiveUploadInterfaceResult?
fileprivate let backgroundView: UIVisualEffectView
fileprivate let containerView: UIView
fileprivate let componentHost: ComponentView<ViewControllerComponentContainer.Environment>
fileprivate let previewContainerView: UIView
fileprivate let previewContainerContentView: UIView
private var previewSnapshotView: UIView?
private var previewBlurView: BlurView
fileprivate var mainPreviewView: CameraSimplePreviewView
fileprivate var additionalPreviewView: CameraSimplePreviewView
private var progressView: RecordingProgressView
private let loadingView: LoadingEffectView
private var resultPreviewView: ResultPreviewView?
private var cameraStateDisposable: Disposable?
// GHOST: Ghostgram round video settings and masks of this screen
fileprivate let ggSupport: GGRoundCameraSupport
private let idleTimerExtensionDisposable = MetaDisposable()
fileprivate var cameraIsActive = true {
didSet {
if self.cameraIsActive {
self.idleTimerExtensionDisposable.set(self.context.sharedContext.applicationBindings.pushIdleTimerExtension())
} else {
self.idleTimerExtensionDisposable.set(nil)
}
}
}
private var presentationData: PresentationData
private var validLayout: ContainerViewLayout?
fileprivate var didAppear: () -> Void = {}
fileprivate let startRecording = ActionSlot<Void>()
fileprivate let stopRecording = ActionSlot<Void>()
fileprivate let cancelRecording = ActionSlot<Void>()
private let completion = ActionSlot<VideoMessageCameraScreen.CaptureResult>()
var cameraState: CameraState {
didSet {
if self.cameraState.isViewOnceEnabled != oldValue.isViewOnceEnabled {
if self.cameraState.isViewOnceEnabled {
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
self.displayViewOnceTooltip(text: presentationData.strings.Chat_PlayVideoMessageOnceTooltip, hasIcon: true)
let _ = ApplicationSpecificNotice.incrementVideoMessagesPlayOnceSuggestion(accountManager: self.context.sharedContext.accountManager, count: 3).startStandalone()
} else {
self.dismissAllTooltips()
}
}
}
}
var previewState: PreviewState? {
didSet {
self.previewStatePromise.set(.single(self.previewState))
self.resultPreviewView?.isMuted = self.previewState?.isMuted ?? true
}
}
var previewStatePromise = Promise<PreviewState?>()
var transitioningToPreview = false
init(controller: VideoMessageCameraScreen) {
self.controller = controller
self.context = controller.context
let useModernVideoMessagePipeline = GGRoundCameraSupport.usesModernCamera(upstream: self.context.sharedContext.immediateExperimentalUISettings.useModernVideoMessagePipeline ?? (self.context.getAppConfigValue("ios_killswitch_disable_neo_round_camera_v2") == nil)) // GHOST: not after the Metal camera failed in this run
if useModernVideoMessagePipeline {
self.cameraImpl = NeoCameraImpl.shared
} else {
self.cameraImpl = LegacyCameraImpl.shared
}
self.updateState = ActionSlot<CameraState>()
self.presentationData = controller.updatedPresentationData?.initial ?? self.context.sharedContext.currentPresentationData.with { $0 }
self.backgroundView = UIVisualEffectView(effect: UIBlurEffect(style: self.presentationData.theme.overallDarkAppearance ? .dark : .light))
self.containerView = UIView()
self.componentHost = ComponentView<ViewControllerComponentContainer.Environment>()
self.previewContainerView = UIView()
self.previewContainerContentView = UIView()
self.previewContainerContentView.clipsToBounds = true
self.previewContainerView.addSubview(self.previewContainerContentView)
// GHOST: rear camera at start (SG-F4), 60 fps and zoom below 1× without the dual camera, masks
let ggSupport = GGRoundCameraSupport(context: controller.context, usesModernCamera: useModernVideoMessagePipeline, present: { [weak controller] c in
controller?.view.window?.rootViewController?.present(c, animated: true)
})
self.ggSupport = ggSupport
let isDualCameraEnabled = cameraImpl.isDualCameraSupported(forRoundVideo: true) && !ggSupport.disablesDualCamera
let isFrontPosition = GGRoundCameraSupport.startsWithFrontCamera
self.mainPreviewView = cameraImpl.makeCameraSimplePreviewView(frame: .zero, main: true, roundVideo: true)
self.additionalPreviewView = cameraImpl.makeCameraSimplePreviewView(frame: .zero, main: false, roundVideo: true)
self.progressView = RecordingProgressView(frame: .zero)
self.loadingView = LoadingEffectView(effectAlpha: 0.1, borderAlpha: 0.25, duration: 1.0)
self.previewBlurView = BlurView()
self.previewBlurView.isUserInteractionEnabled = false
if isDualCameraEnabled {
self.mainPreviewView.resetPlaceholder(front: false)
self.additionalPreviewView.resetPlaceholder(front: true)
} else {
self.mainPreviewView.resetPlaceholder(front: isFrontPosition)
}
self.cameraState = CameraState(
position: isFrontPosition ? .front : .back,
flashMode: .off,
flashModeDidChange: false,
flashTint: .white,
flashTintSize: 1.0,
recording: .none,
duration: 0.0,
isDualCameraEnabled: isDualCameraEnabled,
isViewOnceEnabled: GGRoundCameraSupport.isViewOnceEnabledInitially(available: controller.viewOnceAvailable) // GHOST: «view once» by default
)
self.previewState = nil
super.init()
self.backgroundColor = .clear
self.view.addSubview(self.containerView)
self.containerView.addSubview(self.previewContainerView)
self.previewContainerContentView.addSubview(self.mainPreviewView)
if isDualCameraEnabled {
self.previewContainerContentView.addSubview(self.additionalPreviewView)
}
self.previewContainerContentView.addSubview(self.progressView)
self.previewContainerContentView.addSubview(self.previewBlurView)
self.previewContainerContentView.addSubview(self.loadingView)
// GHOST: masks preview inside the circle and the masks panel
self.ggSupport.attach(containerView: self.containerView, previewContentView: self.previewContainerContentView, below: self.progressView)
self.completion.connect { [weak self] result in
if let self {
// GHOST: audio post-processing of the clip before it is added (normalisation, no sound, voice changer)
if case let .video(video) = result {
self.ggSupport.processClip(path: video.videoPath, completion: { [weak self] path, isLate in // GHOST: nil = refused, isLate = back after the next recording started
self?.ggAddProcessedClip(path.flatMap { .video(VideoMessageCameraScreen.CaptureResult.Video(videoPath: $0, dimensions: video.dimensions, duration: video.duration, thumbnail: video.thumbnail)) }, isLate: isLate)
})
return
}
self.addCaptureResult(result)
}
}
if isDualCameraEnabled {
self.mainPreviewView.removePlaceholder(delay: 0.0)
}
self.withReadyCamera(isFirstTime: true, { [weak self] in
guard let self else {
return
}
if !isDualCameraEnabled {
self.mainPreviewView.removePlaceholder(delay: 0.0)
}
self.loadingView.alpha = 0.0
self.additionalPreviewView.removePlaceholder(delay: 0.0)
})
self.idleTimerExtensionDisposable.set(self.context.sharedContext.applicationBindings.pushIdleTimerExtension())
}
deinit {
self.cameraStateDisposable?.dispose()
self.idleTimerExtensionDisposable.dispose()
self.backgroundView.removeFromSuperview()
self.ggSupport.invalidate() // GHOST: stop the masks
}
func withReadyCamera(isFirstTime: Bool = false, _ f: @escaping () -> Void) {
let previewReady: Signal<Bool, NoError>
if #available(iOS 13.0, *) {
previewReady = self.cameraState.isDualCameraEnabled ? self.additionalPreviewView.isPreviewing : self.mainPreviewView.isPreviewing |> delay(0.3, queue: Queue.mainQueue())
} else {
previewReady = .single(true) |> delay(0.35, queue: Queue.mainQueue())
}
let _ = (previewReady
|> filter { $0 }
|> take(1)
|> deliverOnMainQueue).startStandalone(next: { _ in
f()
})
}
func setupLiveUpload(filePath: String) {
guard let controller = self.controller, controller.allowLiveUpload, self.liveUploadInterface == nil else {
return
}
// GHOST: the raw voice must not be uploaded when the audio is going to be processed
guard self.ggSupport.allowsLiveUpload else {
return
}
let liveUploadInterface = LegacyLiveUploadInterface(context: self.context)
Queue.mainQueue().after(1.5, {
liveUploadInterface.setup(withFileURL: URL(fileURLWithPath: filePath))
})
self.liveUploadInterface = liveUploadInterface
}
override func didLoad() {
super.didLoad()
self.view.disablesInteractiveModalDismiss = true
self.view.disablesInteractiveKeyboardGestureRecognizer = true
let pinchGestureRecognizer = UIPinchGestureRecognizer(target: self, action: #selector(self.handlePinch(_:)))
self.view.addGestureRecognizer(pinchGestureRecognizer)
}
fileprivate func setupCamera() {
guard self.camera == nil else {
return
}
let camera = self.cameraImpl.makeCamera(
configuration: Camera.Configuration(
preset: .hd1920x1080,
position: self.cameraState.position,
isDualEnabled: self.cameraState.isDualCameraEnabled,
audio: true,
photo: false,
metadata: false,
isRoundVideo: true,
frameProcessor: self.ggSupport.frameProcessor // GHOST: masks
),
previewView: self.mainPreviewView,
secondaryPreviewView: self.additionalPreviewView
)
self.cameraStateDisposable = combineLatest(
queue: Queue.mainQueue(),
camera.flashMode,
camera.position
).startStrict(next: { [weak self] flashMode, position in
guard let self else {
return
}
self.cameraState = self.cameraState.updatedPosition(position).updatedFlashMode(flashMode)
if !self.cameraState.isDualCameraEnabled {
self.animatePositionChange()
}
self.requestUpdateLayout(transition: .easeInOut(duration: 0.2))
})
camera.focus(at: CGPoint(x: 0.5, y: 0.5), autoFocus: true)
camera.startCapture()
self.camera = camera
self.ggSupport.cameraDidStart(camera) // GHOST: start zoom
Queue.mainQueue().justDispatch {
// GHOST: 3-2-1 countdown before the recording starts
if self.ggSupport.startRecordingAfterCountdown({ [startRecording = self.startRecording] in startRecording.invoke(Void()) }) {
return
}
self.startRecording.invoke(Void())
}
}
// GHOST: Telegram's Metal camera failed before the first clip (report of build 196: no picture,
// the screen closed at once): the legacy camera takes over and the recording starts again, as
// the old client recorded round videos; the next screens of this run start with it
// (GGRoundCameraSupport.usesModernCamera). false = nothing to fall back to.
fileprivate func ggFallBackToLegacyCamera(error: CameraRecordingError) -> Bool {
guard let controller = self.controller, !(self.cameraImpl is LegacyCameraImpl), self.results.isEmpty, self.ggSupport.pendingClipCount == 0, self.previewState == nil else {
return false
}
Logger.shared.log("VideoMessageCamera", "Metal camera failed (\(error)), falling back to the legacy camera")
let wasLocked = controller.cameraState.recording == .handsFree || controller.scheduledLock
self.cameraStateDisposable?.dispose()
self.cameraStateDisposable = nil
self.camera?.stopCapture(invalidate: true)
self.camera = nil
self.ggSupport.cameraDidFallBackToLegacy()
let cameraImpl: CameraImpl = LegacyCameraImpl.shared
self.cameraImpl = cameraImpl
let isDualCameraEnabled = cameraImpl.isDualCameraSupported(forRoundVideo: true) && !self.ggSupport.disablesDualCamera
let mainPreviewView = cameraImpl.makeCameraSimplePreviewView(frame: self.mainPreviewView.frame, main: true, roundVideo: true)
let additionalPreviewView = cameraImpl.makeCameraSimplePreviewView(frame: self.additionalPreviewView.frame, main: false, roundVideo: true)
self.previewContainerContentView.insertSubview(mainPreviewView, belowSubview: self.mainPreviewView)
if isDualCameraEnabled {
self.previewContainerContentView.insertSubview(additionalPreviewView, aboveSubview: mainPreviewView)
}
self.mainPreviewView.removeFromSuperview()
self.additionalPreviewView.removeFromSuperview()
self.mainPreviewView = mainPreviewView
self.additionalPreviewView = additionalPreviewView
if isDualCameraEnabled {
mainPreviewView.resetPlaceholder(front: false)
additionalPreviewView.resetPlaceholder(front: true)
mainPreviewView.removePlaceholder(delay: 0.0)
} else {
mainPreviewView.resetPlaceholder(front: self.cameraState.position == .front)
}
let state = self.cameraState
self.cameraState = CameraState(position: state.position, flashMode: state.flashMode, flashModeDidChange: false, flashTint: state.flashTint, flashTintSize: state.flashTintSize, recording: .none, duration: 0.0, isDualCameraEnabled: isDualCameraEnabled, isViewOnceEnabled: state.isViewOnceEnabled)
controller.scheduledLock = wasLocked
controller.lastActionTimestamp = nil
self.requestUpdateLayout(transition: .immediate)
self.withReadyCamera(isFirstTime: true, { [weak self] in
guard let self else {
return
}
if !isDualCameraEnabled {
self.mainPreviewView.removePlaceholder(delay: 0.0)
}
self.additionalPreviewView.removePlaceholder(delay: 0.0)
})
// The audio session of the screen is still active: the legacy camera starts at once and
// `setupCamera` starts the recording again.
self.setupCamera()
return true
}
@objc private func handlePinch(_ gestureRecognizer: UIPinchGestureRecognizer) {
// GHOST: «remember the zoom»: the pinch keeps its zoom instead of snapping back
if self.ggSupport.handlePinch(gestureRecognizer) {
return
}
switch gestureRecognizer.state {
case .began:
self.pinchStartTimestamp = CACurrentMediaTime()
case .changed:
guard let camera = self.camera else {
return
}
let scale = gestureRecognizer.scale
camera.setZoomDelta(scale)
gestureRecognizer.scale = 1.0
case .ended:
let pinchStartTimestamp = self.pinchStartTimestamp
self.pinchStartTimestamp = nil
if let pinchStartTimestamp, CACurrentMediaTime() - pinchStartTimestamp <= 0.5 {
return
}
self.camera?.rampZoom(1.0, rate: 8.0)
self.ggSupport.didResetZoom() // GHOST: keep the zoom slider in sync
case .cancelled:
self.pinchStartTimestamp = nil
self.camera?.rampZoom(1.0, rate: 8.0)
self.ggSupport.didResetZoom() // GHOST: keep the zoom slider in sync
case .failed:
self.pinchStartTimestamp = nil
default:
break
}
}
private var animatingIn = false
func animateIn() {
self.animatingIn = true
self.backgroundView.alpha = 0.0
UIView.animate(withDuration: 0.4, animations: {
self.backgroundView.alpha = 1.0
})
let targetPosition = self.previewContainerView.center
self.previewContainerView.center = CGPoint(x: targetPosition.x, y: self.frame.height + self.previewContainerView.frame.height / 2.0)
UIView.animate(withDuration: 0.5, delay: 0.0, usingSpringWithDamping: 0.8, initialSpringVelocity: 0.2, animations: {
self.previewContainerView.center = targetPosition
}, completion: { [weak self] _ in
self?.animatingIn = false
})
if let view = self.componentHost.view {
view.layer.animateAlpha(from: 0.1, to: 1.0, duration: 0.25)
}
}
func animateOut(completion: @escaping () -> Void) {
self.camera?.stopCapture(invalidate: true)
self.ggSupport.invalidate() // GHOST: stop the masks
UIView.animate(withDuration: 0.25, animations: {
self.backgroundView.alpha = 0.0
}, completion: { [weak self] _ in
self?.backgroundView.removeFromSuperview()
completion()
})
self.componentHost.view?.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.15, removeOnCompletion: false)
self.previewContainerView.layer.animateAlpha(from: 1.0, to: 0.0, duration: 0.25, removeOnCompletion: false)
}
private func animatePositionChange() {
if let snapshotView = self.mainPreviewView.snapshotView(afterScreenUpdates: false) {
self.previewContainerContentView.insertSubview(snapshotView, belowSubview: self.progressView)
self.previewSnapshotView = snapshotView
let action = { [weak self] in
guard let self else {
return
}
UIView.animate(withDuration: 0.2, animations: {
self.previewSnapshotView?.alpha = 0.0
}, completion: { [weak self] _ in
self?.previewSnapshotView?.removeFromSuperview()
self?.previewSnapshotView = nil
})
}
// GHOST: with the flip animation the snapshot goes once the new camera runs (the second stays the ceiling)
self.ggSupport.holdPositionChangeSnapshot(camera: self.camera, previewView: self.mainPreviewView, release: action)
self.requestUpdateLayout(transition: .immediate)
}
}
func pauseCameraCapture() {
self.mainPreviewView.isEnabled = false
self.additionalPreviewView.isEnabled = false
self.camera?.stopCapture()
self.cameraIsActive = false
self.requestUpdateLayout(transition: .immediate)
}
func resumeCameraCapture() {
if !self.mainPreviewView.isEnabled {
if let snapshotView = self.resultPreviewView?.snapshotView(afterScreenUpdates: false) {
self.previewContainerContentView.insertSubview(snapshotView, belowSubview: self.previewBlurView)
self.previewSnapshotView = snapshotView
}
self.mainPreviewView.isEnabled = true
self.additionalPreviewView.isEnabled = true
self.camera?.startCapture()
UIView.animate(withDuration: 0.25, animations: {
self.loadingView.alpha = 1.0
self.previewBlurView.effect = UIBlurEffect(style: .dark)
})
let action = { [weak self] in
guard let self else {
return
}
UIView.animate(withDuration: 0.4, animations: {
self.previewBlurView.effect = nil
self.previewSnapshotView?.alpha = 0.0
}, completion: { [weak self] _ in
self?.previewSnapshotView?.removeFromSuperview()
self?.previewSnapshotView = nil
})
}
let _ = (self.mainPreviewView.isPreviewing
|> filter { $0 }
|> take(1)).startStandalone(next: { _ in
action()
})
self.cameraIsActive = true
self.requestUpdateLayout(transition: .immediate)
}
}
fileprivate var results: [VideoMessageCameraScreen.CaptureResult] = []
fileprivate var resultsPipe = ValuePipe<VideoMessageCameraScreen.CaptureResult>()
// GHOST: a clip back from the audio processing (GGRoundCameraSupport.processClip). nil = refused (the voice could not be hidden): nothing is sent, the camera closes with a message. A clip back while the next one is being recorded is only appended: no pause of the running camera, no preview.
fileprivate func ggAddProcessedClip(_ result: VideoMessageCameraScreen.CaptureResult?, isLate: Bool) {
guard let controller = self.controller else {
return
}
guard let result else {
self.ggSupport.presentAudioFailure(on: controller)
controller.completion(nil, nil, nil, nil)
return
}
if isLate && controller.cameraState.recording != .none {
self.results.append(result)
self.resultsPipe.putNext(result)
return
}
self.addCaptureResult(result)
}
func addCaptureResult(_ result: VideoMessageCameraScreen.CaptureResult) {
guard let controller = self.controller else {
return
}
if self.results.isEmpty {
if let liveUploadData = self.liveUploadInterface?.fileUpdated(true) as? LegacyLiveUploadInterfaceResult {
self.currentLiveUploadData = liveUploadData
}
} else {
self.currentLiveUploadData = nil
}
let _ = ApplicationSpecificNotice.incrementVideoMessagesPauseSuggestion(accountManager: self.context.sharedContext.accountManager, count: 3).startStandalone()
self.pauseCameraCapture()
self.results.append(result)
self.resultsPipe.putNext(result)
self.transitioningToPreview = false
if !controller.isSendingImmediately {
let composition = composition(with: self.results)
controller.updatePreviewState({ _ in
return PreviewState(composition: composition, trimRange: nil, isMuted: true)
}, transition: .spring(duration: 0.4))
}
}
private func debugSaveResult(path: String) {
guard let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) else {
return
}
let id = Int64.random(in: Int64.min ... Int64.max)
let fileResource = LocalFileReferenceMediaResource(localFilePath: path, randomId: id)
let file = TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: Int64(data.count), attributes: [.FileName(fileName: "video.mp4")], alternativeRepresentations: [])
let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])
let _ = enqueueMessages(account: self.context.engine.account, peerId: self.context.engine.account.peerId, messages: [message]).startStandalone()
}
/// Whether `view` is a control the component host put on screen (the record-more, view-once,
/// flip and flash buttons are all `UIButton`s; the mute badge and the front-flash image are not).
private func isComponentControl(_ view: UIView?) -> Bool {
guard let view, let componentView = self.componentHost.view, view.isDescendant(of: componentView) else {
return false
}
if view is UIControl {
return true
}
if let gestureRecognizers = view.gestureRecognizers, !gestureRecognizers.isEmpty {
return true
}
return false
}
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
let result = super.hitTest(point, with: event)
// The preview is claimed by its *bounding square*, while only the inscribed circle is
// visible - `previewContainerContentView` clips to a corner radius. The component host is a
// full-screen sibling above `previewContainerView`, and when the keyboard is up the container
// is only as tall as the input panel's top edge, so on a small screen the controls pinned to
// its bottom edge sit inside that square's bottom corners while remaining outside the circle.
// `super.hitTest` has already resolved which of the two the touch really landed on, so only
// fall back to the preview when it did not land on a control.
if let resultPreviewView = self.resultPreviewView, !self.isComponentControl(result) {
if resultPreviewView.bounds.contains(self.view.convert(point, to: resultPreviewView)) {
return resultPreviewView
}
}
if let controller = self.controller, let layout = self.validLayout {
let insets = layout.insets(options: .input)
if point.y > layout.size.height - max(insets.bottom, layout.additionalInsets.bottom) - controller.inputPanelFrame.0.height {
if layout.metrics.isTablet {
if point.x < layout.size.width * 0.33 {
return result
}
}
return nil
}
}
return result
}
fileprivate func maybePresentTooltips() {
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
let _ = (ApplicationSpecificNotice.getVideoMessagesPauseSuggestion(accountManager: self.context.sharedContext.accountManager)
|> deliverOnMainQueue).startStandalone(next: { [weak self] pauseCounter in
guard let self else {
return
}
if pauseCounter >= 3 {
let _ = (ApplicationSpecificNotice.getVideoMessagesPlayOnceSuggestion(accountManager: self.context.sharedContext.accountManager)
|> deliverOnMainQueue).startStandalone(next: { [weak self] counter in
guard let self else {
return
}
if counter >= 3 {
return
}
Queue.mainQueue().after(0.3) {
self.displayViewOnceTooltip(text: presentationData.strings.Chat_TapToPlayVideoMessageOnceTooltip, hasIcon: true)
}
let _ = ApplicationSpecificNotice.incrementVideoMessagesPlayOnceSuggestion(accountManager: self.context.sharedContext.accountManager).startStandalone()
})
} else {
Queue.mainQueue().after(0.3) {
self.displayPauseTooltip(text: presentationData.strings.Chat_PauseVideoMessageTooltip)
}
let _ = ApplicationSpecificNotice.incrementVideoMessagesPauseSuggestion(accountManager: self.context.sharedContext.accountManager).startStandalone()
}
})
}
private func displayViewOnceTooltip(text: String, hasIcon: Bool) {
guard let controller = self.controller, let sourceView = self.componentHost.findTaggedView(tag: viewOnceButtonTag) else {
return
}
self.dismissAllTooltips()
let absoluteFrame = sourceView.convert(sourceView.bounds, to: self.view)
let location = CGRect(origin: CGPoint(x: absoluteFrame.midX - 20.0, y: absoluteFrame.midY), size: CGSize())
let tooltipController = TooltipScreen(
account: context.account,
sharedContext: context.sharedContext,
text: .markdown(text: text),
balancedTextLayout: true,
constrainWidth: 240.0,
style: .customBlur(UIColor(rgb: 0x18181a), 0.0),
arrowStyle: .small,
icon: hasIcon ? .animation(name: "anim_autoremove_on", delay: 0.1, tintColor: nil) : nil,
location: .point(location, .right),
displayDuration: .default,
inset: 8.0,
cornerRadius: 8.0,
shouldDismissOnTouch: { _, _ in
return .ignore
}
)
controller.present(tooltipController, in: .window(.root))
}
private func displayPauseTooltip(text: String) {
guard let controller = self.controller, let sourceView = self.componentHost.findTaggedView(tag: viewOnceButtonTag) else {
return
}
self.dismissAllTooltips()
let absoluteFrame = sourceView.convert(sourceView.bounds, to: self.view)
let location = CGRect(origin: CGPoint(x: absoluteFrame.midX - 20.0, y: absoluteFrame.midY + 53.0), size: CGSize())
let tooltipController = TooltipScreen(
account: context.account,
sharedContext: context.sharedContext,
text: .markdown(text: text),
balancedTextLayout: true,
constrainWidth: 240.0,
style: .customBlur(UIColor(rgb: 0x18181a), 0.0),
arrowStyle: .small,
icon: nil,
location: .point(location, .right),
displayDuration: .default,
inset: 8.0,
cornerRadius: 8.0,
shouldDismissOnTouch: { _, _ in
return .ignore
}
)
controller.present(tooltipController, in: .window(.root))
}
fileprivate func dismissAllTooltips() {
guard let controller = self.controller else {
return
}
controller.window?.forEachController({ controller in
if let controller = controller as? TooltipScreen {
controller.dismiss()
}
})
controller.forEachController({ controller in
if let controller = controller as? TooltipScreen {
controller.dismiss()
}
return true
})
}
func updateTrimRange(start: Double, end: Double, updatedEnd: Bool, apply: Bool) {
guard let controller = self.controller else {
return
}
self.resultPreviewView?.updateTrimRange(start: start, end: end, updatedEnd: updatedEnd, apply: apply)
controller.updatePreviewState({ state in
if let state {
return PreviewState(composition: state.composition, trimRange: start..<end, isMuted: state.isMuted)
} else {
return nil
}
}, transition: .immediate)
}
@objc func resultTapped() {
guard let controller = self.controller else {
return
}
controller.updatePreviewState({ state in
if let state {
return PreviewState(composition: state.composition, trimRange: state.trimRange, isMuted: !state.isMuted)
} else {
return nil
}
}, transition: .easeInOut(duration: 0.2))
}
func requestUpdateLayout(transition: ComponentTransition) {
if let layout = self.validLayout {
self.containerLayoutUpdated(layout: layout, forceUpdate: true, transition: transition)
}
}
func containerLayoutUpdated(layout: ContainerViewLayout, forceUpdate: Bool = false, transition: ComponentTransition) {
guard let controller = self.controller else {
return
}
let isFirstTime = self.validLayout == nil
self.validLayout = layout
let environment = ViewControllerComponentContainer.Environment(
statusBarHeight: layout.statusBarHeight ?? 0.0,
navigationHeight: 0.0,
safeInsets: UIEdgeInsets(
top: (layout.statusBarHeight ?? 0.0) + 5.0,
left: layout.safeInsets.left,
bottom: 44.0,
right: layout.safeInsets.right
),
additionalInsets: layout.additionalInsets,
inputHeight: layout.inputHeight ?? 0.0,
metrics: layout.metrics,
deviceMetrics: layout.deviceMetrics,
orientation: nil,
isVisible: true,
theme: self.presentationData.theme,
strings: self.presentationData.strings,
dateTimeFormat: self.presentationData.dateTimeFormat,
controller: { [weak self] in
return self?.controller
}
)
if isFirstTime {
self.didAppear()
}
var backgroundFrame = CGRect(origin: .zero, size: CGSize(width: layout.size.width, height: controller.inputPanelFrame.0.minY))
let actualBackgroundFrame = CGRect(origin: .zero, size: CGSize(width: layout.size.width, height: layout.size.height))
if backgroundFrame.maxY < layout.size.height - 100.0 && (layout.inputHeight ?? 0.0).isZero && !controller.inputPanelFrame.1 && layout.additionalInsets.bottom.isZero {
backgroundFrame = CGRect(origin: .zero, size: CGSize(width: layout.size.width, height: layout.size.height - layout.intrinsicInsets.bottom - controller.inputPanelFrame.0.height - 8.0))
}
transition.setPosition(view: self.backgroundView, position: actualBackgroundFrame.center)
transition.setBounds(view: self.backgroundView, bounds: CGRect(origin: .zero, size: actualBackgroundFrame.size))
transition.setPosition(view: self.containerView, position: backgroundFrame.center)
transition.setBounds(view: self.containerView, bounds: CGRect(origin: .zero, size: backgroundFrame.size))
let availableHeight = layout.size.height - (layout.inputHeight ?? 0.0)
let previewSide = min(369.0, layout.size.width - 24.0)
let previewFrame: CGRect
if layout.metrics.isTablet {
// Use the scene this view actually lives in; `connectedScenes` is unordered and a
// tablet can have several window scenes.
let statusBarOrientation = self.containerView.window?.windowScene?.interfaceOrientation ?? .portrait
if statusBarOrientation == .landscapeLeft {
previewFrame = CGRect(origin: CGPoint(x: layout.size.width - 44.0 - previewSide, y: floorToScreenPixels((layout.size.height - previewSide) / 2.0)), size: CGSize(width: previewSide, height: previewSide))
} else if statusBarOrientation == .landscapeRight {
previewFrame = CGRect(origin: CGPoint(x: 44.0, y: floorToScreenPixels((layout.size.height - previewSide) / 2.0)), size: CGSize(width: previewSide, height: previewSide))
} else {
previewFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - previewSide) / 2.0), y: max(layout.statusBarHeight ?? 0.0 + 24.0, availableHeight * 0.2 - previewSide / 2.0)), size: CGSize(width: previewSide, height: previewSide))
}
} else {
previewFrame = CGRect(origin: CGPoint(x: floorToScreenPixels((layout.size.width - previewSide) / 2.0), y: max(layout.statusBarHeight ?? 0.0 + 24.0, availableHeight * 0.4 - previewSide / 2.0)), size: CGSize(width: previewSide, height: previewSide))
}
if !self.animatingIn {
transition.setFrame(view: self.previewContainerView, frame: previewFrame)
transition.setFrame(view: self.previewContainerContentView, frame: CGRect(origin: CGPoint(), size: previewFrame.size))
}
transition.setCornerRadius(layer: self.previewContainerContentView.layer, cornerRadius: previewSide / 2.0)
let previewBounds = CGRect(origin: .zero, size: previewFrame.size)
let previewInnerSize: CGSize
let additionalPreviewInnerSize: CGSize
if self.cameraState.isDualCameraEnabled {
previewInnerSize = CGSize(width: previewFrame.size.width, height: previewFrame.size.width / 9.0 * 16.0)
additionalPreviewInnerSize = CGSize(width: previewFrame.size.width, height: previewFrame.size.width / 3.0 * 4.0)
} else {
previewInnerSize = CGSize(width: previewFrame.size.width, height: previewFrame.size.width / 3.0 * 4.0)
additionalPreviewInnerSize = CGSize(width: previewFrame.size.width, height: previewFrame.size.width / 3.0 * 4.0)
}
let previewInnerFrame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((previewFrame.height - previewInnerSize.height) / 2.0)), size: previewInnerSize)
let additionalPreviewInnerFrame = CGRect(origin: CGPoint(x: 0.0, y: floorToScreenPixels((previewFrame.height - additionalPreviewInnerSize.height) / 2.0)), size: additionalPreviewInnerSize)
if self.cameraState.isDualCameraEnabled {
self.mainPreviewView.frame = previewInnerFrame
self.additionalPreviewView.frame = additionalPreviewInnerFrame
} else {
self.mainPreviewView.frame = self.cameraState.position == .front ? additionalPreviewInnerFrame : previewInnerFrame
}
self.progressView.frame = previewBounds
self.progressView.value = CGFloat(self.cameraState.duration / 60.0)
transition.setAlpha(view: self.additionalPreviewView, alpha: self.cameraState.position == .front ? 1.0 : 0.0)
self.previewBlurView.frame = previewBounds
self.previewSnapshotView?.center = previewBounds.center
self.loadingView.update(size: previewBounds.size, transition: .immediate)
let componentSize = self.componentHost.update(
transition: transition,
component: AnyComponent(
VideoMessageCameraScreenComponent(
context: self.context,
cameraState: self.cameraState,
containerSize: backgroundFrame.size,
previewFrame: previewFrame,
safeInsets: layout.safeInsets,
isPreviewing: self.previewState != nil || self.transitioningToPreview,
isMuted: self.previewState?.isMuted ?? true,
totalDuration: self.previewState?.composition.duration.seconds ?? 0.0,
getController: { [weak self] in
return self?.controller
},
present: { [weak self] c in
self?.controller?.present(c, in: .window(.root))
},
push: { [weak self] c in
self?.controller?.push(c)
},
startRecording: self.startRecording,
stopRecording: self.stopRecording,
cancelRecording: self.cancelRecording,
completion: self.completion
)
),
environment: {
environment
},
forceUpdate: forceUpdate,
containerSize: actualBackgroundFrame.size
)
if let componentView = self.componentHost.view {
if componentView.superview == nil {
self.containerView.addSubview(componentView)
}
let componentFrame = CGRect(origin: .zero, size: componentSize)
transition.setFrame(view: componentView, frame: componentFrame)
}
// GHOST: masks panel, masks preview, zoom slider, countdown
self.ggSupport.update(layout: GGRoundVideoCameraLayout(containerSize: backgroundFrame.size, previewFrame: previewFrame, isShowingResult: self.previewState != nil || self.transitioningToPreview, isRecording: self.cameraState.recording != .none, isFrontCamera: self.cameraState.position == .front, safeInsets: layout.safeInsets), animated: !transition.animation.isImmediate)
if let previewState = self.previewState {
if previewState.composition !== self.resultPreviewView?.composition {
self.resultPreviewView?.removeFromSuperview()
self.resultPreviewView = nil
}
let resultPreviewView: ResultPreviewView
if let current = self.resultPreviewView {
resultPreviewView = current
} else {
resultPreviewView = ResultPreviewView(composition: previewState.composition)
resultPreviewView.onLoop = { [weak self] in
if let self, let controller = self.controller {
controller.updatePreviewState({ state in
if let state {
return PreviewState(composition: state.composition, trimRange: state.trimRange, isMuted: true)
}
return nil
}, transition: .easeInOut(duration: 0.2))
}
}
self.previewContainerContentView.addSubview(resultPreviewView)
self.resultPreviewView = resultPreviewView
resultPreviewView.layer.animateAlpha(from: 0.0, to: 1.0, duration: 0.2)
resultPreviewView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(self.resultTapped)))
}
resultPreviewView.frame = previewBounds
} else if let resultPreviewView = self.resultPreviewView {
self.resultPreviewView = nil
resultPreviewView.removeFromSuperview()
}
if isFirstTime {
self.animateIn()
}
}
}
fileprivate var node: Node {
return self.displayNode as! Node
}
private let context: AccountContext
private let updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?
private let inputPanelFrame: (CGRect, Bool)
fileprivate var allowLiveUpload: Bool
fileprivate var viewOnceAvailable: Bool
fileprivate let completion: (EnqueueMessage?, Bool?, Int32?, Int32?) -> Void
private var audioSessionDisposable: Disposable?
private let hapticFeedback = HapticFeedback()
private var validLayout: ContainerViewLayout?
fileprivate var camera: CameraProtocol? {
return self.node.camera
}
fileprivate var cameraState: CameraState {
return self.node.cameraState
}
fileprivate func updateCameraState(_ f: (CameraState) -> CameraState, transition: ComponentTransition) {
self.node.cameraState = f(self.node.cameraState)
self.node.requestUpdateLayout(transition: transition)
self.durationValue.set(self.cameraState.duration)
}
fileprivate func updatePreviewState(_ f: (PreviewState?) -> PreviewState?, transition: ComponentTransition) {
self.node.previewState = f(self.node.previewState)
self.node.requestUpdateLayout(transition: transition)
}
public final class RecordingStatus {
public let micLevel: Signal<Float, NoError>
public let duration: Signal<TimeInterval, NoError>
public init(micLevel: Signal<Float, NoError>, duration: Signal<TimeInterval, NoError>) {
self.micLevel = micLevel
self.duration = duration
}
}
private let micLevelValue = ValuePromise<Float>(0.0)
private let durationValue = ValuePromise<TimeInterval>(0.0)
public let recordingStatus: RecordingStatus
public var onStop: () -> Void = {
}
public var onResume: () -> Void = {
}
public var backgroundView: UIVisualEffectView {
return self.node.backgroundView
}
public struct RecordedVideoData {
public let duration: Double
public let frames: [UIImage]
public let framesUpdateTimestamp: Double
public let trimRange: Range<Double>?
}
private var currentResults: Signal<[VideoMessageCameraScreen.CaptureResult], NoError> {
// GHOST: clips in the audio post-processing are added later: wait for every one of them (GGRoundCameraSupport)
if let settled = self.node.ggSupport.settledResults(results: { [weak self] in self?.node.results ?? [] }, waitsForNextClip: self.waitingForNextResult) {
self.waitingForNextResult = false
return settled
}
var results: Signal<[VideoMessageCameraScreen.CaptureResult], NoError> = .single(self.node.results)
if self.waitingForNextResult {
results = results
|> mapToSignal { initial in
return self.node.resultsPipe.signal()
|> take(1)
|> map { next in
var updatedResults = initial
updatedResults.append(next)
return updatedResults
}
}
}
self.waitingForNextResult = false
return results
}
public func takenRecordedData() -> Signal<RecordedVideoData?, NoError> {
let previewState = self.node.previewStatePromise.get()
let count = 13
let initialPlaceholder: Signal<UIImage?, NoError>
if let firstResult = self.node.results.first {
if case let .video(video) = firstResult {
initialPlaceholder = .single(video.thumbnail)
} else {
initialPlaceholder = .single(nil)
}
} else {
initialPlaceholder = self.camera?.transitionImage ?? .single(nil)
}
var approximateDuration: Double
if let recordingStartTime = self.recordingStartTime {
approximateDuration = CACurrentMediaTime() - recordingStartTime
} else {
approximateDuration = 1.0
}
let immediateResult: Signal<RecordedVideoData?, NoError> = initialPlaceholder
|> take(1)
|> mapToSignal { initialPlaceholder in
return videoFrames(asset: nil, count: count, initialPlaceholder: initialPlaceholder)
|> map { framesAndUpdateTimestamp in
return RecordedVideoData(
duration: approximateDuration,
frames: framesAndUpdateTimestamp.0,
framesUpdateTimestamp: framesAndUpdateTimestamp.1,
trimRange: nil
)
}
}
return immediateResult
|> mapToSignal { immediateResult in
return .single(immediateResult)
|> then(
self.currentResults
|> take(1)
|> mapToSignal { results in
var totalDuration: Double = 0.0
for result in results {
if case let .video(video) = result {
totalDuration += video.duration
}
}
let composition = composition(with: results)
return combineLatest(
queue: Queue.mainQueue(),
videoFrames(asset: composition, count: count, initialTimestamp: immediateResult?.framesUpdateTimestamp),
previewState
)
|> map { framesAndUpdateTimestamp, previewState in
return RecordedVideoData(
duration: totalDuration,
frames: framesAndUpdateTimestamp.0,
framesUpdateTimestamp: framesAndUpdateTimestamp.1,
trimRange: previewState?.trimRange
)
}
}
)
}
}
fileprivate weak var chatNode: ASDisplayNode?
public init(
context: AccountContext,
updatedPresentationData: (initial: PresentationData, signal: Signal<PresentationData, NoError>)?,
allowLiveUpload: Bool,
viewOnceAvailable: Bool,
inputPanelFrame: (CGRect, Bool),
chatNode: ASDisplayNode?,
completion: @escaping (EnqueueMessage?, Bool?, Int32?, Int32?) -> Void
) {
self.context = context
self.updatedPresentationData = updatedPresentationData
self.allowLiveUpload = allowLiveUpload
self.viewOnceAvailable = viewOnceAvailable
self.inputPanelFrame = inputPanelFrame
self.chatNode = chatNode
self.completion = completion
self.recordingStatus = RecordingStatus(micLevel: self.micLevelValue.get(), duration: self.durationValue.get())
super.init(navigationBarPresentationData: nil)
self.statusBar.statusBarStyle = .Ignore
self.supportedOrientations = ViewControllerSupportedOrientations(regularSize: .all, compactSize: .portrait)
self.navigationPresentation = .flatModal
self.requestAudioSession()
}
required public init(coder: NSCoder) {
preconditionFailure()
}
deinit {
self.audioSessionDisposable?.dispose()
}
override public func loadDisplayNode() {
self.displayNode = Node(controller: self)
super.displayNodeDidLoad()
}
fileprivate var didSend = false
fileprivate var lastActionTimestamp: Double?
fileprivate var isSendingImmediately = false
public func sendVideoRecording(silentPosting: Bool? = nil, scheduleTime: Int32? = nil, repeatPeriod: Int32? = nil, messageEffect: ChatSendMessageEffect? = nil) {
guard !self.didSend else {
return
}
var skipAction = false
let currentTimestamp = CACurrentMediaTime()
if let lastActionTimestamp = self.lastActionTimestamp, currentTimestamp - lastActionTimestamp < 0.5 {
skipAction = true
}
if case .none = self.cameraState.recording, self.node.results.isEmpty, self.node.ggSupport.pendingClipCount == 0 /* GHOST: a clip still in the audio processing is waited for */ {
self.completion(nil, nil, nil, nil)
return
}
if case .none = self.cameraState.recording {
} else {
if self.cameraState.duration > 0.5 {
if skipAction {
return
}
self.isSendingImmediately = true
self.waitingForNextResult = true
self.node.stopRecording.invoke(Void())
} else {
self.completion(nil, nil, nil, nil)
return
}
}
guard !skipAction else {
return
}
self.didSend = true
let _ = (self.currentResults
|> take(1)
|> deliverOnMainQueue).startStandalone(next: { [weak self] results in
guard let self, let firstResult = results.first, case let .video(video) = firstResult else {
return
}
var videoPaths: [String] = []
var duration: Double = 0.0
var hasAdjustments = results.count > 1 || self.node.ggSupport.hasProcessedAudio // GHOST: processed audio never reuses the raw live upload
for result in results {
if case let .video(video) = result {
videoPaths.append(video.videoPath)
duration += video.duration
}
}
if duration < 1.0 {
self.completion(nil, nil, nil, nil)
return
}
var startTime: Double = 0.0
let finalDuration: Double
if let trimRange = self.node.previewState?.trimRange {
startTime = trimRange.lowerBound
finalDuration = trimRange.upperBound - trimRange.lowerBound
if finalDuration != duration {
hasAdjustments = true
}
} else {
finalDuration = duration
}
let dimensions = PixelDimensions(width: GGRoundCameraSupport.sendSide(400), height: GGRoundCameraSupport.sendSide(400)) // GHOST: round video resolution
let thumbnailImage: Signal<UIImage, NoError>
if startTime > 0.0 {
thumbnailImage = Signal { subscriber in
let composition = composition(with: results)
let imageGenerator = AVAssetImageGenerator(asset: composition)
imageGenerator.maximumSize = dimensions.cgSize
imageGenerator.appliesPreferredTrackTransform = true
imageGenerator.generateCGImagesAsynchronously(forTimes: [NSValue(time: CMTime(seconds: startTime, preferredTimescale: composition.duration.timescale))], completionHandler: { _, image, _, _, _ in
if let image {
subscriber.putNext(UIImage(cgImage: image))
} else {
subscriber.putNext(video.thumbnail)
}
subscriber.putCompletion()
})
return ActionDisposable {
imageGenerator.cancelAllCGImageGeneration()
}
}
} else {
thumbnailImage = .single(video.thumbnail)
}
let _ = (thumbnailImage
|> deliverOnMainQueue).startStandalone(next: { [weak self] thumbnailImage in
guard let self else {
return
}
let values = MediaEditorValues(
peerId: self.context.account.peerId,
originalDimensions: dimensions,
cropOffset: .zero,
cropRect: CGRect(origin: .zero, size: dimensions.cgSize),
cropScale: 1.0,
cropRotation: 0.0,
cropMirroring: false,
cropOrientation: nil,
gradientColors: nil,
videoTrimRange: self.node.previewState?.trimRange,
videoBounce: false,
videoIsMuted: false,
videoIsFullHd: false,
videoIsMirrored: false,
videoVolume: nil,
additionalVideoPath: nil,
additionalVideoIsDual: false,
additionalVideoMirroringChanges: [],
additionalVideoPosition: nil,
additionalVideoScale: nil,
additionalVideoRotation: nil,
additionalVideoPositionChanges: [],
additionalVideoTrimRange: nil,
additionalVideoOffset: nil,
additionalVideoVolume: nil,
collage: [],
nightTheme: false,
drawing: nil,
maskDrawing: nil,
entities: [],
toolValues: [:],
audioTrack: nil,
audioTrackTrimRange: nil,
audioTrackOffset: nil,
audioTrackVolume: nil,
audioTrackSamples: nil,
collageTrackSamples: nil,
coverImageTimestamp: nil,
coverDimensions: nil,
qualityPreset: .videoMessage
)
var resourceAdjustments: VideoMediaResourceAdjustments? = nil
if let valuesData = try? JSONEncoder().encode(values) {
let data = EngineMemoryBuffer(data: valuesData)
let digest = EngineMemoryBuffer(data: data.md5Digest())
resourceAdjustments = VideoMediaResourceAdjustments(data: data, digest: digest, isStory: false)
}
let resource: TelegramMediaResource
let liveUploadData: LegacyLiveUploadInterfaceResult?
if let current = self.node.currentLiveUploadData {
liveUploadData = current
} else {
liveUploadData = self.node.liveUploadInterface?.fileUpdated(true) as? LegacyLiveUploadInterfaceResult
}
if !hasAdjustments, let liveUploadData, let data = try? Data(contentsOf: URL(fileURLWithPath: video.videoPath)) {
resource = LocalFileMediaResource(fileId: liveUploadData.id)
self.context.engine.resources.storeResourceData(id: EngineMediaResource.Id(resource.id), data: data, synchronous: true)
} else {
resource = LocalFileVideoMediaResource(randomId: Int64.random(in: Int64.min ... Int64.max), paths: videoPaths, adjustments: resourceAdjustments)
}
var previewRepresentations: [TelegramMediaImageRepresentation] = []
let thumbnailResource = LocalFileMediaResource(fileId: Int64.random(in: Int64.min ... Int64.max))
let thumbnailSize = video.dimensions.cgSize.aspectFitted(CGSize(width: 320.0, height: 320.0))
if let thumbnailData = scaleImageToPixelSize(image: thumbnailImage, size: thumbnailSize)?.jpegData(compressionQuality: 0.4) {
self.context.engine.resources.storeResourceData(id: EngineMediaResource.Id(thumbnailResource.id), data: thumbnailData)
previewRepresentations.append(TelegramMediaImageRepresentation(dimensions: PixelDimensions(thumbnailSize), resource: thumbnailResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false))
}
let tempFile = EngineTempBox.shared.tempFile(fileName: "file")
defer {
EngineTempBox.shared.dispose(tempFile)
}
if let data = compressImageToJPEG(thumbnailImage, quality: 0.7, tempFilePath: tempFile.path) {
context.account.postbox.mediaBox.storeCachedResourceRepresentation(resource, representation: CachedVideoFirstFrameRepresentation(), data: data)
}
let media = TelegramMediaFile(fileId: EngineMedia.Id(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.FileName(fileName: "video.mp4"), .Video(duration: finalDuration, size: video.dimensions, flags: GGRoundCameraSupport.sendsAsRegularVideo ? [] : [.instantRoundVideo] /* GHOST: «send as an ordinary video» */, preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: [])
var attributes: [EngineMessage.Attribute] = []
if self.cameraState.isViewOnceEnabled {
attributes.append(AutoremoveTimeoutMessageAttribute(timeout: viewOnceTimeout, countdownBeginTime: nil))
}
if let messageEffect {
attributes.append(EffectMessageAttribute(id: messageEffect.id))
}
// GHOST: «save a copy to the gallery» (background, never delays sending)
GGRoundCameraSupport.saveCopyToGallery(paths: videoPaths, trimRange: self.node.previewState?.trimRange)
self.completion(.message(
text: "",
attributes: attributes,
inlineStickers: [:],
mediaReference: .standalone(media: media),
threadId: nil,
replyToMessageId: nil,
replyToStoryId: nil,
localGroupingKey: nil,
correlationId: nil,
bubbleUpEmojiOrStickersets: []
), silentPosting, scheduleTime, repeatPeriod)
})
})
}
private var waitingForNextResult = false
public func stopVideoRecording() -> Bool {
guard !self.didSend else {
return false
}
// GHOST: released during the countdown: nothing was recorded, close the camera
if self.node.ggSupport.cancelCountdown() {
self.completion(nil, nil, nil, nil)
return false
}
self.node.dismissAllTooltips()
self.waitingForNextResult = true
self.node.transitioningToPreview = true
self.node.requestUpdateLayout(transition: .spring(duration: 0.4))
self.node.stopRecording.invoke(Void())
return true
}
fileprivate var recordingStartTime: Double?
fileprivate var scheduledLock = false
public func lockVideoRecording() {
guard !self.didSend else {
return
}
if case .none = self.cameraState.recording {
self.scheduledLock = true
self.node.requestUpdateLayout(transition: .spring(duration: 0.4))
} else {
self.updateCameraState({ $0.updatedRecording(.handsFree) }, transition: .spring(duration: 0.4))
}
self.node.maybePresentTooltips()
}
public func discardVideo() {
self.node.cancelRecording.invoke(Void())
self.requestDismiss(animated: true)
}
public func extractVideoSnapshot() -> UIView? {
if let snapshotView = self.node.previewContainerView.snapshotView(afterScreenUpdates: false) {
snapshotView.frame = self.node.previewContainerView.convert(self.node.previewContainerView.bounds, to: nil)
return snapshotView
}
return nil
}
public func hideVideoSnapshot() {
self.node.previewContainerView.isHidden = true
}
public func updateTrimRange(start: Double, end: Double, updatedEnd: Bool, apply: Bool) {
self.node.updateTrimRange(start: start, end: end, updatedEnd: updatedEnd, apply: apply)
}
private func requestAudioSession() {
let audioSessionType: ManagedAudioSessionType
if self.context.sharedContext.currentMediaInputSettings.with({ $0 }).pauseMusicOnRecording {
audioSessionType = .record(speaker: false, video: false, withOthers: false)
} else {
audioSessionType = .record(speaker: false, video: false, withOthers: true)
}
self.audioSessionDisposable = self.context.sharedContext.mediaManager.audioSession.push(audioSessionType: audioSessionType, activate: { [weak self] _ in
GGRoundCameraSupport.applyMicrophoneMode() // GHOST: microphone mode of round videos
if let self {
Queue.mainQueue().after(0.05) {
self.node.setupCamera()
}
}
}, deactivate: { _ in
return .single(Void())
})
}
private var isDismissed = false
fileprivate func requestDismiss(animated: Bool) {
guard !self.isDismissed else {
return
}
self.node.ggSupport.cancelCountdown() // GHOST: countdown
self.node.dismissAllTooltips()
self.node.camera?.stopCapture(invalidate: true)
self.isDismissed = true
if animated {
self.node.animateOut(completion: {
self.dismiss(animated: false)
})
} else {
self.dismiss(animated: false)
}
}
override public func containerLayoutUpdated(_ layout: ContainerViewLayout, transition: ContainedViewLayoutTransition) {
self.validLayout = layout
super.containerLayoutUpdated(layout, transition: transition)
if !self.isDismissed {
(self.displayNode as! Node).containerLayoutUpdated(layout: layout, transition: ComponentTransition(transition))
}
}
public func makeSendMessageContextPreview() -> ChatSendMessageContextScreenMediaPreview? {
return VideoMessageSendMessageContextPreview(node: self.node)
}
}
private func composition(with results: [VideoMessageCameraScreen.CaptureResult]) -> AVComposition {
let composition = AVMutableComposition()
var currentTime = CMTime.zero
for result in results {
guard case let .video(video) = result else {
continue
}
let asset = AVAsset(url: URL(fileURLWithPath: video.videoPath))
let duration = asset.duration
do {
try composition.insertTimeRange(
CMTimeRangeMake(start: .zero, duration: duration),
of: asset,
at: currentTime
)
currentTime = CMTimeAdd(currentTime, duration)
} catch {
}
}
return composition
}
private class BlurView: UIVisualEffectView {
private func setup() {
for subview in self.subviews {
if subview.description.contains("VisualEffectSubview") {
subview.isHidden = true
}
}
if let sublayer = self.layer.sublayers?[0], let filters = sublayer.filters {
sublayer.backgroundColor = nil
sublayer.isOpaque = false
let allowedKeys: [String] = [
"gaussianBlur"
]
sublayer.filters = filters.filter { filter in
guard let filter = filter as? NSObject else {
return true
}
let filterName = String(describing: filter)
if !allowedKeys.contains(filterName) {
return false
}
return true
}
}
}
override var effect: UIVisualEffect? {
get {
return super.effect
}
set {
super.effect = newValue
self.setup()
}
}
override func didAddSubview(_ subview: UIView) {
super.didAddSubview(subview)
self.setup()
}
}
private final class VideoMessageSendMessageContextPreview: UIView, ChatSendMessageContextScreenMediaPreview {
var isReady: Signal<Bool, NoError> {
return .single(true)
}
var view: UIView {
return self
}
var globalClippingRect: CGRect? {
return nil
}
var layoutType: ChatSendMessageContextScreenMediaPreviewLayoutType {
return .videoMessage
}
private weak var previewContainerContentParentView: UIView?
private let previewContainerContentView: UIView
init(node: VideoMessageCameraScreen.Node) {
self.previewContainerContentParentView = node.previewContainerView
self.previewContainerContentView = node.previewContainerContentView
super.init(frame: CGRect())
}
required init(coder: NSCoder) {
preconditionFailure()
}
func animateIn(transition: ComponentTransition) {
self.addSubview(self.previewContainerContentView)
guard let previewContainerContentParentView = self.previewContainerContentParentView else {
return
}
let fromFrame = previewContainerContentParentView.convert(CGRect(origin: CGPoint(), size: self.previewContainerContentView.bounds.size), to: self)
let toFrame = self.previewContainerContentView.frame
transition.animatePosition(view: self.previewContainerContentView, from: CGPoint(x: fromFrame.midX - toFrame.midX, y: fromFrame.midY - toFrame.midY), to: CGPoint(), additive: true)
}
func animateOut(transition: ComponentTransition) {
guard let previewContainerContentParentView = self.previewContainerContentParentView else {
return
}
let toFrame = previewContainerContentParentView.convert(CGRect(origin: CGPoint(), size: self.previewContainerContentView.bounds.size), to: self)
let previewContainerContentView = self.previewContainerContentView
transition.setPosition(view: self.previewContainerContentView, position: toFrame.center, completion: { [weak previewContainerContentParentView, weak previewContainerContentView] _ in
guard let previewContainerContentParentView, let previewContainerContentView else {
return
}
previewContainerContentView.frame = CGRect(origin: CGPoint(), size: previewContainerContentView.bounds.size)
previewContainerContentParentView.addSubview(previewContainerContentView)
})
}
func animateOutOnSend(transition: ComponentTransition) {
guard let previewContainerContentParentView = self.previewContainerContentParentView else {
return
}
if let snapshotView = self.previewContainerContentView.snapshotView(afterScreenUpdates: false) {
self.addSubview(snapshotView)
transition.setAlpha(view: snapshotView, alpha: 0.0)
}
self.previewContainerContentView.frame = CGRect(origin: CGPoint(), size: self.previewContainerContentView.bounds.size)
previewContainerContentParentView.addSubview(self.previewContainerContentView)
}
func update(containerSize: CGSize, transition: ComponentTransition) -> CGSize {
return self.previewContainerContentView.bounds.size
}
}