From 94e4605af336c57f01411fe65df268fad804187b Mon Sep 17 00:00:00 2001 From: Selim Mustafaev Date: Mon, 31 Mar 2025 17:23:10 +0300 Subject: [PATCH] Playing audio records --- AutoCat.xcodeproj/project.pbxproj | 16 ++++ AutoCat/SceneDelegate.swift | 1 + .../AudioRecordViewModel.swift | 4 +- .../RecordsScreen/RecordsCoordinator.swift | 3 +- .../RecordsScreen/RecordsViewModel.swift | 25 +++++- .../RecordPlayerService.swift | 80 +++++++++++++++++++ .../RecordPlayerServiceProtocol.swift | 17 ++++ .../VehicleRecordService.swift | 2 +- AutoCatCore/Utils/Constants.swift | 2 + 9 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 AutoCatCore/Services/RecordPlayerService/RecordPlayerService.swift create mode 100644 AutoCatCore/Services/RecordPlayerService/RecordPlayerServiceProtocol.swift diff --git a/AutoCat.xcodeproj/project.pbxproj b/AutoCat.xcodeproj/project.pbxproj index 93d5ab0..9dddb81 100644 --- a/AutoCat.xcodeproj/project.pbxproj +++ b/AutoCat.xcodeproj/project.pbxproj @@ -146,6 +146,8 @@ 7A99406426E4BFAE002E9CB6 /* VehicleNoteCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A99406326E4BFAE002E9CB6 /* VehicleNoteCell.swift */; }; 7A9FEEC82529AB23001CA50E /* RxRealmDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A9FEEC72529AB23001CA50E /* RxRealmDataSource.swift */; }; 7AA514E02D0B75B3001CAC50 /* StorageService+Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA514DF2D0B75B3001CAC50 /* StorageService+Events.swift */; }; + 7AA515D02D9ABCC800EB3418 /* RecordPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA515CF2D9ABCC800EB3418 /* RecordPlayerService.swift */; }; + 7AA515D22D9ABCE600EB3418 /* RecordPlayerServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA515D12D9ABCE600EB3418 /* RecordPlayerServiceProtocol.swift */; }; 7AA7BC3525A5DFB80053A5D5 /* ExceptionCatcher in Frameworks */ = {isa = PBXBuildFile; productRef = 7A813DC02508C4D900CC93B9 /* ExceptionCatcher */; }; 7AA7BC3625A5DFB80053A5D5 /* PKHUD in Frameworks */ = {isa = PBXBuildFile; productRef = 7AABDE1C2532F3EB0041AFC6 /* PKHUD */; }; 7AAAFAD32C4D0FD00050410D /* ACImageSliderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AAAFAD22C4D0FD00050410D /* ACImageSliderView.swift */; }; @@ -444,6 +446,8 @@ 7A99406326E4BFAE002E9CB6 /* VehicleNoteCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VehicleNoteCell.swift; sourceTree = ""; }; 7A9FEEC72529AB23001CA50E /* RxRealmDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RxRealmDataSource.swift; sourceTree = ""; }; 7AA514DF2D0B75B3001CAC50 /* StorageService+Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "StorageService+Events.swift"; sourceTree = ""; }; + 7AA515CF2D9ABCC800EB3418 /* RecordPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordPlayerService.swift; sourceTree = ""; }; + 7AA515D12D9ABCE600EB3418 /* RecordPlayerServiceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordPlayerServiceProtocol.swift; sourceTree = ""; }; 7AAAFAD22C4D0FD00050410D /* ACImageSliderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ACImageSliderView.swift; sourceTree = ""; }; 7AAAFAD92C4D1AFE0050410D /* Zoomable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Zoomable.swift; sourceTree = ""; }; 7AAAFADB2C4D1E130050410D /* ACImageSliderView+Modifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ACImageSliderView+Modifier.swift"; sourceTree = ""; }; @@ -823,6 +827,7 @@ 7A45FB362C2706D000618694 /* Services */ = { isa = PBXGroup; children = ( + 7AA515CE2D9ABC9B00EB3418 /* RecordPlayerService */, 7ABDA8012D8704C90083C715 /* VehicleRecordService */, 7A9519772D80B3B200E69883 /* AudioRecordService */, 7AB4E4392D3D3F390006D052 /* VehicleService */, @@ -1043,6 +1048,15 @@ path = RecordsScreen; sourceTree = ""; }; + 7AA515CE2D9ABC9B00EB3418 /* RecordPlayerService */ = { + isa = PBXGroup; + children = ( + 7AA515CF2D9ABCC800EB3418 /* RecordPlayerService.swift */, + 7AA515D12D9ABCE600EB3418 /* RecordPlayerServiceProtocol.swift */, + ); + path = RecordPlayerService; + sourceTree = ""; + }; 7AAAFAD12C4D0FB00050410D /* ACImageSlider */ = { isa = PBXGroup; children = ( @@ -1630,6 +1644,7 @@ 7A06E0B32C707E13005731AC /* SettingsServiceProtocol.swift in Sources */, 7A06E0B52C707E2B005731AC /* SettingsService.swift in Sources */, 7AF6D21F2677C1680086EA64 /* Response.swift in Sources */, + 7AA515D02D9ABCC800EB3418 /* RecordPlayerService.swift in Sources */, 7A60D24D2C5A9D4900D13F7B /* LocationService.swift in Sources */, 7A60D24F2C5A9DA800D13F7B /* LocationServiceProtocol.swift in Sources */, 7A761C07267E8E7F0005F28F /* AnyEncodable.swift in Sources */, @@ -1647,6 +1662,7 @@ 7A60D2512C5A9E4200D13F7B /* GeocoderProtocol.swift in Sources */, 7AB4E4382D3D0C5C0006D052 /* VehiclesArchive.swift in Sources */, 7A64A21C2C19E87B00284124 /* OsagoDto.swift in Sources */, + 7AA515D22D9ABCE600EB3418 /* RecordPlayerServiceProtocol.swift in Sources */, 7A809F392D66755B00CF1B3C /* Error+Canceled.swift in Sources */, 7AF6D21D2677C1680086EA64 /* Osago.swift in Sources */, 7A1CF81629A42117007962DA /* Realm.swift in Sources */, diff --git a/AutoCat/SceneDelegate.swift b/AutoCat/SceneDelegate.swift index 012bc0a..ba7afb8 100644 --- a/AutoCat/SceneDelegate.swift +++ b/AutoCat/SceneDelegate.swift @@ -70,6 +70,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { settingsService: settingsService ) container.register(VehicleRecordServiceProtocol.self, instance: vehicleRecordService) + container.register(RecordPlayerServiceProtocol.self, instance: RecordPlayerService()) } func setupRootController(scene: UIScene, openReport number: String?) { diff --git a/AutoCat/Screens/RecordsScreen/AudioRecordView/AudioRecordViewModel.swift b/AutoCat/Screens/RecordsScreen/AudioRecordView/AudioRecordViewModel.swift index 624fd2c..de7fc00 100644 --- a/AutoCat/Screens/RecordsScreen/AudioRecordView/AudioRecordViewModel.swift +++ b/AutoCat/Screens/RecordsScreen/AudioRecordView/AudioRecordViewModel.swift @@ -18,7 +18,7 @@ struct AudioRecordViewModel: Identifiable { var date: String var onPlay: () -> Void - init(dto: AudioRecordDto, onPlay: @escaping () -> Void) { + init(dto: AudioRecordDto, isPlaying: Bool = false, onPlay: @escaping () -> Void) { self.id = dto.id self.duration = Formatters.time.string(from: dto.duration) @@ -26,7 +26,7 @@ struct AudioRecordViewModel: Identifiable { self.date = Formatters.short.string( from: Date(timeIntervalSince1970: dto.addedDate) ) - self.isPlaying = false + self.isPlaying = isPlaying self.onPlay = onPlay } } diff --git a/AutoCat/Screens/RecordsScreen/RecordsCoordinator.swift b/AutoCat/Screens/RecordsScreen/RecordsCoordinator.swift index b65eb96..72eb15a 100644 --- a/AutoCat/Screens/RecordsScreen/RecordsCoordinator.swift +++ b/AutoCat/Screens/RecordsScreen/RecordsCoordinator.swift @@ -20,7 +20,8 @@ final class RecordsCoordinator { let resolver = ServiceContainer.shared let viewModel = RecordsViewModel( recordService: resolver.resolve(VehicleRecordServiceProtocol.self), - storageService: resolver.resolve(StorageServiceProtocol.self) + storageService: resolver.resolve(StorageServiceProtocol.self), + recordPlayer: resolver.resolve(RecordPlayerServiceProtocol.self) ) let view = RecordsScreen(viewModel: viewModel) diff --git a/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift b/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift index d5bfb68..67aa6db 100644 --- a/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift +++ b/AutoCat/Screens/RecordsScreen/RecordsViewModel.swift @@ -15,24 +15,31 @@ final class RecordsViewModel: ACHudContainer { let recordService: VehicleRecordServiceProtocol let storageService: StorageServiceProtocol + let recordPlayer: RecordPlayerServiceProtocol var hud: ACHud? var showRecordingAlert: Bool = false var records: [AudioRecordDto] = [] + var playingRecord: AudioRecordDto? var recordModels: [AudioRecordViewModel] { - records.map { record in - AudioRecordViewModel(dto: record) { [weak self] in + return records.map { record in + AudioRecordViewModel( + dto: record, + isPlaying: record.id == playingRecord?.id + ) { [weak self] in self?.onPlayTapped(record: record) } } } init(recordService: VehicleRecordServiceProtocol, - storageService: StorageServiceProtocol) { + storageService: StorageServiceProtocol, + recordPlayer: RecordPlayerServiceProtocol) { self.recordService = recordService self.storageService = storageService + self.recordPlayer = recordPlayer } func onAppear() async { @@ -65,5 +72,17 @@ final class RecordsViewModel: ACHudContainer { } func onPlayTapped(record: AudioRecordDto) { + do { + playingRecord = record + try recordPlayer.play(record: record) { [weak self] dto, error in + self?.playingRecord = nil + if let error { + self?.hud = .error(error) + } + } + } catch { + playingRecord = nil + hud = .error(error) + } } } diff --git a/AutoCatCore/Services/RecordPlayerService/RecordPlayerService.swift b/AutoCatCore/Services/RecordPlayerService/RecordPlayerService.swift new file mode 100644 index 0000000..700c9fa --- /dev/null +++ b/AutoCatCore/Services/RecordPlayerService/RecordPlayerService.swift @@ -0,0 +1,80 @@ +// +// RecordPlayerService.swift +// AutoCatCore +// +// Created by Selim Mustafaev on 31.03.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import AVFoundation + +public final class RecordPlayerService: NSObject { + + var player: AVAudioPlayer? + var record: AudioRecordDto? + var onStop: ((AudioRecordDto, Error?) -> Void)? +} + +extension RecordPlayerService: RecordPlayerServiceProtocol { + + public func play(record: AudioRecordDto, onStop: ((AudioRecordDto, Error?) -> Void)?) throws { + + player?.stop() + + let url = try FileManager.default.url(for: record.path, in: Constants.audioRecordsFolder) + player = try AVAudioPlayer(contentsOf: url) + player?.delegate = self + + self.record = record + self.onStop = onStop + +// try AVAudioSession.sharedInstance().setCategory(.playback) +// try AVAudioSession.sharedInstance().setActive(true) + + print("==== playing started ====") + player?.play() + } + + public func pause() { + + player?.pause() + } + + public var currentPlayingId: TimeInterval? { + guard player?.isPlaying == true else { + return nil + } + + return record?.id + } +} + +extension RecordPlayerService: AVAudioPlayerDelegate { + + public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + guard let record else { + return + } + + print("==== playing stopped ====") + onStop?(record, nil) + reset() + } + + public func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: (any Error)?) { + guard let record else { + return + } + + print("==== playing error ====") + onStop?(record, error) + reset() + } + + func reset() { + + onStop = nil + player = nil + record = nil + } +} diff --git a/AutoCatCore/Services/RecordPlayerService/RecordPlayerServiceProtocol.swift b/AutoCatCore/Services/RecordPlayerService/RecordPlayerServiceProtocol.swift new file mode 100644 index 0000000..8dcdffa --- /dev/null +++ b/AutoCatCore/Services/RecordPlayerService/RecordPlayerServiceProtocol.swift @@ -0,0 +1,17 @@ +// +// RecordPlayerServiceProtocol.swift +// AutoCatCore +// +// Created by Selim Mustafaev on 31.03.2025. +// Copyright © 2025 Selim Mustafaev. All rights reserved. +// + +import Foundation + +public protocol RecordPlayerServiceProtocol { + + var currentPlayingId: TimeInterval? { get } + + func play(record: AudioRecordDto, onStop: ((AudioRecordDto, Error?) -> Void)?) throws + func pause() +} diff --git a/AutoCatCore/Services/VehicleRecordService/VehicleRecordService.swift b/AutoCatCore/Services/VehicleRecordService/VehicleRecordService.swift index 653f154..578098e 100644 --- a/AutoCatCore/Services/VehicleRecordService/VehicleRecordService.swift +++ b/AutoCatCore/Services/VehicleRecordService/VehicleRecordService.swift @@ -95,7 +95,7 @@ extension VehicleRecordService: VehicleRecordServiceProtocol { date = Date() let fileName = "recording-\(date.timeIntervalSince1970).m4a" - let url = try FileManager.default.url(for: fileName, in: "recordings") + let url = try FileManager.default.url(for: fileName, in: Constants.audioRecordsFolder) self.url = url try await recordService.startRecording(to: url) diff --git a/AutoCatCore/Utils/Constants.swift b/AutoCatCore/Utils/Constants.swift index d1e5abb..9dd283e 100644 --- a/AutoCatCore/Utils/Constants.swift +++ b/AutoCatCore/Utils/Constants.swift @@ -50,4 +50,6 @@ public enum Constants { public static let reportLinkTokenSecret = "#TheTruthIsOutThere" public static let reportLinkBaseURL = "https://auto.aliencat.pro/report.html" + + public static let audioRecordsFolder = "recordings" }