mirror of
https://github.com/Swiftgram/Telegram-iOS.git
synced 2026-04-08 14:17:54 +00:00
Fixes
fix localeWithStrings globally (#30)
Fix badge on zoomed devices. closes #9
Hide channel bottom panel closes #27
Another attempt to fix badge on some Zoomed devices
Force System Share sheet tg://sg/debug
fixes for device badge
New Crowdin updates (#34)
* New translations sglocalizable.strings (Chinese Traditional)
* New translations sglocalizable.strings (Chinese Simplified)
* New translations sglocalizable.strings (Chinese Traditional)
Fix input panel hidden on selection (#31)
* added if check for selectionState != nil
* same order of subnodes
Revert "Fix input panel hidden on selection (#31)"
This reverts commit e8a8bb1496.
Fix input panel for channels Closes #37
Quickly share links with system's share menu
force tabbar when editing
increase height for correct animation
New translations sglocalizable.strings (Ukrainian) (#38)
Hide Post Story button
Fix 10.15.1
Fix archive option for long-tap
Enable in-app Safari
Disable some unsupported purchases
disableDeleteChatSwipeOption + refactor restart alert
Hide bot in suggestions list
Fix merge v11.0
Fix exceptions for safari webview controller
New Crowdin updates (#47)
* New translations sglocalizable.strings (Romanian)
* New translations sglocalizable.strings (French)
* New translations sglocalizable.strings (Spanish)
* New translations sglocalizable.strings (Afrikaans)
* New translations sglocalizable.strings (Arabic)
* New translations sglocalizable.strings (Catalan)
* New translations sglocalizable.strings (Czech)
* New translations sglocalizable.strings (Danish)
* New translations sglocalizable.strings (German)
* New translations sglocalizable.strings (Greek)
* New translations sglocalizable.strings (Finnish)
* New translations sglocalizable.strings (Hebrew)
* New translations sglocalizable.strings (Hungarian)
* New translations sglocalizable.strings (Italian)
* New translations sglocalizable.strings (Japanese)
* New translations sglocalizable.strings (Korean)
* New translations sglocalizable.strings (Dutch)
* New translations sglocalizable.strings (Norwegian)
* New translations sglocalizable.strings (Polish)
* New translations sglocalizable.strings (Portuguese)
* New translations sglocalizable.strings (Serbian (Cyrillic))
* New translations sglocalizable.strings (Swedish)
* New translations sglocalizable.strings (Turkish)
* New translations sglocalizable.strings (Vietnamese)
* New translations sglocalizable.strings (Indonesian)
* New translations sglocalizable.strings (Hindi)
* New translations sglocalizable.strings (Uzbek)
New Crowdin updates (#49)
* New translations sglocalizable.strings (Arabic)
* New translations sglocalizable.strings (Arabic)
New translations sglocalizable.strings (Russian) (#51)
Call confirmation
WIP Settings search
Settings Search
Localize placeholder
Update AccountUtils.swift
mark mutual contact
Align back context action to left
New Crowdin updates (#54)
* New translations sglocalizable.strings (Chinese Simplified)
* New translations sglocalizable.strings (Chinese Traditional)
* New translations sglocalizable.strings (Ukrainian)
Independent Playground app for simulator
New translations sglocalizable.strings (Ukrainian) (#55)
Playground UIKit base and controllers
Inject SwiftUI view with overflow to AsyncDisplayKit
Launch Playgound project on simulator
Create .swiftformat
Move Playground to example
Update .swiftformat
Init SwiftUIViewController
wip
New translations sglocalizable.strings (Chinese Traditional) (#57)
Xcode 16 fixes
Fix
New translations sglocalizable.strings (Italian) (#59)
New translations sglocalizable.strings (Chinese Simplified) (#63)
Force disable CallKit integration due to missing NSE Entitlement
Fix merge
Fix whole chat translator
Sweetpad config
Bump version
11.3.1 fixes
Mutual contact placement fix
Disable Video PIP swipe
Update versions.json
Fix PIP crash
2163 lines
94 KiB
Swift
2163 lines
94 KiB
Swift
import SGSimpleSettings
|
|
import Foundation
|
|
import UIKit
|
|
import Display
|
|
import AsyncDisplayKit
|
|
import ComponentFlow
|
|
import SwiftSignalKit
|
|
import ViewControllerComponent
|
|
import ComponentDisplayAdapters
|
|
import TelegramPresentationData
|
|
import AccountContext
|
|
import Postbox
|
|
import TelegramCore
|
|
import PresentationDataUtils
|
|
import Camera
|
|
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
|
|
|
|
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 previewFrame: CGRect
|
|
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,
|
|
previewFrame: CGRect,
|
|
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.previewFrame = previewFrame
|
|
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.cameraState != rhs.cameraState {
|
|
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 buttonBackground
|
|
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 .buttonBackground:
|
|
let innerSize = CGSize(width: 40.0, height: 40.0)
|
|
image = generateFilledCircleImage(diameter: innerSize.width, color: theme.rootController.navigationBar.opaqueBackgroundColor, strokeColor: theme.chat.inputPanel.panelSeparatorColor, strokeWidth: 0.5, backgroundColor: nil)!
|
|
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
|
|
|
|
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) {
|
|
Queue.mainQueue().after(0.15) {
|
|
self.resultDisposable.set((camera.startRecording()
|
|
|> deliverOnMainQueue).start(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] _ in
|
|
if let self, let controller = self.getController() {
|
|
controller.completion(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).start(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 flipButton = Child(CameraButton.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.availableSize
|
|
|
|
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 frontFlash = frontFlash.update(
|
|
component: Image(image: state.image(.flashImage, theme: environment.theme), tintColor: component.cameraState.flashTint.color),
|
|
availableSize: availableSize,
|
|
transition: .easeInOut(duration: 0.2)
|
|
)
|
|
context.add(frontFlash
|
|
.position(CGPoint(x: context.availableSize.width / 2.0, y: context.availableSize.height / 2.0))
|
|
.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 flipButton = flipButton.update(
|
|
component: CameraButton(
|
|
content: AnyComponentWithIdentity(
|
|
id: "flip",
|
|
component: AnyComponent(
|
|
Image(
|
|
image: state.image(.flip, theme: environment.theme),
|
|
tintColor: environment.theme.list.itemAccentColor,
|
|
size: CGSize(width: 30.0, height: 30.0)
|
|
)
|
|
)
|
|
),
|
|
minSize: CGSize(width: 44.0, height: 44.0),
|
|
isExclusive: false,
|
|
action: { [weak state] in
|
|
if let state {
|
|
state.togglePosition()
|
|
}
|
|
}
|
|
),
|
|
availableSize: availableSize,
|
|
transition: context.transition
|
|
)
|
|
context.add(flipButton
|
|
.position(CGPoint(x: flipButton.size.width / 2.0 + 8.0, 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.list.itemAccentColor,
|
|
startingPosition: !component.cameraState.flashModeDidChange ? .end : .begin,
|
|
size: CGSize(width: 40.0, height: 40.0),
|
|
loop: false,
|
|
playOnce: flashAction
|
|
)
|
|
)
|
|
)
|
|
} else {
|
|
flashContentComponent = AnyComponentWithIdentity(
|
|
id: "staticIcon",
|
|
component: AnyComponent(
|
|
Image(
|
|
image: state.image(.flash, theme: environment.theme),
|
|
tintColor: environment.theme.list.itemAccentColor,
|
|
size: CGSize(width: 30.0, height: 30.0)
|
|
)
|
|
)
|
|
)
|
|
}
|
|
|
|
if !environment.metrics.isTablet {
|
|
let flashButton = flashButton.update(
|
|
component: CameraButton(
|
|
content: flashContentComponent,
|
|
minSize: CGSize(width: 44.0, height: 44.0),
|
|
isExclusive: false,
|
|
action: { [weak state] in
|
|
if let state {
|
|
state.toggleFlashMode()
|
|
Queue.mainQueue().justDispatch {
|
|
flashAction.invoke(Void())
|
|
}
|
|
}
|
|
}
|
|
),
|
|
availableSize: availableSize,
|
|
transition: context.transition
|
|
)
|
|
context.add(flashButton
|
|
.position(CGPoint(x: flipButton.size.width + 8.0 + 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(
|
|
Image(
|
|
image: state.image(.buttonBackground, theme: environment.theme),
|
|
size: CGSize(width: 40.0, height: 40.0)
|
|
)
|
|
)
|
|
),
|
|
AnyComponentWithIdentity(
|
|
id: "icon",
|
|
component: AnyComponent(
|
|
BundleIconComponent(
|
|
name: component.cameraState.isViewOnceEnabled ? "Media Gallery/ViewOnceEnabled" : "Media Gallery/ViewOnce",
|
|
tintColor: environment.theme.list.itemAccentColor
|
|
)
|
|
)
|
|
)
|
|
])
|
|
),
|
|
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 - 2.0 - UIScreenPixel, 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(
|
|
Image(
|
|
image: state.image(.buttonBackground, theme: environment.theme),
|
|
size: CGSize(width: 40.0, height: 40.0)
|
|
)
|
|
)
|
|
),
|
|
AnyComponentWithIdentity(
|
|
id: "icon",
|
|
component: AnyComponent(
|
|
BundleIconComponent(
|
|
name: "Chat/Input/Text/IconVideo",
|
|
tintColor: environment.theme.list.itemAccentColor
|
|
)
|
|
)
|
|
)
|
|
])
|
|
),
|
|
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 - 2.0 - UIScreenPixel, 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 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
|
|
fileprivate var camera: Camera?
|
|
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?
|
|
|
|
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
|
|
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.containerView.clipsToBounds = true
|
|
|
|
self.componentHost = ComponentView<ViewControllerComponentContainer.Environment>()
|
|
|
|
self.previewContainerView = UIView()
|
|
|
|
self.previewContainerContentView = UIView()
|
|
self.previewContainerContentView.clipsToBounds = true
|
|
self.previewContainerView.addSubview(self.previewContainerContentView)
|
|
|
|
let isDualCameraEnabled = Camera.isDualCameraSupported(forRoundVideo: true)
|
|
// MARK: Swiftgram
|
|
let isFrontPosition = !SGSimpleSettings.shared.startTelescopeWithRearCam
|
|
|
|
self.mainPreviewView = CameraSimplePreviewView(frame: .zero, main: true, roundVideo: true)
|
|
self.additionalPreviewView = CameraSimplePreviewView(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: false
|
|
)
|
|
|
|
self.previewState = nil
|
|
|
|
super.init()
|
|
|
|
self.backgroundColor = .clear
|
|
|
|
self.view.addSubview(self.backgroundView)
|
|
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)
|
|
|
|
self.completion.connect { [weak self] result in
|
|
if let self {
|
|
self.addCaptureResult(result)
|
|
}
|
|
}
|
|
if isDualCameraEnabled {
|
|
self.mainPreviewView.removePlaceholder(delay: 0.0)
|
|
}
|
|
self.withReadyCamera(isFirstTime: true, {
|
|
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()
|
|
}
|
|
|
|
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
|
|
}
|
|
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 = Camera(
|
|
configuration: Camera.Configuration(
|
|
preset: .hd1920x1080,
|
|
position: self.cameraState.position,
|
|
isDualEnabled: self.cameraState.isDualCameraEnabled,
|
|
audio: true,
|
|
photo: false,
|
|
metadata: false,
|
|
isRoundVideo: true
|
|
),
|
|
previewView: self.mainPreviewView,
|
|
secondaryPreviewView: self.additionalPreviewView
|
|
)
|
|
|
|
self.cameraStateDisposable = combineLatest(
|
|
queue: Queue.mainQueue(),
|
|
camera.flashMode,
|
|
camera.position
|
|
).start(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
|
|
|
|
Queue.mainQueue().justDispatch {
|
|
self.startRecording.invoke(Void())
|
|
}
|
|
}
|
|
|
|
@objc private func handlePinch(_ gestureRecognizer: UIPinchGestureRecognizer) {
|
|
guard let camera = self.camera else {
|
|
return
|
|
}
|
|
switch gestureRecognizer.state {
|
|
case .changed:
|
|
let scale = gestureRecognizer.scale
|
|
camera.setZoomDelta(scale)
|
|
gestureRecognizer.scale = 1.0
|
|
case .ended, .cancelled:
|
|
camera.rampZoom(1.0, rate: 8.0)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
private var animatingIn = false
|
|
func animateIn() {
|
|
self.animatingIn = true
|
|
|
|
// if let chatNode = self.controller?.chatNode {
|
|
// chatNode.supernode?.view.insertSubview(self.backgroundView, aboveSubview: chatNode.view)
|
|
// }
|
|
|
|
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: { _ 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)
|
|
|
|
UIView.animate(withDuration: 0.25, animations: {
|
|
self.backgroundView.alpha = 0.0
|
|
}, completion: { _ 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: { _ in
|
|
self.previewSnapshotView?.removeFromSuperview()
|
|
self.previewSnapshotView = nil
|
|
})
|
|
}
|
|
|
|
Queue.mainQueue().after(1.0) {
|
|
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: { _ in
|
|
self.previewSnapshotView?.removeFromSuperview()
|
|
self.previewSnapshotView = nil
|
|
})
|
|
}
|
|
if #available(iOS 13.0, *) {
|
|
let _ = (self.mainPreviewView.isPreviewing
|
|
|> filter { $0 }
|
|
|> take(1)).startStandalone(next: { _ in
|
|
action()
|
|
})
|
|
} else {
|
|
Queue.mainQueue().after(1.0) {
|
|
action()
|
|
}
|
|
}
|
|
|
|
self.cameraIsActive = true
|
|
self.requestUpdateLayout(transition: .immediate)
|
|
}
|
|
}
|
|
|
|
fileprivate var results: [VideoMessageCameraScreen.CaptureResult] = []
|
|
fileprivate var resultsPipe = ValuePipe<VideoMessageCameraScreen.CaptureResult>()
|
|
|
|
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
|
|
}
|
|
|
|
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: MediaId(namespace: Namespaces.Media.LocalFile, id: id), partialReference: nil, resource: fileResource, previewRepresentations: [], videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: Int64(data.count), attributes: [.FileName(fileName: "video.mp4")], alternativeRepresentations: [])
|
|
let message: EnqueueMessage = .message(text: "", attributes: [], inlineStickers: [:], mediaReference: .standalone(media: file), threadId: nil, replyToMessageId: nil, replyToStoryId: nil, localGroupingKey: nil, correlationId: nil, bubbleUpEmojiOrStickersets: [])
|
|
|
|
let _ = enqueueMessages(account: self.context.engine.account, peerId: self.context.engine.account.peerId, messages: [message]).start()
|
|
}
|
|
|
|
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
|
|
let result = super.hitTest(point, with: event)
|
|
|
|
if let resultPreviewView = self.resultPreviewView {
|
|
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 - insets.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 maybePresentViewOnceTooltip() {
|
|
let presentationData = self.context.sharedContext.currentPresentationData.with { $0 }
|
|
let _ = (ApplicationSpecificNotice.getVideoMessagesPlayOnceSuggestion(accountManager: 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()
|
|
})
|
|
}
|
|
|
|
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))
|
|
}
|
|
|
|
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))
|
|
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))
|
|
}
|
|
|
|
transition.setPosition(view: self.backgroundView, position: backgroundFrame.center)
|
|
transition.setBounds(view: self.backgroundView, bounds: CGRect(origin: .zero, size: backgroundFrame.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 {
|
|
let statusBarOrientation: UIInterfaceOrientation
|
|
if #available(iOS 13.0, *) {
|
|
statusBarOrientation = UIApplication.shared.windows.first?.windowScene?.interfaceOrientation ?? .portrait
|
|
} else {
|
|
statusBarOrientation = UIApplication.shared.statusBarOrientation
|
|
}
|
|
|
|
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,
|
|
previewFrame: previewFrame,
|
|
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: backgroundFrame.size
|
|
)
|
|
if let componentView = self.componentHost.view {
|
|
if componentView.superview == nil {
|
|
self.containerView.addSubview(componentView)
|
|
componentView.clipsToBounds = true
|
|
}
|
|
|
|
let componentFrame = CGRect(origin: .zero, size: componentSize)
|
|
transition.setFrame(view: componentView, frame: componentFrame)
|
|
}
|
|
|
|
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?) -> Void
|
|
|
|
private var audioSessionDisposable: Disposable?
|
|
|
|
private let hapticFeedback = HapticFeedback()
|
|
|
|
private var validLayout: ContainerViewLayout?
|
|
|
|
public var camera: Camera? {
|
|
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 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> {
|
|
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?) -> 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, 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.completion(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)
|
|
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
|
|
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)
|
|
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: 400, height: 400)
|
|
|
|
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, videoIsMuted: false, videoIsFullHd: false, videoIsMirrored: false, videoVolume: nil, additionalVideoPath: nil, additionalVideoIsDual: false, 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, qualityPreset: .videoMessage)
|
|
|
|
var resourceAdjustments: VideoMediaResourceAdjustments? = nil
|
|
if let valuesData = try? JSONEncoder().encode(values) {
|
|
let data = MemoryBuffer(data: valuesData)
|
|
let digest = MemoryBuffer(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.account.postbox.mediaBox.storeResourceData(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.account.postbox.mediaBox.storeResourceData(thumbnailResource.id, data: thumbnailData)
|
|
previewRepresentations.append(TelegramMediaImageRepresentation(dimensions: PixelDimensions(thumbnailSize), resource: thumbnailResource, progressiveSizes: [], immediateThumbnailData: nil, hasVideo: false, isPersonal: false))
|
|
}
|
|
|
|
let tempFile = TempBox.shared.tempFile(fileName: "file")
|
|
defer {
|
|
TempBox.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: MediaId(namespace: Namespaces.Media.LocalFile, id: Int64.random(in: Int64.min ... Int64.max)), partialReference: nil, resource: resource, previewRepresentations: previewRepresentations, videoThumbnails: [], immediateThumbnailData: nil, mimeType: "video/mp4", size: nil, attributes: [.FileName(fileName: "video.mp4"), .Video(duration: finalDuration, size: video.dimensions, flags: [.instantRoundVideo], preloadSize: nil, coverTime: nil, videoCodec: nil)], alternativeRepresentations: [])
|
|
|
|
var attributes: [MessageAttribute] = []
|
|
if self.cameraState.isViewOnceEnabled {
|
|
attributes.append(AutoremoveTimeoutMessageAttribute(timeout: viewOnceTimeout, countdownBeginTime: nil))
|
|
}
|
|
if let messageEffect {
|
|
attributes.append(EffectMessageAttribute(id: messageEffect.id))
|
|
}
|
|
|
|
self.completion(.message(
|
|
text: "",
|
|
attributes: attributes,
|
|
inlineStickers: [:],
|
|
mediaReference: .standalone(media: media),
|
|
threadId: nil,
|
|
replyToMessageId: nil,
|
|
replyToStoryId: nil,
|
|
localGroupingKey: nil,
|
|
correlationId: nil,
|
|
bubbleUpEmojiOrStickersets: []
|
|
), silentPosting, scheduleTime)
|
|
})
|
|
})
|
|
}
|
|
|
|
private var waitingForNextResult = false
|
|
public func stopVideoRecording() -> Bool {
|
|
guard !self.didSend else {
|
|
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.maybePresentViewOnceTooltip()
|
|
}
|
|
|
|
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
|
|
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.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
|
|
}
|
|
}
|