대부분 모든기능 제작
하단 player연결, 일시정지, 스킵, 볼륨, 특정시간재생, queue 디자인 변경, queue 변경기능 제작
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { Guild, Message, TextChannel } from "discord.js";
|
import { Guild, Message, TextChannel } from "discord.js";
|
||||||
import { LoadType, Player, Track, TrackEndEvent } from "shoukaku";
|
import { LoadType, Player, Track, TrackEndEvent, TrackStartEvent } from "shoukaku";
|
||||||
import { client, lavalinkManager } from "../index";
|
import { client, lavalinkManager, Redis } from "../index";
|
||||||
import { timeFormat } from "../utils/music/Utils";
|
import { timeFormat } from "../utils/music/Utils";
|
||||||
import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
|
import { default_content, default_embed, default_image, getButtons } from "../utils/music/Config";
|
||||||
import { GuildType } from "../../db/db";
|
import { GuildType } from "../../db/db";
|
||||||
@@ -30,6 +30,11 @@ export class GuildPlayer {
|
|||||||
public msg: Message,
|
public msg: Message,
|
||||||
) {
|
) {
|
||||||
this.player.setGlobalVolume(50);
|
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) => {
|
this.player.on("end", async (data: TrackEndEvent) => {
|
||||||
if (this.isDead) return;
|
if (this.isDead) return;
|
||||||
if (data.reason === "replaced") return;
|
if (data.reason === "replaced") return;
|
||||||
@@ -65,6 +70,13 @@ export class GuildPlayer {
|
|||||||
public get isRecommend(): boolean {
|
public get isRecommend(): boolean {
|
||||||
return this.GDB?.options.recommend ?? false;
|
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) {
|
public async addTrack(track: Track, userId: string) {
|
||||||
this.queue.push({ ...track, userId });
|
this.queue.push({ ...track, userId });
|
||||||
@@ -73,7 +85,7 @@ export class GuildPlayer {
|
|||||||
await this.playNext();
|
await this.playNext();
|
||||||
} else {
|
} else {
|
||||||
// 재생목록 추가되었을때
|
// 재생목록 추가되었을때
|
||||||
this.setMsg();
|
this.setMsg({ player: false, queue: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,7 +97,7 @@ export class GuildPlayer {
|
|||||||
await this.playNext();
|
await this.playNext();
|
||||||
} else {
|
} else {
|
||||||
// 재생목록 추가되었을때
|
// 재생목록 추가되었을때
|
||||||
this.setMsg();
|
this.setMsg({ player: false, queue: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,10 +112,10 @@ export class GuildPlayer {
|
|||||||
public async setPause() {
|
public async setPause() {
|
||||||
if (this.isPaused) {
|
if (this.isPaused) {
|
||||||
await this.player.setPaused(false);
|
await this.player.setPaused(false);
|
||||||
this.setMsg();
|
this.setMsg({ player: true, queue: false });
|
||||||
} else if (this.isPlaying) {
|
} else if (this.isPlaying) {
|
||||||
await this.player.setPaused(true);
|
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);
|
let nowQueue = this.queue.slice(1);
|
||||||
nowQueue = shuffle(nowQueue);
|
nowQueue = shuffle(nowQueue);
|
||||||
this.queue = [this.queue[0], ...nowQueue];
|
this.queue = [this.queue[0], ...nowQueue];
|
||||||
this.setMsg();
|
this.setMsg({ player: false, queue: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
public setRecommend() {
|
public setRecommend() {
|
||||||
if (!this.GDB) return;
|
if (!this.GDB) return;
|
||||||
this.GDB.options.recommend = !this.GDB.options.recommend;
|
this.GDB.options.recommend = !this.GDB.options.recommend;
|
||||||
DB.guild.update(this.GDB);
|
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) {
|
public seek(num: number) {
|
||||||
if (!this.isPlaying) return;
|
if (!this.isPlaying) return;
|
||||||
if (!this.nowTrack) return;
|
if (!this.nowTrack) return;
|
||||||
this.player.seekTo(num);
|
this.player.seekTo(num);
|
||||||
|
Redis.publishState("player_update", { guildId: this.guild.id });
|
||||||
}
|
}
|
||||||
|
|
||||||
private async autoPlay() {
|
private async autoPlay() {
|
||||||
@@ -189,7 +209,9 @@ export class GuildPlayer {
|
|||||||
lavalinkManager.shoukaku.leaveVoiceChannel(this.guild.id);
|
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);
|
const { channel, msg, check } = await checkTextChannelAndMsg(this.guild, this.textChannel, this.msg);
|
||||||
if (!check) return;
|
if (!check) return;
|
||||||
this.textChannel = channel;
|
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 ] : [],
|
files: embed.data.image?.url === default_embed(this.guild.id).data.image?.url ? [ default_image ] : [],
|
||||||
components: [ getButtons(
|
components: [ getButtons(
|
||||||
this.isPlaying || this.isPaused,
|
this.isPlaying || this.isPaused,
|
||||||
this.isPaused,
|
this.isPlaying && this.isPaused,
|
||||||
realQueue.length > 1,
|
realQueue.length > 1,
|
||||||
) ]
|
) ]
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,21 @@ import { Spotify } from "../utils/api/Spotify";
|
|||||||
import { lavalinkManager } from "../index";
|
import { lavalinkManager } from "../index";
|
||||||
import { getGuildById, getVoiceChannelById } from "../utils/music/Channel";
|
import { getGuildById, getVoiceChannelById } from "../utils/music/Channel";
|
||||||
import { channelJoin } from "../commands/join";
|
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 {
|
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 });
|
||||||
@@ -38,7 +51,7 @@ export class RedisClient {
|
|||||||
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") {
|
if (data.action === "player_play") {
|
||||||
const resultKey = `player:play:${data.requestId}`;
|
const resultKey = `player:play:${data.requestId}`;
|
||||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
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.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: "세션을 찾을수 없습니다." }));
|
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 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") {
|
if (data.action === "player_playlist") {
|
||||||
const resultKey = `player:play:${data.requestId}`;
|
const resultKey = `player:playlist:${data.requestId}`;
|
||||||
if (!data.serverId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "serverId를 찾을수 없습니다." }));
|
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.userId) return await this.pub.setex(resultKey, 60, JSON.stringify({ success: false, message: "userId를 찾을수 없습니다." }));
|
||||||
const guild = await getGuildById(data.serverId);
|
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: "세션을 찾을수 없습니다." }));
|
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 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) {
|
} catch (err) {
|
||||||
Logger.error(`명령어 처리 중 에러: ${String(err)}`);
|
Logger.error(`명령어 처리 중 에러: ${String(err)}`);
|
||||||
@@ -93,6 +211,38 @@ export class RedisClient {
|
|||||||
Logger.log(`[Redis Pub] bot -> site 전송: ${event}`);
|
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() {
|
public runTest() {
|
||||||
Logger.debug(`[Redis Test] 3초 뒤에 테스트 통신 시작...`);
|
Logger.debug(`[Redis Test] 3초 뒤에 테스트 통신 시작...`);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|||||||
@@ -124,7 +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];
|
||||||
if (track.url) searchCache.set(lowerQuery, track.url);
|
if (track?.url) searchCache.set(lowerQuery, track.url);
|
||||||
return track.url;
|
return track?.url ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -211,7 +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];
|
||||||
if (video.url) searchCache.set(lowerQuery, video.url);
|
if (video?.url) searchCache.set(lowerQuery, video.url);
|
||||||
return video.url;
|
return video?.url ?? null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
54
page/src/app/api/player/events/route.ts
Normal file
54
page/src/app/api/player/events/route.ts
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
42
page/src/app/api/player/now/route.ts
Normal file
42
page/src/app/api/player/now/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
43
page/src/app/api/player/pause/route.ts
Normal file
43
page/src/app/api/player/pause/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
44
page/src/app/api/player/seek/route.ts
Normal file
44
page/src/app/api/player/seek/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, 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
42
page/src/app/api/player/skip/route.ts
Normal file
42
page/src/app/api/player/skip/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
44
page/src/app/api/player/volume/route.ts
Normal file
44
page/src/app/api/player/volume/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, 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
54
page/src/app/api/queue/events/route.ts
Normal file
54
page/src/app/api/queue/events/route.ts
Normal file
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
44
page/src/app/api/queue/list/route.ts
Normal file
44
page/src/app/api/queue/list/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 } = 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
46
page/src/app/api/queue/remove/route.ts
Normal file
46
page/src/app/api/queue/remove/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
47
page/src/app/api/queue/set/route.ts
Normal file
47
page/src/app/api/queue/set/route.ts
Normal file
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,10 +57,10 @@ export default function MusicPlayerLayout() {
|
|||||||
setSearchQuery={setSearchQuery}
|
setSearchQuery={setSearchQuery}
|
||||||
onSelectServer={handleSelectServer}
|
onSelectServer={handleSelectServer}
|
||||||
/>
|
/>
|
||||||
<QueueSidebar />
|
<QueueSidebar selectedServer={selectedServer} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<PlayerBar />
|
<PlayerBar selectedServer={selectedServer} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -38,7 +38,6 @@ export default function MainContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 🌟 [추가된 부분] next-auth 세션에서 유저 식별자 가져오기
|
// 🌟 [추가된 부분] next-auth 세션에서 유저 식별자 가져오기
|
||||||
// (NextAuth 설정에 따라 id가 없을 수도 있으므로 없을 경우 name을 대체로 사용합니다)
|
|
||||||
const userId = (session?.user as any)?.id;
|
const userId = (session?.user as any)?.id;
|
||||||
|
|
||||||
let endpoint = "";
|
let endpoint = "";
|
||||||
@@ -61,7 +60,7 @@ export default function MainContent({
|
|||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok && data.success) {
|
if (res.ok && data.success) {
|
||||||
alert(data.message || "요청이 성공했습니다.");
|
// alert(data.message || "요청이 성공했습니다.");
|
||||||
} else {
|
} else {
|
||||||
alert(data.message || data.error || "요청 처리 중 문제가 발생했습니다.");
|
alert(data.message || data.error || "요청 처리 중 문제가 발생했습니다.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,364 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { SkipForward, SkipBack, Volume2, Pause } from "lucide-react";
|
import { useState, useEffect, useCallback, useRef } from "react";
|
||||||
|
import { SkipForward, SkipBack, Volume2, VolumeX, Pause, Play } from "lucide-react";
|
||||||
|
import { useSession } from "next-auth/react";
|
||||||
|
|
||||||
|
interface PlayerBarProps {
|
||||||
|
selectedServer: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PlayerBar({ selectedServer }: PlayerBarProps) {
|
||||||
|
const { data: session } = useSession();
|
||||||
|
|
||||||
|
// 재생 상태 관리
|
||||||
|
const [track, setTrack] = useState<any>(null);
|
||||||
|
const [botPlayer, setBotPlayer] = useState<boolean>(false);
|
||||||
|
const [isPlaying, setIsPlaying] = useState<boolean>(false);
|
||||||
|
const [isPaused, setIsPaused] = useState<boolean>(false);
|
||||||
|
const [volume, setVolume] = useState<number>(50);
|
||||||
|
|
||||||
|
// 시간 및 진행도 관리
|
||||||
|
const [position, setPosition] = useState<number>(0);
|
||||||
|
const [duration, setDuration] = useState<number>(0);
|
||||||
|
const isDragging = useRef<boolean>(false); // 재생바를 드래그 중인지 여부
|
||||||
|
// 👇 [추가할 부분] 볼륨 바를 잡고 있는지 여부 추적
|
||||||
|
const [isVolumeDragging, setIsVolumeDragging] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// 1. 현재 재생 상태 불러오기 (player_now)
|
||||||
|
const fetchNowPlaying = useCallback(async () => {
|
||||||
|
if (!selectedServer) return;
|
||||||
|
|
||||||
|
const userId = (session?.user as any)?.id;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/now', {
|
||||||
|
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 && data.track) {
|
||||||
|
setBotPlayer(data.botPlayer);
|
||||||
|
setIsPlaying(data.isPlaying);
|
||||||
|
setIsPaused(data.isPaused);
|
||||||
|
setTrack(data.track);
|
||||||
|
setDuration(data.track.info.length || 0);
|
||||||
|
setVolume(data.volume ?? 50);
|
||||||
|
// 드래그 중이 아닐 때만 서버 시간으로 동기화 (안 그러면 드래그할 때 튐)
|
||||||
|
if (!isDragging.current) {
|
||||||
|
setPosition(data.position || 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setTrack(null);
|
||||||
|
setIsPlaying(false);
|
||||||
|
setPosition(0);
|
||||||
|
setDuration(0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("재생 정보 불러오기 실패:", error);
|
||||||
|
}
|
||||||
|
}, [selectedServer, session]);
|
||||||
|
|
||||||
|
// 2. 초기 로드 및 SSE 실시간 업데이트 수신
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedServer) return;
|
||||||
|
fetchNowPlaying();
|
||||||
|
|
||||||
|
// 봇에서 "곡 변경", "일시정지" 등의 이벤트가 발생하면 새로고침하라는 신호
|
||||||
|
const eventSource = new EventSource(`/api/player/events?serverId=${selectedServer.id}`);
|
||||||
|
eventSource.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
if (data.type === "player_update") {
|
||||||
|
fetchNowPlaying();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return () => eventSource.close();
|
||||||
|
}, [selectedServer, fetchNowPlaying]);
|
||||||
|
|
||||||
|
// 3. 🌟 로컬 1초 타이머 & 10초 서버 동기화 통합 (재생 중일 때만 작동!)
|
||||||
|
useEffect(() => {
|
||||||
|
let localInterval: NodeJS.Timeout;
|
||||||
|
let syncInterval: NodeJS.Timeout;
|
||||||
|
|
||||||
|
// 노래가 재생 중이고, 유저가 재생바를 잡고 있지 않을 때만 타이머들을 가동합니다.
|
||||||
|
if (isPlaying && !isDragging.current) {
|
||||||
|
|
||||||
|
// ① 1초마다 프론트엔드 단독으로 시계 굴리기 (부드러운 애니메이션용)
|
||||||
|
localInterval = setInterval(() => {
|
||||||
|
if (!isPaused) setPosition((prev) => {
|
||||||
|
if (prev >= duration) return duration;
|
||||||
|
return prev + 1000;
|
||||||
|
});
|
||||||
|
}, 1000);
|
||||||
|
|
||||||
|
// ② 10초마다 진짜 시간 서버에 물어보기 (오차 교정용)
|
||||||
|
syncInterval = setInterval(() => {
|
||||||
|
if (!isPaused) fetchNowPlaying();
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일시정지되거나 컴포넌트가 꺼지면 두 타이머 모두 깔끔하게 청소합니다.
|
||||||
|
return () => {
|
||||||
|
clearInterval(localInterval);
|
||||||
|
clearInterval(syncInterval);
|
||||||
|
};
|
||||||
|
}, [isPlaying, duration, fetchNowPlaying]);
|
||||||
|
|
||||||
|
|
||||||
|
// ================= [ 컨트롤러 액션 ] =================
|
||||||
|
|
||||||
|
// 재생/일시정지 토글 (player_pause)
|
||||||
|
const handleTogglePause = async () => {
|
||||||
|
if (!selectedServer || !track) return;
|
||||||
|
if (!isPlaying) return;
|
||||||
|
const userId = (session?.user as any)?.id;
|
||||||
|
// UI 즉각 반영 (Optimistic UI)
|
||||||
|
setIsPaused(!isPaused);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/player/pause', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: selectedServer.id,
|
||||||
|
userId: userId,
|
||||||
|
isPaused: String(isPaused),
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok && data.success) {
|
||||||
|
if (data.isPaused?.trim().toLocaleLowerCase() === "true") {
|
||||||
|
setIsPaused(true);
|
||||||
|
} else {
|
||||||
|
setIsPaused(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("일시정지 에러:", error);
|
||||||
|
setIsPaused(!isPaused); // 실패 시 롤백
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 다음 곡 스킵 (player_skip)
|
||||||
|
const handleSkip = async () => {
|
||||||
|
if (!selectedServer || !track) return;
|
||||||
|
const userId = (session?.user as any)?.id;
|
||||||
|
try {
|
||||||
|
await fetch('/api/player/skip', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: selectedServer.id,
|
||||||
|
userId: userId,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
// 성공하면 곧 SSE 이벤트가 와서 fetchNowPlaying을 트리거하겠지만, 즉각 반응을 위해 찔러줌
|
||||||
|
setTimeout(fetchNowPlaying, 500);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("스킵 에러:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🌟 [수정됨] 마우스 이벤트와 터치 이벤트를 모두 허용하도록 타입 변경
|
||||||
|
const handleSeekEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
||||||
|
if (!selectedServer || !track) return;
|
||||||
|
const userId = (session?.user as any)?.id;
|
||||||
|
isDragging.current = false;
|
||||||
|
|
||||||
|
// 🌟 [수정됨] e.target 대신 e.currentTarget을 사용해야 타입 에러가 나지 않습니다.
|
||||||
|
const newPosition = Number(e.currentTarget.value);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/player/seek', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: selectedServer.id,
|
||||||
|
seek: newPosition,
|
||||||
|
userId: userId,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("시간 이동 에러:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 볼륨 조절 (player_volume)
|
||||||
|
const handleVolumeChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (!selectedServer) return;
|
||||||
|
setVolume(Number(e.target.value)); // UI 즉시 반영
|
||||||
|
};
|
||||||
|
|
||||||
|
// 🌟 [신규 추가] 드래그가 끝났을 때 (마우스를 뗐을 때) 딱 한 번 API 전송
|
||||||
|
const handleVolumeEnd = async (e: React.MouseEvent<HTMLInputElement> | React.TouchEvent<HTMLInputElement>) => {
|
||||||
|
setIsVolumeDragging(false);
|
||||||
|
if (!selectedServer) return;
|
||||||
|
const userId = (session?.user as any)?.id;
|
||||||
|
const finalVolume = Number(e.currentTarget.value);
|
||||||
|
setVolume(finalVolume); // UI 즉시 반영
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch('/api/player/volume', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
serverId: selectedServer.id,
|
||||||
|
userId: userId,
|
||||||
|
volume: finalVolume,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("볼륨 조절 에러:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 시간 포맷 변환 함수 (ms -> mm:ss)
|
||||||
|
const formatTime = (ms: number) => {
|
||||||
|
if (!ms || isNaN(ms)) return "00:00";
|
||||||
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
|
const seconds = totalSeconds % 60;
|
||||||
|
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 진행도 퍼센트 계산 (0 ~ 100)
|
||||||
|
const progressPercent = duration > 0 ? (position / duration) * 100 : 0;
|
||||||
|
|
||||||
export default function PlayerBar() {
|
|
||||||
return (
|
return (
|
||||||
<footer className="h-24 flex-shrink-0 bg-black border-t border-neutral-800 px-6 flex items-center justify-between z-50">
|
<footer className="h-24 flex-shrink-0 bg-black border-t border-neutral-800 px-6 flex items-center justify-between z-50">
|
||||||
|
|
||||||
|
{/* 1. 노래 정보 영역 */}
|
||||||
<div className="flex items-center gap-4 w-1/4">
|
<div className="flex items-center gap-4 w-1/4">
|
||||||
<div className="w-14 h-14 bg-neutral-800 rounded-md shadow-md"></div>
|
{track ? (
|
||||||
<div className="flex flex-col">
|
<>
|
||||||
<span className="text-sm font-bold text-white hover:underline cursor-pointer">내 손을 잡아</span>
|
<div className="w-14 h-14 bg-neutral-800 rounded-md shadow-md overflow-hidden">
|
||||||
<span className="text-xs text-neutral-400 hover:underline cursor-pointer">아이유(IU)</span>
|
{track.info?.artworkUrl && <img src={track.info.artworkUrl} alt="cover" className="w-full h-full object-cover" />}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col overflow-hidden">
|
||||||
|
<span className="text-sm font-bold text-white truncate cursor-pointer hover:underline">{track.info?.title}</span>
|
||||||
|
<span className="text-xs text-neutral-400 truncate cursor-pointer hover:underline">{track.info?.author?.replace(" - Topic", "")}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// 재생 중인 곡이 없을 때 빈 공간
|
||||||
|
<div className="flex items-center gap-4 text-neutral-600">
|
||||||
|
<div className="w-14 h-14 bg-neutral-900 rounded-md"></div>
|
||||||
|
<span className="text-sm">재생 중인 곡 없음</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 2. 중앙 컨트롤러 영역 */}
|
||||||
<div className="flex flex-col items-center gap-2 w-2/4 max-w-2xl">
|
<div className="flex flex-col items-center gap-2 w-2/4 max-w-2xl">
|
||||||
<div className="flex items-center gap-6">
|
<div className="flex items-center gap-6">
|
||||||
<button className="text-neutral-400 hover:text-white transition-colors">
|
{/* 이전 곡 (비활성화 상태) */}
|
||||||
|
<button className="text-neutral-600 cursor-not-allowed">
|
||||||
<SkipBack size={20} />
|
<SkipBack size={20} />
|
||||||
</button>
|
</button>
|
||||||
<button className="w-8 h-8 rounded-full bg-white flex items-center justify-center hover:scale-105 transition-transform text-black">
|
|
||||||
<Pause size={18} fill="black" />
|
{/* 재생/일시정지 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={handleTogglePause}
|
||||||
|
disabled={!botPlayer || !isPlaying || !track}
|
||||||
|
className={`w-8 h-8 rounded-full flex items-center justify-center transition-transform text-black
|
||||||
|
${track ? 'bg-white hover:scale-105' : 'bg-neutral-700 cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
|
{!isPaused ? <Pause size={18} fill="black" /> : <Play size={18} fill="black" className="ml-0.5" />}
|
||||||
</button>
|
</button>
|
||||||
<button className="text-neutral-400 hover:text-white transition-colors">
|
|
||||||
|
{/* 다음 곡 스킵 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={handleSkip}
|
||||||
|
disabled={!botPlayer || !isPlaying || !track}
|
||||||
|
className={`transition-colors ${isPlaying && track ? 'text-neutral-400 hover:text-white' : 'text-neutral-600 cursor-not-allowed'}`}
|
||||||
|
>
|
||||||
<SkipForward size={20} />
|
<SkipForward size={20} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 재생 바 (Range Input으로 구현하여 드래그/클릭 완벽 지원) */}
|
||||||
<div className="flex items-center gap-2 w-full text-xs text-neutral-400">
|
<div className="flex items-center gap-2 w-full text-xs text-neutral-400">
|
||||||
<span>01:12</span>
|
<span className="w-10 text-right">{formatTime(position)}</span>
|
||||||
<div className="h-1 bg-neutral-600 rounded-full flex-1 cursor-pointer group">
|
|
||||||
<div className="h-full bg-white rounded-full w-1/3 group-hover:bg-green-500 transition-colors relative">
|
<div className="flex-1 flex items-center group relative h-4">
|
||||||
<div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md"></div>
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={duration || 100}
|
||||||
|
value={position}
|
||||||
|
disabled={!botPlayer || !isPlaying || !track}
|
||||||
|
onChange={(e) => {
|
||||||
|
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을 덮어씌워서 클릭 이벤트를 완벽하게 받습니다.
|
||||||
|
/>
|
||||||
|
{/* 시각적으로 보여주는 커스텀 바 */}
|
||||||
|
<div className="w-full h-1 bg-neutral-600 rounded-full overflow-hidden absolute z-10 pointer-events-none">
|
||||||
|
<div
|
||||||
|
className="h-full bg-white group-hover:bg-green-500 transition-colors pointer-events-none"
|
||||||
|
style={{ width: `${progressPercent}%` }}
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
|
{/* 동그란 핸들바 */}
|
||||||
|
<div
|
||||||
|
className="absolute w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md pointer-events-none z-10 -ml-1.5"
|
||||||
|
style={{ left: `${progressPercent}%` }}
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
<span>03:16</span>
|
|
||||||
|
<span className="w-10">{formatTime(duration)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 3. 우측 볼륨 영역 */}
|
||||||
<div className="flex items-center justify-end gap-3 w-1/4 text-neutral-400">
|
<div className="flex items-center justify-end gap-3 w-1/4 text-neutral-400">
|
||||||
<Volume2 size={20} />
|
{volume === 0 ? <VolumeX size={20} /> : <Volume2 size={20} />}
|
||||||
<div className="w-24 h-1 bg-neutral-600 rounded-full cursor-pointer group">
|
|
||||||
<div className="h-full bg-white rounded-full w-2/3 group-hover:bg-green-500 transition-colors"></div>
|
<div className="w-24 h-4 flex items-center group relative">
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
value={volume}
|
||||||
|
disabled={!botPlayer || !isPlaying || !track}
|
||||||
|
onChange={handleVolumeChange} // 눈에 보이는 볼륨만 즉시 변경
|
||||||
|
onMouseDown={() => setIsVolumeDragging(true)}
|
||||||
|
onTouchStart={() => setIsVolumeDragging(true)}
|
||||||
|
onMouseUp={handleVolumeEnd} // 🌟 마우스를 뗐을 때 봇으로 전송
|
||||||
|
onTouchEnd={handleVolumeEnd} // 🌟 스마트폰 터치를 뗐을 때 봇으로 전송
|
||||||
|
className="absolute w-full h-1 opacity-0 cursor-pointer z-20"
|
||||||
|
/>
|
||||||
|
{/* 커스텀 볼륨 바 디자인 */}
|
||||||
|
<div className="w-full h-1 bg-neutral-600 rounded-full overflow-hidden absolute z-10 pointer-events-none">
|
||||||
|
<div
|
||||||
|
className="h-full bg-white group-hover:bg-green-500 transition-colors pointer-events-none"
|
||||||
|
style={{ width: `${volume}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/* 👇 [수정할 부분] 핸들바 안에 말풍선 툴팁 코드 추가 */}
|
||||||
|
<div
|
||||||
|
className="absolute w-3 h-3 bg-white rounded-full opacity-0 group-hover:opacity-100 shadow-md pointer-events-none z-10 -ml-1.5 flex justify-center"
|
||||||
|
style={{ left: `${volume}%` }}
|
||||||
|
>
|
||||||
|
{/* 드래그 중(isVolumeDragging === true)일 때만 나타나는 숫자 말풍선 */}
|
||||||
|
{isVolumeDragging && (
|
||||||
|
<div className="absolute bottom-4 bg-neutral-800 text-white text-[10px] font-bold py-1 px-2 rounded shadow-lg border border-neutral-700 whitespace-nowrap animate-in fade-in zoom-in duration-150">
|
||||||
|
{volume}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,24 +1,258 @@
|
|||||||
"use client";
|
"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<any[]>([]);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
|
||||||
|
const [draggingIndex, setDraggingIndex] = useState<number | null>(null);
|
||||||
|
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const dragItem = useRef<number | null>(null);
|
||||||
|
const dragOverItem = useRef<number | null>(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 (
|
return (
|
||||||
<aside className="w-80 bg-black p-6 border-l border-neutral-800 flex flex-col">
|
<aside className="w-80 bg-black p-6 border-l border-neutral-800 flex flex-col">
|
||||||
<h2 className="text-lg font-bold mb-6">현재 재생 목록</h2>
|
<h2 className="text-lg font-bold mb-6">현재 재생 목록</h2>
|
||||||
<div className="flex flex-col gap-4 overflow-y-auto">
|
|
||||||
{[1, 2, 3, 4, 5].map((item) => (
|
{queue.length === 0 ? (
|
||||||
<div key={item} className="flex items-center gap-3 group cursor-pointer">
|
<div className="flex flex-col items-center justify-center text-neutral-500 h-full gap-2">
|
||||||
<div className="w-10 h-10 bg-neutral-800 rounded flex-shrink-0"></div>
|
<Music size={32} className="opacity-50" />
|
||||||
<div className="flex flex-col overflow-hidden">
|
<p className="text-sm">대기열에 노래가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col overflow-y-auto pr-2 pb-10">
|
||||||
|
{queue.map((item, index) => {
|
||||||
|
const isDropTarget = dragOverIndex === index && draggingIndex !== index;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${item.id || index}-${item.info?.title || ''}`}
|
||||||
|
draggable
|
||||||
|
onDragStart={() => {
|
||||||
|
dragItem.current = index;
|
||||||
|
// 🌟 마우스 잔상(고스트)이 까맣게 캡처되는 것을 막기 위한 0초 딜레이 트릭
|
||||||
|
setTimeout(() => setDraggingIndex(index), 0);
|
||||||
|
}}
|
||||||
|
onDragEnter={() => {
|
||||||
|
dragOverItem.current = index;
|
||||||
|
setDragOverIndex(index);
|
||||||
|
}}
|
||||||
|
onDragEnd={handleSort}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
className={`relative flex items-center gap-3 group bg-neutral-900/50 p-2 rounded-md transition-all duration-200 ease-in-out cursor-grab active:cursor-grabbing border border-transparent hover:border-neutral-700 my-1
|
||||||
|
${draggingIndex === index ? 'opacity-30 scale-95 shadow-inner' : 'opacity-100'}
|
||||||
|
${isDropTarget ? 'mt-10' : 'mt-1'}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* 초록색 드롭 인디케이터 선 */}
|
||||||
|
{isDropTarget && (
|
||||||
|
<div className="absolute -top-5 left-0 right-0 h-0.5 bg-green-500 rounded-full shadow-[0_0_8px_rgba(34,197,94,0.8)] z-10" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-neutral-600 group-hover:text-neutral-400">
|
||||||
|
<GripVertical size={16} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="w-10 h-10 bg-neutral-700 rounded flex-shrink-0 overflow-hidden shadow-md">
|
||||||
|
{item.info?.artworkUrl ? (
|
||||||
|
<img src={item.info.artworkUrl} alt="" className="w-full h-full object-cover" />
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col overflow-hidden flex-1 pointer-events-none">
|
||||||
<span className="text-sm font-medium text-white truncate group-hover:text-green-500 transition-colors">
|
<span className="text-sm font-medium text-white truncate group-hover:text-green-500 transition-colors">
|
||||||
{item === 1 ? "Hype Boy" : "Supernova"}
|
{item.info?.title || "제목 없음"}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-neutral-400 truncate">
|
<span className="text-xs text-neutral-400 truncate">
|
||||||
{item === 1 ? "NewJeans" : "aespa"}
|
{item.info?.author?.replace(" - Topic", "") || "알 수 없는 아티스트"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleDelete(index);
|
||||||
|
}}
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-neutral-500 hover:text-red-500 transition-all p-1"
|
||||||
|
title="대기열에서 삭제"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* 🌟 [신규 추가] 맨 아래로 드래그하기 위한 전용 투명 드롭존 */}
|
||||||
|
{draggingIndex !== null && (
|
||||||
|
<div
|
||||||
|
onDragEnter={() => {
|
||||||
|
dragOverItem.current = queue.length;
|
||||||
|
setDragOverIndex(queue.length);
|
||||||
|
}}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
className={`relative h-12 flex items-center justify-center transition-all ${dragOverIndex === queue.length ? 'mt-4' : 'mt-0'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{dragOverIndex === queue.length && (
|
||||||
|
<div className="absolute top-1/2 left-0 right-0 h-0.5 bg-green-500 rounded-full shadow-[0_0_8px_rgba(34,197,94,0.8)] z-10" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user