Files
music_bot_v2/page/src/components/player/MainContent.tsx
tkrmagid 2b7a91774d feat(page): 주요 음악 서비스 관행에 맞춰 조작 보완 + 화면·버튼 동작 문서화
화면별 사용 흐름과 버튼 동작을 전수 정리한 뒤, 스포티파이 웹 플레이어·유튜브 뮤직·애플 뮤직에서
공통으로 쓰이는 관행과 대조해 빠져 있던 것을 채웠다.

추가/변경:
- 스피커 아이콘을 장식에서 음소거 토글 버튼으로(해제 시 직전 볼륨 복원). 세 서비스 공통 동작.
- 키보드 단축키: Space 재생/일시정지, M 음소거, 좌우 5초 탐색, Ctrl/Cmd+오른쪽 다음 곡.
  입력창에 포커스가 있으면 가로채지 않는다.
- 대기열 맨 위에 "지금 재생 중" 블록 추가. 재생바가 이미 폴링하는 값을 위로 올려 공유하므로
  같은 API 를 두 번 호출하지 않는다.
- 대기열의 곡을 누르면 그 곡부터 재생. 봇에 점프 액션이 없어 queue/set 으로 대상 곡부터 남긴 뒤
  skip 하는 방식이며, 건너뛴 곡이 빠지는 것까지 스포티파이 대기열과 동일하다.
- 검색 결과 카드에 재생시간 표기(같은 제목의 짧은 클립과 원곡 구분에 필요).

문서(page/README.md 5-1~5-3):
- 화면(ViewMode)별 진입 경로·보이는 것·할 수 있는 일 표
- 버튼/조작별 동작·호출 API·비활성 조건 전수 표
- 타 서비스 관행 대비 개선 항목과 미지원 항목(이전 곡·반복 재생: 봇 API 한계) 명시

검증: tsc 통과, lint 에러 0. 브라우저에서 음소거 토글(볼륨 0↔60 복원), Space 로 재생↔일시정지 전환,
"지금 재생 중" 표시, 대기열 곡의 "…부터 재생" 동작, 재생시간 표기를 실제 렌더링으로 확인.
2026-09-29 14:05:38 +09:00

