# PRD: 메인 콘텐츠 탭 내부 랭킹 탭 ## 1. Overview Figma `cont_002` 화면(`24:6857`)을 기준으로 메인 콘텐츠 탭의 내부 `랭킹` 탭을 구성하고, `GET /api/v2/audio/rankings` 응답을 기존 V2 콘텐츠 랭킹 위젯 중심으로 표시한다. --- ## 2. Problem - 메인 콘텐츠 탭은 현재 내부 `추천` 탭만 연결되어 있고, Figma에 정의된 내부 `랭킹` 탭 화면과 API 연동 요구가 아직 문서화되어 있지 않다. - 랭킹 화면은 상단 콘텐츠 title-bar, 내부 Text Tab bar, 랭킹 유형 Capsule Tab bar, 순위별 카드 목록이 함께 배치되어야 한다. - API 응답은 `AudioRankingResponse`와 `AudioRankingItemResponse` 형태이고, 기존 `ContentRankingItem` 위젯 계약은 `rankChangeType`, `rankChangeAmount`, `imageUrl`, `isBlocked` 등 별도 필드를 요구하므로 mapper 규칙이 필요하다. - V2 패키지 하위에 이미 `TextTabBarView`, `CapsuleTabBarView`, `ContentRankingAdapter` 및 순위별 카드 위젯이 있으므로 재사용 가능한 후보를 먼저 식별하고 중복 UI 생성을 줄여야 한다. --- ## 3. Goals - 메인 콘텐츠 탭에서 내부 `랭킹` 탭 선택 시 Figma `24:6857` 기준 랭킹 화면을 표시한다. - `GET /api/v2/audio/rankings` 응답을 받아 선택된 랭킹 유형의 오디오 콘텐츠 순위를 표시한다. - 내부 랭킹 유형은 `주간 인기`, `지금 뜨는 중`, `매출`, `판매량`, `댓글수`, `좋아요` 순서로 표시한다. - 기존 `kr.co.vividnext.sodalive.v2.widget.contentranking` 위젯군을 우선 재사용한다. - 랭킹 item 터치 시 유효한 `contentId`를 오디오 콘텐츠 상세 화면으로 전달한다. - API DTO, UI model, mapper, loading/empty/error, tab 선택 동작은 구현 계획에서 검증 가능하도록 정리한다. --- ## 4. Non-Goals - 이번 PRD 작성 단계에서는 코드, 리소스, 레이아웃 파일을 구현하지 않는다. - 서버 API 스키마, 랭킹 산정 기준, 정렬 기준은 클라이언트에서 변경하지 않는다. - 콘텐츠 추천 탭의 섹션 구조를 리팩터링하지 않는다. - 레거시 오디오 메인/랭킹 화면을 직접 수정하지 않는다. - Compose 전환, ViewPager2 기반 swipe tab 전환, pagination, pull-to-refresh, skeleton/shimmer는 이번 범위에 포함하지 않는다. - Figma에 없는 추가 필터, 검색, 상세 통계, 랭킹 설명 문구, 광고 영역은 추가하지 않는다. - `coverImageUrl` 외 별도 이미지 asset을 새로 제작하지 않는다. - Text Tab bar 영역의 `전체` 탭은 화면에 항목만 표시하고, 실제 content 화면/API 연동 구현은 이번 범위에서 제외한다. --- ## 5. Target Users - 메인 콘텐츠 탭에서 인기/상승/매출/판매량/댓글/좋아요 기준 오디오 순위를 탐색하려는 앱 사용자. - 랭킹 카드에서 순위, 순위 변동, 신규 진입 여부, 제목, 크리에이터 닉네임을 빠르게 확인하려는 앱 사용자. - 랭킹 item을 눌러 오디오 콘텐츠 상세로 이동하려는 앱 사용자. - `kr.co.vividnext.sodalive.v2.main.content`와 V2 랭킹 위젯을 유지보수하는 Android 개발자. --- ## 6. User Stories - 사용자는 콘텐츠 탭에서 `랭킹`을 눌러 콘텐츠 랭킹 목록을 바로 보고 싶다. - 사용자는 `주간 인기`, `지금 뜨는 중`, `매출`, `판매량`, `댓글수`, `좋아요` 기준을 전환하며 순위를 비교하고 싶다. - 사용자는 1위, 2~7위, 8~10위, 11위 이후 카드가 시각적으로 구분되길 기대한다. - 사용자는 `New` 또는 순위 상승/하락/유지 상태를 카드에서 확인하고 싶다. - 사용자는 랭킹 item을 터치해 해당 오디오 콘텐츠 상세로 이동하고 싶다. - 개발자는 신규 API 응답을 기존 `ContentRankingAdapter` 계약으로 명확히 변환해 UI 중복 구현을 피하고 싶다. --- ## 7. Core Features ### 메인 콘텐츠 내부 랭킹 탭 메인 콘텐츠 화면의 내부 Text Tab bar에서 `랭킹` 선택 상태일 때 랭킹 유형 탭과 랭킹 목록을 표시한다. #### Requirements - 내부 Text Tab bar 항목은 Figma 기준 `추천`, `랭킹`, `전체` 순서로 표시한다. - `랭킹` 선택 시 Text Tab bar selected index가 `랭킹`으로 갱신되어야 한다. - title-bar와 Text Tab bar는 기존 메인 콘텐츠 화면과 동일하게 상단에 유지한다. - 랭킹 유형 Capsule Tab bar는 Text Tab bar 아래에 배치한다. - 랭킹 목록은 Capsule Tab bar 아래에서 세로 스크롤 가능해야 한다. - 추천 탭으로 다시 전환하면 기존 추천 탭 content가 표시되어야 한다. - Text Tab bar 영역의 `전체` 탭은 항목만 표시하고, 선택 시 표시할 실제 content 화면/API 연동은 이번 범위에서 구현하지 않는다. #### Edge Cases - 랭킹 탭을 반복 선택해도 동일 타입 API를 불필요하게 중복 호출하지 않는 정책을 구현 계획에서 결정한다. - `items`가 비어 있으면 랭킹 목록 영역을 비우고, Figma에 없는 별도 empty 문구는 추가하지 않는다. - API error 시 기존 `ContentMainViewModel`의 error/toast/loading 정책과 일관되게 처리한다. ### Audio Ranking API Integration 랭킹 화면은 `GET /api/v2/audio/rankings` 응답을 받아 기존 콘텐츠 랭킹 위젯용 UI model로 변환한다. #### API Endpoint - `GET /api/v2/audio/rankings` #### Request Query 선택한 랭킹 유형은 `type` query parameter로 전달한다. ```http GET /api/v2/audio/rankings?type=WEEKLY_POPULAR ``` #### Response Contract ```kotlin data class AudioRankingResponse( val showRankChange: Boolean, val type: AudioRankingType, val items: List ) enum class AudioRankingType { WEEKLY_POPULAR, RISING, REVENUE, SALES_COUNT, COMMENT_COUNT, LIKE_COUNT } data class AudioRankingItemResponse( val contentId: Long, val title: String, val creatorNickname: String, val rank: Int, val rankChange: Int?, @JsonProperty("isNew") val isNew: Boolean, val coverImageUrl: String? ) ``` #### Mapping Requirements | Response field | UI/widget field | Requirement | | --- | --- | --- | | `items[].contentId` | `ContentRankingItem.contentId` | 문자열 변환 후 전달한다. | | `items[].rank` | `ContentRankingItem.rank` | `1` 이상인 item만 표시한다. | | `items[].title` | `ContentRankingItem.contentName` | 그대로 전달하고, 말줄임은 기존 위젯 정책을 따른다. | | `items[].creatorNickname` | `ContentRankingItem.creatorName` | 그대로 전달한다. | | `items[].coverImageUrl` | `ContentRankingItem.imageUrl` | null이면 빈 문자열로 변환하고 기존 이미지 로딩/placeholder 정책을 따른다. | | `items[].isNew` | `ContentRankingItem.rankChangeType` | `true`이면 `RankingChangeType.New`로 매핑한다. | | `items[].rankChange` | `ContentRankingItem.rankChangeType`, `ContentRankingItem.rankChangeAmount` | `null` 또는 `0`이면 `Stay`, 양수이면 `Increase`, 음수이면 `Decrease`로 매핑하고 표시 숫자는 절대값을 사용한다. | | `showRankChange` | 순위 변동 표시 여부 | `false`이면 모든 item의 rank-num 영역을 `GONE` 처리한다. 기존 위젯에 옵션이 없으면 최소 확장이 필요하다. | | `type` | 선택된 랭킹 유형 상태 | 응답 타입과 현재 선택 탭이 다르면 구현 계획에서 무시/수용 정책을 명확히 정한다. | #### Rank Change Rules - `isNew=true`이면 `rankChange` 값과 무관하게 `RankingChangeType.New`로 표시한다. - `isNew=false && rankChange=null`이면 `RankingChangeType.Stay`, amount `0`으로 매핑한다. - `isNew=false && rankChange=0`이면 `RankingChangeType.Stay`, amount `0`으로 매핑한다. - `isNew=false && rankChange=5`이면 `RankingChangeType.Increase`, amount `5`로 매핑한다. - `isNew=false && rankChange=-3`이면 `RankingChangeType.Decrease`, amount `3`으로 매핑한다. - 일반화하면 `rankChange > 0`은 `Increase`, `rankChange < 0`은 `Decrease`로 매핑하고 표시 숫자는 `abs(rankChange)`를 사용한다. - `showRankChange=false`이면 `isNew`, `rankChange` 값과 무관하게 rank-num 영역을 `GONE` 처리한다. - API 응답 item은 서버에서 `rank` 오름차순으로 내려온다고 기대하되, 클라이언트에서도 `rank` 기준 오름차순 정렬 후 표시한다. #### Edge Cases - `rank < 1` item은 기존 `ContentRankingItem` 생성 조건에 맞지 않으므로 표시하지 않는다. - `contentId <= 0` item은 표시할 수는 있으나 클릭 이동은 무시한다. - 동일 rank가 중복되면 서버 데이터 오류로 보고 클라이언트는 `rank` 정렬 결과를 그대로 표시한다. 중복 보정 UI는 추가하지 않는다. - `title` 또는 `creatorNickname`이 빈 문자열이면 빈 문자열 그대로 표시하고 대체 문구를 추가하지 않는다. - `coverImageUrl`이 null/blank이거나 이미지 로딩에 실패하면 기존 `loadUrl`/placeholder 정책을 따른다. ### Ranking Type Capsule Tab Figma `24:6882` 기준으로 랭킹 유형 필터를 가로 스크롤 capsule tab으로 표시한다. #### Requirements - 탭 라벨과 타입 매핑은 아래 순서를 따른다. | UI Label | API Type | | --- | --- | | `주간 인기` | `WEEKLY_POPULAR` | | `지금 뜨는 중` | `RISING` | | `매출` | `REVENUE` | | `판매량` | `SALES_COUNT` | | `댓글수` | `COMMENT_COUNT` | | `좋아요` | `LIKE_COUNT` | - 초기 선택은 Figma와 enum 첫 항목 기준 `주간 인기`로 한다. - 선택된 tab은 흰색 배경/검은색 텍스트, 미선택 tab은 검은색 배경/회색 border/흰색 텍스트 스타일을 따른다. - 기존 `CapsuleTabBarView` 재사용을 우선한다. - 탭 선택 시 선택 상태를 갱신하고 해당 타입의 랭킹 데이터를 로드한다. #### Edge Cases - 같은 tab을 다시 누른 경우 선택 상태와 목록을 유지한다. - 네트워크 요청 중 다른 tab을 선택하면 마지막으로 선택된 tab의 응답만 화면에 반영하는 정책을 구현 계획에서 검증한다. ### Content Ranking Widget Reuse 기존 `kr.co.vividnext.sodalive.v2.widget.contentranking` 컴포넌트를 랭킹 목록에 사용한다. #### Existing Widget Fit - Figma `24:6857`의 카드 구조는 기존 콘텐츠 랭킹 위젯의 순위 구간과 일치한다. - 1위: `ContentRankingCardVariant.Large` - 2위~7위: `ContentRankingCardVariant.MediumGrid` - 8위~10위: `ContentRankingCardVariant.SmallGrid` - 11위 이후: `ContentRankingCardVariant.Horizontal` - 기존 `ContentRankingAdapter.createGridLayoutManager()`의 span 정책을 사용한다. - Pattaya/Galada 계열 순위 숫자, dim gradient, New badge, caret rank-num은 기존 위젯 정책을 따른다. - `ContentRankingItem.creatorId`는 API 응답에 없으므로 빈 문자열로 채운다. 이번 화면의 클릭 목적지는 `contentId`만 사용한다. - `ContentRankingItem.isBlocked`에 대응되는 API 필드가 없으므로 기본값은 `false`로 한다. #### Required Change Candidates - `showRankChange=false`일 때 rank-num 영역을 `GONE` 처리하는 옵션이 기존 `ContentRanking*CardView`에 없으면 최소 확장이 필요하다. - `ContentRankingItem.imageUrl`이 non-null `String`이므로 `coverImageUrl=null` 응답을 빈 문자열 또는 기존 placeholder용 값으로 안전하게 변환해야 한다. - `ContentRankingAdapter.submitItems()`는 현재 전체 갱신 방식이므로 DiffUtil 적용은 이번 범위에서 필수로 보지 않는다. - 응답 DTO와 위젯 item은 mapper로 분리해 DTO가 View 계층에 직접 노출되지 않도록 한다. ### Click Routing 랭킹 item 클릭 시 오디오 콘텐츠 상세로 이동한다. #### Requirements - `contentId > 0`인 item만 클릭 이벤트가 동작한다. - 오디오 콘텐츠 상세 이동은 기존 메인 콘텐츠 추천 탭의 `AudioContentDetailActivity` 이동 패턴을 재사용한다. - intent extra는 기존 `Constants.EXTRA_AUDIO_CONTENT_ID`를 사용한다. - 랭킹 item 클릭 시 상세 이동 외 별도 analytics/logging은 추가하지 않는다. #### Edge Cases - `contentId <= 0`이면 클릭을 무시한다. - 빠른 연속 클릭 방지는 기존 화면 공통 정책이 있으면 따른다. 없으면 이번 범위에서 별도 debounce를 추가하지 않는다. --- ## 8. UX / UI Expectations - 전체 배경은 black을 유지한다. - title-bar는 Figma 기준 `콘텐츠` 타이틀과 우측 cash/search/storage 아이콘을 유지한다. - 내부 Text Tab bar는 `추천`, `랭킹`, `전체`을 표시하고, 선택된 `랭킹`은 흰색 텍스트로 보여야 한다. - Capsule Tab bar는 높이 52dp 영역 안에서 가로 스크롤 가능해야 한다. - 랭킹 목록 좌우 여백은 Figma의 374px content 폭과 기존 `ContentRankingAdapter` 계산 정책에 맞춘다. - 카드 간 gap은 후속 Figma `567:17824` 확인 기준 8dp 간격을 따른다. - 1위 대형 카드는 `coverImageUrl`을 전체 카드 배경에 사용하고, 배경에는 blur/dim/gradient를 적용한다. - 1위 대형 카드는 동일 `coverImageUrl`을 별도 전경 1:1 이미지로도 표시하며, 접근 가능한 item의 전경 이미지는 blur 처리하지 않는다. - 1위 카드는 넓은 대형 카드, 2~7위는 2열 정사각 카드, 8~10위는 3열 정사각 카드, 11위 이후는 가로형 카드로 표시한다. - 긴 제목/크리에이터명은 기존 `ContentRankingItem`의 rank별 말줄임 정책을 따른다. - 1위~20위까지 응답이 내려오면 Figma 예시처럼 자연스럽게 세로 스크롤로 확인할 수 있어야 한다. --- ## 9. Technical Constraints - Android XML Views, ViewBinding, RecyclerView 기반 기존 구조를 유지한다. - 신규 API DTO, Repository, ViewModel, mapper, UI state는 `kr.co.vividnext.sodalive.v2.main.content` 하위에 둔다. - 신규 `Activity`, `Fragment`, `ViewModel` 및 연결 하위 코드는 `kr.co.vividnext.sodalive.v2` 패키지 하위에 작성한다. - 기존 메인 콘텐츠 추천 탭 구현과 같은 `ApiResponse`, RxJava `Single`, `BaseViewModel.compositeDisposable`, Koin 등록 패턴을 따른다. - JSON 매핑 annotation은 프로젝트의 Gson 관례에 맞춰 `@SerializedName`을 사용한다. 사용자 제공 DTO의 `@JsonProperty("isNew")`는 서버 계약 의미로만 기록하고, 실제 구현에서는 기존 Gson 설정과 충돌하지 않게 한다. - 네트워크/API 등록은 `AppDI.kt`의 `AudioRecommendationsApi`, `AudioRecommendationsRepository`, `ContentMainViewModel` 등록 위치와 일관되게 추가한다. - 레거시 패키지의 `AudioContentRankingAllActivity` 또는 기존 오디오 메인 화면 파일은 직접 수정하지 않는다. - 구현 전 `docs/20260623_메인_콘텐츠_탭_내부_랭킹_탭/plan-task.md`를 작성한 뒤 해당 계획에 따라 최소 구현한다. --- ## 10. Metrics - `랭킹` 탭 선택 시 랭킹 유형 Capsule Tab bar와 콘텐츠 랭킹 목록이 표시된다. - 초기 랭킹 유형은 `WEEKLY_POPULAR`/`주간 인기`로 선택된다. - 랭킹 유형 탭 6개가 Figma 순서대로 노출된다. - API 응답 item이 `ContentRankingAdapter`에 전달된다. - 1위, 2~7위, 8~10위, 11위 이후 variant가 기존 `ContentRankingPlacement` 정책과 일치한다. - `isNew=true` item은 New badge로 표시된다. - `isNew=false && (rankChange=null || rankChange=0)` item은 유지 상태로 표시된다. - `rankChange`가 양수/음수이면 상승/하락 상태와 절대값 숫자가 표시된다. - `showRankChange=false` 응답에서는 순위 변동 영역이 `GONE` 처리된다. - `contentId > 0` item 클릭 시 오디오 콘텐츠 상세로 이동한다. --- ## 11. Reusable V2 Widget Candidates - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/TextTabBarView.kt` - 메인 콘텐츠 내부 상단 `추천`/`랭킹`/`전체` Text Tab bar에 재사용 가능하다. - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/CapsuleTabBarView.kt` - 랭킹 유형 `주간 인기`/`지금 뜨는 중`/`매출`/`판매량`/`댓글수`/`좋아요` 가로 capsule tab에 재사용 가능하다. - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingAdapter.kt` - Figma와 같은 순위별 mixed grid/list RecyclerView adapter로 재사용 가능하다. - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingItem.kt` - API item을 위젯 표시 계약으로 변환하는 목표 UI model로 재사용 가능하다. - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingPlacement.kt` - rank별 `Large`/`MediumGrid`/`SmallGrid`/`Horizontal` 배치 정책이 Figma와 일치한다. - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingDeltaPresentation.kt` - `New`, 상승, 하락, 유지 rank-num 표시 정책에 재사용 가능하다. - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingLargeCardView.kt` - 1위 카드에 재사용 가능하다. - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingMediumGridCardView.kt` - 2~7위 카드에 재사용 가능하다. - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingSmallGridCardView.kt` - 8~10위 카드에 재사용 가능하다. - `app/src/main/java/kr/co/vividnext/sodalive/v2/widget/contentranking/ContentRankingHorizontalCardView.kt` - 11위 이후 가로형 카드에 재사용 가능하다. - `app/src/main/java/kr/co/vividnext/sodalive/v2/main/content/ContentMainFragment.kt` - 기존 title-bar/Text Tab bar/추천 탭 구조와 오디오 상세 이동 패턴을 확장 대상으로 삼는다. - `app/src/main/res/layout/fragment_v2_main_content.xml` - 현재 콘텐츠 화면의 title-bar, Text Tab bar, 추천 content container 구조를 기준으로 랭킹 content container 추가를 검토한다. --- ## 12. Open Questions - `coverImageUrl=null`일 때 사용할 정확한 placeholder asset이 별도로 지정되어 있는지 확인이 필요하다. 지정이 없으면 기존 이미지 로딩 정책을 따른다. --- ## 13. Verification Log - 2026-06-25: Figma `24:5659`의 rank component screenshot과 콘텐츠 랭킹 위젯 구현을 함께 확인했다. 콘텐츠 랭킹도 크리에이터 랭킹과 동일하게 순위 `TextView`와 rank-num을 별도 view로 표시하며, `includeFontPadding=false` 적용 상태에서도 Pattaya glyph가 고정 rank box 하단에 붙어 보이면 rank-num과 시각적으로 붙는 문제가 발생할 수 있음을 확인했다. - 2026-06-25: 후속 요구사항으로 콘텐츠 랭킹 카드의 기존 Figma 위치값은 유지하고, 순위 숫자 `TextView` 내부 하단 padding만 보정해 폰트별 glyph 하단 차이가 rank-num 간격을 침범하지 않도록 한다. - 2026-06-25: 콘텐츠 랭킹 Large/Grid/Horizontal 카드에 scale 기반 하단 padding을 적용하고, `ContentRankingCardViewTest`에서 `includeFontPadding=false`와 padding 값을 검증하도록 했다. `ContentRankingCardViewTest`, `contentranking.*`, `mergeDebugResources`, `compileDebugKotlin`은 통과했고, `ktlintCheck`는 기존 전역 위반으로 실패했다. - 2026-06-25: 리뷰 게이트 후속으로 콘텐츠 rank `TextView`의 중앙 정렬과 horizontal 고정 rank box 누락을 보완했다. 콘텐츠 rank XML 3종에 `android:gravity="center"`를 추가하고, horizontal rank `TextView`를 `48x52` 고정 박스로 배치해 bottom padding이 rank-num 위치를 밀지 않도록 했다. - 2026-06-25: 후속 요구사항으로 콘텐츠 랭킹 카드 간 간격을 4dp에서 8dp로 변경하고, Figma `567:17824`처럼 1위 대형 카드의 `coverImageUrl`은 blur 배경으로만 표시하며 별도 전경 1:1 이미지는 숨기도록 범위를 갱신했다. - 2026-06-25: 사용자 최신 정정에 따라 Phase 8의 '전경 1:1 이미지 숨김' 요구를 supersede했다. 1위 대형 카드의 배경 blur는 유지하되, 동일 `coverImageUrl` 전경 1:1 이미지는 계속 표시하고 접근 가능한 item에서는 전경 이미지를 blur 처리하지 않는다. 기존 8dp 카드 간격 요구는 유지한다.