feat(ai-character): 공통 오디오 플레이어 UI 개선

This commit is contained in:
Yu Sung
2026-08-03 17:02:28 +09:00
parent 344674c756
commit a213479e8e
5 changed files with 179 additions and 23 deletions

View File

@@ -15,6 +15,7 @@
| 재작성일 | 2026-07-26 |
| 요구사항 기준 | [prd.md](./prd.md) |
| API 기준 | [api-contract.openapi.json](./api-contract.openapi.json) |
| 현재 활성 Goal | 없음 (`P4-R9` 완료) |
## 목표
@@ -2438,6 +2439,130 @@ field와 수정 금지 control이 실제 network 요청·화면에 일치하는
- REFACTOR/회귀: 기존 success/progress/415/malformed response/cancel retry contract를 유지했다. reviewer blocker로 fetch-first 순서 회귀를 추가한 뒤 `auth` 주입 시 storage fallback을 쓰지 않도록 보완했다. `npm run test:run -- src/shared/api/__tests__/client-auth.test.ts src/features/audio-contents/tests/audio-upload.test.ts src/features/audio-contents/tests/audio-upload-auth-lifecycle.test.ts` — 3 files / 22 tests passed. `npm run test:run -- src/features/audio-contents` — 9 files / 58 tests passed. `npm run test:run -- src/app src/features/auth src/shared/api src/features/audio-contents/tests/audio-upload.test.ts` — 13 files / 91 tests passed. `npm run test:run` — 79 files / 397 tests passed. `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod`, targeted `git diff --check` — 모두 exit 0. build는 기존 503.04kB chunk warning만 표시했다. LSP diagnostics는 변경 파일·디렉터리 기준 오류 0건이었다.
- E2E/수동 확인: 사용자 지시에 따라 개발 중 E2E는 반복 실행하지 않고 모든 Task 구현 뒤 필요 시 수행한다.
### Task R4.9 — 공통 오디오 플레이어 compact UI
**Goal 실행 `P4-R9`:** 공통 `AdminAudioPlayer`를 image·video 영역 없는 compact audio-only UI로 개선해 오디오 콘텐츠 목록·상세와 커뮤니티 목록·Sheet에 동일하게 적용한다.
- **Reference packet:** `.omo/evidence/p4-r9-reference/plyr-audio-{375,768,1280}.png`, `.omo/evidence/p4-r9-reference/media-chrome-audio-{375,768,1280}.png`. Plyr의 밝은 52px compact bar를 primary surface로, Media Chrome의 시간·배속·음량 control anatomy를 secondary structure로 사용한다.
- **시작 조건:** PRD `AUDIO-021`, `AUDIO-026`, `AUDIO-034`와 기존 `AdminAudioPlayer`·`AudioPlaybackProvider` 단일 재생 계약.
- **완료 증거:** 아래 RED/GREEN/REFACTOR 체크박스, focused·전체 unit·mock E2E·typecheck·lint·build 통과, Chromium/mobile Chrome 실제 화면 screenshot과 visual QA 판정, QA resource teardown 기록.
- **범위 밖:** 새 audio player dependency, waveform, playlist, download control, cover/poster/video viewport, API·DTO·signed URL lifecycle 변경.
**Files:**
- Modify: `src/shared/ui/admin-audio-player.tsx`
- Modify: `src/shared/ui/__tests__/admin-audio-player.test.tsx`
- Verify unchanged: `src/features/audio-contents/components/AudioContentList.tsx`
- Verify unchanged: `src/features/audio-contents/components/AudioContentListItem.tsx`
- Verify unchanged: `src/features/audio-contents/pages/AudioContentDetailPage.tsx`
- Verify unchanged: `src/features/community-posts/components/CommunityPostList.tsx`
- Verify unchanged: `src/features/community-posts/components/CommunityPostListItem.tsx`
- Verify unchanged: `src/features/community-posts/components/CommunityPostSheet.tsx`
- Test: `src/features/audio-contents/tests/audio-list.test.tsx`
- Test: `src/features/audio-contents/tests/audio-player.test.tsx`
- Test: `src/features/community-posts/tests/community-list.test.tsx`
- Test: `src/features/community-posts/tests/community-sheet.test.tsx`
- E2E: `tests/e2e/audio-content.spec.ts`
- E2E: `tests/e2e/community.spec.ts`
**Interfaces:**
- Consumes: `AdminAudioPlayerProps { playerId, src, title }`, `useAudioPlayback`, native `HTMLAudioElement` events.
- Produces: 기존 props와 재생 동작을 바꾸지 않는 compact audio-only control; `재생 진행``재생 설정` accessible group.
**시나리오 계약:**
| 시나리오 | 이진 통과 조건 | 실제 surface | 자동 test |
|---|---|---|---|
| Happy path | 오디오 목록에서 재생 버튼을 누르면 버튼 이름이 `일시정지`로 바뀌고, 상시 설명 label 없는 재생·진행 slider·현재/전체 시간·배속·음량 control이 표준 viewport에서 Plyr/Media Chrome처럼 한 줄에 표시된다. | mock Chromium `/ai-characters/101/audio-contents`에서 `page.click({ name: "재생" })` 후 reference-fidelity screenshot | `src/shared/ui/__tests__/admin-audio-player.test.tsx``AdminAudioPlayer matches the compact reference control anatomy` |
| Edge | 320px viewport와 200% zoom에서 player의 `scrollWidth <= clientWidth`이고 모든 control이 viewport 안에 있다. | mock mobile Chrome 목록·상세 screenshot과 overflow probe | `tests/e2e/audio-content.spec.ts`의 기존 320px·200% zoom player 시나리오 |
| Adjacent regression | 커뮤니티 목록과 Sheet가 같은 player 구조를 사용하고 두 player 중 새 항목 재생 시 기존 항목이 pause되며 오류·수동 재시도 계약이 유지된다. | mock Chromium `/ai-characters/101/community-posts` 목록·Sheet 재생과 screenshot | `src/shared/ui/__tests__/admin-audio-player.test.tsx`, `src/features/community-posts/tests/community-sheet.test.tsx`, `tests/e2e/community.spec.ts` |
**TDD 절차:**
- [x] **RED: 실패 테스트 작성/실패 확인**`src/shared/ui/__tests__/admin-audio-player.test.tsx`에 다음 reference-anatomy test를 작성한다.
production code를 수정하기 전에 mock Chromium의 오디오 콘텐츠 목록·상세와 커뮤니티 목록·Sheet 현재 화면을 reference screenshot으로 저장한다.
```tsx
test("AdminAudioPlayer matches the compact reference control anatomy", () => {
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/audio.m4a" title="샘플 오디오" />);
const player = screen.getByRole("group", { name: "샘플 오디오 오디오 플레이어" });
const controls = within(player).getByRole("group", { name: "재생 제어" });
expect(within(controls).getByRole("button", { name: "재생" })).toBeInTheDocument();
expect(within(controls).getByRole("slider", { name: "재생 위치" })).toBeInTheDocument();
expect(within(controls).getByRole("combobox", { name: "재생 속도" })).toBeInTheDocument();
expect(within(controls).getByRole("slider", { name: "볼륨" })).toBeInTheDocument();
expect(within(player).queryByText("볼륨")).not.toBeInTheDocument();
expect(within(player).queryByText("재생 속도")).not.toBeInTheDocument();
expect(player.querySelector("img")).not.toBeInTheDocument();
expect(player.querySelector("video")).not.toBeInTheDocument();
});
```
`npm run test:run -- src/shared/ui/__tests__/admin-audio-player.test.tsx -t "compact reference control anatomy"` 실행 시 `재생 제어` group을 찾지 못해 실패해야 한다.
- [x] **GREEN: 최소 구현/통과 확인** — `src/shared/ui/admin-audio-player.tsx`의 기존 state·handler·native `<audio>`는 유지하고 반환 JSX만 다음 구조로 교체한다.
```tsx
<section aria-label={`${title} 오디오 플레이어`} className="flex min-w-0 flex-col gap-2 rounded-lg border border-border bg-card px-1 py-1 sm:px-3" onKeyDown={handleKeyDown} role="group" tabIndex={0}>
<audio controlsList="nodownload" onDurationChange={(event) => setDuration(event.currentTarget.duration)} onEnded={() => { setIsPlaying(false); clearPlayer(); }} onError={() => { setHasError(true); setIsPlaying(false); }} onPause={() => setIsPlaying(false)} onPlay={() => setIsPlaying(true)} onTimeUpdate={(event) => setCurrentTime(event.currentTarget.currentTime)} preload="metadata" ref={audioRef} src={src} />
<div aria-label="재생 제어" className="flex min-w-0 flex-wrap items-center gap-1 sm:gap-2" role="group">
<button aria-label={isPlaying ? "일시정지" : "재생"} className="grid size-11 shrink-0 place-items-center rounded-full border border-input bg-primary text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={togglePlay} type="button">
<svg aria-hidden="true" className="size-5" fill="currentColor" viewBox="0 0 24 24">
{isPlaying ? <path d="M7 5h4v14H7zm6 0h4v14h-4z" /> : <path d="m8 5 11 7-11 7z" />}
</svg>
</button>
<input aria-label="재생 위치" className="h-11 min-w-11 flex-1 accent-primary" max={duration || 0} min="0" onChange={(event) => changeCurrentTime(Number(event.currentTarget.value))} step="1" type="range" value={currentTime} />
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{formatTime(currentTime)}/{formatTime(duration)}</span>
<select aria-label="재생 속도" className="min-h-11 w-11 shrink-0 rounded-md border border-input bg-card px-1 text-center text-base font-semibold sm:text-sm" defaultValue="1" onChange={(event) => changePlaybackRate(Number(event.currentTarget.value))}>
<option value="0.75">0.75×</option><option value="1">1×</option><option value="1.25">1.25×</option><option value="1.5">1.5×</option><option value="2">2×</option>
</select>
<svg aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" fill="currentColor" viewBox="0 0 24 24"><path d="M4 9v6h4l5 4V5L8 9zm11.5 3a3.5 3.5 0 0 0-1.5-2.87v5.74A3.5 3.5 0 0 0 15.5 12Zm-1.5-7.46v2.06a6 6 0 0 1 0 10.8v2.06a8 8 0 0 0 0-14.92Z" /></svg>
<input aria-label="볼륨" className="h-11 min-w-11 basis-11 shrink grow-0 accent-primary" defaultValue="1" max="1" min="0" onChange={(event) => changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" />
</div>
{hasError ? <div className="flex min-w-0 flex-col gap-2 rounded-md border border-destructive bg-card p-3 text-sm text-destructive" role="alert"><p className="break-words">오디오를 재생할 수 없습니다. 페이지 새로고침 후 다시 시도하세요.</p><button className="rounded-md border border-input bg-card px-3 py-2 font-semibold hover:bg-accent" onClick={retry} type="button">오디오 다시 시도</button></div> : null}
</section>
```
같은 focused 명령이 `exit 0`이고 신규 test가 통과해야 한다.
- [x] **REFACTOR: 정리/회귀 확인** — event handler나 player context를 추출하지 않고 JSX의 반복 class와 접근성 이름만 점검한다. `src/shared/ui/admin-audio-player.tsx`를 250 pure LOC 이하로 유지하고 기존 signed URL 비기록, 단일 재생, keyboard, seek, volume, speed, 오류·수동 재시도 test를 모두 통과시킨다.
**검증 기준:**
- **실행 명령:**
```bash
npm run test:run -- src/shared/ui/__tests__/admin-audio-player.test.tsx src/features/audio-contents/tests/audio-list.test.tsx src/features/audio-contents/tests/audio-player.test.tsx src/features/community-posts/tests/community-list.test.tsx src/features/community-posts/tests/community-sheet.test.tsx
npm run test:run
npm run e2e:mock -- tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts
npm run typecheck
npm run lint
npm run build:dev
npm run build:prod
git diff --check -- src/shared/ui/admin-audio-player.tsx src/shared/ui/__tests__/admin-audio-player.test.tsx docs/20260725_AI캐릭터관리자웹/prd.md docs/20260725_AI캐릭터관리자웹/plan-task.md
```
- **기대 결과:** 모든 명령 `exit 0`; 신규 compact group test와 기존 전체 unit·Chromium/mobile Chrome mock E2E 통과; 변경 TSX LSP diagnostics 오류 0건; 다운로드 control·image·video·waveform 0개.
- **수동 확인:** `npm run dev:mock`을 실행하고 Playwright로 오디오 콘텐츠 목록·상세, 커뮤니티 목록·Sheet를 desktop 1280px와 mobile 320px에서 연다. 재생, seek, 볼륨, 배속, 단일 재생, 오류·재시도를 조작하고 각 surface screenshot을 저장한다. 200% zoom에서 clipping·overflow를 검사하고 `visual-qa`의 pixel diff 및 디자인 시스템/기능 무결성·시각 충실도/CJK 판정을 모두 통과시킨다.
- **QA teardown:** Playwright browser/context를 닫고 mock dev server PID를 종료한 뒤 8889 port listener가 없음을 확인해 Progress에 기록한다.
- [x] RED·GREEN·REFACTOR, browser artifact 경로, visual QA 판정, teardown 결과를 이 Task 하단에 누적한다.
**전체 중단 조건:** `P4-R9`의 세 시나리오가 RED→GREEN 및 실제 Chromium/mobile Chrome surface에서 통과하고, 전체 unit·mock E2E·typecheck·lint·build가 green이며 LSP 오류와 QA resource가 0개일 때 즉시 종료한다.
**P4-R9 구현·검증 기록 — 2026-08-03:**
- **RED:** 첫 reference-anatomy test는 `재생 제어` group 부재로 `1 failed / 5 skipped`였다. 후속 폭·접근성 RED는 volume의 `hidden` class, seek/rate/volume 최소 폭·font·basis, 표준 viewport 단일 행 폭 예산 assertion이 각각 기존 구현에서 실패했다.
- **GREEN:** native `<audio>`와 기존 상태·handler는 유지하고 Plyr의 밝은 compact surface와 Media Chrome의 명시적 time/rate/volume anatomy를 semantic token 기반 live DOM으로 구현했다. 최종 focused player test는 `1 passed / 5 skipped`, player 전체는 `6 passed`였다.
- **REFACTOR:** 새 dependency·consumer 변경·상태 abstraction 없이 class와 accessible name만 정리했다. `AdminAudioPlayer`는 250 pure LOC 이하이며 변경 TSX 2개 LSP diagnostics는 오류 0건이다.
- **자동 검증:** focused `5 files / 19 tests`, 전체 `81 files / 426 tests`, `npm run typecheck`, `npm run lint`, `npm run build:dev`, `npm run build:prod`, `git diff --check`가 모두 exit 0이었다. 두 build에는 기존 500kB chunk warning만 남았다.
- **브라우저 V3:** `npm run e2e:mock -- tests/e2e/audio-content.spec.ts tests/e2e/community.spec.ts`는 Chromium/mobile Chrome에서 `25 passed / 1 intentional skip / 0 failed`였다. `.omo/evidence/p4-r9-v3/`의 player 16개·context 16개·상태 3개 fresh PNG에서 375/768/1280의 12개 표준 조합은 모두 5455px 단일 행, 320px/200% 4개 조합은 모든 control을 유지한 채 overflow 없이 wrap했다.
- **기능·시각 판정:** play/pause, seek, rate 1.5, volume 0.5, 단일 active track, 일반 오류·수동 retry와 API refetch delta 0을 확인했다. 독립 design-system/functional 및 visual/CJK reviewer가 같은 V3 세트를 각각 PASS로 판정했고 blocker는 없다.
- **Teardown:** Playwright browser는 종료됐다. QA-owned server는 없었고, QA 이전부터 실행 중인 8889의 PID 4837/4875는 사용자 프로세스로 보존했다.
---
## Phase 5. Series vertical slice
@@ -4478,7 +4603,7 @@ critical·serious 0건과 mock/server 상태 분리를 확인한다. E2E용 ADMI
| 1 | `AUTH-001~013`, `UX-001~002`, `FILE`의 domain-neutral component mechanics, §7, §10 공통, §11.1~11.2, §12 | `src/shared`, `src/features/auth`, `tests/e2e/auth.spec.ts` |
| 2 | `MOCK-001~009`, §11.1~11.2, §12~13 | `src/shared/mocks`, `tests/e2e/mock-preview-shell.spec.ts` |
| 3 | `CHAR-001~018`, Character 관련 `FILE`, `MOCK`, §7, §9 | `src/features/characters`, `tests/e2e/character-workspace.spec.ts` |
| 4 | `AUDIO-001~033`, Audio 관련 `FILE`, `MOCK`, §9 | `src/features/audio-contents`, `tests/e2e/audio-content.spec.ts` |
| 4 | `AUDIO-001~034`, Audio 관련 `FILE`, `MOCK`, §9 | `src/features/audio-contents`, `tests/e2e/audio-content.spec.ts` |
| 5 | `SERIES-001~018`, Series 관련 `FILE`, `MOCK`, §9 | `src/features/series`, `tests/e2e/series.spec.ts` |
| 6 | `COMMUNITY-001~015`, Community 관련 `FILE`, `MOCK`, §9 | `src/features/community-posts`, `tests/e2e/community.spec.ts` |
| 7 | `FANTALK-001~011`, `MOCK`, §9 | `src/features/fan-talks`, `tests/e2e/fan-talk.spec.ts` |
@@ -4494,7 +4619,7 @@ critical·serious 0건과 mock/server 상태 분리를 확인한다. E2E용 ADMI
|---|---|---|
| `AUTH-001~013`, PRD `14.1` 로그인·세션·로그아웃 | Phase 1 Gate, `tests/e2e/auth.spec.ts`, P9 server-boundary 재검증 | 활성 범위 검증 완료 |
| `CHAR-001~018`와 Character image/file 기준 | Phase 3 Gate, `tests/e2e/character-workspace.spec.ts`, P9 resource/error regression, P10-T1 | 기존 mock UI 완료. `EXT-001`, `EXT-007` 해결; v2 원작 선택기는 P10-T1에서 완료, active-only server 검증은 P10-GATE에서 분리 |
| `AUDIO-001~033`와 Audio file/player 기준 | Phase 4 focused/mock 기록, `tests/e2e/audio-content.spec.ts`, P9 resource/error regression, P10-T2 | 기존 mock UI와 UTC 전송·`timezone` 제거 client/mock 완료. 실제 개발 API Audio 수동 QA는 별도 대기 |
| `AUDIO-001~034`와 Audio file/player 기준 | Phase 4 focused/mock 기록, `tests/e2e/audio-content.spec.ts`, P9 resource/error regression, P10-T2, `P4-R9` | 기존 mock UI와 UTC 전송·`timezone` 제거 client/mock 완료. compact audio-only 공용 player UI는 `P4-R9`에서 구현·V3 시각 QA 완료했고 실제 개발 API Audio 수동 QA는 별도 대기 |
| `SERIES-001~018` | Phase 5 focused/mock 기록, `tests/e2e/series.spec.ts`, P9 resource/error regression, P10-T3 | 연결·순서와 v2 장르·CRUD mock/client 완료. server Series E2E는 로그인 fixture 차단으로 P10-GATE에서 분리 |
| `COMMUNITY-001~015` | Phase 6 focused/mock 기록, `tests/e2e/community.spec.ts`, P9 resource/error regression, P10-T4 | 기존 mock UI와 pagination object·`timezone` 제거 client/mock 완료. 실제 개발 API Community 수동 QA는 별도 대기 |
| `FANTALK-001~012` | Phase 7 focused/mock 기록, `tests/e2e/fan-talk.spec.ts`, P9 resource/error regression, P10-T5 | 목록·답변 생성·답변 수정·팬 원글 삭제 mock/client 완료. 실제 개발 API FanTalk 수동 QA는 별도 대기 |

