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

@@ -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 { 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();
});
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", () => {
render(
<AudioPlaybackProvider>

View File

@@ -87,7 +87,7 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps
}
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
controlsList="nodownload"
onDurationChange={(event) => setDuration(event.currentTarget.duration)}
@@ -106,30 +106,26 @@ export function AdminAudioPlayer({ playerId, src, title }: AdminAudioPlayerProps
ref={audioRef}
src={src}
/>
<div className="flex flex-wrap items-center gap-2">
<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">
{isPlaying ? "일시정지" : "재생"}
<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)] 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">
<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>
<span className="text-sm text-muted-foreground">{formatTime(currentTime)} / {formatTime(duration)}</span>
</div>
<label className="flex flex-col gap-2 text-sm font-semibold">
<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">
<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>
</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 ? (
<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>