import { DisplayComponentV2Builder } from "../lib/displayComponents/builders/v2Builder"; import { LikeService } from "../services/LikeService"; import { PlaylistService } from "../services/PlaylistService"; import { MusicHistoryService } from "../services/MusicHistoryService"; import { isAutoplayEnabledForGuild } from "../../commands/messages/music/autoplay"; import { isShuffleEnabled, getRepeatMode, getRepeatModeLabel, getRepeatModeEmoji } from "../services/MusicStateService"; interface TrackInfo { title: string; author: string; duration: number; trackId: string; thumbnail?: string; } /** * Creates ComponentsV2 messages for "Now Playing" with interactive buttons and playlist SelectMenu * Returns array of 2 containers: [info container, buttons+menu container] */ export async function createNowPlayingMessage( trackInfo: TrackInfo, queueLength: number, userId: string, guildId: string, client: any // Add client parameter ) { // Create consistent trackId from title and author (same format as music_like.ts) const consistentTrackId = Buffer.from( `${trackInfo.title}:${trackInfo.author}` ) .toString("base64") .substring(0, 50); const isLiked = await LikeService.isTrackLiked( userId, guildId, consistentTrackId ); const durationText = formatDuration(trackInfo.duration); // Container 1: Track info with thumbnail const infoContainer = new DisplayComponentV2Builder().setAccentColor( 0x5865f2 ); // Discord blurple if (trackInfo.thumbnail) { // Use section with accessory for thumbnail infoContainer.addSection( [ { type: 10, content: `**Añadido a la cola**\n${trackInfo.title}\n${trackInfo.author} • Duración: ${durationText}`, }, ], { type: 11, media: { url: trackInfo.thumbnail } } ); } else { infoContainer.addText( `**Añadido a la cola**\n${trackInfo.title}\n${trackInfo.author} • Duración: ${durationText}` ); } // Container 2: "Now playing" text + buttons + SelectMenu const buttonsContainer = new DisplayComponentV2Builder() .setAccentColor(0x608beb) // Slightly different blue .addText("**Ahora reproduciendo:**") .addText(`${trackInfo.title} - ${trackInfo.author}`); // Create buttons const likeButton = DisplayComponentV2Builder.createButton( isLiked ? "music_unlike" : "music_like", isLiked ? "Te gusta" : "Like", isLiked ? 1 : 2, // 2 = secondary (gray) when liked, 1 = primary (blue) when not liked false, { name: "❤️" } ); // Check shuffle state const shuffleActive = isShuffleEnabled(guildId); const shuffleButton = DisplayComponentV2Builder.createButton( "music_shuffle", "Shuffle", shuffleActive ? 3 : 1, // 3 = success (green) when active, 1 = primary (blue) when inactive false, { name: "🔀" } ); // Check repeat mode const repeatModeValue = getRepeatMode(guildId); let repeatStyle = 1; // Default: blue if (repeatModeValue === 'one') repeatStyle = 3; // Green for repeat one if (repeatModeValue === 'all') repeatStyle = 4; // Red for repeat all const repeatButton = DisplayComponentV2Builder.createButton( "music_repeat", getRepeatModeLabel(repeatModeValue), repeatStyle, false, { name: getRepeatModeEmoji(repeatModeValue) } ); // Check if autoplay is enabled for this guild const isAutoplayEnabled = isAutoplayEnabledForGuild(guildId); const autoplayButton = DisplayComponentV2Builder.createButton( "music_autoplay_toggle", "Autoplay", isAutoplayEnabled ? 3 : 1, // 3 = success (green) when enabled, 1 = primary (blue) when disabled false, { name: "⏩" } ); // Add Skip button const skipButton = DisplayComponentV2Builder.createButton( "music_skip", "Skip", 2, // 2 = secondary (gray) false, { name: "⏭️" } ); // Add ActionRow with buttons (max 5 buttons per row) buttonsContainer.addActionRow([ likeButton, shuffleButton, repeatButton, autoplayButton, skipButton, ]); // Get user's playlists for SelectMenu const playlists = await PlaylistService.getUserPlaylists(userId, guildId); // Create SelectMenu options const selectOptions = [ { label: "Crear playlist nueva", value: "create_new", emoji: { name: "🔹" }, }, ]; // Add existing playlists playlists.forEach((playlist) => { selectOptions.push({ label: playlist.name, value: playlist.id, emoji: { name: playlist.isDefault ? "❤️" : "📝" }, }); }); // Create SelectMenu // Use only first 50 chars of trackId hash to stay under 100 char limit const trackIdHash = trackInfo.trackId.substring(0, 50); const selectMenu = DisplayComponentV2Builder.createSelectMenu( `music_save_select:${trackIdHash}`, "Guardar en playlist", selectOptions ); // Add SelectMenu in its own ActionRow buttonsContainer.addActionRow([selectMenu]); // Get user's listening history for second SelectMenu try { const history = await MusicHistoryService.getRecentHistory(userId, 50); // Remove duplicates by trackId (keep most recent) const uniqueTracksMap = new Map(); for (const track of history) { // Skip tracks without title or author if (!track.title || !track.author) continue; const trackId = track.trackId || `${track.title}:${track.author}`; if (!uniqueTracksMap.has(trackId)) { uniqueTracksMap.set(trackId, track); } } // Convert to array and take first 25 (Discord limit) const uniqueTracks = Array.from(uniqueTracksMap.values()).slice(0, 25); // Only create history menu if there are valid tracks if (uniqueTracks.length > 0) { // Create history select menu options with validation const historyOptions = uniqueTracks .filter(track => track.title && track.author) // Ensure valid data .map(track => ({ label: track.title.substring(0, 100), // Discord limit value: (track.trackId || `${track.title}:${track.author}`).substring(0, 100), // Discord limit description: track.author.substring(0, 100), emoji: { name: '🎵' } })); // Only add if we have valid options if (historyOptions.length > 0) { const historyMenu = DisplayComponentV2Builder.createSelectMenu( 'music_history_select', 'Reproducir del historial', historyOptions ); // Add history SelectMenu in its own ActionRow buttonsContainer.addActionRow([historyMenu]); } } } catch (error) { console.error("Error loading history for select menu:", error); // Continue without history menu if there's an error } // Add "Similar Songs" select menu (like YouTube's related videos) try { const { getSimilarTracks } = await import("../../commands/messages/music/autoplay.js"); const similarTracks = await getSimilarTracks(trackInfo, guildId, client); if (similarTracks && similarTracks.length > 0) { // Store similar tracks in Redis for the select menu handler const { redis } = await import("../../core/database/redis.js"); const redisKey = `music:similar:${guildId}`; await redis.set(redisKey, JSON.stringify(similarTracks), { EX: 3600 }); // Expire after 1 hour console.log(`[musicMessages] Stored ${similarTracks.length} similar tracks in Redis for guild ${guildId}`); // Create similar songs select menu options const similarOptions = similarTracks .slice(0, 15) // Limit to 15 similar tracks .map(track => ({ label: track.info.title.substring(0, 100), // Discord limit value: track.encoded.substring(0, 100), // Discord limit description: track.info.author.substring(0, 100), emoji: { name: '🔗' } // Link emoji for "related" })); if (similarOptions.length > 0) { const similarMenu = DisplayComponentV2Builder.createSelectMenu( 'music_similar', '🎵 Canciones similares', similarOptions ); // Add similar SelectMenu in its own ActionRow buttonsContainer.addActionRow([similarMenu]); } } } catch (error) { console.error("Error loading similar tracks for select menu:", error); // Continue without similar menu if there's an error } // Return both containers return [infoContainer.toJSON(), buttonsContainer.toJSON()]; } /** * Format duration from milliseconds to MM:SS or HH:MM:SS */ function formatDuration(ms: number): string { const seconds = Math.floor((ms / 1000) % 60); const minutes = Math.floor((ms / (1000 * 60)) % 60); const hours = Math.floor(ms / (1000 * 60 * 60)); if (hours > 0) { return `${hours}:${String(minutes).padStart(2, "0")}:${String( seconds ).padStart(2, "0")}`; } return `${minutes}:${String(seconds).padStart(2, "0")}`; }