399 lines
17 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { signIn, useSession } from "next-auth/react";
import { Play, ChevronLeft, Server, Music, Loader2, SearchX, MonitorPlay, Disc, ListMusic, LogIn } from "lucide-react";
import { ViewMode } from "@/app/page";
// 🌟 [추가됨] 전역 토스트 훅 불러오기
import { useToast } from "@/components/ToastProvider";
import type { DiscordServer, SearchTrack, SearchResults } from "@/types/music";
/** ms -> m:ss (검색 결과 재생시간 표기용) */
function formatDuration(ms?: number) {
if (!ms || Number.isNaN(ms)) return "";
const total = Math.floor(ms / 1000);
return `${Math.floor(total / 60)}:${(total % 60).toString().padStart(2, "0")}`;
}
interface MainContentProps {
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
selectedServer: DiscordServer | null;
setSelectedServer: (server: DiscordServer | null) => void;
searchQuery: string;
setSearchQuery: (query: string) => void;
onSelectServer: (server: DiscordServer) => void;
}
export default function MainContent({
viewMode,
setViewMode,
selectedServer,
setSelectedServer,
searchQuery,
setSearchQuery,
onSelectServer,
}: MainContentProps) {
const { data: session, status } = useSession();
// 🌟 [추가됨] 훅을 실행해서 showToast 함수 꺼내기
const { showToast } = useToast();
const [servers, setServers] = useState<DiscordServer[]>([]);
const [isFetching, setIsFetching] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResults>({
spotify: [],
youtubeMusic: [],
youtubeVideo: []
});
const [isSearching, setIsSearching] = useState(false);
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: SearchTrack, playlistUrl?: string) => {
if (!selectedServer) {
// 🌟 alert -> showToast 교체
showToast("명령을 내릴 디스코드 서버가 선택되지 않았습니다.", "error");
return;
}
const userId = session?.user?.id;
if (!userId) {
showToast("로그인이 필요합니다.", "error");
return;
}
let endpoint = "";
const bodyData: Record<string, unknown> = { serverId: selectedServer.id };
if (actionType === 'player_play') {
endpoint = "/api/player/play";
bodyData.track = track;
} else if (actionType === 'player_playlist') {
endpoint = "/api/player/playlist";
bodyData.playlistUrl = playlistUrl;
}
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(bodyData),
});
const data = await res.json();
if (res.ok && data.success) {
// 🌟 성공 시 초록색 알림
showToast("재생목록에 곡을 추가했습니다!", "success");
} else {
// 🌟 에러 시 빨간색 알림
showToast(data.message || data.error || "요청 처리 중 문제가 발생했습니다.", "error");
}
} catch (error) {
console.error("Music Action Error:", error);
// 🌟 네트워크 에러 시 알림
showToast('서버와 통신할 수 없습니다.', "error");
}
};
const getPermissionLabel = (server: DiscordServer | null) => {
if (!server) return "알 수 없음";
if (server.owner) return "👑 서버 주인";
// Discord permissions 는 큰 정수 문자열로 도착함. 숫자/문자열만 받아 안전하게 BigInt 화.
const raw: unknown = server.permissions;
if (typeof raw !== "string" && typeof raw !== "number") return "👤 일반 멤버";
try {
const perms = BigInt(raw);
if ((perms & 0x8n) === 0x8n) return "🛠️ 관리자";
if ((perms & 0x20n) === 0x20n) return "⚙️ 매니저";
return "👤 일반 멤버";
} catch {
return "👤 일반 멤버";
}
};
useEffect(() => {
if (status === "authenticated") {
setIsFetching(true);
const cached = sessionStorage.getItem("filtered_servers");
if (cached) {
try {
const parsed = JSON.parse(cached);
if (Array.isArray(parsed)) {
setServers(parsed);
setIsFetching(false);
}
} catch {
// 캐시 손상 시 무시하고 아래 fetch로 새로 받는다.
sessionStorage.removeItem("filtered_servers");
}
}
fetch("/api/servers")
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data)) {
setServers(data);
sessionStorage.setItem("filtered_servers", JSON.stringify(data));
}
})
.finally(() => setIsFetching(false));
}
}, [status]);
useEffect(() => {
if (viewMode === "SEARCH_RESULT" && searchQuery) {
setIsSearching(true);
setSearchResults({ spotify: [], youtubeMusic: [], youtubeVideo: [] });
fetch(`/api/search?q=${encodeURIComponent(searchQuery)}`)
.then(async (res) => {
const data = await res.json();
if (res.ok && data) {
setSearchResults({
spotify: data.spotify || [],
youtubeMusic: data.youtubeMusic || [],
youtubeVideo: data.youtubeVideo || []
});
} else {
console.error("Search Error:", data);
}
})
.catch(err => console.error("Fetch Error:", err))
.finally(() => setIsSearching(false));
}
}, [viewMode, searchQuery]);
if (status === "unauthenticated") {
return (
<main className="flex flex-1 flex-col items-center justify-center gap-5 bg-neutral-900 px-6 text-center">
<span className="flex h-16 w-16 items-center justify-center rounded-2xl bg-neutral-800">
<ListMusic size={32} className="text-green-500" aria-hidden />
</span>
<div>
<h2 className="text-2xl font-bold">디스코드로 시작하기</h2>
<p className="mt-2 max-w-sm text-sm leading-relaxed text-neutral-400">
로그인하면 내가 속한 서버 중 음악 봇이 있는 서버가 보이고,
브라우저에서 바로 노래를 검색·재생할 수 있습니다.
</p>
</div>
{/* 안내만 하고 버튼이 없으면 사용자가 우상단을 찾아 헤매게 된다 — 여기서 바로 로그인시킨다. */}
<button
onClick={() => signIn("discord")}
className="flex h-12 items-center gap-2 rounded-full bg-[#5865F2] px-6 font-medium text-white shadow-lg transition hover:bg-[#4752C4] active:scale-95"
>
<LogIn size={18} aria-hidden />
Discord 로그인
</button>
</main>
);
}
if (status === "loading") {
return (
<main className="flex flex-1 items-center justify-center bg-neutral-900">
<Loader2 className="animate-spin text-neutral-600" size={32} aria-label="불러오는 중" />
</main>
);
}
const hasAnyResults = searchResults.spotify.length > 0 || searchResults.youtubeMusic.length > 0 || searchResults.youtubeVideo.length > 0;
// 카드 전체가 눌리는 버튼이다. 예전에는 hover 시에만 나타나는 작은 아이콘만 눌러야 해서
// 터치 기기(hover 없음)에서는 재생 방법을 찾기 어려웠다.
const renderTrackCard = (track: SearchTrack) => (
<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]"
>
<span className="relative mb-2 block aspect-square overflow-hidden rounded-md bg-neutral-700 shadow-lg">
{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 />
</span>
</span>
<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>
</button>
);
return (
<main className="flex flex-1 flex-col overflow-y-auto bg-gradient-to-b from-neutral-800/50 to-neutral-900 p-4 pb-28 sm:p-6 lg:p-8">
{/* 화면 1: 서버 목록 */}
{viewMode === "SERVER_LIST" && (
<>
<h2 className="mb-1 text-xl font-bold sm:text-2xl">어느 서버에서 들을까요?</h2>
<p className="mb-5 text-sm text-neutral-400">음악 봇이 들어가 있는 서버만 보입니다.</p>
{isFetching && servers.length === 0 ? (
<div className="flex flex-col items-center gap-3 py-20 text-neutral-400">
<Loader2 className="animate-spin text-green-500" size={36} aria-hidden />
<p>서버 목록을 불러오는 중...</p>
</div>
) : servers.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-20 text-center text-neutral-500">
<Server size={40} className="opacity-50" aria-hidden />
<p className="text-sm">표시할 서버가 없습니다.</p>
<p className="max-w-sm text-xs text-neutral-600">
내가 속한 서버 중 음악 봇이 들어가 있는 곳이 없거나, 봇이 아직 준비 중일 수 있습니다.
</p>
</div>
) : (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 sm:gap-4 md:grid-cols-4 xl:grid-cols-5">
{servers.map((server) => (
<button
key={server.id}
type="button"
onClick={() => onSelectServer(server)}
className="w-full rounded-xl border border-transparent bg-neutral-800/40 p-3 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]"
>
<span className="mb-2 flex aspect-square items-center justify-center overflow-hidden rounded-full bg-neutral-700 shadow-lg">
{server.icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://cdn.discordapp.com/icons/${server.id}/${server.icon}.png`}
className="h-full w-full object-cover"
alt=""
/>
) : (
<Server size={24} className="text-neutral-500" aria-hidden />
)}
</span>
<span className="block truncate text-center text-sm font-medium">{server.name}</span>
</button>
))}
</div>
)}
</>
)}
{/* 화면 2: 서버 상세 대시보드 */}
{viewMode === "SERVER_DETAIL" && selectedServer && (
<div className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<button onClick={() => {
setViewMode("SERVER_LIST");
setSelectedServer(null);
setSearchQuery("");
}} className="flex items-center gap-1 text-neutral-400 hover:text-white mb-6 cursor-pointer group">
<ChevronLeft size={20} className="group-hover:-translate-x-1 transition-transform" />
서버 목록으로 돌아가기
</button>
<div className="mb-6 flex flex-col items-center gap-4 text-center sm:flex-row sm:items-end sm:gap-6 sm:text-left">
<span className="flex h-28 w-28 flex-shrink-0 items-center justify-center overflow-hidden rounded-2xl bg-neutral-800 shadow-2xl sm:h-40 sm:w-40">
{selectedServer.icon ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={`https://cdn.discordapp.com/icons/${selectedServer.id}/${selectedServer.icon}.png`}
className="h-full w-full object-cover"
alt=""
/>
) : <Server size={56} className="text-neutral-500" aria-hidden />}
</span>
<div className="min-w-0">
<span className="text-xs font-bold uppercase tracking-wider text-neutral-400">서버 대시보드</span>
<h1 className="mt-1 break-words text-2xl font-black sm:text-4xl lg:text-5xl">{selectedServer.name}</h1>
<p className="mt-2 text-sm text-neutral-400">
위 검색창에서 노래를 찾아 바로 재생할 수 있습니다.
</p>
</div>
</div>
{/* 무엇을 해야 하는지 알려주는 안내 — 서버만 고르면 다음 행동이 막막했던 문제 보완 */}
<div className="mb-6 rounded-xl border border-green-500/20 bg-green-500/10 p-4">
<p className="text-sm text-green-200">
먼저 디스코드에서 <strong>음성 채널에 입장</strong>한 뒤, 노래를 검색해 재생 버튼을 누르세요.
</p>
</div>
<div className="rounded-xl border border-neutral-800 bg-black/20 p-4 sm:p-6">
<h3 className="mb-4 flex items-center gap-2 text-base font-bold sm:text-lg">
<Music size={20} aria-hidden /> 상세 정보
</h3>
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2 sm:gap-4">
<div className="rounded-lg border border-white/5 bg-neutral-800/50 p-4">
<p className="mb-1 text-neutral-500">서버 고유 ID</p>
<p className="break-all font-mono text-white">{selectedServer.id}</p>
</div>
<div className="rounded-lg border border-white/5 bg-neutral-800/50 p-4">
<p className="mb-1 text-neutral-500">나의 서버 권한</p>
<p className="font-bold text-green-400">{getPermissionLabel(selectedServer)}</p>
</div>
</div>
</div>
</div>
)}
{/* 화면 3: 검색 결과 목록 (3가지 카테고리로 분할) */}
{viewMode === "SEARCH_RESULT" && (
<div>
<h2 className="mb-1 break-words text-xl font-bold sm:text-2xl">&ldquo;{searchQuery}&rdquo; 검색 결과</h2>
<p className="mb-6 text-sm text-neutral-400">곡을 누르면 대기열에 추가됩니다.</p>
{isSearching ? (
<div className="flex flex-col items-center py-20">
<Loader2 className="animate-spin text-green-500 mb-4" size={40} />
<p>노래를 찾는 중입니다...</p>
</div>
) : !hasAnyResults ? (
<div className="flex flex-col items-center py-20 text-neutral-500">
<SearchX size={48} className="mb-4" />
<p>검색 결과가 없습니다.</p>
</div>
) : (
<div className="flex flex-col gap-10 pb-10">
{/* 카테고리 1: Spotify */}
{searchResults.spotify.length > 0 && (
<section>
<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>
</section>
)}
{/* 카테고리 2: YouTube Music */}
{searchResults.youtubeMusic.length > 0 && (
<section>
<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>
</section>
)}
{/* 카테고리 3: YouTube Video */}
{searchResults.youtubeVideo.length > 0 && (
<section>
<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>
</section>
)}
</div>
)}
</div>
)}
</main>
);
}