feat(ai-character): 공통 오디오 플레이어 UI 개선
This commit is contained in:
@@ -175,9 +175,12 @@ Primary font stack: `Pretendard`, `Noto Sans KR`, `Apple SD Gothic Neo`, `system
|
|||||||
|
|
||||||
### AdminAudioPlayer
|
### AdminAudioPlayer
|
||||||
|
|
||||||
- Structure: native audio element wrapped with play/pause, seek, time, volume, speed, generic error, and manual retry controls.
|
- Reference: [Plyr audio](https://plyr.io/#audio) is the primary surface reference; [Media Chrome audio](https://www.media-chrome.org/docs/en/audio-player) supplies the explicit current/duration and playback-rate anatomy. Runtime extraction on 2026-08-03 found 44–52px single-row bars, icon-only media controls, a flexible progress range, compact time/rate values, and no persistent descriptive labels.
|
||||||
|
- Structure: native audio element wrapped by one compact control bar ordered play/pause → flexible seek → current/duration → playback rate → volume icon/range, followed by the existing generic error and manual retry block. No image, poster, video viewport, waveform, or settings menu is rendered.
|
||||||
|
- Surface: `card`, `border`, `input`, `primary`, `primary-foreground`, and existing radius/spacing tokens only. The standard bar is approximately one 44px control high with 4–12px token spacing; remaining inline space goes to seek before volume.
|
||||||
- Variants: shared signed-URL player only; it never downloads, autoplays, auto-refetches, or infers signed URL expiry.
|
- Variants: shared signed-URL player only; it never downloads, autoplays, auto-refetches, or infers signed URL expiry.
|
||||||
- Accessibility: player region is named by title, keyboard Space/Enter toggles play, seek/volume use range inputs, speed uses native select.
|
- Responsive: 375px, 768px, and 1280px standard viewports keep a single visual bar. Only constrained containers at 200% zoom may wrap secondary controls; clipping and horizontal overflow are forbidden.
|
||||||
|
- Accessibility: player region is named by title, keyboard Space/Enter toggles play, seek/volume use range inputs, speed uses native select, and icon-only controls retain accessible names. Visible `볼륨` and `재생 속도` labels are omitted to match the compact reference while labels remain available to assistive technology.
|
||||||
- Motion: none.
|
- Motion: none.
|
||||||
|
|
||||||
### AudioPlaybackProvider
|
### AudioPlaybackProvider
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
| 재작성일 | 2026-07-26 |
|
| 재작성일 | 2026-07-26 |
|
||||||
| 요구사항 기준 | [prd.md](./prd.md) |
|
| 요구사항 기준 | [prd.md](./prd.md) |
|
||||||
| API 기준 | [api-contract.openapi.json](./api-contract.openapi.json) |
|
| 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건이었다.
|
- 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 구현 뒤 필요 시 수행한다.
|
- 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개 표준 조합은 모두 54–55px 단일 행, 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
|
## 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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 재검증 | 활성 범위 검증 완료 |
|
| `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에서 분리 |
|
| `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에서 분리 |
|
| `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는 별도 대기 |
|
| `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는 별도 대기 |
|
| `FANTALK-001~012` | Phase 7 focused/mock 기록, `tests/e2e/fan-talk.spec.ts`, P9 resource/error regression, P10-T5 | 목록·답변 생성·답변 수정·팬 원글 삭제 mock/client 완료. 실제 개발 API FanTalk 수동 QA는 별도 대기 |
|
||||||
|
|||||||
@@ -249,12 +249,18 @@ AI 캐릭터를 생성하고, AI 캐릭터가 사람 크리에이터처럼 콘
|
|||||||
| AUDIO-031 | 확정 | 생성 성공은 `data.contentId`를 사용해 상세로 이동할 수 있다. 수정·soft delete 성공은 `data=null`이므로 기존 ID 기준 cache를 무효화한다. |
|
| 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-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-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`이 없다. 따라서 공개 예약·테마·오디오 원본 변경은 생성 화면에서만 제공하고 수정 화면에서는 읽기 전용으로 표시한다.
|
현 수정 계약에는 `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 만료로 구분하지 않고 “오디오를 재생할 수 없습니다”와 수동 재시도·페이지 새로고침 안내를 표시한다.
|
- 재생 오류는 원인을 signed URL 만료로 구분하지 않고 “오디오를 재생할 수 없습니다”와 수동 재시도·페이지 새로고침 안내를 표시한다.
|
||||||
@@ -866,6 +872,7 @@ vertical slice와 남은 수동 QA에서 검증한다. `EXT-006`은 현재 확
|
|||||||
- `prefers-reduced-motion`에서 불필요한 transition이 제거된다.
|
- `prefers-reduced-motion`에서 불필요한 transition이 제거된다.
|
||||||
- 모든 핵심 route의 axe 기반 자동 검사에서 critical·serious 접근성 위반이 0건이다.
|
- 모든 핵심 route의 axe 기반 자동 검사에서 critical·serious 접근성 위반이 0건이다.
|
||||||
- `ui-ux-pro-max`의 loading, reduced motion, z-index, touch 검증 항목을 확인한다.
|
- `ui-ux-pro-max`의 loading, reduced motion, z-index, touch 검증 항목을 확인한다.
|
||||||
|
- 공통 오디오 플레이어가 오디오 콘텐츠 목록·상세와 커뮤니티 목록·Sheet에서 동일한 compact audio-only UI로 표시되고, 320px와 200% zoom에서도 핵심 control이 가려지거나 가로 overflow를 만들지 않는다.
|
||||||
|
|
||||||
## 15. Open Questions와 결정 절차
|
## 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 | `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-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-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에 동일하게 적용한다. |
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player";
|
import { AdminAudioPlayer } from "@/shared/ui/admin-audio-player";
|
||||||
@@ -33,6 +33,30 @@ test("AdminAudioPlayer wraps native audio with controls and no download or autop
|
|||||||
expect(screen.getByRole("combobox", { name: "재생 속도" })).toBeInTheDocument();
|
expect(screen.getByRole("combobox", { name: "재생 속도" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("AdminAudioPlayer matches the compact reference control anatomy", () => {
|
||||||
|
render(<AdminAudioPlayer playerId="one" src="https://cdn.example.com/signed/audio.m4a?token=secret" title="샘플 오디오" />);
|
||||||
|
|
||||||
|
const player = screen.getByRole("group", { name: "샘플 오디오 오디오 플레이어" });
|
||||||
|
const controls = within(player).getByRole("group", { name: "재생 제어" });
|
||||||
|
expect(within(controls).getByRole("button", { name: "재생" })).toBeInTheDocument();
|
||||||
|
const seek = within(controls).getByRole("slider", { name: "재생 위치" });
|
||||||
|
const speed = within(controls).getByRole("combobox", { name: "재생 속도" });
|
||||||
|
const volume = within(controls).getByRole("slider", { name: "볼륨" });
|
||||||
|
expect.soft(player).toHaveClass("px-1", "sm:px-3");
|
||||||
|
expect(seek).toHaveClass("min-w-11");
|
||||||
|
expect.soft(speed).toHaveClass("w-11");
|
||||||
|
expect(speed).toHaveClass("text-base");
|
||||||
|
expect.soft(volume).toHaveClass("basis-11");
|
||||||
|
expect(volume).toHaveClass("min-w-11");
|
||||||
|
expect(volume).toBeInTheDocument();
|
||||||
|
expect(volume.className).not.toContain("hidden");
|
||||||
|
expect.soft(within(controls).queryByText("0:00/0:00")).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();
|
||||||
|
});
|
||||||
|
|
||||||
test("AudioPlaybackProvider keeps only one player active by player id", () => {
|
test("AudioPlaybackProvider keeps only one player active by player id", () => {
|
||||||
render(
|
render(
|
||||||
<AudioPlaybackProvider>
|
<AudioPlaybackProvider>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label={`${title} 오디오 플레이어`} className="flex min-w-0 flex-col gap-3 rounded-lg border border-border bg-card p-4" onKeyDown={handleKeyDown} role="group" tabIndex={0}>
|
<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
|
<audio
|
||||||
controlsList="nodownload"
|
controlsList="nodownload"
|
||||||
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
|
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
|
||||||
@@ -106,30 +106,26 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps
|
|||||||
ref={audioRef}
|
ref={audioRef}
|
||||||
src={src}
|
src={src}
|
||||||
/>
|
/>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div aria-label="재생 제어" className="flex min-w-0 flex-wrap items-center gap-1 sm:gap-2" role="group">
|
||||||
<button className="rounded-md border border-input bg-primary px-4 py-2 font-semibold text-primary-foreground hover:bg-[var(--button-bg-hover)] active:bg-[var(--button-bg-active)]" onClick={togglePlay} type="button">
|
<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)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 active:bg-[var(--button-bg-active)]" onClick={togglePlay} type="button">
|
||||||
{isPlaying ? "일시정지" : "재생"}
|
<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>
|
</button>
|
||||||
<span className="text-sm text-muted-foreground">{formatTime(currentTime)} / {formatTime(duration)}</span>
|
<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} />
|
||||||
</div>
|
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">{formatTime(currentTime)}/{formatTime(duration)}</span>
|
||||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
<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))}>
|
||||||
재생 위치
|
|
||||||
<input aria-label="재생 위치" max={duration || 0} min="0" onChange={(event) => changeCurrentTime(Number(event.currentTarget.value))} step="1" type="range" value={currentTime} />
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
|
||||||
볼륨
|
|
||||||
<input aria-label="볼륨" defaultValue="1" max="1" min="0" onChange={(event) => changeVolume(Number(event.currentTarget.value))} step="0.05" type="range" />
|
|
||||||
</label>
|
|
||||||
<label className="flex flex-col gap-2 text-sm font-semibold">
|
|
||||||
재생 속도
|
|
||||||
<select aria-label="재생 속도" className="rounded-md border border-input bg-card px-3 py-2 text-base" onChange={(event) => changePlaybackRate(Number(event.currentTarget.value))} defaultValue="1">
|
|
||||||
<option value="0.75">0.75×</option>
|
<option value="0.75">0.75×</option>
|
||||||
<option value="1">1×</option>
|
<option value="1">1×</option>
|
||||||
<option value="1.25">1.25×</option>
|
<option value="1.25">1.25×</option>
|
||||||
<option value="1.5">1.5×</option>
|
<option value="1.5">1.5×</option>
|
||||||
<option value="2">2×</option>
|
<option value="2">2×</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
<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 ? (
|
{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">
|
<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>
|
<p className="break-words">오디오를 재생할 수 없습니다. 페이지 새로고침 후 다시 시도하세요.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user