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 0000000..1e1a712 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/btn_bar_play.imageset/btn_bar_play.png differ 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 0000000..d1b72cc Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/btn_bar_stop.imageset/btn_bar_stop.png differ 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 0000000..df22156 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/btn_follow.imageset/btn_follow.png differ diff --git a/SodaLive/Resources/Assets.xcassets/btn_following.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/btn_following.imageset/Contents.json new file mode 100644 index 0000000..382ea96 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/btn_following.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "btn_following.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/btn_following.imageset/btn_following.png b/SodaLive/Resources/Assets.xcassets/btn_following.imageset/btn_following.png new file mode 100644 index 0000000..e9507dd Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/btn_following.imageset/btn_following.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_badge_manager.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_badge_manager.imageset/Contents.json new file mode 100644 index 0000000..3ef92ff --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_badge_manager.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_badge_manager.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_badge_manager.imageset/ic_badge_manager.png b/SodaLive/Resources/Assets.xcassets/ic_badge_manager.imageset/ic_badge_manager.png new file mode 100644 index 0000000..0ecc7b3 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_badge_manager.imageset/ic_badge_manager.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_bottom_white.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_bottom_white.imageset/Contents.json new file mode 100644 index 0000000..d0d6032 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_bottom_white.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_bottom_white.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_bottom_white.imageset/ic_bottom_white.png b/SodaLive/Resources/Assets.xcassets/ic_bottom_white.imageset/ic_bottom_white.png new file mode 100644 index 0000000..da528d9 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_bottom_white.imageset/ic_bottom_white.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_donation.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_donation.imageset/Contents.json new file mode 100644 index 0000000..6bceb70 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_donation.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_donation.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_donation.imageset/ic_donation.png b/SodaLive/Resources/Assets.xcassets/ic_donation.imageset/ic_donation.png new file mode 100644 index 0000000..3732210 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_donation.imageset/ic_donation.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_donation_message_list.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_donation_message_list.imageset/Contents.json new file mode 100644 index 0000000..fb29808 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_donation_message_list.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_donation_message_list.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_donation_message_list.imageset/ic_donation_message_list.png b/SodaLive/Resources/Assets.xcassets/ic_donation_message_list.imageset/ic_donation_message_list.png new file mode 100644 index 0000000..f7e7728 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_donation_message_list.imageset/ic_donation_message_list.png differ 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 0000000..1e1dab5 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_donation_status.imageset/ic_donation_status.png differ 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 0000000..2e1937d Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_edit.imageset/ic_edit.png differ 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 0000000..ce5efb9 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_live_detail_bottom.imageset/ic_live_detail_bottom.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_live_detail_top.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_live_detail_top.imageset/Contents.json new file mode 100644 index 0000000..a160618 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_live_detail_top.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_live_detail_top.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_live_detail_top.imageset/ic_live_detail_top.png b/SodaLive/Resources/Assets.xcassets/ic_live_detail_top.imageset/ic_live_detail_top.png new file mode 100644 index 0000000..67c63a0 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_live_detail_top.imageset/ic_live_detail_top.png differ 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 0000000..9bc69ff Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_mic_off.imageset/ic_mic_off.png differ 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 0000000..31ed38e Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_mic_on.imageset/ic_mic_on.png differ 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 0000000..e98bfdf Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_noti_pause.imageset/ic_noti_pause.png differ 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 0000000..c2c2754 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_noti_play.imageset/ic_noti_play.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_noti_stop.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_noti_stop.imageset/Contents.json new file mode 100644 index 0000000..b41199d --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_noti_stop.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_noti_stop.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_noti_stop.imageset/ic_noti_stop.png b/SodaLive/Resources/Assets.xcassets/ic_noti_stop.imageset/ic_noti_stop.png new file mode 100644 index 0000000..560b922 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_noti_stop.imageset/ic_noti_stop.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_notice_normal.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_notice_normal.imageset/Contents.json new file mode 100644 index 0000000..bd9e657 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_notice_normal.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_notice_normal.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_notice_normal.imageset/ic_notice_normal.png b/SodaLive/Resources/Assets.xcassets/ic_notice_normal.imageset/ic_notice_normal.png new file mode 100644 index 0000000..830880c Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_notice_normal.imageset/ic_notice_normal.png differ 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 0000000..6820e27 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_notice_selected.imageset/ic_notice_selected.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_request_speak.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_request_speak.imageset/Contents.json new file mode 100644 index 0000000..1b29429 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_request_speak.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_request_speak.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_request_speak.imageset/ic_request_speak.png b/SodaLive/Resources/Assets.xcassets/ic_request_speak.imageset/ic_request_speak.png new file mode 100644 index 0000000..52a3b13 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_request_speak.imageset/ic_request_speak.png differ 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 0000000..5181ef2 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_share.imageset/ic_share.png differ diff --git a/SodaLive/Resources/Assets.xcassets/ic_speaker_off.imageset/Contents.json b/SodaLive/Resources/Assets.xcassets/ic_speaker_off.imageset/Contents.json new file mode 100644 index 0000000..00b34e6 --- /dev/null +++ b/SodaLive/Resources/Assets.xcassets/ic_speaker_off.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "ic_speaker_off.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/SodaLive/Resources/Assets.xcassets/ic_speaker_off.imageset/ic_speaker_off.png b/SodaLive/Resources/Assets.xcassets/ic_speaker_off.imageset/ic_speaker_off.png new file mode 100644 index 0000000..61e0381 Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_speaker_off.imageset/ic_speaker_off.png differ 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 0000000..be48c2d Binary files /dev/null and b/SodaLive/Resources/Assets.xcassets/ic_speaker_on.imageset/ic_speaker_on.png differ 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(