From 0f8b7404698e226ec9eff305b36b37e1006cdb04 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Tue, 15 Aug 2023 01:22:15 +0900 Subject: [PATCH] =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=EB=B0=A9=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../xcshareddata/swiftpm/Package.resolved | 18 + .../btn_bar_play.imageset/Contents.json | 21 + .../btn_bar_play.imageset/btn_bar_play.png | Bin 0 -> 513 bytes .../btn_bar_stop.imageset/Contents.json | 21 + .../btn_bar_stop.imageset/btn_bar_stop.png | Bin 0 -> 294 bytes .../btn_follow.imageset/Contents.json | 21 + .../btn_follow.imageset/btn_follow.png | Bin 0 -> 4311 bytes .../btn_following.imageset/Contents.json | 21 + .../btn_following.imageset/btn_following.png | Bin 0 -> 4465 bytes .../ic_badge_manager.imageset/Contents.json | 21 + .../ic_badge_manager.png | Bin 0 -> 2088 bytes .../ic_bottom_white.imageset/Contents.json | 21 + .../ic_bottom_white.png | Bin 0 -> 401 bytes .../ic_donation.imageset/Contents.json | 21 + .../ic_donation.imageset/ic_donation.png | Bin 0 -> 4898 bytes .../Contents.json | 21 + .../ic_donation_message_list.png | Bin 0 -> 3606 bytes .../ic_donation_status.imageset/Contents.json | 21 + .../ic_donation_status.png | Bin 0 -> 6819 bytes .../ic_edit.imageset/Contents.json | 21 + .../ic_edit.imageset/ic_edit.png | Bin 0 -> 491 bytes .../Contents.json | 21 + .../ic_live_detail_bottom.png | Bin 0 -> 281 bytes .../ic_live_detail_top.imageset/Contents.json | 21 + .../ic_live_detail_top.png | Bin 0 -> 280 bytes .../ic_mic_off.imageset/Contents.json | 21 + .../ic_mic_off.imageset/ic_mic_off.png | Bin 0 -> 645 bytes .../ic_mic_on.imageset/Contents.json | 21 + .../ic_mic_on.imageset/ic_mic_on.png | Bin 0 -> 564 bytes .../ic_noti_pause.imageset/Contents.json | 21 + .../ic_noti_pause.imageset/ic_noti_pause.png | Bin 0 -> 442 bytes .../ic_noti_play.imageset/Contents.json | 21 + .../ic_noti_play.imageset/ic_noti_play.png | Bin 0 -> 670 bytes .../ic_noti_stop.imageset/Contents.json | 21 + .../ic_noti_stop.imageset/ic_noti_stop.png | Bin 0 -> 556 bytes .../ic_notice_normal.imageset/Contents.json | 21 + .../ic_notice_normal.png | Bin 0 -> 2695 bytes .../ic_notice_selected.imageset/Contents.json | 21 + .../ic_notice_selected.png | Bin 0 -> 2262 bytes .../ic_request_speak.imageset/Contents.json | 21 + .../ic_request_speak.png | Bin 0 -> 5076 bytes .../ic_share.imageset/Contents.json | 21 + .../ic_share.imageset/ic_share.png | Bin 0 -> 950 bytes .../ic_speaker_off.imageset/Contents.json | 21 + .../ic_speaker_off.png | Bin 0 -> 5185 bytes .../ic_speaker_on.imageset/Contents.json | 21 + .../ic_speaker_on.imageset/ic_speaker_on.png | Bin 0 -> 5950 bytes SodaLive/Sources/Agora/Agora.swift | 140 ++ SodaLive/Sources/App/AppState.swift | 1 + .../Sources/Live/GetRoomListResponse.swift | 4 +- SodaLive/Sources/Live/LiveApi.swift | 81 +- SodaLive/Sources/Live/LiveRepository.swift | 52 + SodaLive/Sources/Live/LiveView.swift | 4 +- .../Live/Now/All/LiveNowAllItemView.swift | 2 +- .../Sources/Live/Now/LiveNowItemView.swift | 6 +- .../All/LiveReservationAllItemView.swift | 2 +- .../Reservation/LiveReservationItemView.swift | 6 +- .../MyLiveReservationItemView.swift | 6 +- .../SectionLiveReservationView.swift | 2 +- .../Sources/Live/Room/Chat/LiveRoomChat.swift | 42 + .../Live/Room/Chat/LiveRoomChatItemView.swift | 130 ++ .../Room/Chat/LiveRoomChatRawMessage.swift | 19 + .../Chat/LiveRoomDonationChatItemView.swift | 70 + .../Room/Chat/LiveRoomJoinChatItemView.swift | 34 + .../Room/DeleteLiveRoomDonationMessage.swift | 13 + .../Live/Room/Detail/LiveDetailView.swift | 2 +- .../Live/Room/Dialog/LiveRoomDialogView.swift | 72 + .../LiveRoomDonationMessageDialog.swift | 118 ++ .../LiveRoomDonationRankingDialog.swift | 95 ++ .../LiveRoomDonationRankingItemView.swift | 114 ++ .../LiveRoomDonationRankingTotalCanView.swift | 36 + .../Room/Dialog/LiveRoomInfoEditDialog.swift | 238 +++ .../Room/Dialog/LiveRoomProfileDialog.swift | 86 + .../Dialog/LiveRoomProfileItemTitleView.swift | 189 +++ .../Dialog/LiveRoomProfilesDialogView.swift | 249 +++ .../LiveRoomUserProfileDialogView.swift | 259 +++ .../GetLiveRoomDonationStatusResponse.swift | 21 + .../GetLiveRoomDonationTotalResponse.swift | 12 + .../Room/GetLiveRoomUserProfileResponse.swift | 25 + .../Live/Room/GetMemberCanResponse.swift | 12 + .../Live/Room/GetRoomInfoResponse.swift | 28 + .../Live/Room/LiveRoomDonationMessage.swift | 15 + .../Live/Room/LiveRoomDonationRequest.swift | 15 + .../Live/Room/LiveRoomKickOutRequest.swift | 13 + .../Sources/Live/Room/LiveRoomMember.swift | 41 + .../Live/Room/LiveRoomRequestType.swift | 12 + .../Live/Room/LiveRoomTopCreatorView.swift | 41 + SodaLive/Sources/Live/Room/LiveRoomView.swift | 932 +++++++++++ .../Sources/Live/Room/LiveRoomViewModel.swift | 1420 +++++++++++++++++ ...SetManagerOrSpeakerOrAudienceRequest.swift | 13 + SodaLive/Sources/Main/Home/HomeView.swift | 56 + SodaLive/Sources/User/UserRepository.swift | 4 + 92 files changed, 5213 insertions(+), 20 deletions(-) create mode 100644 SodaLive/Resources/Assets.xcassets/btn_bar_play.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/btn_bar_play.imageset/btn_bar_play.png create mode 100644 SodaLive/Resources/Assets.xcassets/btn_bar_stop.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/btn_bar_stop.imageset/btn_bar_stop.png create mode 100644 SodaLive/Resources/Assets.xcassets/btn_follow.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/btn_follow.imageset/btn_follow.png create mode 100644 SodaLive/Resources/Assets.xcassets/btn_following.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/btn_following.imageset/btn_following.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_badge_manager.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_badge_manager.imageset/ic_badge_manager.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_bottom_white.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_bottom_white.imageset/ic_bottom_white.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_donation.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_donation.imageset/ic_donation.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_donation_message_list.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_donation_message_list.imageset/ic_donation_message_list.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_donation_status.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_donation_status.imageset/ic_donation_status.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_edit.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_edit.imageset/ic_edit.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_live_detail_bottom.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_live_detail_bottom.imageset/ic_live_detail_bottom.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_live_detail_top.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_live_detail_top.imageset/ic_live_detail_top.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_mic_off.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_mic_off.imageset/ic_mic_off.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_mic_on.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_mic_on.imageset/ic_mic_on.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_noti_pause.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_noti_pause.imageset/ic_noti_pause.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_noti_play.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_noti_play.imageset/ic_noti_play.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_noti_stop.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_noti_stop.imageset/ic_noti_stop.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_notice_normal.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_notice_normal.imageset/ic_notice_normal.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_notice_selected.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_notice_selected.imageset/ic_notice_selected.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_request_speak.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_request_speak.imageset/ic_request_speak.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_share.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_share.imageset/ic_share.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_speaker_off.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_speaker_off.imageset/ic_speaker_off.png create mode 100644 SodaLive/Resources/Assets.xcassets/ic_speaker_on.imageset/Contents.json create mode 100644 SodaLive/Resources/Assets.xcassets/ic_speaker_on.imageset/ic_speaker_on.png create mode 100644 SodaLive/Sources/Agora/Agora.swift create mode 100644 SodaLive/Sources/Live/Room/Chat/LiveRoomChat.swift create mode 100644 SodaLive/Sources/Live/Room/Chat/LiveRoomChatItemView.swift create mode 100644 SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift create mode 100644 SodaLive/Sources/Live/Room/Chat/LiveRoomDonationChatItemView.swift create mode 100644 SodaLive/Sources/Live/Room/Chat/LiveRoomJoinChatItemView.swift create mode 100644 SodaLive/Sources/Live/Room/DeleteLiveRoomDonationMessage.swift create mode 100644 SodaLive/Sources/Live/Room/Dialog/LiveRoomDialogView.swift create mode 100644 SodaLive/Sources/Live/Room/Dialog/LiveRoomDonationMessageDialog.swift create mode 100644 SodaLive/Sources/Live/Room/Dialog/LiveRoomDonationRankingDialog.swift create mode 100644 SodaLive/Sources/Live/Room/Dialog/LiveRoomDonationRankingItemView.swift create mode 100644 SodaLive/Sources/Live/Room/Dialog/LiveRoomDonationRankingTotalCanView.swift create mode 100644 SodaLive/Sources/Live/Room/Dialog/LiveRoomInfoEditDialog.swift create mode 100644 SodaLive/Sources/Live/Room/Dialog/LiveRoomProfileDialog.swift create mode 100644 SodaLive/Sources/Live/Room/Dialog/LiveRoomProfileItemTitleView.swift create mode 100644 SodaLive/Sources/Live/Room/Dialog/LiveRoomProfilesDialogView.swift create mode 100644 SodaLive/Sources/Live/Room/Dialog/LiveRoomUserProfileDialogView.swift create mode 100644 SodaLive/Sources/Live/Room/GetLiveRoomDonationStatusResponse.swift create mode 100644 SodaLive/Sources/Live/Room/GetLiveRoomDonationTotalResponse.swift create mode 100644 SodaLive/Sources/Live/Room/GetLiveRoomUserProfileResponse.swift create mode 100644 SodaLive/Sources/Live/Room/GetMemberCanResponse.swift create mode 100644 SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift create mode 100644 SodaLive/Sources/Live/Room/LiveRoomDonationMessage.swift create mode 100644 SodaLive/Sources/Live/Room/LiveRoomDonationRequest.swift create mode 100644 SodaLive/Sources/Live/Room/LiveRoomKickOutRequest.swift create mode 100644 SodaLive/Sources/Live/Room/LiveRoomMember.swift create mode 100644 SodaLive/Sources/Live/Room/LiveRoomRequestType.swift create mode 100644 SodaLive/Sources/Live/Room/LiveRoomTopCreatorView.swift create mode 100644 SodaLive/Sources/Live/Room/LiveRoomView.swift create mode 100644 SodaLive/Sources/Live/Room/LiveRoomViewModel.swift create mode 100644 SodaLive/Sources/Live/Room/SetManagerOrSpeakerOrAudienceRequest.swift diff --git a/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved b/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved index b9948a4..0448761 100644 --- a/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/SodaLive.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,6 +9,24 @@ "version" : "1.2022062300.0" } }, + { + "identity" : "agorartcengine_ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/AgoraIO/AgoraRtcEngine_iOS.git", + "state" : { + "revision" : "2e035dbfd39dea92ba9efd6447cd976fba85d5ff", + "version" : "4.2.2" + } + }, + { + "identity" : "agorartm_ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/AgoraIO/AgoraRtm_iOS", + "state" : { + "revision" : "8d8d126da7e420798f39d1d95b6148eeb93971aa", + "version" : "1.4.10" + } + }, { "identity" : "alamofire", "kind" : "remoteSourceControl", diff --git a/SodaLive/Resources/Assets.xcassets/btn_bar_play.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/btn_bar_play.imageset/Contents.json new file mode 100644 index 0000000..fc11c48 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/btn_bar_play.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "btn_bar_play.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/btn_bar_play.imageset/btn_bar_play.png b/SodaLive/Resources/Assets.xcassets/btn_bar_play.imageset/btn_bar_play.png new file mode 100644 index 0000000000000000000000000000000000000000..1e1a712734d0c041e089a2037e22430ac3ceead8 GIT binary patch literal 513 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#Q3?xr`X+H!~Ea{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyaaL-1AIbU-3xr)zkmPr>sKHN1Rp+pfG|FN`UGUZd-u+L!rXsA#atype!&cL z1?wL=@A;;hkz39PG>p#Jmj}UaJ5O2boB3rr4=ma7bt{9 z-K=5_6q*^V^-s{ZEIlngVRr3bcRS%bb1MTygoF;I+sNv~-aOp5LSom(yX?Og`1^h6 znP;i4$G@!n8Q+JG%)2}K?KU)5RDW;%`bbvGRaT(k>2B; z@WecRZ|y2+rsWKClT$sYXRj2I&X6ZSV$_NQ9Iwku}^1bSxl{XcEF~Q*J>gTe~DWM4fx>(z7 literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/btn_bar_stop.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/btn_bar_stop.imageset/Contents.json new file mode 100644 index 0000000..2a75d15 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/btn_bar_stop.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "btn_bar_stop.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/btn_bar_stop.imageset/btn_bar_stop.png b/SodaLive/Resources/Assets.xcassets/btn_bar_stop.imageset/btn_bar_stop.png new file mode 100644 index 0000000000000000000000000000000000000000..d1b72ccbc4512c8b07fcefb6563f2d22f6006d2b GIT binary patch literal 294 zcmeAS@N?(olHy`uVBq!ia0vp^6(G#Q3?xr`X+H!~Ea{HEjtmSN`?>!lvI6;x#X;^) z4C~IxyaaM20(?ST-3xr)zkmPj+czNj?%g{e31K{+^L;W<4SPwDUogWf>6~q|{oE=W z3xGm1JzX3_A`ZX3q086IAmEaCsf{_|z@rVKzEun?+xU8&oa?{++IzrBU3Su}?|;<8 z-ifdZUR*eLUFe&zuWy&hKF(gRYug$y_ja*IY>-X%kGXMoGAd54({p|^?}6XZ?52FJ zI+comkH!akyQVKLJkfYC!9aqCtr5i fk?ikyvEA2T2`njxgN@xNACE9;e literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/btn_follow.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/btn_follow.imageset/Contents.json new file mode 100644 index 0000000..508b496 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/btn_follow.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "btn_follow.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/btn_follow.imageset/btn_follow.png b/SodaLive/Resources/Assets.xcassets/btn_follow.imageset/btn_follow.png new file mode 100644 index 0000000000000000000000000000000000000000..df2215687c5604d3ea48146a674fbee0ea81a813 GIT binary patch literal 4311 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 z,YsCfLR&$T`I@=4o=kflgNjRfBlX)p(Q;jq%|oGwJx03s1iG?YN!Rqt!h|Dof^ zA5YnOUsqro-qDwlj^# cT(FAyKe3VRIth%a+yDRo07*qoM6N<$f(byazyJUM literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_donation_status.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_donation_status.imageset/Contents.json new file mode 100644 index 0000000..d27c48c --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_donation_status.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_donation_status.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_donation_status.imageset/ic_donation_status.png b/SodaLive/Resources/Assets.xcassets/ic_donation_status.imageset/ic_donation_status.png new file mode 100644 index 0000000000000000000000000000000000000000..1e1dab5ed29f66f746b3b4930c84db5dd9a37019 GIT binary patch literal 6819 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 RykP(U002ovPDHLkV1k9)@BIJ( literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_edit.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_edit.imageset/Contents.json new file mode 100644 index 0000000..5201062 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_edit.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_edit.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_edit.imageset/ic_edit.png b/SodaLive/Resources/Assets.xcassets/ic_edit.imageset/ic_edit.png new file mode 100644 index 0000000000000000000000000000000000000000..2e1937d38b8258322f15871e88dff5822e0a3a77 GIT binary patch literal 491 zcmV!d28+W-*d*8i$p~B08V}zE)bD5U{8J%NQ}r95F)=x zBt&EhcqZ@8z2Ao#^Y!>$Y=n5gp8PNj_w&;H&P(U0kQI%K-XF3+LZ!H3P_dT`K$e;K%##Gx#UBy_e2F~kq4&(G|7Wg0ovq2 zaDYa65EP(Q9vlW}mIo&1@Z9piu9sk$DJAUZ&UJctU=A`kxE2j+RU6hRGnAo5nCmOS7Bg5L;$n(~0V zk$t%1M#rqR7~J01GW{-+;T3sgw6``vxg@ h;?h7qc;BE=ieK?dCyc4Ed?Nq=002ovPDHLkV1iSq$}<1} literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_live_detail_bottom.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_live_detail_bottom.imageset/Contents.json new file mode 100644 index 0000000..f4586e2 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_live_detail_bottom.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_live_detail_bottom.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_live_detail_bottom.imageset/ic_live_detail_bottom.png b/SodaLive/Resources/Assets.xcassets/ic_live_detail_bottom.imageset/ic_live_detail_bottom.png new file mode 100644 index 0000000000000000000000000000000000000000..ce5efb91e900554f64b0d75436000c74af263183 GIT binary patch literal 281 zcmeAS@N?(olHy`uVBq!ia0vp^HXzKx3?xrnI^qbVSkfJRf%L|H?mvmFKt5w}kh>GZ zx^prwfgI5QpAc8~0-y5o@`{RzzP>&n31kDoj_I;iKm}|iL4Lsu9zQvD$5reS=K+dL z@N{tuu{izqiZ54#f&lA-HGBe&>;;c+9S|1TqPNSj<-5Jp%)dRqmI?nbvvb>b*T8k+ zWM}0ti+5YGZ zx^prwfgI5QpAc8~0-tg)=;`UH0E0=BCPmfMIRF)~mjw9*GyL)9+&!QD$V&EnpirNu zi(`nz>9<$T@-`T7um$8iW{}vM{fNPKM?)c#`G+U}lz)V8U3%c;zopr09`9<8UO$Q literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_mic_off.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_mic_off.imageset/Contents.json new file mode 100644 index 0000000..db9b5aa --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_mic_off.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_mic_off.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_mic_off.imageset/ic_mic_off.png b/SodaLive/Resources/Assets.xcassets/ic_mic_off.imageset/ic_mic_off.png new file mode 100644 index 0000000000000000000000000000000000000000..9bc69ff9f56a3593ea4bf37e7596d3c46968f490 GIT binary patch literal 645 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0J3?w7mbKU|emUKs7M+SzC{oH>NS%G}U;vjb? zhIQv;UIIB<0X`wF?gc*oi38nilg&rf5C*QR~emvMnCX$ zaSZV|K6RS4H?yNa!}M;AkBp4m8wDJY7{(2=TO>A5Fn&i_YmKd0$$Zub*5`}{FA{hR2qf8xKz3+|sgq5p$HAWgvb!Nl-%c94oEZ*l6=Ypi{D88t)nD2cBG)4oqT<3SwZDULmP4 zp~*38k}8h?6N}8GCKiclo{mg1mzkJ%u&@s@(rKG_BxYKq)(_<&Rmr-I;>FW0U*9AGqQo@5)yx zyR%A%rvKc8VoO!uHC-V!q7mVt=S&R6TvGS#NzgU_?ZJ}h=#xI>o^g~xOj;6`qtpbZ z^$X1e=1gpQV#VFP#Nnv!janb>ZI2H!YOXl3$G+B}gmZfDony28Wq2;Ti1PC_p6h=h zw5RWs`}IDdJ^mBzwT#mF%c>1_BJmeQ%}BF_8<72RfKuaVW^&mu0c}|M&j^{m65l*6^Jud;v@Z44$rjF6*2UngBOn B5J&(3 literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_mic_on.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_mic_on.imageset/Contents.json new file mode 100644 index 0000000..43e4602 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_mic_on.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_mic_on.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_mic_on.imageset/ic_mic_on.png b/SodaLive/Resources/Assets.xcassets/ic_mic_on.imageset/ic_mic_on.png new file mode 100644 index 0000000000000000000000000000000000000000..31ed38e7bf2c195f87e5c783c00e5e49eb4d40f5 GIT binary patch literal 564 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0J3?w7mbKU|emUKs7M+SzC{oH>NS%G}U;vjb? zhIQv;UIIC~0X`wF?gc*oi3Vrm?lJ*2@|6Vn1vAWFaDSJ=`+5g~=m`cFJ|EamTaXdLefwv^Sy3CrPzvhIsLPbY3Yrm|=L2KzbT!#*PdOz=>2~*O6-R09Pp4?mr zVA##HH;~oYi7{Mr7t=hgEi>{Qf(5?k zF>h)rSJ3Nfs(mjMAj+{o^FsCagcF)4m^&9f;kFOSzqNOF!?ewR9F#a1!t8m~{!8Sn zWX@Vw-8f~pypch{f~WiUm9LkSut-E*-ILYRnKsR4RSrGs7<*QdQ`?)# zfNR6^9Tf)F0+~I+5!YfI!Wl~LC(l^9*CWS*Vb|}w&K@_mI2_{Y7JM)F-s+^()}OLw z%nf47lMe4LpLfGZY`ScW9e4G$*?hqh?HJu7&pc(*TWPEIU|o^l&Aq!t8@6Bi70!UtNzBwjS`Cga~MCqWn}PvDe~kC TL+NW^gfV!!`njxgN@xNA2y*za literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_noti_pause.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_noti_pause.imageset/Contents.json new file mode 100644 index 0000000..5943676 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_noti_pause.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_noti_pause.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_noti_pause.imageset/ic_noti_pause.png b/SodaLive/Resources/Assets.xcassets/ic_noti_pause.imageset/ic_noti_pause.png new file mode 100644 index 0000000000000000000000000000000000000000..e98bfdf1461ab2feee6c696bb5ba7f90c08e0f07 GIT binary patch literal 442 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=oCO|{#S9GG!XV7ZFl&wkP>``W z$lZxy-8q?;Kn_c~qpu?a!^VE@KZ&eBIe$+V$B+ufx3>@GHXBH^Jv`sSn5DAAOnQpo zA(dV$$6b!bj^P<v+3~|dlf}AJq z)^9qWXjnS&g>Pu6e20K+f<&92+}E#vOniMKr|Am)=)ALbPgeJ$3;{*wJyp3u|5&3{ zlnh@>uP^JEqI6*YF@}IX#sxCW4wh^UMLY~oB^XW_GE7Nk&^S(l#8Qq6>wNRfX0Lev zW4aT^bLsU~K$kw4Q$6i?s--%+T~zjD)*0_*&t#wT)?enHd`4JFb+^&uz4uRlh?}=O uL|k><*V_Hl<^L)iULO*k*3sdyIsbWY0@pT4iEF^1X7F_Nb6Mw<&;$Uz*{RI{ literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_noti_play.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_noti_play.imageset/Contents.json new file mode 100644 index 0000000..0f26833 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_noti_play.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_noti_play.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_noti_play.imageset/ic_noti_play.png b/SodaLive/Resources/Assets.xcassets/ic_noti_play.imageset/ic_noti_play.png new file mode 100644 index 0000000000000000000000000000000000000000..c2c27540e34eb9bbf3e267b1f6a23e10b2900330 GIT binary patch literal 670 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 BG$Q~2 literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_notice_selected.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_notice_selected.imageset/Contents.json new file mode 100644 index 0000000..7abdfe5 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_notice_selected.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_notice_selected.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_notice_selected.imageset/ic_notice_selected.png b/SodaLive/Resources/Assets.xcassets/ic_notice_selected.imageset/ic_notice_selected.png new file mode 100644 index 0000000000000000000000000000000000000000..6820e27d363f74a04b6df98eb433d14aa16a82ef GIT binary patch literal 2262 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 literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_share.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_share.imageset/Contents.json new file mode 100644 index 0000000..aca48d5 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_share.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_share.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_share.imageset/ic_share.png b/SodaLive/Resources/Assets.xcassets/ic_share.imageset/ic_share.png new file mode 100644 index 0000000000000000000000000000000000000000..5181ef2a570d695ed930f8be298e05e3d6f5b1e5 GIT binary patch literal 950 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 literal 0 HcmV?d00001 diff --git a/SodaLive/Resources/Assets.xcassets/ic_speaker_on.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_speaker_on.imageset/Contents.json new file mode 100644 index 0000000..39be652 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_speaker_on.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_speaker_on.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_speaker_on.imageset/ic_speaker_on.png b/SodaLive/Resources/Assets.xcassets/ic_speaker_on.imageset/ic_speaker_on.png new file mode 100644 index 0000000000000000000000000000000000000000..be48c2d04961f8ff8d8b196468b4340727b4c1b1 GIT binary patch literal 5950 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 literal 0 HcmV?d00001 diff --git a/SodaLive/Sources/Agora/Agora.swift b/SodaLive/Sources/Agora/Agora.swift new file mode 100644 index 0000000..15a93e8 --- /dev/null +++ b/SodaLive/Sources/Agora/Agora.swift @@ -0,0 +1,140 @@ +// +// Agora.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import Foundation +import AgoraRtcKit +import AgoraRtmKit + +final class Agora { + static let shared = Agora() + + var rtcEngineDelegate: AgoraRtcEngineDelegate? + var rtmDelegate: AgoraRtmDelegate? + + var rtcEngine: AgoraRtcEngineKit? + + var rtmKit: AgoraRtmKit? + var rtmChannel: AgoraRtmChannel? + + func initialize() { + rtcEngine = AgoraRtcEngineKit.sharedEngine(withAppId: AGORA_APP_ID, delegate: rtcEngineDelegate) + rtcEngine?.setChannelProfile(.liveBroadcasting) + rtcEngine?.enableAudio() + rtcEngine?.enableAudioVolumeIndication(500, smooth: 3, reportVad: true) + + rtmKit = AgoraRtmKit(appId: AGORA_APP_ID, delegate: rtmDelegate) + } + + func deInit() { + if let rtcEngine = rtcEngine { + rtcEngine.leaveChannel(nil) + + DispatchQueue.global(qos: .userInitiated).async { + AgoraRtcEngineKit.destroy() + } + } + + rtmChannel?.leave(completion: nil) + rtmKit?.logout(completion: nil) + rtcEngine = nil + rtmChannel = nil + rtmKit = nil + } + + func setRole(role: AgoraClientRole) { + self.rtcEngine?.setClientRole(role) + } + + func joinChannel( + roomInfo: GetRoomInfoResponse, + rtmChannelDelegate: AgoraRtmChannelDelegate, + onConnectSuccess: @escaping (Bool) -> Void, + onConnectFail: @escaping () -> Void + ) { + if rtmChannel != nil { + return + } + + let userId = UserDefaults.int(forKey: .userId) + + rtcEngine?.joinChannel( + byToken: roomInfo.rtcToken, + channelId: roomInfo.channelName, + info: nil, + uid: UInt(userId), + joinSuccess: nil + ) + + rtcEngine?.setAudioProfile(.musicHighQualityStereo) + rtcEngine?.setAudioScenario(.gameStreaming) + + rtmChannel = rtmKit?.createChannel( + withId: roomInfo.channelName, + delegate: rtmChannelDelegate + ) + + rtmKit?.login( + byToken: roomInfo.rtmToken, + user: String(userId), + completion: { [unowned self] loginErrorCode in + if loginErrorCode == .ok { + self.rtmChannel?.join(completion: { joinChannelErrorCode in + if joinChannelErrorCode == .channelErrorOk { + if userId == roomInfo.creatorId { + self.setRole(role: .broadcaster) + } else { + self.setRole(role: .audience) + } + + onConnectSuccess(userId == roomInfo.creatorId) + } else { + onConnectFail() + } + }) + } else { + onConnectFail() + } + } + ) + } + + func sendMessageToPeer(peerId: String, rawMessage: Data, completion: AgoraRtmSendPeerMessageBlock?) { + let option = AgoraRtmSendMessageOptions() + option.enableOfflineMessaging = false + option.enableHistoricalMessaging = false + + let message = AgoraRtmRawMessage(rawData: rawMessage, description: "") + rtmKit?.send(message, toPeer: peerId, completion: completion) + } + + func mute(_ isMute: Bool) { + rtcEngine?.muteLocalAudioStream(isMute) + } + + func speakerMute(_ isMute: Bool) { + rtcEngine?.muteAllRemoteAudioStreams(isMute) + } + + func sendMessageToGroup(textMessage: String, completion: @escaping AgoraRtmSendChannelMessageBlock) { + let message = AgoraRtmMessage(text: textMessage) + rtmChannel?.send(message, completion: completion) + } + + func sendRawMessageToGroup(rawMessage: LiveRoomChatRawMessage, completion: AgoraRtmSendChannelMessageBlock? = nil, fail: (() -> Void)? = nil) { + let encoder = JSONEncoder() + let jsonMessageData = try? encoder.encode(rawMessage) + + if let jsonMessageData = jsonMessageData { + let message = AgoraRtmRawMessage(rawData: jsonMessageData, description: "") + rtmChannel?.send(message, completion: completion) + } else { + if let fail = fail { + fail() + } + } + } +} diff --git a/SodaLive/Sources/App/AppState.swift b/SodaLive/Sources/App/AppState.swift index 7f69b1c..89b7ee0 100644 --- a/SodaLive/Sources/App/AppState.swift +++ b/SodaLive/Sources/App/AppState.swift @@ -17,6 +17,7 @@ class AppState: ObservableObject { @Published var isShowPlayer = false { didSet { if isShowPlayer { + ContentPlayManager.shared.stopAudio() } } } diff --git a/SodaLive/Sources/Live/GetRoomListResponse.swift b/SodaLive/Sources/Live/GetRoomListResponse.swift index 4021457..ac5054b 100644 --- a/SodaLive/Sources/Live/GetRoomListResponse.swift +++ b/SodaLive/Sources/Live/GetRoomListResponse.swift @@ -19,8 +19,8 @@ struct GetRoomListResponse: Decodable, Hashable { let price: Int let tags: [String] let channelName: String? - let managerNickname: String - let managerId: Int + let creatorNickname: String + let creatorId: Int let isReservation: Bool let isPrivateRoom: Bool } diff --git a/SodaLive/Sources/Live/LiveApi.swift b/SodaLive/Sources/Live/LiveApi.swift index d5c35ff..edbf311 100644 --- a/SodaLive/Sources/Live/LiveApi.swift +++ b/SodaLive/Sources/Live/LiveApi.swift @@ -23,6 +23,19 @@ enum LiveApi { case startLive(request: StartLiveRequest) case cancelRoom(request: CancelLiveRequest) case editLiveRoomInfo(roomId: Int, parameters: [MultipartFormData]) + case quitRoom(roomId: Int) + case getRoomInfo(roomId: Int) + case donation(request: LiveRoomDonationRequest) + case refundDonation(roomId: Int) + case setListener(request: SetManagerOrSpeakerOrAudienceRequest) + case setSpeaker(request: SetManagerOrSpeakerOrAudienceRequest) + case setManager(request: SetManagerOrSpeakerOrAudienceRequest) + case kickOut(request: LiveRoomKickOutRequest) + case donationStatus(roomId: Int) + case donationTotal(roomId: Int) + case getDonationMessageList(roomId: Int) + case deleteDonationMessage(roomId: Int, messageUUID: String) + case getUserProfile(roomId: Int, userId: Int) } extension LiveApi: TargetType { @@ -73,19 +86,61 @@ extension LiveApi: TargetType { case .editLiveRoomInfo(let roomId, _): return "/live/room/\(roomId)" + + case .quitRoom: + return "/live/room/quit" + + case .getRoomInfo(let roomId): + return "/live/room/info/\(roomId)" + + case .donation: + return "/live/room/donation" + + case .refundDonation(let roomId): + return "/live/room/donation/refund/\(roomId)" + + case .setListener: + return "/live/room/info/set/listener" + + case .setSpeaker: + return "/live/room/info/set/speaker" + + case .setManager: + return "/live/room/info/set/manager" + + case .kickOut: + return "/live/room/kick-out" + + case .donationStatus(let roomId): + return "/live/room/\(roomId)/donation-list" + + case .donationTotal(let roomId): + return "/live/room/\(roomId)/donation-total" + + case .getDonationMessageList: + return "/live/room/donation-message" + + case .deleteDonationMessage: + return "/live/room/donation-message" + + case .getUserProfile(let roomId, let userId): + return "/live/room/\(roomId)/profile/\(userId)" } } var method: Moya.Method { switch self { - case .roomList, .recentVisitRoomUsers, .getReservations, .getReservation, .getRoomDetail, .getTags, .getRecentRoomInfo: + case .roomList, .recentVisitRoomUsers, .getReservations, .getReservation, .getRoomDetail, .getTags, .getRecentRoomInfo, .getRoomInfo, .donationStatus, .donationTotal, .getDonationMessageList, .getUserProfile: return .get - case .makeReservation, .enterRoom, .createRoom: + case .makeReservation, .enterRoom, .createRoom, .quitRoom, .donation, .refundDonation, .kickOut: return .post - case .cancelReservation, .startLive, .cancelRoom, .editLiveRoomInfo: + case .setListener, .setSpeaker, .setManager, .cancelReservation, .startLive, .cancelRoom, .editLiveRoomInfo: return .put + + case .deleteDonationMessage: + return .delete } } @@ -107,7 +162,7 @@ extension LiveApi: TargetType { parameters: parameters, encoding: URLEncoding.queryString) - case .recentVisitRoomUsers, .getTags, .getRecentRoomInfo: + case .recentVisitRoomUsers, .getTags, .getRecentRoomInfo, .getRoomInfo, .refundDonation, .donationStatus, .donationTotal, .getUserProfile: return .requestPlain case .getReservations(let isActive): @@ -153,6 +208,24 @@ extension LiveApi: TargetType { case .editLiveRoomInfo(_, let parameters): return .uploadMultipart(parameters) + + case .quitRoom(let roomId): + return .requestParameters(parameters: ["id": roomId], encoding: URLEncoding.queryString) + + case .donation(let request): + return .requestJSONEncodable(request) + + case .setListener(let request), .setSpeaker(let request), .setManager(let request): + return .requestJSONEncodable(request) + + case .kickOut(let request): + return .requestJSONEncodable(request) + + case .getDonationMessageList(let roomId): + return .requestParameters(parameters: ["roomId": roomId], encoding: URLEncoding.queryString) + + case .deleteDonationMessage(let roomId, let messageUUID): + return .requestJSONEncodable(DeleteLiveRoomDonationMessage(roomId: roomId, messageUUID: messageUUID)) } } diff --git a/SodaLive/Sources/Live/LiveRepository.swift b/SodaLive/Sources/Live/LiveRepository.swift index 98bda99..d44af7b 100644 --- a/SodaLive/Sources/Live/LiveRepository.swift +++ b/SodaLive/Sources/Live/LiveRepository.swift @@ -64,4 +64,56 @@ final class LiveRepository { func editLiveRoomInfo(roomId: Int, parameters: [MultipartFormData]) -> AnyPublisher { return api.requestPublisher(.editLiveRoomInfo(roomId: roomId, parameters: parameters)) } + + func quitRoom(roomId: Int) -> AnyPublisher { + return api.requestPublisher(.quitRoom(roomId: roomId)) + } + + func getRoomInfo(roomId: Int) -> AnyPublisher { + return api.requestPublisher(.getRoomInfo(roomId: roomId)) + } + + func donation(roomId: Int, can: Int, message: String = "") -> AnyPublisher { + return api.requestPublisher(.donation(request: LiveRoomDonationRequest(roomId: roomId, can: can, message: message))) + } + + func refundDonation(roomId: Int) -> AnyPublisher { + return api.requestPublisher(.refundDonation(roomId: roomId)) + } + + func setListener(roomId: Int, userId: Int) -> AnyPublisher { + return api.requestPublisher(.setListener(request: SetManagerOrSpeakerOrAudienceRequest(roomId: roomId, memberId: userId))) + } + + func setSpeaker(roomId: Int, userId: Int) -> AnyPublisher { + return api.requestPublisher(.setSpeaker(request: SetManagerOrSpeakerOrAudienceRequest(roomId: roomId, memberId: userId))) + } + + func setManager(roomId: Int, userId: Int) -> AnyPublisher { + api.requestPublisher(.setManager(request: SetManagerOrSpeakerOrAudienceRequest(roomId: roomId, memberId: userId))) + } + + func kickOut(roomId: Int, userId: Int) -> AnyPublisher { + return api.requestPublisher(.kickOut(request: LiveRoomKickOutRequest(roomId: roomId, userId: userId))) + } + + func donationStatus(roomId: Int) -> AnyPublisher { + return api.requestPublisher(.donationStatus(roomId: roomId)) + } + + func getTotalDoantionCan(roomId: Int) -> AnyPublisher { + return api.requestPublisher(.donationTotal(roomId: roomId)) + } + + func getDonationMessageList(roomId: Int) -> AnyPublisher { + return api.requestPublisher(.getDonationMessageList(roomId: roomId)) + } + + func deleteDonationMessage(roomId: Int, messageUUID: String) -> AnyPublisher { + return api.requestPublisher(.deleteDonationMessage(roomId: roomId, messageUUID: messageUUID)) + } + + func getUserProfile(roomId: Int, userId: Int) -> AnyPublisher { + api.requestPublisher(.getUserProfile(roomId: roomId, userId: userId)) + } } diff --git a/SodaLive/Sources/Live/LiveView.swift b/SodaLive/Sources/Live/LiveView.swift index d22e003..e57080b 100644 --- a/SodaLive/Sources/Live/LiveView.swift +++ b/SodaLive/Sources/Live/LiveView.swift @@ -58,7 +58,9 @@ struct LiveView: View { if viewModel.liveNowItems.count > 0 { SectionLiveNowView( items: viewModel.liveNowItems, - onClickParticipant: {_ in}, + onClickParticipant: { + viewModel.enterRoom(roomId: $0) + }, onTapCreateLive: { AppState.shared.setAppStep(step: .createLive(timeSettingMode: .NOW, onSuccess: onCreateSuccess)) } diff --git a/SodaLive/Sources/Live/Now/All/LiveNowAllItemView.swift b/SodaLive/Sources/Live/Now/All/LiveNowAllItemView.swift index e66b205..5c08bf7 100644 --- a/SodaLive/Sources/Live/Now/All/LiveNowAllItemView.swift +++ b/SodaLive/Sources/Live/Now/All/LiveNowAllItemView.swift @@ -38,7 +38,7 @@ struct LiveNowAllItemView: View { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top, spacing: 0) { VStack(alignment: .leading, spacing: 0) { - Text(item.managerNickname) + Text(item.creatorNickname) .font(.custom(Font.medium.rawValue, size: 11.3)) .foregroundColor(Color(hex: "bbbbbb")) diff --git a/SodaLive/Sources/Live/Now/LiveNowItemView.swift b/SodaLive/Sources/Live/Now/LiveNowItemView.swift index e2f5123..3bda28d 100644 --- a/SodaLive/Sources/Live/Now/LiveNowItemView.swift +++ b/SodaLive/Sources/Live/Now/LiveNowItemView.swift @@ -74,7 +74,7 @@ struct LiveNowItemView: View { Spacer() - Text("\(item.managerNickname)") + Text("\(item.creatorNickname)") .font(.custom(Font.medium.rawValue, size: 13.3)) .foregroundColor(Color.white) } @@ -101,8 +101,8 @@ struct LiveNowItemView_Previews: PreviewProvider { price: 0, tags: ["팬미팅", "힐링"], channelName: nil, - managerNickname: "user8", - managerId: 19, + creatorNickname: "user8", + creatorId: 19, isReservation: false, isPrivateRoom: true ) diff --git a/SodaLive/Sources/Live/Reservation/All/LiveReservationAllItemView.swift b/SodaLive/Sources/Live/Reservation/All/LiveReservationAllItemView.swift index 29e5216..2df6497 100644 --- a/SodaLive/Sources/Live/Reservation/All/LiveReservationAllItemView.swift +++ b/SodaLive/Sources/Live/Reservation/All/LiveReservationAllItemView.swift @@ -41,7 +41,7 @@ struct LiveReservationAllItemView: View { .font(.custom(Font.medium.rawValue, size: 9.3)) .foregroundColor(Color(hex: "ffd300")) - Text(item.managerNickname) + Text(item.creatorNickname) .font(.custom(Font.medium.rawValue, size: 11.3)) .foregroundColor(Color(hex: "bbbbbb")) .padding(.top, 10) diff --git a/SodaLive/Sources/Live/Reservation/LiveReservationItemView.swift b/SodaLive/Sources/Live/Reservation/LiveReservationItemView.swift index d3fd195..633fc58 100644 --- a/SodaLive/Sources/Live/Reservation/LiveReservationItemView.swift +++ b/SodaLive/Sources/Live/Reservation/LiveReservationItemView.swift @@ -41,7 +41,7 @@ struct LiveReservationItemView: View { .font(.custom(Font.medium.rawValue, size: 9.3)) .foregroundColor(Color(hex: "ffd300")) - Text(item.managerNickname) + Text(item.creatorNickname) .font(.custom(Font.medium.rawValue, size: 11.3)) .foregroundColor(Color(hex: "bbbbbb")) .padding(.top, 10) @@ -114,8 +114,8 @@ struct LiveReservationItemView_Previews: PreviewProvider { price: 0, tags: ["팬미팅", "힐링"], channelName: nil, - managerNickname: "user8", - managerId: 19, + creatorNickname: "user8", + creatorId: 19, isReservation: false, isPrivateRoom: true )) diff --git a/SodaLive/Sources/Live/Reservation/MyLiveReservationItemView.swift b/SodaLive/Sources/Live/Reservation/MyLiveReservationItemView.swift index 452ac20..ede36f8 100644 --- a/SodaLive/Sources/Live/Reservation/MyLiveReservationItemView.swift +++ b/SodaLive/Sources/Live/Reservation/MyLiveReservationItemView.swift @@ -53,7 +53,7 @@ struct MyLiveReservationItemView: View { .font(.custom(Font.medium.rawValue, size: 9.3)) .foregroundColor(Color(hex: "ffd300")) - Text(item.managerNickname) + Text(item.creatorNickname) .font(.custom(Font.medium.rawValue, size: 11.3)) .foregroundColor(Color(hex: "bbbbbb")) .padding(.top, 10) @@ -103,8 +103,8 @@ struct MyLiveReservationItemView_Previews: PreviewProvider { price: 0, tags: ["팬미팅", "힐링"], channelName: nil, - managerNickname: "user8", - managerId: 19, + creatorNickname: "user8", + creatorId: 19, isReservation: false, isPrivateRoom: true ), diff --git a/SodaLive/Sources/Live/Reservation/SectionLiveReservationView.swift b/SodaLive/Sources/Live/Reservation/SectionLiveReservationView.swift index e51d460..11af554 100644 --- a/SodaLive/Sources/Live/Reservation/SectionLiveReservationView.swift +++ b/SodaLive/Sources/Live/Reservation/SectionLiveReservationView.swift @@ -53,7 +53,7 @@ struct SectionLiveReservationView: View { ForEach(0.. Void + + var body: some View { + HStack(alignment: .top, spacing: 13.3) { + ZStack { + switch chatMessage.rank + 1 { + case -2: + Color(hex: "4999e3") + .frame(width: 33.3, height: 33.3, alignment: .top) + .clipShape(Circle()) + + case -1: + Color(hex: "6f3dec") + .frame(width: 33.3, height: 33.3, alignment: .top) + .clipShape(Circle()) + + case 1: + Color(hex: "fdca2f") + .frame(width: 33.3, height: 33.3, alignment: .top) + .clipShape(Circle()) + + case 2: + Color(hex: "dcdcdc") + .frame(width: 33.3, height: 33.3, alignment: .top) + .clipShape(Circle()) + + case 3: + Color(hex: "c67e4a") + .frame(width: 33.3, height: 33.3, alignment: .top) + .clipShape(Circle()) + + default: + Color(hex: "bbbbbb") + .frame(width: 33.3, height: 33.3, alignment: .top) + .clipShape(Circle()) + } + + KFImage(URL(string: chatMessage.profileUrl)) + .resizable() + .scaledToFill() + .frame(width: 30, height: 30, alignment: .top) + .clipShape(Circle()) + + VStack(alignment: .trailing, spacing: 0) { + Spacer() + + switch chatMessage.rank + 1 { + case -2: + Image("ic_badge_manager") + .resizable() + .frame(width: 16.7, height: 16.7) + + case -1: + Image("ic_crown") + .resizable() + .frame(width: 16.7, height: 16.7) + + case 1: + Image("ic_crown_1") + .resizable() + .frame(width: 16.7, height: 16.7) + + case 2: + Image("ic_crown_2") + .resizable() + .frame(width: 16.7, height: 16.7) + + case 3: + Image("ic_crown_3") + .resizable() + .frame(width: 16.7, height: 16.7) + + default: + EmptyView() + } + } + .frame(width: 33.3, height: 33.3, alignment: .trailing) + } + .onTapGesture { onClickProfile() } + + VStack(alignment: .leading, spacing: 6.7) { + HStack(spacing: 5) { + if chatMessage.rank == -3 { + Text("스탭") + .font(.custom(Font.medium.rawValue, size: 10)) + .foregroundColor(.white) + .padding(2) + .background(Color(hex: "4999e3")) + .cornerRadius(2) + } + + Text(chatMessage.nickname) + .font(.custom(Font.light.rawValue, size: 12)) + .foregroundColor(.white) + } + + Text(chatMessage.chat) + .font(.custom(Font.medium.rawValue, size: 14)) + .foregroundColor(.white) + .multilineTextAlignment(.leading) + .lineLimit(nil) + .lineSpacing(6) + .fixedSize(horizontal: false, vertical: true) + } + .padding(.horizontal, 8) + .padding(.vertical, 5.3) + .background( + UserDefaults.int(forKey: .userId) == chatMessage.userId ? + Color(hex: "9970ff").opacity(0.6) : + Color.black.opacity(0.6) + ) + .cornerRadius(3.3) + } + .frame(width: screenSize().width - 86, alignment: .leading) + .padding(.leading, 20) + } +} diff --git a/SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift b/SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift new file mode 100644 index 0000000..d5255b1 --- /dev/null +++ b/SodaLive/Sources/Live/Room/Chat/LiveRoomChatRawMessage.swift @@ -0,0 +1,19 @@ +// +// LiveRoomChatRawMessage.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import Foundation + +struct LiveRoomChatRawMessage: Codable { + enum LiveRoomChatRawMessageType: String, Codable { + case DONATION, EDIT_ROOM_INFO, SET_MANAGER + } + + let type: LiveRoomChatRawMessageType + let message: String + let can: Int + let donationMessage: String? +} diff --git a/SodaLive/Sources/Live/Room/Chat/LiveRoomDonationChatItemView.swift b/SodaLive/Sources/Live/Room/Chat/LiveRoomDonationChatItemView.swift new file mode 100644 index 0000000..d2922bc --- /dev/null +++ b/SodaLive/Sources/Live/Room/Chat/LiveRoomDonationChatItemView.swift @@ -0,0 +1,70 @@ +// +// LiveRoomDonationChatItemView.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import SwiftUI +import Kingfisher + +struct LiveRoomDonationChatItemView: View { + + let chatMessage: LiveRoomDonationChat + + var body: some View { + HStack(spacing: 13.3) { + ZStack(alignment: .bottomTrailing) { + KFImage(URL(string: chatMessage.profileUrl)) + .resizable() + .scaledToFill() + .frame(width: 33.3, height: 33.3, alignment: .top) + .clipped() + .cornerRadius(23.3) + + Image("ic_can") + } + + VStack(alignment: .leading, spacing: 6.7) { + HStack(spacing: 0) { + Text(chatMessage.nickname) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(.white) + + Text("님이") + .font(.custom(Font.light.rawValue, size: 12)) + .foregroundColor(.white) + } + + HStack(spacing: 0) { + Text("\(chatMessage.can)캔") + .font(.custom(Font.medium.rawValue, size: 13)) + .foregroundColor(Color(hex: "fdca2f")) + + Text("을 후원하셨습니다.") + .font(.custom(Font.medium.rawValue, size: 13)) + .foregroundColor(.white) + } + + if !chatMessage.donationMessage.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text("\"\(chatMessage.donationMessage)\"") + .font(.custom(Font.medium.rawValue, size: 13)) + .foregroundColor(.white) + } + } + } + .padding(13) + .frame(width: screenSize().width - 86, alignment: .leading) + .background( + chatMessage.can >= 100000 ? Color(hex: "c25264") : + chatMessage.can >= 50000 ? Color(hex: "d85e37").opacity(0.9) : + chatMessage.can >= 10000 ? Color(hex: "d38c38").opacity(0.9) : + chatMessage.can >= 5000 ? Color(hex: "59548f").opacity(0.9) : + chatMessage.can >= 1000 ? Color(hex: "4d6aa4").opacity(0.9) : + chatMessage.can >= 500 ? Color(hex: "2d7390").opacity(0.9) : + Color(hex: "548f7d").opacity(0.9) + ) + .cornerRadius(10) + .padding(.leading, 20) + } +} diff --git a/SodaLive/Sources/Live/Room/Chat/LiveRoomJoinChatItemView.swift b/SodaLive/Sources/Live/Room/Chat/LiveRoomJoinChatItemView.swift new file mode 100644 index 0000000..8d40107 --- /dev/null +++ b/SodaLive/Sources/Live/Room/Chat/LiveRoomJoinChatItemView.swift @@ -0,0 +1,34 @@ +// +// LiveRoomJoinChatItemView.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import SwiftUI + +struct LiveRoomJoinChatItemView: View { + + let chatMessage: LiveRoomJoinChat + + var body: some View { + HStack(spacing: 0) { + Text("'") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "eeeeee")) + + Text(chatMessage.nickname) + .font(.custom(Font.bold.rawValue, size: 12)) + .foregroundColor(Color(hex: "ffdc00")) + + Text("'님이 입장하셨습니다.") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "eeeeee")) + } + .padding(.vertical, 6.7) + .frame(width: screenSize().width - 86) + .background(Color(hex: "3d2a6c")) + .cornerRadius(4.7) + .padding(.leading, 20) + } +} diff --git a/SodaLive/Sources/Live/Room/DeleteLiveRoomDonationMessage.swift b/SodaLive/Sources/Live/Room/DeleteLiveRoomDonationMessage.swift new file mode 100644 index 0000000..666b535 --- /dev/null +++ b/SodaLive/Sources/Live/Room/DeleteLiveRoomDonationMessage.swift @@ -0,0 +1,13 @@ +// +// DeleteLiveRoomDonationMessage.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import Foundation + +struct DeleteLiveRoomDonationMessage: Encodable { + let roomId: Int + let messageUUID: String +} diff --git a/SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift b/SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift index d526aca..49de2ea 100644 --- a/SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift +++ b/SodaLive/Sources/Live/Room/Detail/LiveDetailView.swift @@ -485,7 +485,7 @@ struct LiveDetailView: View { if room.numberOfParticipants > 0 { HStack(spacing: 6.7) { - Image(isExpandParticipantArea ? "ic_suda_detail_top" : "ic_suda_detail_bottom") + Image(isExpandParticipantArea ? "ic_live_detail_top" : "ic_live_detail_bottom") .resizable() .frame(width: 20, height: 20) diff --git a/SodaLive/Sources/Live/Room/Dialog/LiveRoomDialogView.swift b/SodaLive/Sources/Live/Room/Dialog/LiveRoomDialogView.swift new file mode 100644 index 0000000..f3efa01 --- /dev/null +++ b/SodaLive/Sources/Live/Room/Dialog/LiveRoomDialogView.swift @@ -0,0 +1,72 @@ +// +// LiveRoomDialogView.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import SwiftUI + +struct LiveRoomDialogView: View { + + let content: String + + let cancelTitle: String? + let cancelAction: (() -> Void)? + + let confirmTitle: String? + let confirmAction: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center, spacing: 11.7) { + Image("ic_request_speak") + .resizable() + .frame(width: 36, height: 36) + + Text(content) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + .padding(.horizontal, 26.7) + + HStack(spacing: 10) { + Spacer() + + if let cancelTitle = cancelTitle, let cancelAction = cancelAction { + Text(cancelTitle) + .font(.custom(Font.medium.rawValue, size: 10)) + .foregroundColor(.white) + .padding(.horizontal, 8) + .padding(.vertical, 8.3) + .background(Color.white.opacity(0.2)) + .cornerRadius(13.3) + .overlay( + RoundedRectangle(cornerRadius: 13.3) + .strokeBorder() + .foregroundColor(.white) + ) + .onTapGesture { cancelAction() } + } + + if let confirmTitle = confirmTitle, let confirmAction = confirmAction { + Text(confirmTitle) + .font(.custom(Font.medium.rawValue, size: 10)) + .foregroundColor(Color(hex: "9970ff")) + .padding(.horizontal, 8) + .padding(.vertical, 8.3) + .background(Color.white) + .cornerRadius(13.3) + .onTapGesture { confirmAction() } + } + } + .padding(.horizontal, 26.7) + .padding(.top, confirmTitle != nil || cancelTitle != nil ? 10 : 0) + } + .padding(.vertical, 20) + .background(Color(hex: "9970ff")) + .cornerRadius(16.7) + .padding(.horizontal, 26.7) + .frame(width: screenSize().width) + } +} diff --git a/SodaLive/Sources/Live/Room/Dialog/LiveRoomDonationMessageDialog.swift b/SodaLive/Sources/Live/Room/Dialog/LiveRoomDonationMessageDialog.swift new file mode 100644 index 0000000..746839c --- /dev/null +++ b/SodaLive/Sources/Live/Room/Dialog/LiveRoomDonationMessageDialog.swift @@ -0,0 +1,118 @@ +// +// LiveRoomDonationMessageDialog.swift +// SodaLive +// +// Created by klaus on 2023/08/15. +// + +import SwiftUI + +struct LiveRoomDonationMessageDialog: View { + + @Binding var isShowing: Bool + @StateObject var viewModel = LiveRoomViewModel() + + var body: some View { + ZStack { + Color.black + .opacity(0.7) + .ignoresSafeArea() + .onTapGesture { + hideKeyboard() + } + + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("후원메시지") + .font(.custom(Font.bold.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Text("(\(viewModel.donationMessageCount))") + .font(.custom(Font.medium.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Text("닫기") + .font(.custom(Font.light.rawValue, size: 14.7)) + .foregroundColor(Color(hex: "eeeeee")) + .onTapGesture { isShowing = false } + } + + ScrollView { + if viewModel.donationMessageList.count > 0 { + LazyVStack(spacing: 10.7) { + ForEach(0.. 10 ? "\(String(item.nickname.prefix(10)))..." : item.nickname + Text(nickname) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + if item.can > 0 { + Text("\(item.can) 코인") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + } + .padding(.horizontal, isTop3Index(index: index) ? 20 : 0) + .padding(.top, getTopPadding(index: index)) + .padding(.bottom, getBottomPadding(index: index)) + .background(Color(hex: "2b2635").opacity(isTop3Index(index: index) ? 1 : 0)) + .cornerRadius(4.7, corners: cornerRadiusConers(index: index)) + .padding(.horizontal, isTop3Index(index: index) ? 0 : 6.7) + } + + private func isTop3Index(index: Int) -> Bool { + return index == 0 || index == 1 || index == 2 + } + + private func getTopPadding(index: Int) -> CGFloat { + if index == 0 || index == 3 { + return 20 + } else { + return 6.7 + } + } + + private func getBottomPadding(index: Int) -> CGFloat { + if (index == 0 && itemCount == 1) || (index == 1 && itemCount == 2) || index == 2 { + return 20 + } else { + return 6.7 + } + } + + private func cornerRadiusConers(index: Int) -> UIRectCorner { + if (index == 0 && itemCount == 1) { + return [.topLeft, .topRight, .bottomLeft, .bottomRight] + } else if index == 0 { + return [.topLeft, .topRight] + } else if (index == 1 && itemCount == 2) || index == 2 { + return [.bottomLeft, .bottomRight] + } else { + return [] + } + } +} diff --git a/SodaLive/Sources/Live/Room/Dialog/LiveRoomDonationRankingTotalCanView.swift b/SodaLive/Sources/Live/Room/Dialog/LiveRoomDonationRankingTotalCanView.swift new file mode 100644 index 0000000..e14dd5a --- /dev/null +++ b/SodaLive/Sources/Live/Room/Dialog/LiveRoomDonationRankingTotalCanView.swift @@ -0,0 +1,36 @@ +// +// LiveRoomDonationRankingTotalCanView.swift +// SodaLive +// +// Created by klaus on 2023/08/15. +// + +import SwiftUI + +struct LiveRoomDonationRankingTotalCanView: View { + + let totalCan: Int + + var body: some View { + HStack(alignment: .center, spacing: 0) { + Text("합계") + .font(.custom(Font.bold.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "d2d2d2")) + + Spacer() + + Text("\(totalCan)") + .font(.custom(Font.medium.rawValue, size: 16)) + .foregroundColor(Color(hex: "9970ff")) + + Text("캔") + .font(.custom(Font.medium.rawValue, size: 10.7)) + .foregroundColor(Color(hex: "bbbbbb")) + .padding(.leading, 4) + } + .padding(.horizontal, 18.7) + .padding(.vertical, 10.7) + .background(Color(hex: "2b2635")) + .cornerRadius(8) + } +} diff --git a/SodaLive/Sources/Live/Room/Dialog/LiveRoomInfoEditDialog.swift b/SodaLive/Sources/Live/Room/Dialog/LiveRoomInfoEditDialog.swift new file mode 100644 index 0000000..a8c4114 --- /dev/null +++ b/SodaLive/Sources/Live/Room/Dialog/LiveRoomInfoEditDialog.swift @@ -0,0 +1,238 @@ +// +// LiveRoomInfoEditDialog.swift +// SodaLive +// +// Created by klaus on 2023/08/15. +// + +import SwiftUI +import Kingfisher + +struct LiveRoomInfoEditDialog: View { + + @Binding var isShowing: Bool + @Binding var isShowPhotoPicker: Bool + + @State private var title = "" + @State private var notice = "" + + let placeholder = "라이브 공지를 입력하세요" + + let viewModel: LiveRoomViewModel + + let isLoading: Bool + let coverImageUrl: String? + let coverImage: UIImage? + var confirmAction: (String, String) -> Void + + init( + isShowing: Binding, + isShowPhotoPicker: Binding, + viewModel: LiveRoomViewModel, + isLoading: Bool, + currentTitle: String, + currentNotice: String, + coverImageUrl: String, + coverImage: UIImage?, + confirmAction: @escaping (String, String) -> Void + ) { + self._isShowing = isShowing + self._isShowPhotoPicker = isShowPhotoPicker + + self.viewModel = viewModel + self.isLoading = isLoading + + self.title = currentTitle + self.notice = currentNotice + self.coverImageUrl = coverImageUrl + self.coverImage = coverImage + self.confirmAction = confirmAction + + UITextView.appearance().backgroundColor = .clear + } + + var body: some View { + ZStack { + GeometryReader { proxy in + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + Text("라이브 수정") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Image("ic_close_white") + .onTapGesture { + isShowing = false + } + } + + HStack { + Spacer() + + ZStack { + if let coverImage = coverImage { + Image(uiImage: coverImage) + .resizable() + .scaledToFill() + .frame(width: 80, height: 116.8, alignment: .top) + .clipped() + .cornerRadius(10) + } else if let coverImageUrl = coverImageUrl { + KFImage(URL(string: coverImageUrl)) + .resizable() + .scaledToFill() + .frame(width: 80, height: 116.8, alignment: .top) + .clipped() + .cornerRadius(10) + } else { + Image("ic_logo_220") + .resizable() + .scaledToFit() + .frame(width: 80, height: 116.8) + .background(Color(hex: "3e3358")) + .cornerRadius(10) + } + + Image("ic_camera") + .padding(10) + .background(Color(hex: "9970ff")) + .cornerRadius(30) + .offset(x: 40, y: 40) + } + .frame(alignment: .bottomTrailing) + .padding(.top, 13.3) + .onTapGesture { + isShowPhotoPicker = true + } + + Spacer() + } + + TitleInputView() + .padding(.top, 40) + + ContentInputView() + .padding(.top, 33.3) + + HStack(spacing: 13.3) { + Text("취소") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "9970ff")) + .padding(.vertical, 16) + .frame(width: (screenSize().width - 40) / 2) + .background(Color(hex: "9970ff").opacity(0.2)) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(lineWidth: 1) + .foregroundColor(Color(hex: "9970ff")) + ) + .onTapGesture { + isShowing = false + } + + Text("수정하기") + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(.white) + .padding(.vertical, 16) + .frame(width: (screenSize().width - 40) / 2) + .background(Color(hex: "9970ff")) + .cornerRadius(10) + .onTapGesture { + confirmAction( + title, + notice.trimmingCharacters(in: .whitespacesAndNewlines) != placeholder ? notice : "" + ) + isShowing = false + } + } + .padding(.top, 45) + + Spacer() + } + .padding(13.3) + .frame(width: proxy.size.width, height: proxy.size.height) + .onTapGesture { hideKeyboard() } + } + .background(Color(hex: "222222").edgesIgnoringSafeArea(.all)) + } + + if viewModel.isShowPopup { + LiveRoomDialogView( + content: viewModel.errorMessage, + cancelTitle: viewModel.popupCancelTitle, + cancelAction: viewModel.popupCancelAction, + confirmTitle: viewModel.popupConfirmTitle, + confirmAction: viewModel.popupConfirmAction + ).onAppear { + if viewModel.popupConfirmTitle == nil && viewModel.popupConfirmAction == nil { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + viewModel.isShowPopup = false + } + } + } + } + + if isLoading { + LoadingView() + } + } + } + + @ViewBuilder + func TitleInputView() -> some View { + VStack(alignment: .leading, spacing: 0) { + Text("제목") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + + TextField("라이브 제목을 입력하세요", text: $title) + .autocapitalization(.none) + .disableAutocorrection(true) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + .accentColor(Color(hex: "9970ff")) + .keyboardType(.default) + .padding(.top, 12) + .padding(.horizontal, 6.7) + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.7)) + .padding(.top, 8.3) + } + } + + @ViewBuilder + func ContentInputView() -> some View { + VStack(spacing: 13.3) { + HStack(spacing: 0) { + Text("공지") + .font(.custom(Font.bold.rawValue, size: 16.7)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Text("\(notice.count)자") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "ff5c49")) + + Text(" / 1000자") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "777777")) + } + + TextViewWrapper( + text: $notice, + placeholder: placeholder, + textColorHex: "eeeeee", + backgroundColorHex: "303030" + ) + .frame(width: screenSize().width - 26.7, height: 133.3) + .cornerRadius(6.7) + .padding(.top, 13.3) + } + } +} diff --git a/SodaLive/Sources/Live/Room/Dialog/LiveRoomProfileDialog.swift b/SodaLive/Sources/Live/Room/Dialog/LiveRoomProfileDialog.swift new file mode 100644 index 0000000..fc60a40 --- /dev/null +++ b/SodaLive/Sources/Live/Room/Dialog/LiveRoomProfileDialog.swift @@ -0,0 +1,86 @@ +// +// LiveRoomProfileDialog.swift +// SodaLive +// +// Created by klaus on 2023/08/15. +// + +import SwiftUI +import Kingfisher + +struct LiveRoomProfileDialog: View { + + @Binding var isShowing: Bool + let profileInfo: LiveRoomMember + let creatorId: Int + let isSpeaker: Bool + + let memberId = UserDefaults.int(forKey: .userId) + + var onClickInviteSpeaker: ((Int) -> Void)? = nil + var onClickChangeListener: ((Int) -> Void)? = nil + + var body: some View { + ZStack { + Color.black.opacity(0.7).ignoresSafeArea() + .onTapGesture { + isShowing = false + } + + HStack(spacing: 13.3) { + KFImage(URL(string: profileInfo.profileImage)) + .resizable() + .frame(width: 80, height: 116.7, alignment: .top) + .clipped() + .cornerRadius(13.3) + + VStack(alignment: .leading, spacing: 0) { + Text(profileInfo.nickname) + .font(.custom(Font.bold.rawValue, size: 20)) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.top, 6.7) + + Spacer() + + if isSpeaker { + if profileInfo.role == .LISTENER, let onClickInviteSpeaker = onClickInviteSpeaker { + Text("스피커로 초대") + .font(.custom(Font.medium.rawValue, size: 10)) + .foregroundColor(Color(hex: "9970ff")) + .padding(.horizontal, 15.4) + .padding(.vertical, 8.3) + .background(Color.white) + .cornerRadius(13.3) + .onTapGesture { + onClickInviteSpeaker(profileInfo.id) + isShowing = false + } + } + + if (memberId == creatorId || memberId == profileInfo.id) && profileInfo.id != creatorId && profileInfo.role == .SPEAKER, + let onClickChangeListener = onClickChangeListener { + Text("리스너로 변경") + .font(.custom(Font.medium.rawValue, size: 10)) + .foregroundColor(Color(hex: "9970ff")) + .padding(.horizontal, 15.4) + .padding(.vertical, 8.3) + .background(Color.white) + .cornerRadius(13.3) + .onTapGesture { + onClickChangeListener(profileInfo.id) + isShowing = false + } + } + } + } + .frame(height: 116.7) + + Spacer() + } + .padding(20) + .frame(width: screenSize().width - 53.4) + .background(Color(hex: "9970ff")) + .cornerRadius(16.7) + } + } +} diff --git a/SodaLive/Sources/Live/Room/Dialog/LiveRoomProfileItemTitleView.swift b/SodaLive/Sources/Live/Room/Dialog/LiveRoomProfileItemTitleView.swift new file mode 100644 index 0000000..157d2f6 --- /dev/null +++ b/SodaLive/Sources/Live/Room/Dialog/LiveRoomProfileItemTitleView.swift @@ -0,0 +1,189 @@ +// +// LiveRoomProfileItemTitleView.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import SwiftUI +import Kingfisher + +struct LiveRoomProfileItemTitleView: View { + + let title: String + let count: Int? + let totalCount: Int? + + var body: some View { + HStack(spacing: 0) { + Text(title) + .font(.custom(Font.bold.rawValue, size: 13)) + .foregroundColor(Color(hex: "eeeeee")) + + if let count = count { + Text("\(count)") + .font(.custom(Font.medium.rawValue, size: 13)) + .foregroundColor(Color(hex: "9970ff")) + .padding(.leading, 6.7) + } + + if let totalCount = totalCount { + Text("/\(totalCount > 9 ? 9 : totalCount - 1)") + .font(.custom(Font.medium.rawValue, size: 13)) + .foregroundColor(Color(hex: "bbbbbb")) + } + + Spacer() + } + } +} + +struct LiveRoomProfileItemMasterView: View { + + let id: Int + let nickname: String + let profileUrl: String + let onClickProfile: (Int) -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + HStack(spacing: 0) { + KFImage(URL(string: profileUrl)) + .resizable() + .frame(width: 60, height: 60) + .clipShape(Circle()) + .onTapGesture { onClickProfile(id) } + + Image("ic_crown") + .padding(.leading, 16.7) + + Text(nickname) + .font(.custom(Font.medium.rawValue, size: 14)) + .foregroundColor(Color(hex: "eeeeee")) + .padding(.leading, 4) + } + .padding(.horizontal, 16.7) + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.3)) + } + } +} + +struct LiveRoomProfileItemUserView: View { + let isStaff: Bool + let userId: Int + let nickname: String + let profileUrl: String + let role: LiveRoomMemberRole + + let onClickChangeListener: (Int) -> Void + let onClickInviteSpeaker: (Int) -> Void + let onClickKickOut: (Int) -> Void + let onClickProfile: (Int) -> Void + + var body: some View { + ZStack { + VStack(spacing: 10) { + HStack(spacing: 0) { + KFImage(URL(string: profileUrl)) + .resizable() + .frame(width: 46.7, height: 46.7) + .clipShape(Circle()) + .onTapGesture { onClickProfile(userId) } + + if role == .MANAGER { + Image("ic_badge_manager") + .padding(.leading, 16.7) + + Text(nickname) + .font(.custom(Font.medium.rawValue, size: 14)) + .foregroundColor(Color(hex: "eeeeee")) + .lineLimit(2) + .multilineTextAlignment(.leading) + .padding(.leading, 4) + .padding(.trailing, 10) + } else { + Text(nickname) + .font(.custom(Font.medium.rawValue, size: 14)) + .foregroundColor(Color(hex: "eeeeee")) + .lineLimit(2) + .multilineTextAlignment(.leading) + .padding(.horizontal, 10) + } + + Spacer() + + if role == .LISTENER && isStaff { + Text("스피커로 초대") + .font(.custom(Font.medium.rawValue, size: 10)) + .foregroundColor(Color(hex: "ffffff")) + .padding(.horizontal, 5.5) + .padding(.vertical, 12) + .background(Color(hex: "9970ff").opacity(0.3)) + .cornerRadius(6.7) + .overlay( + RoundedRectangle(cornerRadius: 6.7) + .stroke(Color(hex: "9970ff"), lineWidth: 1) + ) + .onTapGesture { + onClickInviteSpeaker(userId) + } + } + + if role == .SPEAKER && (userId == UserDefaults.int(forKey: .userId) || isStaff) { + Text("리스너로 변경") + .font(.custom(Font.medium.rawValue, size: 10)) + .foregroundColor(Color(hex: "ffffff")) + .padding(.horizontal, 5.5) + .padding(.vertical, 12) + .background(Color(hex: "9970ff")) + .cornerRadius(6.7) + .onTapGesture { + onClickChangeListener(userId) + } + } + + if role != .MANAGER && isStaff { + Image("ic_kick_out") + .padding(.leading, 10) + .onTapGesture { + onClickKickOut(userId) + } + } + } + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.3)) + } + .padding(.horizontal, 16.7) + } + } +} + +struct LiveRoomProfileRequestSpeakerView: View { + + let onClickRequestSpeaker: () -> Void + + var body: some View { + HStack(spacing: 6.7) { + Spacer() + Image("ic_request_speak") + Text("스피커 요청하기") + .font(.custom(Font.bold.rawValue, size: 13.3)) + .foregroundColor(.white) + Spacer() + } + .padding(.vertical, 8) + .overlay( + RoundedRectangle(cornerRadius: 5.3) + .stroke(Color(hex: "909090"), lineWidth: 1) + ) + .onTapGesture { + onClickRequestSpeaker() + } + .padding(.horizontal, 16.7) + } +} diff --git a/SodaLive/Sources/Live/Room/Dialog/LiveRoomProfilesDialogView.swift b/SodaLive/Sources/Live/Room/Dialog/LiveRoomProfilesDialogView.swift new file mode 100644 index 0000000..12aaf9d --- /dev/null +++ b/SodaLive/Sources/Live/Room/Dialog/LiveRoomProfilesDialogView.swift @@ -0,0 +1,249 @@ +// +// LiveRoomProfilesDialogView.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import SwiftUI +import Kingfisher + +struct LiveRoomProfilesDialogView: View { + + @Binding var isShowing: Bool + + let viewModel: LiveRoomViewModel + let roomInfo: GetRoomInfoResponse + + var profiles: [AnyView] = [] + let accountId = UserDefaults.int(forKey: .userId) + + init( + isShowing: Binding, + viewModel: LiveRoomViewModel, + roomInfo: GetRoomInfoResponse, + isShowRequestSpeaker: Bool, + onClickRequestSpeaker: @escaping () -> Void, + registerNotification: @escaping () -> Void, + unRegisterNotification: @escaping () -> Void, + onClickProfile: @escaping (Int) -> Void + ) { + self._isShowing = isShowing + self.viewModel = viewModel + self.roomInfo = roomInfo + + self.profiles.append( + AnyView( + LiveRoomProfileItemTitleView( + title: "스탭", + count: roomInfo.managerList.count, + totalCount: nil + ) + .padding(.vertical, 14) + ) + ) + + let isStaff = viewModel.isEqualToStaffId(creatorId: UserDefaults.int(forKey: .userId)) || + roomInfo.creatorId == UserDefaults.int(forKey: .userId) + + for manager in roomInfo.managerList { + self.profiles.append( + AnyView( + LiveRoomProfileItemUserView( + isStaff: isStaff , + userId: manager.id, + nickname: manager.nickname, + profileUrl: manager.profileImage, + role: manager.role, + onClickChangeListener: { _ in }, + onClickInviteSpeaker: { _ in }, + onClickKickOut: { _ in }, + onClickProfile: onClickProfile + ) + ) + ) + } + + self.profiles.append( + AnyView( + LiveRoomProfileItemTitleView( + title: "스피커", + count: roomInfo.speakerList.count - 1, + totalCount: roomInfo.totalAvailableParticipantsCount + ) + .padding(.vertical, 14) + ) + ) + + for speaker in roomInfo.speakerList { + if speaker.id == roomInfo.creatorId { + self.profiles.insert( + AnyView( + LiveRoomProfileItemMasterView( + id: speaker.id, + nickname: speaker.nickname, + profileUrl: speaker.profileImage, + onClickProfile: onClickProfile + ) + ), + at: 0 + ) + } else { + self.profiles.append( + AnyView( + LiveRoomProfileItemUserView( + isStaff: isStaff, + userId: speaker.id, + nickname: speaker.nickname, + profileUrl: speaker.profileImage, + role: speaker.role, + onClickChangeListener: { + if $0 == UserDefaults.int(forKey: .userId) { + viewModel.setListener() + return + } + + viewModel.changeListener(peerId: $0) + }, + onClickInviteSpeaker: { _ in }, + onClickKickOut: { + viewModel.kickOutId = $0 + viewModel.isShowKickOutPopup = true + }, + onClickProfile: onClickProfile + ) + ) + ) + } + } + + if isShowRequestSpeaker { + self.profiles.append( + AnyView( + LiveRoomProfileRequestSpeakerView { + onClickRequestSpeaker() + } + ) + ) + } + + self.profiles.append( + AnyView( + LiveRoomProfileItemTitleView( + title: "리스너", + count: nil, + totalCount: nil + ) + .padding(.top, 20) + .padding(.bottom, 14) + ) + ) + + for listener in roomInfo.listenerList { + self.profiles.append( + AnyView( + LiveRoomProfileItemUserView( + isStaff: isStaff, + userId: listener.id, + nickname: listener.nickname, + profileUrl: listener.profileImage, + role: listener.role, + onClickChangeListener: { _ in }, + onClickInviteSpeaker: { + if viewModel.liveRoomInfo!.speakerList.count <= 9 { + viewModel.inviteSpeaker(peerId: $0) + viewModel.popupContent = "스피커 요청을 보냈습니다.\n잠시만 기다려 주세요." + viewModel.isShowPopup = true + } else { + viewModel.errorMessage = "스피커 정원을 초과했습니다." + viewModel.isShowErrorPopup = true + } + }, + onClickKickOut: { + viewModel.kickOutId = $0 + viewModel.isShowKickOutPopup = true + }, + onClickProfile: onClickProfile + ) + ) + ) + } + } + + var body: some View { + ZStack { + VStack(spacing: 16.7) { + HStack(spacing: 0) { + Text("참여자") + .font(.custom(Font.bold.rawValue, size: 15)) + .foregroundColor(Color(hex: "eeeeee")) + + Text("\(roomInfo.participantsCount)") + .font(.custom(Font.medium.rawValue, size: 14)) + .foregroundColor(Color(hex: "9970ff")) + .padding(.leading, 6.7) + + Text("/\(roomInfo.totalAvailableParticipantsCount)") + .font(.custom(Font.medium.rawValue, size: 14)) + .foregroundColor(Color(hex: "bbbbbb")) + + Spacer() + + Image("ic_close_white") + .resizable() + .frame(width: 20, height: 20) + .onTapGesture { isShowing = false } + } + + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 20) { + ForEach(0.. Void + let onClickReleaseManager: (Int) -> Void + let onClickFollow: (Int) -> Void + let onClickUnFollow: (Int) -> Void + let onClickInviteSpeaker: (Int) -> Void + let onClickChangeListener: (Int) -> Void + let onClickMenu: (Int, String, Bool) -> Void + + var body: some View { + ZStack { + VStack(spacing: 0) { + HStack(spacing: 0) { + Text("프로필") + .font(.custom(Font.bold.rawValue, size: 15)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Image("ic_close_white") + .onTapGesture { + isShowing = false + } + } + .padding(.top, 13.3) + + ScrollView(.vertical, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + Text(userProfile.nickname) + .font(.custom(Font.bold.rawValue, size: 18.3)) + .foregroundColor(Color(hex: "eeeeee")) + + Text(userProfile.gender) + .font(.custom(Font.medium.rawValue, size: 11.3)) + .foregroundColor(Color(hex: "ffffff")) + .padding(.horizontal, 5.3) + .padding(.vertical, 3) + .background(Color(hex: "555555")) + .cornerRadius(23.3) + + Spacer() + + Image("ic_seemore_vertical") + .onTapGesture { + onClickMenu( + userProfile.userId, + userProfile.nickname, + userProfile.isBlock + ) + } + } + .padding(.top, 21.3) + + HStack(spacing: 8) { + if let isFollwing = userProfile.isFollowing { + if isFollwing { + HStack(spacing: 4) { + Image("ic_alarm_selected") + .resizable() + .frame(width: 18.7, height: 18.7) + + Text("팔로잉") + .font(.custom(Font.bold.rawValue, size: 12)) + .foregroundColor(Color(hex: "ffffff")) + } + .padding(.vertical, 7.3) + .frame(maxWidth: .infinity) + .background(Color(hex: "3e1b93")) + .cornerRadius(16.7) + .overlay( + RoundedRectangle(cornerRadius: 16.7) + .strokeBorder(lineWidth: 1) + .foregroundColor(Color(hex: "9970ff")) + ) + .onTapGesture { onClickUnFollow(userProfile.userId) } + } else { + HStack(spacing: 4) { + Image("ic_alarm") + .resizable() + .frame(width: 18.7, height: 18.7) + + Text("팔로우") + .font(.custom(Font.bold.rawValue, size: 12)) + .foregroundColor(Color(hex: "ffffff")) + } + .padding(.vertical, 7.3) + .frame(maxWidth: .infinity) + .cornerRadius(16.7) + .overlay( + RoundedRectangle(cornerRadius: 16.7) + .strokeBorder(lineWidth: 1) + .foregroundColor(Color(hex: "9970ff")) + ) + .onTapGesture { onClickFollow(userProfile.userId) } + } + } + + HStack(spacing: 4) { + Image("ic_message_send") + .resizable() + .frame(width: 18.7, height: 18.7) + + Text("메시지 보내기") + .font(.custom(Font.bold.rawValue, size: 12)) + .foregroundColor(Color(hex: "ffffff")) + } + .padding(.vertical, 7.3) + .frame(maxWidth: .infinity) + .background(Color(hex: "9970ff")) + .cornerRadius(16.7) + .onTapGesture { + AppState.shared.setAppStep(step: .writeTextMessage( + userId: userProfile.userId, + nickname: userProfile.nickname)) + } + } + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 21.3) + + KFImage(URL(string: userProfile.profileUrl)) + .resizable() + .aspectRatio(CGSize(width: 1, height: 1), contentMode: .fill) + .cornerRadius(8) + .padding(.top, 21.3) + + HStack(spacing: 8) { + if let isSpeaker = userProfile.isSpeaker { + Text(isSpeaker ? "리스너 변경" : "스피커 초대") + .font(.custom(Font.bold.rawValue, size: 15)) + .foregroundColor(Color(hex: "9970ff")) + .frame(maxWidth: .infinity) + .padding(.vertical, 13) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(lineWidth: 1) + .foregroundColor(Color(hex: "9970ff")) + ) + .onTapGesture { + if isSpeaker { + onClickChangeListener(userProfile.userId) + } else { + onClickInviteSpeaker(userProfile.userId) + } + + isShowing = false + } + } + + if let isManager = userProfile.isManager { + Text(isManager ? "스탭 해제" : "스탭 지정") + .font(.custom(Font.bold.rawValue, size: 15)) + .foregroundColor(Color(hex: "9970ff")) + .frame(maxWidth: .infinity) + .padding(.vertical, 13) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(lineWidth: 1) + .foregroundColor(Color(hex: "9970ff")) + ) + .onTapGesture { + if isManager { + onClickReleaseManager(userProfile.userId) + } else { + onClickSetManager(userProfile.userId) + } + + isShowing = false + } + } + + if (userProfile.isSpeaker != nil && !viewModel.isEqualToStaffId(creatorId: userProfile.userId)) || + (userProfile.isSpeaker != nil && userProfile.isManager != nil) { + Text("내보내기") + .font(.custom(Font.bold.rawValue, size: 15)) + .foregroundColor(Color(hex: "9970ff")) + .frame(maxWidth: .infinity) + .padding(.vertical, 13) + .cornerRadius(8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .strokeBorder(lineWidth: 1) + .foregroundColor(Color(hex: "9970ff")) + ) + .onTapGesture { + viewModel.kickOutId = userProfile.userId + viewModel.isShowKickOutPopup = true + } + } + } + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 21.3) + + Text(userProfile.tags) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "9970ff")) + .lineSpacing(3) + .padding(.top, 21.3) + + Text(userProfile.introduce) + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "909090")) + .lineLimit(introduceLineLimit) + .lineSpacing(3) + .fixedSize(horizontal: false, vertical: true) + .padding(.vertical, 20) + .onTapGesture { + if let _ = introduceLineLimit { + self.introduceLineLimit = nil + } else { + self.introduceLineLimit = 2 + } + } + } + } + } + .padding(.horizontal, 13.3) + .background(Color(hex: "222222").edgesIgnoringSafeArea(.all)) + .cornerRadius(8) + + if viewModel.isShowKickOutPopup { + SodaDialog( + title: "내보내기", + desc: viewModel.kickOutDesc, + confirmButtonTitle: "내보내기", + confirmButtonAction: { + viewModel.kickOut() + isShowing = false + }, + cancelButtonTitle: "취소", + cancelButtonAction: { + viewModel.isShowKickOutPopup = false + viewModel.kickOutDesc = "" + viewModel.kickOutId = 0 + } + ) + } + } + } +} diff --git a/SodaLive/Sources/Live/Room/GetLiveRoomDonationStatusResponse.swift b/SodaLive/Sources/Live/Room/GetLiveRoomDonationStatusResponse.swift new file mode 100644 index 0000000..c969dbd --- /dev/null +++ b/SodaLive/Sources/Live/Room/GetLiveRoomDonationStatusResponse.swift @@ -0,0 +1,21 @@ +// +// GetLiveRoomDonationStatusResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import Foundation + +struct GetLiveRoomDonationStatusResponse: Decodable { + let donationList: [GetLiveRoomDonationItem] + let totalCount: Int + let totalCan: Int +} + +struct GetLiveRoomDonationItem: Decodable { + let profileImage: String + let nickname: String + let userId: Int + let can: Int +} diff --git a/SodaLive/Sources/Live/Room/GetLiveRoomDonationTotalResponse.swift b/SodaLive/Sources/Live/Room/GetLiveRoomDonationTotalResponse.swift new file mode 100644 index 0000000..140c429 --- /dev/null +++ b/SodaLive/Sources/Live/Room/GetLiveRoomDonationTotalResponse.swift @@ -0,0 +1,12 @@ +// +// GetLiveRoomDonationTotalResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import Foundation + +struct GetLiveRoomDonationTotalResponse: Decodable { + let totalDonationCan: Int +} diff --git a/SodaLive/Sources/Live/Room/GetLiveRoomUserProfileResponse.swift b/SodaLive/Sources/Live/Room/GetLiveRoomUserProfileResponse.swift new file mode 100644 index 0000000..5e003d8 --- /dev/null +++ b/SodaLive/Sources/Live/Room/GetLiveRoomUserProfileResponse.swift @@ -0,0 +1,25 @@ +// +// GetLiveRoomUserProfileResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import Foundation + +struct GetLiveRoomUserProfileResponse: Decodable { + let userId: Int + let nickname: String + let profileUrl: String + let gender: String + let instagramUrl: String + let youtubeUrl: String + let websiteUrl: String + let blogUrl: String + let introduce: String + let tags: String + let isSpeaker: Bool? + let isManager: Bool? + let isFollowing: Bool? + let isBlock: Bool +} diff --git a/SodaLive/Sources/Live/Room/GetMemberCanResponse.swift b/SodaLive/Sources/Live/Room/GetMemberCanResponse.swift new file mode 100644 index 0000000..bf4e0c6 --- /dev/null +++ b/SodaLive/Sources/Live/Room/GetMemberCanResponse.swift @@ -0,0 +1,12 @@ +// +// GetMemberCanResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import Foundation + +struct GetMemberCanResponse: Decodable { + let can: Int +} diff --git a/SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift b/SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift new file mode 100644 index 0000000..c8a526f --- /dev/null +++ b/SodaLive/Sources/Live/Room/GetRoomInfoResponse.swift @@ -0,0 +1,28 @@ +// +// GetRoomInfoResponse.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +struct GetRoomInfoResponse: Decodable { + let roomId: Int + let title: String + let notice: String + let coverImageUrl: String + let channelName: String + let rtcToken: String + let rtmToken: String + let creatorId: Int + let creatorNickname: String + let creatorProfileUrl: String + let isFollowing: Bool + let participantsCount: Int + let totalAvailableParticipantsCount: Int + let speakerList: [LiveRoomMember] + let listenerList: [LiveRoomMember] + let managerList: [LiveRoomMember] + let donationRankingTop3UserIds: [Int] + let isPrivateRoom: Bool + let password: String? +} diff --git a/SodaLive/Sources/Live/Room/LiveRoomDonationMessage.swift b/SodaLive/Sources/Live/Room/LiveRoomDonationMessage.swift new file mode 100644 index 0000000..60cad92 --- /dev/null +++ b/SodaLive/Sources/Live/Room/LiveRoomDonationMessage.swift @@ -0,0 +1,15 @@ +// +// LiveRoomDonationMessage.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import Foundation + +struct LiveRoomDonationMessage: Decodable { + let uuid: String + let nickname: String + let canMessage: String + let donationMessage: String +} diff --git a/SodaLive/Sources/Live/Room/LiveRoomDonationRequest.swift b/SodaLive/Sources/Live/Room/LiveRoomDonationRequest.swift new file mode 100644 index 0000000..b437cad --- /dev/null +++ b/SodaLive/Sources/Live/Room/LiveRoomDonationRequest.swift @@ -0,0 +1,15 @@ +// +// LiveRoomDonationRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import Foundation + +struct LiveRoomDonationRequest: Encodable { + let roomId: Int + let can: Int + let message: String + let container: String = "ios" +} diff --git a/SodaLive/Sources/Live/Room/LiveRoomKickOutRequest.swift b/SodaLive/Sources/Live/Room/LiveRoomKickOutRequest.swift new file mode 100644 index 0000000..84a6f35 --- /dev/null +++ b/SodaLive/Sources/Live/Room/LiveRoomKickOutRequest.swift @@ -0,0 +1,13 @@ +// +// LiveRoomKickOutRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import Foundation + +struct LiveRoomKickOutRequest: Encodable { + let roomId: Int + let userId: Int +} diff --git a/SodaLive/Sources/Live/Room/LiveRoomMember.swift b/SodaLive/Sources/Live/Room/LiveRoomMember.swift new file mode 100644 index 0000000..9c11b0f --- /dev/null +++ b/SodaLive/Sources/Live/Room/LiveRoomMember.swift @@ -0,0 +1,41 @@ +// +// LiveRoomMember.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +struct LiveRoomMember: Decodable, Hashable { + let id: Int + let nickname: String + let profileImage: String + let role: LiveRoomMemberRole +} + +enum LiveRoomMemberRole: String, Decodable { + case LISTENER, SPEAKER, MANAGER +} + +enum LiveRoomProfileItemType: String { + case MASTER, SPEAKER_TITLE, LISTENER_TITLE, USER +} + +protocol LiveRoomProfileItem: Hashable { + var type: LiveRoomProfileItemType { get set } +} + +struct LiveRoomProfileItemSpeakerTitle: LiveRoomProfileItem { + var type: LiveRoomProfileItemType = .SPEAKER_TITLE +} + +struct LiveRoomProfileItemListenerTitle: LiveRoomProfileItem { + var type: LiveRoomProfileItemType = .LISTENER_TITLE +} + +struct LiveRoomProfileItemMaster: LiveRoomProfileItem { + var type: LiveRoomProfileItemType = .MASTER +} + +struct LiveRoomProfileItemUser: LiveRoomProfileItem { + var type: LiveRoomProfileItemType = .USER +} diff --git a/SodaLive/Sources/Live/Room/LiveRoomRequestType.swift b/SodaLive/Sources/Live/Room/LiveRoomRequestType.swift new file mode 100644 index 0000000..3dc37bc --- /dev/null +++ b/SodaLive/Sources/Live/Room/LiveRoomRequestType.swift @@ -0,0 +1,12 @@ +// +// LiveRoomRequestType.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import Foundation + +enum LiveRoomRequestType: String { + case REQUEST_SPEAKER, REQUEST_SPEAKER_ALLOW, INVITE_SPEAKER, CHANGE_LISTENER, KICK_OUT, SET_MANAGER, RELEASE_MANAGER +} diff --git a/SodaLive/Sources/Live/Room/LiveRoomTopCreatorView.swift b/SodaLive/Sources/Live/Room/LiveRoomTopCreatorView.swift new file mode 100644 index 0000000..1fe9d20 --- /dev/null +++ b/SodaLive/Sources/Live/Room/LiveRoomTopCreatorView.swift @@ -0,0 +1,41 @@ +// +// LiveRoomTopCreatorView.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import SwiftUI + +import Kingfisher + +struct LiveRoomTopCreatorView: View { + + var nickname: String + var profileImageUrl: String + var isFollowing: Bool + var onClickProfile: () -> Void + var onClickFollow: (Bool) -> Void + + var body: some View { + HStack(spacing: 5.3) { + KFImage(URL(string: profileImageUrl)) + .resizable() + .scaledToFill() + .frame(width: 33.3, height: 33.3) + .clipShape(Circle()) + .onTapGesture { onClickProfile() } + + Image("ic_crown") + + Text(nickname) + .font(.custom(Font.light.rawValue, size: 12)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + + Image(isFollowing ? "btn_following" : "btn_follow") + .onTapGesture { onClickFollow(isFollowing) } + } + } +} diff --git a/SodaLive/Sources/Live/Room/LiveRoomView.swift b/SodaLive/Sources/Live/Room/LiveRoomView.swift new file mode 100644 index 0000000..9b88d20 --- /dev/null +++ b/SodaLive/Sources/Live/Room/LiveRoomView.swift @@ -0,0 +1,932 @@ +// +// LiveRoomView.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import SwiftUI +import Kingfisher +import PopupView + +struct LiveRoomView: View { + @State private var isShowingNewChat = false + @State private var isShowPhotoPicker = false + + let columns = [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ] + + let chatColumns = [GridItem(.flexible())] + + @StateObject var keyboardHandler = KeyboardHandler() + @StateObject var viewModel = LiveRoomViewModel() + + var body: some View { + ZStack { + Color.black.edgesIgnoringSafeArea(.all) + + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 6.7) { + Text( + UserDefaults.int(forKey: .userId) == viewModel.liveRoomInfo?.creatorId ? + "라이브 종료": + "나가기" + ) + .font(.custom(Font.medium.rawValue, size: 10)) + .foregroundColor(Color(hex: "ff5c49")) + .padding(.horizontal, 14.3) + .padding(.vertical, 8.3) + .overlay( + RoundedRectangle(cornerRadius: 13.3) + .stroke(Color(hex: "ff5c49"), lineWidth: 1) + ) + .onTapGesture { + if let liveRoomInfo = viewModel.liveRoomInfo, liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) { + viewModel.isShowLiveEndPopup = true + } else { + viewModel.isShowQuitPopup = true + } + } + + Spacer() + + Text(viewModel.isBgOn ? "배경 ON" : "배경 OFF") + .font(.custom(Font.medium.rawValue, size: 10)) + .foregroundColor(Color(hex: viewModel.isBgOn ? "9970ff" : "eeeeee")) + .padding(.horizontal, 14.3) + .padding(.vertical, 8.3) + .overlay( + RoundedRectangle(cornerRadius: 13.3) + .stroke(Color(hex: viewModel.isBgOn ? "9970ff" : "bbbbbb"), lineWidth: 1) + ) + .onTapGesture { + viewModel.isBgOn.toggle() + } + + HStack(spacing: 4.7) { + Image("ic_share") + .resizable() + .frame(width: 16, height: 16) + } + .padding(.horizontal, 14.3) + .padding(.vertical, 6) + .overlay( + RoundedRectangle(cornerRadius: 13.3) + .stroke(Color(hex: "bbbbbb"), lineWidth: 1) + ) + .onTapGesture { + viewModel.shareRoom() + } + + if let liveRoomInfo = viewModel.liveRoomInfo, + liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) { + HStack(spacing: 4.7) { + Image("ic_edit") + .resizable() + .frame(width: 16, height: 16) + } + .padding(.horizontal, 14.3) + .padding(.vertical, 6) + .overlay( + RoundedRectangle(cornerRadius: 13.3) + .stroke(Color(hex: "bbbbbb"), lineWidth: 1) + ) + .onTapGesture { + viewModel.isShowEditRoomInfoDialog = true + } + } + } + .padding(.horizontal, 13.3) + + if let liveRoomInfo = viewModel.liveRoomInfo { + ZStack { + VStack(alignment: .leading, spacing: 0) { + Text(liveRoomInfo.title) + .font(.custom(Font.bold.rawValue, size: 15.3)) + .foregroundColor(Color(hex: "eeeeee")) + .lineLimit(1) + .padding(.top, 16.7) + .padding(.horizontal, 13.3) + + LiveRoomTopCreatorView( + nickname: liveRoomInfo.creatorNickname, + profileImageUrl: liveRoomInfo.creatorProfileUrl, + isFollowing: liveRoomInfo.isFollowing, + onClickProfile: { + if liveRoomInfo.creatorId != UserDefaults.int(forKey: .userId) { + viewModel.getUserProfile(userId: liveRoomInfo.creatorId) + } + }, + onClickFollow: { + if $0 { + viewModel.unRegisterNotification() + } else { + viewModel.registerNotification() + } + } + ) + .padding(.top, 16.7) + .padding(.horizontal, 13.3) + + Rectangle() + .frame(height: 1) + .foregroundColor(Color(hex: "909090").opacity(0.3)) + .padding(.horizontal, 13.3) + .padding(.top, 8) + + NotificationView(liveRoomInfo: liveRoomInfo) + } + + if viewModel.isMute { + Image("img_noti_mute") + } + } + + if viewModel.isShowNotice { + HStack(alignment: .top, spacing: 8) { + Text("[공지]") + .font(.custom(Font.bold.rawValue, size: 11.3)) + .foregroundColor(.white) + + AttributedTextView( + attributedString: makeAttributedString(liveRoomInfo.notice), + lineLimit: viewModel.isExpandNotice ? Int.max : 1 + ) { + UIApplication.shared.open($0) + + } + .fixedSize(horizontal: false, vertical: true) + .lineSpacing(6) + } + .padding(.horizontal, 26.7) + .padding(.vertical, 13.3) + .frame(width: screenSize().width, alignment: .leading) + .background(Color(hex: "3d2a6c")) + .padding(.top, 10) + .onTapGesture { + viewModel.isExpandNotice.toggle() + } + } + + if !viewModel.isSpeakerFold { + HStack(spacing: 0) { + Text("스피커") + .font(.custom(Font.bold.rawValue, size: 12)) + .foregroundColor(Color(hex: "eeeeee")) + + Spacer() + } + .padding(.top, 20) + .padding(.horizontal, 23.3) + + LazyVGrid(columns: columns) { + ForEach(liveRoomInfo.speakerList, id: \.self) { speaker in + VStack(spacing: 6.7) { + ZStack { + KFImage(URL(string: speaker.profileImage)) + .resizable() + .scaledToFill() + .frame(width: 46.7, height: 46.7, alignment: .top) + .clipShape(Circle()) + .overlay( + Circle() + .stroke( + Color(hex: "9970ff"), + lineWidth: viewModel.activeSpeakers.contains(UInt(speaker.id)) ? 3 : 0 + ) + ) + + if viewModel.muteSpeakers.contains(UInt(speaker.id)) { + Image("ic_mute") + .resizable() + .frame(width: 46.7, height: 46.7) + } + + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 0) { + Spacer() + + if liveRoomInfo.creatorId == speaker.id { + Image("ic_crown") + .resizable() + .frame(width: 16.7, height: 16.7) + } + } + + Spacer() + } + } + .frame(width: 46.7, height: 46.7) + + Text(speaker.nickname) + .font(.custom(Font.light.rawValue, size: 12)) + .foregroundColor(Color(hex: "bbbbbb")) + .lineLimit(1) + } + .onTapGesture { + viewModel.selectedProfile = speaker + viewModel.isShowProfilePopup = true + } + } + } + .padding(.top, 16.7) + .padding(.horizontal, 23.3) + } + } + } + .padding(.vertical, 16.7) + .frame(width: screenSize().width) + .background(Color(hex: "222222")) + .cornerRadius(16.7, corners: [.topLeft, .topRight]) + + ZStack(alignment: .top) { + ScrollViewReader { proxy in + ZStack(alignment: .bottomTrailing) { + if let liveRoomInfo = viewModel.liveRoomInfo, viewModel.isBgOn { + GeometryReader { proxy in + KFImage(URL(string: liveRoomInfo.coverImageUrl)) + .resizable() + .scaledToFill() + .frame(width: proxy.size.width, height: proxy.size.height, alignment: .center) + .clipped() + + Color.black.opacity(0.4) + } + } + + VStack(alignment: .leading, spacing: 0) { + ScrollView(.vertical, showsIndicators: false) { + scrollObservableView + ChatView() + .frame(width: screenSize().width) + } + .rotationEffect(Angle(degrees: 180)) + .onTapGesture { hideKeyboard() } + .onPreferenceChange(ScrollOffsetKey.self) { + viewModel.setOffset($0) + } + + InputChatView { + isShowingNewChat = false + proxy.scrollTo(viewModel.messages.count - 1, anchor: .center) + }.padding(.bottom, keyboardHandler.keyboardHeight > 0 ? 0 : 15) + } + + VStack(spacing: 13.3) { + if viewModel.role == .SPEAKER { + Image(viewModel.isMute ? "ic_mic_off" : "ic_mic_on") + .resizable() + .frame(width: 26.7, height: 26.7) + .padding(11) + .background(Color(hex: "525252").opacity(0.6)) + .cornerRadius(10) + .padding(.bottom, 13.3) + .onTapGesture { + viewModel.toggleMute() + } + } + + Image(viewModel.isSpeakerMute ? "ic_speaker_off" : "ic_speaker_on") + .resizable() + .frame(width: 26.7, height: 26.7) + .padding(11) + .background(Color(hex: "525252").opacity(0.6)) + .cornerRadius(10) + .padding(.bottom, 13.3) + .onTapGesture { + viewModel.toggleSpeakerMute() + } + + if let liveRoomInfo = viewModel.liveRoomInfo, liveRoomInfo.creatorId == UserDefaults.int(forKey: .userId) && UserDefaults.string(forKey: .role) == MemberRole.CREATOR.rawValue { + Image("ic_donation_message_list") + .resizable() + .frame(width: 26.7, height: 26.7) + .padding(11) + .background(Color(hex: "525252").opacity(0.6)) + .cornerRadius(10) + .onTapGesture { + viewModel.isShowDonationMessagePopup = true + } + } else { + Image("ic_donation") + .resizable() + .frame(width: 26.7, height: 26.7) + .padding(11) + .background(Color(hex: "525252").opacity(0.6)) + .cornerRadius(10) + .onTapGesture { + viewModel.isShowDonationPopup = true + } + } + } + .padding(.trailing, 16.7) + .padding(.bottom, 85) + + if isShowingNewChat { + NewChatView{ + isShowingNewChat = false + proxy.scrollTo(viewModel.messages.count - 1, anchor: .center) + }.padding(.bottom, 70) + } + } + .frame(width: screenSize().width) + .animation(nil) + } + + HStack(spacing: 0) { + Spacer() + + HStack(spacing: 6.7) { + Image(viewModel.isSpeakerFold ? "ic_live_detail_bottom" : "ic_live_detail_top") + .resizable() + .frame(width: 20, height: 20) + + Text(viewModel.isSpeakerFold ? "펼치기" : "접기") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "bbbbbb")) + } + .padding(.vertical, 6.7) + .padding(.horizontal, 13.3) + .background(Color(hex: "222222")) + .cornerRadius(10, corners: [.bottomLeft, .bottomRight]) + .onTapGesture { + viewModel.isSpeakerFold.toggle() + } + } + .background( + LinearGradient( + gradient: Gradient(colors: [Color(hex: "222222").opacity(0.95), Color.black.opacity(0.005)]), + startPoint: .top, + endPoint: .bottom + ).ignoresSafeArea() + ) + } + } + .popup(isPresented: $viewModel.isShowErrorPopup, type: .toast, position: .top, autohideIn: 1.3) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.errorMessage) + .padding(.vertical, 13.3) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.center) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + .onDisappear { + viewModel.quitRoom() + } + } + } + .cornerRadius(16.7, corners: [.topLeft, .topRight]) + .offset(y: -(keyboardHandler.keyboardHeight > 0 ? keyboardHandler.keyboardHeight : 0)) + .onAppear { + UIApplication.shared.isIdleTimerDisabled = true + UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil) + + viewModel.getMemberCan() + viewModel.initAgoraEngine() + viewModel.getRoomInfo() + + NotificationCenter.default.addObserver( + forName: UIApplication.willTerminateNotification, + object: nil, + queue: .main) { _ in + viewModel.quitRoom() + sleep(3) + } + } + .onDisappear { + UIApplication.shared.isIdleTimerDisabled = false + NotificationCenter.default.removeObserver(self) + } + + ZStack { + if viewModel.isShowProfilePopup, let liveRoomInfo = viewModel.liveRoomInfo, let selectedProfile = viewModel.selectedProfile { + LiveRoomProfileDialog( + isShowing: $viewModel.isShowProfilePopup, + profileInfo: selectedProfile, + creatorId: liveRoomInfo.creatorId, + isSpeaker: viewModel.role == .SPEAKER, + onClickInviteSpeaker: { inviteSpeaker(peerId: $0) }, + onClickChangeListener: { + if $0 == UserDefaults.int(forKey: .userId) { + viewModel.setListener() + return + } + + viewModel.changeListener(peerId: $0) + } + ) + } + + if viewModel.isShowDonationPopup { + LiveRoomDonationDialogView(isShowing: $viewModel.isShowDonationPopup, isAudioContentDonation: false) { can, message in + viewModel.donation(can: can, message: message) + } + } + + if viewModel.isShowQuitPopup { + SodaDialog( + title: "라이브 나가기", + desc: "라이브에서 나가시겠습니까?", + confirmButtonTitle: "예", + confirmButtonAction: { + viewModel.isShowQuitPopup = false + viewModel.quitRoom() + }, + cancelButtonTitle: "아니오", + cancelButtonAction: { + viewModel.isShowQuitPopup = false + } + ) + } + + if viewModel.isShowLiveEndPopup { + SodaDialog( + title: "라이브 종료", + desc: "라이브를 종료하시겠습니까?\n" + + "라이브를 종료하면 대화내용은\n" + + "저장되지 않고 사라집니다.\n" + + "참여자들 또한 라이브가 종료되어\n" + + "강제퇴장 됩니다.", + confirmButtonTitle: "예", + confirmButtonAction: { + viewModel.isShowLiveEndPopup = false + viewModel.quitRoom() + }, + cancelButtonTitle: "아니오", + cancelButtonAction: { + viewModel.isShowLiveEndPopup = false + } + ) + } + + if viewModel.isShowPopup { + LiveRoomDialogView( + content: viewModel.popupContent, + cancelTitle: viewModel.popupCancelTitle, + cancelAction: viewModel.popupCancelAction, + confirmTitle: viewModel.popupConfirmTitle, + confirmAction: viewModel.popupConfirmAction + ).onAppear { + if viewModel.popupConfirmTitle == nil && viewModel.popupConfirmAction == nil { + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + viewModel.isShowPopup = false + viewModel.popupCancelTitle = nil + viewModel.popupCancelAction = nil + viewModel.popupConfirmTitle = nil + viewModel.popupConfirmAction = nil + } + } + } + } + } + + ZStack { + if viewModel.isShowProfileList, let liveRoomInfo = viewModel.liveRoomInfo { + LiveRoomProfilesDialogView( + isShowing: $viewModel.isShowProfileList, + viewModel: viewModel, + roomInfo: liveRoomInfo, + isShowRequestSpeaker: viewModel.role != .SPEAKER, + onClickRequestSpeaker: { + viewModel.requestSpeaker() + }, + registerNotification: { viewModel.registerNotification() }, + unRegisterNotification: { viewModel.unRegisterNotification() }, + onClickProfile: { + if $0 != UserDefaults.int(forKey: .userId) { + viewModel.getUserProfile(userId: $0) + } + } + ) + } + + if viewModel.isShowUserProfilePopup, let userProfile = viewModel.userProfile { + Color.black.opacity(0.7) + .edgesIgnoringSafeArea(.all) + + LiveRoomUserProfileDialogView( + isShowing: $viewModel.isShowUserProfilePopup, + viewModel: viewModel, + userProfile: userProfile, + onClickSetManager: { + viewModel.setManagerMessageToPeer(userId: $0) + viewModel.setManager(userId: $0) + }, + onClickReleaseManager: { viewModel.changeListener(peerId: $0, isFromManager: true) }, + onClickFollow: { viewModel.registerNotification(creatorId: $0, isGetUserProfile: true) }, + onClickUnFollow: { viewModel.unRegisterNotification(creatorId: $0, isGetUserProfile: true) }, + onClickInviteSpeaker: { inviteSpeaker(peerId: $0) }, + onClickChangeListener: { + viewModel.changeListener(peerId: $0) + }, + onClickMenu: { userId, userNickname, isBlocked in + viewModel.reportUserId = userId + viewModel.reportUserNickname = userNickname + viewModel.reportUserIsBlocked = isBlocked + viewModel.isShowReportMenu = true + } + ) + .padding(20) + .popup(isPresented: $viewModel.isShowReportPopup, type: .toast, position: .top, autohideIn: 1.3) { + GeometryReader { geo in + HStack { + Spacer() + Text(viewModel.reportMessage) + .padding(.vertical, 13.3) + .frame(width: geo.size.width - 66.7, alignment: .center) + .font(.custom(Font.medium.rawValue, size: 12)) + .background(Color(hex: "9970ff")) + .foregroundColor(Color.white) + .multilineTextAlignment(.center) + .cornerRadius(20) + .padding(.top, 66.7) + Spacer() + } + } + } + } + + if viewModel.isShowReportMenu { + VStack(spacing: 0) { + ProfileReportMenuView( + isShowing: $viewModel.isShowReportMenu, + isBlockedUser: viewModel.reportUserIsBlocked, + userBlockAction: { viewModel.isShowUesrBlockConfirm = true }, + userUnBlockAction: { viewModel.userUnBlock() }, + userReportAction: { viewModel.isShowUesrReportView = true }, + profileReportAction: { viewModel.isShowProfileReportConfirm = true } + ) + + Rectangle() + .foregroundColor(Color(hex: "222222")) + .frame(width: screenSize().width, height: 15.3) + } + .ignoresSafeArea() + } + + if viewModel.isShowUesrBlockConfirm { + UserBlockConfirmDialogView( + isShowing: $viewModel.isShowUesrBlockConfirm, + nickname: viewModel.reportUserNickname, + confirmAction: { viewModel.userBlock() } + ) + } + + if viewModel.isShowUesrReportView { + UserReportDialogView( + isShowing: $viewModel.isShowUesrReportView, + confirmAction: { reason in + viewModel.report(type: .USER, reason: reason) + } + ) + } + + if viewModel.isShowProfileReportConfirm { + ProfileReportDialogView( + isShowing: $viewModel.isShowProfileReportConfirm, + confirmAction: { + viewModel.report(type: .PROFILE) + } + ) + } + } + + if viewModel.isLoading && viewModel.liveRoomInfo == nil { + LoadingView() + } + } + .ignoresSafeArea(.keyboard) + .edgesIgnoringSafeArea(keyboardHandler.keyboardHeight > 0 ? .bottom : .init()) + .sheet( + isPresented: $viewModel.isShowShareView, + onDismiss: { viewModel.shareMessage = "" }, + content: { + ActivityViewController(activityItems: [viewModel.shareMessage]) + } + ) + .sheet(isPresented: $isShowPhotoPicker) { + ImagePicker( + isShowing: $isShowPhotoPicker, + selectedImage: $viewModel.coverImage, + sourceType: .photoLibrary + ) + } + .sheet(isPresented: $viewModel.isShowEditRoomInfoDialog) { + if let liveRoomInfo = viewModel.liveRoomInfo { + LiveRoomInfoEditDialog( + isShowing: $viewModel.isShowEditRoomInfoDialog, + isShowPhotoPicker: $isShowPhotoPicker, + viewModel: viewModel, + isLoading: viewModel.isLoading, + currentTitle: liveRoomInfo.title, + currentNotice: liveRoomInfo.notice, + coverImageUrl: liveRoomInfo.coverImageUrl, + coverImage: viewModel.coverImage + ) { newTitle, newNotice in + self.viewModel.editLiveRoomInfo( + title: newTitle, + notice: newNotice + ) + } + } else { + EmptyView() + .onAppear { + viewModel.isShowEditRoomInfoDialog = false + } + } + } + .sheet(isPresented: $viewModel.isShowDonationRankingPopup) { + LiveRoomDonationRankingDialog(isShowing: $viewModel.isShowDonationRankingPopup) + } + .sheet(isPresented: $viewModel.isShowDonationMessagePopup) { + LiveRoomDonationMessageDialog(isShowing: $viewModel.isShowDonationMessagePopup) + } + } + + func makeAttributedString(_ text: String) -> NSAttributedString { + let attributedString = NSMutableAttributedString(string: text) + + let urlRegex = try! NSRegularExpression(pattern: "\\b(https?://\\S+\\b|www\\.\\S+\\b)") + let matches = urlRegex.matches(in: text, options: [], range: NSRange(text.startIndex..., in: text)) + + for match in matches { + let url = (text as NSString).substring(with: match.range) + if let detectedURL = URL(string: url) { + attributedString.addAttribute(.link, value: detectedURL, range: match.range) + } + } + + return attributedString + } + + + private func inviteSpeaker(peerId: Int) { + if viewModel.liveRoomInfo!.speakerList.count <= 9 { + viewModel.inviteSpeaker(peerId: peerId) + self.viewModel.popupContent = "스피커 요청을 보냈습니다.\n잠시만 기다려 주세요." + self.viewModel.isShowPopup = true + } else { + viewModel.popupContent = "스피커 정원을 초과했습니다." + viewModel.isShowPopup = true + } + } + + private var scrollObservableView: some View { + GeometryReader { proxy in + let offsetY = proxy.frame(in: .global).origin.y + Color.clear + .preference( + key: ScrollOffsetKey.self, + value: offsetY + ) + .onAppear { + viewModel.setOriginOffset(offsetY) + } + } + .frame(height: 0) + } + + struct ScrollOffsetKey: PreferenceKey { + static var defaultValue: CGFloat = .zero + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value += nextValue() + } + } + + @ViewBuilder + func NotificationView(liveRoomInfo: GetRoomInfoResponse) -> some View { + HStack(spacing: 8) { + Image( + viewModel.isShowNotice ? + "ic_notice_selected" : + "ic_notice_normal" + ) + .onTapGesture { + viewModel.isShowNotice.toggle() + } + .padding(.trailing, 10) + + Spacer() + + HStack(spacing: 4.7) { + Image("ic_donation_status") + .resizable() + .frame(width: 16, height: 16) + + Text("\(viewModel.totalDonationCan)") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "bbbbbb")) + } + .padding(.horizontal, 11.5) + .padding(.vertical, 5.3) + .overlay( + RoundedRectangle(cornerRadius: 12.8) + .strokeBorder(lineWidth: 1) + .foregroundColor(Color(hex: "eeeeee")) + ) + .onTapGesture { + viewModel.isShowDonationRankingPopup = true + } + + HStack(spacing: 0) { + Text("참여자") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "bbbbbb")) + + Text("\(liveRoomInfo.participantsCount)") + .font(.custom(Font.medium.rawValue, size: 12)) + .foregroundColor(Color(hex: "9970ff")) + .padding(.leading, 6.7) + } + .padding(.horizontal, 11.5) + .padding(.vertical, 7.3) + .overlay( + RoundedRectangle(cornerRadius: 12.8) + .strokeBorder(lineWidth: 1) + .foregroundColor(Color(hex: "eeeeee")) + ) + .onTapGesture { + viewModel.isShowProfileList = true + } + } + .padding(.top, 13.3) + .padding(.horizontal, 13.3) + } + + @ViewBuilder + func NewChatView(scrollToBottom: @escaping () -> Void) -> some View { + HStack(spacing: 0) { + Spacer() + + HStack(spacing: 6.7) { + Image("ic_bottom_white") + Text("새로운 채팅") + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + } + .padding(.vertical, 8) + .padding(.horizontal, 13.3) + .background(Color(hex: "555555").opacity(0.8)) + .cornerRadius(16.7) + .padding(.bottom, 13.3) + .onTapGesture { scrollToBottom() } + + Spacer() + } + } + + @ViewBuilder + func InputChatView(scrollToBottom: @escaping () -> Void) -> some View { + HStack(spacing: 0) { + TextField("채팅을 입력하세요", text: $viewModel.chatMessage) + .autocapitalization(.none) + .disableAutocorrection(true) + .font(.custom(Font.medium.rawValue, size: 13.3)) + .foregroundColor(Color(hex: "eeeeee")) + .accentColor(Color(hex: "9970ff")) + .keyboardType(.default) + .padding(.horizontal, 13.3) + + Spacer() + + Image("btn_message_send") + .resizable() + .frame(width: 35, height: 35) + .padding(6.7) + .onTapGesture { + viewModel.sendMessage() + scrollToBottom() + } + } + .background(Color(hex: "232323")) + .cornerRadius(10) + .overlay( + RoundedRectangle(cornerRadius: 10) + .strokeBorder(lineWidth: 1) + .foregroundColor(Color(hex: "eeeeee")) + ) + .padding(13.3) + } + + @ViewBuilder + func ChatView() -> some View { + LazyVGrid(columns: chatColumns, alignment: .leading, spacing: 20) { + ForEach(0.. (56.7 * 2) { + isShowingNewChat = true + } + } + } +} + +struct LiveRoomView_Previews: PreviewProvider { + static var previews: some View { + LiveRoomView() + } +} + +struct AttributedTextView: UIViewRepresentable { + let attributedString: NSAttributedString + let lineLimit: Int + let onURLTapped: (URL) -> Void + + + func makeUIView(context: Context) -> UITextView { + let textView = UITextView() + textView.isEditable = false + textView.isSelectable = false + textView.isScrollEnabled = false + textView.backgroundColor = .clear + textView.font = UIFont(name: Font.light.rawValue, size: 11.3) + textView.textColor = .white + textView.textContainer.lineFragmentPadding = 0 + textView.textContainerInset = .zero + textView.textContainer.maximumNumberOfLines = lineLimit + textView.textContainer.lineBreakMode = lineLimit == 1 ? .byTruncatingTail : .byWordWrapping + textView.delegate = context.coordinator + + textView.isUserInteractionEnabled = true + + // Add tap gesture recognizer to handle URL tap events + let tapGestureRecognizer = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTapGesture(_:))) + textView.addGestureRecognizer(tapGestureRecognizer) + + + return textView + } + + func updateUIView(_ uiView: UITextView, context: Context) { + uiView.attributedText = attributedString + uiView.textColor = UIColor.white + uiView.textContainer.maximumNumberOfLines = lineLimit + uiView.textContainer.lineBreakMode = lineLimit == 1 ? .byTruncatingTail : .byWordWrapping + } + + func makeCoordinator() -> Coordinator { + Coordinator(onURLTapped: onURLTapped) + } + + class Coordinator: NSObject, UITextViewDelegate { + let onURLTapped: (URL) -> Void + let linkAttributeName = NSAttributedString.Key.link.rawValue + + init(onURLTapped: @escaping (URL) -> Void) { + self.onURLTapped = onURLTapped + } + + @objc func handleTapGesture(_ gesture: UITapGestureRecognizer) { + let textView = gesture.view as? UITextView + let location = gesture.location(in: textView) + + let layoutManager = textView?.layoutManager + let characterIndex = layoutManager?.characterIndex(for: location, in: textView!.textContainer, fractionOfDistanceBetweenInsertionPoints: nil) + + if characterIndex != NSNotFound { + let attributedString = textView?.attributedText + + attributedString?.enumerateAttribute(NSAttributedString.Key(rawValue: linkAttributeName), in: NSRange(location: 0, length: attributedString!.length), options: []) { value, range, _ in + if let url = value as? URL, NSLocationInRange(characterIndex!, range) { + onURLTapped(url) + } + } + } + } + } +} diff --git a/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift new file mode 100644 index 0000000..1b24d73 --- /dev/null +++ b/SodaLive/Sources/Live/Room/LiveRoomViewModel.swift @@ -0,0 +1,1420 @@ +// +// LiveRoomViewModel.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import Foundation +import Moya +import Combine + +import AgoraRtcKit +import AgoraRtmKit + +import FirebaseDynamicLinks + +final class LiveRoomViewModel: NSObject, ObservableObject { + + private var agora: Agora = Agora.shared + + private let repository = LiveRepository() + private let userRepository = UserRepository() + private let reportRepository = ReportRepository() + private var subscription = Set() + + @Published var chatMessage = "" + @Published var isSpeakerMute = false + @Published var isMute = false + @Published var role = LiveRoomMemberRole.LISTENER + + @Published var messageChangeFlag = false + @Published var messages = [LiveRoomChat]() + @Published var activeSpeakers = [UInt]() + @Published var muteSpeakers = [UInt]() + @Published var liveRoomInfo: GetRoomInfoResponse? + @Published var userProfile: GetLiveRoomUserProfileResponse? + + @Published var isLoading = false + @Published var errorMessage = "" + @Published var reportMessage = "" + @Published var isShowReportPopup = false + @Published var isShowErrorPopup = false + @Published var isShowUserProfilePopup = false + + @Published var popupContent = "" + @Published var popupCancelTitle: String? = nil + @Published var popupCancelAction: (() -> Void)? = nil + @Published var popupConfirmTitle: String? = nil + @Published var popupConfirmAction: (() -> Void)? = nil + @Published var isShowPopup = false { + didSet { + if !isShowPopup { + resetPopupContent() + } + } + } + + @Published var isShowProfileList = false + @Published var isShowProfilePopup = false { + didSet { + if !isShowProfilePopup { + selectedProfile = nil + } + } + } + @Published var selectedProfile: LiveRoomMember? + + @Published var isShowNotice = true { + didSet { + if !isShowNotice { + isExpandNotice = false + } + } + } + + @Published var isExpandNotice = false + + @Published var isShowDonationPopup = false + + @Published var isShowDonationMessagePopup = false + + @Published var isShowDonationRankingPopup = false + + @Published var isSpeakerFold = false + + @Published var isShowQuitPopup = false + + @Published var isShowLiveEndPopup = false + + @Published var isShowEditRoomInfoDialog = false + + @Published var isShowShareView = false + + @Published var shareMessage = "" + + @Published var isShowKickOutPopup = false + @Published var kickOutDesc = "" + @Published var kickOutId = 0 { + didSet { + kickOutDesc = "\(getUserNicknameAndProfileUrl(accountId: kickOutId).nickname)님을 내보내시겠어요?" + } + } + + @Published var totalDonationCan = 0 + @Published var donationMessageList = [LiveRoomDonationMessage]() + @Published var donationMessageCount = 0 + + @Published var isBgOn = true + @Published var donationStatus: GetLiveRoomDonationStatusResponse? + + @Published private(set) var offset: CGFloat = 0 + @Published private(set) var originOffset: CGFloat = 0 + private var isCheckedOriginOffset: Bool = false + + @Published var coverImage: UIImage? = nil + + @Published var isShowReportMenu = false + @Published var isShowUesrBlockConfirm = false + @Published var isShowUesrReportView = false + @Published var isShowProfileReportConfirm = false + + @Published var reportUserId = 0 + @Published var reportUserNickname = "" + @Published var reportUserIsBlocked = false + + func setOriginOffset(_ offset: CGFloat) { + guard !isCheckedOriginOffset else { return } + self.originOffset = offset + self.offset = offset + isCheckedOriginOffset = true + } + + func setOffset(_ offset: CGFloat) { + guard isCheckedOriginOffset else { return } + self.offset = offset + } + + func initAgoraEngine() { + agora.rtcEngineDelegate = self + agora.rtmDelegate = self + agora.initialize() + } + + private func deInitAgoraEngine() { + agora.deInit() + } + + func agoraConnectSuccess(isManager: Bool) { + if isManager { + role = .SPEAKER + } else { + role = .LISTENER + } + + DEBUG_LOG("agoraConnectSuccess") + } + + func agoraConnectFail() { + DEBUG_LOG("agoraConnectFail") + AppState.shared.roomId = 0 + AppState.shared.isShowPlayer = false + } + + func quitRoom() { + let roomId = liveRoomInfo?.roomId + isLoading = true + + if let index = muteSpeakers.firstIndex(of: UInt(UserDefaults.int(forKey: .userId))) { + muteSpeakers.remove(at: index) + } + + repository.quitRoom(roomId: roomId!) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.deInitAgoraEngine() + self.liveRoomInfo = nil + AppState.shared.roomId = 0 + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowErrorPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowErrorPopup = true + } + + self.isLoading = false + } + .store(in: &subscription) + } + + func getRoomInfo(userId: Int = 0, onSuccess: @escaping (String) -> Void = { _ in }) { + isLoading = true + + repository.getRoomInfo(roomId: AppState.shared.roomId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.liveRoomInfo = data + self.agora.joinChannel( + roomInfo: data, + rtmChannelDelegate: self, + onConnectSuccess: self.agoraConnectSuccess, + onConnectFail: self.agoraConnectFail + ) + + getTotalDonationCan() + + if (userId > 0) { + let nickname = getUserNicknameAndProfileUrl(accountId: userId).nickname + onSuccess(nickname) + } + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowErrorPopup = true + } + + self.isLoading = false + } catch { + self.isLoading = false + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowErrorPopup = true + } + } + .store(in: &subscription) + } + + func toggleMute() { + isMute.toggle() + agora.mute(isMute) + + if isMute { + muteSpeakers.append(UInt(UserDefaults.int(forKey: .userId))) + } else { + if let index = muteSpeakers.firstIndex(of: UInt(UserDefaults.int(forKey: .userId))) { + muteSpeakers.remove(at: index) + } + } + } + + func toggleSpeakerMute() { + isSpeakerMute.toggle() + agora.speakerMute(isSpeakerMute) + } + + func sendMessage() { + DispatchQueue.main.async {[unowned self] in + if chatMessage.count > 0 { + agora.sendMessageToGroup(textMessage: chatMessage, completion: { [unowned self] errorCode in + if errorCode == .errorOk { + let (nickname, profileUrl) = self.getUserNicknameAndProfileUrl(accountId: UserDefaults.int(forKey: .userId)) + let rank = getUserRank(userId: UserDefaults.int(forKey: .userId)) + self.messages.append(LiveRoomNormalChat(userId: UserDefaults.int(forKey: .userId), profileUrl: profileUrl, nickname: nickname, rank: rank, chat: chatMessage)) + + self.messageChangeFlag.toggle() + if self.messages.count > 100 { + self.messages.remove(at: 0) + } + } + + self.chatMessage = "" + }) + } + } + } + + func donation(can: Int, message: String = "") { + if can > 0 { + isLoading = true + + repository.donation(roomId: AppState.shared.roomId, can: can, message: message) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + self.isLoading = false + + if decoded.success { + let rawMessage = "\(can)코인을 후원하셨습니다." + let donationRawMessage = LiveRoomChatRawMessage( + type: .DONATION, + message: rawMessage, + can: can, + donationMessage: message + ) + + UserDefaults.set(UserDefaults.int(forKey: .can) - can, forKey: .can) + + agora.sendRawMessageToGroup( + rawMessage: donationRawMessage, + completion: { [unowned self] errorCode in + if errorCode == .errorOk { + let (nickname, profileUrl) = self.getUserNicknameAndProfileUrl(accountId: UserDefaults.int(forKey: .userId)) + self.messages.append( + LiveRoomDonationChat( + profileUrl: profileUrl, + nickname: nickname, + chat: rawMessage, + can: can, + donationMessage: message + ) + ) + + totalDonationCan += can + + self.messageChangeFlag.toggle() + if self.messages.count > 100 { + self.messages.remove(at: 0) + } + } else { + refundDonation() + } + }, + fail: { [unowned self] in + refundDonation() + } + ) + } else { + if let message = decoded.message { + self.popupContent = message + } else { + self.popupContent = "후원에 실패했습니다.\n다시 후원해주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.isLoading = false + self.popupContent = "후원에 실패했습니다.\n다시 후원해주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } else { + popupContent = "1코인 이상 후원하실 수 있습니다." + isShowPopup = true + } + } + + private func refundDonation() { + isLoading = true + + repository.refundDonation(roomId: AppState.shared.roomId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + self.isLoading = false + + if decoded.success { + self.popupContent = "후원에 실패했습니다.\n다시 후원해주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.popupContent = "후원에 실패한 코인이 환불되지 않았습니다\n고객센터로 문의해주세요." + } + + self.isShowPopup = true + } + } catch { + self.isLoading = false + self.popupContent = "후원에 실패한 코인이 환불되지 않았습니다\n고객센터로 문의해주세요." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func requestSpeaker() { + agora.sendMessageToPeer(peerId: String(liveRoomInfo!.creatorId), rawMessage: LiveRoomRequestType.REQUEST_SPEAKER.rawValue.data(using: .utf8)!, completion: { [unowned self] errorCode in + if errorCode == .ok { + self.popupContent = "스피커 요청을 보냈습니다.\n잠시만 기다려 주세요." + self.isShowPopup = true + } + }) + } + + func inviteSpeaker(peerId: Int) { + agora.sendMessageToPeer(peerId: String(peerId), rawMessage: LiveRoomRequestType.INVITE_SPEAKER.rawValue.data(using: .utf8)!, completion: { [unowned self] errorCode in + if errorCode == .ok { + self.popupContent = "스피커 요청을 보냈습니다.\n잠시만 기다려 주세요." + self.isShowPopup = true + } + }) + } + + func changeListener(peerId: Int, isFromManager: Bool = false) { + agora.sendMessageToPeer(peerId: String(peerId), rawMessage: LiveRoomRequestType.CHANGE_LISTENER.rawValue.data(using: .utf8)!, completion: { [unowned self] errorCode in + if errorCode == .ok { + if isFromManager { + getRoomInfo() + setManagerMessage() + releaseManagerMessageToPeer(userId: peerId) + self.popupContent = "\(getUserNicknameAndProfileUrl(accountId: peerId).nickname)님을 스탭에서 해제했어요." + } else { + self.popupContent = "\(getUserNicknameAndProfileUrl(accountId: peerId).nickname)님을 리스너로 변경했어요." + } + + self.isShowPopup = true + } + }) + } + + private func getUserNicknameAndProfileUrl(accountId: Int) -> (nickname: String, profileUrl: String) { + for staff in liveRoomInfo!.managerList { + if staff.id == accountId { + return (staff.nickname, staff.profileImage) + } + } + + for speaker in liveRoomInfo!.speakerList { + if speaker.id == accountId { + return (speaker.nickname, speaker.profileImage) + } + } + + for listener in liveRoomInfo!.listenerList { + if listener.id == accountId { + return (listener.nickname, listener.profileImage) + } + } + + return ("", "") + } + + func isEqualToStaffId(creatorId: Int) -> Bool { + for staff in liveRoomInfo!.managerList { + if staff.id == creatorId { + return true + } + } + + return false + } + + func setListener() { + repository.setListener(roomId: AppState.shared.roomId, userId: UserDefaults.int(forKey: .userId)) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.role = .LISTENER + self.agora.setRole(role: .audience) + self.isMute = false + self.agora.mute(isMute) + if let index = self.muteSpeakers.firstIndex(of: UInt(UserDefaults.int(forKey: .userId))) { + self.muteSpeakers.remove(at: index) + } + self.getRoomInfo() + } + } catch { + } + } + .store(in: &subscription) + } + + private func setSpeaker() { + repository.setSpeaker(roomId: AppState.shared.roomId, userId: UserDefaults.int(forKey: .userId)) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.role = .SPEAKER + self.agora.setRole(role: .broadcaster) + self.popupContent = "스피커가 되었어요!" + self.isShowPopup = true + self.isMute = false + self.getRoomInfo() + } + } catch { + } + } + .store(in: &subscription) + } + + private func resetPopupContent() { + errorMessage = "" + popupContent = "" + popupCancelTitle = nil + popupCancelAction = nil + popupConfirmTitle = nil + popupConfirmAction = nil + } + + private func requestSpeakerAllow(_ peerId: String) { + agora.sendMessageToPeer(peerId: peerId, rawMessage: LiveRoomRequestType.REQUEST_SPEAKER_ALLOW.rawValue.data(using: .utf8)!, completion: nil) + } + + func editLiveRoomInfo(title: String, notice: String) { + let request = EditLiveRoomInfoRequest( + title: liveRoomInfo!.title != title ? title : nil, + notice: liveRoomInfo!.notice != notice ? notice : nil, + numberOfPeople: nil, + beginDateTimeString: nil, + timezone: nil + ) + + if (request.title == nil && request.notice == nil && coverImage == nil) { + self.errorMessage = "변경사항이 없습니다." + self.isShowErrorPopup = true + return + } + + var multipartData = [MultipartFormData]() + + let encoder = JSONEncoder() + encoder.outputFormatting = .withoutEscapingSlashes + + if (request.title != nil || request.notice != nil) { + let jsonData = try? encoder.encode(request) + if let jsonData = jsonData { + multipartData.append(MultipartFormData(provider: .data(jsonData), name: "request")) + } + } + + if let coverImage = coverImage, let imageData = coverImage.jpegData(compressionQuality: 0.8) { + multipartData.append( + MultipartFormData( + provider: .data(imageData), + name: "coverImage", + fileName: "\(UUID().uuidString)_\(Date().timeIntervalSince1970 * 1000).jpg", + mimeType: "image/*") + ) + } + + + + repository.editLiveRoomInfo(roomId: AppState.shared.roomId, parameters: multipartData) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.coverImage = nil + self.getRoomInfo() + + let editRoomInfoMessage = LiveRoomChatRawMessage( + type: .EDIT_ROOM_INFO, + message: "", + can: 0, + donationMessage: "" + ) + + self.agora.sendRawMessageToGroup(rawMessage: editRoomInfoMessage) + } else { + self.errorMessage = decoded.message ?? "라이브 정보를 수정하지 못했습니다.\n다시 시도해 주세요." + self.isShowPopup = true + } + } catch { + self.errorMessage = "라이브 정보를 수정하지 못했습니다.\n다시 시도해 주세요." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func shareRoom() { + guard let link = URL(string: "https://sodalive.net/?room_id=\(AppState.shared.roomId)") else { return } + let dynamicLinksDomainURIPrefix = "https://sodalive.page.link" + guard let linkBuilder = DynamicLinkComponents(link: link, domainURIPrefix: dynamicLinksDomainURIPrefix) else { + self.errorMessage = "공유링크를 생성하지 못했습니다.\n다시 시도해 주세요." + self.isShowErrorPopup = true + return + } + + linkBuilder.iOSParameters = DynamicLinkIOSParameters(bundleID: "kr.co.vividnext.sodalive") + linkBuilder.iOSParameters?.appStoreID = "1630284226" + + linkBuilder.androidParameters = DynamicLinkAndroidParameters(packageName: "kr.co.vividnext.sodalive") + + guard let longDynamicLink = linkBuilder.url else { + self.errorMessage = "공유링크를 생성하지 못했습니다.\n다시 시도해 주세요." + self.isShowErrorPopup = true + return + } + DEBUG_LOG("The long URL is: \(longDynamicLink)") + + DynamicLinkComponents.shortenURL(longDynamicLink, options: nil) { [unowned self] url, warnings, error in + let shortUrl = url?.absoluteString + + if let liveRoomInfo = self.liveRoomInfo { + let urlString = shortUrl != nil ? shortUrl! : longDynamicLink.absoluteString + if liveRoomInfo.isPrivateRoom { + shareMessage = "\(UserDefaults.string(forKey: .nickname))님이 귀하를 소다라이브 비공개라이브에 초대하였습니다.\n" + + "※ 라이브 참여: \(urlString)\n" + + "(입장 비밀번호: \(liveRoomInfo.password!))" + } else { + shareMessage = "\(UserDefaults.string(forKey: .nickname))님이 귀하를 소다라이브 공개라이브에 초대하였습니다.\n" + + "※ 라이브 참여: \(urlString)" + } + + isShowShareView = true + } else { + self.errorMessage = "공유링크를 생성하지 못했습니다.\n다시 시도해 주세요." + self.isShowErrorPopup = true + return + } + } + } + + func kickOut() { + repository.kickOut(roomId: AppState.shared.roomId, userId: kickOutId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { _ in + + } + .store(in: &subscription) + + let nickname = getUserNicknameAndProfileUrl(accountId: kickOutId).nickname + agora.sendMessageToPeer(peerId: String(kickOutId), rawMessage: LiveRoomRequestType.KICK_OUT.rawValue.data(using: .utf8)!, completion: { [unowned self] errorCode in + if errorCode == .ok { + self.popupContent = "\(nickname)님을 내보냈습니다." + self.isShowPopup = true + } + }) + + if let index = muteSpeakers.firstIndex(of: UInt(kickOutId)) { + muteSpeakers.remove(at: index) + } + + isShowKickOutPopup = false + kickOutDesc = "" + kickOutId = 0 + } + + func getDonationStatus() { + isLoading = true + repository.donationStatus(roomId: AppState.shared.roomId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.donationStatus = data + } else { + self.errorMessage = "후원현황을 가져오지 못했습니다\n다시 시도해 주세요." + self.isShowPopup = true + } + } catch { + self.isLoading = false + self.errorMessage = "후원현황을 가져오지 못했습니다\n다시 시도해 주세요." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func registerNotification(creatorId: Int? = nil, isGetUserProfile: Bool = false) { + var userId = 0 + + if let creatorId = creatorId { + userId = creatorId + } else if let liveRoomInfo = liveRoomInfo { + userId = liveRoomInfo.creatorId + } + + if userId > 0 { + isLoading = true + + userRepository.creatorFollow(creatorId: userId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.getRoomInfo() + + if isGetUserProfile { + getUserProfile(userId: userId) + } + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + } + + func unRegisterNotification(creatorId: Int? = nil, isGetUserProfile: Bool = false) { + var userId = 0 + + if let creatorId = creatorId { + userId = creatorId + } else if let liveRoomInfo = liveRoomInfo { + userId = liveRoomInfo.creatorId + } + + if userId > 0 { + isLoading = true + + userRepository.creatorUnFollow(creatorId: userId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.getRoomInfo() + + if isGetUserProfile { + getUserProfile(userId: userId) + } + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + } + + func getUserRank(userId: Int) -> Int { + // 방장 -> -2 + // 스탭 -> -3 + // 나머지 -> 체크 + if userId == liveRoomInfo!.creatorId { + return -2 + } else if isEqualToStaffId(creatorId: userId) { + return -3 + } else { + return liveRoomInfo!.donationRankingTop3UserIds.firstIndex(of: userId) ?? -1 + } + } + + func getTotalDonationCan() { + repository.getTotalDoantionCan(roomId: AppState.shared.roomId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.totalDonationCan = data.totalDonationCan + } + } catch { + } + } + .store(in: &subscription) + } + + func getMemberCan() { + userRepository.getMemberCan() + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { response in + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + UserDefaults.set(data.can, forKey: .can) + } + } catch { + } + } + .store(in: &subscription) + } + + func getDonationMessageList() { + isLoading = true + repository.getDonationMessageList(roomId: AppState.shared.roomId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse<[LiveRoomDonationMessage]>.self, from: responseData) + + if let data = decoded.data, decoded.success { + self.donationMessageList.removeAll() + self.donationMessageList.append(contentsOf: data) + self.donationMessageCount = data.count + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func deleteDonationMessage(uuid: String) { + isLoading = true + repository.deleteDonationMessage(roomId: AppState.shared.roomId, messageUUID: uuid) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.donationMessageCount -= 1 + let filteredDonationMessageList = self.donationMessageList.filter { $0.uuid != uuid } + self.donationMessageList.removeAll() + self.donationMessageList.append(contentsOf: filteredDonationMessageList) + } else { + self.errorMessage = "메시지를 삭제하지 못했습니다.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } catch { + self.errorMessage = "메시지를 삭제하지 못했습니다.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func getUserProfile(userId: Int) { + isLoading = true + repository.getUserProfile(roomId: AppState.shared.roomId, userId: userId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponse.self, from: responseData) + + if let data = decoded.data, decoded.success { + userProfile = data + isShowUserProfilePopup = true + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func setManager(userId: Int) { + isLoading = true + repository.setManager(roomId: AppState.shared.roomId, userId: userId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + getRoomInfo() + setManagerMessage() + + self.popupContent = "\(getUserNicknameAndProfileUrl(accountId: userId).nickname)님을 스탭으로 지정했습니다." + self.isShowPopup = true + } else { + if let message = decoded.message { + self.errorMessage = message + } else { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowPopup = true + } + } catch { + self.errorMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowPopup = true + } + } + .store(in: &subscription) + } + + func releaseManagerMessageToPeer(userId: Int) { + agora.sendMessageToPeer( + peerId: String(userId), + rawMessage: LiveRoomRequestType.RELEASE_MANAGER.rawValue.data(using: .utf8)!, + completion: nil + ) + } + + func setManagerMessageToPeer(userId: Int) { + agora.sendMessageToPeer( + peerId: String(userId), + rawMessage: LiveRoomRequestType.SET_MANAGER.rawValue.data(using: .utf8)!, + completion: nil + ) + } + + private func setManagerMessage() { + let setManagerMessage = LiveRoomChatRawMessage( + type: .SET_MANAGER, + message: "", + can: 0, + donationMessage: "" + ) + + self.agora.sendRawMessageToGroup(rawMessage: setManagerMessage) + } + + func userBlock() { + isLoading = true + userRepository.memberBlock(userId: reportUserId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.reportMessage = "차단하였습니다." + self.getUserProfile(userId: reportUserId) + self.reportUserId = 0 + self.reportUserNickname = "" + self.reportUserIsBlocked = false + } else { + if let message = decoded.message { + self.reportMessage = message + } else { + self.reportMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + } + + self.isShowReportPopup = true + } catch { + self.reportMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowReportPopup = true + } + } + .store(in: &subscription) + } + + func userUnBlock() { + isLoading = true + userRepository.memberUnBlock(userId: reportUserId) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if decoded.success { + self.reportMessage = "차단이 해제 되었습니다." + self.getUserProfile(userId: reportUserId) + self.reportUserId = 0 + self.reportUserNickname = "" + self.reportUserIsBlocked = false + } else { + if let message = decoded.message { + self.reportMessage = message + } else { + self.reportMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + } + + self.isShowReportPopup = true + } catch { + self.reportMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowReportPopup = true + } + } + .store(in: &subscription) + } + + func report(type: ReportType, reason: String = "프로필 신고") { + isLoading = true + + let request = ReportRequest(type: type, reason: reason, reportedMemberId: reportUserId, cheersId: nil, audioContentId: nil) + reportRepository.report(request: request) + .sink { result in + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + } + } receiveValue: { [unowned self] response in + self.isLoading = false + let responseData = response.data + + self.reportUserId = 0 + self.reportUserNickname = "" + self.reportUserIsBlocked = false + + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(ApiResponseWithoutData.self, from: responseData) + + if let message = decoded.message { + self.reportMessage = message + } else { + self.reportMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + } + + self.isShowReportPopup = true + } catch { + self.reportMessage = "다시 시도해 주세요.\n계속 같은 문제가 발생할 경우 고객센터로 문의 주시기 바랍니다." + self.isShowReportPopup = true + } + } + .store(in: &subscription) + } +} + +extension LiveRoomViewModel: AgoraRtcEngineDelegate { + func rtcEngine(_ engine: AgoraRtcEngineKit, reportAudioVolumeIndicationOfSpeakers speakers: [AgoraRtcAudioVolumeInfo], totalVolume: Int) { + let activeSpeakerIds = speakers + .filter { $0.volume > 0 } + .map { $0.uid } + + DEBUG_LOG("activeSpeakerIds::: \(activeSpeakerIds)") + activeSpeakers.removeAll() + activeSpeakers.append(contentsOf: activeSpeakerIds) + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didAudioMuted muted: Bool, byUid uid: UInt) { + if muted && !muteSpeakers.contains(uid){ + muteSpeakers.append(uid) + } else { + if let index = muteSpeakers.firstIndex(of: uid) { + muteSpeakers.remove(at: index) + } + } + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didJoinedOfUid uid: UInt, elapsed: Int) { + getRoomInfo() + } + + func rtcEngine(_ engine: AgoraRtcEngineKit, didOfflineOfUid uid: UInt, reason: AgoraUserOfflineReason) { + DispatchQueue.main.async {[unowned self] in + if uid == UInt(self.liveRoomInfo!.creatorId) { + // 라이브 종료 + self.liveRoomInfo = nil + self.errorMessage = "라이브가 종료되었습니다." + self.isShowErrorPopup = true + } else { + // get room info + self.getRoomInfo() + } + } + } +} + +extension LiveRoomViewModel: AgoraRtmDelegate { + func rtmKit(_ kit: AgoraRtmKit, messageReceived message: AgoraRtmMessage, fromPeer peerId: String) { + if message.type == .raw, let rawMessage = message as? AgoraRtmRawMessage { + let rawMessageString = String(data: rawMessage.rawData, encoding: .utf8) + + DispatchQueue.main.async { [unowned self] in + if rawMessageString == LiveRoomRequestType.CHANGE_LISTENER.rawValue { + self.setListener() + return + } + + if rawMessageString == LiveRoomRequestType.REQUEST_SPEAKER.rawValue { + self.popupContent = "\(getUserNicknameAndProfileUrl(accountId: Int(peerId)!).nickname)님이 스피커 요청을 했어요!\n스퍼커로 초대할까요?" + self.popupCancelTitle = "건너뛰기" + self.popupCancelAction = { + self.isShowPopup = false + } + self.popupConfirmTitle = "스피커로 초대" + self.popupConfirmAction = { + self.isShowPopup = false + if self.liveRoomInfo!.speakerList.count <= 9 { + self.requestSpeakerAllow(peerId) + } else { + self.errorMessage = "스피커 정원이 초과되었습니다." + self.isShowErrorPopup = true + } + } + self.isShowPopup = true + return + } + + if rawMessageString == LiveRoomRequestType.INVITE_SPEAKER.rawValue && self.role == .LISTENER { + self.popupContent = "스피커로 초대되었어요" + self.popupCancelTitle = "다음에요" + self.popupCancelAction = { + self.isShowPopup = false + } + self.popupConfirmTitle = "스피커로 참여하기" + self.popupConfirmAction = { + self.isShowPopup = false + self.setSpeaker() + } + self.isShowPopup = true + return + } + + if rawMessageString == LiveRoomRequestType.REQUEST_SPEAKER_ALLOW.rawValue && self.role == .LISTENER { + self.setSpeaker() + return + } + + if rawMessageString == LiveRoomRequestType.KICK_OUT.rawValue { + if let roomInfo = self.liveRoomInfo { + self.popupContent = "\(self.getUserNicknameAndProfileUrl(accountId: roomInfo.creatorId).nickname)님이 라이브에서 내보냈습니다." + } else { + self.popupContent = "방장님이 라이브에서 내보냈습니다." + } + self.isShowPopup = true + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [unowned self] in + self.quitRoom() + } + return + } + + if rawMessageString == LiveRoomRequestType.SET_MANAGER.rawValue { + if self.role == .SPEAKER { + self.role = .LISTENER + self.isMute = false + self.agora.mute(isMute) + self.agora.setRole(role: .audience) + } + + if let roomInfo = self.liveRoomInfo { + self.popupContent = "\(self.getUserNicknameAndProfileUrl(accountId: roomInfo.creatorId).nickname)님이 스탭으로 지정했습니다." + } else { + self.popupContent = "방장님이 스탭으로 지정했습니다" + } + self.isShowPopup = true + } + + if rawMessageString == LiveRoomRequestType.RELEASE_MANAGER.rawValue { + if let roomInfo = self.liveRoomInfo { + self.popupContent = "\(self.getUserNicknameAndProfileUrl(accountId: roomInfo.creatorId).nickname)님이 스탭에서 해제했습니다." + } else { + self.popupContent = "방장님이 스탭에서 해제했습니다." + } + self.isShowPopup = true + } + } + } + } +} + +extension LiveRoomViewModel: AgoraRtmChannelDelegate { + func channel(_ channel: AgoraRtmChannel, messageReceived message: AgoraRtmMessage, from member: AgoraRtmMember) { + let (nickname, profileUrl) = getUserNicknameAndProfileUrl(accountId: Int(member.userId)!) + + if message.type == .raw, let rawMessage = message as? AgoraRtmRawMessage { + do { + let jsonDecoder = JSONDecoder() + let decoded = try jsonDecoder.decode(LiveRoomChatRawMessage.self, from: rawMessage.rawData) + + if decoded.type == .DONATION { + self.messages.append( + LiveRoomDonationChat( + profileUrl: profileUrl, + nickname: nickname, + chat: decoded.message, + can: decoded.can, + donationMessage: decoded.donationMessage ?? "" + ) + ) + + self.totalDonationCan += decoded.can + } else if decoded.type == .EDIT_ROOM_INFO || decoded.type == .SET_MANAGER { + self.getRoomInfo() + } + } catch { + } + } else { + let chat = message.text + let rank = getUserRank(userId: Int(member.userId) ?? 0) + + if !chat.trimmingCharacters(in: .whitespaces).isEmpty { + messages.append(LiveRoomNormalChat(userId: Int(member.userId)!, profileUrl: profileUrl, nickname: nickname, rank: rank, chat: chat)) + } + } + + DispatchQueue.main.async { [unowned self] in + self.messageChangeFlag.toggle() + if self.messages.count > 100 { + self.messages.remove(at: 0) + } + } + } + + func channel(_ channel: AgoraRtmChannel, memberJoined member: AgoraRtmMember) { + getRoomInfo(userId: Int(member.userId)!) { [unowned self] nickname in + if !nickname.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + DispatchQueue.main.async { [unowned self] in + self.messages.append(LiveRoomJoinChat(nickname: nickname)) + self.messageChangeFlag.toggle() + if self.messages.count > 100 { + self.messages.remove(at: 0) + } + } + } + } + } + + func channel(_ channel: AgoraRtmChannel, memberLeft member: AgoraRtmMember) { + if let liveRoomInfo = liveRoomInfo, liveRoomInfo.creatorId != Int(member.userId)! { + getRoomInfo() + } + } +} diff --git a/SodaLive/Sources/Live/Room/SetManagerOrSpeakerOrAudienceRequest.swift b/SodaLive/Sources/Live/Room/SetManagerOrSpeakerOrAudienceRequest.swift new file mode 100644 index 0000000..d0e11db --- /dev/null +++ b/SodaLive/Sources/Live/Room/SetManagerOrSpeakerOrAudienceRequest.swift @@ -0,0 +1,13 @@ +// +// SetManagerOrSpeakerOrAudienceRequest.swift +// SodaLive +// +// Created by klaus on 2023/08/14. +// + +import Foundation + +struct SetManagerOrSpeakerOrAudienceRequest: Encodable { + let roomId: Int + let memberId: Int +} diff --git a/SodaLive/Sources/Main/Home/HomeView.swift b/SodaLive/Sources/Main/Home/HomeView.swift index 1ee19bb..42ec89d 100644 --- a/SodaLive/Sources/Main/Home/HomeView.swift +++ b/SodaLive/Sources/Main/Home/HomeView.swift @@ -14,6 +14,7 @@ struct HomeView: View { @StateObject var viewModel = HomeViewModel() @StateObject var appState = AppState.shared + @StateObject var contentPlayManager = ContentPlayManager.shared private let liveView = LiveView() private let explorer = ExplorerView() @@ -49,6 +50,57 @@ struct HomeView: View { Spacer() + if contentPlayManager.isShowingMiniPlayer { + HStack(spacing: 0) { + KFImage(URL(string: contentPlayManager.coverImage)) + .resizable() + .frame(width: 36.7, height: 36.7) + .cornerRadius(5.3) + + VStack(alignment: .leading, spacing: 2.3) { + Text(contentPlayManager.title) + .font(.custom(Font.medium.rawValue, size: 13)) + .foregroundColor(Color(hex: "eeeeee")) + .lineLimit(2) + + Text(contentPlayManager.nickname) + .font(.custom(Font.medium.rawValue, size: 11)) + .foregroundColor(Color(hex: "d2d2d2")) + } + .padding(.horizontal, 10.7) + + Spacer() + + Image(contentPlayManager.isPlaying ? "ic_noti_pause" : "btn_bar_play") + .resizable() + .frame(width: 25, height: 25) + .onTapGesture { + if contentPlayManager.isPlaying { + contentPlayManager.pauseAudio() + } else { + contentPlayManager + .playAudio(contentId: contentPlayManager.contentId) + } + } + + Image("ic_noti_stop") + .resizable() + .frame(width: 25, height: 25) + .padding(.leading, 16) + .onTapGesture { contentPlayManager.stopAudio() } + } + .padding(.vertical, 10.7) + .padding(.horizontal, 13.3) + .background(Color(hex: "222222")) + .contentShape(Rectangle()) + .onTapGesture { + appState + .setAppStep( + step: .contentDetail(contentId: contentPlayManager.contentId) + ) + } + } + BottomTabView(width: proxy.size.width, currentTab: $viewModel.currentTab) if proxy.safeAreaInsets.bottom > 0 { @@ -63,6 +115,10 @@ struct HomeView: View { viewModel.getEventPopup() } + if appState.isShowPlayer { + LiveRoomView() + } + if appState.isShowNotificationSettingsDialog { NotificationSettingsDialog() } diff --git a/SodaLive/Sources/User/UserRepository.swift b/SodaLive/Sources/User/UserRepository.swift index ef21b22..7c2d8b0 100644 --- a/SodaLive/Sources/User/UserRepository.swift +++ b/SodaLive/Sources/User/UserRepository.swift @@ -37,6 +37,10 @@ final class UserRepository { return api.requestPublisher(.getMemberInfo) } + func getMemberCan() -> AnyPublisher { + return api.requestPublisher(.getMemberInfo) + } + func updateNotificationSettings(live: Bool? = nil, uploadContent: Bool? = nil, message: Bool? = nil) -> AnyPublisher { return api.requestPublisher( .notification(