From 374fbdb1cedeb847c347008771280f30a9b28e39 Mon Sep 17 00:00:00 2001 From: tkrmagid-desktop Date: Fri, 10 Apr 2026 00:40:40 +0900 Subject: [PATCH] =?UTF-8?q?=EB=8C=80=EB=B6=80=EB=B6=84=20=EB=AA=A8?= =?UTF-8?q?=EB=93=A0=EA=B8=B0=EB=8A=A5=20=EC=A0=9C=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 하단 player연결, 일시정지, 스킵, 볼륨, 특정시간재생, queue 디자인 변경, queue 변경기능 제작 --- bot/src/classes/GuildPlayer.ts | 42 ++- bot/src/classes/RedisClient.ts | 162 ++++++++- bot/src/utils/api/Spotify.ts | 4 +- bot/src/utils/api/YoutubeMusic.ts | 4 +- page/src/app/api/player/events/route.ts | 54 +++ page/src/app/api/player/now/route.ts | 42 +++ page/src/app/api/player/pause/route.ts | 43 +++ page/src/app/api/player/seek/route.ts | 44 +++ page/src/app/api/player/skip/route.ts | 42 +++ page/src/app/api/player/volume/route.ts | 44 +++ page/src/app/api/queue/events/route.ts | 54 +++ page/src/app/api/queue/list/route.ts | 44 +++ page/src/app/api/queue/remove/route.ts | 46 +++ page/src/app/api/queue/set/route.ts | 47 +++ page/src/app/page.tsx | 4 +- page/src/components/player/MainContent.tsx | 53 ++- page/src/components/player/PlayerBar.tsx | 355 ++++++++++++++++++-- page/src/components/player/QueueSidebar.tsx | 264 ++++++++++++++- 18 files changed, 1265 insertions(+), 83 deletions(-) create mode 100644 page/src/app/api/player/events/route.ts create mode 100644 page/src/app/api/player/now/route.ts create mode 100644 page/src/app/api/player/pause/route.ts create mode 100644 page/src/app/api/player/seek/route.ts create mode 100644 page/src/app/api/player/skip/route.ts create mode 100644 page/src/app/api/player/volume/route.ts create mode 100644 page/src/app/api/queue/events/route.ts create mode 100644 page/src/app/api/queue/list/route.ts create mode 100644 page/src/app/api/queue/remove/route.ts create mode 100644 page/src/app/api/queue/set/route.ts diff --git a/bot/src/classes/GuildPlayer.ts b/bot/src/classes/GuildPlayer.ts index d37a9d4..e226bda 100644 --- a/bot/src/classes/GuildPlayer.ts +++ b/bot/src/classes/GuildPlayer.ts @@ -1,6 +1,6 @@ import { Guild, Message, TextChannel } from "discord.js"; -import { LoadType, Player, Track, TrackEndEvent } from "shoukaku"; -import { client, lavalinkManager } from "../index"; +import { LoadType, Player, Track, TrackEndEvent, TrackStartEvent } from "shoukaku"; +import { client, lavalinkManager, Redis } from "../index"; import { timeFormat } from "../utils/music/Utils"; import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config"; import { GuildType } from "../../db/db"; @@ -30,6 +30,11 @@ export class GuildPlayer { public msg: Message, ) { this.player.setGlobalVolume(50); + this.player.on("start", (_data: TrackStartEvent) => { + Redis.publishState("player_update", { + guildId: this.guild.id, + }); + }); this.player.on("end", async (data: TrackEndEvent) => { if (this.isDead) return; if (data.reason === "replaced") return; @@ -65,6 +70,13 @@ export class GuildPlayer { public get isRecommend(): boolean { return this.GDB?.options.recommend ?? false; } + public get position(): number { + if (!this.isPlaying) return 0; + return this.player.position; + } + public get volume(): number { + return this.player.volume; + } public async addTrack(track: Track, userId: string) { this.queue.push({ ...track, userId }); @@ -73,7 +85,7 @@ export class GuildPlayer { await this.playNext(); } else { // 재생목록 추가되었을때 - this.setMsg(); + this.setMsg({ player: false, queue: true }); } } @@ -85,7 +97,7 @@ export class GuildPlayer { await this.playNext(); } else { // 재생목록 추가되었을때 - this.setMsg(); + this.setMsg({ player: false, queue: true }); } } @@ -100,10 +112,10 @@ export class GuildPlayer { public async setPause() { if (this.isPaused) { await this.player.setPaused(false); - this.setMsg(); + this.setMsg({ player: true, queue: false }); } else if (this.isPlaying) { await this.player.setPaused(true); - this.setMsg(); + this.setMsg({ player: true, queue: false }); } } @@ -122,20 +134,28 @@ export class GuildPlayer { let nowQueue = this.queue.slice(1); nowQueue = shuffle(nowQueue); this.queue = [this.queue[0], ...nowQueue]; - this.setMsg(); + this.setMsg({ player: false, queue: true }); } public setRecommend() { if (!this.GDB) return; this.GDB.options.recommend = !this.GDB.options.recommend; DB.guild.update(this.GDB); - this.setMsg(); + this.setMsg({ player: false, queue: false }); + } + + public setVolume(volume: number) { + if (!this.isPlaying) return; + if (!this.nowTrack) return; + this.player.setGlobalVolume(volume); + this.setMsg({ player: true, queue: false }); } public seek(num: number) { if (!this.isPlaying) return; if (!this.nowTrack) return; this.player.seekTo(num); + Redis.publishState("player_update", { guildId: this.guild.id }); } private async autoPlay() { @@ -189,7 +209,9 @@ export class GuildPlayer { lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id); } - private async setMsg() { + public async setMsg(update: { player: boolean; queue: boolean; } = { player: true, queue: true }) { + if (update.player) Redis.publishState("player_update", { guildId: this.guild.id }); + if (update.queue) Redis.publishState("queue_update", { guildId: this.guild.id }); const { channel, msg, check } = await checkTextChannelAndMsg(this.guild, this.textChannel, this.msg); if (!check) return; this.textChannel = channel; @@ -247,7 +269,7 @@ export class GuildPlayer { files: embed.data.image?.url === default_embed(this.guild.id).data.image?.url ? [ default_image ] : [], components: [ getButtons( this.isPlaying || this.isPaused, - this.isPaused, + this.isPlaying && this.isPaused, realQueue.length > 1, ) ] }); diff --git a/bot/src/classes/RedisClient.ts b/bot/src/classes/RedisClient.ts index 1201621..afa1c51 100644 --- a/bot/src/classes/RedisClient.ts +++ b/bot/src/classes/RedisClient.ts @@ -6,8 +6,21 @@ import { Spotify } from "../utils/api/Spotify"; import { lavalinkManager } from "../index"; import { getGuildById, getVoiceChannelById } from "../utils/music/Channel"; import { channelJoin } from "../commands/join"; +import { GuildPlayer } from "./GuildPlayer"; +import { Guild, VoiceChannel } from "discord.js"; -type SubAction = "search" | "player_play" | "player_playlist"; +type SubAction = + "search" | + "player_now" | + "player_play" | + "player_playlist" | + "player_paused" | + "player_skip" | + "player_seek" | + "player_volume" | + "queue_list" | + "queue_set" | + "queue_remove"; export class RedisClient { public pub: Redis = new Redis({ host: Config.redis.host, port: Config.redis.port }); @@ -38,7 +51,7 @@ export class RedisClient { await this.pub.setex(resultKey, 60, JSON.stringify(results)); Logger.log(`[Redis Pub] [setex] 결과 저장: (${resultKey})`); } - else if (data.action === "player_play") { + 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를 찾을수 없습니다." })); @@ -52,10 +65,10 @@ export class RedisClient { } 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: "노래 추가 완료" })); + 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.action === "player_playlist") { + const resultKey = `player:playlist:${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); @@ -68,7 +81,112 @@ export class RedisClient { } 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: "플레이리스트 추가 완료" })); + await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, message: "플레이리스트 추가 완료" })); + } + if (data.action === "player_now") { + const resultKey = `player:now:${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 player = lavalinkManager.getPlayer(data.serverId); + // if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." })); + await this.pub.setex(resultKey, 60, JSON.stringify({ + success: true, + botPlayer: !!player, + isPlaying: player?.isPlaying, + isPaused: player?.isPaused, + position: player?.position, + volume: player?.volume, + track: player?.nowTrack ?? null + })); + } + if (data.action === "queue_list") { + const resultKey = `queue:list:${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 player = lavalinkManager.getPlayer(data.serverId); + // if (!player) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." })); + await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, queue: player?.queue?.slice(1) ?? [] })); + } + if (data.action === "queue_set") { + const resultKey = `queue:set:${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를 찾을수 없습니다." })); + if (!data.newQueue) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "newQueue를 찾을수 없습니다." })); + const context = await this.getContext(data.serverId, resultKey, data.userId); + if (!context.ok) return; + const nowTrack = context.player.nowTrack ?? context.player.queue?.[0]; + context.player.queue.length = 0; + if (nowTrack) context.player.queue.push(nowTrack); + for (const rawTrack of data.newQueue) { + if (rawTrack.encoded) context.player.queue.push(rawTrack); + } + await this.pub.setex(resultKey, 60, JSON.stringify({ success: true })); + context.player.setMsg(); + } + if (data.action === "queue_remove") { + const resultKey = `queue:remove:${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를 찾을수 없습니다." })); + if (!data.index) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index를 찾을수 없습니다." })); + const context = await this.getContext(data.serverId, resultKey, data.userId); + if (!context.ok) return; + const numIndex = Number(data.index); + if (isNaN(numIndex)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index 타입이 올바르지 않습니다." })); + if (numIndex < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index는 0보다 크거나 같아야합니다." })); + if (numIndex >= context.player.queue.length-1) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "index가 queue.length보다 클수 없습니다." })); + const [removedTrack] = context.player.queue.splice(numIndex+1, 1); + await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, removedTrack })); + context.player.setMsg(); + } + if (data.action === "player_paused") { + const resultKey = `player:paused:${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를 찾을수 없습니다." })); + if (!data.isPaused) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "paused를 찾을수 없습니다." })); + const context = await this.getContext(data.serverId, resultKey, data.userId); + if (!context.ok) return; + await context.player.setPause(); + await this.pub.setex(resultKey, 60, JSON.stringify({ success: true, paused: context.player.isPaused })); + } + if (data.action === "player_skip") { + const resultKey = `player:skip:${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 context = await this.getContext(data.serverId, resultKey, data.userId); + if (!context.ok) return; + if (context.player.isPlaying) context.player.skip(); + await this.pub.setex(resultKey, 60, JSON.stringify({ success: true })); + } + if (data.action === "player_seek") { + const resultKey = `player:seek:${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를 찾을수 없습니다." })); + if (!data.seek) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek를 찾을수 없습니다." })); + const context = await this.getContext(data.serverId, resultKey, data.userId); + if (!context.ok) return; + if (!context.player.isPlaying || !context.player.nowTrack) await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." })); + const duration = context.player.nowTrack?.info.length || 0; + const numSeek = Number(data.seek); + if (isNaN(numSeek)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek 타입이 올바르지 않습니다." })); + if (numSeek < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek는 0보다 크거나 같아야합니다." })); + if (numSeek > duration) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "seek가 duration보다 클수 없습니다." })); + context.player.seek(numSeek); + await this.pub.setex(resultKey, 60, JSON.stringify({ success: true })); + } + if (data.action === "player_volume") { + const resultKey = `player:volume:${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를 찾을수 없습니다." })); + if (!data.volume) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume을 찾을수 없습니다." })); + const context = await this.getContext(data.serverId, resultKey, data.userId); + if (!context.ok) return; + if (!context.player.isPlaying || !context.player.nowTrack) await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "재생중인 노래가 없습니다." })); + const numVolume = Number(data.volume); + if (isNaN(numVolume)) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume 타입이 올바르지 않습니다." })); + if (numVolume < 0) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume은 0보다 크거나 같아야합니다." })); + if (numVolume > 100) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "volume이 100보다 클수 없습니다." })); + context.player.setVolume(numVolume); + await this.pub.setex(resultKey, 60, JSON.stringify({ success: true })); } } catch (err) { Logger.error(`명령어 처리 중 에러: ${String(err)}`); @@ -93,6 +211,38 @@ export class RedisClient { Logger.log(`[Redis Pub] bot -> site 전송: ${event}`); } + private async getContext(guildId: string, resultKey: string, userId: string): Promise<{ + ok: true; + guild: Guild; + voiceChannel: VoiceChannel | null; + player: GuildPlayer; + } | { ok: false; }> { + const guild = await getGuildById(guildId); + if (!guild) { + await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "guild를 찾을수 없습니다." })); + return { ok: false }; + } + let player = lavalinkManager.getPlayer(guild.id); + const voiceChannel = await getVoiceChannelById(guild, userId); + if (!player) { + if (!voiceChannel) { + await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "음성채널에 들어가서 이용해주세요." })); + return { ok: false }; + } + player = (await channelJoin(guild, voiceChannel.id)).player; + } + if (!player) { + await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "player를 찾을수 없습니다." })); + return { ok: false }; + } + return { + ok: true, + guild, + voiceChannel, + player, + }; + } + public runTest() { Logger.debug(`[Redis Test] 3초 뒤에 테스트 통신 시작...`); setTimeout(() => { diff --git a/bot/src/utils/api/Spotify.ts b/bot/src/utils/api/Spotify.ts index dac3332..fbd1099 100644 --- a/bot/src/utils/api/Spotify.ts +++ b/bot/src/utils/api/Spotify.ts @@ -124,7 +124,7 @@ export const Spotify = { const lowerQuery = query.toLocaleLowerCase().trim(); if (searchCache.has(lowerQuery)) return searchCache.get(lowerQuery) ?? null; const track = (await this.getSearchFull(query) ?? [])?.[0]; - if (track.url) searchCache.set(lowerQuery, track.url); - return track.url; + if (track?.url) searchCache.set(lowerQuery, track.url); + return track?.url ?? null; } } \ No newline at end of file diff --git a/bot/src/utils/api/YoutubeMusic.ts b/bot/src/utils/api/YoutubeMusic.ts index 81ff52e..470b1b9 100644 --- a/bot/src/utils/api/YoutubeMusic.ts +++ b/bot/src/utils/api/YoutubeMusic.ts @@ -211,7 +211,7 @@ export const YoutubeMusic = { const lowerQuery = query.toLocaleLowerCase().trim(); if (searchCache.has(lowerQuery)) return searchCache.get(lowerQuery) ?? null; const video = (await this.getSearchFull(query) ?? [])?.[0]; - if (video.url) searchCache.set(lowerQuery, video.url); - return video.url; + if (video?.url) searchCache.set(lowerQuery, video.url); + return video?.url ?? null; } }; \ No newline at end of file diff --git a/page/src/app/api/player/events/route.ts b/page/src/app/api/player/events/route.ts new file mode 100644 index 0000000..b3f6c93 --- /dev/null +++ b/page/src/app/api/player/events/route.ts @@ -0,0 +1,54 @@ +// src/app/api/queue/events/route.ts +import { NextRequest } from "next/server"; +import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트 + +// 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다. +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + // 프론트엔드에서 보낸 serverId 가져오기 + const serverId = req.nextUrl.searchParams.get("serverId"); + + if (!serverId) { + return new Response("Missing serverId", { status: 400 }); + } + + // SSE(Server-Sent Events) 스트림 생성 + const stream = new ReadableStream({ + async start(controller) { + // 🚨 중요: 구독(Subscribe) 전용으로 쓸 독립적인 Redis 연결을 하나 복제합니다. + const subscriber = Redis.duplicate(); + + // 'bot-site' 채널 구독 + await subscriber.subscribe("bot-site"); + + // 메세지가 들어올 때마다 실행 + subscriber.on("message", (channel, message) => { + if (channel !== "bot-site") return; + const data = JSON.parse(message); + if (data.guildId !== serverId) return; + // 알림이 울린 서버와 현재 유저가 보고 있는 서버가 일치할 때만! + if (data.event === "player_update") { + // 프론트엔드로 "새로고침해!" 라는 데이터를 전송 + controller.enqueue(`data: ${JSON.stringify({ type: "player_update" })}\n\n`); + } + }); + + // 클라이언트(웹사이트)가 브라우저를 닫거나 다른 페이지로 가면 연결 종료 및 정리 + req.signal.addEventListener("abort", () => { + subscriber.unsubscribe("bot-site"); + subscriber.quit(); + controller.close(); + }); + } + }); + + // 스트림 응답 헤더 설정 (연결을 끊지 않고 계속 유지) + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }); +} \ No newline at end of file diff --git a/page/src/app/api/player/now/route.ts b/page/src/app/api/player/now/route.ts new file mode 100644 index 0000000..a4b2893 --- /dev/null +++ b/page/src/app/api/player/now/route.ts @@ -0,0 +1,42 @@ +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 } = body; + + if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); + if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + + const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; + const resultKey = `player:now:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + + // 봇에게 'player_now' 명령 전송 + await Redis.publish("site-bot", JSON.stringify({ + action: "player_now", + requestId: requestId, + serverId: serverId, + userId: userId, + })); + + // 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 }); + } +} \ No newline at end of file diff --git a/page/src/app/api/player/pause/route.ts b/page/src/app/api/player/pause/route.ts new file mode 100644 index 0000000..b6b7023 --- /dev/null +++ b/page/src/app/api/player/pause/route.ts @@ -0,0 +1,43 @@ +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, isPaused } = body; + if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); + if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + if (!isPaused) return NextResponse.json({ error: "isPaused 정보가 필요합니다." }, { status: 400 }); + + const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; + const resultKey = `player:paused:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + + // 봇에게 'player_pause' 명령 전송 + await Redis.publish("site-bot", JSON.stringify({ + action: "player_paused", + requestId: requestId, + serverId: serverId, + userId: userId, + isPaused: isPaused, + })); + + // 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 }); + } +} \ No newline at end of file diff --git a/page/src/app/api/player/seek/route.ts b/page/src/app/api/player/seek/route.ts new file mode 100644 index 0000000..7f7116e --- /dev/null +++ b/page/src/app/api/player/seek/route.ts @@ -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, seek } = body; + + if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); + if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + if (!seek) return NextResponse.json({ error: "seek 정보가 필요합니다." }, { status: 400 }); + + const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; + const resultKey = `player:seek:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + + // 봇에게 'player_seek' 명령 전송 + await Redis.publish("site-bot", JSON.stringify({ + action: "player_seek", + requestId: requestId, + serverId: serverId, + userId: userId, + seek: seek, + })); + + // 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 }); + } +} \ No newline at end of file diff --git a/page/src/app/api/player/skip/route.ts b/page/src/app/api/player/skip/route.ts new file mode 100644 index 0000000..25bdee1 --- /dev/null +++ b/page/src/app/api/player/skip/route.ts @@ -0,0 +1,42 @@ +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 } = body; + + if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); + if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + + const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; + const resultKey = `player:skip:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + + // 봇에게 'player_skip' 명령 전송 + await Redis.publish("site-bot", JSON.stringify({ + action: "player_skip", + requestId: requestId, + serverId: serverId, + userId: userId, + })); + + // 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 }); + } +} \ No newline at end of file diff --git a/page/src/app/api/player/volume/route.ts b/page/src/app/api/player/volume/route.ts new file mode 100644 index 0000000..6d70f15 --- /dev/null +++ b/page/src/app/api/player/volume/route.ts @@ -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, volume } = body; + + if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); + if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + if (!volume) return NextResponse.json({ error: "volume 정보가 필요합니다." }, { status: 400 }); + + const requestId = `req:${Date.now()}-${Math.random().toString(36).substring(7)}`; + const resultKey = `player:volume:${requestId}`; // 봇이 대답을 남길 Redis 방 이름 + + // 봇에게 'player_volume' 명령 전송 + await Redis.publish("site-bot", JSON.stringify({ + action: "player_volume", + requestId: requestId, + serverId: serverId, + userId: userId, + volume: volume, + })); + + // 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 }); + } +} \ No newline at end of file diff --git a/page/src/app/api/queue/events/route.ts b/page/src/app/api/queue/events/route.ts new file mode 100644 index 0000000..0a374c6 --- /dev/null +++ b/page/src/app/api/queue/events/route.ts @@ -0,0 +1,54 @@ +// src/app/api/queue/events/route.ts +import { NextRequest } from "next/server"; +import { Redis } from "@/lib/Redis"; // 사용 중인 Redis 클라이언트 + +// 이 API는 캐시되지 않고 항상 실시간으로 작동해야 합니다. +export const dynamic = "force-dynamic"; + +export async function GET(req: NextRequest) { + // 프론트엔드에서 보낸 serverId 가져오기 + const serverId = req.nextUrl.searchParams.get("serverId"); + + if (!serverId) { + return new Response("Missing serverId", { status: 400 }); + } + + // SSE(Server-Sent Events) 스트림 생성 + const stream = new ReadableStream({ + async start(controller) { + // 🚨 중요: 구독(Subscribe) 전용으로 쓸 독립적인 Redis 연결을 하나 복제합니다. + const subscriber = Redis.duplicate(); + + // 'bot-site' 채널 구독 + await subscriber.subscribe("bot-site"); + + // 메세지가 들어올 때마다 실행 + subscriber.on("message", (channel, message) => { + if (channel !== "bot-site") return; + const data = JSON.parse(message); + if (data.guildId !== serverId) return; + // 알림이 울린 서버와 현재 유저가 보고 있는 서버가 일치할 때만! + if (data.event === "queue_update") { + // 프론트엔드로 "새로고침해!" 라는 데이터를 전송 + controller.enqueue(`data: ${JSON.stringify({ type: "queue_update" })}\n\n`); + } + }); + + // 클라이언트(웹사이트)가 브라우저를 닫거나 다른 페이지로 가면 연결 종료 및 정리 + req.signal.addEventListener("abort", () => { + subscriber.unsubscribe("bot-site"); + subscriber.quit(); + controller.close(); + }); + } + }); + + // 스트림 응답 헤더 설정 (연결을 끊지 않고 계속 유지) + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + "Connection": "keep-alive", + }, + }); +} \ No newline at end of file diff --git a/page/src/app/api/queue/list/route.ts b/page/src/app/api/queue/list/route.ts new file mode 100644 index 0000000..8fb6667 --- /dev/null +++ b/page/src/app/api/queue/list/route.ts @@ -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 } = body; + + if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); + if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + + // 1. 고유한 요청 ID(진동벨) 생성 + const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; + const resultKey = `queue:list:${requestId}`; + + // 2. 봇에게 'queue_list' 명령 발송 + await Redis.publish("site-bot", JSON.stringify({ + action: "queue_list", + serverId: serverId, + userId: userId, + requestId: requestId, // 🌟 봇이 대답을 남길 키 + })); + + // 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 List API Error:", error); + return NextResponse.json({ success: false, message: "서버 오류가 발생했습니다." }, { status: 500 }); + } +} \ No newline at end of file diff --git a/page/src/app/api/queue/remove/route.ts b/page/src/app/api/queue/remove/route.ts new file mode 100644 index 0000000..f24e24d --- /dev/null +++ b/page/src/app/api/queue/remove/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { Redis } from "@/lib/Redis"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { serverId, index, userId } = body; + + if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); + if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + if (!index) return NextResponse.json({ error: "index 정보가 필요합니다." }, { status: 400 }); + + const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; + const resultKey = `queue:remove:${requestId}`; + + // 봇에게 'remove_queue' 명령 발송 (몇 번째 인덱스를 지워라) + await Redis.publish("site-bot", JSON.stringify({ + action: "queue_remove", + serverId: serverId, + requestId: requestId, + userId: userId, + index: index, + })); + + // 4. 결과가 올라올 때까지 기다리기 (Polling) + // 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다. + for (let i = 0; i < 10; i++) { + // 0.5초 대기 + await new Promise(resolve => setTimeout(resolve, 500)); + + // Redis 게시판 확인 + const resultData = await Redis.get(resultKey); + console.log(resultData); + + if (resultData) { + // 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료 + return NextResponse.json(JSON.parse(resultData)); + } + } + + // 5초가 지나도 응답이 없으면 타임아웃 + return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 }); + } catch (error) { + return NextResponse.json({ error: "서버 오류" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/page/src/app/api/queue/set/route.ts b/page/src/app/api/queue/set/route.ts new file mode 100644 index 0000000..ce6fb57 --- /dev/null +++ b/page/src/app/api/queue/set/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; +import { Redis } from "@/lib/Redis"; + +export async function POST(request: Request) { + try { + const body = await request.json(); + const { serverId, newQueue, userId } = body; + + if (!serverId) return NextResponse.json({ error: "serverId 정보가 필요합니다." }, { status: 400 }); + if (!userId) return NextResponse.json({ error: "userId 정보가 필요합니다." }, { status: 400 }); + if (newQueue === undefined || newQueue === null) return NextResponse.json({ error: "newQueue 정보가 필요합니다." }, { status: 400 }); + + const requestId = `${Date.now()}-${Math.random().toString(36).substring(7)}`; + const resultKey = `queue:set:${requestId}`; + + // 봇에게 'queue_set' 명령 발송 (전체 대기열을 통째로 덮어써라!) + await Redis.publish("site-bot", JSON.stringify({ + action: "queue_set", + serverId: serverId, + requestId: requestId, + userId: userId, + newQueue: newQueue, + })); + + // 4. 결과가 올라올 때까지 기다리기 (Polling) + // 최대 10번(약 5초) 동안 0.5초 간격으로 확인합니다. + for (let i = 0; i < 10; i++) { + // 0.5초 대기 + await new Promise(resolve => setTimeout(resolve, 500)); + + // Redis 게시판 확인 + const resultData = await Redis.get(resultKey); + console.log(resultData); + + if (resultData) { + // 🌟 봇이 결과를 올렸다면! 데이터를 돌려주고 종료 + return NextResponse.json(JSON.parse(resultData)); + } + } + + // 5초가 지나도 응답이 없으면 타임아웃 + return NextResponse.json({ error: "봇이 검색에 응답하지 않습니다." }, { status: 504 }); + } catch (error) { + console.error("Queue Reorder API Error:", error); + return NextResponse.json({ error: "서버 오류" }, { status: 500 }); + } +} \ No newline at end of file diff --git a/page/src/app/page.tsx b/page/src/app/page.tsx index e622331..d4eaff0 100644 --- a/page/src/app/page.tsx +++ b/page/src/app/page.tsx @@ -57,10 +57,10 @@ export default function MusicPlayerLayout() { setSearchQuery={setSearchQuery} onSelectServer={handleSelectServer} /> - + - + ); } \ No newline at end of file diff --git a/page/src/components/player/MainContent.tsx b/page/src/components/player/MainContent.tsx index f641a90..c7933cd 100644 --- a/page/src/components/player/MainContent.tsx +++ b/page/src/components/player/MainContent.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { useSession } from "next-auth/react"; // 🌟 [수정됨] 상단 아이콘에 쓸 ListPlus 추가 -import { Play, ChevronLeft, Server, Music, Loader2, SearchX, ListPlus } from "lucide-react"; +import { Play, ChevronLeft, Server, Music, Loader2, SearchX, ListPlus } from "lucide-react"; import { ViewMode } from "@/app/page"; interface MainContentProps { @@ -15,7 +15,7 @@ interface MainContentProps { onSelectServer: (server: any) => void; } -export default function MainContent({ +export default function MainContent({ viewMode, setViewMode, selectedServer, @@ -36,9 +36,8 @@ export default function MainContent({ alert("명령을 내릴 디스코드 서버가 선택되지 않았습니다."); return; } - + // 🌟 [추가된 부분] next-auth 세션에서 유저 식별자 가져오기 - // (NextAuth 설정에 따라 id가 없을 수도 있으므로 없을 경우 name을 대체로 사용합니다) const userId = (session?.user as any)?.id; let endpoint = ""; @@ -61,7 +60,7 @@ export default function MainContent({ const data = await res.json(); if (res.ok && data.success) { - alert(data.message || "요청이 성공했습니다."); + // alert(data.message || "요청이 성공했습니다."); } else { alert(data.message || data.error || "요청 처리 중 문제가 발생했습니다."); } @@ -143,7 +142,7 @@ export default function MainContent({ return (
- + {/* 화면 1: 서버 목록 */} {viewMode === "SERVER_LIST" && ( <> @@ -156,8 +155,8 @@ export default function MainContent({ ) : (
{servers.map((server) => ( -
onSelectServer(server)} 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" > @@ -185,12 +184,12 @@ export default function MainContent({ 서버 목록으로 돌아가기 - +
- {selectedServer.icon ? ( - - ) : } + {selectedServer.icon ? ( + + ) : }
서버 대시보드 @@ -198,19 +197,19 @@ export default function MainContent({

이 서버에서 음악 봇이 활발하게 작동 중입니다.

- +
-

상세 정보

-
-
-

서버 고유 ID

-

{selectedServer.id}

-
-
-

나의 서버 권한

-

{getPermissionLabel(selectedServer)}

-
-
+

상세 정보

+
+
+

서버 고유 ID

+

{selectedServer.id}

+
+
+

나의 서버 권한

+

{getPermissionLabel(selectedServer)}

+
+
)} @@ -235,11 +234,11 @@ export default function MainContent({
{searchResults.map((track) => (
- + {/* 🌟 썸네일 이미지 영역 (여기 안에 버튼 2개가 떠다닙니다) */}
{track.thumbnail && {track.title}} - + {/* 🌟 [주석으로 제거] 우측 상단: 재생목록 추가 버튼 (위에서 아래로 스르륵) */} {/* */} {/* 🌟 [수정됨] 우측 하단: 바로 재생 버튼 (기존 코드 유지 및 onClick 연결) */} - - -
+ {/* 재생 바 (Range Input으로 구현하여 드래그/클릭 완벽 지원) */}
- 01:12 -
-
-
+ {formatTime(position)} + +
+ { + isDragging.current = true; + setPosition(Number(e.target.value)); // 드래그 중 화면 즉시 업데이트 + }} + onMouseUp={handleSeekEnd} + onTouchEnd={handleSeekEnd} + className="absolute w-full h-1 opacity-0 cursor-pointer z-20" + // ↑ 투명한 진짜 input을 덮어씌워서 클릭 이벤트를 완벽하게 받습니다. + /> + {/* 시각적으로 보여주는 커스텀 바 */} +
+
+ {/* 동그란 핸들바 */} +
- 03:16 + + {formatTime(duration)}
+ {/* 3. 우측 볼륨 영역 */}
- -
-
+ {volume === 0 ? : } + +
+ setIsVolumeDragging(true)} + onTouchStart={() => setIsVolumeDragging(true)} + onMouseUp={handleVolumeEnd} // 🌟 마우스를 뗐을 때 봇으로 전송 + onTouchEnd={handleVolumeEnd} // 🌟 스마트폰 터치를 뗐을 때 봇으로 전송 + className="absolute w-full h-1 opacity-0 cursor-pointer z-20" + /> + {/* 커스텀 볼륨 바 디자인 */} +
+
+
+ {/* 👇 [수정할 부분] 핸들바 안에 말풍선 툴팁 코드 추가 */} +
+ {/* 드래그 중(isVolumeDragging === true)일 때만 나타나는 숫자 말풍선 */} + {isVolumeDragging && ( +
+ {volume} +
+ )} +
+ ); } \ No newline at end of file diff --git a/page/src/components/player/QueueSidebar.tsx b/page/src/components/player/QueueSidebar.tsx index 97f27bd..7f97db6 100644 --- a/page/src/components/player/QueueSidebar.tsx +++ b/page/src/components/player/QueueSidebar.tsx @@ -1,24 +1,258 @@ "use client"; +import { useState, useRef, useEffect, useCallback } from "react"; +import { Trash2, GripVertical, Music } from "lucide-react"; +import { useSession } from "next-auth/react"; + +interface QueueSidebarProps { + selectedServer: any; +} + +export default function QueueSidebar({ selectedServer }: QueueSidebarProps) { + const { data: session, status } = useSession(); + + const [queue, setQueue] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + const [draggingIndex, setDraggingIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + + const dragItem = useRef(null); + const dragOverItem = useRef(null); + + const fetchQueue = useCallback(async () => { + if (!selectedServer) return; + + const userId = (session?.user as any)?.id; + if (!userId) return; + + setIsLoading(true); + try { + const res = await fetch('/api/queue/list', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverId: selectedServer.id, + userId: userId, + }) + }); + const data = await res.json(); + if (res.ok && data.success && Array.isArray(data.queue)) { + setQueue(data.queue); + } else { + setQueue([]); + } + } catch (error) { + console.error("큐 불러오기 실패:", error); + setQueue([]); + } finally { + setIsLoading(false); + } + }, [selectedServer, session]); + + useEffect(() => { + if (status === "loading" || !selectedServer) return; + + fetchQueue(); + + const eventSource = new EventSource(`/api/queue/events?serverId=${selectedServer.id}`); + + eventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + if (data.type === "queue_update") { + fetchQueue(); + } + }; + + eventSource.onerror = (error) => { + console.error("SSE 연결 오류:", error); + eventSource.close(); + }; + + return () => { + eventSource.close(); + }; + }, [selectedServer, status, fetchQueue]); + + // 🌟 [수정됨] 드래그 앤 드롭 정렬 함수 + const handleSort = async () => { + const fromIndex = dragItem.current; + const toIndex = dragOverItem.current; + + // 1. 마우스를 떼는 즉시 UI 애니메이션을 원래대로 되돌립니다. + setDraggingIndex(null); + setDragOverIndex(null); + dragItem.current = null; + dragOverItem.current = null; + + // 드래그 위치가 없거나 서버가 없으면 종료 + if (fromIndex === null || toIndex === null || !selectedServer) { + return; + } + + // 2. 실제 노래가 삽입될 최종 인덱스 계산 + let insertIndex = toIndex; + if (fromIndex < toIndex && toIndex !== queue.length) { + insertIndex = toIndex - 1; + } + + // 🌟 [신규 추가] 결과적으로 순서가 바뀌지 않았다면 여기서 바로 함수를 종료합니다! (API 요청 안 함) + if (fromIndex === insertIndex) { + console.log("순서가 변경되지 않아서 통신을 건너뜁니다."); + return; + } + + // --- 여기서부터는 진짜 순서가 바뀌었을 때만 실행됩니다 --- + const userId = (session?.user as any)?.id; + + // 3. 화면 즉시 업데이트 + const newQueue = [...queue]; + const draggedItem = newQueue.splice(fromIndex, 1)[0]; + newQueue.splice(insertIndex, 0, draggedItem); + setQueue(newQueue); + + // 4. 서버(API)에 변경된 배열 전송 + try { + const res = await fetch('/api/queue/set', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverId: selectedServer.id, + userId: userId, + newQueue: newQueue + }) + }); + const data = await res.json(); + if (!res.ok || !data.success) { + alert(data.message || data.error || "요청 처리 중 문제가 발생했습니다."); + fetchQueue(); // 실패 시 원래 큐로 복구 + } + } catch (error) { + console.error("순서 동기화 실패:", error); + alert("순서 변경을 서버에 적용하지 못했습니다."); + fetchQueue(); + } + }; + + const handleDelete = async (indexToRemove: number) => { + if (!selectedServer) return; + const userId = (session?.user as any)?.id; + + const newQueue = queue.filter((_, index) => index !== indexToRemove); + setQueue(newQueue); + + try { + const res = await fetch('/api/queue/remove', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + serverId: selectedServer.id, + userId: userId, + index: indexToRemove, + }) + }); + const data = await res.json(); + if (!res.ok || !data.success) { + alert(data.message || data.error || "요청 처리 중 문제가 발생했습니다."); + fetchQueue(); + } + } catch (error) { + console.error("삭제 실패:", error); + alert("노래를 삭제하지 못했습니다."); + fetchQueue(); + } + }; -export default function QueueSidebar() { return ( ); } \ No newline at end of file