feat(page): 곡 검색 결과를 리스트 행으로, 대기열 접기/펴기 추가 — 익숙한 형태로 정렬
"많이 쓰는 음악 사이트처럼" 이라는 요구에 맞춰, 형태가 관행과 달랐던 두 곳을 바꿨다. 1) 검색 결과: 그리드 카드 -> 리스트 행 그리드(커버 중심)는 앨범·재생목록을 훑을 때 쓰는 형태이고, 개별 곡 검색 결과는 스포티파이·유튜브뮤직 모두 행(번호/썸네일/제목·아티스트/재생시간) 형식이다. 행 전체가 버튼이라 터치로도 바로 누를 수 있고, 마우스 환경에서는 번호 자리에 재생 아이콘이 겹치는 스포티파이 곡 목록 방식을 따랐다. 좁은 화면은 hover 가 없어 썸네일에 재생 아이콘을 항상 얹는다. 2) 대기열 접기/펴기 재생바 우측(스포티파이와 같은 자리)에 대기열 버튼을 두어 넓은 화면에서도 접을 수 있게 했다. 화면 크기별 기본값이 달라(좁은 화면=접힘, 넓은 화면=펼침) 상태를 둘로 나눴다. 창 크기를 읽어 한 상태로 합치면 SSR/하이드레이션이 어긋나고 setState-in-effect 경고도 난다. 검증: tsc 통과, lint 에러 0. 데스크톱(1440x900)·모바일(390x844)에서 검색 결과 행 렌더링과 대기열 접기/펴기 동작을 실제 화면으로 확인. README 5-3 비교표에 두 항목 추가.
This commit is contained in:
@@ -124,6 +124,8 @@ site: "bot-site" 구독 → serverId 일치하는 것만 EventSource(SSE)로 브
|
||||
|
||||
| 관행 | 예전 | 현재 |
|
||||
|------|------|------|
|
||||
| 곡 검색 결과 표시 | 그리드 카드 | 리스트 행(번호·썸네일·제목/아티스트·재생시간). 그리드는 앨범/재생목록처럼 커버가 주인공일 때 쓰는 형태이고, 개별 곡은 두 서비스 모두 행 형식이다 |
|
||||
| 대기열 접기/펴기 | 넓은 화면에서 항상 고정 | 재생바 우측 대기열 버튼으로 접고 펼 수 있음(스포티파이와 같은 위치) |
|
||||
| 좁은 화면 대응 | 3열 고정이라 모바일에서 본문이 눌려 사용 불가 | 단일 컬럼 + 대기열 슬라이드 패널 |
|
||||
| 대기열 상단에 "지금 재생 중" | 없음(다음 곡만) | 현재 곡을 강조해 표시 |
|
||||
| 대기열 곡 클릭 → 그 곡부터 재생 | 없음 | 지원(앞 곡들은 빠짐 — 스포티파이 대기열과 동일) |
|
||||
|
||||
@@ -18,8 +18,12 @@ export default function MusicPlayerLayout() {
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("SERVER_LIST");
|
||||
const [selectedServer, setSelectedServer] = useState<DiscordServer | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// 모바일에서 대기열은 상시 컬럼이 아니라 오른쪽에서 밀려나오는 패널로 띄운다.
|
||||
// 대기열 표시 상태는 화면 크기별로 기본값이 달라서 두 개로 나눠 둔다.
|
||||
// (창 크기를 읽어 한 상태로 합치면 SSR·하이드레이션이 어긋난다)
|
||||
// · 좁은 화면: 기본 접힘 → 버튼으로 슬라이드 패널을 연다
|
||||
// · 넓은 화면: 기본 펼침 → 재생바의 대기열 버튼으로 접을 수 있다
|
||||
const [isQueueOpen, setIsQueueOpen] = useState(false);
|
||||
const [isQueueDocked, setIsQueueDocked] = useState(true);
|
||||
// 재생바가 이미 폴링하는 현재 곡을 대기열 패널과 공유한다(같은 API 를 두 번 부르지 않기 위함).
|
||||
const [nowPlaying, setNowPlaying] = useState<Track | null>(null);
|
||||
const handleNowPlayingChange = useCallback((t: Track | null) => setNowPlaying(t), []);
|
||||
@@ -95,8 +99,9 @@ export default function MusicPlayerLayout() {
|
||||
<div
|
||||
className={`absolute inset-y-0 right-0 z-40 w-[22rem] max-w-[88%] transform border-l border-neutral-800 bg-neutral-950 shadow-2xl transition-transform duration-300 ease-out
|
||||
${isQueueOpen ? "translate-x-0" : "translate-x-full"}
|
||||
lg:static lg:z-auto lg:w-80 lg:max-w-none lg:translate-x-0 lg:shadow-none`}
|
||||
aria-hidden={!isQueueOpen && typeof window !== "undefined" && window.innerWidth < 1024}
|
||||
${isQueueDocked
|
||||
? "lg:static lg:z-auto lg:w-80 lg:max-w-none lg:translate-x-0 lg:shadow-none"
|
||||
: "lg:hidden"}`}
|
||||
>
|
||||
<QueueSidebar
|
||||
selectedServer={selectedServer}
|
||||
@@ -113,6 +118,8 @@ export default function MusicPlayerLayout() {
|
||||
selectedServer={selectedServer}
|
||||
onToggleQueue={() => setIsQueueOpen((v) => !v)}
|
||||
isQueueOpen={isQueueOpen}
|
||||
onToggleDock={() => setIsQueueDocked((v) => !v)}
|
||||
isQueueDocked={isQueueDocked}
|
||||
onNowPlayingChange={handleNowPlayingChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -197,34 +197,49 @@ export default function MainContent({
|
||||
|
||||
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
|
||||
|
||||
// 카드 전체가 눌리는 버튼이다. 예전에는 hover 시에만 나타나는 작은 아이콘만 눌러야 해서
|
||||
// 터치 기기(hover 없음)에서는 재생 방법을 찾기 어려웠다.
|
||||
const renderTrackCard = (track: SearchTrack) => (
|
||||
/**
|
||||
* 곡 하나는 '리스트 행'으로 보여준다.
|
||||
* 그리드 카드는 앨범/재생목록처럼 커버가 주인공일 때 쓰는 형태고,
|
||||
* 개별 곡 검색 결과는 스포티파이·유튜브뮤직 모두 행(제목·아티스트·재생시간) 형식이라
|
||||
* 사용자에게 익숙한 쪽으로 맞춘다. 행 전체가 버튼이라 터치로도 바로 누를 수 있다.
|
||||
*/
|
||||
const renderTrackRow = (track: SearchTrack, index: number) => (
|
||||
<button
|
||||
key={track.videoId || track.id || track.url || track.title}
|
||||
type="button"
|
||||
onClick={() => handleMusicAction('player_play', track)}
|
||||
aria-label={`${track.title} 재생목록에 추가`}
|
||||
className="group w-full rounded-xl border border-transparent bg-neutral-800/40 p-3 text-left shadow-md transition-all hover:border-neutral-700 hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-green-500 active:scale-[0.98]"
|
||||
className="group flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left transition-colors hover:bg-neutral-800 focus:outline-none focus:ring-2 focus:ring-green-500 active:bg-neutral-700/60"
|
||||
>
|
||||
<span className="relative mb-2 block aspect-square overflow-hidden rounded-md bg-neutral-700 shadow-lg">
|
||||
{/* 번호 자리에 hover 시 재생 아이콘이 겹치는 건 스포티파이 곡 목록과 같은 방식 */}
|
||||
<span className="hidden w-5 flex-shrink-0 text-center text-sm tabular-nums text-neutral-500 group-hover:hidden sm:block">
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="hidden w-5 flex-shrink-0 items-center justify-center text-white sm:group-hover:flex">
|
||||
<Play fill="currentColor" size={14} aria-hidden />
|
||||
</span>
|
||||
|
||||
<span className="relative h-11 w-11 flex-shrink-0 overflow-hidden rounded bg-neutral-700">
|
||||
{track.thumbnail && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={track.thumbnail} className="h-full w-full object-cover" alt="" />
|
||||
)}
|
||||
{/* 터치 기기에서는 항상 보이고, 마우스 환경에서는 hover 시 강조된다. */}
|
||||
<span className="absolute bottom-2 right-2 flex h-11 w-11 items-center justify-center rounded-full bg-green-500 shadow-xl transition-all group-hover:bg-green-400 sm:h-10 sm:w-10 sm:opacity-90 sm:group-hover:opacity-100">
|
||||
<Play fill="black" stroke="black" size={18} aria-hidden />
|
||||
{/* 좁은 화면은 hover 가 없으니 재생 아이콘을 항상 얹어 눌러야 할 곳을 알려준다. */}
|
||||
<span className="absolute inset-0 flex items-center justify-center bg-black/40 sm:hidden">
|
||||
<Play fill="white" stroke="white" size={16} aria-hidden />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-white">{track.title}</span>
|
||||
<span className="flex items-center justify-between gap-2 text-xs text-neutral-400">
|
||||
<span className="truncate">{track.artist}</span>
|
||||
{/* 재생시간 표기는 어느 음악 서비스에나 있는 정보다(같은 제목의 짧은 클립/전체 곡 구분에 필요). */}
|
||||
{track.duration ? (
|
||||
<span className="flex-shrink-0 tabular-nums text-neutral-500">{formatDuration(track.duration)}</span>
|
||||
) : null}
|
||||
<span className="block truncate text-xs text-neutral-400">{track.artist}</span>
|
||||
</span>
|
||||
|
||||
{track.duration ? (
|
||||
<span className="flex-shrink-0 text-xs tabular-nums text-neutral-500">
|
||||
{formatDuration(track.duration)}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -360,8 +375,8 @@ export default function MainContent({
|
||||
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-green-400">
|
||||
<Disc size={24} /> Spotify 결과
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
|
||||
{searchResults.spotify.map(renderTrackCard)}
|
||||
<div className="flex flex-col">
|
||||
{searchResults.spotify.map(renderTrackRow)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -372,8 +387,8 @@ export default function MainContent({
|
||||
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-red-500">
|
||||
<Music size={24} /> YouTube Music
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
|
||||
{searchResults.youtubeMusic.map(renderTrackCard)}
|
||||
<div className="flex flex-col">
|
||||
{searchResults.youtubeMusic.map(renderTrackRow)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -384,8 +399,8 @@ export default function MainContent({
|
||||
<h3 className="text-xl font-bold mb-4 flex items-center gap-2 text-white">
|
||||
<MonitorPlay size={24} className="text-red-600" /> YouTube 영상
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
|
||||
{searchResults.youtubeVideo.map(renderTrackCard)}
|
||||
<div className="flex flex-col">
|
||||
{searchResults.youtubeVideo.map(renderTrackRow)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -8,11 +8,14 @@ interface PlayerBarProps {
|
||||
selectedServer: DiscordServer | null;
|
||||
onToggleQueue?: () => void;
|
||||
isQueueOpen?: boolean;
|
||||
/** 넓은 화면에서 우측 대기열 컬럼을 접었다 펴는 토글 */
|
||||
onToggleDock?: () => void;
|
||||
isQueueDocked?: boolean;
|
||||
/** 대기열 패널에도 "지금 재생 중"을 보여주기 위해 현재 곡을 위로 올려준다(중복 폴링 방지). */
|
||||
onNowPlayingChange?: (track: Track | null) => void;
|
||||
}
|
||||
|
||||
export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen, onNowPlayingChange }: PlayerBarProps) {
|
||||
export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen, onToggleDock, isQueueDocked, onNowPlayingChange }: PlayerBarProps) {
|
||||
const { data: session } = useSession();
|
||||
|
||||
// 재생 상태 관리
|
||||
@@ -423,7 +426,7 @@ export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen,
|
||||
<SkipForward size={20} aria-hidden />
|
||||
</button>
|
||||
|
||||
{/* 모바일 전용: 대기열 토글 */}
|
||||
{/* 좁은 화면에서 대기열 열기(넓은 화면에는 우측 하단에 같은 버튼을 따로 둔다) */}
|
||||
{onToggleQueue && (
|
||||
<button
|
||||
onClick={onToggleQueue}
|
||||
@@ -469,8 +472,19 @@ export default function PlayerBar({ selectedServer, onToggleQueue, isQueueOpen,
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. 볼륨 (좁은 화면에서는 숨긴다 — 기기 자체 볼륨으로 조절 가능) */}
|
||||
{/* 3. 우측 보조 조작: 대기열 + 볼륨 (스포티파이도 이 자리에 대기열 버튼을 둔다) */}
|
||||
<div className="hidden w-1/4 items-center justify-end gap-2 text-neutral-400 sm:flex">
|
||||
{onToggleDock && (
|
||||
<button
|
||||
onClick={onToggleDock}
|
||||
aria-label={isQueueDocked ? "대기열 접기" : "대기열 펼치기"}
|
||||
title="대기열"
|
||||
className={`hidden h-10 w-10 items-center justify-center rounded-full transition-colors hover:bg-neutral-800 hover:text-white lg:flex
|
||||
${isQueueDocked ? "text-green-500" : ""}`}
|
||||
>
|
||||
<ListMusic size={20} aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleToggleMute}
|
||||
disabled={!canControl}
|
||||
|
||||
Reference in New Issue
Block a user