View File

@@ -249,12 +249,18 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘
| AUDIO-031 | 확정 | 생성 성공은 `data.contentId`를 사용해 상세로 이동할 수 있다. 수정·soft delete 성공은 `data=null`이므로 기존 ID 기준 cache를 무효화한다. |
| AUDIO-032 | 확정 | 생성 `request`는 필수 `title`, `detail`, `tags`, `price`와 OpenAPI의 optional field만 보낸다. 수정은 `title`, `detail`, `tags`, `price`, `isAdult`, `isActive`, `isPointAvailable`, `isCommentAvailable`만 변경할 수 있다. |
| AUDIO-033 | 확정 | 생성 form은 OpenAPI의 `purchaseOption`, `limited`, `isAdult`, `isGeneratePreview`, `isOnlyRental`, `isPointAvailable`, `isCommentAvailable`, `isFullDetailVisible`, `previewStartTime`, `previewEndTime`, `languageCode`를 계약 enum·type과 default에 맞춰 제공한다. 계약에 없는 추가 상관관계 validation은 만들지 않는다. |
| AUDIO-034 | 확정 | 공통 관리자 오디오 플레이어는 오디오 콘텐츠 목록·상세와 커뮤니티 목록·Sheet에 동일한 compact audio-only control bar를 사용한다. Plyr audio player를 1차 시각 기준, Media Chrome audio player를 control anatomy 기준으로 삼아 표준 viewport에서는 재생·진행·현재/전체 시간·배속·음량을 상시 텍스트 label 없이 한 줄에 표시한다. 별도 image·video 영역을 만들지 않고 기존 화면의 cover·게시물 media는 그대로 유지한다. |
현 수정 계약에는 `releaseDate`, `themeId`, `contentFile`이 없다. 따라서 공개 예약·테마·오디오 원본 변경은 생성 화면에서만 제공하고 수정 화면에서는 읽기 전용으로 표시한다.
#### 관리자 오디오 플레이어
- 재생/일시정지, 탐색, 현재/전체 시간, 볼륨, 배속을 제공한다.
- native `<audio>`와 현재 재생 상태·오류 처리 로직은 유지하고, [Plyr audio](https://plyr.io/#audio)의 밝은 compact bar를 1차 시각 기준, [Media Chrome audio](https://www.media-chrome.org/docs/en/audio-player)의 명시적 시간·배속 anatomy를 보조 기준으로 사용한다.
- 표준 viewport의 기본 배치는 원형 재생 버튼, 유동형 재생 위치 slider, 현재/전체 시간, compact 배속 select, 음량 icon과 slider 순서의 단일 control bar다. `볼륨`, `재생 속도` 같은 설명 label은 화면에 상시 노출하지 않고 accessible name으로 제공한다.
- 200% zoom처럼 실제 player 폭이 control 최소 폭보다 작을 때만 secondary control을 다음 줄로 보내며, control이 잘리거나 가로 overflow가 생기지 않아야 한다.
- 플레이어 내부에는 cover image, poster, video viewport를 표시하지 않는다. 오디오 콘텐츠 cover와 게시물 media는 기존 화면 영역에서만 표시한다.
- 새 audio player library, waveform, playlist와 download control은 추가하지 않는다.
- 명시적인 다운로드 버튼은 제공하지 않는다.
- 여러 행의 플레이어가 동시에 재생되지 않게 현재 재생 항목을 단일화한다.
- 재생 오류는 원인을 signed URL 만료로 구분하지 않고 “오디오를 재생할 수 없습니다”와 수동 재시도·페이지 새로고침 안내를 표시한다.
@@ -866,6 +872,7 @@ vertical slice와 남은 수동 QA에서 검증한다. `EXT-006`은 현재 확
- `prefers-reduced-motion`에서 불필요한 transition이 제거된다.
- 모든 핵심 route의 axe 기반 자동 검사에서 critical·serious 접근성 위반이 0건이다.
- `ui-ux-pro-max`의 loading, reduced motion, z-index, touch 검증 항목을 확인한다.
- 공통 오디오 플레이어가 오디오 콘텐츠 목록·상세와 커뮤니티 목록·Sheet에서 동일한 compact audio-only UI로 표시되고, 320px와 200% zoom에서도 핵심 control이 가려지거나 가로 overflow를 만들지 않는다.
## 15. Open Questions와 결정 절차
@@ -928,3 +935,4 @@ vertical slice와 남은 수동 QA에서 검증한다. `EXT-006`은 현재 확
| 2026-07-29 | `EXT-009`의 가격 범위 `0..99999`를 Audio 생성·수정과 Community 생성 OpenAPI request schema, 요구사항, Phase 10 경계 test에 동일하게 적용한다. |
| 2026-07-29 | 현재 FanTalk UI는 목록 item 기반 Sheet와 backend 반환 순서만 사용하므로 별도 상세 GET·답변 상태 filter·sort query가 필요하지 않다고 확정했다. 중복 생성 전용 오류 key 분기도 제외하고 일반 오류 후 목록을 재조회한다. 답변 1개 불변식은 `FANTALK-003`의 backend 수용 기준으로 server integration에서 검증하므로 `EXT-004`를 해결로 종결한다. |
| 2026-07-31 | 사용자 직접 지시에 따라 로컬 자동 Gate와 지원 browser 범위는 데스크톱 Chrome·모바일 Chrome의 Chrome 2종으로 한정한다. Playwright는 Chromium/mobile Chrome project만 유지하고, 현재 제품 지원 대상이 아닌 WebKit·Mobile Safari 자동 실행과 수동 QA는 테스트 시간을 크게 늘리므로 현재 릴리스 범위에서 제외한다. |
| 2026-08-03 | 공통 `AdminAudioPlayer`의 native media 동작과 단일 재생·오류 계약은 유지하고, Plyr·Media Chrome의 audio-only control 배치를 참고한 compact 가로형 UI로 개선한다. 플레이어 내부 image·video 영역과 새 외부 라이브러리·waveform은 추가하지 않으며 오디오 콘텐츠 목록·상세와 커뮤니티 목록·Sheet에 동일하게 적용한다. |