fix/refactor: IOS тоже члены общества!!! Не полноценные пока что но все-же. Теперь при нажатии по кнопке входа bottom sheet с условиями открывается сам.
This commit is contained in:
+60
@@ -69,8 +69,68 @@ target 'Runner' do
|
||||
end
|
||||
end
|
||||
|
||||
# libopus already ships with ogg_opus_player as a static xcframework. Nothing in
|
||||
# the app references its encoder entry points, so the linker would drop them and
|
||||
# `DynamicLibrary.process()` (OpusOggEncoder) would find nothing. Force-loading
|
||||
# the slice keeps the whole library, which is what voice-message encoding needs.
|
||||
OPUS_FORCE_LOAD =
|
||||
'-force_load "${PODS_XCFRAMEWORKS_BUILD_DIR}/ogg_opus_player/libopus.a"'.freeze
|
||||
|
||||
# permission_handler compiles every permission handler unless told otherwise;
|
||||
# the unused ones reference APIs that make App Store review ask for usage
|
||||
# descriptions the app has no reason to declare.
|
||||
PERMISSION_MACROS = %w[
|
||||
PERMISSION_CAMERA=1
|
||||
PERMISSION_MICROPHONE=1
|
||||
PERMISSION_PHOTOS=1
|
||||
PERMISSION_PHOTOS_ADD_ONLY=1
|
||||
PERMISSION_LOCATION=1
|
||||
PERMISSION_LOCATION_WHENINUSE=1
|
||||
PERMISSION_CONTACTS=1
|
||||
PERMISSION_NOTIFICATIONS=1
|
||||
PERMISSION_LOCATION_ALWAYS=0
|
||||
PERMISSION_MEDIA_LIBRARY=0
|
||||
PERMISSION_EVENTS=0
|
||||
PERMISSION_EVENTS_FULL_ACCESS=0
|
||||
PERMISSION_REMINDERS=0
|
||||
PERMISSION_SPEECH_RECOGNIZER=0
|
||||
PERMISSION_SENSORS=0
|
||||
PERMISSION_BLUETOOTH=0
|
||||
PERMISSION_APP_TRACKING_TRANSPARENCY=0
|
||||
PERMISSION_CRITICAL_ALERTS=0
|
||||
PERMISSION_ASSISTANT=0
|
||||
].freeze
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
|
||||
next unless target.name == 'permission_handler_apple'
|
||||
|
||||
target.build_configurations.each do |config|
|
||||
config.build_settings['GCC_PREPROCESSOR_DEFINITIONS'] =
|
||||
['$(inherited)'] + PERMISSION_MACROS
|
||||
end
|
||||
end
|
||||
|
||||
installer.aggregate_targets.each do |target|
|
||||
next unless target.name == 'Pods-Runner'
|
||||
|
||||
%w[debug profile release].each do |name|
|
||||
xcconfig = target.xcconfig_path(name)
|
||||
next unless File.exist?(xcconfig)
|
||||
|
||||
contents = File.read(xcconfig)
|
||||
next if contents.include?('ogg_opus_player/libopus.a')
|
||||
|
||||
if contents =~ /^OTHER_LDFLAGS = .*$/
|
||||
contents = contents.sub(/^OTHER_LDFLAGS = (.*)$/) do
|
||||
"OTHER_LDFLAGS = #{Regexp.last_match(1)} #{OPUS_FORCE_LOAD}"
|
||||
end
|
||||
else
|
||||
contents += "\nOTHER_LDFLAGS = $(inherited) #{OPUS_FORCE_LOAD}\n"
|
||||
end
|
||||
File.write(xcconfig, contents)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
4B1A4BBCD56B3CE42AD0A480 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
AA11BB22CC33DD44EE550101 /* KometVideo.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550001 /* KometVideo.swift */; };
|
||||
AA11BB22CC33DD44EE550102 /* KometVideoNote.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550002 /* KometVideoNote.swift */; };
|
||||
AA11BB22CC33DD44EE550103 /* KometNotifications.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA11BB22CC33DD44EE550003 /* KometNotifications.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||
@@ -56,6 +59,10 @@
|
||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||
AA11BB22CC33DD44EE550001 /* KometVideo.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometVideo.swift; sourceTree = "<group>"; };
|
||||
AA11BB22CC33DD44EE550002 /* KometVideoNote.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometVideoNote.swift; sourceTree = "<group>"; };
|
||||
AA11BB22CC33DD44EE550003 /* KometNotifications.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KometNotifications.swift; sourceTree = "<group>"; };
|
||||
AA11BB22CC33DD44EE550004 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||
782E66FE4E292DCFD0D1B7D2 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||
789A1AEF2F4FF7DEED202476 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||
@@ -157,6 +164,10 @@
|
||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
|
||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||
AA11BB22CC33DD44EE550001 /* KometVideo.swift */,
|
||||
AA11BB22CC33DD44EE550002 /* KometVideoNote.swift */,
|
||||
AA11BB22CC33DD44EE550003 /* KometNotifications.swift */,
|
||||
AA11BB22CC33DD44EE550004 /* Runner.entitlements */,
|
||||
);
|
||||
path = Runner;
|
||||
sourceTree = "<group>";
|
||||
@@ -396,6 +407,9 @@
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||
AA11BB22CC33DD44EE550101 /* KometVideo.swift in Sources */,
|
||||
AA11BB22CC33DD44EE550102 /* KometVideoNote.swift in Sources */,
|
||||
AA11BB22CC33DD44EE550103 /* KometNotifications.swift in Sources */,
|
||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -495,6 +509,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -677,6 +692,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
@@ -699,6 +715,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = ru.komet.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||
|
||||
+136
-34
@@ -1,50 +1,152 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
final class KometStreamHandler: NSObject, FlutterStreamHandler {
|
||||
private let onSink: (FlutterEventSink?) -> Void
|
||||
|
||||
init(onSink: @escaping (FlutterEventSink?) -> Void) {
|
||||
self.onSink = onSink
|
||||
}
|
||||
|
||||
func onListen(
|
||||
withArguments arguments: Any?,
|
||||
eventSink events: @escaping FlutterEventSink
|
||||
) -> FlutterError? {
|
||||
onSink(events)
|
||||
return nil
|
||||
}
|
||||
|
||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||
onSink(nil)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@main
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
private var channels: [FlutterMethodChannel] = []
|
||||
private var eventChannels: [FlutterEventChannel] = []
|
||||
private var streamHandlers: [KometStreamHandler] = []
|
||||
private var videoNote: KometVideoNote?
|
||||
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
KometNotifications.shared.start()
|
||||
|
||||
let controller = window?.rootViewController as? FlutterViewController
|
||||
if let messenger = controller?.binaryMessenger {
|
||||
let channel = FlutterMethodChannel(
|
||||
name: "ru.komet.app/app_icon",
|
||||
binaryMessenger: messenger
|
||||
)
|
||||
channel.setMethodCallHandler { (call, result) in
|
||||
guard call.method == "setAppIcon" else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
}
|
||||
let args = call.arguments as? [String: Any]
|
||||
let name = args?["name"] as? String
|
||||
let iconName: String? = (name == "DefaultIcon") ? nil : name
|
||||
if !UIApplication.shared.supportsAlternateIcons {
|
||||
result(FlutterError(
|
||||
code: "UNSUPPORTED",
|
||||
message: "Alternate icons are not supported",
|
||||
details: nil
|
||||
))
|
||||
return
|
||||
}
|
||||
UIApplication.shared.setAlternateIconName(iconName) { error in
|
||||
if let error = error {
|
||||
result(FlutterError(
|
||||
code: "APPLY_FAILED",
|
||||
message: error.localizedDescription,
|
||||
details: nil
|
||||
))
|
||||
} else {
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
if let controller = window?.rootViewController as? FlutterViewController {
|
||||
let messenger = controller.binaryMessenger
|
||||
registerAppIcon(messenger)
|
||||
registerVideo(messenger)
|
||||
registerVideoNote(messenger)
|
||||
registerNotifications(messenger)
|
||||
}
|
||||
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
|
||||
private func method(_ name: String, _ messenger: FlutterBinaryMessenger,
|
||||
_ handler: @escaping FlutterMethodCallHandler) {
|
||||
let channel = FlutterMethodChannel(name: name, binaryMessenger: messenger)
|
||||
channel.setMethodCallHandler(handler)
|
||||
channels.append(channel)
|
||||
}
|
||||
|
||||
private func events(_ name: String, _ messenger: FlutterBinaryMessenger,
|
||||
_ onSink: @escaping (FlutterEventSink?) -> Void) {
|
||||
let handler = KometStreamHandler(onSink: onSink)
|
||||
let channel = FlutterEventChannel(name: name, binaryMessenger: messenger)
|
||||
channel.setStreamHandler(handler)
|
||||
streamHandlers.append(handler)
|
||||
eventChannels.append(channel)
|
||||
}
|
||||
|
||||
private func registerAppIcon(_ messenger: FlutterBinaryMessenger) {
|
||||
method("ru.komet.app/app_icon", messenger) { call, result in
|
||||
guard call.method == "setAppIcon" else {
|
||||
result(FlutterMethodNotImplemented)
|
||||
return
|
||||
}
|
||||
let name = (call.arguments as? [String: Any])?["name"] as? String
|
||||
let iconName: String? = (name == "DefaultIcon") ? nil : name
|
||||
guard UIApplication.shared.supportsAlternateIcons else {
|
||||
result(FlutterError(code: "UNSUPPORTED",
|
||||
message: "Alternate icons are not supported",
|
||||
details: nil))
|
||||
return
|
||||
}
|
||||
UIApplication.shared.setAlternateIconName(iconName) { error in
|
||||
if let error = error {
|
||||
result(FlutterError(code: "APPLY_FAILED",
|
||||
message: error.localizedDescription,
|
||||
details: nil))
|
||||
} else {
|
||||
result(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func registerVideo(_ messenger: FlutterBinaryMessenger) {
|
||||
method("ru.komet.app/video", messenger) { call, result in
|
||||
KometVideo.shared.handle(call, result: result)
|
||||
}
|
||||
}
|
||||
|
||||
private func registerVideoNote(_ messenger: FlutterBinaryMessenger) {
|
||||
guard let textures = registrar(forPlugin: "KometVideoNote")?.textures() else { return }
|
||||
|
||||
method("ru.komet.app/video_note", messenger) { [weak self] call, result in
|
||||
guard let self = self else { return }
|
||||
switch call.method {
|
||||
case "permission":
|
||||
KometVideoNote.requestPermission(result)
|
||||
case "init":
|
||||
let args = call.arguments as? [String: Any] ?? [:]
|
||||
self.videoNote?.dispose()
|
||||
let recorder = KometVideoNote(registry: textures)
|
||||
self.videoNote = recorder
|
||||
recorder.initialize(
|
||||
front: (args["front"] as? NSNumber)?.boolValue ?? true,
|
||||
edge: (args["size"] as? NSNumber)?.intValue ?? 480,
|
||||
fps: (args["fps"] as? NSNumber)?.intValue ?? 30,
|
||||
result: result)
|
||||
case "start":
|
||||
self.withRecorder(result) { $0.start(result: result) }
|
||||
case "switch":
|
||||
self.withRecorder(result) { $0.switchCamera(result: result) }
|
||||
case "torch":
|
||||
let on = ((call.arguments as? [String: Any])?["on"] as? NSNumber)?.boolValue ?? false
|
||||
self.withRecorder(result) { $0.setTorch(on: on, result: result) }
|
||||
case "stop":
|
||||
self.withRecorder(result) { $0.stop(result: result) }
|
||||
case "dispose":
|
||||
self.videoNote?.dispose()
|
||||
self.videoNote = nil
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func withRecorder(_ result: @escaping FlutterResult,
|
||||
_ body: (KometVideoNote) -> Void) {
|
||||
guard let recorder = videoNote else {
|
||||
result(FlutterError(code: "NOT_READY", message: "recorder not initialized", details: nil))
|
||||
return
|
||||
}
|
||||
body(recorder)
|
||||
}
|
||||
|
||||
private func registerNotifications(_ messenger: FlutterBinaryMessenger) {
|
||||
method("ru.komet.app/notifications", messenger) { call, result in
|
||||
KometNotifications.shared.handle(call, result: result)
|
||||
}
|
||||
events("ru.komet.app/notification_events", messenger) { sink in
|
||||
KometNotifications.shared.attach(sink)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,5 +84,31 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleLocalizations</key>
|
||||
<array>
|
||||
<string>ru</string>
|
||||
<string>en</string>
|
||||
</array>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UIFileSharingEnabled</key>
|
||||
<true/>
|
||||
<key>LSSupportsOpeningDocumentsInPlace</key>
|
||||
<true/>
|
||||
<key>ITSAppUsesNonExemptEncryption</key>
|
||||
<false/>
|
||||
<key>LSApplicationQueriesSchemes</key>
|
||||
<array>
|
||||
<string>tel</string>
|
||||
<string>telprompt</string>
|
||||
<string>sms</string>
|
||||
<string>mailto</string>
|
||||
<string>maps</string>
|
||||
<string>comgooglemaps</string>
|
||||
<string>yandexmaps</string>
|
||||
<string>yandexnavi</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import Flutter
|
||||
import UIKit
|
||||
import UserNotifications
|
||||
|
||||
final class KometNotifications: NSObject {
|
||||
static let shared = KometNotifications()
|
||||
|
||||
private static let chatKeys = ["komet_chat", "chatId", "chat_id"]
|
||||
|
||||
private var sink: FlutterEventSink?
|
||||
private var pendingChatId: Int64 = 0
|
||||
private var activeChatId: Int64 = 0
|
||||
|
||||
func start() {
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
}
|
||||
|
||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "consumeInitialChat":
|
||||
let chatId = pendingChatId
|
||||
pendingChatId = 0
|
||||
result(chatId > 0 ? NSNumber(value: chatId) : nil)
|
||||
case "setActiveChat":
|
||||
activeChatId = Self.chatId(from: call.arguments)
|
||||
dismissDelivered(chatId: activeChatId)
|
||||
result(nil)
|
||||
case "clearActiveChat":
|
||||
activeChatId = 0
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
func attach(_ sink: FlutterEventSink?) {
|
||||
self.sink = sink
|
||||
}
|
||||
|
||||
func deliver(chatId: Int64) {
|
||||
guard chatId > 0 else { return }
|
||||
if let sink = sink {
|
||||
sink(NSNumber(value: chatId))
|
||||
} else {
|
||||
pendingChatId = chatId
|
||||
}
|
||||
}
|
||||
|
||||
private func dismissDelivered(chatId: Int64) {
|
||||
guard chatId > 0 else { return }
|
||||
let center = UNUserNotificationCenter.current()
|
||||
center.getDeliveredNotifications { delivered in
|
||||
let identifiers = delivered
|
||||
.filter { Self.chatId(from: $0.request.content.userInfo) == chatId }
|
||||
.map { $0.request.identifier }
|
||||
guard !identifiers.isEmpty else { return }
|
||||
center.removeDeliveredNotifications(withIdentifiers: identifiers)
|
||||
}
|
||||
}
|
||||
|
||||
private static func chatId(from raw: Any?) -> Int64 {
|
||||
if let number = raw as? NSNumber { return number.int64Value }
|
||||
if let text = raw as? String { return Int64(text) ?? 0 }
|
||||
if let map = raw as? [AnyHashable: Any] {
|
||||
for key in chatKeys {
|
||||
if let value = map[key], let parsed = optionalChatId(value) { return parsed }
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
private static func optionalChatId(_ raw: Any) -> Int64? {
|
||||
if let number = raw as? NSNumber { return number.int64Value }
|
||||
if let text = raw as? String { return Int64(text) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension KometNotifications: UNUserNotificationCenterDelegate {
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification,
|
||||
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
|
||||
) {
|
||||
let chatId = Self.chatId(from: notification.request.content.userInfo)
|
||||
if chatId > 0, chatId == activeChatId {
|
||||
completionHandler([])
|
||||
return
|
||||
}
|
||||
if #available(iOS 14.0, *) {
|
||||
completionHandler([.banner, .list, .sound, .badge])
|
||||
} else {
|
||||
completionHandler([.alert, .sound, .badge])
|
||||
}
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
didReceive response: UNNotificationResponse,
|
||||
withCompletionHandler completionHandler: @escaping () -> Void
|
||||
) {
|
||||
deliver(chatId: Self.chatId(from: response.notification.request.content.userInfo))
|
||||
completionHandler()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
import AVFoundation
|
||||
import CoreImage
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
private struct VideoExportSpec {
|
||||
let input: String
|
||||
let output: String
|
||||
let startMs: Int?
|
||||
let endMs: Int?
|
||||
let removeAudio: Bool
|
||||
let rotationDegrees: Double
|
||||
let flipH: Bool
|
||||
let crop: [Double]?
|
||||
let outWidth: Int
|
||||
let outHeight: Int
|
||||
let rgbMatrix: [Double]?
|
||||
let overlay: String?
|
||||
let centerSquare: Bool
|
||||
|
||||
init?(_ arguments: Any?) {
|
||||
guard let args = arguments as? [String: Any],
|
||||
let input = args["input"] as? String,
|
||||
let output = args["output"] as? String else { return nil }
|
||||
self.input = input
|
||||
self.output = output
|
||||
startMs = (args["startMs"] as? NSNumber)?.intValue
|
||||
endMs = (args["endMs"] as? NSNumber)?.intValue
|
||||
removeAudio = (args["removeAudio"] as? NSNumber)?.boolValue ?? false
|
||||
rotationDegrees = (args["rotationDegrees"] as? NSNumber)?.doubleValue ?? 0
|
||||
flipH = (args["flipH"] as? NSNumber)?.boolValue ?? false
|
||||
crop = (args["crop"] as? [NSNumber])?.map { $0.doubleValue }
|
||||
outWidth = (args["outWidth"] as? NSNumber)?.intValue ?? 0
|
||||
outHeight = (args["outHeight"] as? NSNumber)?.intValue ?? 0
|
||||
rgbMatrix = (args["rgbMatrix"] as? [NSNumber])?.map { $0.doubleValue }
|
||||
overlay = args["overlay"] as? String
|
||||
centerSquare = false
|
||||
}
|
||||
|
||||
init(input: String, output: String, edge: Int) {
|
||||
self.input = input
|
||||
self.output = output
|
||||
startMs = nil
|
||||
endMs = nil
|
||||
removeAudio = false
|
||||
rotationDegrees = 0
|
||||
flipH = false
|
||||
crop = nil
|
||||
outWidth = edge
|
||||
outHeight = edge
|
||||
rgbMatrix = nil
|
||||
overlay = nil
|
||||
centerSquare = true
|
||||
}
|
||||
}
|
||||
|
||||
final class KometVideo {
|
||||
static let shared = KometVideo()
|
||||
|
||||
private let queue = DispatchQueue(label: "ru.komet.app.video", qos: .userInitiated)
|
||||
private var session: AVAssetExportSession?
|
||||
private var cancelled = false
|
||||
|
||||
func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||
switch call.method {
|
||||
case "probe":
|
||||
probe(call.arguments, result)
|
||||
case "frames":
|
||||
frames(call.arguments, result)
|
||||
case "cropSquare":
|
||||
cropSquare(call.arguments, result)
|
||||
case "edit":
|
||||
guard let spec = VideoExportSpec(call.arguments) else {
|
||||
result(FlutterError(code: "BAD_ARGS", message: "input/output required", details: nil))
|
||||
return
|
||||
}
|
||||
export(spec) { ok in result(NSNumber(value: ok)) }
|
||||
case "editProgress":
|
||||
let value = session.map { Int(($0.progress * 100).rounded()) } ?? -1
|
||||
result(NSNumber(value: value))
|
||||
case "editCancel":
|
||||
cancelled = true
|
||||
session?.cancelExport()
|
||||
result(nil)
|
||||
default:
|
||||
result(FlutterMethodNotImplemented)
|
||||
}
|
||||
}
|
||||
|
||||
private func probe(_ arguments: Any?, _ result: @escaping FlutterResult) {
|
||||
guard let args = arguments as? [String: Any], let input = args["input"] as? String else {
|
||||
result(FlutterError(code: "BAD_ARGS", message: "input required", details: nil))
|
||||
return
|
||||
}
|
||||
queue.async {
|
||||
let asset = AVURLAsset(url: URL(fileURLWithPath: input))
|
||||
guard let track = asset.tracks(withMediaType: .video).first else {
|
||||
Self.reply(result, nil)
|
||||
return
|
||||
}
|
||||
let size = track.naturalSize.applying(track.preferredTransform)
|
||||
let seconds = CMTimeGetSeconds(asset.duration)
|
||||
let durationMs = seconds.isFinite && seconds > 0 ? Int((seconds * 1000).rounded()) : 0
|
||||
let fps = Double(track.nominalFrameRate)
|
||||
let payload: [String: Any] = [
|
||||
"width": Int(abs(size.width).rounded()),
|
||||
"height": Int(abs(size.height).rounded()),
|
||||
"durationMs": durationMs,
|
||||
"fps": fps > 0 ? fps : 30.0,
|
||||
"hasAudio": !asset.tracks(withMediaType: .audio).isEmpty,
|
||||
]
|
||||
Self.reply(result, payload)
|
||||
}
|
||||
}
|
||||
|
||||
private func frames(_ arguments: Any?, _ result: @escaping FlutterResult) {
|
||||
guard let args = arguments as? [String: Any],
|
||||
let input = args["input"] as? String,
|
||||
let times = args["times"] as? [NSNumber] else {
|
||||
result(FlutterError(code: "BAD_ARGS", message: "input/times required", details: nil))
|
||||
return
|
||||
}
|
||||
let edge = (args["size"] as? NSNumber)?.intValue ?? 256
|
||||
let precise = (args["precise"] as? NSNumber)?.boolValue ?? false
|
||||
queue.async {
|
||||
let asset = AVURLAsset(url: URL(fileURLWithPath: input))
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
generator.maximumSize = CGSize(width: edge, height: edge)
|
||||
if precise {
|
||||
generator.requestedTimeToleranceBefore = .zero
|
||||
generator.requestedTimeToleranceAfter = .zero
|
||||
}
|
||||
var output: [Any] = []
|
||||
for time in times {
|
||||
let at = CMTime(value: CMTimeValue(time.int64Value), timescale: 1000)
|
||||
guard let cgImage = try? generator.copyCGImage(at: at, actualTime: nil),
|
||||
let data = UIImage(cgImage: cgImage).jpegData(compressionQuality: 0.9) else {
|
||||
output.append(NSNull())
|
||||
continue
|
||||
}
|
||||
output.append(FlutterStandardTypedData(bytes: data))
|
||||
}
|
||||
Self.reply(result, output)
|
||||
}
|
||||
}
|
||||
|
||||
private func cropSquare(_ arguments: Any?, _ result: @escaping FlutterResult) {
|
||||
guard let args = arguments as? [String: Any],
|
||||
let input = args["input"] as? String,
|
||||
let output = args["output"] as? String else {
|
||||
result(FlutterError(code: "BAD_ARGS", message: "input/output required", details: nil))
|
||||
return
|
||||
}
|
||||
let edge = (args["size"] as? NSNumber)?.intValue ?? 480
|
||||
export(VideoExportSpec(input: input, output: output, edge: edge)) { ok in
|
||||
if ok {
|
||||
result(output)
|
||||
} else {
|
||||
result(FlutterError(code: "TRANSCODE_FAILED", message: "export failed", details: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func export(_ spec: VideoExportSpec, completion: @escaping (Bool) -> Void) {
|
||||
queue.async {
|
||||
self.cancelled = false
|
||||
let asset = AVURLAsset(url: URL(fileURLWithPath: spec.input))
|
||||
guard let videoTrack = asset.tracks(withMediaType: .video).first else {
|
||||
Self.reply { completion(false) }
|
||||
return
|
||||
}
|
||||
|
||||
let totalSeconds = CMTimeGetSeconds(asset.duration)
|
||||
let start = CMTime(value: CMTimeValue(spec.startMs ?? 0), timescale: 1000)
|
||||
let endMs = spec.endMs ?? (totalSeconds.isFinite ? Int((totalSeconds * 1000).rounded()) : 0)
|
||||
let end = CMTime(value: CMTimeValue(max(endMs, spec.startMs ?? 0)), timescale: 1000)
|
||||
let range = CMTimeRange(start: start, end: end)
|
||||
guard range.duration.seconds > 0 else {
|
||||
Self.reply { completion(false) }
|
||||
return
|
||||
}
|
||||
|
||||
let composition = AVMutableComposition()
|
||||
guard let compositionVideo = composition.addMutableTrack(
|
||||
withMediaType: .video, preferredTrackID: kCMPersistentTrackID_Invalid) else {
|
||||
Self.reply { completion(false) }
|
||||
return
|
||||
}
|
||||
do {
|
||||
try compositionVideo.insertTimeRange(range, of: videoTrack, at: .zero)
|
||||
} catch {
|
||||
Self.reply { completion(false) }
|
||||
return
|
||||
}
|
||||
compositionVideo.preferredTransform = videoTrack.preferredTransform
|
||||
|
||||
if !spec.removeAudio,
|
||||
let audioTrack = asset.tracks(withMediaType: .audio).first,
|
||||
let compositionAudio = composition.addMutableTrack(
|
||||
withMediaType: .audio, preferredTrackID: kCMPersistentTrackID_Invalid) {
|
||||
try? compositionAudio.insertTimeRange(range, of: audioTrack, at: .zero)
|
||||
}
|
||||
|
||||
let natural = videoTrack.naturalSize.applying(videoTrack.preferredTransform)
|
||||
let outWidth = spec.outWidth > 0 ? spec.outWidth : Int(abs(natural.width).rounded())
|
||||
let outHeight = spec.outHeight > 0 ? spec.outHeight : Int(abs(natural.height).rounded())
|
||||
guard outWidth > 0, outHeight > 0 else {
|
||||
Self.reply { completion(false) }
|
||||
return
|
||||
}
|
||||
|
||||
let transform = videoTrack.preferredTransform
|
||||
let overlay = spec.overlay.flatMap { UIImage(contentsOfFile: $0) }.flatMap { CIImage(image: $0) }
|
||||
let renderSize = CGSize(width: outWidth, height: outHeight)
|
||||
|
||||
let videoComposition = AVMutableVideoComposition(asset: composition) { request in
|
||||
let image = Self.render(
|
||||
request.sourceImage,
|
||||
spec: spec,
|
||||
transform: transform,
|
||||
overlay: overlay,
|
||||
renderSize: renderSize)
|
||||
request.finish(with: image, context: nil)
|
||||
}
|
||||
videoComposition.renderSize = renderSize
|
||||
|
||||
let outputURL = URL(fileURLWithPath: spec.output)
|
||||
try? FileManager.default.removeItem(at: outputURL)
|
||||
|
||||
guard let session = AVAssetExportSession(
|
||||
asset: composition, presetName: AVAssetExportPresetHighestQuality) else {
|
||||
Self.reply { completion(false) }
|
||||
return
|
||||
}
|
||||
session.outputURL = outputURL
|
||||
session.outputFileType = .mp4
|
||||
session.videoComposition = videoComposition
|
||||
session.shouldOptimizeForNetworkUse = true
|
||||
self.session = session
|
||||
|
||||
session.exportAsynchronously {
|
||||
let ok = session.status == .completed && !self.cancelled
|
||||
self.session = nil
|
||||
if !ok { try? FileManager.default.removeItem(at: outputURL) }
|
||||
Self.reply { completion(ok) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func render(
|
||||
_ source: CIImage,
|
||||
spec: VideoExportSpec,
|
||||
transform: CGAffineTransform,
|
||||
overlay: CIImage?,
|
||||
renderSize: CGSize
|
||||
) -> CIImage {
|
||||
var image = normalized(source.transformed(by: transform))
|
||||
|
||||
if spec.flipH {
|
||||
image = normalized(image.transformed(by: CGAffineTransform(scaleX: -1, y: 1)))
|
||||
}
|
||||
if abs(spec.rotationDegrees) > 0.01 {
|
||||
let radians = CGFloat(spec.rotationDegrees * .pi / 180)
|
||||
image = normalized(image.transformed(by: CGAffineTransform(rotationAngle: radians)))
|
||||
}
|
||||
if spec.centerSquare {
|
||||
let extent = image.extent
|
||||
let side = min(extent.width, extent.height)
|
||||
image = normalized(image.cropped(to: CGRect(
|
||||
x: extent.midX - side / 2,
|
||||
y: extent.midY - side / 2,
|
||||
width: side,
|
||||
height: side)))
|
||||
} else if let crop = spec.crop, crop.count == 4 {
|
||||
let extent = image.extent
|
||||
let left = CGFloat((crop[0] + 1) / 2)
|
||||
let right = CGFloat((crop[1] + 1) / 2)
|
||||
let bottom = CGFloat((1 - crop[2]) / 2)
|
||||
let top = CGFloat((1 - crop[3]) / 2)
|
||||
let rect = CGRect(
|
||||
x: extent.minX + left * extent.width,
|
||||
y: extent.minY + (1 - bottom) * extent.height,
|
||||
width: max(1, (right - left) * extent.width),
|
||||
height: max(1, (bottom - top) * extent.height))
|
||||
image = normalized(image.cropped(to: rect))
|
||||
}
|
||||
|
||||
let extent = image.extent
|
||||
if extent.width > 0, extent.height > 0 {
|
||||
image = image.transformed(by: CGAffineTransform(
|
||||
scaleX: renderSize.width / extent.width,
|
||||
y: renderSize.height / extent.height))
|
||||
image = normalized(image)
|
||||
}
|
||||
|
||||
if let matrix = spec.rgbMatrix, matrix.count == 16,
|
||||
let filter = CIFilter(name: "CIColorMatrix") {
|
||||
filter.setValue(image, forKey: kCIInputImageKey)
|
||||
filter.setValue(vector(matrix, 0, 4, 8), forKey: "inputRVector")
|
||||
filter.setValue(vector(matrix, 1, 5, 9), forKey: "inputGVector")
|
||||
filter.setValue(vector(matrix, 2, 6, 10), forKey: "inputBVector")
|
||||
filter.setValue(CIVector(x: 0, y: 0, z: 0, w: 1), forKey: "inputAVector")
|
||||
filter.setValue(vector(matrix, 12, 13, 14), forKey: "inputBiasVector")
|
||||
if let output = filter.outputImage { image = output }
|
||||
}
|
||||
|
||||
if let overlay = overlay {
|
||||
image = overlay.composited(over: image)
|
||||
}
|
||||
|
||||
return image.cropped(to: CGRect(origin: .zero, size: renderSize))
|
||||
}
|
||||
|
||||
private static func vector(_ m: [Double], _ x: Int, _ y: Int, _ z: Int) -> CIVector {
|
||||
CIVector(x: CGFloat(m[x]), y: CGFloat(m[y]), z: CGFloat(m[z]), w: 0)
|
||||
}
|
||||
|
||||
private static func normalized(_ image: CIImage) -> CIImage {
|
||||
let extent = image.extent
|
||||
guard extent.origin != .zero else { return image }
|
||||
return image.transformed(
|
||||
by: CGAffineTransform(translationX: -extent.origin.x, y: -extent.origin.y))
|
||||
}
|
||||
|
||||
private static func reply(_ result: @escaping FlutterResult, _ value: Any?) {
|
||||
DispatchQueue.main.async { result(value) }
|
||||
}
|
||||
|
||||
private static func reply(_ block: @escaping () -> Void) {
|
||||
DispatchQueue.main.async(execute: block)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
import AVFoundation
|
||||
import CoreImage
|
||||
import Flutter
|
||||
import UIKit
|
||||
|
||||
final class KometVideoNoteTexture: NSObject, FlutterTexture {
|
||||
private let lock = NSLock()
|
||||
private var latest: CVPixelBuffer?
|
||||
|
||||
func push(_ buffer: CVPixelBuffer) {
|
||||
lock.lock()
|
||||
latest = buffer
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func copyPixelBuffer() -> Unmanaged<CVPixelBuffer>? {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
guard let buffer = latest else { return nil }
|
||||
return Unmanaged.passRetained(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
final class KometVideoNote: NSObject {
|
||||
private let registry: FlutterTextureRegistry
|
||||
private let queue = DispatchQueue(label: "ru.komet.app.videonote", qos: .userInitiated)
|
||||
private let ciContext = CIContext(options: [.useSoftwareRenderer: false])
|
||||
|
||||
private let session = AVCaptureSession()
|
||||
private let videoOutput = AVCaptureVideoDataOutput()
|
||||
private let audioOutput = AVCaptureAudioDataOutput()
|
||||
private let texture = KometVideoNoteTexture()
|
||||
|
||||
private var textureId: Int64 = 0
|
||||
private var deviceInput: AVCaptureDeviceInput?
|
||||
private var audioInput: AVCaptureDeviceInput?
|
||||
private var position: AVCaptureDevice.Position = .front
|
||||
private var edge: Int = 480
|
||||
private var fps: Int = 30
|
||||
|
||||
private var pixelBufferPool: CVPixelBufferPool?
|
||||
private var writer: AVAssetWriter?
|
||||
private var writerVideo: AVAssetWriterInput?
|
||||
private var writerAudio: AVAssetWriterInput?
|
||||
private var adaptor: AVAssetWriterInputPixelBufferAdaptor?
|
||||
private var outputURL: URL?
|
||||
private var recording = false
|
||||
private var sessionStarted = false
|
||||
|
||||
init(registry: FlutterTextureRegistry) {
|
||||
self.registry = registry
|
||||
super.init()
|
||||
}
|
||||
|
||||
static func requestPermission(_ result: @escaping FlutterResult) {
|
||||
AVCaptureDevice.requestAccess(for: .video) { video in
|
||||
guard video else {
|
||||
DispatchQueue.main.async { result(NSNumber(value: false)) }
|
||||
return
|
||||
}
|
||||
AVCaptureDevice.requestAccess(for: .audio) { audio in
|
||||
DispatchQueue.main.async { result(NSNumber(value: audio)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func initialize(front: Bool, edge: Int, fps: Int, result: @escaping FlutterResult) {
|
||||
self.position = front ? .front : .back
|
||||
self.edge = max(16, edge)
|
||||
self.fps = max(1, fps)
|
||||
|
||||
queue.async {
|
||||
guard AVCaptureDevice.authorizationStatus(for: .video) == .authorized else {
|
||||
Self.fail(result, "NO_PERMISSION", "camera permission required")
|
||||
return
|
||||
}
|
||||
do {
|
||||
try self.configureSession()
|
||||
} catch {
|
||||
Self.fail(result, "NO_CAMERA", error.localizedDescription)
|
||||
return
|
||||
}
|
||||
self.session.startRunning()
|
||||
|
||||
DispatchQueue.main.async {
|
||||
if self.textureId == 0 {
|
||||
self.textureId = self.registry.register(self.texture)
|
||||
}
|
||||
result(["textureId": NSNumber(value: self.textureId), "hasFlash": self.hasTorch()])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func switchCamera(result: @escaping FlutterResult) {
|
||||
queue.async {
|
||||
self.position = self.position == .front ? .back : .front
|
||||
do {
|
||||
try self.configureSession()
|
||||
} catch {
|
||||
Self.fail(result, "NO_CAMERA", error.localizedDescription)
|
||||
return
|
||||
}
|
||||
DispatchQueue.main.async { result(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
func setTorch(on: Bool, result: @escaping FlutterResult) {
|
||||
queue.async {
|
||||
guard let device = self.deviceInput?.device, device.hasTorch else {
|
||||
DispatchQueue.main.async { result(NSNumber(value: false)) }
|
||||
return
|
||||
}
|
||||
var applied = false
|
||||
if (try? device.lockForConfiguration()) != nil {
|
||||
device.torchMode = on ? .on : .off
|
||||
device.unlockForConfiguration()
|
||||
applied = on
|
||||
}
|
||||
DispatchQueue.main.async { result(NSNumber(value: applied)) }
|
||||
}
|
||||
}
|
||||
|
||||
func start(result: @escaping FlutterResult) {
|
||||
queue.async {
|
||||
guard !self.recording else {
|
||||
DispatchQueue.main.async { result(nil) }
|
||||
return
|
||||
}
|
||||
do {
|
||||
try self.prepareWriter()
|
||||
} catch {
|
||||
Self.fail(result, "START_FAILED", error.localizedDescription)
|
||||
return
|
||||
}
|
||||
self.sessionStarted = false
|
||||
self.recording = true
|
||||
DispatchQueue.main.async { result(nil) }
|
||||
}
|
||||
}
|
||||
|
||||
func stop(result: @escaping FlutterResult) {
|
||||
queue.async {
|
||||
guard self.recording, let writer = self.writer else {
|
||||
Self.fail(result, "NOT_RECORDING", "no active recording")
|
||||
return
|
||||
}
|
||||
self.recording = false
|
||||
self.writerVideo?.markAsFinished()
|
||||
self.writerAudio?.markAsFinished()
|
||||
let url = self.outputURL
|
||||
writer.finishWriting {
|
||||
let ok = writer.status == .completed
|
||||
self.writer = nil
|
||||
self.writerVideo = nil
|
||||
self.writerAudio = nil
|
||||
self.adaptor = nil
|
||||
self.outputURL = nil
|
||||
let path: String? = ok ? url?.path : nil
|
||||
DispatchQueue.main.async {
|
||||
result(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dispose() {
|
||||
queue.sync {
|
||||
if self.recording {
|
||||
self.recording = false
|
||||
self.writerVideo?.markAsFinished()
|
||||
self.writerAudio?.markAsFinished()
|
||||
self.writer?.cancelWriting()
|
||||
self.writer = nil
|
||||
}
|
||||
if self.session.isRunning { self.session.stopRunning() }
|
||||
for input in self.session.inputs { self.session.removeInput(input) }
|
||||
for output in self.session.outputs { self.session.removeOutput(output) }
|
||||
self.deviceInput = nil
|
||||
self.audioInput = nil
|
||||
self.pixelBufferPool = nil
|
||||
}
|
||||
if textureId != 0 {
|
||||
registry.unregisterTexture(textureId)
|
||||
textureId = 0
|
||||
}
|
||||
}
|
||||
|
||||
private func hasTorch() -> Bool {
|
||||
deviceInput?.device.hasTorch ?? false
|
||||
}
|
||||
|
||||
private func configureSession() throws {
|
||||
session.beginConfiguration()
|
||||
defer { session.commitConfiguration() }
|
||||
|
||||
if let existing = deviceInput {
|
||||
session.removeInput(existing)
|
||||
deviceInput = nil
|
||||
}
|
||||
|
||||
guard let device = AVCaptureDevice.default(
|
||||
.builtInWideAngleCamera, for: .video, position: position)
|
||||
?? AVCaptureDevice.default(for: .video) else {
|
||||
throw NSError(domain: "KometVideoNote", code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "no camera found"])
|
||||
}
|
||||
let input = try AVCaptureDeviceInput(device: device)
|
||||
guard session.canAddInput(input) else {
|
||||
throw NSError(domain: "KometVideoNote", code: 2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "camera input rejected"])
|
||||
}
|
||||
session.addInput(input)
|
||||
deviceInput = input
|
||||
|
||||
if session.canSetSessionPreset(.hd1280x720) {
|
||||
session.sessionPreset = .hd1280x720
|
||||
}
|
||||
|
||||
if audioInput == nil,
|
||||
let microphone = AVCaptureDevice.default(for: .audio),
|
||||
let input = try? AVCaptureDeviceInput(device: microphone),
|
||||
session.canAddInput(input) {
|
||||
session.addInput(input)
|
||||
audioInput = input
|
||||
}
|
||||
|
||||
if !session.outputs.contains(videoOutput) {
|
||||
videoOutput.videoSettings = [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||
]
|
||||
videoOutput.alwaysDiscardsLateVideoFrames = true
|
||||
videoOutput.setSampleBufferDelegate(self, queue: queue)
|
||||
if session.canAddOutput(videoOutput) { session.addOutput(videoOutput) }
|
||||
}
|
||||
if !session.outputs.contains(audioOutput) {
|
||||
audioOutput.setSampleBufferDelegate(self, queue: queue)
|
||||
if session.canAddOutput(audioOutput) { session.addOutput(audioOutput) }
|
||||
}
|
||||
|
||||
if let connection = videoOutput.connection(with: .video) {
|
||||
if connection.isVideoOrientationSupported {
|
||||
connection.videoOrientation = .portrait
|
||||
}
|
||||
if connection.isVideoMirroringSupported {
|
||||
connection.automaticallyAdjustsVideoMirroring = false
|
||||
connection.isVideoMirrored = position == .front
|
||||
}
|
||||
}
|
||||
|
||||
if (try? device.lockForConfiguration()) != nil {
|
||||
let duration = CMTimeMake(value: 1, timescale: Int32(fps))
|
||||
if device.activeFormat.videoSupportedFrameRateRanges.contains(where: {
|
||||
$0.minFrameRate <= Double(fps) && Double(fps) <= $0.maxFrameRate
|
||||
}) {
|
||||
device.activeVideoMinFrameDuration = duration
|
||||
device.activeVideoMaxFrameDuration = duration
|
||||
}
|
||||
device.unlockForConfiguration()
|
||||
}
|
||||
|
||||
pixelBufferPool = Self.makePool(edge: edge)
|
||||
}
|
||||
|
||||
private func prepareWriter() throws {
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
let url = directory.appendingPathComponent(
|
||||
"komet_note_\(Int(Date().timeIntervalSince1970 * 1000)).mp4")
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
|
||||
let writer = try AVAssetWriter(outputURL: url, fileType: .mp4)
|
||||
let videoSettings: [String: Any] = [
|
||||
AVVideoCodecKey: AVVideoCodecType.h264,
|
||||
AVVideoWidthKey: edge,
|
||||
AVVideoHeightKey: edge,
|
||||
AVVideoCompressionPropertiesKey: [
|
||||
AVVideoAverageBitRateKey: 1_024_000,
|
||||
AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel,
|
||||
],
|
||||
]
|
||||
let video = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings)
|
||||
video.expectsMediaDataInRealTime = true
|
||||
guard writer.canAdd(video) else {
|
||||
throw NSError(domain: "KometVideoNote", code: 3,
|
||||
userInfo: [NSLocalizedDescriptionKey: "video input rejected"])
|
||||
}
|
||||
writer.add(video)
|
||||
|
||||
let audioSettings: [String: Any] = [
|
||||
AVFormatIDKey: kAudioFormatMPEG4AAC,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVSampleRateKey: 44100,
|
||||
AVEncoderBitRateKey: 64000,
|
||||
]
|
||||
let audio = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings)
|
||||
audio.expectsMediaDataInRealTime = true
|
||||
if writer.canAdd(audio) { writer.add(audio) }
|
||||
|
||||
adaptor = AVAssetWriterInputPixelBufferAdaptor(
|
||||
assetWriterInput: video,
|
||||
sourcePixelBufferAttributes: [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferWidthKey as String: edge,
|
||||
kCVPixelBufferHeightKey as String: edge,
|
||||
])
|
||||
|
||||
guard writer.startWriting() else {
|
||||
throw NSError(domain: "KometVideoNote", code: 4,
|
||||
userInfo: [NSLocalizedDescriptionKey: "writer refused to start"])
|
||||
}
|
||||
|
||||
self.writer = writer
|
||||
self.writerVideo = video
|
||||
self.writerAudio = writer.inputs.contains(audio) ? audio : nil
|
||||
self.outputURL = url
|
||||
}
|
||||
|
||||
private static func makePool(edge: Int) -> CVPixelBufferPool? {
|
||||
let attributes: [String: Any] = [
|
||||
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA,
|
||||
kCVPixelBufferWidthKey as String: edge,
|
||||
kCVPixelBufferHeightKey as String: edge,
|
||||
kCVPixelBufferIOSurfacePropertiesKey as String: [String: Any](),
|
||||
]
|
||||
var pool: CVPixelBufferPool?
|
||||
CVPixelBufferPoolCreate(kCFAllocatorDefault, nil, attributes as CFDictionary, &pool)
|
||||
return pool
|
||||
}
|
||||
|
||||
private func squareBuffer(from source: CVPixelBuffer) -> CVPixelBuffer? {
|
||||
guard let pool = pixelBufferPool else { return nil }
|
||||
var target: CVPixelBuffer?
|
||||
guard CVPixelBufferPoolCreatePixelBuffer(kCFAllocatorDefault, pool, &target)
|
||||
== kCVReturnSuccess, let output = target else { return nil }
|
||||
|
||||
let image = CIImage(cvPixelBuffer: source)
|
||||
let extent = image.extent
|
||||
let side = min(extent.width, extent.height)
|
||||
let cropped = image.cropped(to: CGRect(
|
||||
x: extent.midX - side / 2,
|
||||
y: extent.midY - side / 2,
|
||||
width: side,
|
||||
height: side))
|
||||
let scale = CGFloat(edge) / side
|
||||
let scaled = cropped
|
||||
.transformed(by: CGAffineTransform(translationX: -cropped.extent.origin.x,
|
||||
y: -cropped.extent.origin.y))
|
||||
.transformed(by: CGAffineTransform(scaleX: scale, y: scale))
|
||||
|
||||
ciContext.render(scaled, to: output)
|
||||
return output
|
||||
}
|
||||
|
||||
private static func fail(_ result: @escaping FlutterResult, _ code: String, _ message: String) {
|
||||
DispatchQueue.main.async {
|
||||
result(FlutterError(code: code, message: message, details: nil))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension KometVideoNote: AVCaptureVideoDataOutputSampleBufferDelegate,
|
||||
AVCaptureAudioDataOutputSampleBufferDelegate {
|
||||
func captureOutput(
|
||||
_ output: AVCaptureOutput,
|
||||
didOutput sampleBuffer: CMSampleBuffer,
|
||||
from connection: AVCaptureConnection
|
||||
) {
|
||||
if output === audioOutput {
|
||||
appendAudio(sampleBuffer)
|
||||
return
|
||||
}
|
||||
guard let source = CMSampleBufferGetImageBuffer(sampleBuffer),
|
||||
let square = squareBuffer(from: source) else { return }
|
||||
|
||||
texture.push(square)
|
||||
let id = textureId
|
||||
if id != 0 {
|
||||
DispatchQueue.main.async { self.registry.textureFrameAvailable(id) }
|
||||
}
|
||||
|
||||
guard recording, let writer = writer, let input = writerVideo,
|
||||
let adaptor = adaptor else { return }
|
||||
let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer)
|
||||
if !sessionStarted {
|
||||
writer.startSession(atSourceTime: timestamp)
|
||||
sessionStarted = true
|
||||
}
|
||||
guard input.isReadyForMoreMediaData else { return }
|
||||
adaptor.append(square, withPresentationTime: timestamp)
|
||||
}
|
||||
|
||||
private func appendAudio(_ sampleBuffer: CMSampleBuffer) {
|
||||
guard recording, sessionStarted, let input = writerAudio,
|
||||
input.isReadyForMoreMediaData else { return }
|
||||
input.append(sampleBuffer)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)ru.komet.app</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
Reference in New Issue
Block a user