diff --git a/alsongDalsong/ASAudioKit/ASAudioAnalyzer.swift b/alsongDalsong/ASAudioKit/ASAudioAnalyzer.swift new file mode 100644 index 00000000..3d53ce43 --- /dev/null +++ b/alsongDalsong/ASAudioKit/ASAudioAnalyzer.swift @@ -0,0 +1,54 @@ +import AVFoundation +import Foundation + +public enum ASAudioAnalyzer { + public static func analyze(data: Data, samplesCount: Int) async throws -> [CGFloat] { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString + ".m4a") + try data.write(to: tempURL) + let file = try AVAudioFile(forReading: tempURL) + + guard + let format = AVAudioFormat( + commonFormat: .pcmFormatFloat32, + sampleRate: file.fileFormat.sampleRate, + channels: file.fileFormat.channelCount, + interleaved: false + ), + let buffer = AVAudioPCMBuffer(pcmFormat: format, frameCapacity: AVAudioFrameCount(file.length)) + else { + return [] + } + + try file.read(into: buffer) + guard let floatChannelData = buffer.floatChannelData else { + return [] + } + + let frameLength = Int(buffer.frameLength) + let samples = Array(UnsafeBufferPointer(start: floatChannelData[0], count: frameLength)) + var result = [CGFloat]() + let chunkedSamples = samples.chunked(into: samples.count / samplesCount) + + for chunk in chunkedSamples { + let squaredSum = chunk.reduce(0) { $0 + $1 * $1 } + let averagePower = squaredSum / Float(chunk.count) + let decibels = 10 * log10(max(averagePower, Float.ulpOfOne)) + + let newAmplitude = 1.8 * pow(10.0, decibels / 20.0) + let clampedAmplitude = min(max(CGFloat(newAmplitude), 0), 1) + result.append(clampedAmplitude) + } + + try? FileManager.default.removeItem(at: tempURL) + + return result + } +} + +extension Array { + func chunked(into size: Int) -> [[Element]] { + stride(from: 0, to: count, by: size).map { + Array(self[$0 ..< Swift.min($0 + size, count)]) + } + } +} diff --git a/alsongDalsong/ASAudioKit/ASAudioKit.xcodeproj/project.pbxproj b/alsongDalsong/ASAudioKit/ASAudioKit.xcodeproj/project.pbxproj index 17a8f041..35c03440 100644 --- a/alsongDalsong/ASAudioKit/ASAudioKit.xcodeproj/project.pbxproj +++ b/alsongDalsong/ASAudioKit/ASAudioKit.xcodeproj/project.pbxproj @@ -7,6 +7,8 @@ objects = { /* Begin PBXBuildFile section */ + 1B7EB01B2CFDA83B00B2BE2A /* ASAudioAnalyzer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7EB01A2CFDA83B00B2BE2A /* ASAudioAnalyzer.swift */; }; + 1B7EB01C2CFDA83B00B2BE2A /* ASAudioAnalyzer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B7EB01A2CFDA83B00B2BE2A /* ASAudioAnalyzer.swift */; }; 4E8907C22CE2489A00D5B547 /* ASAudioKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4E89071E2CE23D1B00D5B547 /* ASAudioKit.framework */; platformFilter = ios; }; 4EB1ED372CE88E500012FFBA /* ASAudioKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4E89071E2CE23D1B00D5B547 /* ASAudioKit.framework */; }; 4EB1ED382CE88E500012FFBA /* ASAudioKit.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 4E89071E2CE23D1B00D5B547 /* ASAudioKit.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -44,6 +46,7 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 1B7EB01A2CFDA83B00B2BE2A /* ASAudioAnalyzer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ASAudioAnalyzer.swift; sourceTree = ""; }; 4E89071E2CE23D1B00D5B547 /* ASAudioKit.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = ASAudioKit.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 4E8907BE2CE2489A00D5B547 /* ASAudioKitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ASAudioKitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 4EB1EC802CE7AA160012FFBA /* ASAudioDemo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = ASAudioDemo.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -121,6 +124,7 @@ 4E8907142CE23D1A00D5B547 = { isa = PBXGroup; children = ( + 1B7EB01A2CFDA83B00B2BE2A /* ASAudioAnalyzer.swift */, 4E8907AD2CE240D400D5B547 /* ASAudioKit */, 4E8907BF2CE2489A00D5B547 /* ASAudioKitTests */, 4EB1EC812CE7AA160012FFBA /* ASAudioDemo */, @@ -300,6 +304,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1B7EB01C2CFDA83B00B2BE2A /* ASAudioAnalyzer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -314,6 +319,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1B7EB01B2CFDA83B00B2BE2A /* ASAudioAnalyzer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/alsongDalsong/ASAudioKit/ASAudioKit/ASAudioPlayer/ASAudioPlayer.swift b/alsongDalsong/ASAudioKit/ASAudioKit/ASAudioPlayer/ASAudioPlayer.swift index 098012d8..d1d04779 100644 --- a/alsongDalsong/ASAudioKit/ASAudioKit/ASAudioPlayer/ASAudioPlayer.swift +++ b/alsongDalsong/ASAudioKit/ASAudioKit/ASAudioPlayer/ASAudioPlayer.swift @@ -13,7 +13,7 @@ public actor ASAudioPlayer: NSObject { public var onPlaybackFinished: (@Sendable () async -> Void)? /// 녹음파일을 재생하고 옵션에 따라 재생시간을 설정합니다. - public func startPlaying(data: Data, option: PlayType) { + public func startPlaying(data: Data, option: PlayType = .full) { configureAudioSession() do { audioPlayer = try AVAudioPlayer(data: data) diff --git a/alsongDalsong/ASEntity/ASEntity/Answer.swift b/alsongDalsong/ASEntity/ASEntity/Answer.swift index d161467d..2e85f98e 100644 --- a/alsongDalsong/ASEntity/ASEntity/Answer.swift +++ b/alsongDalsong/ASEntity/ASEntity/Answer.swift @@ -1,6 +1,6 @@ import Foundation -public struct Answer: Codable, Equatable { +public struct Answer: Codable, Equatable, Sendable, Hashable { public var player: Player? public var music: Music? public var playlist: Playlist? diff --git a/alsongDalsong/ASEntity/ASEntity/Music.swift b/alsongDalsong/ASEntity/ASEntity/Music.swift index d9070493..be1cb667 100644 --- a/alsongDalsong/ASEntity/ASEntity/Music.swift +++ b/alsongDalsong/ASEntity/ASEntity/Music.swift @@ -1,6 +1,6 @@ import Foundation -public struct Music: Codable, Equatable, Identifiable, Sendable { +public struct Music: Codable, Equatable, Identifiable, Sendable, Hashable { public var id: String? public var title: String? public var artist: String? diff --git a/alsongDalsong/ASEntity/ASEntity/Player.swift b/alsongDalsong/ASEntity/ASEntity/Player.swift index 4c8539c6..58b558ae 100644 --- a/alsongDalsong/ASEntity/ASEntity/Player.swift +++ b/alsongDalsong/ASEntity/ASEntity/Player.swift @@ -1,6 +1,6 @@ import Foundation -public struct Player: Codable, Equatable, Identifiable { +public struct Player: Codable, Equatable, Identifiable, Sendable, Hashable { public var id: String public var avatarUrl: URL? public var nickname: String? diff --git a/alsongDalsong/ASEntity/ASEntity/Playlist.swift b/alsongDalsong/ASEntity/ASEntity/Playlist.swift index 78fa5379..ec30cc00 100644 --- a/alsongDalsong/ASEntity/ASEntity/Playlist.swift +++ b/alsongDalsong/ASEntity/ASEntity/Playlist.swift @@ -1,6 +1,6 @@ import Foundation -public struct Playlist: Codable, Equatable { +public struct Playlist: Codable, Equatable, Sendable, Hashable { public var artworkUrl: URL? public var title: String? diff --git a/alsongDalsong/ASEntity/ASEntity/Record.swift b/alsongDalsong/ASEntity/ASEntity/Record.swift index c6225f81..8059a7aa 100644 --- a/alsongDalsong/ASEntity/ASEntity/Record.swift +++ b/alsongDalsong/ASEntity/ASEntity/Record.swift @@ -1,6 +1,6 @@ import Foundation -public struct Record: Codable, Equatable { +public struct Record: Codable, Equatable, Sendable, Hashable { public var player: Player? public var recordOrder: UInt8? public var fileUrl: URL? diff --git a/alsongDalsong/ASRepository/ASRepository/Protocols/RepositoryProtocols.swift b/alsongDalsong/ASRepository/ASRepository/Protocols/RepositoryProtocols.swift index 8cdae54b..89728571 100644 --- a/alsongDalsong/ASRepository/ASRepository/Protocols/RepositoryProtocols.swift +++ b/alsongDalsong/ASRepository/ASRepository/Protocols/RepositoryProtocols.swift @@ -48,7 +48,6 @@ public protocol SubmitsRepositoryProtocol { public protocol AvatarRepositoryProtocol { func getAvatarUrls() async throws -> [URL] - func getAvatarData(url: URL) async -> Data? } public protocol RoomActionRepositoryProtocol: Sendable { @@ -61,15 +60,14 @@ public protocol RoomActionRepositoryProtocol: Sendable { func resetGame() async throws -> Bool } -public protocol MusicRepositoryProtocol { - func getMusicData(url: URL) async -> Data? -} - public protocol GameStateRepositoryProtocol { func getGameState() -> AnyPublisher } public protocol HummingResultRepositoryProtocol { func getResult() -> AnyPublisher<[(answer: Answer, records: [ASEntity.Record], submit: Answer, recordOrder: UInt8)], Never> - func getRecordData(url: URL) -> Future +} + +public protocol DataDownloadRepositoryProtocol { + func downloadData(url: URL) async -> Data? } diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/AvatarRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/AvatarRepository.swift index fcaf211d..d9fc959c 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/AvatarRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/AvatarRepository.swift @@ -6,14 +6,11 @@ import ASRepositoryProtocol public final class AvatarRepository: AvatarRepositoryProtocol { // TODO: - Container로 주입 private let storageManager: ASFirebaseStorageProtocol - private let networkManager: ASNetworkManagerProtocol public init ( - storageManager: ASFirebaseStorageProtocol, - networkManager: ASNetworkManagerProtocol + storageManager: ASFirebaseStorageProtocol ) { self.storageManager = storageManager - self.networkManager = networkManager } public func getAvatarUrls() async throws -> [URL] { @@ -24,14 +21,4 @@ public final class AvatarRepository: AvatarRepositoryProtocol { throw error } } - - public func getAvatarData(url: URL) async -> Data? { - do { - guard let endpoint = ResourceEndpoint(url: url) else { return nil } - let data = try await self.networkManager.sendRequest(to: endpoint, type: .none, body: nil, option: .both) - return data - } catch { - return nil - } - } } diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/DataDownloadRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/DataDownloadRepository.swift new file mode 100644 index 00000000..779a6b69 --- /dev/null +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/DataDownloadRepository.swift @@ -0,0 +1,20 @@ +import ASNetworkKit +import ASRepositoryProtocol + +public final class DataDownloadRepository: DataDownloadRepositoryProtocol { + private var networkManager: ASNetworkManagerProtocol + + public init(networkManager: ASNetworkManagerProtocol) { + self.networkManager = networkManager + } + + public func downloadData(url: URL) async -> Data? { + guard let endpoint = ResourceEndpoint(url: url) else { return nil } + do { + let data = try await networkManager.sendRequest(to: endpoint, type: .none, body: nil, option: .both) + return data + } catch { + return nil + } + } +} diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/HummingResultRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/HummingResultRepository.swift index fd5bf857..2e090e13 100644 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/HummingResultRepository.swift +++ b/alsongDalsong/ASRepository/ASRepository/Repositories/HummingResultRepository.swift @@ -1,15 +1,15 @@ -import Foundation -import Combine import ASEntity import ASNetworkKit import ASRepositoryProtocol +import Combine +import Foundation public final class HummingResultRepository: HummingResultRepositoryProtocol { private var mainRepository: MainRepositoryProtocol private let storageManager: ASFirebaseStorageProtocol private let networkManager: ASNetworkManagerProtocol - - public init ( + + public init( storageManager: ASFirebaseStorageProtocol, networkManager: ASNetworkManagerProtocol, mainRepository: MainRepositoryProtocol @@ -18,8 +18,15 @@ public final class HummingResultRepository: HummingResultRepositoryProtocol { self.networkManager = networkManager self.mainRepository = mainRepository } - - public func getResult() -> AnyPublisher<[(answer: Answer, records: [ASEntity.Record], submit: Answer, recordOrder: UInt8)], Never> { + + public func getResult() -> + AnyPublisher<[( + answer: Answer, + records: [ASEntity.Record], + submit: Answer, + recordOrder: UInt8 + )], Never> + { Publishers.Zip4(mainRepository.answers, mainRepository.records, mainRepository.submits, mainRepository.recordOrder) .compactMap { answers, records, submits, recordOrder in answers?.map { answer in @@ -27,14 +34,14 @@ public final class HummingResultRepository: HummingResultRepositoryProtocol { from: records, count: answers?.count ?? 0) let relatedSubmit: Answer = self.getRelatedSubmit(for: answer, from: submits) - + return (answer: answer, records: relatedRecords, submit: relatedSubmit, recordOrder: recordOrder ?? 0) } } .receive(on: DispatchQueue.main) .eraseToAnyPublisher() } - + private func getRelatedRecords(for answer: Answer, from records: [ASEntity.Record]?, count: Int) -> [ASEntity.Record] { var filteredRecords: [ASEntity.Record] = [] @@ -42,7 +49,7 @@ public final class HummingResultRepository: HummingResultRepositoryProtocol { let tempCheck: Int = (((answer.player?.order ?? 0) + i) % count) if let filteredRecord = records?.first(where: { record in (tempCheck == record.player?.order) && - (record.recordOrder ?? 0 == i) + (record.recordOrder ?? 0 == i) }) { filteredRecords.append(filteredRecord) } @@ -53,43 +60,29 @@ public final class HummingResultRepository: HummingResultRepositoryProtocol { private func getRelatedSubmit(for answer: Answer, from submits: [Answer]?) -> Answer { let temp = (answer.player?.order ?? 0) - 1 + (submits?.count ?? 0) - let targetOrder = temp % (submits?.count == 0 ? 1 : submits?.count ?? 1) - + let targetOrder = temp % (submits?.isEmpty == true ? 1 : submits?.count ?? 1) + let submit = submits?.first(where: { submit in targetOrder == submit.player?.order }) - - //TODO: nil 값에 대한 처리 필요 + + // TODO: nil 값에 대한 처리 필요 return submit ?? Answer.answerStub1 } - - public func getRecordData(url: URL) -> Future { - Future { promise in - Task { - do { - guard let endpoint = ResourceEndpoint(url: url) else { return promise(.failure(ASNetworkErrors.urlError)) } - let data = try await self.networkManager.sendRequest(to: endpoint, type: .json, body: nil, option: .both) - promise(.success(data)) - } catch { - promise(.failure(error)) - } - } - } - } } public final class LocalHummingResultRepository: HummingResultRepositoryProtocol { private let storageManager: ASFirebaseStorageProtocol private let networkManager: ASNetworkManagerProtocol - - public init ( + + public init( storageManager: ASFirebaseStorageProtocol, networkManager: ASNetworkManagerProtocol ) { self.storageManager = storageManager self.networkManager = networkManager } - + public func getResult() -> AnyPublisher<[(answer: Answer, records: [ASEntity.Record], submit: Answer, recordOrder: UInt8)], Never> { let tempAnswers = [Answer.answerStub1, Answer.answerStub2, Answer.answerStub3, Answer.answerStub4] let tempRecords = [ @@ -99,17 +92,17 @@ public final class LocalHummingResultRepository: HummingResultRepositoryProtocol ASEntity.Record.recordStub4_1, ASEntity.Record.recordStub4_2, ASEntity.Record.recordStub4_3, ] let tempSubmits = [Answer.answerStub1, Answer.answerStub2, Answer.answerStub3, Answer.answerStub4] - + return Just(tempAnswers.map { answer in let relatedRecords = getRelatedRecords(for: answer, from: tempRecords, count: tempAnswers.count) let relatedSubmit = getRelatedSubmit(for: answer, from: tempSubmits) - + return (answer: answer, records: relatedRecords, submit: relatedSubmit, recordOrder: 0) }) .receive(on: DispatchQueue.main) .eraseToAnyPublisher() } - + private func getRelatedRecords(for answer: Answer, from records: [ASEntity.Record]?, count: Int) -> [ASEntity.Record] { var filteredRecords: [ASEntity.Record] = [] @@ -117,12 +110,12 @@ public final class LocalHummingResultRepository: HummingResultRepositoryProtocol let tempCheck: Int = (((answer.player?.order ?? 0) + i) % count) if let filteredRecord = records?.first(where: { record in (tempCheck == record.player?.order) && - (record.recordOrder ?? 0 == i) + (record.recordOrder ?? 0 == i) }) { filteredRecords.append(filteredRecord) } } - + return filteredRecords } @@ -136,17 +129,13 @@ public final class LocalHummingResultRepository: HummingResultRepositoryProtocol return submit ?? Answer.answerStub1 } - public func getRecordData(url: URL) -> Future { - Future { promise in - Task { - do { - guard let endpoint = ResourceEndpoint(url: url) else { return promise(.failure(ASNetworkErrors.urlError)) } - let data = try await self.networkManager.sendRequest(to: endpoint, type: .json, body: nil, option: .both) - promise(.success(data)) - } catch { - promise(.failure(error)) - } - } + public func getRecordData(url: URL) async -> Data? { + do { + guard let endpoint = ResourceEndpoint(url: url) else { return nil } + let data = try await self.networkManager.sendRequest(to: endpoint, type: .json, body: nil, option: .both) + return data + } catch { + return nil } } } diff --git a/alsongDalsong/ASRepository/ASRepository/Repositories/MusicRepository.swift b/alsongDalsong/ASRepository/ASRepository/Repositories/MusicRepository.swift deleted file mode 100644 index 7592a0ca..00000000 --- a/alsongDalsong/ASRepository/ASRepository/Repositories/MusicRepository.swift +++ /dev/null @@ -1,28 +0,0 @@ -import ASNetworkKit -import Combine -import Foundation -import ASRepositoryProtocol - -public final class MusicRepository: MusicRepositoryProtocol { - // TODO: - Container로 주입 - private let firebaseManager: ASFirebaseStorageProtocol - private let networkManager: ASNetworkManagerProtocol - - public init( - firebaseManager: ASFirebaseStorageProtocol, - networkManager: ASNetworkManagerProtocol - ) { - self.firebaseManager = firebaseManager - self.networkManager = networkManager - } - - public func getMusicData(url: URL) async -> Data? { - do { - guard let endpoint = ResourceEndpoint(url: url) else { return nil } - let data = try await self.networkManager.sendRequest(to: endpoint, type: .json, body: nil, option: .both) - return data - } catch { - return nil - } - } -} diff --git a/alsongDalsong/ASRepository/ASRepository/RepsotioryAssembly.swift b/alsongDalsong/ASRepository/ASRepository/RepsotioryAssembly.swift index 8436cf5a..4cceecb0 100644 --- a/alsongDalsong/ASRepository/ASRepository/RepsotioryAssembly.swift +++ b/alsongDalsong/ASRepository/ASRepository/RepsotioryAssembly.swift @@ -4,7 +4,7 @@ import ASRepositoryProtocol public struct RepsotioryAssembly: Assembly { public init() {} - + public func assemble(container: Registerable) { container.registerSingleton(MainRepositoryProtocol.self) { r in let databaseManager = r.resolve(ASFirebaseDatabaseProtocol.self) @@ -14,7 +14,7 @@ public struct RepsotioryAssembly: Assembly { networkManager: networkManager ) } - + container.register(AnswersRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) let networkManager = r.resolve(ASNetworkManagerProtocol.self) @@ -23,32 +23,21 @@ public struct RepsotioryAssembly: Assembly { networkManager: networkManager ) } - + container.register(AvatarRepositoryProtocol.self) { r in let storageManager = r.resolve(ASFirebaseStorageProtocol.self) - let networkManager = r.resolve(ASNetworkManagerProtocol.self) return AvatarRepository( - storageManager: storageManager, - networkManager: networkManager + storageManager: storageManager ) } - + container.register(GameStatusRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) return GameStatusRepository( mainRepository: mainRepository ) } - - container.register(MusicRepositoryProtocol.self) { r in - let firebaseManager = r.resolve(ASFirebaseStorageProtocol.self) - let networkManager = r.resolve(ASNetworkManagerProtocol.self) - return MusicRepository( - firebaseManager: firebaseManager, - networkManager: networkManager - ) - } - + container.register(PlayersRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) let firebaseAuthManager = r.resolve(ASFirebaseAuthProtocol.self) @@ -57,14 +46,14 @@ public struct RepsotioryAssembly: Assembly { firebaseAuthManager: firebaseAuthManager ) } - + container.register(RecordsRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) return RecordsRepository( mainRepository: mainRepository ) } - + container.register(RoomActionRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) let authManager = r.resolve(ASFirebaseAuthProtocol.self) @@ -75,21 +64,21 @@ public struct RepsotioryAssembly: Assembly { networkManager: networkManager ) } - + container.register(RoomInfoRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) return RoomInfoRepository( mainRepository: mainRepository ) } - + container.register(SelectedRecordsRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) return SelectedRecordsRepository( mainRepository: mainRepository ) } - + container.register(SubmitsRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) let networkManager = r.resolve(ASNetworkManagerProtocol.self) @@ -98,12 +87,12 @@ public struct RepsotioryAssembly: Assembly { networkManager: networkManager ) } - + container.register(GameStateRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) return GameStateRepository(mainRepository: mainRepository) } - + container.register(HummingResultRepositoryProtocol.self) { r in let mainRepository = r.resolve(MainRepositoryProtocol.self) let storageManager = r.resolve(ASFirebaseStorageProtocol.self) @@ -114,5 +103,12 @@ public struct RepsotioryAssembly: Assembly { mainRepository: mainRepository ) } + + container.register(DataDownloadRepositoryProtocol.self) { r in + let networkManager = r.resolve(ASNetworkManagerProtocol.self) + return DataDownloadRepository( + networkManager: networkManager + ) + } } } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/asLightGray.colorset/Contents.json b/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/asLightGray.colorset/Contents.json index 7f37a1d4..98ce6157 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/asLightGray.colorset/Contents.json +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/asLightGray.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.945", - "green" : "0.941", - "red" : "0.925" + "blue" : "0xF6", + "green" : "0xF4", + "red" : "0xF2" } }, "idiom" : "universal" diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/mojojojo.imageset/Contents.json b/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/mojojojo.imageset/Contents.json deleted file mode 100644 index 33e0c4ae..00000000 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/mojojojo.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "mojojojo.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/mojojojo.imageset/mojojojo.png b/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/mojojojo.imageset/mojojojo.png deleted file mode 100644 index eb2ee19f..00000000 Binary files a/alsongDalsong/alsongDalsong/alsongDalsong/Resources/Assets.xcassets/mojojojo.imageset/mojojojo.png and /dev/null differ diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SpeechBubbleCell.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SpeechBubbleCell.swift index c756213c..103c8463 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SpeechBubbleCell.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SpeechBubbleCell.swift @@ -1,11 +1,20 @@ -import SwiftUI -import UIKit import ASEntity import Combine +import SwiftUI +import UIKit enum MessageType { - case music(Music) - case record + case music(MappedAnswer) + case record(MappedRecord) + + var bubbleHeight: CGFloat { + switch self { + case .music: + return 90 + case .record: + return 64 + } + } } enum MessageAlignment { @@ -14,132 +23,170 @@ enum MessageAlignment { } struct SpeechBubbleCell: View { - let alignment: MessageAlignment + let row: Int let messageType: MessageType - let avatarImagePublisher: (URL?) async -> Data? - let avatarURL: URL - let artworkImagePublisher: (URL?) async -> Data? - let artworkURL: URL? - let name: String - @State private var isVisible = false - + private var alignment: MessageAlignment { + row.isMultiple(of: 2) ? .left : .right + } + + private var playerInfo: PlayerInfo { + switch messageType { + case let .music(music): return music + case let .record(record): return record + } + } + var body: some View { - HStack { - if alignment == .left { - ProfileView(imagePublisher: avatarImagePublisher, - name: name, - isHost: false, imageUrl: avatarURL) - .padding(.trailing, 10) - } - if isVisible { + if alignment == .left { + HStack(spacing: 12) { + avatarView(info: playerInfo) speechBubble - .transition(.move(edge: alignment == .left ? .leading : .trailing)) - .animation(.easeInOut(duration: 1.0), value: isVisible) - } - if alignment == .right { - ProfileView(imagePublisher: avatarImagePublisher, - name: name, - isHost: false, imageUrl: avatarURL) - .padding(.leading, 10) } } - .onAppear { - withAnimation { - isVisible = true + if alignment == .right { + HStack(spacing: 12) { + speechBubble + avatarView(info: playerInfo) } } } - + @ViewBuilder private var speechBubble: some View { - var height: CGFloat { - switch messageType { - case .music(_): - return 90 - case .record: - return 64 - } - } - ZStack { - BubbleShape(alignment: alignment) - .frame(width: 235, height: height + 5) - .foregroundStyle(Color.asShadow) - .offset(x: 5, y: 5) - - BubbleShape(alignment: alignment) - .frame(width: 230, height: height) - .foregroundStyle(.white) - .overlay( - BubbleShape(alignment: alignment) - .stroke(.black, lineWidth: 9) + contentView + .padding(.bottom, 8) + .padding(.leading, alignment == .left ? 36 : 0) + .frame(width: 230, height: messageType.bubbleHeight) + .bubbleStyle( + alignment: alignment, + fillColor: .asSystem, + borderColor: .black, + lineWidth: 5 ) - - ZStack(alignment: .leading) { - BubbleShape(alignment: alignment) - .frame(width: 230, height: height) - .foregroundStyle(.asSystem) - - switch messageType { - case .music(let music): - HStack { - AsyncImageView(imagePublisher: artworkImagePublisher, url: artworkURL) - .frame(width: 60, height: 60) - .clipShape(RoundedRectangle(cornerRadius: 6)) - - VStack(alignment: .leading) { - Text(music.title ?? "") - .font(.doHyeon(size: 20)) - .foregroundStyle(.asBlack) - .lineLimit(1) - - Text(music.artist ?? "") - .font(.doHyeon(size: 20)) - .foregroundStyle(.gray) - .lineLimit(1) - } - Spacer() - } - .frame(width: 220) - .offset(x: alignment == .left ? 30 : 5, y: -4) - case .record: - HStack { - WaveFormViewWrapper() + } + } + + @ViewBuilder + private var contentView: some View { + switch messageType { + case let .music(music): + HStack { + artworkView(music) + .frame(width: 60, height: 60) + .clipShape(RoundedRectangle(cornerRadius: 6)) + + VStack(alignment: .leading) { + Text(music.title) + .foregroundStyle(.asBlack) + + Text(music.artist) + .foregroundStyle(.gray) } - .frame(width: 215) - .offset(x: alignment == .left ? 22 : -7, y: -4) + .frame(width: 130) + .font(.custom("Dohyeon-Regular", size: 20)) + .lineLimit(1) + Spacer() } - } + case let .record(record): + HStack { + WaveFormWrapper(columns: record.recordAmplitudes, sampleCount: 24, circleColor: .asBlack, highlightColor: .asGreen) + .frame(width: 200) + Spacer() + } + } + } + + @ViewBuilder + private func avatarView(info: PlayerInfo) -> some View { + VStack { + Image(uiImage: UIImage(data: info.playerAvatarData) ?? UIImage()) + .resizable() + .background(Color.asMint) + .aspectRatio(contentMode: .fill) + .frame(width: 75, height: 75) + .clipShape(Circle()) + .overlay( + Circle().stroke(Color.white, lineWidth: 5) + ) + .padding(.bottom, 4) + Text(info.playerName) + .font(.doHyeon(size: 16)) + .multilineTextAlignment(.center) + .lineLimit(2) } + .frame(width: 75) + } + + @ViewBuilder + private func artworkView(_ music: MappedAnswer) -> some View { + Image(uiImage: UIImage(data: music.artworkData) ?? UIImage()) + .resizable() + .aspectRatio(contentMode: .fill) } } struct BubbleShape: Shape { let alignment: MessageAlignment - + func path(in rect: CGRect) -> Path { var path = Path() - if alignment == .right { - path.move(to: CGPoint(x: rect.maxX - 40, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.maxX - 20, y: rect.minY + 12)) - path.addLine(to: CGPoint(x: rect.maxX - 40, y: rect.minY)) - path.addRoundedRect(in: CGRect(x: rect.minX - 10, - y: rect.minY, - width: rect.width - 10, - height: rect.height - 10), cornerSize: CGSize(width: 12, height: 12)) + addSpeechTailRight(&path, in: rect) + addRoundedBody(&path, in: rect, isRight: true) } else { - path.addRoundedRect(in: CGRect(x: rect.minX + 20, - y: rect.minY, - width: rect.width - 10, - height: rect.height - 10), cornerSize: CGSize(width: 12, height: 12)) - path.move(to: CGPoint(x: rect.minX, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.minX + 40, y: rect.minY)) - path.addLine(to: CGPoint(x: rect.minX + 20, y: rect.minY + 12)) - path.addLine(to: CGPoint(x: rect.minX, y: rect.minY)) + addRoundedBody(&path, in: rect, isRight: false) + addSpeechTailLeft(&path, in: rect) } path.closeSubpath() return path } + + private func addSpeechTailRight(_ path: inout Path, in rect: CGRect) { + path.move(to: CGPoint(x: rect.maxX - 40, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX - 8, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.maxX - 20, y: rect.minY + 16)) + path.addLine(to: CGPoint(x: rect.maxX - 40, y: rect.minY)) + } + + private func addSpeechTailLeft(_ path: inout Path, in rect: CGRect) { + path.move(to: CGPoint(x: rect.minX + 8, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX + 40, y: rect.minY)) + path.addLine(to: CGPoint(x: rect.minX + 20, y: rect.minY + 16)) + path.addLine(to: CGPoint(x: rect.minX + 8, y: rect.minY)) + } + + private func addRoundedBody(_ path: inout Path, in rect: CGRect, isRight: Bool) { + let xOffset: CGFloat = isRight ? -10 : 20 + path.addRoundedRect( + in: CGRect(x: rect.minX + xOffset, y: rect.minY, width: rect.width - 10, height: rect.height - 10), + cornerSize: CGSize(width: 12, height: 12) + ) + } +} + +struct BubbleBackgroundModifier: ViewModifier { + let alignment: MessageAlignment + let fillColor: Color + let borderColor: Color + let lineWidth: CGFloat + + func body(content: Content) -> some View { + content + .background( + ZStack { + BubbleShape(alignment: alignment) + .stroke(borderColor, lineWidth: lineWidth) + BubbleShape(alignment: alignment) + .fill(fillColor) + .shadow(color: .asShadow, radius: 0, x: 6, y: 6) + } + ) + } +} + +extension View { + func bubbleStyle(alignment: MessageAlignment, fillColor: Color, borderColor: Color, lineWidth: CGFloat) -> some View { + modifier(BubbleBackgroundModifier(alignment: alignment, fillColor: fillColor, borderColor: borderColor, lineWidth: lineWidth)) + } } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SwiftUIComponents/ModeView.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SwiftUIComponents/ModeView.swift index d76fb810..e3cda738 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SwiftUIComponents/ModeView.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SwiftUIComponents/ModeView.swift @@ -22,8 +22,9 @@ struct ModeView: View { .clipShape(RoundedRectangle(cornerRadius: 8)) .padding() Text(modeInfo.description) - .font(.doHyeon(size: 16)) + .font(.doHyeon(size: 20)) .padding(.horizontal) + .minimumScaleFactor(0.01) Spacer() } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SwiftUIComponents/ProfileView.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SwiftUIComponents/ProfileView.swift index 0f0abc03..d480d0b0 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SwiftUIComponents/ProfileView.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/SwiftUIComponents/ProfileView.swift @@ -16,7 +16,7 @@ struct AsyncImageView: View { } else { Image(systemName: "person.circle.fill") .resizable() - .frame(width: 75, height: 75) + .aspectRatio(contentMode: .fill) .clipShape(Circle()) .overlay(Circle().stroke(Color.white, lineWidth: 5)) } @@ -49,21 +49,19 @@ struct ProfileView: View { .offset(y: -20) : nil } + .padding(.bottom, 4) if let name { Text(name) .font(.doHyeon(size: 16)) .multilineTextAlignment(.center) - .frame(maxWidth: 75, maxHeight: 32.0) + .lineLimit(2) } else { Text("비어 있음") .font(.doHyeon(size: 16)) .multilineTextAlignment(.center) - .frame(maxWidth: 75, maxHeight: 32.0) + .lineLimit(2) } } + .frame(width: 75) } } - -#Preview { -// ProfileView(imagePublisher: Just(Data()).setFailureType(to: Error.self).eraseToAnyPublisher(), name: "틀틀보", isHost: true) -} diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/ASAvatarCircleView.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/ASAvatarCircleView.swift index 49cae622..941a0237 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/ASAvatarCircleView.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/ASAvatarCircleView.swift @@ -41,30 +41,3 @@ final class ASAvatarCircleView: UIView { imageView.image = UIImage(data: imageData) } } - -//struct ASAvatarCircleViewWrapper: UIViewRepresentable { -// let backgroundColor: UIColor = UIColor.asMint -// @Binding var imageURL: URL? -// -// // MARK: - UIViewRepresentable Methods -// func makeUIView(context: Context) -> ASAvatarCircleView { -// let avatarView = ASAvatarCircleView(backgroundColor: backgroundColor) -// if let imageURL = imageURL { -// avatarView.setImage(imageURL: imageURL) -// } else { -// if let imagePath = Bundle.main.path(forResource: "mojojojo", ofType: "png") { -// let imageURL = URL(fileURLWithPath: imagePath) -// avatarView.setImage(imageURL: imageURL) -// } -// } -// return avatarView -// } -// -// func updateUIView(_ uiView: ASAvatarCircleView, context: Context) { -// if let imageURL = imageURL { -// uiView.setImage(imageURL: imageURL) -// } else { -// //TODO: 이미지 URL 받아서 setImage 호출 -// } -// } -//} diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/AudioVisualizerView.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/AudioVisualizerView.swift deleted file mode 100644 index 206115f3..00000000 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/AudioVisualizerView.swift +++ /dev/null @@ -1,199 +0,0 @@ -import Combine -import UIKit -import SwiftUI - -final class AudioVisualizerView: UIView { - private var playButton = UIButton() - private var waveFormView = WaveFormView() - private var customBackgroundColor: UIColor = .asMint - private var cancellables = Set() - var onPlayButtonTapped: ((_ isPlaying: Bool) -> Void)? - - init() { - super.init(frame: .zero) - setupButton() - addSubViews() - setupLayout() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setupButton() - addSubViews() - setupLayout() - } - - override func layoutSubviews() { - super.layoutSubviews() - setupView() - } - - func changeBackgroundColor(color: UIColor) { - customBackgroundColor = color - } - - private func setupButton() { - let config = UIImage.SymbolConfiguration(pointSize: 24, weight: .regular) - let playImage = UIImage(systemName: "play.fill", withConfiguration: config) - let stopImage = UIImage(systemName: "stop.fill", withConfiguration: config) - playButton.setImage(playImage, for: .normal) - playButton.setImage(stopImage, for: .selected) - playButton.tintColor = .white - playButton.adjustsImageWhenHighlighted = false - } - - private func setupView() { - layer.cornerRadius = 12 - layer.backgroundColor = customBackgroundColor.cgColor - } - - private func addSubViews() { - addSubview(playButton) - playButton.translatesAutoresizingMaskIntoConstraints = false - addSubview(waveFormView) - waveFormView.translatesAutoresizingMaskIntoConstraints = false - } - - private func setupLayout() { - NSLayoutConstraint.activate([ - playButton.topAnchor.constraint(equalTo: topAnchor, constant: 16), - playButton.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -16), - playButton.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12), - playButton.trailingAnchor.constraint(equalTo: waveFormView.leadingAnchor, constant: -12), - - waveFormView.topAnchor.constraint(equalTo: topAnchor, constant: 8), - waveFormView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), - waveFormView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12), - ]) - } - - func bind( - to dataSource: Published.Publisher - ) { - dataSource - .receive(on: DispatchQueue.main) - .sink { [weak self] amplitude in - self?.updateWaveForm(amplitude: CGFloat(amplitude)) - } - .store(in: &cancellables) - } - - func updateWaveForm(amplitude: CGFloat) { - waveFormView.updateVisualizerView(with: amplitude) - } - - func stopWaveForm() { - waveFormView.removeVisualizerCircles() - } -} - -final class WaveFormView: UIView { - var columnWidth: CGFloat? - var columns: [CAShapeLayer] = [] - var amplitudesHistory: [CGFloat] = [] - let numOfColumns: Int - - override class func awakeFromNib() { - super.awakeFromNib() - } - - init(numOfColumns: Int = 43) { - self.numOfColumns = numOfColumns - super.init(frame: .zero) - } - - required init?(coder: NSCoder) { - numOfColumns = 43 - super.init(coder: coder) - } - - override func layoutSubviews() { - super.layoutSubviews() - drawVisualizerCircles() - } - - /// 파형의 기본 틀을 그립니다. - func drawVisualizerCircles() { - amplitudesHistory = Array(repeating: 0, count: numOfColumns) - let diameter = bounds.width / CGFloat(2 * numOfColumns + 1) - columnWidth = diameter - let startingPointY = bounds.midY - diameter / 2 - var startingPointX = bounds.minX + diameter - - for _ in 0 ..< numOfColumns { - let circleOrigin = CGPoint(x: startingPointX, y: startingPointY) - let circleSize = CGSize(width: diameter, height: diameter) - let circle = UIBezierPath(roundedRect: CGRect(origin: circleOrigin, size: circleSize), cornerRadius: diameter / 2) - - let circleLayer = CAShapeLayer() - circleLayer.path = circle.cgPath - circleLayer.fillColor = UIColor.white.cgColor - - layer.addSublayer(circleLayer) - columns.append(circleLayer) - startingPointX += 2 * diameter - } - } - - /// 그려진 파형을 모두 삭제합니다. - fileprivate func removeVisualizerCircles() { - for column in columns { - column.removeFromSuperlayer() - } - - columns.removeAll() - } - - private func computeNewPath(for layer: CAShapeLayer, with amplitude: CGFloat) -> CGPath { - let width = columnWidth ?? 8.0 - let maxHeightGain = bounds.height - 3 * width - let heightGain = maxHeightGain * amplitude - let newHeight = width + heightGain - let newOrigin = CGPoint(x: layer.path?.boundingBox.origin.x ?? 0, - y: (layer.superlayer?.bounds.midY ?? 0) - (newHeight / 2)) - let newSize = CGSize(width: width, height: newHeight) - - return UIBezierPath(roundedRect: CGRect(origin: newOrigin, size: newSize), cornerRadius: width / 2).cgPath - } - - /// 오른쪽에서 파형이 시작 - fileprivate func updateVisualizerView(with amplitude: CGFloat) { - guard columns.count == numOfColumns else { return } - amplitudesHistory.append(amplitude) - amplitudesHistory.removeFirst() - for i in 0 ..< columns.count { - columns[i].path = computeNewPath(for: columns[i], with: amplitudesHistory[i]) - if amplitudesHistory[i] != 0 { - columns[i].fillColor = UIColor.white.cgColor - } - } - } - - /// 왼쪽에서 파형이 시작 - fileprivate func reverseUpdateVisualizerView(with amplitude: CGFloat) { - guard columns.count == numOfColumns else { return } - amplitudesHistory.insert(amplitude, at: 0) - amplitudesHistory.removeLast() - - for i in 0 ..< columns.count { - columns[i].path = computeNewPath(for: columns[i], with: amplitudesHistory[i]) - if amplitudesHistory[i] != 0 { - columns[i].fillColor = UIColor.white.cgColor - } - } - } -} - -struct WaveFormViewWrapper: UIViewRepresentable { - //@Binding var amplitude: Float - - func makeUIView(context: Context) -> WaveFormView { - let view = WaveFormView() - - return view - } - - func updateUIView(_ uiView: WaveFormView, context: Context) { - //uiView.updateVisualizerView(with: CGFloat(amplitude)) - } -} diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/CornerImageView.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/CornerImageView.swift index 72213b9e..1f765beb 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/CornerImageView.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/CornerImageView.swift @@ -23,8 +23,8 @@ final class GuideIconView: UIView { addSubview(imageView) NSLayoutConstraint.activate([ - imageView.widthAnchor.constraint(equalToConstant: 24), - imageView.heightAnchor.constraint(equalToConstant: 24), + imageView.widthAnchor.constraint(equalToConstant: 18), + imageView.heightAnchor.constraint(equalToConstant: 18), imageView.centerXAnchor.constraint(equalTo: centerXAnchor), imageView.centerYAnchor.constraint(equalTo: centerYAnchor) ]) @@ -55,19 +55,21 @@ final class GuideIconView: UIView { guard animationCount < times else { return } animationCount += 1 - transform = CGAffineTransform.identity transform = CGAffineTransform.identity UIView.animate( - withDuration: 1.5, + withDuration: 0.4, delay: 0, - usingSpringWithDamping: 0.5, + usingSpringWithDamping: 1.0, initialSpringVelocity: 1.0, - options: .curveEaseInOut, + options: .curveLinear, animations: { - self.transform = CGAffineTransform(scaleX: 1.2, y: 1.2) + self.transform = CGAffineTransform(scaleX: 1.3, y: 1.3) }, completion: { _ in - UIView.animate(withDuration: 0.2) { + UIView.animate( + withDuration: 0.4, + delay: 0.1 + ) { self.transform = CGAffineTransform.identity } completion: { [weak self] _ in self?.animate(times: times) @@ -76,7 +78,7 @@ final class GuideIconView: UIView { ) } - func animateBounces(times: Int = 5) { + func animateBounces(times: Int = 10) { animationCount = 0 animate(times: times) } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/WaveForm.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/WaveForm.swift new file mode 100644 index 00000000..63268891 --- /dev/null +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/WaveForm.swift @@ -0,0 +1,111 @@ +import Foundation +import UIKit + +final class WaveForm: UIView { + private var columnWidth: CGFloat? + private var columns: [CAShapeLayer] = [] + private let numOfColumns: Int + private var count: Int = 0 + private var circleColor: UIColor + private var highlightColor: UIColor + + override class func awakeFromNib() { + super.awakeFromNib() + } + + init(numOfColumns: Int = 48, circleColor: UIColor = .white, highlightColor: UIColor = .black) { + self.numOfColumns = numOfColumns + self.circleColor = circleColor + self.highlightColor = highlightColor + super.init(frame: .zero) + } + + required init?(coder: NSCoder) { + numOfColumns = 48 + circleColor = .white + highlightColor = .black + super.init(coder: coder) + } + + override func layoutSubviews() { + super.layoutSubviews() + if columns.isEmpty { + drawVisualizerCircles() + } + } + + func resetView() { + removeVisualizerCircles() + } + + func drawColumns(with amplitudes: [CGFloat]) { + for amplitude in amplitudes { + updateAmplitude(with: amplitude) + } + } + + private func drawVisualizerCircles() { + let diameter = bounds.width / CGFloat(2 * numOfColumns + 1) + columnWidth = diameter + let startingPointY = bounds.midY - diameter / 2 + var startingPointX = bounds.minX + diameter + + for _ in 0 ..< numOfColumns { + let circleOrigin = CGPoint(x: startingPointX, y: startingPointY) + let circleSize = CGSize(width: diameter, height: diameter) + let circle = UIBezierPath(roundedRect: CGRect(origin: circleOrigin, size: circleSize), cornerRadius: diameter / 2) + + let circleLayer = CAShapeLayer() + circleLayer.path = circle.cgPath + circleLayer.fillColor = circleColor.cgColor + + layer.addSublayer(circleLayer) + columns.append(circleLayer) + startingPointX += 2 * diameter + } + } + + private func removeVisualizerCircles() { + for column in columns { + column.removeFromSuperlayer() + } + count = 0 + columns.removeAll() + } + + func resetColor() { + for column in columns { + column.fillColor = circleColor.cgColor + } + } + + func updatePlayingIndex(_ index: Int) { + columns[index].fillColor = highlightColor.cgColor + } + + func updateAmplitude(with amplitude: CGFloat, direction: Direction = .LTR) { + guard columns.count == numOfColumns, count < numOfColumns else { return } + let index = direction == .LTR ? count : numOfColumns - count - 1 + columns[index].path = computeNewPath(for: columns[index], with: amplitude) + columns[index].fillColor = circleColor.cgColor + count += 1 + } + + private func computeNewPath(for layer: CAShapeLayer, with amplitude: CGFloat) -> CGPath { + let width = columnWidth ?? 8.0 + let maxHeightGain = bounds.height - 3 * width + let heightGain = maxHeightGain * amplitude + let newHeight = width + heightGain + let newOrigin = CGPoint(x: layer.path?.boundingBox.origin.x ?? 0, + y: (layer.superlayer?.bounds.midY ?? 0) - (newHeight / 2)) + let newSize = CGSize(width: width, height: newHeight) + + return UIBezierPath(roundedRect: CGRect(origin: newOrigin, size: newSize), cornerRadius: width / 2).cgPath + } +} + +extension WaveForm { + enum Direction { + case RTL, LTR + } +} diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/WaveFormWrapper.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/WaveFormWrapper.swift new file mode 100644 index 00000000..47575eb8 --- /dev/null +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Components/UIKitComponents/WaveFormWrapper.swift @@ -0,0 +1,19 @@ +import SwiftUI + +struct WaveFormWrapper: UIViewRepresentable { + let columns: [CGFloat] + let sampleCount: Int + let circleColor: UIColor + let highlightColor: UIColor + + func makeUIView(context: Context) -> WaveForm { + let view = WaveForm(numOfColumns: sampleCount, circleColor: circleColor, highlightColor: highlightColor) + return view + } + + func updateUIView(_ uiView: WaveForm, context: Context) { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + uiView.drawColumns(with: columns) + } + } +} diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/SceneDelegate.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/SceneDelegate.swift index 33a738fc..87733ead 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/SceneDelegate.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/SceneDelegate.swift @@ -32,7 +32,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { let onboardingVM = OnboardingViewModel( avatarRepository: DIContainer.shared.resolve(AvatarRepositoryProtocol.self), - roomActionRepository: DIContainer.shared.resolve(RoomActionRepositoryProtocol.self) + roomActionRepository: DIContainer.shared.resolve(RoomActionRepositoryProtocol.self), + dataDownloadRepository: DIContainer.shared.resolve(DataDownloadRepositoryProtocol.self) ) let onboardingVC = OnboardingViewController(viewmodel: onboardingVM, inviteCode: inviteCode) let navigationController = UINavigationController(rootViewController: onboardingVC) diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Utils/AudioHelper.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Utils/AudioHelper.swift index f745c344..a1becf9a 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Utils/AudioHelper.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Utils/AudioHelper.swift @@ -66,6 +66,18 @@ actor AudioHelper { cancellable?.cancel() cancellable = nil } + + func analyze(with data: Data) async -> [CGFloat] { + do { + Logger.debug("파형분석 시작") + let columns = try await ASAudioAnalyzer.analyze(data: data, samplesCount: 24) + Logger.debug("파형분석 완료") + Logger.debug(columns) + return columns + } catch { + return [] + } + } } // MARK: - Play Audio @@ -80,7 +92,8 @@ extension AudioHelper { func startPlaying(_ file: Data?, sourceType type: FileSource = .imported(.large), option: PlayType = .full, - needsWaveUpdate: Bool = false) async { + needsWaveUpdate: Bool = false) async + { guard await checkRecorderState(), await checkPlayerState() else { return } guard let file else { return } @@ -94,7 +107,20 @@ extension AudioHelper { if needsWaveUpdate { updatePlayIndex() } - await player?.startPlaying(data: file, option: playType) + await play(file: file, option: option) + } + + func play(file: Data, option: PlayType) async { + switch option { + case .full: await player?.startPlaying(data: file) + case let .partial(time): + await player?.startPlaying(data: file) + do { + try await Task.sleep(nanoseconds: UInt64(time * 1_000_000_000)) + await stopPlaying() + } catch { Logger.error(error.localizedDescription) } + @unknown default: break + } } func stopPlaying() async { @@ -148,7 +174,6 @@ extension AudioHelper { try await Task.sleep(nanoseconds: 6 * 1_000_000_000) let recordedData = await stopRecording() sendDataThrough(recorderDataSubject, recordedData ?? Data()) - removeTimer() } catch { Logger.error(error.localizedDescription) } } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Game/GameNavigationController.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Game/GameNavigationController.swift index a475cad4..6aa53b3e 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Game/GameNavigationController.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Game/GameNavigationController.swift @@ -11,16 +11,16 @@ final class GameNavigationController: @unchecked Sendable { private let gameStateRepository: GameStateRepositoryProtocol private let roomActionRepository: RoomActionRepositoryProtocol private var subscriptions: Set = [] - + private var gameInfo: GameState? { didSet { guard let gameInfo else { return } updateViewControllers(state: gameInfo) } } - + private let roomNumber: String - + init(navigationController: UINavigationController, gameStateRepository: GameStateRepositoryProtocol, roomActionRepository: RoomActionRepositoryProtocol, @@ -31,12 +31,12 @@ final class GameNavigationController: @unchecked Sendable { self.roomActionRepository = roomActionRepository self.roomNumber = roomNumber } - + @available(*, unavailable) - required init?(coder aDecoder: NSCoder) { + required init?(coder _: NSCoder) { fatalError("init(coder:) has not been implemented") } - + func setConfiguration() { gameStateRepository.getGameState() .receive(on: DispatchQueue.main) @@ -46,17 +46,17 @@ final class GameNavigationController: @unchecked Sendable { } .store(in: &subscriptions) } - + private func setupNavigationBar(for viewController: UIViewController) { navigationController.navigationBar.isHidden = false navigationController.navigationBar.tintColor = .asBlack - navigationController.navigationBar.titleTextAttributes = [.font: UIFont.font(.dohyeon, ofSize: 24)] - - let backButtonImage = UIImage(systemName: "rectangle.portrait.and.arrow.forward")? - .withRenderingMode(.alwaysTemplate) - .applyingSymbolConfiguration(.init(pointSize: 24, weight: .medium))? - .rotate(radians: .pi) - + let defaultFontSize = UIFont.preferredFont(forTextStyle: .headline).pointSize as CGFloat? + var fontStyle = UIFont() + if let defaultFontSize { fontStyle = UIFont.font(ofSize: defaultFontSize)} + else { fontStyle = UIFont.font(ofSize: 18) } + navigationController.navigationBar.titleTextAttributes = [.font: fontStyle] + let backButtonImage = setImage() + let backButtonAction = UIAction { [weak self] _ in let alert = DefaultAlertController( titleText: .leaveRoom, @@ -71,96 +71,108 @@ final class GameNavigationController: @unchecked Sendable { } let backButton = UIBarButtonItem(image: backButtonImage, primaryAction: backButtonAction) - + viewController.navigationItem.leftBarButtonItem = backButton viewController.title = setTitle() } - + private func setTitle() -> String { guard let gameInfo else { return "" } let viewType = gameInfo.resolveViewType() switch viewType { - case .submitMusic: - return "노래 선택" - case .humming: - return "허밍" - case .rehumming: - guard let recordOrder = gameInfo.recordOrder else { return "" } - let rounds = gameInfo.players.count - 2 - return "리허밍 \(recordOrder)/\(rounds)" - case .submitAnswer: - return "정답 맞추기" - case .result: - guard let recordOrder = gameInfo.recordOrder else { return "" } - let currentRound = Int(recordOrder) - (gameInfo.players.count - 2) - return "결과 확인 \(currentRound)/\(gameInfo.players.count)" - case .lobby: - return "#\(roomNumber)" - default: - return "" + case .submitMusic: + return "노래 선택" + case .humming: + return "허밍" + case .rehumming: + guard let recordOrder = gameInfo.recordOrder else { return "" } + let rounds = gameInfo.players.count - 2 + return "리허밍 \(recordOrder)/\(rounds)" + case .submitAnswer: + return "정답 맞추기" + case .result: + guard let recordOrder = gameInfo.recordOrder else { return "" } + let currentRound = Int(recordOrder) - (gameInfo.players.count - 2) + return "결과 확인 \(currentRound)/\(gameInfo.players.count)" + case .lobby: + return "#\(roomNumber)" + default: + return "" + } + } + + private func setImage() -> UIImage? { + guard let gameInfo else { return UIImage() } + let viewType = gameInfo.resolveViewType() + switch viewType { + case .lobby: return UIImage(systemName: "rectangle.portrait.and.arrow.forward")? + .rotate(radians: .pi) + default: return UIImage(systemName: "house") } } - + private func updateViewControllers(state: GameState) { let viewType = state.resolveViewType() switch viewType { - case .submitMusic: - navigateToSelectMusic() - case .humming: - navigateToHumming() - case .rehumming: - navigateToRehumming() - case .submitAnswer: - navigateToSubmitAnswer() - case .result: - navigateToResult() - case .lobby: - navigateToLobby() - default: - break + case .submitMusic: + navigateToSelectMusic() + case .humming: + navigateToHumming() + case .rehumming: + navigateToRehumming() + case .submitAnswer: + navigateToSubmitAnswer() + case .result: + navigateToResult() + case .lobby: + navigateToLobby() + default: + break } } - + private func navigateToLobby() { if navigationController.topViewController is LobbyViewController { return } - + if let vc = navigationController.viewControllers.first(where: { $0 is LobbyViewController }) { navigationController.popToViewController(vc, animated: true) return } - + let roomInfoRepository: RoomInfoRepositoryProtocol = DIContainer.shared.resolve(RoomInfoRepositoryProtocol.self) let playersRepository: PlayersRepositoryProtocol = DIContainer.shared.resolve(PlayersRepositoryProtocol.self) let roomActionRepository: RoomActionRepositoryProtocol = DIContainer.shared.resolve(RoomActionRepositoryProtocol.self) let avatarRepository: AvatarRepositoryProtocol = DIContainer.shared.resolve(AvatarRepositoryProtocol.self) - + let dataDownloadRepository: DataDownloadRepositoryProtocol = DIContainer.shared.resolve(DataDownloadRepositoryProtocol.self) + let vm = LobbyViewModel( playersRepository: playersRepository, roomInfoRepository: roomInfoRepository, roomActionRepository: roomActionRepository, - avatarRepository: avatarRepository + avatarRepository: avatarRepository, + dataDownloadRepository: dataDownloadRepository ) let vc = LobbyViewController(lobbyViewModel: vm) setupNavigationBar(for: vc) navigationController.pushViewController(vc, animated: true) } - + private func navigateToSelectMusic() { - let musicRepository = DIContainer.shared.resolve(MusicRepositoryProtocol.self) let playersRepository = DIContainer.shared.resolve(PlayersRepositoryProtocol.self) let answersRepository = DIContainer.shared.resolve(AnswersRepositoryProtocol.self) let gameStatusRepository = DIContainer.shared.resolve(GameStatusRepositoryProtocol.self) - + let dataDownloadRepository = DIContainer.shared.resolve(DataDownloadRepositoryProtocol.self) + let vm = SelectMusicViewModel( - musicRepository: musicRepository, playersRepository: playersRepository, answerRepository: answersRepository, - gameStatusRepository: gameStatusRepository + gameStatusRepository: gameStatusRepository, + dataDownloadRepository: dataDownloadRepository ) let vc = SelectMusicViewController(selectMusicViewModel: vm) - + let guideVC = GuideViewController(type: .submitMusic) { [weak self] in guard let self else { return } navigationController.pushViewController(vc, animated: true) @@ -168,13 +180,13 @@ final class GameNavigationController: @unchecked Sendable { } navigationController.pushViewController(guideVC, animated: true) } - + private func navigateToHumming() { let gameStatusRepository = DIContainer.shared.resolve(GameStatusRepositoryProtocol.self) let playersRepository = DIContainer.shared.resolve(PlayersRepositoryProtocol.self) let answersRepository = DIContainer.shared.resolve(AnswersRepositoryProtocol.self) let recordsRepository = DIContainer.shared.resolve(RecordsRepositoryProtocol.self) - + let vm = HummingViewModel( gameStatusRepository: gameStatusRepository, playersRepository: playersRepository, @@ -182,7 +194,7 @@ final class GameNavigationController: @unchecked Sendable { recordsRepository: recordsRepository ) let vc = HummingViewController(viewModel: vm) - + let guideVC = GuideViewController(type: .humming) { [weak self] in guard let self else { return } navigationController.pushViewController(vc, animated: true) @@ -190,44 +202,43 @@ final class GameNavigationController: @unchecked Sendable { } navigationController.pushViewController(guideVC, animated: true) } - + private func navigateToRehumming() { let gameStatusRepository = DIContainer.shared.resolve(GameStatusRepositoryProtocol.self) let playersRepository = DIContainer.shared.resolve(PlayersRepositoryProtocol.self) let recordsRepository = DIContainer.shared.resolve(RecordsRepositoryProtocol.self) - + let vm = RehummingViewModel( gameStatusRepository: gameStatusRepository, playersRepository: playersRepository, recordsRepository: recordsRepository ) let vc = RehummingViewController(viewModel: vm) - + let guideVC = GuideViewController(type: .rehumming) { [weak self] in guard let self else { return } navigationController.pushViewController(vc, animated: true) setupNavigationBar(for: vc) } navigationController.pushViewController(guideVC, animated: true) - } - + private func navigateToSubmitAnswer() { let gameStatusRepository = DIContainer.shared.resolve(GameStatusRepositoryProtocol.self) let playersRepository = DIContainer.shared.resolve(PlayersRepositoryProtocol.self) let recordsRepository = DIContainer.shared.resolve(RecordsRepositoryProtocol.self) let submitsRepository = DIContainer.shared.resolve(SubmitsRepositoryProtocol.self) - let musicRepository = DIContainer.shared.resolve(MusicRepositoryProtocol.self) + let dataDownloadRepository = DIContainer.shared.resolve(DataDownloadRepositoryProtocol.self) let vm = SubmitAnswerViewModel( gameStatusRepository: gameStatusRepository, playersRepository: playersRepository, recordsRepository: recordsRepository, submitsRepository: submitsRepository, - musicRepository: musicRepository + dataDownloadRepository: dataDownloadRepository ) let vc = SubmitAnswerViewController(viewModel: vm) - + let guideVC = GuideViewController(type: .submitAnswer) { [weak self] in guard let self else { return } navigationController.pushViewController(vc, animated: true) @@ -235,30 +246,29 @@ final class GameNavigationController: @unchecked Sendable { } navigationController.pushViewController(guideVC, animated: true) } - + private func navigateToResult() { if navigationController.topViewController is HummingResultViewController { navigationController.topViewController?.title = setTitle() return } let hummingResultRepository = DIContainer.shared.resolve(HummingResultRepositoryProtocol.self) - let avatarRepository = DIContainer.shared.resolve(AvatarRepositoryProtocol.self) let gameStatusRepository = DIContainer.shared.resolve(GameStatusRepositoryProtocol.self) let playerRepository = DIContainer.shared.resolve(PlayersRepositoryProtocol.self) let roomActionRepository = DIContainer.shared.resolve(RoomActionRepositoryProtocol.self) let roomInfoRepository = DIContainer.shared.resolve(RoomInfoRepositoryProtocol.self) - let musicRepository = DIContainer.shared.resolve(MusicRepositoryProtocol.self) + let dataDownloadRepository = DIContainer.shared.resolve(DataDownloadRepositoryProtocol.self) + let vm = HummingResultViewModel( hummingResultRepository: hummingResultRepository, - avatarRepository: avatarRepository, gameStatusRepository: gameStatusRepository, playerRepository: playerRepository, roomActionRepository: roomActionRepository, roomInfoRepository: roomInfoRepository, - musicRepository: musicRepository + dataDownloadRepository: dataDownloadRepository ) let vc = HummingResultViewController(viewModel: vm) - + let guideVC = GuideViewController(type: .result) { [weak self] in guard let self else { return } navigationController.pushViewController(vc, animated: true) @@ -266,13 +276,13 @@ final class GameNavigationController: @unchecked Sendable { } navigationController.pushViewController(guideVC, animated: true) } - + private func navigationToWaiting() {} - + private func leaveRoom() { Task { do { - let _ = try await roomActionRepository.leaveRoom() + _ = try await roomActionRepository.leaveRoom() } catch { Logger.error(error.localizedDescription) } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Humming/HummingView.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Humming/HummingView.swift index b7741dda..42cfcc43 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Humming/HummingView.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Humming/HummingView.swift @@ -34,7 +34,7 @@ final class HummingViewController: UIViewController { hummingPanel.bind(to: viewModel.$isRecording) hummingPanel.onRecordingFinished = { [weak self] recordedData in self?.recordButton.updateButton(.reRecord) - self?.viewModel.updateRecordedData(with: recordedData) + self?.viewModel.didRecordingFinished(recordedData) } submitButton.bind(to: viewModel.$recordedData) } @@ -58,7 +58,7 @@ final class HummingViewController: UIViewController { private func setAction() { recordButton.addAction(UIAction { [weak self] _ in self?.recordButton.updateButton(.recording) - self?.viewModel.startRecording() + self?.viewModel.didTappedRecordButton() }, for: .touchUpInside) @@ -88,9 +88,9 @@ final class HummingViewController: UIViewController { musicPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), musicPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), - hummingPanel.topAnchor.constraint(equalTo: musicPanel.bottomAnchor, constant: 36), - hummingPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), - hummingPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + hummingPanel.topAnchor.constraint(equalTo: musicPanel.bottomAnchor, constant: 32), + hummingPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + hummingPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), hummingPanel.heightAnchor.constraint(equalToConstant: 84), submissionStatus.topAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -16), @@ -98,20 +98,16 @@ final class HummingViewController: UIViewController { buttonStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), buttonStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), - buttonStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -24), + buttonStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -16), buttonStack.heightAnchor.constraint(equalToConstant: 64), ]) } private func submitHumming() async throws { - do { - progressBar.cancelCompletion() - try await viewModel.submitHumming() - submitButton.updateButton(.submitted) - recordButton.updateButton(.disabled) - } catch { - throw error - } + progressBar.cancelCompletion() + viewModel.didTappedSubmitButton() + submitButton.updateButton(.submitted) + recordButton.updateButton(.disabled) } } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Humming/HummingViewModel.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Humming/HummingViewModel.swift index d271a1b8..f05d865b 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Humming/HummingViewModel.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Humming/HummingViewModel.swift @@ -1,4 +1,5 @@ import ASEntity +import ASLogKit import ASRepositoryProtocol import Combine import Foundation @@ -32,27 +33,34 @@ final class HummingViewModel: @unchecked Sendable { bindAnswer() } - func submitHumming() async throws { + func didTappedRecordButton() { + startRecording() + } + + func didRecordingFinished(_ data: Data) { + updateRecordedData(with: data) + } + + func didTappedSubmitButton() { + Task { await submitHumming() } + } + + private func submitHumming() async { do { let result = try await recordsRepository.uploadRecording(recordedData ?? Data()) - if result { - return - } else { - // 전송 안됨, 오류 alert - } + if !result { Logger.error("Humming Did not sent") } } catch { - throw error + Logger.error(error.localizedDescription) } - return } - func startRecording() { + private func startRecording() { if !isRecording { isRecording = true } } - func updateRecordedData(with data: Data) { + private func updateRecordedData(with data: Data) { // TODO: - data가 empty일 때(녹음이 제대로 되지 않았을 때 사용자 오류처리 필요 guard !data.isEmpty else { return } recordedData = data diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyView.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyView.swift index 4e3dc3b5..8a64b575 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyView.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyView.swift @@ -7,7 +7,7 @@ struct LobbyView: View { var body: some View { VStack { ScrollView(.horizontal) { - HStack(spacing: 16) { + HStack(alignment: .top, spacing: 16) { ForEach(0 ..< viewModel.playerMaxCount) { index in if index < viewModel.players.count { let player = viewModel.players[index] diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewController.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewController.swift index d2da6bb4..33a739bd 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewController.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewController.swift @@ -2,11 +2,10 @@ import Combine import SwiftUI final class LobbyViewController: UIViewController { - let inviteButton = ASButton() - let startButton = ASButton() - private var hostingController: UIHostingController? - - let viewmodel: LobbyViewModel + private let inviteButton = ASButton() + private let startButton = ASButton() + private var lobbyView = UIViewController() + private let viewmodel: LobbyViewModel private var cancellables: Set = [] init(lobbyViewModel: LobbyViewModel) { @@ -27,21 +26,6 @@ final class LobbyViewController: UIViewController { bindToComponents() } - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - if viewmodel.isLeaveRoom { - hostingController?.view.removeFromSuperview() - hostingController = nil - } - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - if viewmodel.isLeaveRoom { - viewmodel.leaveRoom() - } - } - private func bindToComponents() { viewmodel.$canBeginGame.combineLatest(viewmodel.$isHost) .receive(on: DispatchQueue.main) @@ -72,14 +56,18 @@ final class LobbyViewController: UIViewController { text: "초대하기!", backgroundColor: .asYellow ) - inviteButton.translatesAutoresizingMaskIntoConstraints = false startButton.setConfiguration( systemImageName: "play.fill", text: "시작하기!", backgroundColor: .asMint ) - startButton.translatesAutoresizingMaskIntoConstraints = false + + lobbyView = UIHostingController(rootView: LobbyView(viewModel: viewmodel)) + + view.addSubview(lobbyView.view) + view.addSubview(startButton) + view.addSubview(inviteButton) } private func setAction() { @@ -95,47 +83,33 @@ final class LobbyViewController: UIViewController { startButton.addAction( UIAction { [weak self] _ in guard let playerCount = self?.viewmodel.players.count else { return } - if playerCount < 3 { - let alert = DefaultAlertController( - titleText: .needMorePlayer, - primaryButtonText: .keep, - secondaryButtonText: .cancel - ) { [weak self] _ in - self?.showStartGameLoading() - } - self?.presentAlert(alert) - } else { + playerCount < 3 ? + self?.showNeedMorePlayers() : self?.showStartGameLoading() - } }, for: .touchUpInside ) } private func setupLayout() { - let lobbyView = UIHostingController(rootView: LobbyView(viewModel: viewmodel)) - hostingController = lobbyView + inviteButton.translatesAutoresizingMaskIntoConstraints = false + startButton.translatesAutoresizingMaskIntoConstraints = false lobbyView.view.translatesAutoresizingMaskIntoConstraints = false - view.addSubview(lobbyView.view) - view.addSubview(startButton) - view.addSubview(inviteButton) - - let safeArea = view.safeAreaLayoutGuide - + NSLayoutConstraint.activate([ - lobbyView.view.topAnchor.constraint(equalTo: safeArea.topAnchor), + lobbyView.view.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), lobbyView.view.bottomAnchor.constraint(equalTo: inviteButton.topAnchor, constant: -20), - lobbyView.view.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor), - lobbyView.view.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor), + lobbyView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + lobbyView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), inviteButton.bottomAnchor.constraint(equalTo: startButton.topAnchor, constant: -25), - inviteButton.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 24), - inviteButton.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -24), + inviteButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + inviteButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), inviteButton.heightAnchor.constraint(equalToConstant: 64), - startButton.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -25), - startButton.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 24), - startButton.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -24), + startButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + startButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + startButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), startButton.heightAnchor.constraint(equalToConstant: 64), ]) } @@ -165,6 +139,17 @@ extension LobbyViewController { presentAlert(alert) } + func showNeedMorePlayers() { + let alert = DefaultAlertController( + titleText: .needMorePlayer, + primaryButtonText: .keep, + secondaryButtonText: .cancel + ) { [weak self] _ in + self?.showStartGameLoading() + } + presentAlert(alert) + } + func showStartGameFailed(_ error: Error) { let alert = SingleButtonAlertController(titleText: .error(error)) presentAlert(alert) diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewModel.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewModel.swift index df44e781..897a5846 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewModel.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Lobby/LobbyViewModel.swift @@ -9,10 +9,15 @@ final class LobbyViewModel: ObservableObject, @unchecked Sendable { private var roomInfoRepository: RoomInfoRepositoryProtocol private var roomActionRepository: RoomActionRepositoryProtocol private var avatarRepository: AvatarRepositoryProtocol - + private var dataDownloadRepository: DataDownloadRepositoryProtocol + let playerMaxCount = 4 private(set) var roomNumber: String = "" @Published var players: [Player] = [] + @Published var host: Player? + @Published var isGameStrted: Bool = false + @Published var isHost: Bool = false + @Published var canBeginGame: Bool = false @Published var mode: Mode = .humming { didSet { if mode != oldValue { @@ -21,31 +26,27 @@ final class LobbyViewModel: ObservableObject, @unchecked Sendable { } } - @Published var host: Player? - @Published var isGameStrted: Bool = false - @Published var isHost: Bool = false - @Published var canBeginGame: Bool = false - var isLeaveRoom = false - private var cancellables: Set = [] - + init(playersRepository: PlayersRepositoryProtocol, roomInfoRepository: RoomInfoRepositoryProtocol, roomActionRepository: RoomActionRepositoryProtocol, - avatarRepository: AvatarRepositoryProtocol) + avatarRepository: AvatarRepositoryProtocol, + dataDownloadRepository: DataDownloadRepositoryProtocol) { self.playersRepository = playersRepository self.roomActionRepository = roomActionRepository self.roomInfoRepository = roomInfoRepository self.avatarRepository = avatarRepository + self.dataDownloadRepository = dataDownloadRepository fetchData() } - + func getAvatarData(url: URL?) async -> Data? { guard let url else { return nil } - return await avatarRepository.getAvatarData(url: url) + return await dataDownloadRepository.downloadData(url: url) } - + func fetchData() { playersRepository.getPlayers() .receive(on: DispatchQueue.main) @@ -53,14 +54,14 @@ final class LobbyViewModel: ObservableObject, @unchecked Sendable { self?.players = players } .store(in: &cancellables) - + playersRepository.getHost() .receive(on: DispatchQueue.main) .sink { [weak self] host in self?.host = host } .store(in: &cancellables) - + roomInfoRepository.getMode() .receive(on: DispatchQueue.main) .sink { [weak self] mode in @@ -70,21 +71,21 @@ final class LobbyViewModel: ObservableObject, @unchecked Sendable { } } .store(in: &cancellables) - + roomInfoRepository.getRoomNumber() .receive(on: DispatchQueue.main) .sink { [weak self] roomNumber in self?.roomNumber = roomNumber } .store(in: &cancellables) - + playersRepository.isHost() .receive(on: DispatchQueue.main) .sink { [weak self] isHost in self?.isHost = isHost } .store(in: &cancellables) - + playersRepository.isHost().combineLatest(playersRepository.getPlayersCount()) .receive(on: DispatchQueue.main) .sink { [weak self] isHost, playerCount in @@ -92,7 +93,7 @@ final class LobbyViewModel: ObservableObject, @unchecked Sendable { } .store(in: &cancellables) } - + func gameStart() async throws { do { _ = try await roomActionRepository.startGame(roomNumber: roomNumber) @@ -100,17 +101,7 @@ final class LobbyViewModel: ObservableObject, @unchecked Sendable { throw error } } - - func leaveRoom() { - Task { - do { - isLeaveRoom = try await roomActionRepository.leaveRoom() - } catch { - Logger.error(error.localizedDescription) - } - } - } - + func changeMode() { Task { do { diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/MusicPanel/MusicPanel.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/MusicPanel/MusicPanel.swift index 43a6b735..46ea0718 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/MusicPanel/MusicPanel.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/MusicPanel/MusicPanel.swift @@ -16,13 +16,11 @@ final class MusicPanel: UIView { private let artistLabel = UILabel() private let labelStack = UIStackView() private var cancellables = Set() - private let musicRepository: MusicRepositoryProtocol private var viewModel: MusicPanelViewModel? = nil private var panelType: MusicPanelType = .large init(_ type: MusicPanelType = .large) { panelType = type - musicRepository = DIContainer.shared.resolve(MusicRepositoryProtocol.self) player = ASMusicPlayerView(type) super.init(frame: .zero) setupUI() @@ -52,11 +50,11 @@ final class MusicPanel: UIView { self.labelStack.isHidden = false self.player.isHidden = false } - + let dataDownloadRepository = DIContainer.shared.resolve(DataDownloadRepositoryProtocol.self) self.viewModel = MusicPanelViewModel( music: music, type: panelType, - musicRepository: self.musicRepository + dataDownloadRepository: dataDownloadRepository ) self.player.updateMusicPanel(color: music?.artworkBackgroundColor?.hexToCGColor()) self.bindViewModel() diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/MusicPanel/MusicPanelViewModel.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/MusicPanel/MusicPanelViewModel.swift index 7548ffb8..96f574ec 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/MusicPanel/MusicPanelViewModel.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/MusicPanel/MusicPanelViewModel.swift @@ -9,17 +9,17 @@ final class MusicPanelViewModel: @unchecked Sendable { @Published var artwork: Data? @Published var preview: Data? @Published private(set) var buttonState: AudioButtonState = .idle - private let musicRepository: MusicRepositoryProtocol? + private let dataDownloadRepository: DataDownloadRepositoryProtocol private var cancellables = Set() init( music: Music?, type: MusicPanelType = .large, - musicRepository: MusicRepositoryProtocol? + dataDownloadRepository: DataDownloadRepositoryProtocol ) { self.music = music self.type = type - self.musicRepository = musicRepository + self.dataDownloadRepository = dataDownloadRepository getPreviewData() getArtworkData() bindAudioHelper() @@ -88,14 +88,14 @@ final class MusicPanelViewModel: @unchecked Sendable { private func getPreviewData() { guard let previewUrl = music?.previewUrl else { return } Task { @MainActor in - preview = await musicRepository?.getMusicData(url: previewUrl) + preview = await dataDownloadRepository.downloadData(url: previewUrl) } } private func getArtworkData() { guard let artworkUrl = music?.artworkUrl else { return } Task { @MainActor in - artwork = await musicRepository?.getMusicData(url: artworkUrl) + artwork = await dataDownloadRepository.downloadData(url: artworkUrl) } } } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Onboarding/OnboardingViewController.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Onboarding/OnboardingViewController.swift index f3ad4e31..47e85ad2 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Onboarding/OnboardingViewController.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Onboarding/OnboardingViewController.swift @@ -63,17 +63,15 @@ final class OnboardingViewController: UIViewController { } private func setupLayout() { - let safeArea = view.safeAreaLayoutGuide - NSLayoutConstraint.activate([ logoImageView.widthAnchor.constraint(equalToConstant: 356), logoImageView.heightAnchor.constraint(equalToConstant: 160), - logoImageView.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor), - logoImageView.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: 48), + logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + logoImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 48), avatarView.widthAnchor.constraint(equalToConstant: 200), avatarView.heightAnchor.constraint(equalToConstant: 200), - avatarView.centerXAnchor.constraint(equalTo: safeArea.centerXAnchor), + avatarView.centerXAnchor.constraint(equalTo: view.centerXAnchor), avatarRefreshButton.leadingAnchor.constraint(equalTo: avatarView.trailingAnchor, constant: -56), avatarRefreshButton.topAnchor.constraint(equalTo: avatarView.bottomAnchor, constant: -56), @@ -81,20 +79,20 @@ final class OnboardingViewController: UIViewController { avatarRefreshButton.heightAnchor.constraint(equalToConstant: 60), nickNamePanel.topAnchor.constraint(equalTo: avatarView.bottomAnchor, constant: 36), - nickNamePanel.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 24), - nickNamePanel.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -24), + nickNamePanel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + nickNamePanel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), nickNamePanel.heightAnchor.constraint(equalToConstant: 100), - createRoomButton.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 24), - createRoomButton.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -24), + createRoomButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + createRoomButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), createRoomButton.topAnchor.constraint(equalTo: nickNamePanel.bottomAnchor, constant: 24), createRoomButton.bottomAnchor.constraint(equalTo: joinRoomButton.topAnchor, constant: -24), createRoomButton.heightAnchor.constraint(equalToConstant: 64), - joinRoomButton.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 24), - joinRoomButton.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -24), + joinRoomButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + joinRoomButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), joinRoomButton.heightAnchor.constraint(equalToConstant: 64), - joinRoomButton.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -24), + joinRoomButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), ]) } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Onboarding/OnboardingViewModel.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Onboarding/OnboardingViewModel.swift index 87044f25..ce5748a5 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Onboarding/OnboardingViewModel.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Onboarding/OnboardingViewModel.swift @@ -6,6 +6,7 @@ import Foundation final class OnboardingViewModel: @unchecked Sendable { private var avatarRepository: AvatarRepositoryProtocol private var roomActionRepository: RoomActionRepositoryProtocol + private var dataDownloadRepository: DataDownloadRepositoryProtocol private var avatars: [URL] = [] private var selectedAvatar: URL? private var cancellables: Set = [] @@ -15,10 +16,12 @@ final class OnboardingViewModel: @unchecked Sendable { @Published var buttonEnabled: Bool = true init(avatarRepository: AvatarRepositoryProtocol, - roomActionRepository: RoomActionRepositoryProtocol) + roomActionRepository: RoomActionRepositoryProtocol, + dataDownloadRepository: DataDownloadRepositoryProtocol) { self.avatarRepository = avatarRepository self.roomActionRepository = roomActionRepository + self.dataDownloadRepository = dataDownloadRepository refreshAvatars() } @@ -39,9 +42,9 @@ final class OnboardingViewModel: @unchecked Sendable { fetchAvatars() } Task { - guard let randomAvatar = avatars.randomElement() else { return } - selectedAvatar = randomAvatar - self.avatarData = await avatarRepository.getAvatarData(url: randomAvatar) + guard let randomAvatarUrl = avatars.randomElement() else { return } + selectedAvatar = randomAvatarUrl + self.avatarData = await dataDownloadRepository.downloadData(url: randomAvatarUrl) } } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/RecordingPanel/RecordingPanel.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/RecordingPanel/RecordingPanel.swift index 9ba08fe6..ae09901e 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/RecordingPanel/RecordingPanel.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/RecordingPanel/RecordingPanel.swift @@ -161,100 +161,3 @@ final class RecordingPanel: UIView { waveFormView.resetView() } } - -private final class WaveForm: UIView { - private var columnWidth: CGFloat? - private var columns: [CAShapeLayer] = [] - private let numOfColumns: Int - private var count: Int = 0 - - override class func awakeFromNib() { - super.awakeFromNib() - } - - init(numOfColumns: Int = 48) { - self.numOfColumns = numOfColumns - super.init(frame: .zero) - } - - required init?(coder: NSCoder) { - numOfColumns = 48 - super.init(coder: coder) - } - - override func layoutSubviews() { - super.layoutSubviews() - if columns.isEmpty { - drawVisualizerCircles() - } - } - - func resetView() { - removeVisualizerCircles() - } - - private func drawVisualizerCircles() { - let diameter = bounds.width / CGFloat(2 * numOfColumns + 1) - columnWidth = diameter - let startingPointY = bounds.midY - diameter / 2 - var startingPointX = bounds.minX + diameter - - for _ in 0 ..< numOfColumns { - let circleOrigin = CGPoint(x: startingPointX, y: startingPointY) - let circleSize = CGSize(width: diameter, height: diameter) - let circle = UIBezierPath(roundedRect: CGRect(origin: circleOrigin, size: circleSize), cornerRadius: diameter / 2) - - let circleLayer = CAShapeLayer() - circleLayer.path = circle.cgPath - circleLayer.fillColor = UIColor.white.cgColor - - layer.addSublayer(circleLayer) - columns.append(circleLayer) - startingPointX += 2 * diameter - } - } - - private func removeVisualizerCircles() { - for column in columns { - column.removeFromSuperlayer() - } - count = 0 - columns.removeAll() - } - - fileprivate func resetColor() { - for column in columns { - column.fillColor = UIColor.white.cgColor - } - } - - fileprivate func updatePlayingIndex(_ index: Int) { - columns[index].fillColor = UIColor.black.cgColor - } - - fileprivate func updateAmplitude(with amplitude: CGFloat, direction: Direction = .LTR) { - guard columns.count == numOfColumns, count < numOfColumns else { return } - let index = direction == .LTR ? count : numOfColumns - count - 1 - columns[index].path = computeNewPath(for: columns[index], with: amplitude) - columns[index].fillColor = UIColor.white.cgColor - count += 1 - } - - private func computeNewPath(for layer: CAShapeLayer, with amplitude: CGFloat) -> CGPath { - let width = columnWidth ?? 8.0 - let maxHeightGain = bounds.height - 3 * width - let heightGain = maxHeightGain * amplitude - let newHeight = width + heightGain - let newOrigin = CGPoint(x: layer.path?.boundingBox.origin.x ?? 0, - y: (layer.superlayer?.bounds.midY ?? 0) - (newHeight / 2)) - let newSize = CGSize(width: width, height: newHeight) - - return UIBezierPath(roundedRect: CGRect(origin: newOrigin, size: newSize), cornerRadius: width / 2).cgPath - } -} - -extension WaveForm { - enum Direction { - case RTL, LTR - } -} diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Rehumming/RehummingView.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Rehumming/RehummingView.swift index 66733b0b..3fc0ac96 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Rehumming/RehummingView.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Rehumming/RehummingView.swift @@ -92,17 +92,17 @@ final class RehummingViewController: UIViewController { musicPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 32), musicPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), - hummingPanel.topAnchor.constraint(equalTo: musicPanel.bottomAnchor, constant: 36), - hummingPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), - hummingPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + hummingPanel.topAnchor.constraint(equalTo: musicPanel.bottomAnchor, constant: 32), + hummingPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + hummingPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), hummingPanel.heightAnchor.constraint(equalToConstant: 84), submissionStatus.topAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -16), submissionStatus.trailingAnchor.constraint(equalTo: buttonStack.trailingAnchor, constant: 16), - buttonStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -24), buttonStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), buttonStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + buttonStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), buttonStack.heightAnchor.constraint(equalToConstant: 64), ]) } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultTableViewDiffableDataSource.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultTableViewDiffableDataSource.swift new file mode 100644 index 00000000..a95b232b --- /dev/null +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultTableViewDiffableDataSource.swift @@ -0,0 +1,64 @@ +import ASEntity +import Foundation +import SwiftUI +import UIKit + +struct ResultTableViewItem: Hashable, Sendable { + let record: MappedRecord? + let submit: MappedAnswer? +} + +final class HummingResultTableViewDiffableDataSource: UITableViewDiffableDataSource { + init(tableView: UITableView) { + super.init(tableView: tableView) { tableView, indexPath, item in + let cell = UITableViewCell() + cell.backgroundColor = .clear + switch indexPath.section { + case 0: + guard let record = item.record else { return cell } + cell.contentConfiguration = UIHostingConfiguration { + SpeechBubbleCell( + row: indexPath.row, + messageType: .record(record) + ) + .padding(.horizontal, 16) + } + + case 1: + guard let submit = item.submit else { return cell } + let recordsCount = tableView.numberOfRows(inSection: 0) + cell.contentConfiguration = UIHostingConfiguration { + SpeechBubbleCell( + row: recordsCount, + messageType: .music(submit) + ) + .padding(.horizontal, 16) + } + default: + return cell + } + + return cell + } + } + + func applySnapshot(_ result: Result) { + let records = result.records + let submit = result.submit + + var snapshot = NSDiffableDataSourceSnapshot() + + snapshot.appendSections([0, 1]) + + snapshot.appendItems(records.map { + ResultTableViewItem(record: $0, submit: nil) + }, toSection: 0) + + if let submit { + snapshot.appendItems([ResultTableViewItem(record: nil, submit: submit)], + toSection: 1) + } + + apply(snapshot, animatingDifferences: false) + } +} diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewController.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewController.swift index 7a4a3f6e..25513178 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewController.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewController.swift @@ -1,291 +1,165 @@ import ASEntity +import ASLogKit import Combine import SwiftUI class HummingResultViewController: UIViewController { - private let musicResultView = MusicResultView(frame: .zero) + private let answerView = MusicPanelView() private let resultTableView = UITableView() - private let button = ASButton() - + private let nextButton = ASButton() + + private var resultTableViewDiffableDataSource: HummingResultTableViewDiffableDataSource? private var viewModel: HummingResultViewModel? private var cancellables = Set() - + init(viewModel: HummingResultViewModel) { self.viewModel = viewModel super.init(nibName: nil, bundle: nil) } - + required init?(coder: NSCoder) { - self.viewModel = nil + viewModel = nil super.init(coder: coder) } - + override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .asLightGray - setResultTableView() - setButton() - setConstraints() - bind() - viewModel?.fetchResult() + setupUI() + setupLayout() + bindViewModel() + viewModel?.bindResult() } - - override func viewDidDisappear(_ animated: Bool) { + + override func viewDidDisappear(_: Bool) { viewModel?.cancelSubscriptions() } - + + private func setupUI() { + setResultTableView() + setButton() + setAction() + view.addSubview(resultTableView) + view.addSubview(nextButton) + view.addSubview(answerView) + } + private func setResultTableView() { - resultTableView.dataSource = self + resultTableViewDiffableDataSource = HummingResultTableViewDiffableDataSource(tableView: resultTableView) resultTableView.separatorStyle = .none resultTableView.allowsSelection = false resultTableView.backgroundColor = .asLightGray resultTableView.contentInset = UIEdgeInsets(top: 10, left: 0, bottom: 0, right: 0) - view.addSubview(resultTableView) } - + private func setButton() { - button.setConfiguration( + nextButton.setConfiguration( systemImageName: "play.fill", text: "다음으로", backgroundColor: .asMint ) - view.addSubview(button) - button.addAction(UIAction { [weak self] _ in + nextButton.updateButton(.disabled) + } + + private func setAction() { + nextButton.addAction(UIAction { [weak self] _ in guard let self else { return } showNextResultLoading() }, for: .touchUpInside) - button.isHidden = true } - - private func setConstraints() { - view.addSubview(musicResultView) - - musicResultView.translatesAutoresizingMaskIntoConstraints = false + + private func setupLayout() { + answerView.translatesAutoresizingMaskIntoConstraints = false resultTableView.translatesAutoresizingMaskIntoConstraints = false - button.translatesAutoresizingMaskIntoConstraints = false - - let layoutGuide = view.safeAreaLayoutGuide - + nextButton.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ - musicResultView.topAnchor.constraint(equalTo: layoutGuide.topAnchor, constant: 20), - musicResultView.leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor, constant: 16), - musicResultView.trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor, constant: -16), - musicResultView.heightAnchor.constraint(equalToConstant: 130), - - resultTableView.topAnchor.constraint(equalTo: musicResultView.bottomAnchor, constant: 20), - resultTableView.leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor), - resultTableView.trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor), - resultTableView.bottomAnchor.constraint(equalTo: button.topAnchor, constant: -30), - - button.leadingAnchor.constraint(equalTo: layoutGuide.leadingAnchor, constant: 24), - button.trailingAnchor.constraint(equalTo: layoutGuide.trailingAnchor, constant: -24), - button.heightAnchor.constraint(equalToConstant: 64), - button.bottomAnchor.constraint(equalTo: layoutGuide.bottomAnchor, constant: -25) + answerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), + answerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + answerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + answerView.heightAnchor.constraint(equalToConstant: 130), + + resultTableView.topAnchor.constraint(equalTo: answerView.bottomAnchor, constant: 20), + resultTableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + resultTableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + resultTableView.bottomAnchor.constraint(equalTo: nextButton.topAnchor, constant: -30), + + nextButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + nextButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + nextButton.heightAnchor.constraint(equalToConstant: 64), + nextButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), ]) } - - private func bind() { - guard let viewModel else { return } - musicResultView.bind(to: viewModel.$currentResult) { [weak self] url in - await self?.viewModel?.getArtworkData(url: url) - } musicFetcher: { - await self.viewModel?.startPlaying() + + func addDataSource(_ phase: ResultPhase, result: Result) { + if case let .record(count) = phase { + var updateResult = result + let records = updateResult.records[0 ... count] + updateResult.records = Array(records) + updateResult.submit = nil + Logger.debug("record update", updateResult) + resultTableViewDiffableDataSource?.applySnapshot(updateResult) + return } - - viewModel.$currentRecords - .receive(on: DispatchQueue.main) - .sink { [weak self] records in - guard let self, !records.isEmpty else { return } - let indexPath = IndexPath(row: records.count - 1, section: 0) - if records.count > resultTableView.numberOfRows(inSection: 0) { - resultTableView.insertRows(at: [indexPath], with: .fade) - } - else { - resultTableView.reloadRows(at: [indexPath], with: .fade) - } - } - .store(in: &cancellables) - - viewModel.$currentsubmit - .receive(on: DispatchQueue.main) - .sink { [weak self] submit in - guard let self else { return } - if submit != nil { - button.isHidden = false - let indexPath = IndexPath(row: 0, section: 1) - if resultTableView.numberOfRows(inSection: 1) == 1 { - resultTableView.reloadRows(at: [indexPath], with: .fade) - } - else { - resultTableView.insertRows(at: [indexPath], with: .fade) - } - } - } - .store(in: &cancellables) - - viewModel.$isNext - .receive(on: DispatchQueue.main) - .sink { [weak self] isNext in - guard let self else { return } - if isNext { - viewModel.nextResultFetch() - button.isHidden = true - if viewModel.hummingResult.isEmpty { - button.updateButton(.complete) - button.removeTarget(nil, action: nil, for: .touchUpInside) - button.addAction(UIAction { _ in - self.showLobbyLoading() - }, for: .touchUpInside) - - } - viewModel.isNext = false - resultTableView.reloadData() - } - } - .store(in: &cancellables) - - viewModel.$isHost - .receive(on: DispatchQueue.main) - .sink { [weak self] isHost in - guard let self else { return } - if isHost { - button.isEnabled = true - } - else { - button.isEnabled = false - } - } - .store(in: &cancellables) - } - - private func nextResultFetch() async throws { - guard let viewModel else { return } - do { - if viewModel.hummingResult.isEmpty { - try await viewModel.navigationToLobby() - } - else { - try await viewModel.changeRecordOrder() - } + if case .submit = phase { + Logger.debug("submit update", result) + resultTableViewDiffableDataSource?.applySnapshot(result) + return } - catch { - throw error + if case .answer = phase { + resultTableViewDiffableDataSource?.applySnapshot((result.answer, [], nil)) } } -} -extension HummingResultViewController: UITableViewDataSource { - func numberOfSections(in tableView: UITableView) -> Int { - 2 - } - - func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - guard let viewModel else { return 0 } - if section == 0 { - return viewModel.currentRecords.count - } - if viewModel.currentsubmit != nil { - return 1 + private func changeButton(_ phase: ResultPhase) { + if case .none = phase { + nextButton.setConfiguration( + systemImageName: "play.fill", + text: "다음으로", + backgroundColor: .asMint + ) + nextButton.isEnabled = true + nextButton.removeTarget(nil, action: nil, for: .touchUpInside) + nextButton.addAction(UIAction { _ in + self.showNextResultLoading() + }, for: .touchUpInside) + } else { + nextButton.updateButton(.disabled) } - else { return 0 } } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - let cell = UITableViewCell() - cell.backgroundColor = .clear - guard let viewModel else { return cell } - - if indexPath.section == 0 { - cell.contentConfiguration = UIHostingConfiguration { - let currentPlayer = viewModel.currentRecords[indexPath.row].player - HStack { - Spacer() - if indexPath.row % 2 == 0 { - if let avatarURL = currentPlayer?.avatarUrl { - SpeechBubbleCell( - alignment: .left, - messageType: .record, - avatarImagePublisher: { url in - await viewModel.getAvatarData(url: url) - }, - avatarURL: avatarURL, - artworkImagePublisher: { url in - await viewModel.getArtworkData(url: url) - }, - artworkURL: nil, - name: currentPlayer?.nickname ?? "" - ) - } - } - else { - if let avatarURL = currentPlayer?.avatarUrl { - SpeechBubbleCell( - alignment: .right, - messageType: .record, - avatarImagePublisher: { url in - await viewModel.getAvatarData(url: url) - }, - avatarURL: avatarURL, - artworkImagePublisher: { url in - await viewModel.getArtworkData(url: url) - }, - artworkURL: nil, - name: currentPlayer?.nickname ?? "" - ) - } - } - Spacer() + + private func bindViewModel() { + guard let viewModel else { return } + answerView.bind(to: viewModel.$result) + + viewModel.$resultPhase + .combineLatest(viewModel.$result, viewModel.$isHost) + .receive(on: DispatchQueue.main) + .sink { [weak self] phase, result, isHost in + guard let self else { return } + addDataSource(phase, result: result) + if result.answer != nil, isHost { + changeButton(phase) } } - } - else { - cell.contentConfiguration = UIHostingConfiguration { - HStack { - Spacer() - if viewModel.currentRecords.count % 2 == 0 { - if let submit = viewModel.currentsubmit, - let avatarURL = submit.player?.avatarUrl, - let artworkURL = submit.music?.artworkUrl { - SpeechBubbleCell( - alignment: .left, - messageType: .music(submit.music ?? .musicStub1), - avatarImagePublisher: { url in - await viewModel.getAvatarData(url: url) - }, - avatarURL: avatarURL, - artworkImagePublisher: { url in - await viewModel.getArtworkData(url: url) - }, - artworkURL: artworkURL, - name: submit.player?.nickname ?? "" - ) - } - } - else { - if let submit = viewModel.currentsubmit, - let avatarURL = submit.player?.avatarUrl, - let artworkURL = submit.music?.artworkUrl { - SpeechBubbleCell( - alignment: .right, - messageType: .music(submit.music ?? .musicStub1), - avatarImagePublisher: { url in - await viewModel.getAvatarData(url: url) - }, - avatarURL: avatarURL, - artworkImagePublisher: { url in - await viewModel.getArtworkData(url: url) - }, - artworkURL: artworkURL, - name: submit.player?.nickname ?? "" - ) - } - } - Spacer() + .store(in: &cancellables) + + viewModel.$canEndGame + .receive(on: DispatchQueue.main) + .sink { [weak self] canEndGame in + guard let self else { return } + if canEndGame { + nextButton.updateButton(.complete) + nextButton.isEnabled = true + nextButton.removeTarget(nil, action: nil, for: .touchUpInside) + nextButton.addAction(UIAction { _ in + self.showLobbyLoading() + }, for: .touchUpInside) + } else { + nextButton.updateButton(.disabled) } } - } - - return cell + .store(in: &cancellables) } } @@ -294,131 +168,30 @@ extension HummingResultViewController { let alert = LoadingAlertController( progressText: .nextResult, loadAction: { [weak self] in - try await self?.nextResultFetch() + await self?.viewModel?.changeRecordOrder() }, errorCompletion: { [weak self] error in - self?.showFailNextLoading(error) + self?.showFailedAlert(error) } ) presentAlert(alert) } - + private func showLobbyLoading() { let alert = LoadingAlertController( progressText: .toLobby, loadAction: { [weak self] in - try await self?.nextResultFetch() + await self?.viewModel?.navigateToLobby() }, errorCompletion: { [weak self] error in - self?.showFailNextLoading(error) + self?.showFailedAlert(error) } ) presentAlert(alert) } - - private func showFailNextLoading(_ error: Error) { + + private func showFailedAlert(_ error: Error) { let alert = SingleButtonAlertController(titleText: .error(error)) presentAlert(alert) } } - -final class MusicResultView: UIView { - private let albumImageView = UIImageView() - private let musicNameLabel = UILabel() - private let singerNameLabel = UILabel() - private let titleLabel = UILabel() - private var cancellables = Set() - - override init(frame: CGRect) { - super.init(frame: frame) - setupView() - setupConstraints() - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - setupView() - setupConstraints() - } - - func bind( - to dataSource: Published.Publisher, - fetcher: @escaping (URL?) async -> Data?, - musicFetcher: @escaping () async -> Void? - ) { - dataSource - .receive(on: DispatchQueue.main) - .compactMap { $0 } - .sink { [weak self] answer in - self?.musicNameLabel.text = answer.music?.title - self?.singerNameLabel.text = answer.music?.artist - Task { - guard let url = answer.music?.artworkUrl else { return } - let data = await fetcher(url) - self?.setImage(data: data) - await musicFetcher() - } - } - .store(in: &cancellables) - } - - private func setImage(data: Data?) { - guard let data else { return } - albumImageView.image = UIImage(data: data) - } - - private func setupView() { - backgroundColor = .asSystem - - titleLabel.text = "정답은..." - titleLabel.font = .font(ofSize: 24) - titleLabel.textColor = .asBlack - titleLabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(titleLabel) - - albumImageView.contentMode = .scaleAspectFill - albumImageView.layer.cornerRadius = 6 - albumImageView.clipsToBounds = true - albumImageView.translatesAutoresizingMaskIntoConstraints = false - albumImageView.image = UIImage(named: "mojojojo") // Placeholder image - addSubview(albumImageView) - - musicNameLabel.font = .font(ofSize: 24) - musicNameLabel.textColor = .asBlack - musicNameLabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(musicNameLabel) - - singerNameLabel.font = .font(ofSize: 24) - singerNameLabel.textColor = UIColor.gray - singerNameLabel.translatesAutoresizingMaskIntoConstraints = false - addSubview(singerNameLabel) - - layer.cornerRadius = 12 - layer.shadowColor = UIColor.asShadow.cgColor - layer.shadowOffset = CGSize(width: 4, height: 4) - layer.shadowRadius = 0 - layer.shadowOpacity = 1.0 - layer.borderWidth = 3 - layer.borderColor = UIColor.black.cgColor - } - - private func setupConstraints() { - NSLayoutConstraint.activate([ - titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 16), - titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), - - albumImageView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12), - albumImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), - albumImageView.widthAnchor.constraint(equalToConstant: 60), - albumImageView.heightAnchor.constraint(equalToConstant: 60), - - musicNameLabel.topAnchor.constraint(equalTo: albumImageView.topAnchor), - musicNameLabel.leadingAnchor.constraint(equalTo: albumImageView.trailingAnchor, constant: 15), - musicNameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), - - singerNameLabel.topAnchor.constraint(equalTo: musicNameLabel.bottomAnchor, constant: 4), - singerNameLabel.leadingAnchor.constraint(equalTo: albumImageView.trailingAnchor, constant: 15), - singerNameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16) - ]) - } -} diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewModel+Entity.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewModel+Entity.swift new file mode 100644 index 00000000..2cd3092e --- /dev/null +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewModel+Entity.swift @@ -0,0 +1,66 @@ +import Foundation + +protocol PlayerInfo { + var playerName: String { get set } + var playerAvatarData: Data { get set } +} + +struct MappedAnswer: Hashable, PlayerInfo { + var artworkData: Data + var previewData: Data + var title: String + var artist: String + var playerName: String + var playerAvatarData: Data + + init(_ artworkData: Data?, _ previewData: Data?, _ title: String?, _ artist: String?, _ playerName: String?, _ playerAvatarData: Data?) { + self.artworkData = artworkData ?? Data() + self.previewData = previewData ?? Data() + self.title = title ?? "" + self.artist = artist ?? "" + self.playerName = playerName ?? "" + self.playerAvatarData = playerAvatarData ?? Data() + } +} + +struct MappedRecord: Hashable, PlayerInfo { + var recordData: Data + var recordAmplitudes: [CGFloat] + var playerName: String + var playerAvatarData: Data + + init(_ recordData: Data?, _ recordAmplitudes: [CGFloat], _ playerName: String?, _ playerAvatarData: Data?) { + self.recordData = recordData ?? Data() + self.recordAmplitudes = recordAmplitudes + self.playerName = playerName ?? "" + self.playerAvatarData = playerAvatarData ?? Data() + } +} + +enum ResultPhase: Equatable { + case none + case answer + case record(Int) + case submit + + var playOption: PlayType { + switch self { + case .record: .full + default: .partial(time: 10) + } + } + + func audioData(_ result: Result) -> Data? { + switch self { + case .answer: result.answer?.previewData + case let .record(count): result.records[count].recordData + case .submit: result.submit?.previewData + default: nil + } + } +} + +enum PlayType { + case full + case partial(time: Int) +} diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewModel.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewModel.swift index 0607658c..98e5b468 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewModel.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/HummingResultViewModel.swift @@ -1,206 +1,248 @@ -import ASAudioKit import ASEntity import ASLogKit import ASRepositoryProtocol import Combine import Foundation +typealias Result = ( + answer: MappedAnswer?, + records: [MappedRecord], + submit: MappedAnswer? +) + final class HummingResultViewModel: @unchecked Sendable { private var hummingResultRepository: HummingResultRepositoryProtocol - private var avatarRepository: AvatarRepositoryProtocol private var gameStatusRepository: GameStatusRepositoryProtocol private var playerRepository: PlayersRepositoryProtocol private var roomActionRepository: RoomActionRepositoryProtocol private var roomInfoRepository: RoomInfoRepositoryProtocol - private var musicRepository: MusicRepositoryProtocol + private var dataDownloadRepository: DataDownloadRepositoryProtocol private var cancellables = Set() - - @Published var isNext: Bool = false - @Published var currentResult: Answer? - @Published var currentRecords: [ASEntity.Record] = [] - @Published var currentsubmit: Answer? + @Published var recordOrder: UInt8? = 0 @Published var isHost: Bool = false - - // 미리 받아놓을 정보 배열 - private var recordsResult: [ASEntity.Record] = [] - private var submitsResult: Answer? + @Published var result: Result = (nil, [], nil) + @Published var resultPhase: ResultPhase = .none + @Published var canEndGame: Bool = false + + var totalResult: [(answer: ASEntity.Answer, records: [ASEntity.Record], submit: ASEntity.Answer)] = [] + + var resultEnded: Bool { + totalResult.isEmpty + } + private var roomNumber: String = "" - - var hummingResult: [(answer: ASEntity.Answer, records: [ASEntity.Record], submit: ASEntity.Answer)] = [] - + init(hummingResultRepository: HummingResultRepositoryProtocol, - avatarRepository: AvatarRepositoryProtocol, gameStatusRepository: GameStatusRepositoryProtocol, playerRepository: PlayersRepositoryProtocol, roomActionRepository: RoomActionRepositoryProtocol, roomInfoRepository: RoomInfoRepositoryProtocol, - musicRepository: MusicRepositoryProtocol) + dataDownloadRepository: DataDownloadRepositoryProtocol) { self.hummingResultRepository = hummingResultRepository - self.avatarRepository = avatarRepository self.gameStatusRepository = gameStatusRepository self.playerRepository = playerRepository self.roomActionRepository = roomActionRepository self.roomInfoRepository = roomInfoRepository - self.musicRepository = musicRepository + self.dataDownloadRepository = dataDownloadRepository + bindPlayers() + bindRoomNumber() + bindRecordOrder() + bindAudio() } - - func fetchResult() { - hummingResultRepository.getResult() - .receive(on: DispatchQueue.main) - .sink { completion in - // TODO: 성공 실패 여부에 따른 처리 - Logger.debug(completion) - } receiveValue: { [weak self] (result: [(answer: ASEntity.Answer, records: [ASEntity.Record], submit: ASEntity.Answer, recordOrder: UInt8)]) in - guard let self else { return } - - if (result.count - 1) < result.first?.recordOrder ?? 0 { return } - Logger.debug("호출") - hummingResult = result.map { - (answer: $0.answer, records: $0.records, submit: $0.submit) - } - - hummingResult.sort { - $0.answer.player?.order ?? 0 < $1.answer.player?.order ?? 1 - } - - Logger.debug("hummingResult \(hummingResult)") - let current = hummingResult.removeFirst() - currentResult = current.answer - recordsResult = current.records - submitsResult = current.submit + /// RecordOrder 증가 시 호출, totalResult에서 첫번째 index를 pop하고 새로운 result로 변경 + private func updateCurrentResult() { + Task { + guard !totalResult.isEmpty else { return } + let displayableResult = totalResult.removeFirst() + let answer = await mapAnswer(displayableResult.answer) + let records = await mapRecords(displayableResult.records) + let submit = await mapAnswer(displayableResult.submit) + result = (answer, records, submit) + Logger.debug("updateCurrentResult에서 한번") + updateResultPhase() + } + } + + /// 오디오 재생이 끝난 후 호출, answer -> records(0...count) -> submit -> answer로 변경 + /// 변경 후 바로 오디오 재생 시작 + /// submit -> answer의 경우에는 recordOrder만 변경 + private func updateResultPhase() { + Task { + switch resultPhase { + case .answer: + Logger.debug("Answer Play") + resultPhase = .record(0) + await startPlaying() + case let .record(count): + Logger.debug("Record \(count) Play") + if result.records.count - 1 == count { resultPhase = .submit } + else { resultPhase = .record(count + 1) } + await startPlaying() + case .submit: + Logger.debug("Submit Play") + resultPhase = .none + await startPlaying() + if totalResult.isEmpty { canEndGame = true } + case .none: + Logger.debug("None") + resultPhase = .answer + await startPlaying() } - .store(in: &cancellables) - + } + } + + func changeRecordOrder() async { + do { + let succeded = try await roomActionRepository.changeRecordOrder(roomNumber: roomNumber) + if !succeded { Logger.error("Changing RecordOrder failed") } + } catch { + Logger.error(error.localizedDescription) + } + } + + func navigateToLobby() async { + do { + guard totalResult.isEmpty else { return } + let succeded = try await roomActionRepository.resetGame() + if !succeded { Logger.error("Game Reset failed") } + } catch { + Logger.error("Game Reset failed") + } + } + + func cancelSubscriptions() { + cancellables.removeAll() + } + + private func mapAnswer(_ answer: Answer) async -> MappedAnswer { + let artworkData = await getArtworkData(answer.music) + let previewData = await getPreviewData(answer.music) + let title = answer.music?.title + let artist = answer.music?.artist + let playerName = answer.player?.nickname + let playerAvatarData = await getAvatarData(url: answer.player?.avatarUrl) + return MappedAnswer(artworkData, previewData, title, artist, playerName, playerAvatarData) + } + + private func mapRecords(_ records: [ASEntity.Record]) async -> [MappedRecord] { + var mappedRecords = [MappedRecord]() + + for record in records { + let recordData = await getRecordData(url: record.fileUrl) + let recordAmplitudes = await AudioHelper.shared.analyze(with: recordData ?? Data()) + Logger.debug(recordAmplitudes) + let playerName = record.player?.nickname + let playerAvatarData = await getAvatarData(url: record.player?.avatarUrl) + mappedRecords.append(MappedRecord(recordData, recordAmplitudes, playerName, playerAvatarData)) + } + + return mappedRecords + } +} + +// MARK: - Play music + +extension HummingResultViewModel { + func startPlaying() async { + let audioData = resultPhase.audioData(result) + let playOption = resultPhase.playOption + await AudioHelper.shared.startPlaying(audioData, option: playOption) + } +} + +// MARK: - Bind with Repositories + +extension HummingResultViewModel { + private func bindPlayers() { playerRepository.isHost() .receive(on: DispatchQueue.main) .sink { [weak self] isHost in - self?.isHost = isHost + guard let self else { return } + self.isHost = isHost } .store(in: &cancellables) - + } + + private func bindRoomNumber() { roomInfoRepository.getRoomNumber() .receive(on: DispatchQueue.main) .sink { [weak self] roomNumber in - self?.roomNumber = roomNumber + guard let self else { return } + self.roomNumber = roomNumber } .store(in: &cancellables) - + } + + private func bindRecordOrder() { Publishers.CombineLatest(gameStatusRepository.getStatus(), gameStatusRepository.getRecordOrder()) .receive(on: DispatchQueue.main) - .sink { status, _ in - // order에 초기값이 들어오는 문제 - if status == .result, self.recordOrder != 0 { - self.isNext = true - } else { - self.recordOrder! += 1 - } + .dropFirst() + .sink { [weak self] _, recordOrder in + guard let self else { return } + Logger.debug("recordOrder changed", recordOrder) + self.recordOrder = recordOrder + updateCurrentResult() } .store(in: &cancellables) } - - func startPlaying() async { - await startPlayingCurrentMusic() - - while !recordsResult.isEmpty { - currentRecords.append(recordsResult.removeFirst()) - guard let fileUrl = currentRecords.last?.fileUrl else { continue } - do { - let data = try await fetchRecordData(url: fileUrl) - await AudioHelper.shared.startPlaying(data) - await waitForPlaybackToFinish() - } catch { - Logger.error("녹음 파일 다운로드에 실패하였습니다.") - } - } - currentsubmit = submitsResult - } - - private func startPlayingCurrentMusic() async -> Void { - guard let fileUrl = currentResult?.music?.previewUrl else { return } - let data = await musicRepository.getMusicData(url: fileUrl) - await AudioHelper.shared.startPlaying(data, option: .partial(time: 10)) - await waitForPlaybackToFinish() - } - - private func waitForPlaybackToFinish() async { - await withCheckedContinuation { continuation in - Task { - while await AudioHelper.shared.isPlaying() { - try? await Task.sleep(nanoseconds: 100_000_000) - } - continuation.resume() - } - } - } - - func nextResultFetch() { - if hummingResult.isEmpty { return } - let current = hummingResult.removeFirst() - currentResult = current.answer - recordsResult = current.records - submitsResult = current.submit - currentRecords.removeAll() - currentsubmit = nil - } - - private func getRecordData(url: URL?) -> AnyPublisher { - if let url { - hummingResultRepository.getRecordData(url: url) - .eraseToAnyPublisher() - } else { - Just(nil) - .setFailureType(to: Error.self) - .eraseToAnyPublisher() - } + + func bindResult() { + hummingResultRepository.getResult() + .receive(on: DispatchQueue.main) + .map { $0.sorted { $0.answer.player?.order ?? 0 < $1.answer.player?.order ?? 1 } } + .sink(receiveCompletion: { Logger.debug($0) }, + receiveValue: { [weak self] sortedResult in + guard let self, isValidResult(sortedResult) else { return } + + totalResult = sortedResult.map { ($0.answer, $0.records, $0.submit) } + updateCurrentResult() + }) + .store(in: &cancellables) } - - private func fetchRecordData(url: URL) async throws -> Data { - try await withCheckedThrowingContinuation { continuation in - getRecordData(url: url) - .compactMap { $0 } - .sink(receiveCompletion: { completion in - if case .failure(let error) = completion { - continuation.resume(throwing: error) + + private func bindAudio() { + Task { + await AudioHelper.shared.playerStatePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] _, isPlaying in + guard let self else { return } + if !isPlaying { + self.updateResultPhase() } - }, receiveValue: { data in - continuation.resume(returning: data) - }) + } .store(in: &cancellables) } } - - func getAvatarData(url: URL?) async -> Data? { + + func isValidResult(_ sortedResult: [(answer: Answer, records: [ASEntity.Record], submit: Answer, recordOrder: UInt8)]) -> Bool { + guard let firstRecordOrder = sortedResult.first?.recordOrder else { return false } + return (sortedResult.count - 1) >= firstRecordOrder + } +} + +// MARK: - Data Download + +private extension HummingResultViewModel { + private func getAvatarData(url: URL?) async -> Data? { guard let url else { return nil } - return await avatarRepository.getAvatarData(url: url) + return await dataDownloadRepository.downloadData(url: url) } - - func changeRecordOrder() async throws { - do { - try await roomActionRepository.changeRecordOrder(roomNumber: roomNumber) - } catch { - Logger.error(error.localizedDescription) - throw error - } + + private func getArtworkData(_ music: Music?) async -> Data? { + guard let url = music?.artworkUrl else { return nil } + return await dataDownloadRepository.downloadData(url: url) } - - func navigationToLobby() async throws { - do { - if !hummingResult.isEmpty { return } - try await roomActionRepository.resetGame() - } catch { - throw error - } + + private func getPreviewData(_ music: Music?) async -> Data? { + guard let url = music?.previewUrl else { return nil } + return await dataDownloadRepository.downloadData(url: url) } - - func getArtworkData(url: URL?) async -> Data? { + + private func getRecordData(url: URL?) async -> Data? { guard let url else { return nil } - return await musicRepository.getMusicData(url: url) - } - - func cancelSubscriptions() { - cancellables.removeAll() + return await dataDownloadRepository.downloadData(url: url) } } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/MusicPanelView.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/MusicPanelView.swift new file mode 100644 index 00000000..f5e7b13c --- /dev/null +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/Result/MusicPanelView.swift @@ -0,0 +1,98 @@ +import UIKit +import Combine +import ASEntity + +final class MusicPanelView: UIView { + private let albumImageView = UIImageView() + private let musicNameLabel = UILabel() + private let singerNameLabel = UILabel() + private let titleLabel = UILabel() + private var cancellables = Set() + + init() { + super.init(frame: .zero) + setupView() + setupConstraints() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + setupView() + setupConstraints() + } + + func bind( + to dataSource: Published.Publisher + ) { + dataSource + .receive(on: DispatchQueue.main) + .compactMap { $0 } + .sink { [weak self] result in + let answer = result.answer + self?.musicNameLabel.text = answer?.title + self?.singerNameLabel.text = answer?.artist + self?.setImage(with: answer?.artworkData) + } + .store(in: &cancellables) + } + + private func setImage(with data: Data?) { + guard let data else { return } + albumImageView.image = UIImage(data: data) + } + + private func setupView() { + backgroundColor = .asSystem + + titleLabel.text = "정답은..." + titleLabel.font = .font(ofSize: 24) + titleLabel.textColor = .asBlack + titleLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(titleLabel) + + albumImageView.contentMode = .scaleAspectFill + albumImageView.layer.cornerRadius = 6 + albumImageView.clipsToBounds = true + albumImageView.translatesAutoresizingMaskIntoConstraints = false + albumImageView.backgroundColor = .secondarySystemBackground + addSubview(albumImageView) + + musicNameLabel.font = .font(ofSize: 24) + musicNameLabel.textColor = .asBlack + musicNameLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(musicNameLabel) + + singerNameLabel.font = .font(ofSize: 24) + singerNameLabel.textColor = UIColor.gray + singerNameLabel.translatesAutoresizingMaskIntoConstraints = false + addSubview(singerNameLabel) + + layer.cornerRadius = 12 + layer.shadowColor = UIColor.asShadow.cgColor + layer.shadowOffset = CGSize(width: 4, height: 4) + layer.shadowRadius = 0 + layer.shadowOpacity = 1.0 + layer.borderWidth = 3 + layer.borderColor = UIColor.black.cgColor + } + + private func setupConstraints() { + NSLayoutConstraint.activate([ + titleLabel.topAnchor.constraint(equalTo: topAnchor, constant: 16), + titleLabel.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + + albumImageView.topAnchor.constraint(equalTo: titleLabel.bottomAnchor, constant: 12), + albumImageView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + albumImageView.widthAnchor.constraint(equalToConstant: 60), + albumImageView.heightAnchor.constraint(equalToConstant: 60), + + musicNameLabel.topAnchor.constraint(equalTo: albumImageView.topAnchor), + musicNameLabel.leadingAnchor.constraint(equalTo: albumImageView.trailingAnchor, constant: 15), + musicNameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + + singerNameLabel.topAnchor.constraint(equalTo: musicNameLabel.bottomAnchor, constant: 4), + singerNameLabel.leadingAnchor.constraint(equalTo: albumImageView.trailingAnchor, constant: 15), + singerNameLabel.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16) + ]) + } +} diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewController.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewController.swift index b0722949..8eb130ac 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewController.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewController.swift @@ -53,7 +53,6 @@ final class SelectMusicViewController: UIViewController { submissionStatus.translatesAutoresizingMaskIntoConstraints = false submitButton.translatesAutoresizingMaskIntoConstraints = false selectMusicView.view.translatesAutoresizingMaskIntoConstraints = false - let safeArea = view.safeAreaLayoutGuide NSLayoutConstraint.activate([ progressBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), @@ -62,16 +61,16 @@ final class SelectMusicViewController: UIViewController { progressBar.heightAnchor.constraint(equalToConstant: 16), selectMusicView.view.topAnchor.constraint(equalTo: progressBar.bottomAnchor), - selectMusicView.view.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor), - selectMusicView.view.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor), + selectMusicView.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + selectMusicView.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), selectMusicView.view.bottomAnchor.constraint(equalTo: submitButton.topAnchor, constant: -20), submissionStatus.topAnchor.constraint(equalTo: submitButton.topAnchor, constant: -16), submissionStatus.trailingAnchor.constraint(equalTo: submitButton.trailingAnchor, constant: 16), - submitButton.leadingAnchor.constraint(equalTo: safeArea.leadingAnchor, constant: 24), - submitButton.trailingAnchor.constraint(equalTo: safeArea.trailingAnchor, constant: -24), - submitButton.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -24), + submitButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), + submitButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + submitButton.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), submitButton.heightAnchor.constraint(equalToConstant: 64), ]) } @@ -82,7 +81,7 @@ final class SelectMusicViewController: UIViewController { }, for: .touchUpInside) progressBar.setCompletionHandler { [weak self] in - guard let selectedMusic = self?.viewModel.selectedMusic else { + guard self?.viewModel.selectedMusic != nil else { self?.showSubmitRandomMusicLoading() return } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewModel.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewModel.swift index 533c4064..4f2ac765 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewModel.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SelectMusic/SelectMusicViewModel.swift @@ -20,24 +20,24 @@ final class SelectMusicViewModel: ObservableObject, @unchecked Sendable { didSet { isPlaying ? playMusic() : stopMusic() } } - private let musicRepository: MusicRepositoryProtocol private let playersRepository: PlayersRepositoryProtocol private let answersRepository: AnswersRepositoryProtocol private let gameStatusRepository: GameStatusRepositoryProtocol + private let dataDownloadRepository: DataDownloadRepositoryProtocol private let musicAPI = ASMusicAPI() private var cancellables = Set() init( - musicRepository: MusicRepositoryProtocol, playersRepository: PlayersRepositoryProtocol, answerRepository: AnswersRepositoryProtocol, - gameStatusRepository: GameStatusRepositoryProtocol + gameStatusRepository: GameStatusRepositoryProtocol, + dataDownloadRepository: DataDownloadRepositoryProtocol ) { - self.musicRepository = musicRepository self.playersRepository = playersRepository self.answersRepository = answerRepository self.gameStatusRepository = gameStatusRepository + self.dataDownloadRepository = dataDownloadRepository bindGameStatus() bindAnswer() bindSubmissionStatus() @@ -89,7 +89,7 @@ final class SelectMusicViewModel: ObservableObject, @unchecked Sendable { func downloadArtwork(url: URL?) async -> Data? { guard let url else { return nil } - return await musicRepository.getMusicData(url: url) + return await dataDownloadRepository.downloadData(url: url) } func handleSelectedSong(with music: Music) { @@ -119,6 +119,7 @@ final class SelectMusicViewModel: ObservableObject, @unchecked Sendable { } } + @MainActor func randomMusic() async throws { do { selectedMusic = try await musicAPI.randomSong(from: "pl.u-aZb00o7uPlzMZzr") @@ -129,7 +130,7 @@ final class SelectMusicViewModel: ObservableObject, @unchecked Sendable { func downloadMusic(url: URL) { Task { - guard let musicData = await musicRepository.getMusicData(url: url) else { + guard let musicData = await dataDownloadRepository.downloadData(url: url) else { return } await updateMusicData(with: musicData) diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewController.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewController.swift index e4b06bf6..c1827e76 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewController.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewController.swift @@ -79,16 +79,16 @@ final class SubmitAnswerViewController: UIViewController { musicPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -32), selectedMusicPanel.topAnchor.constraint(equalTo: musicPanel.bottomAnchor, constant: 32), - selectedMusicPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), - selectedMusicPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + selectedMusicPanel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + selectedMusicPanel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), selectedMusicPanel.heightAnchor.constraint(equalToConstant: 100), submissionStatus.topAnchor.constraint(equalTo: buttonStack.topAnchor, constant: -16), submissionStatus.trailingAnchor.constraint(equalTo: buttonStack.trailingAnchor, constant: 16), - buttonStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -24), buttonStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24), buttonStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24), + buttonStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), buttonStack.heightAnchor.constraint(equalToConstant: 64), ]) } diff --git a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewModel.swift b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewModel.swift index c724969e..3e9f3a93 100644 --- a/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewModel.swift +++ b/alsongDalsong/alsongDalsong/alsongDalsong/Sources/Views/SubmitAnswer/SubmitAnswerViewModel.swift @@ -23,11 +23,11 @@ final class SubmitAnswerViewModel: ObservableObject, @unchecked Sendable { didSet { isPlaying ? playingMusic() : stopMusic() } } - private let musicRepository: MusicRepositoryProtocol private let gameStatusRepository: GameStatusRepositoryProtocol private let playersRepository: PlayersRepositoryProtocol private let recordsRepository: RecordsRepositoryProtocol private let submitsRepository: SubmitsRepositoryProtocol + private let dataDownloadRepository: DataDownloadRepositoryProtocol private let musicAPI = ASMusicAPI() private var cancellables: Set = [] @@ -37,13 +37,13 @@ final class SubmitAnswerViewModel: ObservableObject, @unchecked Sendable { playersRepository: PlayersRepositoryProtocol, recordsRepository: RecordsRepositoryProtocol, submitsRepository: SubmitsRepositoryProtocol, - musicRepository: MusicRepositoryProtocol + dataDownloadRepository: DataDownloadRepositoryProtocol ) { self.gameStatusRepository = gameStatusRepository self.playersRepository = playersRepository self.recordsRepository = recordsRepository self.submitsRepository = submitsRepository - self.musicRepository = musicRepository + self.dataDownloadRepository = dataDownloadRepository bindGameStatus() } @@ -105,12 +105,12 @@ final class SubmitAnswerViewModel: ObservableObject, @unchecked Sendable { func downloadArtwork(url: URL?) async -> Data? { guard let url else { return nil } - return await musicRepository.getMusicData(url: url) + return await dataDownloadRepository.downloadData(url: url) } func downloadMusic(url: URL) { Task { - guard let musicData = await musicRepository.getMusicData(url: url) else { + guard let musicData = await dataDownloadRepository.downloadData(url: url) else { return } await updateMusicData(with: musicData) @@ -143,6 +143,7 @@ final class SubmitAnswerViewModel: ObservableObject, @unchecked Sendable { guard let selectedMusic else { return } do { let response = try await submitsRepository.submitAnswer(answer: selectedMusic) + } catch { throw error }