From b42ca61ce401ca612fcefd9b8f1cf8dd3835f035 Mon Sep 17 00:00:00 2001 From: Yu Sung Date: Mon, 6 Jul 2026 14:03:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(content):=20=EC=B6=94=EC=B2=9C=20=EC=98=A4?= =?UTF-8?q?=EB=94=94=EC=98=A4=20=ED=83=AD=EC=9D=84=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SodaLive.xcodeproj/project.pbxproj | 192 ++++++++- SodaLive/Sources/I18n/I18n.swift | 51 +++ .../AudioContent/AudioContentListRow.swift | 364 ++++++++++++++++++ .../AudioContentThumbnailCard.swift | 231 +++++++++++ .../V2/Component/Banner/BannerCarousel.swift | 44 ++- .../Audio/CreatorChannelAudioTabView.swift | 8 +- .../CreatorChannelAudioContentListItem.swift | 221 ----------- .../CreatorChannelAudioSection.swift | 23 +- ...eatorChannelHomeAudioContentListItem.swift | 172 --------- .../Live/CreatorChannelLiveTabView.swift | 8 +- .../V2/Main/Content/MainContentTab.swift | 12 + .../V2/Main/Content/MainContentView.swift | 64 +++ .../MainContentAudioBannerSection.swift | 35 ++ .../MainContentAudioEmptyStateView.swift | 20 + ...ainContentAudioHorizontalCardSection.swift | 32 ++ .../MainContentAudioListCarouselSection.swift | 60 +++ ...MainContentMostCommentedAudioSection.swift | 92 +++++ ...inContentRecommendedAudioGridSection.swift | 40 ++ .../MainContentVoiceOnOnlySection.swift | 56 +++ .../MainContentRecommendationView.swift | 87 +++++ .../MainContentRecommendationViewModel.swift | 72 ++++ .../Models/AudioRecommendationsResponse.swift | 65 ++++ .../MainContentRecommendationApi.swift | 37 ++ .../MainContentRecommendationRepository.swift | 12 + .../Components/MainHomeBannerSection.swift | 27 +- SodaLive/Sources/V2/Main/MainView.swift | 48 ++- .../plan-task.md | 226 +++++++++-- docs/20260705_메인_콘텐츠_내부_추천_탭/prd.md | 2 +- 28 files changed, 1841 insertions(+), 460 deletions(-) create mode 100644 SodaLive/Sources/V2/Component/AudioContent/AudioContentListRow.swift create mode 100644 SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift delete mode 100644 SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelAudioContentListItem.swift delete mode 100644 SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelHomeAudioContentListItem.swift create mode 100644 SodaLive/Sources/V2/Main/Content/MainContentTab.swift create mode 100644 SodaLive/Sources/V2/Main/Content/MainContentView.swift create mode 100644 SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioBannerSection.swift create mode 100644 SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioEmptyStateView.swift create mode 100644 SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioHorizontalCardSection.swift create mode 100644 SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioListCarouselSection.swift create mode 100644 SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentMostCommentedAudioSection.swift create mode 100644 SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentRecommendedAudioGridSection.swift create mode 100644 SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentVoiceOnOnlySection.swift create mode 100644 SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift create mode 100644 SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationViewModel.swift create mode 100644 SodaLive/Sources/V2/Main/Content/Recommendation/Models/AudioRecommendationsResponse.swift create mode 100644 SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationApi.swift create mode 100644 SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationRepository.swift diff --git a/SodaLive.xcodeproj/project.pbxproj b/SodaLive.xcodeproj/project.pbxproj index 00347972..2b57faf1 100644 --- a/SodaLive.xcodeproj/project.pbxproj +++ b/SodaLive.xcodeproj/project.pbxproj @@ -7,6 +7,48 @@ objects = { /* Begin PBXBuildFile section */ + C0A701000000000000000001 /* MainContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000001 /* MainContentView.swift */; }; + C0A701000000000000000002 /* MainContentTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000002 /* MainContentTab.swift */; }; + C0A701000000000000000003 /* MainContentRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000003 /* MainContentRecommendationView.swift */; }; + C0A701000000000000000004 /* MainContentRecommendationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000004 /* MainContentRecommendationViewModel.swift */; }; + C0A701000000000000000005 /* AudioRecommendationsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000005 /* AudioRecommendationsResponse.swift */; }; + C0A701000000000000000006 /* MainContentRecommendationApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000006 /* MainContentRecommendationApi.swift */; }; + C0A701000000000000000007 /* MainContentRecommendationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000007 /* MainContentRecommendationRepository.swift */; }; + C0A701000000000000000008 /* MainContentAudioBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000008 /* MainContentAudioBannerSection.swift */; }; + C0A701000000000000000009 /* MainContentAudioHorizontalCardSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000009 /* MainContentAudioHorizontalCardSection.swift */; }; + C0A70100000000000000000A /* MainContentAudioListCarouselSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000A /* MainContentAudioListCarouselSection.swift */; }; + C0A70100000000000000000B /* MainContentVoiceOnOnlySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000B /* MainContentVoiceOnOnlySection.swift */; }; + C0A70100000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000C /* MainContentMostCommentedAudioSection.swift */; }; + C0A70100000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000D /* MainContentRecommendedAudioGridSection.swift */; }; + C0A70100000000000000000F /* MainContentAudioEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000F /* MainContentAudioEmptyStateView.swift */; }; + C0A702000000000000000001 /* MainContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000001 /* MainContentView.swift */; }; + C0A702000000000000000002 /* MainContentTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000002 /* MainContentTab.swift */; }; + C0A702000000000000000003 /* MainContentRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000003 /* MainContentRecommendationView.swift */; }; + C0A702000000000000000004 /* MainContentRecommendationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000004 /* MainContentRecommendationViewModel.swift */; }; + C0A702000000000000000005 /* AudioRecommendationsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000005 /* AudioRecommendationsResponse.swift */; }; + C0A702000000000000000006 /* MainContentRecommendationApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000006 /* MainContentRecommendationApi.swift */; }; + C0A702000000000000000007 /* MainContentRecommendationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000007 /* MainContentRecommendationRepository.swift */; }; + C0A702000000000000000008 /* MainContentAudioBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000008 /* MainContentAudioBannerSection.swift */; }; + C0A702000000000000000009 /* MainContentAudioHorizontalCardSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000009 /* MainContentAudioHorizontalCardSection.swift */; }; + C0A70200000000000000000A /* MainContentAudioListCarouselSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000A /* MainContentAudioListCarouselSection.swift */; }; + C0A70200000000000000000B /* MainContentVoiceOnOnlySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000B /* MainContentVoiceOnOnlySection.swift */; }; + C0A70200000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000C /* MainContentMostCommentedAudioSection.swift */; }; + C0A70200000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000D /* MainContentRecommendedAudioGridSection.swift */; }; + C0A70200000000000000000F /* MainContentAudioEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000F /* MainContentAudioEmptyStateView.swift */; }; + C0A703000000000000000001 /* MainContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000001 /* MainContentView.swift */; }; + C0A703000000000000000002 /* MainContentTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000002 /* MainContentTab.swift */; }; + C0A703000000000000000003 /* MainContentRecommendationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000003 /* MainContentRecommendationView.swift */; }; + C0A703000000000000000004 /* MainContentRecommendationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000004 /* MainContentRecommendationViewModel.swift */; }; + C0A703000000000000000005 /* AudioRecommendationsResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000005 /* AudioRecommendationsResponse.swift */; }; + C0A703000000000000000006 /* MainContentRecommendationApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000006 /* MainContentRecommendationApi.swift */; }; + C0A703000000000000000007 /* MainContentRecommendationRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000007 /* MainContentRecommendationRepository.swift */; }; + C0A703000000000000000008 /* MainContentAudioBannerSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000008 /* MainContentAudioBannerSection.swift */; }; + C0A703000000000000000009 /* MainContentAudioHorizontalCardSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A700000000000000000009 /* MainContentAudioHorizontalCardSection.swift */; }; + C0A70300000000000000000A /* MainContentAudioListCarouselSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000A /* MainContentAudioListCarouselSection.swift */; }; + C0A70300000000000000000B /* MainContentVoiceOnOnlySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000B /* MainContentVoiceOnOnlySection.swift */; }; + C0A70300000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000C /* MainContentMostCommentedAudioSection.swift */; }; + C0A70300000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000D /* MainContentRecommendedAudioGridSection.swift */; }; + C0A70300000000000000000F /* MainContentAudioEmptyStateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A70000000000000000000F /* MainContentAudioEmptyStateView.swift */; }; 00C796627A6758D271215EC5 /* CreatorProfileItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26881ED30292E28D2E5F84CD /* CreatorProfileItem.swift */; }; 042BEE0C973700FB5F4540CD /* I18n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 826D626B7714E5C28ED31074 /* I18n.swift */; }; 0470E68C6F302A131264E3AF /* ChannelDonationViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DF2BFD2E09F509C44C92214 /* ChannelDonationViewModel.swift */; }; @@ -1634,6 +1676,12 @@ 753741799DEF278DC4C515AA /* KakaoSDKAuth in Frameworks */ = {isa = PBXBuildFile; productRef = 8069DA64D0A280105DF612B6 /* KakaoSDKAuth */; }; 7A1DDAAE227BF176BDEF1CB9 /* TextTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = E61608194B084224DB6B78B5 /* TextTabBar.swift */; }; 7AB453CE38CA0B3D612E9467 /* AudioContentCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = E92246F5C1254CBED72AD9AD /* AudioContentCard.swift */; }; + C0A704000000000000000001 /* AudioContentListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A702000000000000000000 /* AudioContentListRow.swift */; }; + C0A704000000000000000002 /* AudioContentListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A702000000000000000000 /* AudioContentListRow.swift */; }; + C0A704000000000000000003 /* AudioContentListRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A702000000000000000000 /* AudioContentListRow.swift */; }; + C0A706000000000000000001 /* AudioContentThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A706000000000000000000 /* AudioContentThumbnailCard.swift */; }; + C0A706000000000000000002 /* AudioContentThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A706000000000000000000 /* AudioContentThumbnailCard.swift */; }; + C0A706000000000000000003 /* AudioContentThumbnailCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0A706000000000000000000 /* AudioContentThumbnailCard.swift */; }; 7B2EE8B4132668F0267B10CB /* UserProfileChannelDonationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 214C32BF44DF384FD6925E0B /* UserProfileChannelDonationView.swift */; }; 7BED9B1CFDC729EC4AF42B35 /* Radius.swift in Sources */ = {isa = PBXBuildFile; fileRef = E31B21FA615404F2E9E7839F /* Radius.swift */; }; 80209D5731C339163084890C /* LanguageSettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 716260E8214A329EE07CAAFA /* LanguageSettingsViewModel.swift */; }; @@ -1711,7 +1759,6 @@ CC0101162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; }; CC0101172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */; }; CC0101182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift */; }; - CC0101192FF0100000A5BBA3 /* CreatorChannelHomeAudioContentListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100192FF0100000A5BBA3 /* CreatorChannelHomeAudioContentListItem.swift */; }; CC01011A2FF0100000A5BBA3 /* CreatorChannelAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001A2FF0100000A5BBA3 /* CreatorChannelAudioSection.swift */; }; CC01011B2FF0100000A5BBA3 /* CreatorChannelSeriesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001B2FF0100000A5BBA3 /* CreatorChannelSeriesSection.swift */; }; CC01011C2FF0100000A5BBA3 /* CreatorChannelCommunitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001C2FF0100000A5BBA3 /* CreatorChannelCommunitySection.swift */; }; @@ -1744,7 +1791,6 @@ CC0102162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; }; CC0102172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */; }; CC0102182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift */; }; - CC0102192FF0100000A5BBA3 /* CreatorChannelHomeAudioContentListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100192FF0100000A5BBA3 /* CreatorChannelHomeAudioContentListItem.swift */; }; CC01021A2FF0100000A5BBA3 /* CreatorChannelAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001A2FF0100000A5BBA3 /* CreatorChannelAudioSection.swift */; }; CC01021B2FF0100000A5BBA3 /* CreatorChannelSeriesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001B2FF0100000A5BBA3 /* CreatorChannelSeriesSection.swift */; }; CC01021C2FF0100000A5BBA3 /* CreatorChannelCommunitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001C2FF0100000A5BBA3 /* CreatorChannelCommunitySection.swift */; }; @@ -1777,7 +1823,6 @@ CC0103162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */; }; CC0103172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */; }; CC0103182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift */; }; - CC0103192FF0100000A5BBA3 /* CreatorChannelHomeAudioContentListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100192FF0100000A5BBA3 /* CreatorChannelHomeAudioContentListItem.swift */; }; CC01031A2FF0100000A5BBA3 /* CreatorChannelAudioSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001A2FF0100000A5BBA3 /* CreatorChannelAudioSection.swift */; }; CC01031B2FF0100000A5BBA3 /* CreatorChannelSeriesSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001B2FF0100000A5BBA3 /* CreatorChannelSeriesSection.swift */; }; CC01031C2FF0100000A5BBA3 /* CreatorChannelCommunitySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01001C2FF0100000A5BBA3 /* CreatorChannelCommunitySection.swift */; }; @@ -1794,13 +1839,10 @@ CC01032E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift */; }; CC01032F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC01002F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift */; }; CC0103302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0100302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift */; }; - CC0201012FF0200000A5BBA3 /* CreatorChannelAudioContentListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200012FF0200000A5BBA3 /* CreatorChannelAudioContentListItem.swift */; }; CC0201022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift */; }; CC0201032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift */; }; - CC0202012FF0200000A5BBA3 /* CreatorChannelAudioContentListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200012FF0200000A5BBA3 /* CreatorChannelAudioContentListItem.swift */; }; CC0202022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift */; }; CC0202032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift */; }; - CC0203012FF0200000A5BBA3 /* CreatorChannelAudioContentListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200012FF0200000A5BBA3 /* CreatorChannelAudioContentListItem.swift */; }; CC0203022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift */; }; CC0203032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0200032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift */; }; CC0301012FF0300000A5BBA3 /* CreatorChannelAudioTabResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0300012FF0300000A5BBA3 /* CreatorChannelAudioTabResponse.swift */; }; @@ -2036,6 +2078,20 @@ 401A3FB52FEEAFA000A5BBA3 /* MainHomeRecommendationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRecommendationView.swift; sourceTree = ""; }; 401A3FB62FEEAFA000A5BBA3 /* MainHomeRankingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeRankingView.swift; sourceTree = ""; }; 401A3FB72FEEAFA000A5BBA3 /* MainHomeFollowingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeFollowingView.swift; sourceTree = ""; }; + C0A700000000000000000001 /* MainContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentView.swift; sourceTree = ""; }; + C0A700000000000000000002 /* MainContentTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentTab.swift; sourceTree = ""; }; + C0A700000000000000000003 /* MainContentRecommendationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRecommendationView.swift; sourceTree = ""; }; + C0A700000000000000000004 /* MainContentRecommendationViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRecommendationViewModel.swift; sourceTree = ""; }; + C0A700000000000000000005 /* AudioRecommendationsResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRecommendationsResponse.swift; sourceTree = ""; }; + C0A700000000000000000006 /* MainContentRecommendationApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRecommendationApi.swift; sourceTree = ""; }; + C0A700000000000000000007 /* MainContentRecommendationRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRecommendationRepository.swift; sourceTree = ""; }; + C0A700000000000000000008 /* MainContentAudioBannerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioBannerSection.swift; sourceTree = ""; }; + C0A700000000000000000009 /* MainContentAudioHorizontalCardSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioHorizontalCardSection.swift; sourceTree = ""; }; + C0A70000000000000000000A /* MainContentAudioListCarouselSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioListCarouselSection.swift; sourceTree = ""; }; + C0A70000000000000000000B /* MainContentVoiceOnOnlySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentVoiceOnOnlySection.swift; sourceTree = ""; }; + C0A70000000000000000000C /* MainContentMostCommentedAudioSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentMostCommentedAudioSection.swift; sourceTree = ""; }; + C0A70000000000000000000D /* MainContentRecommendedAudioGridSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentRecommendedAudioGridSection.swift; sourceTree = ""; }; + C0A70000000000000000000F /* MainContentAudioEmptyStateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainContentAudioEmptyStateView.swift; sourceTree = ""; }; 401A3FC12FEEB00100A5BBA3 /* MainHomeLiveSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeLiveSection.swift; sourceTree = ""; }; 401A3FC52FEEB00200A5BBA3 /* MainHomeBannerSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeBannerSection.swift; sourceTree = ""; }; 401A3FCC2FEEB00300A5BBA3 /* MainHomeActiveCreatorSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainHomeActiveCreatorSection.swift; sourceTree = ""; }; @@ -2765,7 +2821,6 @@ CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelDonationSection.swift; sourceTree = ""; }; CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelNoticeSection.swift; sourceTree = ""; }; CC0100182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelScheduleSection.swift; sourceTree = ""; }; - CC0100192FF0100000A5BBA3 /* CreatorChannelHomeAudioContentListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelHomeAudioContentListItem.swift; sourceTree = ""; }; CC01001A2FF0100000A5BBA3 /* CreatorChannelAudioSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelAudioSection.swift; sourceTree = ""; }; CC01001B2FF0100000A5BBA3 /* CreatorChannelSeriesSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelSeriesSection.swift; sourceTree = ""; }; CC01001C2FF0100000A5BBA3 /* CreatorChannelCommunitySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelCommunitySection.swift; sourceTree = ""; }; @@ -2782,7 +2837,8 @@ CC01002E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLiveApi.swift; sourceTree = ""; }; CC01002F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLiveRepository.swift; sourceTree = ""; }; CC0100302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLiveViewModel.swift; sourceTree = ""; }; - CC0200012FF0200000A5BBA3 /* CreatorChannelAudioContentListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelAudioContentListItem.swift; sourceTree = ""; }; + C0A702000000000000000000 /* AudioContentListRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContentListRow.swift; sourceTree = ""; }; + C0A706000000000000000000 /* AudioContentThumbnailCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioContentThumbnailCard.swift; sourceTree = ""; }; CC0200022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLiveStartButton.swift; sourceTree = ""; }; CC0200032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelLiveTabView.swift; sourceTree = ""; }; CC0300012FF0300000A5BBA3 /* CreatorChannelAudioTabResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreatorChannelAudioTabResponse.swift; sourceTree = ""; }; @@ -3099,6 +3155,59 @@ path = Like; sourceTree = ""; }; + C0A700000000000000000101 /* Content */ = { + isa = PBXGroup; + children = ( + C0A700000000000000000102 /* Recommendation */, + C0A700000000000000000001 /* MainContentView.swift */, + C0A700000000000000000002 /* MainContentTab.swift */, + ); + path = Content; + sourceTree = ""; + }; + C0A700000000000000000102 /* Recommendation */ = { + isa = PBXGroup; + children = ( + C0A700000000000000000105 /* Components */, + C0A700000000000000000103 /* Models */, + C0A700000000000000000104 /* Repository */, + C0A700000000000000000003 /* MainContentRecommendationView.swift */, + C0A700000000000000000004 /* MainContentRecommendationViewModel.swift */, + ); + path = Recommendation; + sourceTree = ""; + }; + C0A700000000000000000103 /* Models */ = { + isa = PBXGroup; + children = ( + C0A700000000000000000005 /* AudioRecommendationsResponse.swift */, + ); + path = Models; + sourceTree = ""; + }; + C0A700000000000000000104 /* Repository */ = { + isa = PBXGroup; + children = ( + C0A700000000000000000006 /* MainContentRecommendationApi.swift */, + C0A700000000000000000007 /* MainContentRecommendationRepository.swift */, + ); + path = Repository; + sourceTree = ""; + }; + C0A700000000000000000105 /* Components */ = { + isa = PBXGroup; + children = ( + C0A700000000000000000008 /* MainContentAudioBannerSection.swift */, + C0A700000000000000000009 /* MainContentAudioHorizontalCardSection.swift */, + C0A70000000000000000000A /* MainContentAudioListCarouselSection.swift */, + C0A70000000000000000000B /* MainContentVoiceOnOnlySection.swift */, + C0A70000000000000000000C /* MainContentMostCommentedAudioSection.swift */, + C0A70000000000000000000D /* MainContentRecommendedAudioGridSection.swift */, + C0A70000000000000000000F /* MainContentAudioEmptyStateView.swift */, + ); + path = Components; + sourceTree = ""; + }; 401A3F992FEEAFA000A5BBA3 /* Models */ = { isa = PBXGroup; children = ( @@ -5347,6 +5456,7 @@ 976A594BED288A98C0EEA03C /* Component */ = { isa = PBXGroup; children = ( + C0A702100000000000000000 /* AudioContent */, E92246F5C1254CBED72AD9AD /* AudioContentCard.swift */, 2D9C7222B946512FD4C4B499 /* Banner */, B068C16B9D7F3F80901622E8 /* Button */, @@ -5363,6 +5473,15 @@ path = Component; sourceTree = ""; }; + C0A702100000000000000000 /* AudioContent */ = { + isa = PBXGroup; + children = ( + C0A702000000000000000000 /* AudioContentListRow.swift */, + C0A706000000000000000000 /* AudioContentThumbnailCard.swift */, + ); + path = AudioContent; + sourceTree = ""; + }; AA4588B91C0F73344C3DC4A4 /* FanTalk */ = { isa = PBXGroup; children = ( @@ -5436,7 +5555,6 @@ CC0100162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift */, CC0100172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift */, CC0100182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift */, - CC0100192FF0100000A5BBA3 /* CreatorChannelHomeAudioContentListItem.swift */, CC01001A2FF0100000A5BBA3 /* CreatorChannelAudioSection.swift */, CC01001B2FF0100000A5BBA3 /* CreatorChannelSeriesSection.swift */, CC01001C2FF0100000A5BBA3 /* CreatorChannelCommunitySection.swift */, @@ -5496,7 +5614,6 @@ CC0100292FF0100000A5BBA3 /* CreatorChannelFloatingActionMenu.swift */, CC01002B2FF0100000A5BBA3 /* CreatorChannelSortBar.swift */, CC01002C2FF0100000A5BBA3 /* CreatorChannelSortContextPopup.swift */, - CC0200012FF0200000A5BBA3 /* CreatorChannelAudioContentListItem.swift */, C40163E3E9DB325EA984D3C4 /* CreatorChannelFloatingIconButton.swift */, ); path = Components; @@ -5718,6 +5835,7 @@ D979B1B9166521052893A897 /* Main */ = { isa = PBXGroup; children = ( + C0A700000000000000000101 /* Content */, 1BA796BAC773D01D80F8F9CE /* Home */, 7900EA9FD1150BABE64CF1B3 /* MainPlaceholderTabView.swift */, 0EC05859619BDC03056911E7 /* MainTab.swift */, @@ -6131,7 +6249,6 @@ CC0101162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */, CC0101172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */, CC0101182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift in Sources */, - CC0101192FF0100000A5BBA3 /* CreatorChannelHomeAudioContentListItem.swift in Sources */, CC01011A2FF0100000A5BBA3 /* CreatorChannelAudioSection.swift in Sources */, CC01011B2FF0100000A5BBA3 /* CreatorChannelSeriesSection.swift in Sources */, CC01011C2FF0100000A5BBA3 /* CreatorChannelCommunitySection.swift in Sources */, @@ -6154,7 +6271,6 @@ CC01012E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */, CC01012F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */, CC0101302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift in Sources */, - CC0201012FF0200000A5BBA3 /* CreatorChannelAudioContentListItem.swift in Sources */, CC0201022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift in Sources */, CC0201032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift in Sources */, CC0301012FF0300000A5BBA3 /* CreatorChannelAudioTabResponse.swift in Sources */, @@ -6217,6 +6333,22 @@ 401A3FCE2FEEB00400A5BBA3 /* MainHomeRecentDebutCreatorSection.swift in Sources */, 401A3FD12FEEB00500A5BBA3 /* MainHomeAiCharacterSection.swift in Sources */, 401A3FE02FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */, + C0A704000000000000000003 /* AudioContentListRow.swift in Sources */, + C0A701000000000000000001 /* MainContentView.swift in Sources */, + C0A701000000000000000002 /* MainContentTab.swift in Sources */, + C0A701000000000000000003 /* MainContentRecommendationView.swift in Sources */, + C0A701000000000000000004 /* MainContentRecommendationViewModel.swift in Sources */, + C0A701000000000000000005 /* AudioRecommendationsResponse.swift in Sources */, + C0A701000000000000000006 /* MainContentRecommendationApi.swift in Sources */, + C0A701000000000000000007 /* MainContentRecommendationRepository.swift in Sources */, + C0A701000000000000000008 /* MainContentAudioBannerSection.swift in Sources */, + C0A701000000000000000009 /* MainContentAudioHorizontalCardSection.swift in Sources */, + C0A70100000000000000000A /* MainContentAudioListCarouselSection.swift in Sources */, + C0A70100000000000000000B /* MainContentVoiceOnOnlySection.swift in Sources */, + C0A70100000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */, + C0A70100000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */, + C0A70100000000000000000F /* MainContentAudioEmptyStateView.swift in Sources */, + C0A706000000000000000003 /* AudioContentThumbnailCard.swift in Sources */, 401A3FAA2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAB2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, 401C10112FEF300000A5BBA3 /* HomeFollowingTabResponse.swift in Sources */, @@ -6964,6 +7096,7 @@ 95FC92B71DBA0095273DEDAC /* DonationRankingPeriod.swift in Sources */, A376733CDBF21EBAF81C8BE2 /* SocialLoginRequest.swift in Sources */, 7AB453CE38CA0B3D612E9467 /* AudioContentCard.swift in Sources */, + C0A704000000000000000001 /* AudioContentListRow.swift in Sources */, 2B65EEB59EE24059AC5DEADC /* BannerCarousel.swift in Sources */, 4572B5004B70CED10BDDB4C3 /* FollowAllButton.swift in Sources */, BCA6CD9E57528C5F4203F1A5 /* CapsuleTabBar.swift in Sources */, @@ -6987,7 +7120,6 @@ CC0102162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */, CC0102172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */, CC0102182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift in Sources */, - CC0102192FF0100000A5BBA3 /* CreatorChannelHomeAudioContentListItem.swift in Sources */, CC01021A2FF0100000A5BBA3 /* CreatorChannelAudioSection.swift in Sources */, CC01021B2FF0100000A5BBA3 /* CreatorChannelSeriesSection.swift in Sources */, CC01021C2FF0100000A5BBA3 /* CreatorChannelCommunitySection.swift in Sources */, @@ -7010,7 +7142,6 @@ CC01022E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */, CC01022F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */, CC0102302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift in Sources */, - CC0202012FF0200000A5BBA3 /* CreatorChannelAudioContentListItem.swift in Sources */, CC0202022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift in Sources */, CC0202032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift in Sources */, CC0302012FF0300000A5BBA3 /* CreatorChannelAudioTabResponse.swift in Sources */, @@ -7059,6 +7190,21 @@ 401A3FD92FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */, 401A3FDD2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */, 401A3FE12FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */, + C0A702000000000000000001 /* MainContentView.swift in Sources */, + C0A702000000000000000002 /* MainContentTab.swift in Sources */, + C0A702000000000000000003 /* MainContentRecommendationView.swift in Sources */, + C0A702000000000000000004 /* MainContentRecommendationViewModel.swift in Sources */, + C0A702000000000000000005 /* AudioRecommendationsResponse.swift in Sources */, + C0A702000000000000000006 /* MainContentRecommendationApi.swift in Sources */, + C0A702000000000000000007 /* MainContentRecommendationRepository.swift in Sources */, + C0A702000000000000000008 /* MainContentAudioBannerSection.swift in Sources */, + C0A702000000000000000009 /* MainContentAudioHorizontalCardSection.swift in Sources */, + C0A70200000000000000000A /* MainContentAudioListCarouselSection.swift in Sources */, + C0A70200000000000000000B /* MainContentVoiceOnOnlySection.swift in Sources */, + C0A70200000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */, + C0A70200000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */, + C0A70200000000000000000F /* MainContentAudioEmptyStateView.swift in Sources */, + C0A706000000000000000001 /* AudioContentThumbnailCard.swift in Sources */, 401A3FAE2FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FAF2FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, 401C10212FEF300000A5BBA3 /* HomeFollowingTabResponse.swift in Sources */, @@ -7846,6 +7992,7 @@ BA511503C1E037162697D771 /* DonationRankingPeriod.swift in Sources */, A3377BAA267EB8951F5CFE12 /* SocialLoginRequest.swift in Sources */, C9E67EC59CB2709A7A75A8B8 /* AudioContentCard.swift in Sources */, + C0A704000000000000000002 /* AudioContentListRow.swift in Sources */, 7389BAA35C414A1A0C1FE616 /* BannerCarousel.swift in Sources */, 9346B50E2D4531CE2CE373C8 /* FollowAllButton.swift in Sources */, 1690309E86928580115E94E2 /* CapsuleTabBar.swift in Sources */, @@ -7869,7 +8016,6 @@ CC0103162FF0100000A5BBA3 /* CreatorChannelDonationSection.swift in Sources */, CC0103172FF0100000A5BBA3 /* CreatorChannelNoticeSection.swift in Sources */, CC0103182FF0100000A5BBA3 /* CreatorChannelScheduleSection.swift in Sources */, - CC0103192FF0100000A5BBA3 /* CreatorChannelHomeAudioContentListItem.swift in Sources */, CC01031A2FF0100000A5BBA3 /* CreatorChannelAudioSection.swift in Sources */, CC01031B2FF0100000A5BBA3 /* CreatorChannelSeriesSection.swift in Sources */, CC01031C2FF0100000A5BBA3 /* CreatorChannelCommunitySection.swift in Sources */, @@ -7892,7 +8038,6 @@ CC01032E2FF0100000A5BBA3 /* CreatorChannelLiveApi.swift in Sources */, CC01032F2FF0100000A5BBA3 /* CreatorChannelLiveRepository.swift in Sources */, CC0103302FF0100000A5BBA3 /* CreatorChannelLiveViewModel.swift in Sources */, - CC0203012FF0200000A5BBA3 /* CreatorChannelAudioContentListItem.swift in Sources */, CC0203022FF0200000A5BBA3 /* CreatorChannelLiveStartButton.swift in Sources */, CC0203032FF0200000A5BBA3 /* CreatorChannelLiveTabView.swift in Sources */, CC0303012FF0300000A5BBA3 /* CreatorChannelAudioTabResponse.swift in Sources */, @@ -7941,6 +8086,21 @@ 401A3FDA2FEEB00700A5BBA3 /* MainHomeCheerCreatorSection.swift in Sources */, 401A3FDE2FEEB00800A5BBA3 /* MainHomePopularCommunitySection.swift in Sources */, 401A3FE22FEEB00900A5BBA3 /* MainHomeBusinessInfoSection.swift in Sources */, + C0A703000000000000000001 /* MainContentView.swift in Sources */, + C0A703000000000000000002 /* MainContentTab.swift in Sources */, + C0A703000000000000000003 /* MainContentRecommendationView.swift in Sources */, + C0A703000000000000000004 /* MainContentRecommendationViewModel.swift in Sources */, + C0A703000000000000000005 /* AudioRecommendationsResponse.swift in Sources */, + C0A703000000000000000006 /* MainContentRecommendationApi.swift in Sources */, + C0A703000000000000000007 /* MainContentRecommendationRepository.swift in Sources */, + C0A703000000000000000008 /* MainContentAudioBannerSection.swift in Sources */, + C0A703000000000000000009 /* MainContentAudioHorizontalCardSection.swift in Sources */, + C0A70300000000000000000A /* MainContentAudioListCarouselSection.swift in Sources */, + C0A70300000000000000000B /* MainContentVoiceOnOnlySection.swift in Sources */, + C0A70300000000000000000C /* MainContentMostCommentedAudioSection.swift in Sources */, + C0A70300000000000000000D /* MainContentRecommendedAudioGridSection.swift in Sources */, + C0A70300000000000000000F /* MainContentAudioEmptyStateView.swift in Sources */, + C0A706000000000000000002 /* AudioContentThumbnailCard.swift in Sources */, 401A3FB22FEEAFA000A5BBA3 /* MainHomeRankingView.swift in Sources */, 401A3FB32FEEAFA000A5BBA3 /* MainHomeFollowingView.swift in Sources */, 401C10312FEF300000A5BBA3 /* HomeFollowingTabResponse.swift in Sources */, diff --git a/SodaLive/Sources/I18n/I18n.swift b/SodaLive/Sources/I18n/I18n.swift index 52e02bbf..a4c23010 100644 --- a/SodaLive/Sources/I18n/I18n.swift +++ b/SodaLive/Sources/I18n/I18n.swift @@ -2998,6 +2998,57 @@ If you block this user, the following features will be restricted. } } + enum MainContentRecommendation { + static var tabTitle: String { + pick(ko: "추천", en: "Recommended", ja: "おすすめ") + } + + static var emptyStateMessage: String { + pick( + ko: "추천 오디오가 아직 없어요.", + en: "No recommended audio yet.", + ja: "おすすめオーディオはまだありません。" + ) + } + + static var loadFailedMessage: String { + pick( + ko: "추천 오디오를 불러오지 못했습니다.\n잠시 후 다시 시도해 주세요.", + en: "Unable to load recommended audio.\nPlease try again later.", + ja: "おすすめオーディオを読み込めませんでした。\nしばらくしてからもう一度お試しください。" + ) + } + + static var latestAudioSectionTitle: String { + pick(ko: "새로 올라온 오디오", en: "Latest audio", ja: "新着オーディオ") + } + + static var newAndHotSectionTitle: String { + pick(ko: "New&Hot", en: "New&Hot", ja: "New&Hot") + } + + static var voiceOnOnlySectionTitle: String { + pick(ko: "오직 보이스온에서만!", en: "Only on VoiceOn!", ja: "VoiceOnだけで!") + } + + static var freeAudioSectionTitle: String { + pick(ko: "무료 오디오", en: "Free audio", ja: "無料オーディオ") + } + + static var pointAudioSectionTitle: String { + pick(ko: "포인트 오디오", en: "Point audio", ja: "ポイントオーディオ") + } + + static var mostCommentedAudioSectionTitle: String { + pick(ko: "최근 댓글이 많은 오디오", en: "Most commented audio", ja: "最近コメントが多いオーディオ") + } + + static var recommendedAudioSectionTitle: String { + pick(ko: "추천 오디오", en: "Recommended audio", ja: "おすすめオーディオ") + } + + } + enum Home { static var liveNowSectionTitle: String { pick(ko: "지금 라이브중", en: "Live now", ja: "ライブ配信中") diff --git a/SodaLive/Sources/V2/Component/AudioContent/AudioContentListRow.swift b/SodaLive/Sources/V2/Component/AudioContent/AudioContentListRow.swift new file mode 100644 index 00000000..0d80ad2d --- /dev/null +++ b/SodaLive/Sources/V2/Component/AudioContent/AudioContentListRow.swift @@ -0,0 +1,364 @@ +import SwiftUI + +struct AudioContentListRowItem: Identifiable { + let id: Int + let title: String + let subtitle: String? + let imageUrl: String? + let price: Int + let isAdult: Bool + let isPointAvailable: Bool + let isFirstContent: Bool + let isOriginalSeries: Bool + let isOwned: Bool + let isRented: Bool +} + +extension AudioContentListRowItem { + init(audioContent: CreatorChannelAudioContentResponse, subtitle: String?) { + self.init( + id: audioContent.audioContentId, + title: audioContent.title, + subtitle: subtitle, + imageUrl: audioContent.imageUrl, + price: audioContent.price, + isAdult: audioContent.isAdult, + isPointAvailable: audioContent.isPointAvailable, + isFirstContent: audioContent.isFirstContent, + isOriginalSeries: audioContent.isOriginalSeries == true, + isOwned: audioContent.isOwned, + isRented: audioContent.isRented + ) + } +} + +enum AudioContentListRowStyle { + case compact(width: CGFloat?, showsAdultTag: Bool = false) + case purchaseState + + static func compactWidth(screenWidth: CGFloat = UIScreen.main.bounds.width) -> CGFloat { + let baselineScreenWidth: CGFloat = 402 + let baselineRowWidth: CGFloat = 346 + return baselineRowWidth * min(screenWidth, baselineScreenWidth) / baselineScreenWidth + } +} + +struct AudioContentListRow: View { + let item: AudioContentListRowItem + let style: AudioContentListRowStyle + let action: () -> Void + + init( + item: AudioContentListRowItem, + style: AudioContentListRowStyle, + action: @escaping () -> Void = {} + ) { + self.item = item + self.style = style + self.action = action + } + + var body: some View { + Button { + action() + } label: { + HStack(alignment: .center, spacing: SodaSpacing.s14) { + thumbnail + + titleArea + .frame(maxWidth: .infinity, alignment: .leading) + + if showsTrailingState { + trailingState + } + } + .padding(.horizontal, horizontalPadding) + .padding(.vertical, verticalPadding) + .frame(width: rowWidth, alignment: .leading) + .frame(maxWidth: maxRowWidth, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + private var titleArea: some View { + VStack(alignment: .leading, spacing: titleSpacing) { + Text(item.title) + .appFont(size: 16, weight: .bold) + .foregroundColor(.white) + .lineLimit(titleLineLimit) + .truncationMode(.tail) + + if let subtitle = item.subtitle, !subtitle.isEmpty { + Text(subtitle) + .appFont(size: 14, weight: .medium) + .foregroundColor(Color.gray500) + .lineLimit(1) + .truncationMode(.tail) + } + } + } + + private var thumbnail: some View { + ZStack(alignment: .topLeading) { + DownsampledKFImage( + url: item.imageUrl.flatMap(URL.init(string:)), + size: CGSize(width: 88, height: 88) + ) + .background(Color.gray800) + + VStack(alignment: .leading, spacing: 0) { + topTagList + + Spacer(minLength: 0) + + bottomTagList + } + } + .frame(width: 88, height: 88) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } + + @ViewBuilder + private var topTagList: some View { + HStack(spacing: 0) { + if item.isOriginalSeries { + CreatorChannelAudioImageTag(imageName: "ic_content_tag_original") + } + + if item.isFirstContent { + CreatorChannelAudioFirstTag() + } + + if showsAdultTag { + Spacer() + + if item.isAdult { + CreatorChannelAudioAdultTag() + .padding(.top, 6) + .padding(.trailing, 6) + } + } + } + } + + @ViewBuilder + private var bottomTagList: some View { + HStack(spacing: 0) { + if item.isPointAvailable { + CreatorChannelAudioImageTag(imageName: "ic_content_tag_point") + } + + if item.price == 0 { + CreatorChannelAudioFreeTag() + } + } + } + + @ViewBuilder + private var trailingState: some View { + if item.isOwned { + playbackState(I18n.Content.Status.owned) + } else if item.isRented { + playbackState(I18n.Content.Status.rented) + } else if item.price == 0 { + playbackState("") + } else { + HStack(spacing: 2) { + Image("ic_bar_cash") + .resizable() + .scaledToFit() + .frame(width: 18, height: 18) + + Text(item.price.comma()) + .appFont(size: 14, weight: .regular) + .foregroundColor(.white) + .lineLimit(1) + } + } + } + + private func playbackState(_ title: String) -> some View { + VStack(spacing: SodaSpacing.s4) { + ZStack { + Color.white + + Image("ic_new_player_play") + .resizable() + .scaledToFit() + .frame(width: 16, height: 16) + } + .frame(width: 28, height: 28) + .clipShape(Circle()) + + if !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + Text(title) + .appFont(size: 12, weight: .medium) + .foregroundColor(Color.gray400) + .lineLimit(1) + } + } + } + + private var rowWidth: CGFloat? { + if case let .compact(width, _) = style { + return width + } + return nil + } + + private var maxRowWidth: CGFloat? { + switch style { + case .compact: + return nil + case .purchaseState: + return .infinity + } + } + + private var horizontalPadding: CGFloat { + switch style { + case .compact: + return 0 + case .purchaseState: + return SodaSpacing.s20 + } + } + + private var verticalPadding: CGFloat { + switch style { + case .compact: + return 0 + case .purchaseState: + return SodaSpacing.s8 + } + } + + private var titleSpacing: CGFloat { + switch style { + case .compact: + return 2 + case .purchaseState: + return SodaSpacing.s6 + } + } + + private var titleLineLimit: Int { + switch style { + case .compact: + return 1 + case .purchaseState: + return 2 + } + } + + private var showsTrailingState: Bool { + if case .purchaseState = style { + return true + } + return false + } + + private var showsAdultTag: Bool { + switch style { + case let .compact(_, showsAdultTag): + return showsAdultTag + case .purchaseState: + return true + } + } +} + +struct CreatorChannelAudioImageTag: View { + let imageName: String + + var body: some View { + Image(imageName) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } +} + +struct CreatorChannelAudioFirstTag: View { + var body: some View { + ZStack { + Color(hex: "FF34B8") + + Image("ic_content_tag_first_star") + .resizable() + .scaledToFit() + .padding(SodaSpacing.s4) + } + .frame(width: 24, height: 24) + } +} + +struct CreatorChannelAudioAdultTag: View { + var body: some View { + ZStack { + Color(hex: "FF4C3C") + + Image("ic_new_shield_small") + .resizable() + .scaledToFit() + .padding(2) + } + .frame(width: 18, height: 18) + .cornerRadius(4) + } +} + +struct CreatorChannelAudioFreeTag: View { + var body: some View { + Text("무료") + .appFont(size: 14, weight: .semibold) + .foregroundColor(.white) + .lineLimit(1) + .padding(SodaSpacing.s4) + .frame(height: 24) + .background(Color.soda900) + } +} + +struct AudioContentListRow_Previews: PreviewProvider { + static var previews: some View { + VStack(spacing: SodaSpacing.s8) { + AudioContentListRow( + item: AudioContentListRowItem( + id: 1, + title: "콘텐츠 이름이 한 줄 콘텐츠 이름이 한 줄 콘텐츠 이름이 한 줄", + subtitle: "3:00 • 시리즈 이름", + imageUrl: "https://picsum.photos/300/300", + price: 0, + isAdult: false, + isPointAvailable: true, + isFirstContent: true, + isOriginalSeries: true, + isOwned: false, + isRented: false + ), + style: .compact(width: 346) + ) + + AudioContentListRow( + item: AudioContentListRowItem( + id: 2, + title: "라이브 다시듣기 제목이 두 줄까지 표시됩니다", + subtitle: "1:43:25", + imageUrl: "https://picsum.photos/300/300", + price: 3000, + isAdult: true, + isPointAvailable: true, + isFirstContent: true, + isOriginalSeries: true, + isOwned: false, + isRented: false + ), + style: .purchaseState + ) + } + .background(Color.black) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift b/SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift new file mode 100644 index 00000000..f9b388d9 --- /dev/null +++ b/SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift @@ -0,0 +1,231 @@ +import SwiftUI + +struct AudioContentThumbnailCardItem: Identifiable { + let id: Int + let title: String + let subtitle: String + let imageUrl: String? + let price: Int + let isAdult: Bool + let isPointAvailable: Bool + let isFirstContent: Bool + let isOriginalSeries: Bool +} + +struct AudioContentThumbnailCard: View { + let item: AudioContentThumbnailCardItem + let width: CGFloat + let titleTypography: SodaTypography + let subtitleTypography: SodaTypography + let labelHorizontalPadding: CGFloat + + init( + item: AudioContentThumbnailCardItem, + size: AudioContentCardSize + ) { + self.item = item + self.width = size.width + self.titleTypography = size.titleTypography + self.subtitleTypography = size.subtitleTypography + self.labelHorizontalPadding = size.labelHorizontalPadding + } + + init( + item: AudioContentThumbnailCardItem, + width: CGFloat, + titleTypography: SodaTypography = .heading4, + subtitleTypography: SodaTypography = .body5, + labelHorizontalPadding: CGFloat = 0 + ) { + self.item = item + self.width = width + self.titleTypography = titleTypography + self.subtitleTypography = subtitleTypography + self.labelHorizontalPadding = labelHorizontalPadding + } + + var body: some View { + VStack(alignment: .leading, spacing: SodaSpacing.s8) { + thumbnail + + labelContent + } + .frame(width: width, alignment: .leading) + } + + private var thumbnail: some View { + ZStack(alignment: .topLeading) { + DownsampledKFImage( + url: item.imageUrl.flatMap(URL.init(string:)), + size: CGSize(width: width, height: width) + ) + .background(Color.gray800) + + VStack(alignment: .leading, spacing: 0) { + topTagRow + + Spacer(minLength: 0) + + bottomTagRow + } + } + .frame(width: width, height: width) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } + + private var labelContent: some View { + VStack(alignment: .leading, spacing: 2) { + Text(item.title) + .appFont(titleTypography) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + + Text(item.subtitle) + .appFont(subtitleTypography) + .foregroundColor(Color.gray500) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(width: width - (labelHorizontalPadding * 2), alignment: .leading) + .padding(.horizontal, labelHorizontalPadding) + .frame(width: width, alignment: .leading) + } + + @ViewBuilder + private var topTagRow: some View { + HStack(spacing: 0) { + if item.isOriginalSeries { + originalTag + } + + if item.isFirstContent { + firstTag + } + + Spacer(minLength: 0) + + if item.isAdult { + adultTag + .padding(.top, SodaSpacing.s6) + .padding(.trailing, SodaSpacing.s6) + } + } + } + + @ViewBuilder + private var bottomTagRow: some View { + HStack(spacing: 0) { + if item.isPointAvailable { + pointTag + } + + if item.price == 0 { + freeTag + } + } + } + + private var originalTag: some View { + Image("ic_content_tag_original") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + + private var firstTag: some View { + ZStack { + Image("ic_content_tag_first_star") + .resizable() + .scaledToFit() + .frame(width: 17, height: 17) + } + .padding(4) + .background(Color(hex: "FF34B8")) + } + + private var adultTag: some View { + ZStack { + Color(hex: "FF4C3C") + + Image("ic_new_shield_small") + .resizable() + .scaledToFit() + .padding(2) + } + .frame(width: 18, height: 18) + .cornerRadius(4) + } + + private var pointTag: some View { + Image("ic_content_tag_point") + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + } + + private var freeTag: some View { + Text("무료") + .appFont(size: 14, weight: .semibold) + .foregroundColor(.white) + .lineLimit(1) + .padding(.horizontal, SodaSpacing.s4) + .frame(height: 24) + .background(Color.soda900) + } +} + +extension AudioContentThumbnailCardItem { + init(audioCard: AudioCardResponse) { + self.init( + id: audioCard.audioContentId, + title: audioCard.title, + subtitle: audioCard.creatorNickname, + imageUrl: audioCard.imageUrl, + price: audioCard.price, + isAdult: audioCard.isAdult, + isPointAvailable: audioCard.isPointAvailable, + isFirstContent: audioCard.isFirstContent, + isOriginalSeries: audioCard.isOriginalSeries + ) + } +} + +struct AudioContentThumbnailCard_Previews: PreviewProvider { + static var previews: some View { + HStack(alignment: .top, spacing: SodaSpacing.s14) { + AudioContentThumbnailCard( + item: AudioContentThumbnailCardItem( + id: 1, + title: "오디오 콘텐츠 제목입니다", + subtitle: "크리에이터명", + imageUrl: "https://picsum.photos/300/300", + price: 0, + isAdult: true, + isPointAvailable: true, + isFirstContent: true, + isOriginalSeries: true + ), + size: .medium + ) + + AudioContentThumbnailCard( + item: AudioContentThumbnailCardItem( + id: 2, + title: "추천 오디오", + subtitle: "크리에이터명", + imageUrl: "https://picsum.photos/300/300", + price: 10, + isAdult: false, + isPointAvailable: false, + isFirstContent: true, + isOriginalSeries: true + ), + width: 170 + ) + } + .padding(SodaSpacing.s20) + .background(Color.black) + .previewLayout(.sizeThatFits) + } +} diff --git a/SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift b/SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift index 8ab5de74..c092ee45 100644 --- a/SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift +++ b/SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift @@ -25,7 +25,6 @@ struct BannerCarousel: View { init( items: [BannerCarouselItem], - height: CGFloat = 120, action: @escaping (BannerCarouselItem) -> Void = { _ in } ) { self.items = Array(items.prefix(BannerCarousel.maxItemCount)) @@ -200,6 +199,49 @@ struct BannerCarousel: View { } } +struct BannerCarouselSection: View { + let items: [Item] + let id: (Item, Int) -> String + let imageUrl: (Item) -> String? + let action: (Item) -> Void + + init( + items: [Item], + id: @escaping (Item, Int) -> String, + imageUrl: @escaping (Item) -> String?, + action: @escaping (Item) -> Void + ) { + self.items = items + self.id = id + self.imageUrl = imageUrl + self.action = action + } + + var body: some View { + if !items.isEmpty { + BannerCarousel(items: carouselItems) { carouselItem in + guard let item = item(for: carouselItem) else { return } + action(item) + } + } + } + + private var carouselItems: [BannerCarouselItem] { + items.enumerated().map { index, item in + BannerCarouselItem( + id: id(item, index), + imageUrl: imageUrl(item) + ) + } + } + + private func item(for carouselItem: BannerCarouselItem) -> Item? { + items.enumerated().first { index, item in + id(item, index) == carouselItem.id + }?.element + } +} + struct BannerCarousel_Previews: PreviewProvider { static var previews: some View { BannerCarousel( diff --git a/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift b/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift index aef55ccb..ee3d0cfc 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift @@ -54,7 +54,13 @@ struct CreatorChannelAudioTabView: View { } ForEach(viewModel.audioContents) { audioContent in - CreatorChannelAudioContentListItem(audioContent: audioContent) { + AudioContentListRow( + item: AudioContentListRowItem( + audioContent: audioContent, + subtitle: audioContent.duration + ), + style: .purchaseState + ) { onTapContent(audioContent.audioContentId) } .onAppear { diff --git a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelAudioContentListItem.swift b/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelAudioContentListItem.swift deleted file mode 100644 index ada9535e..00000000 --- a/SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelAudioContentListItem.swift +++ /dev/null @@ -1,221 +0,0 @@ -import SwiftUI - -struct CreatorChannelAudioContentListItem: View { - let audioContent: CreatorChannelAudioContentResponse - let action: () -> Void - - init( - audioContent: CreatorChannelAudioContentResponse, - action: @escaping () -> Void = {} - ) { - self.audioContent = audioContent - self.action = action - } - - var body: some View { - Button { - action() - } label: { - HStack(alignment: .center, spacing: SodaSpacing.s14) { - thumbnail - - VStack(alignment: .leading, spacing: SodaSpacing.s6) { - Text(audioContent.title) - .appFont(size: 16, weight: .bold) - .foregroundColor(.white) - .lineLimit(2) - .truncationMode(.tail) - - if let duration = audioContent.duration, !duration.isEmpty { - Text(duration) - .appFont(size: 14, weight: .medium) - .foregroundColor(Color.gray500) - .lineLimit(1) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - trailingState - } - .padding(.horizontal, SodaSpacing.s20) - .padding(.vertical, SodaSpacing.s8) - .frame(maxWidth: .infinity, alignment: .leading) - } - .buttonStyle(.plain) - } - - private var thumbnail: some View { - ZStack(alignment: .topLeading) { - DownsampledKFImage( - url: audioContent.imageUrl.flatMap(URL.init(string:)), - size: CGSize(width: 88, height: 88) - ) - .background(Color.gray800) - - VStack(alignment: .leading, spacing: 0) { - topTagList - - Spacer(minLength: 0) - - bottomTagList - } - } - .frame(width: 88, height: 88) - .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) - } - - @ViewBuilder - private var topTagList: some View { - HStack(spacing: 0) { - if audioContent.isOriginalSeries == true { - CreatorChannelAudioImageTag(imageName: "ic_content_tag_original") - } - - if audioContent.isFirstContent { - CreatorChannelAudioFirstTag() - } - - Spacer() - - if audioContent.isAdult { - CreatorChannelAudioAdultTag() - .padding(.top, 6) - .padding(.trailing, 6) - } - } - } - - @ViewBuilder - private var bottomTagList: some View { - HStack(spacing: 0) { - if audioContent.isPointAvailable { - CreatorChannelAudioImageTag(imageName: "ic_content_tag_point") - } - - if audioContent.price == 0 { - CreatorChannelAudioFreeTag() - } - } - } - - @ViewBuilder - private var trailingState: some View { - if audioContent.isOwned { - playbackState(I18n.Content.Status.owned) - } else if audioContent.isRented { - playbackState(I18n.Content.Status.rented) - } else if audioContent.price == 0 { - playbackState("") - } else { - HStack(spacing: 2) { - Image("ic_bar_cash") - .resizable() - .scaledToFit() - .frame(width: 18, height: 18) - - Text(audioContent.price.comma()) - .appFont(size: 14, weight: .regular) - .foregroundColor(.white) - .lineLimit(1) - } - } - } - - private func playbackState(_ title: String) -> some View { - VStack(spacing: SodaSpacing.s4) { - ZStack { - Color.white - - Image("ic_new_player_play") - .resizable() - .scaledToFit() - .frame(width: 16, height: 16) - } - .frame(width: 28, height: 28) - .clipShape(Circle()) - - if !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text(title) - .appFont(size: 12, weight: .medium) - .foregroundColor(Color.gray400) - .lineLimit(1) - } - } - } -} - -struct CreatorChannelAudioContentListItem_Previews: PreviewProvider { - static var previews: some View { - VStack(spacing: SodaSpacing.s8) { - CreatorChannelAudioContentListItem( - audioContent: CreatorChannelAudioContentResponse( - audioContentId: 1, - title: "라이브 다시듣기 제목이 두 줄까지 표시됩니다", - duration: "1:43:25", - imageUrl: "https://picsum.photos/300/300", - price: 10, - isAdult: true, - isPointAvailable: true, - isFirstContent: true, - seriesName: nil, - isOriginalSeries: true, - isOwned: true, - isRented: false - ) - ) - - CreatorChannelAudioContentListItem( - audioContent: CreatorChannelAudioContentResponse( - audioContentId: 1, - title: "라이브 다시듣기 제목이 두 줄까지 표시됩니다", - duration: "1:43:25", - imageUrl: "https://picsum.photos/300/300", - price: 10, - isAdult: true, - isPointAvailable: true, - isFirstContent: true, - seriesName: nil, - isOriginalSeries: true, - isOwned: false, - isRented: true - ) - ) - - CreatorChannelAudioContentListItem( - audioContent: CreatorChannelAudioContentResponse( - audioContentId: 1, - title: "라이브 다시듣기 제목이 두 줄까지 표시됩니다", - duration: "1:43:25", - imageUrl: "https://picsum.photos/300/300", - price: 3000, - isAdult: true, - isPointAvailable: true, - isFirstContent: true, - seriesName: nil, - isOriginalSeries: true, - isOwned: false, - isRented: false - ) - ) - - CreatorChannelAudioContentListItem( - audioContent: CreatorChannelAudioContentResponse( - audioContentId: 1, - title: "라이브 다시듣기 제목이 두 줄까지 표시됩니다", - duration: "1:43:25", - imageUrl: "https://picsum.photos/300/300", - price: 0, - isAdult: true, - isPointAvailable: true, - isFirstContent: true, - seriesName: nil, - isOriginalSeries: true, - isOwned: false, - isRented: false - ) - ) - } - .background(Color.black) - .previewLayout(.sizeThatFits) - } -} diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelAudioSection.swift b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelAudioSection.swift index dda24d91..df11dd64 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelAudioSection.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelAudioSection.swift @@ -8,6 +8,10 @@ struct CreatorChannelAudioSection: View { private let homeVisibleCount = 9 private let itemsPerColumn = 3 + private var rowWidth: CGFloat { + AudioContentListRowStyle.compactWidth() + } + init( audioContents: [CreatorChannelAudioContentResponse], onSelectTab: @escaping (CreatorChannelTab) -> Void, @@ -30,7 +34,13 @@ struct CreatorChannelAudioSection: View { ForEach(audioContentColumns.indices, id: \.self) { index in VStack(alignment: .leading, spacing: SodaSpacing.s8) { ForEach(audioContentColumns[index]) { audioContent in - CreatorChannelHomeAudioContentListItem(audioContent: audioContent) { + AudioContentListRow( + item: AudioContentListRowItem( + audioContent: audioContent, + subtitle: subtitle(for: audioContent) + ), + style: .compact(width: rowWidth) + ) { onTapContent(audioContent.audioContentId) } } @@ -54,6 +64,17 @@ struct CreatorChannelAudioSection: View { Array(visibleAudioContents[index.. String? { + let subtitle = [audioContent.duration, audioContent.seriesName] + .compactMap { value in + guard let value, !value.isEmpty else { return nil } + return value + } + .joined(separator: " • ") + + return subtitle.isEmpty ? nil : subtitle + } } struct CreatorChannelAudioSection_Previews: PreviewProvider { diff --git a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelHomeAudioContentListItem.swift b/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelHomeAudioContentListItem.swift deleted file mode 100644 index 6f1cfc7f..00000000 --- a/SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelHomeAudioContentListItem.swift +++ /dev/null @@ -1,172 +0,0 @@ -import SwiftUI - -struct CreatorChannelHomeAudioContentListItem: View { - let audioContent: CreatorChannelAudioContentResponse - let action: () -> Void - - init( - audioContent: CreatorChannelAudioContentResponse, - action: @escaping () -> Void = {} - ) { - self.audioContent = audioContent - self.action = action - } - - var body: some View { - Button(action: action) { - HStack(alignment: .center, spacing: SodaSpacing.s14) { - thumbnail - - VStack(alignment: .leading, spacing: 2) { - Text(audioContent.title) - .appFont(size: 16, weight: .bold) - .foregroundColor(.white) - .lineLimit(1) - .truncationMode(.tail) - - if !subtitle.isEmpty { - Text(subtitle) - .appFont(size: 14, weight: .medium) - .foregroundColor(Color.gray500) - .lineLimit(1) - .truncationMode(.tail) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - .frame(width: 346, alignment: .leading) - } - .buttonStyle(.plain) - } - - private var subtitle: String { - [audioContent.duration, audioContent.seriesName] - .compactMap { value in - guard let value, !value.isEmpty else { return nil } - return value - } - .joined(separator: " • ") - } - - private var thumbnail: some View { - ZStack(alignment: .topLeading) { - DownsampledKFImage( - url: audioContent.imageUrl.flatMap(URL.init(string:)), - size: CGSize(width: 88, height: 88) - ) - .background(Color.gray800) - - VStack(alignment: .leading, spacing: 0) { - topTagList - - Spacer(minLength: 0) - - bottomTagList - } - } - .frame(width: 88, height: 88) - .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) - } - - @ViewBuilder - private var topTagList: some View { - HStack(spacing: 0) { - if audioContent.isOriginalSeries == true { - CreatorChannelAudioImageTag(imageName: "ic_content_tag_original") - } - - if audioContent.isFirstContent { - CreatorChannelAudioFirstTag() - } - } - } - - @ViewBuilder - private var bottomTagList: some View { - HStack(spacing: 0) { - if audioContent.isPointAvailable { - CreatorChannelAudioImageTag(imageName: "ic_content_tag_point") - } - - if audioContent.price == 0 { - CreatorChannelAudioFreeTag() - } - } - } -} - -struct CreatorChannelAudioImageTag: View { - let imageName: String - - var body: some View { - Image(imageName) - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - } -} - -struct CreatorChannelAudioFirstTag: View { - var body: some View { - ZStack { - Color(hex: "FF34B8") - - Image("ic_content_tag_first_star") - .resizable() - .scaledToFit() - .padding(SodaSpacing.s4) - } - .frame(width: 24, height: 24) - } -} - -struct CreatorChannelAudioAdultTag: View { - var body: some View { - ZStack { - Color(hex: "FF4C3C") - - Image("ic_new_shield_small") - .resizable() - .scaledToFit() - .padding(2) - } - .frame(width: 18, height: 18) - .cornerRadius(4) - } -} - -struct CreatorChannelAudioFreeTag: View { - var body: some View { - Text("무료") - .appFont(size: 14, weight: .semibold) - .foregroundColor(.white) - .lineLimit(1) - .padding(SodaSpacing.s4) - .frame(height: 24) - .background(Color.soda900) - } -} - -struct CreatorChannelHomeAudioContentListItem_Previews: PreviewProvider { - static var previews: some View { - CreatorChannelHomeAudioContentListItem( - audioContent: CreatorChannelAudioContentResponse( - audioContentId: 1, - title: "콘텐츠 이름이 한 줄 콘텐츠 이름이 한 줄 콘텐츠 이름이 한 줄", - duration: "3:00", - imageUrl: "https://picsum.photos/300/300", - price: 0, - isAdult: false, - isPointAvailable: true, - isFirstContent: true, - seriesName: "시리즈 이름", - isOriginalSeries: true, - isOwned: false, - isRented: false - ) - ) - .padding(SodaSpacing.s14) - .background(Color.black) - .previewLayout(.sizeThatFits) - } -} diff --git a/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift b/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift index 32bfbbf6..bb138c0e 100644 --- a/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift +++ b/SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift @@ -44,7 +44,13 @@ struct CreatorChannelLiveTabView: View { .padding(.bottom, SodaSpacing.s12) ForEach(viewModel.liveReplayContents) { audioContent in - CreatorChannelAudioContentListItem(audioContent: audioContent) { + AudioContentListRow( + item: AudioContentListRowItem( + audioContent: audioContent, + subtitle: audioContent.duration + ), + style: .purchaseState + ) { onTapContent(audioContent.audioContentId) } .onAppear { diff --git a/SodaLive/Sources/V2/Main/Content/MainContentTab.swift b/SodaLive/Sources/V2/Main/Content/MainContentTab.swift new file mode 100644 index 00000000..3e68cc80 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/MainContentTab.swift @@ -0,0 +1,12 @@ +import Foundation + +enum MainContentTab: CaseIterable, Hashable { + case recommendation + + var title: String { + switch self { + case .recommendation: + return I18n.MainContentRecommendation.tabTitle + } + } +} diff --git a/SodaLive/Sources/V2/Main/Content/MainContentView.swift b/SodaLive/Sources/V2/Main/Content/MainContentView.swift new file mode 100644 index 00000000..a4990b36 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/MainContentView.swift @@ -0,0 +1,64 @@ +import SwiftUI + +struct MainContentView: View { + let onTapCanCharge: () -> Void + let onTapSearch: () -> Void + let onTapNotificationList: () -> Void + let onTapContent: (Int) -> Void + let onTapBanner: (AudioBannerResponse) -> Void + let onTapSeries: (Int) -> Void + + @State private var selectedTab: MainContentTab = .recommendation + + var body: some View { + VStack(spacing: 0) { + HomeTitleBar { + HStack(spacing: SodaSpacing.s14) { + Image("ic_bar_cash") + .onTapGesture { onTapCanCharge() } + + Image("ic_bar_search") + .onTapGesture { onTapSearch() } + + Image("ic_bar_bell") + .onTapGesture { onTapNotificationList() } + } + } + + TextTabBar( + items: MainContentTab.allCases, + selectedItem: $selectedTab, + title: { $0.title } + ) + + tabContent + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(Color.black.ignoresSafeArea()) + } + + @ViewBuilder + private var tabContent: some View { + switch selectedTab { + case .recommendation: + MainContentRecommendationView( + onTapContent: onTapContent, + onTapBanner: onTapBanner, + onTapSeries: onTapSeries + ) + } + } +} + +struct MainContentView_Previews: PreviewProvider { + static var previews: some View { + MainContentView( + onTapCanCharge: {}, + onTapSearch: {}, + onTapNotificationList: {}, + onTapContent: { _ in }, + onTapBanner: { _ in }, + onTapSeries: { _ in } + ) + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioBannerSection.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioBannerSection.swift new file mode 100644 index 00000000..91c14243 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioBannerSection.swift @@ -0,0 +1,35 @@ +import SwiftUI + +struct MainContentAudioBannerSection: View { + let items: [AudioBannerResponse] + let onTapBanner: (AudioBannerResponse) -> Void + + var body: some View { + BannerCarouselSection( + items: items, + id: fallbackId, + imageUrl: { $0.imageUrl }, + action: onTapBanner + ) + } + + private func fallbackId(for item: AudioBannerResponse, index: Int) -> String { + if let eventItem = item.eventItem { + return "content-banner-\(index)-event-\(eventItem.id)" + } + + if let creatorId = item.creatorId { + return "content-banner-\(index)-creator-\(creatorId)" + } + + if let seriesId = item.seriesId { + return "content-banner-\(index)-series-\(seriesId)" + } + + if let link = item.link, !link.isEmpty { + return "content-banner-\(index)-link-\(link)" + } + + return "content-banner-\(index)-image-\(item.imageUrl)" + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioEmptyStateView.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioEmptyStateView.swift new file mode 100644 index 00000000..aba5043e --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioEmptyStateView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct MainContentAudioEmptyStateView: View { + let message: String + + var body: some View { + VStack(spacing: SodaSpacing.s12) { + Image(systemName: "waveform") + .font(.system(size: 32, weight: .semibold)) + .foregroundColor(Color.gray600) + + Text(message) + .appFont(.body1) + .foregroundColor(Color.gray400) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(SodaSpacing.s20) + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioHorizontalCardSection.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioHorizontalCardSection.swift new file mode 100644 index 00000000..ec01c2c9 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioHorizontalCardSection.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct MainContentAudioHorizontalCardSection: View { + let title: String + let items: [AudioCardResponse] + let onTapContent: (Int) -> Void + + var body: some View { + if !items.isEmpty { + VStack(alignment: .leading, spacing: SodaSpacing.s12) { + SectionTitle(title: title) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: SodaSpacing.s14) { + ForEach(items) { item in + Button { + onTapContent(item.audioContentId) + } label: { + AudioContentThumbnailCard( + item: AudioContentThumbnailCardItem(audioCard: item), + size: .medium + ) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, SodaSpacing.s20) + } + } + } + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioListCarouselSection.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioListCarouselSection.swift new file mode 100644 index 00000000..468b4f32 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioListCarouselSection.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct MainContentAudioListCarouselSection: View { + let title: String + let items: [AudioCardResponse] + let onTapContent: (Int) -> Void + + private var chunks: [[AudioCardResponse]] { + stride(from: 0, to: items.count, by: 3).map { + Array(items[$0.. AudioContentListRowItem { + AudioContentListRowItem( + id: item.audioContentId, + title: item.title, + subtitle: item.creatorNickname, + imageUrl: item.imageUrl, + price: item.price, + isAdult: item.isAdult, + isPointAvailable: item.isPointAvailable, + isFirstContent: item.isFirstContent, + isOriginalSeries: item.isOriginalSeries, + isOwned: false, + isRented: false + ) + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentMostCommentedAudioSection.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentMostCommentedAudioSection.swift new file mode 100644 index 00000000..fbe349a4 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentMostCommentedAudioSection.swift @@ -0,0 +1,92 @@ +import SwiftUI + +struct MainContentMostCommentedAudioSection: View { + let title: String + let items: [CommentedAudioResponse] + let onTapContent: (Int) -> Void + + private let thumbnailSize: CGFloat = 88 + + private var cardWidth: CGFloat { + UIScreen.main.bounds.width - (SodaSpacing.s14 * 2) + } + + var body: some View { + if !items.isEmpty { + VStack(alignment: .leading, spacing: SodaSpacing.s14) { + SectionTitle(title: title) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: SodaSpacing.s4) { + ForEach(items) { item in + card(item) + } + } + .padding(.leading, SodaSpacing.s14) + .padding(.trailing, SodaSpacing.s20) + } + } + } + } + + private func card(_ item: CommentedAudioResponse) -> some View { + Button { + onTapContent(item.audioContentId) + } label: { + VStack(alignment: .leading, spacing: SodaSpacing.s14) { + audioSummary(item) + + commentPreview(item) + } + .padding(SodaSpacing.s14) + .frame(width: cardWidth, alignment: .leading) + .background(Color.gray900) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } + .buttonStyle(.plain) + } + + private func audioSummary(_ item: CommentedAudioResponse) -> some View { + HStack(alignment: .center, spacing: SodaSpacing.s14) { + DownsampledKFImage( + url: URL(string: item.imageUrl), + size: CGSize(width: thumbnailSize, height: thumbnailSize) + ) + .background(Color.gray800) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + + Text(item.title) + .appFont(size: 16, weight: .bold) + .foregroundColor(.white) + .lineLimit(1) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + + private func commentPreview(_ item: CommentedAudioResponse) -> some View { + HStack(alignment: .center, spacing: SodaSpacing.s14) { + DownsampledKFImage( + url: URL(string: item.latestCommentWriterProfileImageUrl), + size: CGSize(width: 28, height: 28) + ) + .background(Color.gray700) + .clipShape(Circle()) + + Text(item.latestComment) + .appFont(size: 14, weight: .regular) + .foregroundColor(.white) + .lineLimit(2) + .truncationMode(.tail) + .frame(maxWidth: .infinity, alignment: .leading) + + Image(systemName: "chevron.down") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white) + .frame(width: 18, height: 18) + } + .padding(SodaSpacing.s12) + .background(Color.gray800) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentRecommendedAudioGridSection.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentRecommendedAudioGridSection.swift new file mode 100644 index 00000000..97bdabc1 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentRecommendedAudioGridSection.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct MainContentRecommendedAudioGridSection: View { + let title: String + let items: [AudioCardResponse] + let onTapContent: (Int) -> Void + + private let columns = [ + GridItem(.flexible(), spacing: SodaSpacing.s14), + GridItem(.flexible(), spacing: SodaSpacing.s14) + ] + + var body: some View { + if !items.isEmpty { + VStack(alignment: .leading, spacing: SodaSpacing.s12) { + SectionTitle(title: title) + + LazyVGrid(columns: columns, alignment: .leading, spacing: SodaSpacing.s20) { + ForEach(items) { item in + Button { + onTapContent(item.audioContentId) + } label: { + GeometryReader { proxy in + let width = proxy.size.width + + AudioContentThumbnailCard( + item: AudioContentThumbnailCardItem(audioCard: item), + width: width + ) + } + .aspectRatio(0.78, contentMode: .fit) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, SodaSpacing.s20) + } + } + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentVoiceOnOnlySection.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentVoiceOnOnlySection.swift new file mode 100644 index 00000000..af20ef60 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentVoiceOnOnlySection.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct MainContentVoiceOnOnlySection: View { + let title: String + let items: [OriginalSeriesResponse] + let onTapSeries: (Int) -> Void + + var body: some View { + if !items.isEmpty { + VStack(alignment: .leading, spacing: SodaSpacing.s12) { + SectionTitle(title: title) + + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: SodaSpacing.s14) { + ForEach(items) { item in + Button { + onTapSeries(item.seriesId) + } label: { + ZStack(alignment: .topLeading) { + DownsampledKFImage( + url: URL(string: item.coverImageUrl), + size: CGSize(width: 122, height: 172) + ) + .background(Color.gray800) + + originalTag + } + .frame(width: 122, height: 172) + .clipShape(RoundedRectangle(cornerRadius: SodaSpacing.s14, style: .continuous)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, SodaSpacing.s20) + } + } + } + } + + private var originalTag: some View { + HStack(spacing: SodaSpacing.s4) { + Image("ic_series_original") + .resizable() + .scaledToFit() + .frame(width: 14, height: 14) + + Image("img_new_only") + .resizable() + .scaledToFit() + .frame(height: 14) + } + .padding(.horizontal, SodaSpacing.s8) + .padding(.vertical, 5) + .background(Color.gray900) + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift new file mode 100644 index 00000000..c01ca995 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct MainContentRecommendationView: View { + let onTapContent: (Int) -> Void + let onTapBanner: (AudioBannerResponse) -> Void + let onTapSeries: (Int) -> Void + + @StateObject private var viewModel = MainContentRecommendationViewModel() + + var body: some View { + ZStack { + Color.black + .ignoresSafeArea() + + if viewModel.isLoading && viewModel.recommendations == nil { + ProgressView() + .progressViewStyle(CircularProgressViewStyle(tint: .white)) + } else if viewModel.shouldShowEmptyState { + MainContentAudioEmptyStateView(message: viewModel.emptyStateMessage) + } else { + ScrollView(showsIndicators: false) { + VStack(alignment: .leading, spacing: SodaSpacing.s24) { + if let recommendations = viewModel.recommendations { + MainContentAudioBannerSection( + items: recommendations.banners, + onTapBanner: onTapBanner + ) + + MainContentAudioHorizontalCardSection( + title: I18n.MainContentRecommendation.latestAudioSectionTitle, + items: recommendations.latestAudios, + onTapContent: onTapContent + ) + + MainContentAudioListCarouselSection( + title: I18n.MainContentRecommendation.newAndHotSectionTitle, + items: recommendations.newAndHotAudios, + onTapContent: onTapContent + ) + + MainContentVoiceOnOnlySection( + title: I18n.MainContentRecommendation.voiceOnOnlySectionTitle, + items: recommendations.originalSeries, + onTapSeries: onTapSeries + ) + + MainContentAudioHorizontalCardSection( + title: I18n.MainContentRecommendation.freeAudioSectionTitle, + items: recommendations.freeAudios, + onTapContent: onTapContent + ) + + MainContentAudioHorizontalCardSection( + title: I18n.MainContentRecommendation.pointAudioSectionTitle, + items: recommendations.pointAudios, + onTapContent: onTapContent + ) + + MainContentMostCommentedAudioSection( + title: I18n.MainContentRecommendation.mostCommentedAudioSectionTitle, + items: recommendations.mostCommentedAudios, + onTapContent: onTapContent + ) + + MainContentRecommendedAudioGridSection( + title: I18n.MainContentRecommendation.recommendedAudioSectionTitle, + items: recommendations.recommendedAudios, + onTapContent: onTapContent + ) + } + } + .padding(.vertical, SodaSpacing.s20) + } + } + } + .onAppear { + if !viewModel.hasLoaded { + viewModel.fetchRecommendations() + } + } + .sodaToast( + isPresented: $viewModel.isShowPopup, + message: viewModel.errorMessage, + autohideIn: 2 + ) + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationViewModel.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationViewModel.swift new file mode 100644 index 00000000..e89f894b --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationViewModel.swift @@ -0,0 +1,72 @@ +import Foundation +import Combine + +final class MainContentRecommendationViewModel: ObservableObject { + private let repository = MainContentRecommendationRepository() + private var subscription = Set() + + @Published var isLoading = false + @Published var isShowPopup = false + @Published var errorMessage = "" + @Published var recommendations: AudioRecommendationsResponse? + @Published var hasLoaded = false + + var emptyStateMessage: String { + if !errorMessage.isEmpty { + return errorMessage + } + + return I18n.MainContentRecommendation.emptyStateMessage + } + + var shouldShowEmptyState: Bool { + hasLoaded && !isLoading && (recommendations?.isEmpty ?? true) + } + + func fetchRecommendations() { + guard !isLoading else { return } + + isLoading = true + + repository.getAudioRecommendations() + .sink { [weak self] result in + guard let self else { return } + + switch result { + case .finished: + DEBUG_LOG("finish") + case .failure(let error): + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.MainContentRecommendation.loadFailedMessage + self.isShowPopup = true + self.isLoading = false + self.hasLoaded = true + } + } receiveValue: { [weak self] response in + guard let self else { return } + + 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.errorMessage = "" + self.recommendations = data + } else { + self.errorMessage = decoded.message ?? I18n.MainContentRecommendation.loadFailedMessage + self.isShowPopup = true + } + } catch { + ERROR_LOG(error.localizedDescription) + self.errorMessage = I18n.MainContentRecommendation.loadFailedMessage + self.isShowPopup = true + } + + self.isLoading = false + self.hasLoaded = true + } + .store(in: &subscription) + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/Models/AudioRecommendationsResponse.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/Models/AudioRecommendationsResponse.swift new file mode 100644 index 00000000..375855aa --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/Models/AudioRecommendationsResponse.swift @@ -0,0 +1,65 @@ +import Foundation + +struct AudioRecommendationsResponse: Decodable { + let banners: [AudioBannerResponse] + let originalSeries: [OriginalSeriesResponse] + let latestAudios: [AudioCardResponse] + let newAndHotAudios: [AudioCardResponse] + let freeAudios: [AudioCardResponse] + let pointAudios: [AudioCardResponse] + let mostCommentedAudios: [CommentedAudioResponse] + let recommendedAudios: [AudioCardResponse] + + var isEmpty: Bool { + banners.isEmpty && + originalSeries.isEmpty && + latestAudios.isEmpty && + newAndHotAudios.isEmpty && + freeAudios.isEmpty && + pointAudios.isEmpty && + mostCommentedAudios.isEmpty && + recommendedAudios.isEmpty + } +} + +struct AudioBannerResponse: Decodable, Hashable, Identifiable { + var id: String { "\(imageUrl)-\(creatorId ?? 0)-\(seriesId ?? 0)-\(link ?? "")" } + + let imageUrl: String + let eventItem: EventItem? + let creatorId: Int? + let seriesId: Int? + let link: String? +} + +struct OriginalSeriesResponse: Decodable, Hashable, Identifiable { + var id: Int { seriesId } + + let seriesId: Int + let coverImageUrl: String +} + +struct AudioCardResponse: Decodable, Hashable, Identifiable { + var id: Int { audioContentId } + + let audioContentId: Int + let title: String + let duration: String + let imageUrl: String + let price: Int + let isAdult: Bool + let isPointAvailable: Bool + let isFirstContent: Bool + let isOriginalSeries: Bool + let creatorNickname: String +} + +struct CommentedAudioResponse: Decodable, Hashable, Identifiable { + var id: Int { audioContentId } + + let audioContentId: Int + let title: String + let imageUrl: String + let latestComment: String + let latestCommentWriterProfileImageUrl: String +} diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationApi.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationApi.swift new file mode 100644 index 00000000..886defc8 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationApi.swift @@ -0,0 +1,37 @@ +import Foundation +import Moya + +enum MainContentRecommendationApi { + case getAudioRecommendations +} + +extension MainContentRecommendationApi: TargetType { + var baseURL: URL { + return URL(string: BASE_URL)! + } + + var path: String { + switch self { + case .getAudioRecommendations: + return "/api/v2/audio/recommendations" + } + } + + var method: Moya.Method { + switch self { + case .getAudioRecommendations: + return .get + } + } + + var task: Moya.Task { + switch self { + case .getAudioRecommendations: + return .requestPlain + } + } + + var headers: [String: String]? { + return ["Authorization": "Bearer \(UserDefaults.string(forKey: UserDefaultsKey.token))"] + } +} diff --git a/SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationRepository.swift b/SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationRepository.swift new file mode 100644 index 00000000..4f2d8243 --- /dev/null +++ b/SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationRepository.swift @@ -0,0 +1,12 @@ +import Foundation +import CombineMoya +import Combine +import Moya + +final class MainContentRecommendationRepository { + private let api = MoyaProvider() + + func getAudioRecommendations() -> AnyPublisher { + return api.requestPublisher(.getAudioRecommendations) + } +} diff --git a/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeBannerSection.swift b/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeBannerSection.swift index 4ac20917..ba974937 100644 --- a/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeBannerSection.swift +++ b/SodaLive/Sources/V2/Main/Home/Recommendation/Components/MainHomeBannerSection.swift @@ -13,27 +13,12 @@ struct MainHomeBannerSection: View { } var body: some View { - if !items.isEmpty { - BannerCarousel(items: carouselItems) { item in - guard let banner = banner(for: item) else { return } - onTapBanner(banner) - } - } - } - - private var carouselItems: [BannerCarouselItem] { - items.enumerated().map { index, item in - BannerCarouselItem( - id: fallbackId(for: item, index: index), - imageUrl: item.imageUrl - ) - } - } - - private func banner(for carouselItem: BannerCarouselItem) -> RecommendationBannerResponse? { - items.enumerated().first { index, item in - fallbackId(for: item, index: index) == carouselItem.id - }?.element + BannerCarouselSection( + items: items, + id: fallbackId, + imageUrl: { $0.imageUrl }, + action: onTapBanner + ) } private func fallbackId(for item: RecommendationBannerResponse, index: Int) -> String { diff --git a/SodaLive/Sources/V2/Main/MainView.swift b/SodaLive/Sources/V2/Main/MainView.swift index 953e9b37..2295e82e 100644 --- a/SodaLive/Sources/V2/Main/MainView.swift +++ b/SodaLive/Sources/V2/Main/MainView.swift @@ -172,7 +172,14 @@ struct MainView: View { onTapFollowingSchedule: handleFollowingScheduleTap ) case .content: - MainPlaceholderTabView(title: MainTab.content.title) + MainContentView( + onTapCanCharge: handleHomeCanChargeTap, + onTapSearch: handleHomeSearchTap, + onTapNotificationList: handleHomeNotificationListTap, + onTapContent: handleRecommendationContentTap, + onTapBanner: handleContentAudioBannerTap, + onTapSeries: handleContentOriginalSeriesTap + ) case .chat: MainPlaceholderTabView(title: MainTab.chat.title) case .my: @@ -548,10 +555,45 @@ struct MainView: View { openRecommendationBannerLink(item.link) } + private func handleContentAudioBannerTap(_ item: AudioBannerResponse) { + if let eventItem = item.eventItem { + performRecommendationDetailAction { + appState.setAppStep(step: .eventDetail(event: eventItem)) + } + return + } + + if let creatorId = item.creatorId { + handleRecommendationCreatorTap(creatorId: creatorId) + return + } + + if let seriesId = item.seriesId { + handleContentOriginalSeriesTap(seriesId: seriesId) + return + } + + openRecommendationBannerLink(item.link) + } + + private func handleContentOriginalSeriesTap(seriesId: Int) { + guard seriesId > 0 else { return } + performRecommendationDetailAction { + appState.setAppStep(step: .seriesDetail(seriesId: seriesId)) + } + } + private func openRecommendationBannerLink(_ link: String?) { guard let link = link?.trimmingCharacters(in: .whitespacesAndNewlines), - !link.isEmpty, - let url = URL(string: link), + !link.isEmpty else { + return + } + + if AppDeepLinkHandler.handle(urlString: link) { + return + } + + guard let url = URL(string: link), UIApplication.shared.canOpenURL(url) else { return } diff --git a/docs/20260705_메인_콘텐츠_내부_추천_탭/plan-task.md b/docs/20260705_메인_콘텐츠_내부_추천_탭/plan-task.md index 36032d3b..3a433183 100644 --- a/docs/20260705_메인_콘텐츠_내부_추천_탭/plan-task.md +++ b/docs/20260705_메인_콘텐츠_내부_추천_탭/plan-task.md @@ -89,7 +89,7 @@ - `audioContentId`, `title`, `duration`, `imageUrl`, `price` - `isAdult`, `isPointAvailable`, `isFirstContent`, `isOriginalSeries`, `creatorNickname` - `CommentedAudioResponse` - - `audioContentId`, `title`, `imageUrl`, `latestCommentWriterProfileImageUrl` + - `audioContentId`, `title`, `imageUrl`, `latestComment`, `latestCommentWriterProfileImageUrl` ## 재사용 가능한 V2 위젯 후보 @@ -98,7 +98,7 @@ - `SodaLive/Sources/V2/Component/TextTabBar.swift` - 내부 `추천` 탭 bar 구성 후보. - `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift` - - 배너 carousel 후보. 현재 정사각형 높이 계산이 들어 있어 Figma의 `362x374` 비율과 맞는지 확인 후 최소 보완한다. + - 배너 carousel 후보. 메인 홈 추천 탭과 동일한 1:1 비율, 무한 슬라이딩, 자동 슬라이딩 동작을 재사용한다. - `SodaLive/Sources/V2/Component/SectionTitle.swift` - 섹션 제목과 우측 chevron 표시 후보. - `SodaLive/Sources/V2/Component/AudioContentCard.swift` @@ -159,7 +159,7 @@ ### Phase 1: 기준점 점검과 콘텐츠 탭 shell 설계 -- [ ] **Task 1.1: Figma와 기존 탭 구조 확인** +- [x] **Task 1.1: Figma와 기존 탭 구조 확인** - 대상 파일: - 확인: `docs/20260705_메인_콘텐츠_내부_추천_탭/prd.md` - 확인: `docs/20260705_메인_콘텐츠_내부_추천_탭/plan-task.md` @@ -175,7 +175,7 @@ - 실행 명령: `rg "GET /api/v2/audio/recommendations|추천 시리즈|키워드의 오디오|Open Questions|미결정 사항 없음|seriesDetail|AppDeepLinkHandler|nickname" docs/20260705_메인_콘텐츠_내부_추천_탭/prd.md && sed -n '140,190p' SodaLive/Sources/V2/Main/MainView.swift && sed -n '1,120p' SodaLive/Sources/V2/Main/Home/MainHomeView.swift` - 기대 결과: PRD의 확정 범위가 확인되고, `.content`는 placeholder이며, `MainHomeView`는 fixed shell과 tab content를 분리한다. -- [ ] **Task 1.2: 내부 콘텐츠 탭 root 생성** +- [x] **Task 1.2: 내부 콘텐츠 탭 root 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Content/MainContentView.swift` - 생성: `SodaLive/Sources/V2/Main/Content/MainContentTab.swift` @@ -191,7 +191,7 @@ ### Phase 2: API, Repository, Response model, ViewModel -- [ ] **Task 2.1: 추천 오디오 응답 모델 생성** +- [x] **Task 2.1: 추천 오디오 응답 모델 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Content/Recommendation/Models/AudioRecommendationsResponse.swift` - 작업 내용: @@ -203,7 +203,7 @@ - 실행 명령: `rg "struct AudioRecommendationsResponse|struct AudioBannerResponse|struct OriginalSeriesResponse|struct AudioCardResponse|struct CommentedAudioResponse|isAdult|isPointAvailable|isFirstContent|isOriginalSeries" SodaLive/Sources/V2/Main/Content/Recommendation/Models/AudioRecommendationsResponse.swift` - 기대 결과: API 응답 필드가 모두 Swift 모델에 반영된다. -- [ ] **Task 2.2: 추천 오디오 API endpoint 생성** +- [x] **Task 2.2: 추천 오디오 API endpoint 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationApi.swift` - 생성: `SodaLive/Sources/V2/Main/Content/Recommendation/Repository/MainContentRecommendationRepository.swift` @@ -218,7 +218,7 @@ - 실행 명령: `rg "/api/v2/audio/recommendations|getAudioRecommendations|Authorization|AnyPublisher" SodaLive/Sources/V2/Main/Content/Recommendation/Repository` - 기대 결과: 신규 endpoint와 repository 메서드가 추천 오디오 전용 경로에만 존재한다. -- [ ] **Task 2.3: ViewModel 생성과 섹션 표시 상태 정리** +- [x] **Task 2.3: ViewModel 생성과 섹션 표시 상태 정리** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationViewModel.swift` - 수정: `SodaLive/Sources/I18n/I18n.swift` @@ -234,7 +234,7 @@ ### Phase 3: 배너 섹션 -- [ ] **Task 3.1: 배너 섹션 생성** +- [x] **Task 3.1: 배너 섹션 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioBannerSection.swift` - 확인/보완: `SodaLive/Sources/V2/Component/Banner/BannerCarousel.swift` @@ -252,7 +252,7 @@ ### Phase 4: 새로 올라온 오디오 섹션 -- [ ] **Task 4.1: 가로 카드 섹션 공통 컴포넌트 생성** +- [x] **Task 4.1: 가로 카드 섹션 공통 컴포넌트 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioHorizontalCardSection.swift` - 생성: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioTagOverlay.swift` @@ -268,7 +268,7 @@ ### Phase 5: New&Hot 섹션 -- [ ] **Task 5.1: New&Hot list carousel 섹션 생성** +- [x] **Task 5.1: New&Hot list carousel 섹션 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioListCarouselSection.swift` - 확인: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelAudioContentListItem.swift` @@ -284,7 +284,7 @@ ### Phase 6: 오직 보이스온에서만 섹션 -- [ ] **Task 6.1: 보이스온 전용 시리즈 섹션 생성** +- [x] **Task 6.1: 보이스온 전용 시리즈 섹션 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentVoiceOnOnlySection.swift` - 확인: `SodaLive/Sources/V2/CreatorChannel/Series/Components/CreatorChannelSeriesListItem.swift` @@ -302,7 +302,7 @@ ### Phase 7: 무료 오디오 섹션 -- [ ] **Task 7.1: 무료 오디오 섹션 연결** +- [x] **Task 7.1: 무료 오디오 섹션 연결** - 대상 파일: - 수정: `SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift` - 확인: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioHorizontalCardSection.swift` @@ -316,7 +316,7 @@ ### Phase 8: 포인트 오디오 섹션 -- [ ] **Task 8.1: 포인트 오디오 섹션 연결** +- [x] **Task 8.1: 포인트 오디오 섹션 연결** - 대상 파일: - 수정: `SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift` - 확인: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioHorizontalCardSection.swift` @@ -330,25 +330,26 @@ ### Phase 9: 최근 댓글이 많은 오디오 섹션 -- [ ] **Task 9.1: 댓글 카드 carousel 섹션 생성** +- [x] **Task 9.1: 댓글 카드 carousel 섹션 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentMostCommentedAudioSection.swift` - 작업 내용: - Figma `24:6820`의 `최근 댓글이 많은 오디오`를 `mostCommentedAudios`로 표시한다. - 오디오 row와 댓글 preview bubble을 하나의 카드로 묶고, 카드 단위로 가로 스크롤한다. + - `latestComment`를 댓글 본문으로 표시한다. - `latestCommentWriterProfileImageUrl`을 댓글 작성자 profile image로 표시한다. - 댓글 preview bubble에는 nickname을 표시하지 않는다. - `CommentedAudioResponse`에는 creator nickname이 없으므로 댓글 영역에 nickname용 UI나 placeholder를 추가하지 않는다. - 카드 탭 시 `onTapContent(audioContentId)`를 호출한다. - 검증 기준: - - 실행 명령: `rg "MainContentMostCommentedAudioSection|mostCommentedAudios|CommentedAudioResponse|latestCommentWriterProfileImageUrl|onTapContent" SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentMostCommentedAudioSection.swift` - - 기대 결과: 최근 댓글 섹션은 댓글 작성자 profile image와 오디오 탭 callback을 사용한다. + - 실행 명령: `rg "MainContentMostCommentedAudioSection|mostCommentedAudios|CommentedAudioResponse|latestComment|latestCommentWriterProfileImageUrl|onTapContent" SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentMostCommentedAudioSection.swift SodaLive/Sources/V2/Main/Content/Recommendation/Models/AudioRecommendationsResponse.swift` + - 기대 결과: 최근 댓글 섹션은 실제 댓글 본문, 댓글 작성자 profile image, 오디오 탭 callback을 사용한다. - 실행 명령: `rg "nickname|creatorNickname" SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentMostCommentedAudioSection.swift` - 기대 결과: 검색 결과가 없어야 한다. 댓글 bubble에는 nickname을 표시하지 않는다. ### Phase 10: 추천 오디오 섹션 -- [ ] **Task 10.1: 추천 오디오 grid 섹션 생성** +- [x] **Task 10.1: 추천 오디오 grid 섹션 생성** - 대상 파일: - 생성: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentRecommendedAudioGridSection.swift` - 수정: `SodaLive/Sources/V2/Main/Content/Recommendation/MainContentRecommendationView.swift` @@ -364,7 +365,7 @@ ### Phase 11: 제외 섹션 미구현 확인 -- [ ] **Task 11.1: 추천 시리즈와 키워드의 오디오 제외 확인** +- [x] **Task 11.1: 추천 시리즈와 키워드의 오디오 제외 확인** - 대상 파일: - 확인: `SodaLive/Sources/V2/Main/Content/Recommendation/**` - 작업 내용: @@ -377,7 +378,7 @@ ### Phase 12: 라우팅, 프로젝트 포함, 전체 검증 -- [ ] **Task 12.1: 상세 진입 callback 연결** +- [x] **Task 12.1: 상세 진입 callback 연결** - 대상 파일: - 수정: `SodaLive/Sources/V2/Main/MainView.swift` - 확인: `SodaLive/Sources/App/AppStep.swift` @@ -393,7 +394,7 @@ - 실행 명령: `rg "contentDetail|eventItem|eventDetail|creatorDetail|seriesDetail|AppDeepLinkHandler|UIApplication.shared.open|MainContentView" SodaLive/Sources/V2/Main/MainView.swift SodaLive/Sources/App/AppStep.swift SodaLive/Sources/ContentView.swift` - 기대 결과: 상세 진입과 event/creator/series route가 기존 route와 guard 흐름을 재사용하고, link는 deep link 우선 후 외부 브라우저로 처리한다. -- [ ] **Task 12.2: 프로젝트 포함 및 빌드 검증** +- [x] **Task 12.2: 프로젝트 포함 및 빌드 검증** - 대상 파일: - 확인/보완: `SodaLive.xcodeproj/project.pbxproj` - 확인: `SodaLive/Sources/V2/Main/Content/**` @@ -407,6 +408,155 @@ - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` - 기대 결과: `BUILD SUCCEEDED` +### Phase 13: 오디오 row 공용 컴포넌트 정리 + +- [x] **Task 13.1: 공용 오디오 list row 컴포넌트 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Component/AudioContent/AudioContentListRow.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelHomeAudioContentListItem.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelAudioContentListItem.swift` + - 작업 내용: + - 크리에이터 채널 홈 탭 오디오 item과 크리에이터 채널 오디오 탭 item의 공통 row 구조를 기반으로 `AudioContentListRow`를 만든다. + - 표시 데이터는 API 모델에 직접 의존하지 않는 `AudioContentListRowItem`으로 둔다. + - 표시 변형은 `AudioContentListRowStyle.compact(width:)`, `AudioContentListRowStyle.purchaseState`로 분리한다. + - `compact`는 title 1줄, subtitle 1줄, trailing 상태 없음으로 구성한다. + - `purchaseState`는 title 2줄, duration subtitle, owned/rented/free/price trailing 상태를 표시한다. + - 기존 tag asset 조합(`ic_content_tag_original`, `ic_content_tag_first_star`, `ic_content_tag_point`, `ic_new_shield_small`, `무료`)을 공용 row 안에서 재사용한다. + - 검증 기준: + - 실행 명령: `rg "struct AudioContentListRow|struct AudioContentListRowItem|enum AudioContentListRowStyle|case compact|case purchaseState|ic_content_tag_original|ic_content_tag_point|ic_content_tag_first_star|ic_new_shield_small" SodaLive/Sources/V2/Component/AudioContent/AudioContentListRow.swift` + - 기대 결과: 공용 row, 표시 모델, style enum, tag asset 사용이 확인된다. + +- [x] **Task 13.2: 메인 콘텐츠 추천 New&Hot에 공용 row 적용** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioListCarouselSection.swift` + - 작업 내용: + - 기존 private `row(_:)` 직접 구현을 제거한다. + - `AudioCardResponse`를 `AudioContentListRowItem`으로 매핑한다. + - subtitle은 Figma 기준 `creatorNickname`을 사용한다. + - style은 `.compact(width: UIScreen.main.bounds.width - (SodaSpacing.s20 * 2))` 또는 동일한 column 폭 기준으로 사용한다. + - 검증 기준: + - 실행 명령: `rg "AudioContentListRow|AudioContentListRowItem|\.compact|creatorNickname|private func row|MainContentAudioTagOverlay" SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioListCarouselSection.swift` + - 기대 결과: 공용 row 사용이 확인되고, 기존 private row와 `MainContentAudioTagOverlay` 의존이 제거된다. + +- [x] **Task 13.3: 크리에이터 채널 홈 오디오 섹션에 공용 row 적용** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelAudioSection.swift` + - 작업 내용: + - `CreatorChannelHomeAudioContentListItem` 사용을 제거한다. + - `CreatorChannelAudioContentResponse`를 `AudioContentListRowItem`으로 매핑한다. + - subtitle은 기존과 동일하게 `duration`과 `seriesName`을 ` • `로 조합한다. + - style은 기존 row 폭과 동일하게 `.compact(width: 346)`을 사용한다. + - 검증 기준: + - 실행 명령: `rg "AudioContentListRow|AudioContentListRowItem|\.compact\(width: 346\)|CreatorChannelHomeAudioContentListItem" SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelAudioSection.swift` + - 기대 결과: 공용 row 사용이 확인되고 기존 home item 참조는 없어야 한다. + +- [x] **Task 13.4: 크리에이터 채널 오디오/라이브 replay 탭에 공용 row 적용** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift` + - 수정: `SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift` + - 작업 내용: + - `CreatorChannelAudioContentListItem` 사용을 제거한다. + - `CreatorChannelAudioContentResponse`를 `AudioContentListRowItem`으로 매핑한다. + - style은 `.purchaseState`를 사용해 기존 가격/소유/대여/무료 재생 상태를 유지한다. + - 검증 기준: + - 실행 명령: `rg "AudioContentListRow|AudioContentListRowItem|\.purchaseState|CreatorChannelAudioContentListItem" SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift` + - 기대 결과: 공용 row 사용이 확인되고 기존 audio item 참조는 없어야 한다. + +- [x] **Task 13.5: 미사용 기존 item 컴포넌트 삭제와 프로젝트 정리** + - 대상 파일: + - 삭제: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelHomeAudioContentListItem.swift` + - 삭제: `SodaLive/Sources/V2/CreatorChannel/Components/CreatorChannelAudioContentListItem.swift` + - 확인/보완: `SodaLive.xcodeproj/project.pbxproj` + - 작업 내용: + - 공용 row 적용 후 참조가 사라진 기존 item 컴포넌트 파일을 삭제한다. + - Xcode project의 file reference/build phase에서 삭제 파일을 제거하고 신규 공용 파일을 추가한다. + - 검증 기준: + - 실행 명령: `! rg "CreatorChannelHomeAudioContentListItem|CreatorChannelAudioContentListItem" SodaLive/Sources/V2` + - 기대 결과: 삭제한 기존 item 컴포넌트 참조가 남아 있지 않아야 한다. + - 실행 명령: `plutil -lint SodaLive.xcodeproj/project.pbxproj` + - 기대 결과: `OK` + +- [x] **Task 13.6: 공용 오디오 row 리팩터링 빌드 검증** + - 대상 파일: + - 확인: `SodaLive/Sources/V2/Component/AudioContent/AudioContentListRow.swift` + - 확인: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioListCarouselSection.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/Home/Components/CreatorChannelAudioSection.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/Audio/CreatorChannelAudioTabView.swift` + - 확인: `SodaLive/Sources/V2/CreatorChannel/Live/CreatorChannelLiveTabView.swift` + - 작업 내용: + - New&Hot, 크리에이터 채널 홈 오디오, 크리에이터 채널 오디오 탭, 라이브 replay 탭이 공용 row를 사용하도록 최종 확인한다. + - `SodaLive-dev` 빌드를 실행한다. + - 검증 기준: + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - 기대 결과: `BUILD SUCCEEDED` + +### Phase 14: 오디오 card 공용 컴포넌트 정리 + +- [x] **Task 14.1: 공용 오디오 card 컴포넌트 생성** + - 대상 파일: + - 생성: `SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift` + - 확인: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioHorizontalCardSection.swift` + - 확인: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentRecommendedAudioGridSection.swift` + - 확인: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioTagOverlay.swift` + - 작업 내용: + - 새로 올라온 오디오, 무료 오디오, 포인트 오디오, 추천 오디오 섹션에서 공통으로 쓰는 카드형 오디오 item 컴포넌트를 만든다. + - 표시 데이터는 API 모델에 직접 의존하지 않는 `AudioContentThumbnailCardItem`으로 둔다. + - title/subtitle typography는 기존 `AudioContentCardSize` 규칙을 유지한다. + - 이미지 좌측 상단에는 오리지널, 처음 업로드 오디오 태그를 표시한다. + - 이미지 우측 상단에는 `isAdult` shield 태그를 표시한다. + - 이미지 좌측 하단에는 포인트, 무료 태그를 표시한다. + - 태그는 Figma node `567:18089`, `567:18090`, `567:18124`, `567:18272` 기준으로 `ic_content_tag_original`, `ic_content_tag_first_star`, `ic_content_tag_point`, `ic_new_shield_small`, `무료` 조합을 사용한다. + - 검증 기준: + - 실행 명령: `rg "struct AudioContentThumbnailCard|struct AudioContentThumbnailCardItem|ic_content_tag_original|ic_content_tag_first_star|ic_content_tag_point|ic_new_shield_small|무료" SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift` + - 기대 결과: 공용 카드, 표시 모델, Figma 기준 태그 조합이 확인된다. + +- [x] **Task 14.2: 새로 올라온/무료/포인트 오디오 섹션에 공용 card 적용** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioHorizontalCardSection.swift` + - 작업 내용: + - `AudioContentCard`와 `MainContentAudioTagOverlay` 조합을 제거한다. + - `AudioCardResponse`를 `AudioContentThumbnailCardItem`으로 매핑한다. + - 기존과 동일하게 medium 카드 크기, title, creatorNickname subtitle을 유지한다. + - 검증 기준: + - 실행 명령: `rg "AudioContentThumbnailCard|AudioContentThumbnailCardItem|AudioContentCard\(|MainContentAudioTagOverlay" SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioHorizontalCardSection.swift` + - 기대 결과: 공용 카드 사용이 확인되고 기존 직접 조합은 없어야 한다. + +- [x] **Task 14.3: 추천 오디오 grid 섹션에 공용 card 적용** + - 대상 파일: + - 수정: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentRecommendedAudioGridSection.swift` + - 작업 내용: + - 기존 thumbnail/tag/title/subtitle 직접 구현을 제거한다. + - `GeometryReader`에서 계산한 width를 공용 card에 전달한다. + - 기존과 동일하게 grid item의 `aspectRatio(0.78, contentMode: .fit)` 흐름을 유지한다. + - 검증 기준: + - 실행 명령: `rg "AudioContentThumbnailCard|AudioContentThumbnailCardItem|MainContentAudioTagOverlay|DownsampledKFImage|Text\(item.title\)" SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentRecommendedAudioGridSection.swift` + - 기대 결과: 공용 카드 사용이 확인되고 grid 내부 직접 구현은 없어야 한다. + +- [x] **Task 14.4: 기존 main content tag overlay 삭제와 프로젝트 정리** + - 대상 파일: + - 삭제: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioTagOverlay.swift` + - 확인/보완: `SodaLive.xcodeproj/project.pbxproj` + - 작업 내용: + - 공용 card 적용 후 사용하지 않는 기존 main content 전용 tag overlay를 삭제한다. + - Xcode project의 file reference/build phase에서 삭제 파일을 제거하고 신규 공용 파일을 추가한다. + - 검증 기준: + - 실행 명령: `! rg "MainContentAudioTagOverlay" SodaLive/Sources/V2 SodaLive.xcodeproj/project.pbxproj` + - 기대 결과: 기존 tag overlay 참조가 남아 있지 않아야 한다. + - 실행 명령: `plutil -lint SodaLive.xcodeproj/project.pbxproj` + - 기대 결과: `OK` + +- [x] **Task 14.5: 공용 오디오 card 리팩터링 빌드 검증** + - 대상 파일: + - 확인: `SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift` + - 확인: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioHorizontalCardSection.swift` + - 확인: `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentRecommendedAudioGridSection.swift` + - 작업 내용: + - 새로 올라온 오디오, 무료 오디오, 포인트 오디오, 추천 오디오 섹션이 공용 card를 사용하도록 최종 확인한다. + - `SodaLive-dev` 빌드를 실행한다. + - 검증 기준: + - 실행 명령: `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - 기대 결과: `BUILD SUCCEEDED` + ## 검증 기록 - 2026-07-05 문서 작성: @@ -451,5 +601,37 @@ - `docs/20260705_메인_콘텐츠_내부_추천_탭/plan-task.md` - 결과: - 신규 사용자 노출 문구를 I18n ko/en/ja로 추가한다는 기준을 Task 2.3에 명시했다. - - 배너 `eventItem`의 기존 event detail 흐름 재사용 기준을 Phase 12 최종 라우팅 검증에 추가했다. - - 이번 작업은 문서 보완만 수행했으므로 빌드/테스트는 실행하지 않았다. + - 배너 `eventItem`의 기존 event detail 흐름 재사용 기준을 Phase 12 최종 라우팅 검증에 추가했다. + - 이번 작업은 문서 보완만 수행했으므로 빌드/테스트는 실행하지 않았다. +- 2026-07-06 문서 기준 구현: + - 실행/확인: + - `SodaLive/Sources/V2/Main/Content/**` + - `SodaLive/Sources/V2/Main/MainView.swift` + - `SodaLive/Sources/I18n/I18n.swift` + - `SodaLive.xcodeproj/project.pbxproj` + - `rg "enum MainContentTab|struct MainContentView|MainContentRecommendationView|case \\.content" SodaLive/Sources/V2/Main` + - `rg "/api/v2/audio/recommendations|getAudioRecommendations|Authorization|AnyPublisher" SodaLive/Sources/V2/Main/Content/Recommendation/Repository` + - `rg "MainContentAudioBannerSection|AudioBannerResponse|imageUrl|eventItem|creatorId|seriesId|link|seriesDetail|AppDeepLinkHandler|UIApplication.shared.open" SodaLive/Sources/V2/Main/Content/Recommendation SodaLive/Sources/V2/Main/MainView.swift` + - `rg "MainContentVoiceOnOnlySection|originalSeries|OriginalSeriesResponse|ic_series_original|img_new_only|onTapSeries|seriesDetail" SodaLive/Sources/V2/Main/Content/Recommendation SodaLive/Sources/V2/Main/MainView.swift SodaLive/Resources/Assets.xcassets/v2` + - `! rg "추천 시리즈|Keyword|키워드의 오디오|RecommendedSeries" SodaLive/Sources/V2/Main/Content/Recommendation` + - `plutil -lint SodaLive.xcodeproj/project.pbxproj` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - 결과: + - `.content` 탭을 신규 `MainContentView`로 교체하고 고정 title bar, 내부 `추천` tab bar, 추천 body 스크롤 구조를 구현했다. + - `GET /api/v2/audio/recommendations` endpoint, repository, response model, ViewModel 상태/디코딩/I18n 문구를 추가했다. + - 배너, 새로 올라온 오디오, New&Hot, 오직 보이스온에서만, 무료 오디오, 포인트 오디오, 최근 댓글이 많은 오디오, 추천 오디오 섹션을 구현했다. + - `추천 시리즈`, `키워드의 오디오`는 구현하지 않았고, 최근 댓글 섹션에는 nickname UI를 추가하지 않았다. + - `plutil`은 `OK`, `xcodebuild`는 `BUILD SUCCEEDED`로 통과했다. +- 2026-07-06 오디오 card 공용 컴포넌트 정리: + - 실행/확인: + - `SodaLive/Sources/V2/Component/AudioContent/AudioContentThumbnailCard.swift` + - `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentAudioHorizontalCardSection.swift` + - `SodaLive/Sources/V2/Main/Content/Recommendation/Components/MainContentRecommendedAudioGridSection.swift` + - `SodaLive.xcodeproj/project.pbxproj` + - `rg "MainContentAudioTagOverlay|AudioContentThumbnailCard" "SodaLive/Sources/V2" "SodaLive.xcodeproj/project.pbxproj"` + - `plutil -lint "SodaLive.xcodeproj/project.pbxproj"` + - `xcodebuild -workspace "SodaLive.xcworkspace" -scheme "SodaLive-dev" -configuration Debug build` + - 결과: + - 새로 올라온 오디오, 무료 오디오, 포인트 오디오, 추천 오디오 섹션이 `AudioContentThumbnailCard`를 사용하도록 정리했다. + - 기존 `MainContentAudioTagOverlay.swift`를 삭제하고 신규 공용 card 파일을 Xcode project에 추가했다. + - `plutil`은 `OK`, `xcodebuild`는 `BUILD SUCCEEDED`로 통과했다. diff --git a/docs/20260705_메인_콘텐츠_내부_추천_탭/prd.md b/docs/20260705_메인_콘텐츠_내부_추천_탭/prd.md index 777202fa..85feb48d 100644 --- a/docs/20260705_메인_콘텐츠_내부_추천_탭/prd.md +++ b/docs/20260705_메인_콘텐츠_내부_추천_탭/prd.md @@ -134,7 +134,7 @@ Swift 모델은 기존 V2 관례에 맞춰 `Decodable` 구조체로 선언한다 - 일반 오디오 카드 섹션은 가로 스크롤 카드 목록으로 표시한다. - `New&Hot`은 3개 row를 한 묶음으로 구성하고 묶음 단위로 가로 스크롤한다. - `최근 댓글이 많은 오디오`는 오디오 row와 댓글 preview bubble을 하나의 카드로 묶어 가로 스크롤한다. -- `최근 댓글이 많은 오디오`의 댓글 preview bubble에는 nickname을 표시하지 않는다. Figma `24:6820` 기준으로 `latestCommentWriterProfileImageUrl`과 댓글 텍스트 영역만 사용한다. +- `최근 댓글이 많은 오디오`의 댓글 preview bubble에는 nickname을 표시하지 않는다. Figma `24:6820` 기준으로 `latestCommentWriterProfileImageUrl`과 `latestComment` 댓글 텍스트 영역만 사용한다. - `추천 오디오`는 2열 grid로 표시한다. - 텍스트가 길면 한 줄 또는 Figma 기준 line limit로 말줄임 처리한다. - 카드/row의 루트 레이아웃은 가능한 부모 width, grid column, aspect ratio 기반으로 구성한다.