0.10.6
This commit is contained in:
commit
92660a4ba0
228 files changed
+24496
No files matched your search
@@ -0,0 +1,147 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class AudioMessageRecorder: NSObject, ObservableObject {
|
||||
struct Recording {
|
||||
let url: URL
|
||||
let duration: TimeInterval
|
||||
}
|
||||
|
||||
@Published private(set) var isRecording = false
|
||||
@Published private(set) var elapsed: TimeInterval = 0
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var timer: Timer?
|
||||
private var startedAt: Date?
|
||||
|
||||
func start() async throws {
|
||||
guard !isRecording else { return }
|
||||
guard await requestPermission() else { throw AudioRecorderError.permissionDenied }
|
||||
guard !Task.isCancelled else { throw CancellationError() }
|
||||
var didStart = false
|
||||
defer {
|
||||
#if os(iOS)
|
||||
if !didStart {
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothHFP])
|
||||
try session.setActive(true)
|
||||
#endif
|
||||
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("LumaRecordings", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let url = directory.appendingPathComponent("voice-\(UUID().uuidString).m4a")
|
||||
let settings: [String: Any] = [
|
||||
AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
|
||||
AVSampleRateKey: 44_100.0,
|
||||
AVNumberOfChannelsKey: 1,
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
|
||||
]
|
||||
let recorder = try AVAudioRecorder(url: url, settings: settings)
|
||||
recorder.prepareToRecord()
|
||||
guard recorder.record() else { throw AudioRecorderError.cannotStart }
|
||||
|
||||
self.recorder = recorder
|
||||
startedAt = Date()
|
||||
elapsed = 0
|
||||
isRecording = true
|
||||
didStart = true
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self, let startedAt = self.startedAt else { return }
|
||||
self.elapsed = Date().timeIntervalSince(startedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func finish() throws -> Recording {
|
||||
guard let recorder, isRecording else { throw AudioRecorderError.notRecording }
|
||||
let duration = max(recorder.currentTime, elapsed)
|
||||
let url = recorder.url
|
||||
recorder.stop()
|
||||
stopState()
|
||||
guard duration >= 0.4 else {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
throw AudioRecorderError.tooShort
|
||||
}
|
||||
return Recording(url: url, duration: duration)
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
let url = recorder?.url
|
||||
recorder?.stop()
|
||||
stopState()
|
||||
if let url {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func stopState() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
recorder = nil
|
||||
startedAt = nil
|
||||
elapsed = 0
|
||||
isRecording = false
|
||||
#if os(iOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func requestPermission() async -> Bool {
|
||||
#if os(iOS)
|
||||
let session = AVAudioApplication.shared
|
||||
switch session.recordPermission {
|
||||
case .granted:
|
||||
return true
|
||||
case .denied:
|
||||
return false
|
||||
case .undetermined:
|
||||
return await withCheckedContinuation { continuation in
|
||||
AVAudioApplication.requestRecordPermission { continuation.resume(returning: $0) }
|
||||
}
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
#else
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized:
|
||||
return true
|
||||
case .denied, .restricted:
|
||||
return false
|
||||
case .notDetermined:
|
||||
return await AVCaptureDevice.requestAccess(for: .audio)
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
private enum AudioRecorderError: LocalizedError {
|
||||
case permissionDenied
|
||||
case cannotStart
|
||||
case notRecording
|
||||
case tooShort
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .permissionDenied:
|
||||
return "Разрешите Luma доступ к микрофону в системных настройках."
|
||||
case .cannotStart:
|
||||
return "Не удалось начать запись голоса."
|
||||
case .notRecording:
|
||||
return "Запись голоса не запущена."
|
||||
case .tooShort:
|
||||
return "Голосовое сообщение получилось слишком коротким."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
enum AvatarImageProcessor {
|
||||
static func pngData(from sourceData: Data) throws -> Data {
|
||||
guard let source = CGImageSourceCreateWithData(sourceData as CFData, nil),
|
||||
let image = CGImageSourceCreateThumbnailAtIndex(
|
||||
source,
|
||||
0,
|
||||
[
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: 512
|
||||
] as CFDictionary
|
||||
) else {
|
||||
throw AvatarImageError.invalidImage
|
||||
}
|
||||
|
||||
let output = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(
|
||||
output,
|
||||
UTType.png.identifier as CFString,
|
||||
1,
|
||||
nil
|
||||
) else {
|
||||
throw AvatarImageError.encodingFailed
|
||||
}
|
||||
CGImageDestinationAddImage(destination, image, nil)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
throw AvatarImageError.encodingFailed
|
||||
}
|
||||
return output as Data
|
||||
}
|
||||
}
|
||||
|
||||
private enum AvatarImageError: LocalizedError {
|
||||
case invalidImage
|
||||
case encodingFailed
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidImage:
|
||||
return "Не удалось прочитать выбранное изображение."
|
||||
case .encodingFailed:
|
||||
return "Не удалось подготовить аватар в формате PNG."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
typealias ChatMediaPlatformImage = UIImage
|
||||
#elseif os(macOS)
|
||||
import AppKit
|
||||
typealias ChatMediaPlatformImage = NSImage
|
||||
#endif
|
||||
|
||||
/// SwiftUI can evaluate every visible bubble repeatedly during scrolling.
|
||||
/// Keep decoded thumbnails in a bounded native-image cache so those passes do
|
||||
/// not repeatedly decompress JPEG/HEIC data on the main actor.
|
||||
@MainActor
|
||||
enum ChatMediaImageCache {
|
||||
private static let cache: NSCache<NSString, ChatMediaPlatformImage> = {
|
||||
let cache = NSCache<NSString, ChatMediaPlatformImage>()
|
||||
cache.countLimit = 160
|
||||
cache.totalCostLimit = 48 * 1_024 * 1_024
|
||||
return cache
|
||||
}()
|
||||
|
||||
static func image(
|
||||
for message: ChatMessage,
|
||||
data: Data
|
||||
) -> ChatMediaPlatformImage? {
|
||||
let cacheKey = "\(message.conversationID)\u{1F}\(message.id)" as NSString
|
||||
if let cached = cache.object(forKey: cacheKey) {
|
||||
return cached
|
||||
}
|
||||
guard let image = ChatMediaPlatformImage(data: data) else { return nil }
|
||||
cache.setObject(image, forKey: cacheKey, cost: data.count)
|
||||
return image
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import CoreLocation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class LocationProvider: NSObject, ObservableObject {
|
||||
@Published private(set) var location: CLLocation?
|
||||
@Published private(set) var authorizationStatus: CLAuthorizationStatus
|
||||
@Published private(set) var errorMessage: String?
|
||||
@Published private(set) var isLocating = false
|
||||
|
||||
private let manager: CLLocationManager
|
||||
|
||||
override init() {
|
||||
let manager = CLLocationManager()
|
||||
self.manager = manager
|
||||
authorizationStatus = manager.authorizationStatus
|
||||
super.init()
|
||||
manager.delegate = self
|
||||
manager.desiredAccuracy = kCLLocationAccuracyBest
|
||||
}
|
||||
|
||||
func requestCurrentLocation() {
|
||||
errorMessage = nil
|
||||
switch manager.authorizationStatus {
|
||||
case .notDetermined:
|
||||
isLocating = true
|
||||
manager.requestWhenInUseAuthorization()
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
isLocating = true
|
||||
manager.requestLocation()
|
||||
case .denied, .restricted:
|
||||
isLocating = false
|
||||
errorMessage = "Доступ к геопозиции выключен. Можно выбрать точку на карте вручную."
|
||||
@unknown default:
|
||||
isLocating = false
|
||||
errorMessage = "Не удалось определить статус доступа к геопозиции."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension LocationProvider: CLLocationManagerDelegate {
|
||||
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
|
||||
let status = manager.authorizationStatus
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
authorizationStatus = status
|
||||
switch status {
|
||||
case .authorizedAlways, .authorizedWhenInUse:
|
||||
isLocating = true
|
||||
self.manager.requestLocation()
|
||||
case .denied, .restricted:
|
||||
isLocating = false
|
||||
errorMessage = "Доступ к геопозиции выключен. Можно выбрать точку на карте вручную."
|
||||
case .notDetermined:
|
||||
break
|
||||
@unknown default:
|
||||
isLocating = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(
|
||||
_ manager: CLLocationManager,
|
||||
didUpdateLocations locations: [CLLocation]
|
||||
) {
|
||||
guard let newest = locations.last else { return }
|
||||
let latitude = newest.coordinate.latitude
|
||||
let longitude = newest.coordinate.longitude
|
||||
let altitude = newest.altitude
|
||||
let horizontalAccuracy = newest.horizontalAccuracy
|
||||
let verticalAccuracy = newest.verticalAccuracy
|
||||
let timestamp = newest.timestamp
|
||||
Task { @MainActor [weak self] in
|
||||
self?.location = CLLocation(
|
||||
coordinate: CLLocationCoordinate2D(latitude: latitude, longitude: longitude),
|
||||
altitude: altitude,
|
||||
horizontalAccuracy: horizontalAccuracy,
|
||||
verticalAccuracy: verticalAccuracy,
|
||||
timestamp: timestamp
|
||||
)
|
||||
self?.isLocating = false
|
||||
self?.errorMessage = nil
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func locationManager(_ manager: CLLocationManager, didFailWithError error: Error) {
|
||||
let description = error.localizedDescription
|
||||
Task { @MainActor [weak self] in
|
||||
self?.isLocating = false
|
||||
self?.errorMessage = "Не удалось получить текущую геопозицию: \(description)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import Foundation
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
struct LoadedMediaFile: Sendable {
|
||||
let data: Data
|
||||
let filename: String
|
||||
let mimeType: String
|
||||
let inferredKind: ChatMessage.Kind
|
||||
}
|
||||
|
||||
struct StagedMediaFile: Sendable {
|
||||
let url: URL
|
||||
let filename: String
|
||||
let contentTypeIdentifier: String?
|
||||
let byteCount: Int
|
||||
}
|
||||
|
||||
enum MediaFileIOError: LocalizedError, Sendable {
|
||||
case coordinatedReadFailed(String)
|
||||
case emptyFile(String)
|
||||
case unreadableFile(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .coordinatedReadFailed(let message):
|
||||
return "Не удалось получить выбранный файл: \(message)"
|
||||
case .emptyFile(let filename):
|
||||
return "Файл «\(filename)» пуст или ещё не загружен из iCloud."
|
||||
case .unreadableFile(let filename):
|
||||
return "Не удалось подготовить «\(filename)» для отправки."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes potentially large media file reads and writes away from the
|
||||
/// main actor so finishing a video note cannot freeze the composer.
|
||||
actor MediaFileIO {
|
||||
/// Monal immediately copies an item-provider URL into its own cache under
|
||||
/// NSFileCoordinator before preview or upload work starts. Do the same for
|
||||
/// every Luma draft so Photos/iCloud temporary URLs can disappear safely.
|
||||
func stageAttachmentDraft(from source: URL) throws -> StagedMediaFile {
|
||||
let fileManager = FileManager.default
|
||||
let directory = fileManager.temporaryDirectory
|
||||
.appendingPathComponent("LumaAttachmentDrafts", isDirectory: true)
|
||||
try fileManager.createDirectory(
|
||||
at: directory,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
|
||||
let accessed = source.startAccessingSecurityScopedResource()
|
||||
defer {
|
||||
if accessed { source.stopAccessingSecurityScopedResource() }
|
||||
}
|
||||
|
||||
var coordinatedError: NSError?
|
||||
var stagedResult: Result<StagedMediaFile, Error>?
|
||||
let coordinator = NSFileCoordinator(filePresenter: nil)
|
||||
coordinator.coordinate(
|
||||
readingItemAt: source,
|
||||
options: [],
|
||||
error: &coordinatedError
|
||||
) { coordinatedURL in
|
||||
stagedResult = Result {
|
||||
let originalValues = try? source.resourceValues(
|
||||
forKeys: [.nameKey, .contentTypeKey]
|
||||
)
|
||||
let coordinatedValues = try coordinatedURL.resourceValues(
|
||||
forKeys: [.fileSizeKey, .contentTypeKey]
|
||||
)
|
||||
let sourceName = originalValues?.name ?? source.lastPathComponent
|
||||
let filename = sourceName.isEmpty
|
||||
? coordinatedURL.lastPathComponent
|
||||
: sourceName
|
||||
let safeComponent = Self.safePathComponent(filename)
|
||||
let destination = directory.appendingPathComponent(
|
||||
"\(UUID().uuidString)-\(safeComponent)"
|
||||
)
|
||||
|
||||
do {
|
||||
try fileManager.copyItem(at: coordinatedURL, to: destination)
|
||||
let stagedValues = try destination.resourceValues(
|
||||
forKeys: [.fileSizeKey, .isRegularFileKey]
|
||||
)
|
||||
let byteCount = stagedValues.fileSize ?? coordinatedValues.fileSize ?? 0
|
||||
guard stagedValues.isRegularFile == true,
|
||||
fileManager.isReadableFile(atPath: destination.path) else {
|
||||
throw MediaFileIOError.unreadableFile(filename)
|
||||
}
|
||||
guard byteCount > 0 else {
|
||||
throw MediaFileIOError.emptyFile(filename)
|
||||
}
|
||||
let contentType = originalValues?.contentType
|
||||
?? coordinatedValues.contentType
|
||||
?? UTType(filenameExtension: source.pathExtension)
|
||||
return StagedMediaFile(
|
||||
url: destination,
|
||||
filename: filename,
|
||||
contentTypeIdentifier: contentType?.identifier,
|
||||
byteCount: byteCount
|
||||
)
|
||||
} catch {
|
||||
try? fileManager.removeItem(at: destination)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let stagedResult {
|
||||
return try stagedResult.get()
|
||||
}
|
||||
throw MediaFileIOError.coordinatedReadFailed(
|
||||
coordinatedError?.localizedDescription ?? "неизвестная ошибка"
|
||||
)
|
||||
}
|
||||
|
||||
func load(
|
||||
from url: URL,
|
||||
preferredKind: ChatMessage.Kind?
|
||||
) throws -> LoadedMediaFile {
|
||||
let values = try url.resourceValues(forKeys: [.nameKey, .contentTypeKey])
|
||||
let filename = values.name ?? url.lastPathComponent
|
||||
let contentType = values.contentType ?? UTType(filenameExtension: url.pathExtension)
|
||||
let kind = preferredKind ?? Self.mediaKind(
|
||||
for: contentType,
|
||||
filename: filename
|
||||
)
|
||||
return LoadedMediaFile(
|
||||
data: try Data(contentsOf: url, options: [.mappedIfSafe]),
|
||||
filename: filename,
|
||||
mimeType: contentType?.preferredMIMEType ?? "application/octet-stream",
|
||||
inferredKind: kind
|
||||
)
|
||||
}
|
||||
|
||||
func write(_ data: Data, to url: URL) throws {
|
||||
try data.write(to: url, options: [.atomic])
|
||||
}
|
||||
|
||||
private static func safePathComponent(_ value: String) -> String {
|
||||
let forbidden = CharacterSet(charactersIn: "/\\:")
|
||||
.union(.controlCharacters)
|
||||
let cleaned = value.components(separatedBy: forbidden).joined(separator: "_")
|
||||
return cleaned.isEmpty ? "attachment" : cleaned
|
||||
}
|
||||
|
||||
private static func mediaKind(
|
||||
for contentType: UTType?,
|
||||
filename: String
|
||||
) -> ChatMessage.Kind {
|
||||
if contentType?.conforms(to: .image) == true { return .photo }
|
||||
if contentType?.conforms(to: .movie) == true { return .video }
|
||||
if contentType?.conforms(to: .audio) == true { return .audio }
|
||||
|
||||
let fallback = UTType(
|
||||
filenameExtension: URL(fileURLWithPath: filename).pathExtension
|
||||
)
|
||||
if fallback?.conforms(to: .image) == true { return .photo }
|
||||
if fallback?.conforms(to: .movie) == true { return .video }
|
||||
if fallback?.conforms(to: .audio) == true { return .audio }
|
||||
return .attachment
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
extension Notification.Name {
|
||||
static let lumaExclusiveMediaPlayback = Notification.Name("app.luma.exclusiveMediaPlayback")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MediaPlaybackCoordinator: ObservableObject {
|
||||
@Published private(set) var currentMessageID: String?
|
||||
@Published private(set) var isPlaying = false
|
||||
@Published private(set) var currentTime: TimeInterval = 0
|
||||
@Published private(set) var duration: TimeInterval = 0
|
||||
@Published private(set) var voiceRate: Double = 1
|
||||
|
||||
private var player: AVPlayer?
|
||||
private var timeObserver: Any?
|
||||
private var endObserver: NSObjectProtocol?
|
||||
private var currentIsVoice = false
|
||||
private var durationTask: Task<Void, Never>?
|
||||
|
||||
deinit {
|
||||
durationTask?.cancel()
|
||||
if let timeObserver, let player {
|
||||
player.removeTimeObserver(timeObserver)
|
||||
}
|
||||
if let endObserver {
|
||||
NotificationCenter.default.removeObserver(endObserver)
|
||||
}
|
||||
}
|
||||
|
||||
func toggle(
|
||||
messageID: String,
|
||||
url: URL,
|
||||
durationHint: TimeInterval?,
|
||||
isVoice: Bool
|
||||
) {
|
||||
if currentMessageID != messageID {
|
||||
prepare(
|
||||
messageID: messageID,
|
||||
url: url,
|
||||
durationHint: durationHint,
|
||||
isVoice: isVoice
|
||||
)
|
||||
}
|
||||
guard player != nil else { return }
|
||||
isPlaying ? pause() : play()
|
||||
}
|
||||
|
||||
func seek(
|
||||
messageID: String,
|
||||
url: URL,
|
||||
durationHint: TimeInterval?,
|
||||
isVoice: Bool,
|
||||
fraction: Double
|
||||
) {
|
||||
if currentMessageID != messageID {
|
||||
prepare(
|
||||
messageID: messageID,
|
||||
url: url,
|
||||
durationHint: durationHint,
|
||||
isVoice: isVoice
|
||||
)
|
||||
}
|
||||
guard let player else { return }
|
||||
let targetDuration = max(duration, durationHint ?? 0)
|
||||
guard targetDuration > 0 else { return }
|
||||
let seconds = max(0, min(1, fraction)) * targetDuration
|
||||
currentTime = seconds
|
||||
player.seek(
|
||||
to: CMTime(seconds: seconds, preferredTimescale: 600),
|
||||
toleranceBefore: .zero,
|
||||
toleranceAfter: .zero
|
||||
)
|
||||
}
|
||||
|
||||
func cycleVoiceRate() {
|
||||
if voiceRate < 1.25 {
|
||||
voiceRate = 1.5
|
||||
} else if voiceRate < 1.75 {
|
||||
voiceRate = 2
|
||||
} else {
|
||||
voiceRate = 1
|
||||
}
|
||||
guard currentIsVoice, isPlaying else { return }
|
||||
player?.playImmediately(atRate: Float(voiceRate))
|
||||
}
|
||||
|
||||
func stop() {
|
||||
durationTask?.cancel()
|
||||
durationTask = nil
|
||||
player?.pause()
|
||||
removeObservers()
|
||||
player = nil
|
||||
currentMessageID = nil
|
||||
isPlaying = false
|
||||
currentTime = 0
|
||||
duration = 0
|
||||
currentIsVoice = false
|
||||
}
|
||||
|
||||
private func prepare(
|
||||
messageID: String,
|
||||
url: URL,
|
||||
durationHint: TimeInterval?,
|
||||
isVoice: Bool
|
||||
) {
|
||||
stop()
|
||||
let player = AVPlayer(url: url)
|
||||
player.actionAtItemEnd = .pause
|
||||
self.player = player
|
||||
currentMessageID = messageID
|
||||
currentIsVoice = isVoice
|
||||
currentTime = 0
|
||||
duration = Self.validDuration(durationHint) ?? 0
|
||||
installObservers(for: player)
|
||||
|
||||
if duration == 0 {
|
||||
durationTask = Task { [weak self] in
|
||||
let asset = AVURLAsset(url: url)
|
||||
guard let time = try? await asset.load(.duration),
|
||||
!Task.isCancelled else { return }
|
||||
let seconds = time.seconds
|
||||
guard seconds.isFinite, seconds > 0,
|
||||
self?.currentMessageID == messageID else { return }
|
||||
self?.duration = seconds
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func play() {
|
||||
guard let player, let currentMessageID else { return }
|
||||
configureAudioSession()
|
||||
NotificationCenter.default.post(
|
||||
name: .lumaExclusiveMediaPlayback,
|
||||
object: currentMessageID
|
||||
)
|
||||
if currentIsVoice {
|
||||
player.playImmediately(atRate: Float(voiceRate))
|
||||
} else {
|
||||
player.play()
|
||||
}
|
||||
isPlaying = true
|
||||
}
|
||||
|
||||
private func pause() {
|
||||
player?.pause()
|
||||
isPlaying = false
|
||||
}
|
||||
|
||||
private func installObservers(for player: AVPlayer) {
|
||||
timeObserver = player.addPeriodicTimeObserver(
|
||||
forInterval: CMTime(seconds: 0.1, preferredTimescale: 600),
|
||||
queue: .main
|
||||
) { [weak self, weak player] time in
|
||||
Task { @MainActor in
|
||||
guard let self, let player else { return }
|
||||
let seconds = time.seconds
|
||||
if seconds.isFinite {
|
||||
self.currentTime = max(0, seconds)
|
||||
}
|
||||
if let itemDuration = player.currentItem?.duration.seconds,
|
||||
itemDuration.isFinite,
|
||||
itemDuration > 0 {
|
||||
self.duration = itemDuration
|
||||
}
|
||||
}
|
||||
}
|
||||
endObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime,
|
||||
object: player.currentItem,
|
||||
queue: .main
|
||||
) { [weak self, weak player] _ in
|
||||
Task { @MainActor in
|
||||
player?.seek(to: .zero)
|
||||
self?.currentTime = 0
|
||||
self?.isPlaying = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeObservers() {
|
||||
if let timeObserver, let player {
|
||||
player.removeTimeObserver(timeObserver)
|
||||
}
|
||||
timeObserver = nil
|
||||
if let endObserver {
|
||||
NotificationCenter.default.removeObserver(endObserver)
|
||||
}
|
||||
endObserver = nil
|
||||
}
|
||||
|
||||
private func configureAudioSession() {
|
||||
#if os(iOS)
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
let mode: AVAudioSession.Mode = currentIsVoice ? .spokenAudio : .default
|
||||
try? session.setCategory(.playback, mode: mode, options: [.allowAirPlay, .allowBluetoothA2DP])
|
||||
try? session.setActive(true)
|
||||
#endif
|
||||
}
|
||||
|
||||
private static func validDuration(_ value: TimeInterval?) -> TimeInterval? {
|
||||
guard let value, value.isFinite, value > 0 else { return nil }
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import ImageIO
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
actor MediaPreviewProcessor {
|
||||
struct Analysis: Sendable {
|
||||
let duration: TimeInterval?
|
||||
let thumbnailData: Data?
|
||||
let waveform: [Float]?
|
||||
}
|
||||
|
||||
static let placeholderWaveform: [Float] = [
|
||||
0.24, 0.42, 0.30, 0.58, 0.76, 0.48, 0.34, 0.67,
|
||||
0.88, 0.61, 0.40, 0.73, 0.52, 0.31, 0.64, 0.82,
|
||||
0.45, 0.69, 0.91, 0.56, 0.38, 0.71, 0.49, 0.79,
|
||||
0.57, 0.35, 0.62, 0.86, 0.53, 0.72, 0.43, 0.65,
|
||||
0.84, 0.51, 0.32, 0.59, 0.75, 0.46, 0.68, 0.39,
|
||||
0.80, 0.55, 0.36, 0.70, 0.47, 0.63, 0.78, 0.44
|
||||
]
|
||||
|
||||
func analyze(
|
||||
url: URL,
|
||||
kind: ChatMessage.Kind,
|
||||
mimeType: String? = nil
|
||||
) async -> Analysis {
|
||||
let asset = Self.mediaAsset(url: url, mimeType: mimeType)
|
||||
let duration: TimeInterval?
|
||||
if [.video, .audio, .voice, .videoNote].contains(kind) {
|
||||
duration = await mediaDuration(for: asset)
|
||||
} else {
|
||||
duration = nil
|
||||
}
|
||||
let thumbnailData: Data?
|
||||
if kind == .photo {
|
||||
thumbnailData = imageThumbnailData(for: url)
|
||||
} else if kind == .video || kind == .videoNote {
|
||||
thumbnailData = await videoThumbnailData(
|
||||
for: asset,
|
||||
duration: duration
|
||||
)
|
||||
} else {
|
||||
thumbnailData = nil
|
||||
}
|
||||
|
||||
let waveform = kind == .voice ? voiceWaveform(for: url) : nil
|
||||
return Analysis(
|
||||
duration: duration,
|
||||
thumbnailData: thumbnailData,
|
||||
waveform: waveform
|
||||
)
|
||||
}
|
||||
|
||||
private func mediaDuration(for asset: AVURLAsset) async -> TimeInterval? {
|
||||
guard let time = try? await asset.load(.duration) else { return nil }
|
||||
let seconds = time.seconds
|
||||
return seconds.isFinite && seconds > 0 ? seconds : nil
|
||||
}
|
||||
|
||||
private func videoThumbnailData(
|
||||
for asset: AVURLAsset,
|
||||
duration: TimeInterval?
|
||||
) async -> Data? {
|
||||
let generator = AVAssetImageGenerator(asset: asset)
|
||||
generator.appliesPreferredTrackTransform = true
|
||||
generator.maximumSize = CGSize(width: 960, height: 960)
|
||||
generator.requestedTimeToleranceBefore = CMTime(
|
||||
seconds: 0.35,
|
||||
preferredTimescale: 600
|
||||
)
|
||||
generator.requestedTimeToleranceAfter = CMTime(
|
||||
seconds: 0.35,
|
||||
preferredTimescale: 600
|
||||
)
|
||||
|
||||
let safeDuration = duration.flatMap { value in
|
||||
value.isFinite && value > 0 ? value : nil
|
||||
}
|
||||
let preferredSecond = safeDuration.map {
|
||||
min(1, max(0.01, $0 * 0.25))
|
||||
} ?? 0.5
|
||||
let earlySecond = safeDuration.map {
|
||||
min(0.05, max(0, $0 * 0.1))
|
||||
} ?? 0.05
|
||||
var attemptedMilliseconds: Set<Int> = []
|
||||
for second in [preferredSecond, earlySecond, 0] {
|
||||
let key = Int((second * 1_000).rounded())
|
||||
guard attemptedMilliseconds.insert(key).inserted else { continue }
|
||||
do {
|
||||
let result = try await generator.image(
|
||||
at: CMTime(seconds: second, preferredTimescale: 600)
|
||||
)
|
||||
if let data = jpegData(from: result.image) {
|
||||
return data
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func imageThumbnailData(for url: URL) -> Data? {
|
||||
guard let source = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
|
||||
let options: [CFString: Any] = [
|
||||
kCGImageSourceCreateThumbnailFromImageAlways: true,
|
||||
kCGImageSourceCreateThumbnailWithTransform: true,
|
||||
kCGImageSourceThumbnailMaxPixelSize: 1_200,
|
||||
kCGImageSourceShouldCacheImmediately: true
|
||||
]
|
||||
guard let image = CGImageSourceCreateThumbnailAtIndex(
|
||||
source,
|
||||
0,
|
||||
options as CFDictionary
|
||||
) else { return nil }
|
||||
return jpegData(from: image)
|
||||
}
|
||||
|
||||
private func jpegData(from image: CGImage) -> Data? {
|
||||
let output = NSMutableData()
|
||||
guard let destination = CGImageDestinationCreateWithData(
|
||||
output as CFMutableData,
|
||||
UTType.jpeg.identifier as CFString,
|
||||
1,
|
||||
nil
|
||||
) else { return nil }
|
||||
let options = [kCGImageDestinationLossyCompressionQuality: 0.82] as CFDictionary
|
||||
CGImageDestinationAddImage(destination, image, options)
|
||||
guard CGImageDestinationFinalize(destination) else { return nil }
|
||||
return output as Data
|
||||
}
|
||||
|
||||
private static func mediaAsset(url: URL, mimeType: String?) -> AVURLAsset {
|
||||
guard let mimeType, !mimeType.isEmpty else {
|
||||
return AVURLAsset(url: url)
|
||||
}
|
||||
// Supplying the MIME type is important for picker files whose temporary
|
||||
// extension does not describe the real QuickTime/HEVC container.
|
||||
return AVURLAsset(
|
||||
url: url,
|
||||
options: ["AVURLAssetOutOfBandMIMETypeKey": mimeType]
|
||||
)
|
||||
}
|
||||
|
||||
private func voiceWaveform(for url: URL, sampleCount: Int = 48) -> [Float]? {
|
||||
do {
|
||||
let file = try AVAudioFile(
|
||||
forReading: url,
|
||||
commonFormat: .pcmFormatFloat32,
|
||||
interleaved: false
|
||||
)
|
||||
let totalFrames = max(1, file.length)
|
||||
let capacity: AVAudioFrameCount = 4_096
|
||||
guard let buffer = AVAudioPCMBuffer(
|
||||
pcmFormat: file.processingFormat,
|
||||
frameCapacity: capacity
|
||||
) else { return nil }
|
||||
|
||||
var peaks = Array(repeating: Float.zero, count: sampleCount)
|
||||
var processedFrames: AVAudioFramePosition = 0
|
||||
|
||||
while processedFrames < totalFrames {
|
||||
let remaining = totalFrames - processedFrames
|
||||
let requested = AVAudioFrameCount(min(AVAudioFramePosition(capacity), remaining))
|
||||
try file.read(into: buffer, frameCount: requested)
|
||||
let frameLength = Int(buffer.frameLength)
|
||||
guard frameLength > 0, let channels = buffer.floatChannelData else { break }
|
||||
let channelCount = Int(buffer.format.channelCount)
|
||||
|
||||
for frame in 0..<frameLength {
|
||||
let absoluteFrame = processedFrames + AVAudioFramePosition(frame)
|
||||
let bin = min(
|
||||
sampleCount - 1,
|
||||
Int(absoluteFrame * AVAudioFramePosition(sampleCount) / totalFrames)
|
||||
)
|
||||
var peak = Float.zero
|
||||
for channel in 0..<channelCount {
|
||||
peak = max(peak, abs(channels[channel][frame]))
|
||||
}
|
||||
peaks[bin] = max(peaks[bin], peak)
|
||||
}
|
||||
processedFrames += AVAudioFramePosition(frameLength)
|
||||
}
|
||||
|
||||
guard let maximum = peaks.max(), maximum > 0 else {
|
||||
return Self.placeholderWaveform
|
||||
}
|
||||
return peaks.map { peak in
|
||||
max(0.08, min(1, sqrt(peak / maximum)))
|
||||
}
|
||||
} catch {
|
||||
return Self.placeholderWaveform
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import Foundation
|
||||
import UserNotifications
|
||||
|
||||
final class NotificationCoordinator: NSObject, UNUserNotificationCenterDelegate {
|
||||
override init() {
|
||||
super.init()
|
||||
// UNUserNotificationCenter keeps a weak delegate. AppModel retains this
|
||||
// coordinator for the lifetime of the application.
|
||||
UNUserNotificationCenter.current().delegate = self
|
||||
}
|
||||
|
||||
func requestAuthorization() async {
|
||||
_ = try? await UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound])
|
||||
}
|
||||
|
||||
func showIncomingMessage(id: String, sender: String, body: String, conversationID: String) {
|
||||
let content = UNMutableNotificationContent()
|
||||
content.title = sender
|
||||
content.body = body
|
||||
content.sound = .default
|
||||
content.threadIdentifier = conversationID
|
||||
content.userInfo = ["conversation": conversationID]
|
||||
|
||||
let request = UNNotificationRequest(identifier: id, content: content, trigger: nil)
|
||||
UNUserNotificationCenter.current().add(request)
|
||||
}
|
||||
|
||||
func userNotificationCenter(
|
||||
_ center: UNUserNotificationCenter,
|
||||
willPresent notification: UNNotification
|
||||
) async -> UNNotificationPresentationOptions {
|
||||
// System notifications are normally hidden while an app is in the
|
||||
// foreground. Explicitly request the same banner/list/sound treatment
|
||||
// used when Luma is backgrounded.
|
||||
[.banner, .list, .sound]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
import Foundation
|
||||
|
||||
struct PhoneWatchSnapshot: Codable {
|
||||
struct Chat: Codable {
|
||||
let jid: String
|
||||
let name: String
|
||||
let unread: Int
|
||||
let messages: [Message]
|
||||
}
|
||||
|
||||
struct Message: Codable, Identifiable {
|
||||
let id: String
|
||||
let body: String
|
||||
let timestamp: Date
|
||||
let outgoing: Bool
|
||||
let encrypted: Bool
|
||||
}
|
||||
|
||||
let chats: [Chat]
|
||||
}
|
||||
|
||||
struct WatchVoiceMessage: Sendable {
|
||||
let transferID: String
|
||||
let jid: String
|
||||
let filename: String
|
||||
let duration: TimeInterval?
|
||||
let data: Data
|
||||
|
||||
var stableMessageID: String {
|
||||
"watch-\(transferID)"
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS) && canImport(WatchConnectivity)
|
||||
import WatchConnectivity
|
||||
|
||||
final class PhoneWatchBridge: NSObject, WCSessionDelegate {
|
||||
var onReply: ((String, String) -> Void)?
|
||||
var onVoiceMessage: ((WatchVoiceMessage) -> Void)? {
|
||||
didSet { deliverPendingVoiceMessages() }
|
||||
}
|
||||
private let session: WCSession?
|
||||
private let snapshotQueue = DispatchQueue(
|
||||
label: "app.luma.watch-snapshot",
|
||||
qos: .utility
|
||||
)
|
||||
private var latestSnapshot: Data?
|
||||
private var pendingVoiceMessages: [WatchVoiceMessage] = []
|
||||
private var deliveredVoiceTransferIDs: Set<String> = []
|
||||
|
||||
override init() {
|
||||
session = WCSession.isSupported() ? .default : nil
|
||||
super.init()
|
||||
pendingVoiceMessages = Self.loadPersistedVoiceMessages()
|
||||
session?.delegate = self
|
||||
session?.activate()
|
||||
}
|
||||
|
||||
func update(conversations: [Conversation], messages: [ChatMessage]) {
|
||||
guard let session else { return }
|
||||
// Copy-on-write snapshots are cheap here; the filtering, sorting and
|
||||
// JSON encoding are not. Keep that work away from SwiftUI's main run
|
||||
// loop, especially immediately after a MAM page is committed.
|
||||
snapshotQueue.async { [weak self, weak session] in
|
||||
guard let self,
|
||||
let session,
|
||||
let data = Self.makeSnapshotData(
|
||||
conversations: conversations,
|
||||
messages: messages
|
||||
),
|
||||
data != self.latestSnapshot else { return }
|
||||
self.latestSnapshot = data
|
||||
self.publishLatestSnapshot(using: session)
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeSnapshotData(
|
||||
conversations: [Conversation],
|
||||
messages: [ChatMessage]
|
||||
) -> Data? {
|
||||
let visibleConversations = Array(conversations.prefix(20))
|
||||
let visibleConversationIDs = Set(visibleConversations.map(\.id))
|
||||
var recentMessagesByConversation: [String: [ChatMessage]] = [:]
|
||||
|
||||
// Keep a bounded, chronologically sorted window. MAM can append an old
|
||||
// overlapping stanza after a newer live message, so array order alone
|
||||
// cannot identify the latest messages reliably.
|
||||
for message in messages where visibleConversationIDs.contains(message.conversationID) {
|
||||
var recent = recentMessagesByConversation[message.conversationID] ?? []
|
||||
let insertionIndex = Self.insertionIndex(for: message, in: recent)
|
||||
recent.insert(message, at: insertionIndex)
|
||||
if recent.count > 20 {
|
||||
recent.removeFirst(recent.count - 20)
|
||||
}
|
||||
recentMessagesByConversation[message.conversationID] = recent
|
||||
}
|
||||
|
||||
let chats = visibleConversations.map { conversation in
|
||||
PhoneWatchSnapshot.Chat(
|
||||
jid: conversation.jid,
|
||||
name: conversation.displayName,
|
||||
unread: conversation.unreadCount,
|
||||
messages: (recentMessagesByConversation[conversation.id] ?? [])
|
||||
.map {
|
||||
PhoneWatchSnapshot.Message(
|
||||
id: $0.id,
|
||||
body: $0.previewText,
|
||||
timestamp: $0.timestamp,
|
||||
outgoing: $0.direction == .outgoing,
|
||||
encrypted: $0.security == .omemo
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
return try? JSONEncoder.watchEncoder.encode(PhoneWatchSnapshot(chats: chats))
|
||||
}
|
||||
|
||||
private static func insertionIndex(
|
||||
for message: ChatMessage,
|
||||
in sortedMessages: [ChatMessage]
|
||||
) -> Int {
|
||||
var lowerBound = 0
|
||||
var upperBound = sortedMessages.count
|
||||
while lowerBound < upperBound {
|
||||
let middle = lowerBound + (upperBound - lowerBound) / 2
|
||||
let existing = sortedMessages[middle]
|
||||
let existingComesFirst = existing.timestamp < message.timestamp
|
||||
|| (existing.timestamp == message.timestamp && existing.id < message.id)
|
||||
if existingComesFirst {
|
||||
lowerBound = middle + 1
|
||||
} else {
|
||||
upperBound = middle
|
||||
}
|
||||
}
|
||||
return lowerBound
|
||||
}
|
||||
|
||||
func session(
|
||||
_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
error: Error?
|
||||
) {
|
||||
guard activationState == .activated else { return }
|
||||
snapshotQueue.async { [weak self, weak session] in
|
||||
guard let self, let session else { return }
|
||||
self.publishLatestSnapshot(using: session)
|
||||
}
|
||||
}
|
||||
|
||||
func sessionDidBecomeInactive(_ session: WCSession) {}
|
||||
|
||||
func sessionDidDeactivate(_ session: WCSession) {
|
||||
session.activate()
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
consume(message)
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
consume(userInfo)
|
||||
}
|
||||
|
||||
func session(_ session: WCSession, didReceive file: WCSessionFile) {
|
||||
guard let metadata = file.metadata,
|
||||
metadata["action"] as? String == "voice",
|
||||
let transferID = metadata["transferID"] as? String,
|
||||
UUID(uuidString: transferID) != nil,
|
||||
let jid = metadata["jid"] as? String,
|
||||
!jid.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
|
||||
do {
|
||||
// WCSession owns this URL only for the duration of the callback.
|
||||
// Copy the bytes before handing the recording to the async upload
|
||||
// pipeline so the temporary file cannot disappear underneath it.
|
||||
let data = try Data(contentsOf: file.fileURL)
|
||||
guard !data.isEmpty else { throw WatchVoiceBridgeError.emptyRecording }
|
||||
let duration = (metadata["duration"] as? NSNumber)?.doubleValue
|
||||
let filename = (metadata["filename"] as? String).flatMap { $0.isEmpty ? nil : $0 }
|
||||
?? file.fileURL.lastPathComponent
|
||||
let message = WatchVoiceMessage(
|
||||
transferID: transferID,
|
||||
jid: jid,
|
||||
filename: filename,
|
||||
duration: duration,
|
||||
data: data
|
||||
)
|
||||
try Self.persistVoiceMessage(message)
|
||||
DispatchQueue.main.async { [weak self] in
|
||||
self?.deliverVoiceMessage(message)
|
||||
}
|
||||
} catch {
|
||||
reportVoiceResult(
|
||||
transferID: transferID,
|
||||
success: false,
|
||||
error: "iPhone не смог прочитать запись: \(error.localizedDescription)",
|
||||
jid: jid
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func reportVoiceResult(
|
||||
transferID: String,
|
||||
success: Bool,
|
||||
error: String? = nil,
|
||||
jid explicitJID: String? = nil
|
||||
) {
|
||||
guard let session else { return }
|
||||
let jid = explicitJID ?? Self.persistedVoiceJID(for: transferID)
|
||||
Self.removePersistedVoiceMessage(transferID: transferID)
|
||||
deliveredVoiceTransferIDs.remove(transferID)
|
||||
var payload: [String: Any] = [
|
||||
"action": "voiceResult",
|
||||
"transferID": transferID,
|
||||
"success": success
|
||||
]
|
||||
if let jid, !jid.isEmpty {
|
||||
payload["jid"] = jid
|
||||
}
|
||||
if let error, !error.isEmpty {
|
||||
payload["error"] = error
|
||||
}
|
||||
|
||||
if session.isReachable {
|
||||
session.sendMessage(payload, replyHandler: nil) { _ in
|
||||
session.transferUserInfo(payload)
|
||||
}
|
||||
} else {
|
||||
session.transferUserInfo(payload)
|
||||
}
|
||||
}
|
||||
|
||||
private func consume(_ payload: [String: Any]) {
|
||||
guard payload["action"] as? String == "reply",
|
||||
let jid = payload["jid"] as? String,
|
||||
let text = payload["text"] as? String,
|
||||
!text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
onReply?(jid, text)
|
||||
}
|
||||
|
||||
private func publishLatestSnapshot(using session: WCSession) {
|
||||
guard let latestSnapshot else { return }
|
||||
try? session.updateApplicationContext(["snapshot": latestSnapshot])
|
||||
}
|
||||
|
||||
private func deliverVoiceMessage(_ message: WatchVoiceMessage) {
|
||||
if let onVoiceMessage {
|
||||
guard deliveredVoiceTransferIDs.insert(message.transferID).inserted else { return }
|
||||
onVoiceMessage(message)
|
||||
} else if !pendingVoiceMessages.contains(where: { $0.transferID == message.transferID }) {
|
||||
pendingVoiceMessages.append(message)
|
||||
}
|
||||
}
|
||||
|
||||
private func deliverPendingVoiceMessages() {
|
||||
guard let onVoiceMessage, !pendingVoiceMessages.isEmpty else { return }
|
||||
let pending = pendingVoiceMessages
|
||||
pendingVoiceMessages.removeAll()
|
||||
for message in pending where deliveredVoiceTransferIDs.insert(message.transferID).inserted {
|
||||
onVoiceMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
private static func persistVoiceMessage(_ message: WatchVoiceMessage) throws {
|
||||
let directory = try watchVoiceOutboxDirectory()
|
||||
let audioFilename = "\(message.transferID).m4a"
|
||||
let audioURL = directory.appendingPathComponent(audioFilename)
|
||||
let metadataURL = directory.appendingPathComponent("\(message.transferID).json")
|
||||
let stored = StoredWatchVoiceMessage(
|
||||
transferID: message.transferID,
|
||||
jid: message.jid,
|
||||
filename: message.filename,
|
||||
duration: message.duration,
|
||||
audioFilename: audioFilename,
|
||||
createdAt: Date()
|
||||
)
|
||||
|
||||
do {
|
||||
try message.data.write(to: audioURL, options: [.atomic])
|
||||
try JSONEncoder().encode(stored).write(to: metadataURL, options: [.atomic])
|
||||
try? FileManager.default.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: audioURL.path
|
||||
)
|
||||
try? FileManager.default.setAttributes(
|
||||
[.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication],
|
||||
ofItemAtPath: metadataURL.path
|
||||
)
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: audioURL)
|
||||
try? FileManager.default.removeItem(at: metadataURL)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private static func loadPersistedVoiceMessages() -> [WatchVoiceMessage] {
|
||||
guard let directory = try? watchVoiceOutboxDirectory() else { return [] }
|
||||
let metadataURLs = (try? FileManager.default.contentsOfDirectory(
|
||||
at: directory,
|
||||
includingPropertiesForKeys: nil
|
||||
)) ?? []
|
||||
let expiration = Date().addingTimeInterval(-7 * 24 * 60 * 60)
|
||||
|
||||
return metadataURLs
|
||||
.filter { $0.pathExtension.lowercased() == "json" }
|
||||
.compactMap { metadataURL in
|
||||
guard let metadataData = try? Data(contentsOf: metadataURL),
|
||||
let stored = try? JSONDecoder().decode(
|
||||
StoredWatchVoiceMessage.self,
|
||||
from: metadataData
|
||||
),
|
||||
stored.createdAt >= expiration else {
|
||||
removePersistedVoiceMessage(
|
||||
transferID: metadataURL.deletingPathExtension().lastPathComponent
|
||||
)
|
||||
return nil
|
||||
}
|
||||
let audioURL = directory.appendingPathComponent(stored.audioFilename)
|
||||
guard let data = try? Data(contentsOf: audioURL), !data.isEmpty else {
|
||||
removePersistedVoiceMessage(transferID: stored.transferID)
|
||||
return nil
|
||||
}
|
||||
return WatchVoiceMessage(
|
||||
transferID: stored.transferID,
|
||||
jid: stored.jid,
|
||||
filename: stored.filename,
|
||||
duration: stored.duration,
|
||||
data: data
|
||||
)
|
||||
}
|
||||
.sorted { $0.transferID < $1.transferID }
|
||||
}
|
||||
|
||||
private static func persistedVoiceJID(for transferID: String) -> String? {
|
||||
guard let directory = try? watchVoiceOutboxDirectory(),
|
||||
let data = try? Data(contentsOf: directory.appendingPathComponent("\(transferID).json")),
|
||||
let stored = try? JSONDecoder().decode(StoredWatchVoiceMessage.self, from: data) else {
|
||||
return nil
|
||||
}
|
||||
return stored.jid
|
||||
}
|
||||
|
||||
private static func removePersistedVoiceMessage(transferID: String) {
|
||||
guard UUID(uuidString: transferID) != nil,
|
||||
let directory = try? watchVoiceOutboxDirectory() else { return }
|
||||
let metadataURL = directory.appendingPathComponent("\(transferID).json")
|
||||
if let data = try? Data(contentsOf: metadataURL),
|
||||
let stored = try? JSONDecoder().decode(StoredWatchVoiceMessage.self, from: data) {
|
||||
try? FileManager.default.removeItem(
|
||||
at: directory.appendingPathComponent(stored.audioFilename)
|
||||
)
|
||||
} else {
|
||||
try? FileManager.default.removeItem(
|
||||
at: directory.appendingPathComponent("\(transferID).m4a")
|
||||
)
|
||||
}
|
||||
try? FileManager.default.removeItem(at: metadataURL)
|
||||
}
|
||||
|
||||
private static func watchVoiceOutboxDirectory() throws -> URL {
|
||||
let base = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first ?? FileManager.default.temporaryDirectory
|
||||
let directory = base.appendingPathComponent("Luma/WatchVoiceOutbox", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
return directory
|
||||
}
|
||||
}
|
||||
|
||||
private struct StoredWatchVoiceMessage: Codable {
|
||||
let transferID: String
|
||||
let jid: String
|
||||
let filename: String
|
||||
let duration: TimeInterval?
|
||||
let audioFilename: String
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
#else
|
||||
|
||||
final class PhoneWatchBridge {
|
||||
var onReply: ((String, String) -> Void)?
|
||||
var onVoiceMessage: ((WatchVoiceMessage) -> Void)?
|
||||
func update(conversations: [Conversation], messages: [ChatMessage]) {}
|
||||
func reportVoiceResult(transferID: String, success: Bool, error: String? = nil) {}
|
||||
}
|
||||
|
||||
#endif
|
||||
|
||||
private extension JSONEncoder {
|
||||
static var watchEncoder: JSONEncoder {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.dateEncodingStrategy = .millisecondsSince1970
|
||||
return encoder
|
||||
}
|
||||
}
|
||||
|
||||
private enum WatchVoiceBridgeError: LocalizedError {
|
||||
case emptyRecording
|
||||
|
||||
var errorDescription: String? {
|
||||
"Файл голосового сообщения пуст."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,930 @@
|
||||
@preconcurrency import AVFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
#if os(iOS)
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@MainActor
|
||||
final class VideoNoteRecorder: NSObject, ObservableObject, AVCaptureFileOutputRecordingDelegate {
|
||||
struct Recording {
|
||||
let url: URL
|
||||
let duration: TimeInterval
|
||||
}
|
||||
|
||||
@Published private(set) var isPrepared = false
|
||||
@Published private(set) var isRecording = false
|
||||
@Published private(set) var elapsed: TimeInterval = 0
|
||||
@Published private(set) var isUsingFrontCamera = true
|
||||
@Published private(set) var isMicrophoneMuted = false
|
||||
|
||||
let session = AVCaptureSession()
|
||||
var onFinished: ((Result<Recording, Error>) -> Void)?
|
||||
|
||||
var hasRequiredAuthorization: Bool {
|
||||
AVCaptureDevice.authorizationStatus(for: .video) == .authorized
|
||||
&& AVCaptureDevice.authorizationStatus(for: .audio) == .authorized
|
||||
}
|
||||
|
||||
private let movieOutput = AVCaptureMovieFileOutput()
|
||||
private let sessionQueue = DispatchQueue(
|
||||
label: "app.luma.video-note.capture-session",
|
||||
qos: .userInitiated
|
||||
)
|
||||
private let fileInspector = VideoNoteFileInspector()
|
||||
private var cameraInput: AVCaptureDeviceInput?
|
||||
private var microphoneInput: AVCaptureDeviceInput?
|
||||
private var timer: Timer?
|
||||
private var startedAt: Date?
|
||||
private var discardCurrentRecording = false
|
||||
private var stopRequested = false
|
||||
private var requestedMinimumDuration: TimeInterval = 0
|
||||
private var sessionGeneration = UUID()
|
||||
private var lifecycle: VideoNoteRecordingLifecycle = .idle
|
||||
private var activeRecordingURL: URL?
|
||||
private var stopRequestTask: Task<Void, Never>?
|
||||
private var startupTimeoutTask: Task<Void, Never>?
|
||||
private var finalizationTimeoutTask: Task<Void, Never>?
|
||||
private var fileValidationTask: Task<Void, Never>?
|
||||
|
||||
private struct CaptureGraph {
|
||||
let cameraInput: AVCaptureDeviceInput
|
||||
let microphoneInput: AVCaptureDeviceInput
|
||||
let usesFrontCamera: Bool
|
||||
}
|
||||
|
||||
private struct MovieOutputSnapshot: Sendable {
|
||||
let isRecording: Bool
|
||||
let recordedDuration: TimeInterval
|
||||
}
|
||||
|
||||
func prepare() async throws {
|
||||
try await waitForPreviousRecordingToFinish()
|
||||
if lifecycle == .prepared, isPrepared { return }
|
||||
guard lifecycle == .idle, !isPrepared else {
|
||||
throw VideoNoteRecorderError.recordingBusy
|
||||
}
|
||||
|
||||
let generation = UUID()
|
||||
sessionGeneration = generation
|
||||
await waitForPendingSessionOperations()
|
||||
guard !Task.isCancelled, sessionGeneration == generation else {
|
||||
throw CancellationError()
|
||||
}
|
||||
guard await Self.requestAccess(for: .video) else {
|
||||
throw VideoNoteRecorderError.cameraPermissionDenied
|
||||
}
|
||||
guard !Task.isCancelled, sessionGeneration == generation else {
|
||||
throw CancellationError()
|
||||
}
|
||||
guard await Self.requestAccess(for: .audio) else {
|
||||
throw VideoNoteRecorderError.microphonePermissionDenied
|
||||
}
|
||||
guard !Task.isCancelled, sessionGeneration == generation else {
|
||||
throw CancellationError()
|
||||
}
|
||||
|
||||
var didPrepare = false
|
||||
defer {
|
||||
if !didPrepare {
|
||||
performShutdown()
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
try AVAudioSession.sharedInstance().setCategory(
|
||||
.playAndRecord,
|
||||
mode: .videoRecording,
|
||||
options: [.defaultToSpeaker, .allowBluetoothHFP]
|
||||
)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
#endif
|
||||
|
||||
let graph = try await configureCaptureGraph()
|
||||
guard !Task.isCancelled, sessionGeneration == generation else {
|
||||
throw CancellationError()
|
||||
}
|
||||
cameraInput = graph.cameraInput
|
||||
microphoneInput = graph.microphoneInput
|
||||
isUsingFrontCamera = graph.usesFrontCamera
|
||||
|
||||
configureVideoConnection()
|
||||
try await startCaptureSession(generation: generation)
|
||||
isPrepared = true
|
||||
lifecycle = .prepared
|
||||
didPrepare = true
|
||||
}
|
||||
|
||||
func start() throws {
|
||||
guard lifecycle == .prepared,
|
||||
isPrepared,
|
||||
session.isRunning else { throw VideoNoteRecorderError.notPrepared }
|
||||
guard !movieOutput.isRecording,
|
||||
activeRecordingURL == nil else {
|
||||
throw VideoNoteRecorderError.recordingBusy
|
||||
}
|
||||
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("LumaRecordings", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
let url = directory.appendingPathComponent("video-note-\(UUID().uuidString).mov")
|
||||
|
||||
discardCurrentRecording = false
|
||||
stopRequested = false
|
||||
requestedMinimumDuration = 0
|
||||
activeRecordingURL = url
|
||||
lifecycle = .starting
|
||||
startedAt = nil
|
||||
elapsed = 0
|
||||
isRecording = true
|
||||
|
||||
let output = movieOutput
|
||||
let delegate = self
|
||||
sessionQueue.async {
|
||||
output.startRecording(to: url, recordingDelegate: delegate)
|
||||
}
|
||||
scheduleStartupTimeout(for: url)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
requestStop(afterMinimumDuration: VideoNoteStopPolicy.minimumCaptureDuration)
|
||||
}
|
||||
|
||||
func stop(afterMinimumDuration minimumDuration: TimeInterval) async {
|
||||
requestStop(afterMinimumDuration: minimumDuration)
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
discardCurrentRecording = true
|
||||
stopRequested = true
|
||||
requestedMinimumDuration = 0
|
||||
|
||||
switch lifecycle {
|
||||
case .starting, .recording:
|
||||
lifecycle = .stopping
|
||||
stopRequestTask?.cancel()
|
||||
stopRequestTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
await self.stopMovieOutput()
|
||||
guard !Task.isCancelled,
|
||||
self.lifecycle == .stopping,
|
||||
let url = self.activeRecordingURL else { return }
|
||||
self.scheduleFinalizationTimeout(for: url)
|
||||
}
|
||||
case .stopping:
|
||||
break
|
||||
case .idle, .prepared:
|
||||
performShutdown()
|
||||
}
|
||||
}
|
||||
|
||||
func switchCamera() async throws {
|
||||
#if os(iOS)
|
||||
guard isPrepared,
|
||||
lifecycle == .prepared,
|
||||
let currentInput = cameraInput else {
|
||||
throw VideoNoteRecorderError.notPrepared
|
||||
}
|
||||
let nextPosition: AVCaptureDevice.Position = isUsingFrontCamera ? .back : .front
|
||||
guard let camera = AVCaptureDevice.default(
|
||||
.builtInWideAngleCamera,
|
||||
for: .video,
|
||||
position: nextPosition
|
||||
) else {
|
||||
throw VideoNoteRecorderError.alternateCameraUnavailable
|
||||
}
|
||||
|
||||
let nextInput = try AVCaptureDeviceInput(device: camera)
|
||||
let session = session
|
||||
try await withCheckedThrowingContinuation {
|
||||
(continuation: CheckedContinuation<Void, Error>) in
|
||||
sessionQueue.async {
|
||||
session.beginConfiguration()
|
||||
session.removeInput(currentInput)
|
||||
guard session.canAddInput(nextInput) else {
|
||||
if session.canAddInput(currentInput) {
|
||||
session.addInput(currentInput)
|
||||
}
|
||||
session.commitConfiguration()
|
||||
continuation.resume(throwing: VideoNoteRecorderError.configurationFailed)
|
||||
return
|
||||
}
|
||||
session.addInput(nextInput)
|
||||
session.commitConfiguration()
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
cameraInput = nextInput
|
||||
isUsingFrontCamera = nextPosition == .front
|
||||
configureVideoConnection()
|
||||
#else
|
||||
throw VideoNoteRecorderError.alternateCameraUnavailable
|
||||
#endif
|
||||
}
|
||||
|
||||
func toggleMicrophoneMuted() {
|
||||
guard isPrepared, let connection = movieOutput.connection(with: .audio) else { return }
|
||||
isMicrophoneMuted.toggle()
|
||||
connection.isEnabled = !isMicrophoneMuted
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
if lifecycle.isBusy || movieOutput.isRecording {
|
||||
cancel()
|
||||
return
|
||||
}
|
||||
performShutdown()
|
||||
}
|
||||
|
||||
nonisolated func fileOutput(
|
||||
_ output: AVCaptureFileOutput,
|
||||
didStartRecordingTo fileURL: URL,
|
||||
from connections: [AVCaptureConnection]
|
||||
) {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.recordingDidStart(url: fileURL)
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func fileOutput(
|
||||
_ output: AVCaptureFileOutput,
|
||||
didFinishRecordingTo outputFileURL: URL,
|
||||
from connections: [AVCaptureConnection],
|
||||
error: Error?
|
||||
) {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.recordingDidFinish(url: outputFileURL, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestStop(afterMinimumDuration minimumDuration: TimeInterval) {
|
||||
guard isRecording,
|
||||
lifecycle == .starting || lifecycle == .recording else { return }
|
||||
stopRequested = true
|
||||
requestedMinimumDuration = max(
|
||||
requestedMinimumDuration,
|
||||
max(0, minimumDuration)
|
||||
)
|
||||
scheduleStopAfterActualStartIfNeeded()
|
||||
}
|
||||
|
||||
private func recordingDidStart(url: URL) {
|
||||
guard VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: activeRecordingURL,
|
||||
outputURL: url
|
||||
), isRecording,
|
||||
lifecycle == .starting || lifecycle == .stopping else { return }
|
||||
|
||||
startupTimeoutTask?.cancel()
|
||||
startupTimeoutTask = nil
|
||||
if startedAt == nil {
|
||||
startedAt = Date()
|
||||
}
|
||||
|
||||
if lifecycle == .stopping {
|
||||
// Cancellation can be requested while startRecording is still
|
||||
// crossing the capture queue. If the first stop observed
|
||||
// isRecording == false, stop again after the real didStart rather
|
||||
// than leaving an orphaned capture running until the watchdog.
|
||||
stopRequestTask?.cancel()
|
||||
stopRequestTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
await self.stopMovieOutput()
|
||||
guard !Task.isCancelled,
|
||||
self.lifecycle == .stopping,
|
||||
VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: self.activeRecordingURL,
|
||||
outputURL: url
|
||||
) else { return }
|
||||
self.scheduleFinalizationTimeout(for: url)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
lifecycle = .recording
|
||||
timer?.invalidate()
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self, let startedAt = self.startedAt else { return }
|
||||
self.elapsed = min(
|
||||
VideoNoteStopPolicy.maximumCaptureDuration,
|
||||
Date().timeIntervalSince(startedAt)
|
||||
)
|
||||
}
|
||||
}
|
||||
scheduleStopAfterActualStartIfNeeded()
|
||||
}
|
||||
|
||||
private func scheduleStopAfterActualStartIfNeeded() {
|
||||
guard stopRequested,
|
||||
lifecycle == .recording,
|
||||
startedAt != nil,
|
||||
let url = activeRecordingURL else { return }
|
||||
|
||||
stopRequestTask?.cancel()
|
||||
let minimumDuration = requestedMinimumDuration
|
||||
let mediaDeadline = Date().addingTimeInterval(
|
||||
VideoNoteStopPolicy.recordedDurationWaitTimeout
|
||||
)
|
||||
stopRequestTask = Task { @MainActor [weak self] in
|
||||
while true {
|
||||
guard !Task.isCancelled,
|
||||
let self,
|
||||
self.lifecycle == .recording,
|
||||
VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: self.activeRecordingURL,
|
||||
outputURL: url
|
||||
) else { return }
|
||||
|
||||
let snapshot = await self.movieOutputSnapshot()
|
||||
guard !Task.isCancelled,
|
||||
self.lifecycle == .recording,
|
||||
VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: self.activeRecordingURL,
|
||||
outputURL: url
|
||||
) else { return }
|
||||
|
||||
if !snapshot.isRecording {
|
||||
// AVFoundation stopped without the user's stop reaching
|
||||
// the output. Wait for didFinish, but never let this state
|
||||
// remain without a finalization watchdog.
|
||||
self.lifecycle = .stopping
|
||||
self.scheduleFinalizationTimeout(for: url)
|
||||
return
|
||||
}
|
||||
|
||||
let remaining = VideoNoteStopPolicy.remainingRecordedDuration(
|
||||
recordedDuration: snapshot.recordedDuration,
|
||||
minimumDuration: minimumDuration
|
||||
)
|
||||
if remaining <= 0 || Date() >= mediaDeadline { break }
|
||||
|
||||
do {
|
||||
try await Task.sleep(
|
||||
nanoseconds: min(
|
||||
VideoNoteStopPolicy.recordedDurationPollNanoseconds,
|
||||
UInt64((remaining * 1_000_000_000).rounded(.up))
|
||||
)
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
guard !Task.isCancelled,
|
||||
let self,
|
||||
self.lifecycle == .recording,
|
||||
VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: self.activeRecordingURL,
|
||||
outputURL: url
|
||||
) else { return }
|
||||
|
||||
self.lifecycle = .stopping
|
||||
await self.stopMovieOutput()
|
||||
guard !Task.isCancelled,
|
||||
self.lifecycle == .stopping,
|
||||
VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: self.activeRecordingURL,
|
||||
outputURL: url
|
||||
) else { return }
|
||||
self.scheduleFinalizationTimeout(for: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func recordingDidFinish(url: URL, error: Error?) {
|
||||
guard VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: activeRecordingURL,
|
||||
outputURL: url
|
||||
) else {
|
||||
// A delayed callback from an earlier attempt must not tear down a
|
||||
// newer capture graph. Do not delete it here: macOS can spell the
|
||||
// same temporary URL through a filesystem alias, and preserving a
|
||||
// doubtful callback is safer than deleting the active movie.
|
||||
return
|
||||
}
|
||||
|
||||
stopRequestTask?.cancel()
|
||||
stopRequestTask = nil
|
||||
startupTimeoutTask?.cancel()
|
||||
startupTimeoutTask = nil
|
||||
finalizationTimeoutTask?.cancel()
|
||||
finalizationTimeoutTask = nil
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
isRecording = false
|
||||
lifecycle = .stopping
|
||||
|
||||
let fallbackDuration = measuredWallClockDuration
|
||||
let shouldDiscard = discardCurrentRecording
|
||||
let wasStopRequested = stopRequested
|
||||
if shouldDiscard {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
finishAndReset(nil)
|
||||
return
|
||||
}
|
||||
|
||||
fileValidationTask?.cancel()
|
||||
fileValidationTask = Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let inspection = await self.fileInspector.inspect(
|
||||
url: url,
|
||||
fallbackDuration: fallbackDuration
|
||||
)
|
||||
guard !Task.isCancelled,
|
||||
VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: self.activeRecordingURL,
|
||||
outputURL: url
|
||||
) else { return }
|
||||
self.fileValidationTask = nil
|
||||
|
||||
if self.discardCurrentRecording {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
self.finishAndReset(nil)
|
||||
return
|
||||
}
|
||||
|
||||
if let inspection,
|
||||
VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
|
||||
stopRequested: wasStopRequested,
|
||||
error: error,
|
||||
recordedDuration: inspection.duration,
|
||||
maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
|
||||
),
|
||||
VideoNoteStopPolicy.isValidFinalDuration(inspection.duration) {
|
||||
self.finishAndReset(.success(Recording(
|
||||
url: url,
|
||||
duration: inspection.duration
|
||||
)))
|
||||
return
|
||||
}
|
||||
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
let failure: Error
|
||||
if !wasStopRequested,
|
||||
!VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
|
||||
stopRequested: false,
|
||||
error: error,
|
||||
recordedDuration: inspection?.duration ?? fallbackDuration,
|
||||
maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
|
||||
) {
|
||||
failure = VideoNoteRecorderError.recordingInterrupted
|
||||
} else if !VideoNoteRecordingCompletionPolicy.shouldKeepOutput(for: error), let error {
|
||||
failure = error
|
||||
} else {
|
||||
failure = VideoNoteRecorderError.emptyRecording
|
||||
}
|
||||
self.finishAndReset(.failure(failure))
|
||||
}
|
||||
}
|
||||
|
||||
private var measuredWallClockDuration: TimeInterval {
|
||||
guard let startedAt else { return max(0, elapsed) }
|
||||
return max(elapsed, Date().timeIntervalSince(startedAt))
|
||||
}
|
||||
|
||||
private func stopMovieOutput() async {
|
||||
let output = movieOutput
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
sessionQueue.async {
|
||||
if output.isRecording {
|
||||
output.stopRecording()
|
||||
}
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func movieOutputSnapshot() async -> MovieOutputSnapshot {
|
||||
let output = movieOutput
|
||||
return await withCheckedContinuation { continuation in
|
||||
sessionQueue.async {
|
||||
let seconds = CMTimeGetSeconds(output.recordedDuration)
|
||||
continuation.resume(returning: MovieOutputSnapshot(
|
||||
isRecording: output.isRecording,
|
||||
recordedDuration: seconds.isFinite && seconds > 0 ? seconds : 0
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func flushCaptureSessionForFinalization() async {
|
||||
let output = movieOutput
|
||||
let session = session
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
sessionQueue.async {
|
||||
if output.isRecording {
|
||||
output.stopRecording()
|
||||
}
|
||||
if session.isRunning {
|
||||
session.stopRunning()
|
||||
}
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleStartupTimeout(for url: URL) {
|
||||
startupTimeoutTask?.cancel()
|
||||
startupTimeoutTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(
|
||||
nanoseconds: VideoNoteStopPolicy.startupTimeoutNanoseconds
|
||||
)
|
||||
guard !Task.isCancelled,
|
||||
let self,
|
||||
self.lifecycle == .starting,
|
||||
VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: self.activeRecordingURL,
|
||||
outputURL: url
|
||||
) else { return }
|
||||
|
||||
self.lifecycle = .stopping
|
||||
await self.stopMovieOutput()
|
||||
guard !Task.isCancelled,
|
||||
self.lifecycle == .stopping,
|
||||
VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: self.activeRecordingURL,
|
||||
outputURL: url
|
||||
) else { return }
|
||||
self.scheduleFinalizationTimeout(for: url)
|
||||
}
|
||||
}
|
||||
|
||||
private func scheduleFinalizationTimeout(for url: URL) {
|
||||
finalizationTimeoutTask?.cancel()
|
||||
finalizationTimeoutTask = Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(
|
||||
nanoseconds: VideoNoteStopPolicy.finalizationTimeoutNanoseconds
|
||||
)
|
||||
guard !Task.isCancelled,
|
||||
let self,
|
||||
self.lifecycle == .stopping,
|
||||
VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: self.activeRecordingURL,
|
||||
outputURL: url
|
||||
) else { return }
|
||||
|
||||
// A few macOS camera drivers stop the file output but delay its
|
||||
// delegate callback until the capture session itself is flushed.
|
||||
// This is recovery-only; the normal path still waits for
|
||||
// didFinishRecording before touching the session.
|
||||
await self.flushCaptureSessionForFinalization()
|
||||
try? await Task.sleep(
|
||||
nanoseconds: VideoNoteStopPolicy.finalizationRecoveryGraceNanoseconds
|
||||
)
|
||||
guard !Task.isCancelled,
|
||||
self.lifecycle == .stopping,
|
||||
VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: self.activeRecordingURL,
|
||||
outputURL: url
|
||||
) else { return }
|
||||
let fallbackDuration = self.measuredWallClockDuration
|
||||
let inspection = await self.fileInspector.inspect(
|
||||
url: url,
|
||||
fallbackDuration: fallbackDuration
|
||||
)
|
||||
guard !Task.isCancelled,
|
||||
self.lifecycle == .stopping,
|
||||
VideoNoteRecordingLifecycle.acceptsCompletion(
|
||||
activeURL: self.activeRecordingURL,
|
||||
outputURL: url
|
||||
) else { return }
|
||||
|
||||
self.finalizationTimeoutTask = nil
|
||||
if self.discardCurrentRecording {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
self.finishAndReset(nil)
|
||||
} else if let inspection,
|
||||
VideoNoteRecordingCompletionPolicy.wasRequestedOrReachedLimit(
|
||||
stopRequested: self.stopRequested,
|
||||
error: nil,
|
||||
recordedDuration: inspection.duration,
|
||||
maximumDuration: VideoNoteStopPolicy.maximumCaptureDuration
|
||||
),
|
||||
VideoNoteStopPolicy.isValidFinalDuration(inspection.duration) {
|
||||
// Some macOS camera drivers finalize the .mov but omit the
|
||||
// delegate callback. A playable file is more authoritative
|
||||
// than the missing callback and remains safe to send.
|
||||
self.finishAndReset(.success(Recording(
|
||||
url: url,
|
||||
duration: inspection.duration
|
||||
)))
|
||||
} else if !self.stopRequested {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
self.finishAndReset(.failure(VideoNoteRecorderError.recordingInterrupted))
|
||||
} else {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
self.finishAndReset(.failure(VideoNoteRecorderError.finalizationTimedOut))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func finishAndReset(_ result: Result<Recording, Error>?) {
|
||||
fileValidationTask = nil
|
||||
let completion = onFinished
|
||||
performShutdown()
|
||||
if let result {
|
||||
completion?(result)
|
||||
}
|
||||
}
|
||||
|
||||
private func performShutdown() {
|
||||
sessionGeneration = UUID()
|
||||
stopRequestTask?.cancel()
|
||||
stopRequestTask = nil
|
||||
startupTimeoutTask?.cancel()
|
||||
startupTimeoutTask = nil
|
||||
finalizationTimeoutTask?.cancel()
|
||||
finalizationTimeoutTask = nil
|
||||
fileValidationTask?.cancel()
|
||||
fileValidationTask = nil
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
resetCaptureGraph()
|
||||
lifecycle = .idle
|
||||
activeRecordingURL = nil
|
||||
discardCurrentRecording = false
|
||||
stopRequested = false
|
||||
requestedMinimumDuration = 0
|
||||
startedAt = nil
|
||||
elapsed = 0
|
||||
isPrepared = false
|
||||
isRecording = false
|
||||
isUsingFrontCamera = true
|
||||
isMicrophoneMuted = false
|
||||
#if os(iOS)
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
#endif
|
||||
}
|
||||
|
||||
private func waitForPreviousRecordingToFinish() async throws {
|
||||
guard lifecycle != .starting, lifecycle != .recording else {
|
||||
throw VideoNoteRecorderError.recordingBusy
|
||||
}
|
||||
let deadline = Date().addingTimeInterval(10)
|
||||
while lifecycle == .stopping, Date() < deadline {
|
||||
try await Task.sleep(nanoseconds: 25_000_000)
|
||||
}
|
||||
guard lifecycle != .stopping else {
|
||||
throw VideoNoteRecorderError.finalizationTimedOut
|
||||
}
|
||||
}
|
||||
|
||||
private func configureVideoConnection() {
|
||||
guard let connection = movieOutput.connection(with: .video) else { return }
|
||||
#if os(iOS)
|
||||
configureInteroperableVideoCodec(for: connection)
|
||||
#endif
|
||||
if connection.isVideoMirroringSupported {
|
||||
connection.automaticallyAdjustsVideoMirroring = false
|
||||
connection.isVideoMirrored = isUsingFrontCamera
|
||||
}
|
||||
#if os(iOS)
|
||||
if connection.isVideoOrientationSupported {
|
||||
connection.videoOrientation = Self.currentVideoOrientation
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private func configureInteroperableVideoCodec(for connection: AVCaptureConnection) {
|
||||
guard movieOutput.availableVideoCodecTypes.contains(.h264) else { return }
|
||||
movieOutput.setOutputSettings(
|
||||
[AVVideoCodecKey: AVVideoCodecType.h264],
|
||||
for: connection
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
private func resetCaptureGraph() {
|
||||
let session = session
|
||||
sessionQueue.async {
|
||||
if session.isRunning {
|
||||
session.stopRunning()
|
||||
}
|
||||
guard !session.inputs.isEmpty || !session.outputs.isEmpty else { return }
|
||||
session.beginConfiguration()
|
||||
session.inputs.forEach { session.removeInput($0) }
|
||||
session.outputs.forEach { session.removeOutput($0) }
|
||||
session.commitConfiguration()
|
||||
}
|
||||
cameraInput = nil
|
||||
microphoneInput = nil
|
||||
}
|
||||
|
||||
private func waitForPendingSessionOperations() async {
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
sessionQueue.async {
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func configureCaptureGraph() async throws -> CaptureGraph {
|
||||
let session = session
|
||||
let movieOutput = movieOutput
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
sessionQueue.async {
|
||||
session.beginConfiguration()
|
||||
do {
|
||||
session.sessionPreset = session.canSetSessionPreset(.vga640x480)
|
||||
? .vga640x480
|
||||
: .medium
|
||||
|
||||
#if os(iOS)
|
||||
let camera = AVCaptureDevice.default(
|
||||
.builtInWideAngleCamera,
|
||||
for: .video,
|
||||
position: .front
|
||||
) ?? AVCaptureDevice.default(for: .video)
|
||||
#else
|
||||
let camera = AVCaptureDevice.default(for: .video)
|
||||
#endif
|
||||
guard let camera else {
|
||||
throw VideoNoteRecorderError.cameraUnavailable
|
||||
}
|
||||
let cameraInput = try AVCaptureDeviceInput(device: camera)
|
||||
guard session.canAddInput(cameraInput) else {
|
||||
throw VideoNoteRecorderError.configurationFailed
|
||||
}
|
||||
session.addInput(cameraInput)
|
||||
|
||||
guard let microphone = AVCaptureDevice.default(for: .audio) else {
|
||||
throw VideoNoteRecorderError.microphoneUnavailable
|
||||
}
|
||||
let microphoneInput = try AVCaptureDeviceInput(device: microphone)
|
||||
guard session.canAddInput(microphoneInput) else {
|
||||
throw VideoNoteRecorderError.configurationFailed
|
||||
}
|
||||
session.addInput(microphoneInput)
|
||||
|
||||
guard session.canAddOutput(movieOutput) else {
|
||||
throw VideoNoteRecorderError.configurationFailed
|
||||
}
|
||||
session.addOutput(movieOutput)
|
||||
movieOutput.maxRecordedDuration = CMTime(
|
||||
seconds: VideoNoteStopPolicy.maximumCaptureDuration,
|
||||
preferredTimescale: 600
|
||||
)
|
||||
session.commitConfiguration()
|
||||
continuation.resume(returning: CaptureGraph(
|
||||
cameraInput: cameraInput,
|
||||
microphoneInput: microphoneInput,
|
||||
usesFrontCamera: camera.position == .front
|
||||
))
|
||||
} catch {
|
||||
session.commitConfiguration()
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startCaptureSession(generation: UUID) async throws {
|
||||
let session = session
|
||||
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
|
||||
sessionQueue.async {
|
||||
if !session.isRunning {
|
||||
session.startRunning()
|
||||
}
|
||||
continuation.resume()
|
||||
}
|
||||
}
|
||||
guard !Task.isCancelled,
|
||||
sessionGeneration == generation,
|
||||
session.isRunning else {
|
||||
throw CancellationError()
|
||||
}
|
||||
}
|
||||
|
||||
private static func requestAccess(for mediaType: AVMediaType) async -> Bool {
|
||||
switch AVCaptureDevice.authorizationStatus(for: mediaType) {
|
||||
case .authorized:
|
||||
return true
|
||||
case .denied, .restricted:
|
||||
return false
|
||||
case .notDetermined:
|
||||
return await AVCaptureDevice.requestAccess(for: mediaType)
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private actor VideoNoteFileInspector {
|
||||
struct Inspection: Sendable {
|
||||
let duration: TimeInterval
|
||||
}
|
||||
|
||||
func inspect(url: URL, fallbackDuration: TimeInterval) async -> Inspection? {
|
||||
let retryDelays: [UInt64] = [0, 120_000_000, 300_000_000]
|
||||
for delay in retryDelays {
|
||||
if delay > 0 {
|
||||
try? await Task.sleep(nanoseconds: delay)
|
||||
}
|
||||
guard let values = try? url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey]),
|
||||
values.isRegularFile == true,
|
||||
let size = values.fileSize,
|
||||
size > 1_024 else { continue }
|
||||
|
||||
let asset = AVURLAsset(url: url)
|
||||
guard let duration = try? await asset.load(.duration),
|
||||
duration.seconds.isFinite,
|
||||
duration.seconds > 0.04,
|
||||
let tracks = try? await asset.loadTracks(withMediaType: .video),
|
||||
!tracks.isEmpty else { continue }
|
||||
return Inspection(duration: duration.seconds)
|
||||
}
|
||||
|
||||
// Keep a non-empty file only when AVFoundation reported enough wall
|
||||
// clock time to make a useful fallback. This branch covers a small set
|
||||
// of macOS camera drivers that delay movie metadata longer than the
|
||||
// delegate callback itself.
|
||||
guard fallbackDuration > 0.25,
|
||||
let values = try? url.resourceValues(forKeys: [.fileSizeKey]),
|
||||
let size = values.fileSize,
|
||||
size > 16_384 else { return nil }
|
||||
return Inspection(duration: fallbackDuration)
|
||||
}
|
||||
}
|
||||
|
||||
enum VideoNoteStopPolicy {
|
||||
static let minimumCaptureDuration: TimeInterval = 1.2
|
||||
static let minimumValidFileDuration: TimeInterval = 0.8
|
||||
static let maximumCaptureDuration: TimeInterval = 60
|
||||
static let recordedDurationWaitTimeout: TimeInterval = 5
|
||||
static let recordedDurationPollNanoseconds: UInt64 = 80_000_000
|
||||
static let startupTimeoutNanoseconds: UInt64 = 6_000_000_000
|
||||
static let finalizationTimeoutNanoseconds: UInt64 = 12_000_000_000
|
||||
static let finalizationRecoveryGraceNanoseconds: UInt64 = 1_000_000_000
|
||||
|
||||
static func remainingRecordedDuration(
|
||||
recordedDuration: TimeInterval,
|
||||
minimumDuration: TimeInterval
|
||||
) -> TimeInterval {
|
||||
max(0, max(0, minimumDuration) - max(0, recordedDuration))
|
||||
}
|
||||
|
||||
static func isValidFinalDuration(_ duration: TimeInterval) -> Bool {
|
||||
duration.isFinite && duration >= minimumValidFileDuration
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
private extension VideoNoteRecorder {
|
||||
static var currentVideoOrientation: AVCaptureVideoOrientation {
|
||||
switch UIDevice.current.orientation {
|
||||
case .portraitUpsideDown:
|
||||
return .portraitUpsideDown
|
||||
case .landscapeLeft:
|
||||
return .landscapeRight
|
||||
case .landscapeRight:
|
||||
return .landscapeLeft
|
||||
default:
|
||||
return .portrait
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
private enum VideoNoteRecorderError: LocalizedError {
|
||||
case cameraPermissionDenied
|
||||
case microphonePermissionDenied
|
||||
case cameraUnavailable
|
||||
case alternateCameraUnavailable
|
||||
case microphoneUnavailable
|
||||
case configurationFailed
|
||||
case notPrepared
|
||||
case recordingBusy
|
||||
case recordingInterrupted
|
||||
case finalizationTimedOut
|
||||
case emptyRecording
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .cameraPermissionDenied:
|
||||
return "Разрешите Luma доступ к камере в системных настройках."
|
||||
case .microphonePermissionDenied:
|
||||
return "Разрешите Luma доступ к микрофону в системных настройках."
|
||||
case .cameraUnavailable:
|
||||
return "Камера недоступна."
|
||||
case .alternateCameraUnavailable:
|
||||
return "Вторая камера недоступна."
|
||||
case .microphoneUnavailable:
|
||||
return "Микрофон недоступен."
|
||||
case .configurationFailed:
|
||||
return "Не удалось настроить запись видеосообщения."
|
||||
case .notPrepared:
|
||||
return "Камера ещё не готова."
|
||||
case .recordingBusy:
|
||||
return "Предыдущая запись ещё завершается. Подождите секунду и повторите."
|
||||
case .recordingInterrupted:
|
||||
return "Камера прервала запись раньше времени. Видеосообщение не отправлено. Повторите запись."
|
||||
case .finalizationTimedOut:
|
||||
return "Камера не смогла завершить видеофайл. Повторите запись."
|
||||
case .emptyRecording:
|
||||
return "Камера создала пустой или нечитаемый видеофайл. Повторите запись."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user