검색해서 재생기능 제작
검색하고 재생 누르면 재생됨 플레이리스트 재생 기능(주소 전달해서 재생하는 방식)
This commit is contained in:
@@ -3,3 +3,12 @@
|
|||||||
|
|
||||||
### 대충 내용
|
### 대충 내용
|
||||||
아마 따로 작성하지 않을까...?
|
아마 따로 작성하지 않을까...?
|
||||||
|
|
||||||
|
#### 마지막으로 만들었던 내용
|
||||||
|
검색하고 재생 누르면 재생됨
|
||||||
|
플레이리스트 재생 기능(주소 전달해서 재생하는 방식)
|
||||||
|
#### 이제 만들어야되는 내용
|
||||||
|
아래 재생바 연동
|
||||||
|
오른쪽 재생목록 연동
|
||||||
|
왼쪽 플레이리스트 가져오기 제작
|
||||||
|
플레이리스트 재생 추가
|
||||||
|
|||||||
@@ -3,6 +3,11 @@ import { Config } from "../utils/Config";
|
|||||||
import { Logger } from "../utils/Logger";
|
import { Logger } from "../utils/Logger";
|
||||||
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
|
import { YoutubeMusic } from "../utils/api/YoutubeMusic";
|
||||||
import { Spotify } from "../utils/api/Spotify";
|
import { Spotify } from "../utils/api/Spotify";
|
||||||
|
import { lavalinkManager } from "../index";
|
||||||
|
import { getGuildById, getVoiceChannelById } from "../utils/music/Channel";
|
||||||
|
import { channelJoin } from "../commands/join";
|
||||||
|
|
||||||
|
type SubAction = "search" | "player_play" | "player_playlist";
|
||||||
|
|
||||||
export class RedisClient {
|
export class RedisClient {
|
||||||
public pub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port });
|
public pub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port });
|
||||||
@@ -21,18 +26,50 @@ export class RedisClient {
|
|||||||
Logger.log(`[Redis Sub] 'bot-commands' 채널 구독 중... (현재 구독 채널 수: ${count})`);
|
Logger.log(`[Redis Sub] 'bot-commands' 채널 구독 중... (현재 구독 채널 수: ${count})`);
|
||||||
});
|
});
|
||||||
|
|
||||||
this.sub.on("message", async (ch, msg) => {
|
this.sub.on("message", async (ch, msg): Promise<any> => {
|
||||||
if (ch !== "site-bot") return;
|
if (ch !== "site-bot") return;
|
||||||
Logger.log(`[Redis Sub] [Message] 수신: {\n 채널: ${ch}\n 내용: ${msg}\n}`);
|
Logger.log(`[Redis Sub] [Message] 수신: {\n 채널: ${ch}\n 내용: ${msg}\n}`);
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(msg) as { action: "search"; requestId: string; [key: string]: any; };
|
const data = JSON.parse(msg) as { action: SubAction; requestId: string; userId?: string; [key: string]: any; };
|
||||||
|
|
||||||
if (data.action === "search") {
|
if (data.action === "search") {
|
||||||
const resultKey = `search:result:${data.requestId}`;
|
const resultKey = `search:${data.requestId}`;
|
||||||
const results = await Spotify.getSearchFull(data.query) ?? await YoutubeMusic.getSearchFull(data.query) ?? [];
|
const results = await Spotify.getSearchFull(data.query) ?? await YoutubeMusic.getSearchFull(data.query) ?? [];
|
||||||
await this.pub.setex(resultKey, 60, JSON.stringify(results));
|
await this.pub.setex(resultKey, 60, JSON.stringify(results));
|
||||||
Logger.log(`[Redis Pub] [setex] 결과 저장: (${resultKey})`);
|
Logger.log(`[Redis Pub] [setex] 결과 저장: (${resultKey})`);
|
||||||
}
|
}
|
||||||
|
else if (data.action === "player_play") {
|
||||||
|
const resultKey = `player:play:${data.requestId}`;
|
||||||
|
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||||
|
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||||
|
const guild = await getGuildById(data.serverId);
|
||||||
|
if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
||||||
|
let player = lavalinkManager.getPlayer(guild.id);
|
||||||
|
const voiceChannel = await getVoiceChannelById(guild, data.userId);
|
||||||
|
if (!player) {
|
||||||
|
if (!voiceChannel) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
|
||||||
|
player = (await channelJoin(guild, voiceChannel.id)).player;
|
||||||
|
}
|
||||||
|
if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
|
||||||
|
await lavalinkManager.search(guild.id, data.track.url, data.userId, player);
|
||||||
|
// await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, message: "노래 추가 완료" }));
|
||||||
|
}
|
||||||
|
else if (data.action === "player_playlist") {
|
||||||
|
const resultKey = `player:play:${data.requestId}`;
|
||||||
|
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
||||||
|
if (!data.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||||
|
const guild = await getGuildById(data.serverId);
|
||||||
|
if (!guild) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." }));
|
||||||
|
let player = lavalinkManager.getPlayer(guild.id);
|
||||||
|
const voiceChannel = await getVoiceChannelById(guild, data.userId);
|
||||||
|
if (!player) {
|
||||||
|
if (!voiceChannel) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." }));
|
||||||
|
player = (await channelJoin(guild, voiceChannel.id)).player;
|
||||||
|
}
|
||||||
|
if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "세션을 찾을수 없습니다." }));
|
||||||
|
await lavalinkManager.search(guild.id, data.playlistUrl, data.userId, player);
|
||||||
|
// await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" }));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Logger.error(`명령어 처리 중 에러: ${String(err)}`);
|
Logger.error(`명령어 처리 중 에러: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
|||||||
3
bot/src/types/Track.d.ts
vendored
3
bot/src/types/Track.d.ts
vendored
@@ -1,7 +1,8 @@
|
|||||||
export interface SongItem {
|
export interface SongItem {
|
||||||
|
videoId: string;
|
||||||
|
url: string;
|
||||||
title: string;
|
title: string;
|
||||||
artist: string;
|
artist: string;
|
||||||
videoId: string;
|
|
||||||
thumbnail: string; // 썸네일 URL
|
thumbnail: string; // 썸네일 URL
|
||||||
duration: number; // 재생시간 (ms 단위)
|
duration: number; // 재생시간 (ms 단위)
|
||||||
}
|
}
|
||||||
@@ -109,10 +109,11 @@ export const Spotify = {
|
|||||||
|
|
||||||
return data.tracks.items.map((track) => ({
|
return data.tracks.items.map((track) => ({
|
||||||
videoId: track.id,
|
videoId: track.id,
|
||||||
|
url: `https://open.spotify.com/track/${track.id}`,
|
||||||
title: track.name,
|
title: track.name,
|
||||||
artist: track.artists.map(artist => artist.name).join(", "),
|
artist: track.artists.map(artist => artist.name).join(", "),
|
||||||
duration: track.duration_ms,
|
duration: track.duration_ms,
|
||||||
thumbnail: track.album.images[2]?.url,
|
thumbnail: track.album.images[0]?.url,
|
||||||
}));
|
}));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Logger.error(`스포티파이 검색 실패: ${err}`);
|
Logger.error(`스포티파이 검색 실패: ${err}`);
|
||||||
@@ -123,8 +124,7 @@ export const Spotify = {
|
|||||||
const lowerQuery = query.toLocaleLowerCase().trim();
|
const lowerQuery = query.toLocaleLowerCase().trim();
|
||||||
if (searchCache.has(lowerQuery)) return searchCache.get(lowerQuery) ?? null;
|
if (searchCache.has(lowerQuery)) return searchCache.get(lowerQuery) ?? null;
|
||||||
const track = (await this.getSearchFull(query) ?? [])?.[0];
|
const track = (await this.getSearchFull(query) ?? [])?.[0];
|
||||||
const url = track?.videoId ? `https://open.spotify.com/track/${track.videoId}` : null;
|
if (track.url) searchCache.set(lowerQuery, track.url);
|
||||||
if (url) searchCache.set(lowerQuery, url);
|
return track.url;
|
||||||
return url;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,9 +146,10 @@ export const YoutubeMusic = {
|
|||||||
|
|
||||||
if (videoId && title) {
|
if (videoId && title) {
|
||||||
results.push({
|
results.push({
|
||||||
|
videoId,
|
||||||
|
url: `https://music.youtube.com/watch?v=${videoId}`,
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
videoId,
|
|
||||||
thumbnail,
|
thumbnail,
|
||||||
duration: parseDurationToMs(durationStr)
|
duration: parseDurationToMs(durationStr)
|
||||||
});
|
});
|
||||||
@@ -187,9 +188,10 @@ export const YoutubeMusic = {
|
|||||||
|
|
||||||
if (videoId && title) {
|
if (videoId && title) {
|
||||||
results.push({
|
results.push({
|
||||||
|
videoId,
|
||||||
|
url: `https://music.youtube.com/watch?v=${videoId}`,
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
videoId,
|
|
||||||
thumbnail,
|
thumbnail,
|
||||||
duration: parseDurationToMs(durationStr || "")
|
duration: parseDurationToMs(durationStr || "")
|
||||||
});
|
});
|
||||||
@@ -209,8 +211,7 @@ export const YoutubeMusic = {
|
|||||||
const lowerQuery = query.toLocaleLowerCase().trim();
|
const lowerQuery = query.toLocaleLowerCase().trim();
|
||||||
if (searchCache.has(lowerQuery)) return searchCache.get(lowerQuery) ?? null;
|
if (searchCache.has(lowerQuery)) return searchCache.get(lowerQuery) ?? null;
|
||||||
const video = (await this.getSearchFull(query) ?? [])?.[0];
|
const video = (await this.getSearchFull(query) ?? [])?.[0];
|
||||||
const url = video?.videoId ? `https://music.youtube.com/watch?v=${video.videoId}` : null;
|
if (video.url) searchCache.set(lowerQuery, video.url);
|
||||||
if (url) searchCache.set(lowerQuery, url);
|
return video.url;
|
||||||
return url;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -3,6 +3,19 @@ import { DB } from "../Database";
|
|||||||
import { Config } from "../Config";
|
import { Config } from "../Config";
|
||||||
import { Logger } from "../Logger";
|
import { Logger } from "../Logger";
|
||||||
import { clearAllMsg } from "./Utils";
|
import { clearAllMsg } from "./Utils";
|
||||||
|
import { client } from "../../index";
|
||||||
|
|
||||||
|
export const getGuildById = async (guildId: string): Promise<Guild | null> => {
|
||||||
|
const guild = client.guilds.cache.get(guildId)?.fetch();
|
||||||
|
if (!guild) return null;
|
||||||
|
return guild;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMemberById = async (guild: Guild, userId: string): Promise<GuildMember | null> => {
|
||||||
|
const member = await guild.members.cache.get(userId)?.fetch(true);
|
||||||
|
if (!member) return null;
|
||||||
|
return member;
|
||||||
|
}
|
||||||
|
|
||||||
export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => {
|
export const getVoiceChannel = (member: GuildMember): VoiceChannel | null => {
|
||||||
if (member.voice.channel?.type === ChannelType.GuildVoice) return member.voice.channel;
|
if (member.voice.channel?.type === ChannelType.GuildVoice) return member.voice.channel;
|
||||||
|
|||||||
@@ -15,13 +15,15 @@ export const authOptions: NextAuthOptions = {
|
|||||||
},
|
},
|
||||||
callbacks: {
|
callbacks: {
|
||||||
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
|
// 디스코드에서 받은 토큰(accessToken)을 우리 세션에 저장해두는 로직
|
||||||
async jwt({ token, account }) {
|
async jwt({ token, account, profile }) {
|
||||||
if (account) {
|
if (account && (profile as any)?.id) {
|
||||||
|
token.id = (profile as any).id;
|
||||||
token.accessToken = account.access_token;
|
token.accessToken = account.access_token;
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
},
|
},
|
||||||
async session({ session, token }: any) {
|
async session({ session, token }: any) {
|
||||||
|
session.user.id = token.id;
|
||||||
session.accessToken = token.accessToken;
|
session.accessToken = token.accessToken;
|
||||||
return session;
|
return session;
|
||||||
},
|
},
|
||||||
|
|||||||
44
page/src/app/api/player/play/route.ts
Normal file
44
page/src/app/api/player/play/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { Redis } from "@/lib/Redis";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { serverId, userId, track } = body;
|
||||||
|
|
||||||
|
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||||
|
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||||
|
if (!track) return NextResponse.json({ error: "track 정보가 필요합니다." }, { status: 400 });
|
||||||
|
|
||||||
|
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||||
|
const resultKey = `player:play:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||||
|
|
||||||
|
// 봇에게 'player_play' 명령 전송
|
||||||
|
await Redis.publish("site-bot", JSON.stringify({
|
||||||
|
action: "player_play",
|
||||||
|
requestId: requestId,
|
||||||
|
serverId: serverId,
|
||||||
|
userId: userId,
|
||||||
|
track: track,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||||
|
const botReply = await Redis.get(resultKey);
|
||||||
|
if (botReply) {
|
||||||
|
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||||
|
await Redis.del(resultKey);
|
||||||
|
const replyData = JSON.parse(botReply);
|
||||||
|
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||||
|
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3초가 지나도 봇이 묵묵부답일 때
|
||||||
|
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Play API Error:", error);
|
||||||
|
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
44
page/src/app/api/player/playlist/route.ts
Normal file
44
page/src/app/api/player/playlist/route.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { Redis } from "@/lib/Redis";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const body = await request.json();
|
||||||
|
const { serverId, userId, playlistUrl } = body;
|
||||||
|
|
||||||
|
if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 });
|
||||||
|
if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 });
|
||||||
|
if (!playlistUrl) return NextResponse.json({ error: "playlistUrl 정보가 필요합니다." }, { status: 400 });
|
||||||
|
|
||||||
|
const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||||
|
const resultKey = `player:playlist:${requestId}`; // 봇이 대답을 남길 Redis 방 이름
|
||||||
|
|
||||||
|
// 봇에게 'player_playlist' 명령 전송
|
||||||
|
await Redis.publish("site-bot", JSON.stringify({
|
||||||
|
action: "player_playlist",
|
||||||
|
requestId: requestId,
|
||||||
|
serverId: serverId,
|
||||||
|
userId: userId,
|
||||||
|
playlistUrl: playlistUrl,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 3. 봇의 대답 기다리기 (최대 약 3초 대기)
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200)); // 0.2초씩 대기
|
||||||
|
const botReply = await Redis.get(resultKey);
|
||||||
|
if (botReply) {
|
||||||
|
// 봇이 대답을 남겼다면! 읽었으니 Redis에서 삭제하고 프론트로 전달
|
||||||
|
await Redis.del(resultKey);
|
||||||
|
const replyData = JSON.parse(botReply);
|
||||||
|
// replyData.success 가 false면 에러 상태코드(400)로 보냄
|
||||||
|
return NextResponse.json(replyData, { status: replyData.success ? 200 : 400 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3초가 지나도 봇이 묵묵부답일 때
|
||||||
|
return NextResponse.json({ success: false, message: "봇이 응답하지 않거나 오프라인 상태입니다." }, { status: 504 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Queue Adds API Error:", error);
|
||||||
|
return NextResponse.json({ error: "서버 오류가 발생했습니다." }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export async function GET(request: Request) {
|
|||||||
|
|
||||||
// 2. 고유한 요청 ID 생성 (예: 1690001234567-abc)
|
// 2. 고유한 요청 ID 생성 (예: 1690001234567-abc)
|
||||||
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
||||||
const resultKey = `search:result:${requestId}`;
|
const resultKey = `search:${requestId}`;
|
||||||
|
|
||||||
// 3. 봇에게 'site-bot' 채널로 검색 명령 발송 (Publish)
|
// 3. 봇에게 'site-bot' 채널로 검색 명령 발송 (Publish)
|
||||||
await Redis.publish("site-bot", JSON.stringify({
|
await Redis.publish("site-bot", JSON.stringify({
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { Play, ChevronLeft, Server, Music, Loader2, SearchX } from "lucide-react";
|
// 🌟 [수정됨] 상단 아이콘에 쓸 ListPlus 추가
|
||||||
|
import { Play, ChevronLeft, Server, Music, Loader2, SearchX, ListPlus } from "lucide-react";
|
||||||
import { ViewMode } from "@/app/page";
|
import { ViewMode } from "@/app/page";
|
||||||
|
|
||||||
interface MainContentProps {
|
interface MainContentProps {
|
||||||
@@ -29,6 +30,47 @@ export default function MainContent({
|
|||||||
const [searchResults, setSearchResults] = useState<any[]>([]);
|
const [searchResults, setSearchResults] = useState<any[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
|
||||||
|
// 🌟 [추가됨] 봇에게 재생 및 대기열 추가 명령을 내리는 함수
|
||||||
|
const handleMusicAction = async (actionType: 'player_play' | 'player_playlist', track?: any, playlistUrl?: string) => {
|
||||||
|
if (!selectedServer) {
|
||||||
|
alert("명령을 내릴 디스코드 서버가 선택되지 않았습니다.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🌟 [추가된 부분] next-auth 세션에서 유저 식별자 가져오기
|
||||||
|
// (NextAuth 설정에 따라 id가 없을 수도 있으므로 없을 경우 name을 대체로 사용합니다)
|
||||||
|
const userId = (session?.user as any)?.id;
|
||||||
|
|
||||||
|
let endpoint = "";
|
||||||
|
let bodyData: any = { serverId: selectedServer.id, userId: userId };
|
||||||
|
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) {
|
||||||
|
alert(data.message || "요청이 성공했습니다.");
|
||||||
|
} else {
|
||||||
|
alert(data.message || data.error || "요청 처리 중 문제가 발생했습니다.");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Music Action Error:", error);
|
||||||
|
alert('서버와 통신할 수 없습니다.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 권한 계산 함수
|
// 권한 계산 함수
|
||||||
const getPermissionLabel = (server: any) => {
|
const getPermissionLabel = (server: any) => {
|
||||||
if (!server) return "알 수 없음";
|
if (!server) return "알 수 없음";
|
||||||
@@ -192,13 +234,37 @@ export default function MainContent({
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
|
||||||
{searchResults.map((track) => (
|
{searchResults.map((track) => (
|
||||||
<div key={track.videoId} className="bg-neutral-800/40 p-4 rounded-xl hover:bg-neutral-800 transition-all cursor-pointer group border border-transparent hover:border-neutral-700 shadow-md">
|
<div key={track.videoId} className="bg-neutral-800/40 p-4 rounded-xl hover:bg-neutral-800 transition-all group border border-transparent hover:border-neutral-700 shadow-md">
|
||||||
|
|
||||||
|
{/* 🌟 썸네일 이미지 영역 (여기 안에 버튼 2개가 떠다닙니다) */}
|
||||||
<div className="aspect-square bg-neutral-700 rounded-md mb-4 relative overflow-hidden shadow-lg">
|
<div className="aspect-square bg-neutral-700 rounded-md mb-4 relative overflow-hidden shadow-lg">
|
||||||
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
|
{track.thumbnail && <img src={track.thumbnail} className="w-full h-full object-cover" alt={track.title} />}
|
||||||
<div className="absolute right-2 bottom-2 bg-green-500 rounded-full p-3 opacity-0 group-hover:opacity-100 transition-all translate-y-2 group-hover:translate-y-0 shadow-xl">
|
|
||||||
|
{/* 🌟 [주석으로 제거] 우측 상단: 재생목록 추가 버튼 (위에서 아래로 스르륵) */}
|
||||||
|
{/* <button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation(); // 부모 div 클릭 이벤트 방지
|
||||||
|
handleMusicAction('queue_add', track);
|
||||||
|
}}
|
||||||
|
className="absolute right-2 top-2 cursor-pointer bg-black/60 hover:bg-black/80 rounded-full p-2 opacity-0 group-hover:opacity-100 transition-all -translate-y-2 group-hover:translate-y-0 shadow-xl text-white backdrop-blur-sm"
|
||||||
|
title="재생목록에 추가"
|
||||||
|
>
|
||||||
|
<ListPlus fill="black" size={25} />
|
||||||
|
</button> */}
|
||||||
|
|
||||||
|
{/* 🌟 [수정됨] 우측 하단: 바로 재생 버튼 (기존 코드 유지 및 onClick 연결) */}
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleMusicAction('player_play', track);
|
||||||
|
}}
|
||||||
|
className="absolute right-2 bottom-2 cursor-pointer bg-green-500 hover:bg-green-400 rounded-full p-3 opacity-0 group-hover:opacity-100 transition-all translate-y-2 group-hover:translate-y-0 shadow-xl"
|
||||||
|
title="바로 재생"
|
||||||
|
>
|
||||||
<Play fill="black" stroke="black" size={20} />
|
<Play fill="black" stroke="black" size={20} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<h3 className="font-semibold text-white truncate">{track.title}</h3>
|
<h3 className="font-semibold text-white truncate">{track.title}</h3>
|
||||||
<p className="text-sm text-neutral-400 truncate">{track.artist}</p>
|
<p className="text-sm text-neutral-400 truncate">{track.artist}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user