0.10.6
This commit is contained in:
commit
92660a4ba0
228 files changed
+24496
No files matched your search
@@ -0,0 +1,13 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
@MainActor
|
||||
struct LumaWatchApp: App {
|
||||
@StateObject private var model = WatchSessionModel()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
WatchChatListView(model: model)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import Foundation
|
||||
import WatchConnectivity
|
||||
|
||||
struct WatchSnapshot: Codable {
|
||||
struct Chat: Codable, Identifiable, Hashable {
|
||||
var id: String { jid }
|
||||
let jid: String
|
||||
let name: String
|
||||
let unread: Int
|
||||
let messages: [Message]
|
||||
}
|
||||
|
||||
struct Message: Codable, Identifiable, Hashable {
|
||||
let id: String
|
||||
let body: String
|
||||
let timestamp: Date
|
||||
let outgoing: Bool
|
||||
let encrypted: Bool
|
||||
}
|
||||
|
||||
let chats: [Chat]
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class WatchSessionModel: NSObject, ObservableObject {
|
||||
@Published private(set) var chats: [WatchSnapshot.Chat] = []
|
||||
@Published private(set) var phoneReachable = false
|
||||
@Published private(set) var voiceStatusByJID: [String: String] = [:]
|
||||
@Published var errorMessage: String?
|
||||
|
||||
private let session: WCSession?
|
||||
private let cacheKey = "luma.watch.snapshot.v1"
|
||||
private let voiceTransferCacheKey = "luma.watch.voice-transfers.v1"
|
||||
private var outgoingVoiceTransfers: [String: OutgoingVoiceTransfer] = [:]
|
||||
|
||||
override init() {
|
||||
session = WCSession.isSupported() ? .default : nil
|
||||
super.init()
|
||||
loadCachedSnapshot()
|
||||
loadOutgoingVoiceTransfers()
|
||||
session?.delegate = self
|
||||
session?.activate()
|
||||
}
|
||||
|
||||
func send(text rawText: String, to jid: String) {
|
||||
let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty, let session else { return }
|
||||
let payload: [String: Any] = [
|
||||
"action": "reply",
|
||||
"jid": jid,
|
||||
"text": text
|
||||
]
|
||||
|
||||
if session.isReachable {
|
||||
session.sendMessage(payload, replyHandler: nil) { [weak self] error in
|
||||
Task { @MainActor in self?.errorMessage = error.localizedDescription }
|
||||
}
|
||||
} else {
|
||||
session.transferUserInfo(payload)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func sendVoice(fileURL: URL, duration: TimeInterval, to jid: String) -> Bool {
|
||||
guard let session else {
|
||||
errorMessage = "WatchConnectivity недоступен."
|
||||
return false
|
||||
}
|
||||
guard session.activationState == .activated else {
|
||||
errorMessage = "Соединение с iPhone ещё не готово. Повторите через несколько секунд."
|
||||
return false
|
||||
}
|
||||
guard session.isCompanionAppInstalled else {
|
||||
errorMessage = "Установите Luma на связанный iPhone."
|
||||
return false
|
||||
}
|
||||
guard duration.isFinite, duration >= 0.4,
|
||||
FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
errorMessage = "Запись голосового сообщения недоступна."
|
||||
return false
|
||||
}
|
||||
|
||||
let transferID = UUID().uuidString
|
||||
let normalizedJID = jid.lowercased()
|
||||
let metadata: [String: Any] = [
|
||||
"action": "voice",
|
||||
"transferID": transferID,
|
||||
"jid": normalizedJID,
|
||||
"filename": fileURL.lastPathComponent,
|
||||
"duration": duration
|
||||
]
|
||||
outgoingVoiceTransfers[transferID] = OutgoingVoiceTransfer(
|
||||
jid: normalizedJID,
|
||||
fileURL: fileURL,
|
||||
createdAt: Date()
|
||||
)
|
||||
persistOutgoingVoiceTransfers()
|
||||
voiceStatusByJID[normalizedJID] = "Голосовое в очереди на iPhone…"
|
||||
session.transferFile(fileURL, metadata: metadata)
|
||||
return true
|
||||
}
|
||||
|
||||
func voiceStatus(for jid: String) -> String? {
|
||||
voiceStatusByJID[jid.lowercased()]
|
||||
}
|
||||
|
||||
private func apply(data: Data) {
|
||||
let decoder = JSONDecoder()
|
||||
decoder.dateDecodingStrategy = .millisecondsSince1970
|
||||
guard let snapshot = try? decoder.decode(WatchSnapshot.self, from: data) else { return }
|
||||
chats = snapshot.chats
|
||||
UserDefaults.standard.set(data, forKey: cacheKey)
|
||||
}
|
||||
|
||||
private func loadCachedSnapshot() {
|
||||
guard let data = UserDefaults.standard.data(forKey: cacheKey) else { return }
|
||||
apply(data: data)
|
||||
}
|
||||
|
||||
private func consume(_ payload: [String: Any]) {
|
||||
guard payload["action"] as? String == "voiceResult",
|
||||
let transferID = payload["transferID"] as? String,
|
||||
let success = payload["success"] as? Bool else { return }
|
||||
|
||||
let transfer = outgoingVoiceTransfers.removeValue(forKey: transferID)
|
||||
let jid = transfer?.jid ?? (payload["jid"] as? String)?.lowercased()
|
||||
guard let jid else { return }
|
||||
|
||||
if let transfer {
|
||||
try? FileManager.default.removeItem(at: transfer.fileURL)
|
||||
}
|
||||
persistOutgoingVoiceTransfers()
|
||||
if success {
|
||||
voiceStatusByJID[jid] = "Голосовое отправлено"
|
||||
} else {
|
||||
voiceStatusByJID[jid] = "Голосовое не отправлено"
|
||||
errorMessage = (payload["error"] as? String) ?? "iPhone не смог отправить голосовое сообщение."
|
||||
}
|
||||
}
|
||||
|
||||
private func finishVoiceTransfer(
|
||||
transferID: String?,
|
||||
fallbackURL: URL,
|
||||
errorDescription: String?
|
||||
) {
|
||||
guard let transferID,
|
||||
let transfer = outgoingVoiceTransfers[transferID] else {
|
||||
try? FileManager.default.removeItem(at: fallbackURL)
|
||||
return
|
||||
}
|
||||
|
||||
// Once WatchConnectivity has copied the file to the iPhone the source
|
||||
// recording is no longer needed. Keep only the transfer metadata while
|
||||
// waiting for the actual XMPP upload result from the companion app.
|
||||
try? FileManager.default.removeItem(at: transfer.fileURL)
|
||||
if let errorDescription {
|
||||
outgoingVoiceTransfers.removeValue(forKey: transferID)
|
||||
persistOutgoingVoiceTransfers()
|
||||
voiceStatusByJID[transfer.jid] = "Не удалось передать запись"
|
||||
errorMessage = errorDescription
|
||||
} else {
|
||||
voiceStatusByJID[transfer.jid] = "iPhone получил запись. Отправляем…"
|
||||
}
|
||||
}
|
||||
|
||||
private func loadOutgoingVoiceTransfers() {
|
||||
guard let data = UserDefaults.standard.data(forKey: voiceTransferCacheKey),
|
||||
let stored = try? JSONDecoder().decode(
|
||||
[String: OutgoingVoiceTransfer].self,
|
||||
from: data
|
||||
) else { return }
|
||||
let expiration = Date().addingTimeInterval(-7 * 24 * 60 * 60)
|
||||
outgoingVoiceTransfers = stored.filter { $0.value.createdAt >= expiration }
|
||||
for transfer in outgoingVoiceTransfers.values {
|
||||
voiceStatusByJID[transfer.jid] = "Ожидает подтверждения iPhone…"
|
||||
}
|
||||
persistOutgoingVoiceTransfers()
|
||||
}
|
||||
|
||||
private func persistOutgoingVoiceTransfers() {
|
||||
guard let data = try? JSONEncoder().encode(outgoingVoiceTransfers) else { return }
|
||||
UserDefaults.standard.set(data, forKey: voiceTransferCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
private struct OutgoingVoiceTransfer: Codable {
|
||||
let jid: String
|
||||
let fileURL: URL
|
||||
let createdAt: Date
|
||||
}
|
||||
|
||||
extension WatchSessionModel: WCSessionDelegate {
|
||||
nonisolated func session(
|
||||
_ session: WCSession,
|
||||
activationDidCompleteWith activationState: WCSessionActivationState,
|
||||
error: Error?
|
||||
) {
|
||||
Task { @MainActor in
|
||||
phoneReachable = session.isReachable
|
||||
if let error { errorMessage = error.localizedDescription }
|
||||
if let data = session.receivedApplicationContext["snapshot"] as? Data {
|
||||
apply(data: data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func sessionReachabilityDidChange(_ session: WCSession) {
|
||||
Task { @MainActor in phoneReachable = session.isReachable }
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveApplicationContext applicationContext: [String: Any]) {
|
||||
guard let data = applicationContext["snapshot"] as? Data else { return }
|
||||
Task { @MainActor in apply(data: data) }
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveMessage message: [String: Any]) {
|
||||
Task { @MainActor in consume(message) }
|
||||
}
|
||||
|
||||
nonisolated func session(_ session: WCSession, didReceiveUserInfo userInfo: [String: Any]) {
|
||||
Task { @MainActor in consume(userInfo) }
|
||||
}
|
||||
|
||||
nonisolated func session(
|
||||
_ session: WCSession,
|
||||
didFinish fileTransfer: WCSessionFileTransfer,
|
||||
error: Error?
|
||||
) {
|
||||
let transferID = fileTransfer.file.metadata?["transferID"] as? String
|
||||
let fileURL = fileTransfer.file.fileURL
|
||||
let errorDescription = error?.localizedDescription
|
||||
Task { @MainActor in
|
||||
finishVoiceTransfer(
|
||||
transferID: transferID,
|
||||
fallbackURL: fileURL,
|
||||
errorDescription: errorDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct WatchChatListView: View {
|
||||
@ObservedObject var model: WatchSessionModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Group {
|
||||
if model.chats.isEmpty {
|
||||
ContentUnavailableView(
|
||||
"Нет чатов",
|
||||
systemImage: "iphone.and.arrow.forward",
|
||||
description: Text("Откройте Luma на iPhone для синхронизации.")
|
||||
)
|
||||
} else {
|
||||
List(model.chats) { chat in
|
||||
NavigationLink(value: chat) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
HStack {
|
||||
Text(chat.name).font(.headline).lineLimit(1)
|
||||
Spacer()
|
||||
if chat.unread > 0 {
|
||||
Text("\(chat.unread)")
|
||||
.font(.caption2.bold())
|
||||
.padding(5)
|
||||
.background(.blue, in: Circle())
|
||||
}
|
||||
}
|
||||
Text(chat.messages.last?.body ?? chat.jid)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Luma")
|
||||
.navigationDestination(for: WatchSnapshot.Chat.self) { chat in
|
||||
WatchChatView(model: model, chat: chat)
|
||||
}
|
||||
}
|
||||
.alert("Luma", isPresented: Binding(
|
||||
get: { model.errorMessage != nil },
|
||||
set: { if !$0 { model.errorMessage = nil } }
|
||||
)) {
|
||||
Button("OK") { model.errorMessage = nil }
|
||||
} message: {
|
||||
Text(model.errorMessage ?? "Ошибка")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct WatchChatView: View {
|
||||
@ObservedObject var model: WatchSessionModel
|
||||
let chat: WatchSnapshot.Chat
|
||||
@StateObject private var voiceRecorder = WatchVoiceRecorder()
|
||||
@State private var reply = ""
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
List {
|
||||
ForEach(chat.messages) { message in
|
||||
HStack {
|
||||
if message.outgoing { Spacer(minLength: 16) }
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
HStack(spacing: 4) {
|
||||
if message.encrypted {
|
||||
Image(systemName: "lock.fill").font(.caption2)
|
||||
}
|
||||
Text(message.body)
|
||||
}
|
||||
Text(message.timestamp, format: .dateTime.hour().minute())
|
||||
.font(.caption2)
|
||||
.opacity(0.7)
|
||||
}
|
||||
.padding(7)
|
||||
.background(message.outgoing ? Color.blue : Color.secondary.opacity(0.25), in: RoundedRectangle(cornerRadius: 11))
|
||||
if !message.outgoing { Spacer(minLength: 16) }
|
||||
}
|
||||
.id(message.id)
|
||||
}
|
||||
|
||||
Section {
|
||||
TextField("Ответ", text: $reply)
|
||||
Button("Отправить") {
|
||||
model.send(text: reply, to: chat.jid)
|
||||
reply = ""
|
||||
}
|
||||
.disabled(reply.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
|
||||
}
|
||||
|
||||
Section("Голосовое") {
|
||||
voiceComposer
|
||||
|
||||
if let status = model.voiceStatus(for: chat.jid) {
|
||||
Label(status, systemImage: "iphone.and.arrow.forward")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Быстрый ответ") {
|
||||
ForEach(["Хорошо", "Спасибо!", "Отвечу позже"], id: \.self) { text in
|
||||
Button(text) { model.send(text: text, to: chat.jid) }
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle(chat.name)
|
||||
.onAppear {
|
||||
if let id = chat.messages.last?.id { proxy.scrollTo(id, anchor: .bottom) }
|
||||
}
|
||||
.onDisappear {
|
||||
voiceRecorder.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var voiceComposer: some View {
|
||||
if voiceRecorder.isRecording {
|
||||
VStack(spacing: 8) {
|
||||
Label(
|
||||
formatDuration(voiceRecorder.elapsed),
|
||||
systemImage: "waveform"
|
||||
)
|
||||
.font(.headline.monospacedDigit())
|
||||
.foregroundStyle(.red)
|
||||
|
||||
ProgressView(
|
||||
value: voiceRecorder.elapsed,
|
||||
total: WatchVoiceRecorder.maximumDuration
|
||||
)
|
||||
.tint(.red)
|
||||
|
||||
HStack(spacing: 8) {
|
||||
Button(role: .destructive) {
|
||||
voiceRecorder.cancelRecording()
|
||||
} label: {
|
||||
Label("Отмена", systemImage: "xmark")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
|
||||
Button {
|
||||
finishVoiceRecording()
|
||||
} label: {
|
||||
Label("Готово", systemImage: "stop.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
} else if let recording = voiceRecorder.recording {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack {
|
||||
Button {
|
||||
do {
|
||||
try voiceRecorder.togglePlayback()
|
||||
} catch {
|
||||
model.errorMessage = error.localizedDescription
|
||||
}
|
||||
} label: {
|
||||
Image(systemName: voiceRecorder.isPlaying ? "pause.fill" : "play.fill")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.accessibilityLabel(voiceRecorder.isPlaying ? "Пауза" : "Прослушать")
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Предпрослушивание")
|
||||
.font(.caption)
|
||||
Text(formatDuration(recording.duration))
|
||||
.font(.caption2.monospacedDigit())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
if model.sendVoice(
|
||||
fileURL: recording.url,
|
||||
duration: recording.duration,
|
||||
to: chat.jid
|
||||
) {
|
||||
_ = voiceRecorder.detachRecording()
|
||||
}
|
||||
} label: {
|
||||
Label("Отправить голосовое", systemImage: "paperplane.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
|
||||
Button(role: .destructive) {
|
||||
voiceRecorder.discardRecording()
|
||||
} label: {
|
||||
Label("Удалить запись", systemImage: "trash")
|
||||
}
|
||||
.font(.caption)
|
||||
}
|
||||
} else {
|
||||
Button {
|
||||
Task {
|
||||
do {
|
||||
try await voiceRecorder.start()
|
||||
} catch {
|
||||
model.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Записать голосовое", systemImage: "mic.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishVoiceRecording() {
|
||||
do {
|
||||
try voiceRecorder.finish()
|
||||
} catch {
|
||||
model.errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDuration(_ value: TimeInterval) -> String {
|
||||
let seconds = max(0, Int(value.rounded(.down)))
|
||||
return String(format: "%d:%02d", seconds / 60, seconds % 60)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import Foundation
|
||||
|
||||
@MainActor
|
||||
final class WatchVoiceRecorder: NSObject, ObservableObject {
|
||||
struct Recording: Equatable {
|
||||
let url: URL
|
||||
let duration: TimeInterval
|
||||
}
|
||||
|
||||
static let maximumDuration: TimeInterval = 60
|
||||
|
||||
@Published private(set) var isRecording = false
|
||||
@Published private(set) var isPlaying = false
|
||||
@Published private(set) var elapsed: TimeInterval = 0
|
||||
@Published private(set) var recording: Recording?
|
||||
|
||||
private var recorder: AVAudioRecorder?
|
||||
private var player: AVAudioPlayer?
|
||||
private var timer: Timer?
|
||||
private var startedAt: Date?
|
||||
|
||||
func start() async throws {
|
||||
guard !isRecording else { return }
|
||||
discardRecording()
|
||||
guard await requestPermission() else { throw WatchVoiceRecorderError.permissionDenied }
|
||||
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
do {
|
||||
try audioSession.setCategory(.playAndRecord, mode: .default)
|
||||
try audioSession.setActive(true)
|
||||
|
||||
let directory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("LumaWatchRecordings", 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,
|
||||
AVEncoderBitRateKey: 32_000,
|
||||
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue
|
||||
]
|
||||
let recorder = try AVAudioRecorder(url: url, settings: settings)
|
||||
recorder.prepareToRecord()
|
||||
guard recorder.record(forDuration: Self.maximumDuration) else {
|
||||
throw WatchVoiceRecorderError.cannotStart
|
||||
}
|
||||
|
||||
self.recorder = recorder
|
||||
startedAt = Date()
|
||||
elapsed = 0
|
||||
isRecording = true
|
||||
timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
guard let self, let startedAt = self.startedAt else { return }
|
||||
self.elapsed = min(Date().timeIntervalSince(startedAt), Self.maximumDuration)
|
||||
if !self.recorderIsActivelyRecording {
|
||||
_ = try? self.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
try? audioSession.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func finish() throws -> Recording {
|
||||
guard let recorder, isRecording else { throw WatchVoiceRecorderError.notRecording }
|
||||
let duration = min(max(recorder.currentTime, elapsed), Self.maximumDuration)
|
||||
let url = recorder.url
|
||||
recorder.stop()
|
||||
stopRecordingState()
|
||||
guard duration >= 0.4 else {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
throw WatchVoiceRecorderError.tooShort
|
||||
}
|
||||
let recording = Recording(url: url, duration: duration)
|
||||
self.recording = recording
|
||||
return recording
|
||||
}
|
||||
|
||||
func cancelRecording() {
|
||||
let url = recorder?.url
|
||||
recorder?.stop()
|
||||
stopRecordingState()
|
||||
if let url {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
func togglePlayback() throws {
|
||||
if isPlaying {
|
||||
stopPlayback()
|
||||
return
|
||||
}
|
||||
guard let recording else { return }
|
||||
|
||||
let audioSession = AVAudioSession.sharedInstance()
|
||||
try audioSession.setCategory(.playback, mode: .spokenAudio)
|
||||
try audioSession.setActive(true)
|
||||
do {
|
||||
let player = try AVAudioPlayer(contentsOf: recording.url)
|
||||
player.delegate = self
|
||||
player.prepareToPlay()
|
||||
guard player.play() else { throw WatchVoiceRecorderError.cannotPlay }
|
||||
self.player = player
|
||||
isPlaying = true
|
||||
} catch {
|
||||
try? audioSession.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func discardRecording() {
|
||||
stopPlayback()
|
||||
if let url = recording?.url {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
recording = nil
|
||||
}
|
||||
|
||||
func detachRecording() -> Recording? {
|
||||
stopPlayback()
|
||||
defer { recording = nil }
|
||||
return recording
|
||||
}
|
||||
|
||||
func reset() {
|
||||
cancelRecording()
|
||||
discardRecording()
|
||||
}
|
||||
|
||||
private var recorderIsActivelyRecording: Bool {
|
||||
recorder?.isRecording == true
|
||||
}
|
||||
|
||||
private func stopRecordingState() {
|
||||
timer?.invalidate()
|
||||
timer = nil
|
||||
recorder = nil
|
||||
startedAt = nil
|
||||
elapsed = 0
|
||||
isRecording = false
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
private func stopPlayback() {
|
||||
player?.stop()
|
||||
player = nil
|
||||
isPlaying = false
|
||||
try? AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
private func requestPermission() async -> Bool {
|
||||
switch AVAudioApplication.shared.recordPermission {
|
||||
case .granted:
|
||||
return true
|
||||
case .denied:
|
||||
return false
|
||||
case .undetermined:
|
||||
return await withCheckedContinuation { continuation in
|
||||
AVAudioApplication.requestRecordPermission { granted in
|
||||
continuation.resume(returning: granted)
|
||||
}
|
||||
}
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension WatchVoiceRecorder: AVAudioPlayerDelegate {
|
||||
nonisolated func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
|
||||
Task { @MainActor [weak self] in
|
||||
self?.stopPlayback()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum WatchVoiceRecorderError: LocalizedError {
|
||||
case permissionDenied
|
||||
case cannotStart
|
||||
case notRecording
|
||||
case tooShort
|
||||
case cannotPlay
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .permissionDenied:
|
||||
return "Разрешите Luma доступ к микрофону в настройках Apple Watch."
|
||||
case .cannotStart:
|
||||
return "Не удалось начать запись на Apple Watch."
|
||||
case .notRecording:
|
||||
return "Запись голосового сообщения не запущена."
|
||||
case .tooShort:
|
||||
return "Голосовое сообщение получилось слишком коротким."
|
||||
case .cannotPlay:
|
||||
return "Не удалось воспроизвести запись."
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user