diff --git a/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/ConnectionClientImpl.swift b/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/ConnectionClientImpl.swift index 42508a7b..c8f47238 100644 --- a/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/ConnectionClientImpl.swift +++ b/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/ConnectionClientImpl.swift @@ -76,6 +76,10 @@ public final class ConnectionClientImpl: ConnectionClient { return capturedImage } + public func toggleLocalAudioTrackState(isEnable: Bool) { + self.webRTCService.setLocalAudioState(isEnable) + } + /// remoteVideoTrack과 상대방의 화면을 볼 수 있는 뷰를 바인딩합니다. public func bindRemoteVideo() { guard let remoteVideoView = remoteVideoView as? RTCMTLVideoView else { return } diff --git a/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/ConnectionRepositoryImpl.swift b/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/ConnectionRepositoryImpl.swift index a2ee29d9..0b2007f0 100644 --- a/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/ConnectionRepositoryImpl.swift +++ b/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/ConnectionRepositoryImpl.swift @@ -14,6 +14,14 @@ public final class ConnectionRepositoryImpl: ConnectionRepository { public var didEnterNewUserPublisher: AnyPublisher<(UserInfo, UIView), Never> { didEnterNewUserSubject.eraseToAnyPublisher() } + private let didChangeLocalAudioTrackStateSubject = CurrentValueSubject(false) + public var didChangeLocalAudioTrackStatePublisher: AnyPublisher { + didChangeLocalAudioTrackStateSubject + .eraseToAnyPublisher() + } + public var currentLocalVideoInputState: Bool { + didChangeLocalAudioTrackStateSubject.value + } private let _localVideoView = CapturableVideoView() public private(set) var localUserInfo: UserInfo? @@ -73,7 +81,6 @@ public final class ConnectionRepositoryImpl: ConnectionRepository { let width2 = CMVideoFormatDescriptionGetDimensions(frame2.formatDescription).width return width1 < width2 }).first else { return } - // 가장 높은 fps 선택 guard let fps = (format.videoSupportedFrameRateRanges .sorted { return $0.maxFrameRate < $1.maxFrameRate }) @@ -144,6 +151,14 @@ public final class ConnectionRepositoryImpl: ConnectionRepository { ) } } + + public func switchLocalAudioTrackState() { + let presentAudioState = didChangeLocalAudioTrackStateSubject.value + clients.forEach { + $0.toggleLocalAudioTrackState(isEnable: !presentAudioState) + } + didChangeLocalAudioTrackStateSubject.send(!presentAudioState) + } } extension ConnectionRepositoryImpl { diff --git a/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/Interface/Service/WebRTCService.swift b/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/Interface/Service/WebRTCService.swift index e2302e2f..54db1c83 100644 --- a/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/Interface/Service/WebRTCService.swift +++ b/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/Interface/Service/WebRTCService.swift @@ -34,4 +34,5 @@ public protocol WebRTCService: RTCPeerConnectionDelegate, RTCDataChannelDelegate // MARK: Audio func muteAudio() func unmuteAudio() + func setLocalAudioState(_ isEnabled: Bool) } diff --git a/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/ServiceImpl/WebRTCServiceImpl.swift b/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/ServiceImpl/WebRTCServiceImpl.swift index bccbff39..db5d061a 100644 --- a/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/ServiceImpl/WebRTCServiceImpl.swift +++ b/PhotoGether/DataLayer/PhotoGetherData/PhotoGetherData/ServiceImpl/WebRTCServiceImpl.swift @@ -33,6 +33,7 @@ public final class WebRTCServiceImpl: NSObject, WebRTCService { ] private var localVideoTrack: RTCVideoTrack? private var remoteVideoTrack: RTCVideoTrack? + private var localAudioTrack: RTCAudioTrack private var localDataChannel: RTCDataChannel? private var remoteDataChannel: RTCDataChannel? @@ -42,7 +43,11 @@ public final class WebRTCServiceImpl: NSObject, WebRTCService { let audioConfig = RTCAudioSessionConfiguration.webRTC() audioConfig.category = AVAudioSession.Category.playAndRecord.rawValue audioConfig.mode = AVAudioSession.Mode.voiceChat.rawValue - audioConfig.categoryOptions = [.defaultToSpeaker] + audioConfig.categoryOptions = [ + .defaultToSpeaker, // 하단 스피커를 기본으로 설정 + .allowBluetooth, // 블루투스 기기의 음성 입출력 지원 + .allowAirPlay // AirPlay를 통해 연결된 다른 기기로 음성 출력 지원 + ] RTCAudioSessionConfiguration.setWebRTC(audioConfig) let mediaConstraint = PeerConnectionSupport.mediaConstraint() @@ -57,6 +62,8 @@ public final class WebRTCServiceImpl: NSObject, WebRTCService { } self.peerConnection = peerConnection + // MARK: AudioTrack 생성 + self.localAudioTrack = PeerConnectionSupport.createAudioTrack() super.init() @@ -64,9 +71,9 @@ public final class WebRTCServiceImpl: NSObject, WebRTCService { self.connectDataChannel(dataChannel: createDataChannel()) // MARK: AudioTrack 연결 - let audioTrack = PeerConnectionSupport.createAudioTrack() - self.connectAudioTrack(audioTrack: audioTrack) + self.connectAudioTrack(audioTrack: self.localAudioTrack) self.configureAudioSession() + self.localAudioTrack.isEnabled = false self.peerConnection.delegate = self self.bindNoti() @@ -222,12 +229,12 @@ public extension WebRTCServiceImpl { private func configureAudioSession() { self.rtcAudioSession.lockForConfiguration() do { + try self.rtcAudioSession.setCategory( .playAndRecord, mode: .voiceChat, options: .defaultToSpeaker ) - try self.rtcAudioSession.overrideOutputAudioPort(.speaker) try self.rtcAudioSession.setActive(true) } catch let error { PTGLogger.default.log("Error changeing AVAudioSession category: \(error)") @@ -269,6 +276,10 @@ public extension WebRTCServiceImpl { self.setAudioEnabled(true) } + func setLocalAudioState(_ isEnabled: Bool) { + self.localAudioTrack.isEnabled = isEnabled + } + private func setAudioEnabled(_ isEnabled: Bool) { setTrackEnabled(RTCAudioTrack.self, isEnabled: isEnabled) } diff --git a/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomain/UseCaseImpl/GetVoiceInputStateUseCaseImpl.swift b/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomain/UseCaseImpl/GetVoiceInputStateUseCaseImpl.swift new file mode 100644 index 00000000..23ba447c --- /dev/null +++ b/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomain/UseCaseImpl/GetVoiceInputStateUseCaseImpl.swift @@ -0,0 +1,13 @@ +import PhotoGetherDomainInterface + +public final class GetVoiceInputStateUseCaseImpl: GetVoiceInputStateUseCase { + public func execute() -> Bool { + return connectionRepository.currentLocalVideoInputState + } + + private let connectionRepository: ConnectionRepository + + public init(connectionRepository: ConnectionRepository) { + self.connectionRepository = connectionRepository + } +} diff --git a/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomain/UseCaseImpl/ToggleLocalMicStateUseCaseImpl.swift b/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomain/UseCaseImpl/ToggleLocalMicStateUseCaseImpl.swift new file mode 100644 index 00000000..4fd81550 --- /dev/null +++ b/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomain/UseCaseImpl/ToggleLocalMicStateUseCaseImpl.swift @@ -0,0 +1,15 @@ +import Combine +import PhotoGetherDomainInterface + +public final class ToggleLocalMicStateUseCaseImpl: ToggleLocalMicStateUseCase { + public func execute() -> AnyPublisher { + connectionRepository.switchLocalAudioTrackState() + return connectionRepository.didChangeLocalAudioTrackStatePublisher + } + + private let connectionRepository: ConnectionRepository + + public init(connectionRepository: ConnectionRepository) { + self.connectionRepository = connectionRepository + } +} diff --git a/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomainInterface/ConnectionClient.swift b/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomainInterface/ConnectionClient.swift index 3a0202bb..8c51ca41 100644 --- a/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomainInterface/ConnectionClient.swift +++ b/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomainInterface/ConnectionClient.swift @@ -19,6 +19,7 @@ public protocol ConnectionClient { func setRemoteUserInfo(_ remoteUserInfo: UserInfo) func sendData(data: Data) func captureVideo() -> UIImage + func toggleLocalAudioTrackState(isEnable: Bool) func bindLocalVideo(videoSource: RTCVideoSource?, _ localVideoView: UIView) func bindRemoteVideo() } diff --git a/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomainInterface/Repository/ConnectionRepository.swift b/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomainInterface/Repository/ConnectionRepository.swift index 23e0a4a1..e135400b 100644 --- a/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomainInterface/Repository/ConnectionRepository.swift +++ b/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomainInterface/Repository/ConnectionRepository.swift @@ -3,15 +3,18 @@ import Combine public protocol ConnectionRepository { var didEnterNewUserPublisher: AnyPublisher<(UserInfo, UIView), Never> { get } + var didChangeLocalAudioTrackStatePublisher: AnyPublisher { get } var localUserInfo: UserInfo? { get } var clients: [ConnectionClient] { get } var localVideoView: UIView { get } var capturedLocalVideo: UIImage? { get } + var currentLocalVideoInputState: Bool { get } func createRoom() -> AnyPublisher func joinRoom(to roomID: String, hostID: String) -> AnyPublisher func sendOffer() async throws - func stopCaptureLocalVideo() -> Bool + func stopCaptureLocalVideo() -> Bool + func switchLocalAudioTrackState() } diff --git a/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomainInterface/UseCase/GetVoiceInputStateUseCase.swift b/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomainInterface/UseCase/GetVoiceInputStateUseCase.swift new file mode 100644 index 00000000..39660665 --- /dev/null +++ b/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomainInterface/UseCase/GetVoiceInputStateUseCase.swift @@ -0,0 +1,3 @@ +public protocol GetVoiceInputStateUseCase { + func execute() -> Bool +} diff --git a/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomainInterface/UseCase/ToggleLocalMicStateUseCase.swift b/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomainInterface/UseCase/ToggleLocalMicStateUseCase.swift new file mode 100644 index 00000000..6ce73d9d --- /dev/null +++ b/PhotoGether/DomainLayer/PhotoGetherDomain/PhotoGetherDomainInterface/UseCase/ToggleLocalMicStateUseCase.swift @@ -0,0 +1,5 @@ +import Combine + +public protocol ToggleLocalMicStateUseCase { + func execute() -> AnyPublisher +} diff --git a/PhotoGether/PhotoGether/App/SceneDelegate.swift b/PhotoGether/PhotoGether/App/SceneDelegate.swift index 6e242ff0..3717fd80 100644 --- a/PhotoGether/PhotoGether/App/SceneDelegate.swift +++ b/PhotoGether/PhotoGether/App/SceneDelegate.swift @@ -30,14 +30,14 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { roomOwnerEntity = DeepLinkParser.parseRoomInfo(from: urlContext.url) } - let webScoketClient: WebSocketClient = WebSocketClientImpl(url: url) + let webSocketClient: WebSocketClient = WebSocketClientImpl(url: url) let roomService: RoomService = RoomServiceImpl( - webSocketClient: webScoketClient + webSocketClient: webSocketClient ) let signalingService: SignalingService = SignalingServiceImpl( - webSocketClient: webScoketClient + webSocketClient: webSocketClient ) let connectionRepository: ConnectionRepository = ConnectionRepositoryImpl( @@ -90,10 +90,20 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { connectionRepository: connectionRepository ) + let toggleLocalMicStateUseCaseImpl = ToggleLocalMicStateUseCaseImpl( + connectionRepository: connectionRepository + ) + + let getVoiceInputStateUseCaseImpl = GetVoiceInputStateUseCaseImpl( + connectionRepository: connectionRepository + ) + let photoRoomViewModel: PhotoRoomViewModel = PhotoRoomViewModel( captureVideosUseCase: captureVideosUseCase, stopVideoCaptureUseCase: stopVideoCaptureUseCase, - getUserInfoUseCase: getLocalVideoUseCase + getUserInfoUseCase: getLocalVideoUseCase, + toggleLocalMicStateUseCase: toggleLocalMicStateUseCaseImpl, + getVoiceInputStateUseCase: getVoiceInputStateUseCaseImpl ) let localDataSource = LocalShapeDataSourceImpl() @@ -150,7 +160,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { receiveStickerListUseCase: receiveStickerListHostUseCase, receiveFrameUseCase: receiveFrameHostUseCase, sendStickerToRepositoryUseCase: sendStickerToRepositoryHostUseCase, - sendFrameToRepositoryUseCase: sendFrameToRepositoryHostUseCase + sendFrameToRepositoryUseCase: sendFrameToRepositoryHostUseCase, + toggleLocalMicStateUseCase: toggleLocalMicStateUseCaseImpl, + getVoiceInputStateUseCase: getVoiceInputStateUseCaseImpl ) let stickerBottomSheetViewModel = StickerBottomSheetViewModel( @@ -174,7 +186,9 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { receiveStickerListUseCase: receiveStickerListGuestUseCase, receiveFrameUseCase: receiveFrameGuestUseCase, sendStickerToRepositoryUseCase: sendStickerToRepositoryGuestUseCase, - sendFrameToRepositoryUseCase: sendFrameToRepositoryGuestUseCase + sendFrameToRepositoryUseCase: sendFrameToRepositoryGuestUseCase, + toggleLocalMicStateUseCase: toggleLocalMicStateUseCaseImpl, + getVoiceInputStateUseCase: getVoiceInputStateUseCaseImpl ) let editPhotoRoomGuestViewController = EditPhotoRoomGuestViewController( @@ -196,7 +210,8 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { getLocalVideoUseCase: getLocalVideoUseCase, getRemoteVideoUseCase: getRemoteVideoUseCase, createRoomUseCase: createRoomUseCase, - didEnterNewUserPublisherUseCase: didEnterNewUserPublisherUseCase + didEnterNewUserPublisherUseCase: didEnterNewUserPublisherUseCase, + toggleLocalMicStateUseCase: toggleLocalMicStateUseCaseImpl ) let waitingRoomViewController: WaitingRoomViewController = WaitingRoomViewController( diff --git a/PhotoGether/PresentationLayer/DesignSystem/DesignSystem/Source/PTGMicButton.swift b/PhotoGether/PresentationLayer/DesignSystem/DesignSystem/Source/PTGMicButton.swift index f9af0439..405c7690 100644 --- a/PhotoGether/PresentationLayer/DesignSystem/DesignSystem/Source/PTGMicButton.swift +++ b/PhotoGether/PresentationLayer/DesignSystem/DesignSystem/Source/PTGMicButton.swift @@ -42,13 +42,8 @@ public final class PTGMicButton: UIButton { layer.cornerRadius = bounds.width / 2 } - public func toggleMicState() { - switch micState { - case .on: - micState = .off - case .off: - micState = .on - } + public func toggleMicState(_ isOn: Bool) { + micState = isOn ? .on : .off buttonImage.image = UIImage(systemName: micState.image) buttonImage.tintColor = micState.color diff --git a/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomGuestViewController.swift b/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomGuestViewController.swift index bb681500..e5effffd 100644 --- a/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomGuestViewController.swift +++ b/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomGuestViewController.swift @@ -11,6 +11,7 @@ import SharePhotoFeature public class EditPhotoRoomGuestViewController: BaseViewController, ViewControllerConfigure { private let navigationView = UIView() private let canvasScrollView = CanvasScrollView() + private let micButton = PTGMicButton(micState: .on) private let bottomView = EditPhotoGuestBottomView() private let bottomSheetViewController: StickerBottomSheetViewController @@ -43,6 +44,9 @@ public class EditPhotoRoomGuestViewController: BaseViewController, ViewControlle bindInput() bindOutput() bindNoti() + micButton.toggleMicState( + viewModel.fetchLocalVoiceInputState() + ) input.send(.initialState) } @@ -55,7 +59,7 @@ public class EditPhotoRoomGuestViewController: BaseViewController, ViewControlle } public func addViews() { - [navigationView, canvasScrollView, bottomView].forEach { + [navigationView, canvasScrollView, bottomView, micButton].forEach { view.addSubview($0) } } @@ -78,6 +82,12 @@ public class EditPhotoRoomGuestViewController: BaseViewController, ViewControlle $0.horizontalEdges.equalToSuperview() $0.height.equalTo(80) } + + micButton.snp.makeConstraints { + $0.bottom.equalTo(bottomView.snp.top).offset(-4) + $0.leading.equalToSuperview().offset(16) + $0.size.equalTo(52) + } } public func configureUI() { @@ -100,6 +110,12 @@ public class EditPhotoRoomGuestViewController: BaseViewController, ViewControlle self?.input.send(.frameButtonDidTap) } .store(in: &cancellables) + + micButton.tapPublisher + .sink { [weak self] in + self?.input.send(.micButtonDidTap) + } + .store(in: &cancellables) } public func bindOutput() { @@ -115,6 +131,8 @@ public class EditPhotoRoomGuestViewController: BaseViewController, ViewControlle self?.updateFrameImage(to: image) case .presentStickerBottomSheet: self?.presentStickerBottomSheet() + case .voiceInputState(let isOn): + self?.micButton.toggleMicState(isOn) } } .store(in: &cancellables) diff --git a/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomGuestViewModel.swift b/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomGuestViewModel.swift index 2a5b0def..8ff585e9 100644 --- a/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomGuestViewModel.swift +++ b/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomGuestViewModel.swift @@ -13,12 +13,14 @@ public final class EditPhotoRoomGuestViewModel { case dragSticker(StickerEntity, PanGestureState) case resizeSticker(StickerEntity, PanGestureState) case stickerViewDidTap(UUID) + case micButtonDidTap } enum Output { case stickerList([StickerEntity]) case frameImage(image: UIImage) case presentStickerBottomSheet + case voiceInputState(Bool) } private var frameImageGenerator: FrameImageGenerator? @@ -26,6 +28,8 @@ public final class EditPhotoRoomGuestViewModel { private let receiveFrameUseCase: ReceiveFrameUseCase private let sendStickerToRepositoryUseCase: SendStickerToRepositoryUseCase private let sendFrameToRepositoryUseCase: SendFrameToRepositoryUseCase + private let toggleLocalMicStateUseCase: ToggleLocalMicStateUseCase + private let getVoiceInputStateUseCase: GetVoiceInputStateUseCase private(set) var userInfo: UserInfo! @@ -39,12 +43,16 @@ public final class EditPhotoRoomGuestViewModel { receiveStickerListUseCase: ReceiveStickerListUseCase, receiveFrameUseCase: ReceiveFrameUseCase, sendStickerToRepositoryUseCase: SendStickerToRepositoryUseCase, - sendFrameToRepositoryUseCase: SendFrameToRepositoryUseCase + sendFrameToRepositoryUseCase: SendFrameToRepositoryUseCase, + toggleLocalMicStateUseCase: ToggleLocalMicStateUseCase, + getVoiceInputStateUseCase: GetVoiceInputStateUseCase ) { self.receiveStickerListUseCase = receiveStickerListUseCase self.receiveFrameUseCase = receiveFrameUseCase self.sendStickerToRepositoryUseCase = sendStickerToRepositoryUseCase self.sendFrameToRepositoryUseCase = sendFrameToRepositoryUseCase + self.toggleLocalMicStateUseCase = toggleLocalMicStateUseCase + self.getVoiceInputStateUseCase = getVoiceInputStateUseCase bind() } @@ -100,12 +108,18 @@ public final class EditPhotoRoomGuestViewModel { self?.handleDragSticker(sticker: sticker, state: dragState) case .resizeSticker(let sticker, let resizeState): self?.handleResizeSticker(sticker: sticker, state: resizeState) + case .micButtonDidTap: + self?.handleMicButtonDidTap() } } .store(in: &cancellables) return output.eraseToAnyPublisher() } + + func fetchLocalVoiceInputState() -> Bool { + getVoiceInputStateUseCase.execute() + } } // MARK: Sticker @@ -310,3 +324,13 @@ extension EditPhotoRoomGuestViewModel { output.send(.presentStickerBottomSheet) } } + +// MARK: Voice Input Control +extension EditPhotoRoomGuestViewModel { + private func handleMicButtonDidTap() { + toggleLocalMicStateUseCase.execute() + .sink { [weak self] state in + self?.output.send(.voiceInputState(state)) + }.store(in: &cancellables) + } +} diff --git a/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomHostViewController.swift b/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomHostViewController.swift index deafbaf0..50e670b3 100644 --- a/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomHostViewController.swift +++ b/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomHostViewController.swift @@ -11,6 +11,7 @@ import SharePhotoFeature public class EditPhotoRoomHostViewController: BaseViewController, ViewControllerConfigure { private let navigationView = UIView() private let canvasScrollView = CanvasScrollView() + private let micButton = PTGMicButton(micState: .on) private let bottomView = EditPhotoHostBottomView() private let bottomSheetViewController: StickerBottomSheetViewController @@ -41,11 +42,14 @@ public class EditPhotoRoomHostViewController: BaseViewController, ViewController configureUI() bindInput() bindOutput() + micButton.toggleMicState( + viewModel.fetchLocalVoiceInputState() + ) input.send(.initialState) } public func addViews() { - [navigationView, canvasScrollView, bottomView].forEach { + [navigationView, canvasScrollView, bottomView, micButton].forEach { view.addSubview($0) } } @@ -68,6 +72,12 @@ public class EditPhotoRoomHostViewController: BaseViewController, ViewController $0.horizontalEdges.equalToSuperview() $0.height.equalTo(80) } + + micButton.snp.makeConstraints { + $0.bottom.equalTo(bottomView.snp.top).offset(-4) + $0.leading.equalToSuperview().offset(16) + $0.size.equalTo(52) + } } public func configureUI() { @@ -98,6 +108,12 @@ public class EditPhotoRoomHostViewController: BaseViewController, ViewController self?.showNextView() } .store(in: &cancellables) + + micButton.tapPublisher + .sink { [weak self] in + self?.input.send(.micButtonDidTap) + } + .store(in: &cancellables) } public func bindOutput() { @@ -113,6 +129,8 @@ public class EditPhotoRoomHostViewController: BaseViewController, ViewController self?.updateFrameImage(to: image) case .presentStickerBottomSheet: self?.presentStickerBottomSheet() + case .voiceInputState(let isOn): + self?.micButton.toggleMicState(isOn) } } .store(in: &cancellables) diff --git a/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomHostViewModel.swift b/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomHostViewModel.swift index 7b653484..586e83a9 100644 --- a/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomHostViewModel.swift +++ b/PhotoGether/PresentationLayer/EditPhotoRoomFeature/EditPhotoRoomFeature/Source/EditPhotoRoomHostViewModel.swift @@ -13,12 +13,14 @@ public final class EditPhotoRoomHostViewModel { case dragSticker(StickerEntity, PanGestureState) case resizeSticker(StickerEntity, PanGestureState) case stickerViewDidTap(UUID) + case micButtonDidTap } enum Output { case stickerList([StickerEntity]) case frameImage(image: UIImage) case presentStickerBottomSheet + case voiceInputState(Bool) } private var frameImageGenerator: FrameImageGenerator? @@ -26,6 +28,8 @@ public final class EditPhotoRoomHostViewModel { private let receiveFrameUseCase: ReceiveFrameUseCase private let sendStickerToRepositoryUseCase: SendStickerToRepositoryUseCase private let sendFrameToRepositoryUseCase: SendFrameToRepositoryUseCase + private let toggleLocalMicStateUseCase: ToggleLocalMicStateUseCase + private let getVoiceInputStateUseCase: GetVoiceInputStateUseCase private(set) var userInfo: UserInfo! @@ -39,12 +43,16 @@ public final class EditPhotoRoomHostViewModel { receiveStickerListUseCase: ReceiveStickerListUseCase, receiveFrameUseCase: ReceiveFrameUseCase, sendStickerToRepositoryUseCase: SendStickerToRepositoryUseCase, - sendFrameToRepositoryUseCase: SendFrameToRepositoryUseCase + sendFrameToRepositoryUseCase: SendFrameToRepositoryUseCase, + toggleLocalMicStateUseCase: ToggleLocalMicStateUseCase, + getVoiceInputStateUseCase: GetVoiceInputStateUseCase ) { self.receiveStickerListUseCase = receiveStickerListUseCase self.receiveFrameUseCase = receiveFrameUseCase self.sendStickerToRepositoryUseCase = sendStickerToRepositoryUseCase self.sendFrameToRepositoryUseCase = sendFrameToRepositoryUseCase + self.toggleLocalMicStateUseCase = toggleLocalMicStateUseCase + self.getVoiceInputStateUseCase = getVoiceInputStateUseCase bind() } @@ -101,12 +109,18 @@ public final class EditPhotoRoomHostViewModel { self?.handleDragSticker(sticker: sticker, state: dragState) case .resizeSticker(let sticker, let resizeState): self?.handleResizeSticker(sticker: sticker, state: resizeState) + case .micButtonDidTap: + self?.handleMicButtonDidTap() } } .store(in: &cancellables) return output.eraseToAnyPublisher() } + + func fetchLocalVoiceInputState() -> Bool { + getVoiceInputStateUseCase.execute() + } } // MARK: Sticker @@ -311,3 +325,13 @@ extension EditPhotoRoomHostViewModel { output.send(.presentStickerBottomSheet) } } + +// MARK: Voice Input Control +extension EditPhotoRoomHostViewModel { + private func handleMicButtonDidTap() { + toggleLocalMicStateUseCase.execute() + .sink { [weak self] state in + self?.output.send(.voiceInputState(state)) + }.store(in: &cancellables) + } +} diff --git a/PhotoGether/PresentationLayer/PhotoRoomFeature/PhotoRoomFeature/Source/View/CameraButton.swift b/PhotoGether/PresentationLayer/PhotoRoomFeature/PhotoRoomFeature/Source/View/CameraButton.swift index c483846f..deb5d17f 100644 --- a/PhotoGether/PresentationLayer/PhotoRoomFeature/PhotoRoomFeature/Source/View/CameraButton.swift +++ b/PhotoGether/PresentationLayer/PhotoRoomFeature/PhotoRoomFeature/Source/View/CameraButton.swift @@ -66,6 +66,7 @@ final class CameraButton: UIButton { func configureTimer(_ count: Int) { innerCircle.isHidden = true + innerEllipsis.isHidden = true timerLabel.isHidden = false timerLabel.text = "\(count)" } diff --git a/PhotoGether/PresentationLayer/PhotoRoomFeature/PhotoRoomFeature/Source/ViewController/PhotoRoomViewController.swift b/PhotoGether/PresentationLayer/PhotoRoomFeature/PhotoRoomFeature/Source/ViewController/PhotoRoomViewController.swift index 0c216c46..44605167 100644 --- a/PhotoGether/PresentationLayer/PhotoRoomFeature/PhotoRoomFeature/Source/ViewController/PhotoRoomViewController.swift +++ b/PhotoGether/PresentationLayer/PhotoRoomFeature/PhotoRoomFeature/Source/ViewController/PhotoRoomViewController.swift @@ -14,9 +14,9 @@ public final class PhotoRoomViewController: BaseViewController, ViewControllerCo private let editPhotoRoomHostViewController: EditPhotoRoomHostViewController private let editPhotoRoomGuestViewController: EditPhotoRoomGuestViewController private let photoRoomBottomView: PhotoRoomBottomView + private let micButton: PTGMicButton private var isHost: Bool - private let input = PassthroughSubject() private let viewModel: PhotoRoomViewModel @@ -34,6 +34,7 @@ public final class PhotoRoomViewController: BaseViewController, ViewControllerCo self.viewModel = viewModel self.isHost = isHost self.photoRoomBottomView = PhotoRoomBottomView(isHost: isHost) + self.micButton = PTGMicButton(micState: .off) super.init(nibName: nil, bundle: nil) } @@ -53,6 +54,9 @@ public final class PhotoRoomViewController: BaseViewController, ViewControllerCo bindInput() bindOutput() bindNoti() + micButton.toggleMicState( + viewModel.fetchLocalVideoInputState() + ) } private func bindNoti() { @@ -69,7 +73,7 @@ public final class PhotoRoomViewController: BaseViewController, ViewControllerCo } public func addViews() { - [navigationView, participantsGridView, photoRoomBottomView].forEach { + [navigationView, participantsGridView, photoRoomBottomView, micButton].forEach { view.addSubview($0) } } @@ -92,6 +96,14 @@ public final class PhotoRoomViewController: BaseViewController, ViewControllerCo $0.horizontalEdges.equalToSuperview() $0.height.equalTo(Constants.bottomViewHeight) } + + micButton.snp.makeConstraints { + $0.bottom.equalTo(photoRoomBottomView.snp.top) + .inset(Constants.micButtonBottomSpacing) + $0.leading.equalTo(view.safeAreaLayoutGuide) + .offset(Constants.micButtonLeadingSpacing) + $0.size.equalTo(Constants.circleButtonSize) + } } public func configureUI() { @@ -108,6 +120,11 @@ public final class PhotoRoomViewController: BaseViewController, ViewControllerCo self?.input.send(.cameraButtonTapped) } .store(in: &cancellables) + + micButton.tapPublisher + .sink { [weak self] in + self?.input.send(.micButtonTapped) + }.store(in: &cancellables) } public func bindOutput() { @@ -132,6 +149,9 @@ public final class PhotoRoomViewController: BaseViewController, ViewControllerCo editPhotoRoomGuestViewController.inject(frameImageGenerator, userInfo: userInfo) self.navigationController?.pushViewController(editPhotoRoomGuestViewController, animated: true) } + case .voiceInputState(let isOn): + micButton.toggleMicState(isOn) + return } } .store(in: &cancellables) @@ -142,5 +162,8 @@ extension PhotoRoomViewController { private enum Constants { static let bottomViewHeight: CGFloat = 80 static let navigationHeight: CGFloat = 48 + static let circleButtonSize: CGSize = CGSize(width: 52, height: 52) + static let micButtonBottomSpacing: CGFloat = -4 + static let micButtonLeadingSpacing: CGFloat = 16 } } diff --git a/PhotoGether/PresentationLayer/PhotoRoomFeature/PhotoRoomFeature/Source/ViewModel/PhotoRoomViewModel.swift b/PhotoGether/PresentationLayer/PhotoRoomFeature/PhotoRoomFeature/Source/ViewModel/PhotoRoomViewModel.swift index c7c6c198..ad5a2287 100644 --- a/PhotoGether/PresentationLayer/PhotoRoomFeature/PhotoRoomFeature/Source/ViewModel/PhotoRoomViewModel.swift +++ b/PhotoGether/PresentationLayer/PhotoRoomFeature/PhotoRoomFeature/Source/ViewModel/PhotoRoomViewModel.swift @@ -8,11 +8,13 @@ public final class PhotoRoomViewModel { enum Input { case cameraButtonTapped + case micButtonTapped } enum Output { case timer(count: Int) case timerCompleted(images: [UIImage], userInfo: UserInfo?) + case voiceInputState(Bool) } private var output = PassthroughSubject() @@ -20,15 +22,21 @@ public final class PhotoRoomViewModel { private let captureVideosUseCase: CaptureVideosUseCase private let stopVideoCaptureUseCase: StopVideoCaptureUseCase private let getUserInfoUseCase: GetLocalVideoUseCase + private let toggleLocalMicStateUseCase: ToggleLocalMicStateUseCase + private let getVoiceInputStateUseCase: GetVoiceInputStateUseCase public init( captureVideosUseCase: CaptureVideosUseCase, stopVideoCaptureUseCase: StopVideoCaptureUseCase, - getUserInfoUseCase: GetLocalVideoUseCase + getUserInfoUseCase: GetLocalVideoUseCase, + toggleLocalMicStateUseCase: ToggleLocalMicStateUseCase, + getVoiceInputStateUseCase: GetVoiceInputStateUseCase ) { self.captureVideosUseCase = captureVideosUseCase self.stopVideoCaptureUseCase = stopVideoCaptureUseCase self.getUserInfoUseCase = getUserInfoUseCase + self.toggleLocalMicStateUseCase = toggleLocalMicStateUseCase + self.getVoiceInputStateUseCase = getVoiceInputStateUseCase } func transform(input: AnyPublisher) -> AnyPublisher { @@ -38,12 +46,16 @@ public final class PhotoRoomViewModel { switch $0 { case .cameraButtonTapped: self.startTimer() + case .micButtonTapped: + self.handleMicButtonDidTap() } }.store(in: &cancellables) return output.eraseToAnyPublisher() } + func fetchLocalVideoInputState() -> Bool { getVoiceInputStateUseCase.execute() } + private func startTimer() { guard let userInfo = getUserInfoUseCase.execute().0 else { return } self.userInfo = userInfo @@ -71,4 +83,11 @@ public final class PhotoRoomViewModel { } } } + + private func handleMicButtonDidTap() { + toggleLocalMicStateUseCase.execute() + .sink { [weak self] state in + self?.output.send(.voiceInputState(state)) + }.store(in: &cancellables) + } } diff --git a/PhotoGether/PresentationLayer/WaitingRoomFeature/WaitingRoomFeature/Source/View/WaitingRoomView.swift b/PhotoGether/PresentationLayer/WaitingRoomFeature/WaitingRoomFeature/Source/View/WaitingRoomView.swift index 0720210f..f2a63627 100644 --- a/PhotoGether/PresentationLayer/WaitingRoomFeature/WaitingRoomFeature/Source/View/WaitingRoomView.swift +++ b/PhotoGether/PresentationLayer/WaitingRoomFeature/WaitingRoomFeature/Source/View/WaitingRoomView.swift @@ -5,7 +5,7 @@ import CoreModule final class WaitingRoomView: UIView { let bottomBarView = UIView() - let micButton = PTGMicButton(micState: .on) + let micButton = PTGMicButton(micState: .off) let linkButton = PTGCircleButton(type: .link) let startButton = PTGPrimaryButton() let particiapntsGridView = PTGParticipantsGridView() @@ -22,8 +22,8 @@ final class WaitingRoomView: UIView { fatalError("init(coder:) has not been implemented") } - func toggleMicButtonState() { - micButton.toggleMicState() + func toggleMicButtonState(isOn: Bool) { + micButton.toggleMicState(isOn) } func updateStartButtonTitle(count: Int) { diff --git a/PhotoGether/PresentationLayer/WaitingRoomFeature/WaitingRoomFeature/Source/ViewController/WaitingRoomViewController.swift b/PhotoGether/PresentationLayer/WaitingRoomFeature/WaitingRoomFeature/Source/ViewController/WaitingRoomViewController.swift index 9d48e88b..be6eff36 100644 --- a/PhotoGether/PresentationLayer/WaitingRoomFeature/WaitingRoomFeature/Source/ViewController/WaitingRoomViewController.swift +++ b/PhotoGether/PresentationLayer/WaitingRoomFeature/WaitingRoomFeature/Source/ViewController/WaitingRoomViewController.swift @@ -101,7 +101,8 @@ public final class WaitingRoomViewController: BaseViewController { updateParticipantNickname(nickname: nickname, position: participantPosition) // MARK: 마이크 음소거 UI 업데이트 - case .micMuteState: + case .micMuteState(let isOn): + waitingRoomView.toggleMicButtonState(isOn: isOn) return // MARK: 초대를 위한 공유시트 present diff --git a/PhotoGether/PresentationLayer/WaitingRoomFeature/WaitingRoomFeature/Source/ViewModel/WaitingRoomViewModel.swift b/PhotoGether/PresentationLayer/WaitingRoomFeature/WaitingRoomFeature/Source/ViewModel/WaitingRoomViewModel.swift index 91e6326d..0c1061b4 100644 --- a/PhotoGether/PresentationLayer/WaitingRoomFeature/WaitingRoomFeature/Source/ViewModel/WaitingRoomViewModel.swift +++ b/PhotoGether/PresentationLayer/WaitingRoomFeature/WaitingRoomFeature/Source/ViewModel/WaitingRoomViewModel.swift @@ -25,6 +25,7 @@ public final class WaitingRoomViewModel { private let getRemoteVideoUseCase: GetRemoteVideoUseCase private let createRoomUseCase: CreateRoomUseCase private let didEnterNewUserPublisherUseCase: DidEnterNewUserPublisherUseCase + private let toggleLocalMicStateUseCase: ToggleLocalMicStateUseCase private var isHost: Bool private var cancellables = Set() @@ -36,7 +37,8 @@ public final class WaitingRoomViewModel { getLocalVideoUseCase: GetLocalVideoUseCase, getRemoteVideoUseCase: GetRemoteVideoUseCase, createRoomUseCase: CreateRoomUseCase, - didEnterNewUserPublisherUseCase: DidEnterNewUserPublisherUseCase + didEnterNewUserPublisherUseCase: DidEnterNewUserPublisherUseCase, + toggleLocalMicStateUseCase: ToggleLocalMicStateUseCase ) { self.isHost = isHost self.sendOfferUseCase = sendOfferUseCase @@ -44,6 +46,7 @@ public final class WaitingRoomViewModel { self.getRemoteVideoUseCase = getRemoteVideoUseCase self.createRoomUseCase = createRoomUseCase self.didEnterNewUserPublisherUseCase = didEnterNewUserPublisherUseCase + self.toggleLocalMicStateUseCase = toggleLocalMicStateUseCase bindSideEffects() } @@ -71,7 +74,7 @@ public final class WaitingRoomViewModel { case .viewDidLoad: handleViewDidLoad() case .micMuteButtonDidTap: - output.send(.micMuteState(true)) // 예제에서는 항상 true 반환 + handleMicButtonDidTap() case .linkButtonDidTap: handleLinkButtonDidTap() case .startButtonDidTap: @@ -139,4 +142,11 @@ public final class WaitingRoomViewModel { }) .store(in: &cancellables) } + + private func handleMicButtonDidTap() { + toggleLocalMicStateUseCase.execute() + .sink { [weak self] state in + self?.output.send(.micMuteState(state)) + }.store(in: &cancellables) + } }