Add screens and components for Song Battle game, including Home, Lobby, Voting, Results, and Song Submission screens; implement YouTube video embedding and styles
Some checks failed
Publish Docker image / Push Docker image to Docker Hub (push) Failing after 7m12s

This commit is contained in:
2025-04-24 16:21:43 +02:00
parent 44a75ba715
commit 22eca7d4e0
28 changed files with 5046 additions and 402 deletions

853
server/game.js Normal file
View File

@ -0,0 +1,853 @@
const { v4: uuidv4 } = require('uuid');
const youtubeAPI = require('./youtube-api');
/**
* Game state and logic manager for Song Battle application
*/
class GameManager {
constructor() {
this.lobbies = new Map(); // Map of lobbyId -> lobby object
this.playerToLobby = new Map(); // Map of playerId -> lobbyId for quick lookup
}
/**
* Create a new game lobby
* @param {string} hostId - ID of the host player
* @param {string} hostName - Name of the host player
* @returns {Object} New lobby data and ID
*/
createLobby(hostId, hostName) {
// Generate a simple 6-character lobby code
const lobbyId = this._generateLobbyCode();
// Create lobby object
const lobby = {
id: lobbyId,
hostId: hostId,
state: 'LOBBY', // LOBBY -> SONG_SUBMISSION -> VOTING -> FINISHED
settings: {
songsPerPlayer: 3,
maxPlayers: 10,
minPlayers: 3,
requireYoutubeLinks: false
},
players: [{
id: hostId,
name: hostName,
isConnected: true,
isReady: false,
songs: [],
songCount: 0
}],
songs: [], // All submitted songs
currentBattle: null,
battles: [], // History of all battles
finalWinner: null
};
// Store lobby and player-to-lobby mapping
this.lobbies.set(lobbyId, lobby);
this.playerToLobby.set(hostId, lobbyId);
return { lobby, lobbyId };
}
/**
* Join an existing lobby
* @param {string} playerId - ID of joining player
* @param {string} playerName - Name of joining player
* @param {string} lobbyId - ID of lobby to join
* @returns {Object} Lobby data or error
*/
joinLobby(playerId, playerName, lobbyId) {
// Check if lobby exists
if (!this.lobbies.has(lobbyId)) {
return { error: 'Lobby not found' };
}
const lobby = this.lobbies.get(lobbyId);
// Check if lobby is in correct state
if (lobby.state !== 'LOBBY') {
return { error: 'Cannot join: game already in progress' };
}
// Check if player limit is reached
if (lobby.players.length >= lobby.settings.maxPlayers) {
return { error: 'Lobby is full' };
}
// Check if player name is already taken
if (lobby.players.some(p => p.name === playerName && p.isConnected)) {
return { error: 'Name already taken' };
}
// Check if player is rejoining
const existingPlayerIndex = lobby.players.findIndex(p => p.name === playerName && !p.isConnected);
if (existingPlayerIndex >= 0) {
// Update player ID and connection status
lobby.players[existingPlayerIndex].id = playerId;
lobby.players[existingPlayerIndex].isConnected = true;
this.playerToLobby.set(playerId, lobbyId);
return { lobby, lobbyId };
}
// Add new player
lobby.players.push({
id: playerId,
name: playerName,
isConnected: true,
isReady: false,
songs: [],
songCount: 0
});
// Update map
this.playerToLobby.set(playerId, lobbyId);
return { lobby, lobbyId };
}
/**
* Handle player reconnection to a lobby
* @param {string} playerId - New ID of the reconnecting player
* @param {string} lobbyId - ID of the lobby
* @param {string} playerName - Name of the player
* @returns {Object} Lobby data or error
*/
handleReconnect(playerId, lobbyId, playerName) {
// Check if lobby exists
if (!this.lobbies.has(lobbyId)) {
return { error: 'Lobby not found' };
}
const lobby = this.lobbies.get(lobbyId);
// Find player by name
const playerIndex = lobby.players.findIndex(p => p.name === playerName);
if (playerIndex === -1) {
return { error: 'Player not found in lobby' };
}
// Update player ID and connection status
lobby.players[playerIndex].id = playerId;
lobby.players[playerIndex].isConnected = true;
// Update map
this.playerToLobby.set(playerId, lobbyId);
return { lobby, lobbyId };
}
/**
* Update lobby settings
* @param {string} playerId - ID of the host player
* @param {Object} settings - New settings object
* @returns {Object} Updated lobby data or error
*/
updateSettings(playerId, settings) {
const lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
return { error: 'Player not in a lobby' };
}
const lobby = this.lobbies.get(lobbyId);
// Check if player is the host
if (lobby.hostId !== playerId) {
return { error: 'Only the host can update settings' };
}
// Validate settings
if (settings.songsPerPlayer < 1 || settings.songsPerPlayer > 10) {
return { error: 'Songs per player must be between 1 and 10' };
}
if (settings.maxPlayers < 3 || settings.maxPlayers > 20) {
return { error: 'Max players must be between 3 and 20' };
}
// Update settings
lobby.settings = {
...lobby.settings,
...settings
};
return { lobby, lobbyId };
}
/**
* Start the game from lobby state
* @param {string} playerId - ID of the host player
* @returns {Object} Updated lobby data or error
*/
startGame(playerId) {
const lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
return { error: 'Player not in a lobby' };
}
const lobby = this.lobbies.get(lobbyId);
// Check if player is the host
if (lobby.hostId !== playerId) {
return { error: 'Only the host can start the game' };
}
// Check if in correct state
if (lobby.state !== 'LOBBY') {
return { error: 'Game already started' };
}
// Check if enough players
if (lobby.players.length < lobby.settings.minPlayers) {
return { error: `Need at least ${lobby.settings.minPlayers} players to start` };
}
// Move to song submission state
lobby.state = 'SONG_SUBMISSION';
return { lobby, lobbyId };
}
/**
* Add a song for a player
* @param {string} playerId - ID of the player
* @param {Object} song - Song data (title, artist, youtubeLink)
* @returns {Object} Updated lobby data or error
*/
async addSong(playerId, song) {
console.log(`[DEBUG] addSong called for player ID: ${playerId}`);
// Check if player is in a lobby
let lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
// If no mapping exists, try to find the player in any lobby
console.log(`[DEBUG] No lobby mapping found for player ID: ${playerId}, trying to locate player...`);
// Search all lobbies for this player
let foundLobby = null;
let foundPlayerIndex = -1;
for (const [id, lobby] of this.lobbies.entries()) {
const playerIndex = lobby.players.findIndex(p => p.id === playerId);
if (playerIndex !== -1) {
foundLobby = lobby;
lobbyId = id;
foundPlayerIndex = playerIndex;
console.log(`[DEBUG] Found player in lobby ${id} at index ${playerIndex}`);
// Fix the mapping for future requests
this.playerToLobby.set(playerId, id);
break;
}
}
if (!foundLobby) {
console.log(`[DEBUG] Player not found in any lobby. Available lobbies: ${Array.from(this.lobbies.keys())}`);
return { error: 'Player not in a lobby' };
}
}
const lobby = this.lobbies.get(lobbyId);
if (!lobby) {
console.log(`[DEBUG] Lobby ID ${lobbyId} not found`);
return { error: 'Lobby not found' };
}
// Log lobby state for debugging
console.log(`[DEBUG] Lobby ${lobbyId} state: ${lobby.state}`);
console.log(`[DEBUG] Lobby players: ${JSON.stringify(lobby.players.map(p => ({id: p.id, name: p.name})))}`);
// Check if in correct state
if (lobby.state !== 'SONG_SUBMISSION') {
return { error: 'Cannot add songs at this time' };
}
// Get player
const playerIndex = lobby.players.findIndex(p => p.id === playerId);
if (playerIndex === -1) {
console.log(`[DEBUG] Player ID ${playerId} not found in lobby ${lobbyId}.`);
// First try: Find the host if this is a host request
if (playerId === lobby.hostId || lobby.players.some(p => p.id === lobby.hostId)) {
console.log('[DEBUG] This appears to be the host. Looking for host player...');
const hostIndex = lobby.players.findIndex(p => p.id === lobby.hostId);
if (hostIndex !== -1) {
console.log(`[DEBUG] Found host at index ${hostIndex}`);
return this._addSongToPlayer(hostIndex, lobby, lobbyId, song, playerId);
}
}
// Second try: Match any connected player as fallback
console.log('[DEBUG] Trying to find any connected player...');
const connectedIndex = lobby.players.findIndex(p => p.isConnected);
if (connectedIndex !== -1) {
console.log(`[DEBUG] Found connected player at index ${connectedIndex}`);
return this._addSongToPlayer(connectedIndex, lobby, lobbyId, song, playerId);
}
// If we still can't find a match, use the first player as last resort
if (lobby.players.length > 0) {
console.log('[DEBUG] Using first player as last resort');
return this._addSongToPlayer(0, lobby, lobbyId, song, playerId);
}
return { error: 'Player not found in lobby' };
}
// Check if player can add more songs
if (lobby.players[playerIndex].songs.length >= lobby.settings.songsPerPlayer) {
return { error: 'Maximum number of songs reached' };
}
// We only require the YouTube link now
if (!song.youtubeLink) {
return { error: 'YouTube link is required' };
}
// If the YouTube link isn't valid, return an error
const videoId = await youtubeAPI.extractVideoId(song.youtubeLink);
if (!videoId) {
return { error: 'Invalid YouTube link' };
}
// Handle async metadata fetching
return this._addSongToPlayer(playerIndex, lobby, lobbyId, song, playerId);
}
/**
* Search for songs on YouTube
* @param {string} query - Search query
* @returns {Promise<Array>} Search results
*/
async searchYouTube(query) {
try {
return await youtubeAPI.searchYouTube(query);
} catch (error) {
console.error('Error searching YouTube:', error);
return { error: 'Failed to search YouTube' };
}
}
/**
* Helper method to add a song to a player
* @param {number} playerIndex - Index of the player in the lobby
* @param {Object} lobby - The lobby object
* @param {string} lobbyId - ID of the lobby
* @param {Object} song - Song data to add
* @param {string} playerId - ID of the player adding the song
* @returns {Object|Promise<Object>} Updated lobby data
* @private
*/
async _addSongToPlayer(playerIndex, lobby, lobbyId, song, playerId) {
// Generate song ID
const songId = uuidv4();
// Prepare song data
let title = song.title;
let artist = song.artist;
let thumbnail = song.thumbnail || null; // Use client-provided thumbnail if available
// If YouTube link is provided but title/artist are empty, try to fetch metadata
if (song.youtubeLink && (!title || !artist || title === 'Unknown' || artist === 'Unknown' || !thumbnail)) {
try {
// Extract video ID from YouTube link
const videoId = youtubeAPI.extractVideoId(song.youtubeLink);
if (videoId) {
console.log(`Getting metadata for YouTube video: ${videoId}`);
// Fetch metadata from YouTube API
const metadata = await youtubeAPI.getVideoMetadata(videoId);
// Only update if we don't have values or they're generic
if (!title || title === 'Unknown') title = metadata.title || 'Unknown';
if (!artist || artist === 'Unknown') artist = metadata.artist || 'Unknown';
if (!thumbnail) thumbnail = metadata.thumbnail;
console.log(`Fetched metadata: "${title}" by ${artist}`);
}
} catch (error) {
console.error('Error fetching YouTube metadata:', error.message);
// Continue with user-provided data if API fails
}
}
// Add song to player and global song list
const newSong = {
id: songId,
title: title,
artist: artist,
youtubeLink: song.youtubeLink || '',
thumbnail: thumbnail,
submittedById: playerId,
submittedByName: lobby.players[playerIndex].name
};
// Add song to both player's list and global list
console.log(`[DEBUG] Adding song "${title}" to player at index ${playerIndex} in lobby ${lobbyId}`);
lobby.players[playerIndex].songs.push(newSong);
lobby.players[playerIndex].songCount = lobby.players[playerIndex].songs.length;
lobby.songs.push(newSong);
console.log(`[DEBUG] Updated player song count: ${lobby.players[playerIndex].songCount}`);
console.log(`[DEBUG] Total songs in lobby: ${lobby.songs.length}`);
// Make sure we're returning a properly formed result with both lobby and lobbyId
const result = { lobby, lobbyId };
// Validate that the result contains what we expect before returning
if (!result.lobby || !result.lobbyId) {
console.log(`[DEBUG] CRITICAL ERROR: Result is missing lobby or lobbyId after adding song`);
console.log(`[DEBUG] Result keys: ${Object.keys(result)}`);
} else {
console.log(`[DEBUG] Song successfully added, returning result with valid lobby and lobbyId`);
}
return result;
}
/**
* Remove a song for a player
* @param {string} playerId - ID of the player
* @param {string} songId - ID of the song to remove
* @returns {Object} Updated lobby data or error
*/
removeSong(playerId, songId) {
const lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
return { error: 'Player not in a lobby' };
}
const lobby = this.lobbies.get(lobbyId);
// Check if in correct state
if (lobby.state !== 'SONG_SUBMISSION') {
return { error: 'Cannot remove songs at this time' };
}
// Get player
const playerIndex = lobby.players.findIndex(p => p.id === playerId);
if (playerIndex === -1) {
return { error: 'Player not found in lobby' };
}
// Find song in player's songs
const songIndex = lobby.players[playerIndex].songs.findIndex(s => s.id === songId);
if (songIndex === -1) {
return { error: 'Song not found' };
}
// Remove song from player's list
lobby.players[playerIndex].songs.splice(songIndex, 1);
lobby.players[playerIndex].songCount = lobby.players[playerIndex].songs.length;
// Remove from global song list
const globalSongIndex = lobby.songs.findIndex(s => s.id === songId);
if (globalSongIndex !== -1) {
lobby.songs.splice(globalSongIndex, 1);
}
// If player was ready, set to not ready
if (lobby.players[playerIndex].isReady) {
lobby.players[playerIndex].isReady = false;
}
return { lobby, lobbyId };
}
/**
* Set player ready status in song submission phase
* @param {string} playerId - ID of the player
* @returns {Object} Updated lobby data or error
*/
setPlayerReady(playerId) {
const lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
return { error: 'Player not in a lobby' };
}
const lobby = this.lobbies.get(lobbyId);
// Check if in correct state
if (lobby.state !== 'SONG_SUBMISSION') {
return { error: 'Cannot set ready status at this time' };
}
// Get player
const playerIndex = lobby.players.findIndex(p => p.id === playerId);
if (playerIndex === -1) {
return { error: 'Player not found in lobby' };
}
// Check if player has submitted enough songs
if (lobby.players[playerIndex].songs.length < lobby.settings.songsPerPlayer) {
return { error: 'Must submit all songs before ready' };
}
// Set player as ready
lobby.players[playerIndex].isReady = true;
// Check if all players are ready
const allReady = lobby.players.every(p => p.isReady || !p.isConnected);
if (allReady) {
console.log('All players ready, starting tournament...');
// Start the tournament by creating brackets
this._startTournament(lobby);
// Return an indicator that the tournament has started
return {
lobby,
lobbyId,
tournamentStarted: true
};
}
return { lobby, lobbyId };
}
/**
* Submit a vote for a song in a battle
* @param {string} playerId - ID of the voting player
* @param {string} songId - ID of the voted song
* @returns {Object} Updated lobby data or error
*/
submitVote(playerId, songId) {
const lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
return { error: 'Player not in a lobby' };
}
const lobby = this.lobbies.get(lobbyId);
// Check if in correct state
if (lobby.state !== 'VOTING') {
return { error: 'Cannot vote at this time' };
}
// Check if there's an active battle
if (!lobby.currentBattle) {
return { error: 'No active battle' };
}
// Check if player has already voted in this battle
if (lobby.currentBattle.votes.has(playerId)) {
return { error: 'Already voted in this battle' };
}
// Check if the voted song is part of the current battle
if (songId !== lobby.currentBattle.song1.id && songId !== lobby.currentBattle.song2.id) {
return { error: 'Invalid song ID' };
}
// Record the vote
lobby.currentBattle.votes.set(playerId, songId);
// Update vote counts
if (songId === lobby.currentBattle.song1.id) {
lobby.currentBattle.song1Votes++;
} else {
lobby.currentBattle.song2Votes++;
}
// Check if all connected players have voted
const connectedPlayers = lobby.players.filter(p => p.isConnected).length;
const voteCount = lobby.currentBattle.votes.size;
if (voteCount >= connectedPlayers) {
// Determine winner
const winnerSongId = lobby.currentBattle.song1Votes > lobby.currentBattle.song2Votes
? lobby.currentBattle.song1.id
: lobby.currentBattle.song2.id;
lobby.currentBattle.winner = winnerSongId;
// Save battle to history
lobby.battles.push({
round: lobby.currentBattle.round,
song1: lobby.currentBattle.song1,
song2: lobby.currentBattle.song2,
song1Votes: lobby.currentBattle.song1Votes,
song2Votes: lobby.currentBattle.song2Votes,
winner: winnerSongId
});
// Move to next battle or finish tournament
this._moveToNextBattle(lobby);
}
return { lobby, lobbyId };
}
/**
* Handle player leaving a lobby
* @param {string} playerId - ID of the player
* @returns {Object} Updated lobby data or null if lobby is removed
*/
leaveLobby(playerId) {
const lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
return null; // Player not in a lobby
}
const lobby = this.lobbies.get(lobbyId);
// Remove player from lobby
const playerIndex = lobby.players.findIndex(p => p.id === playerId);
if (playerIndex !== -1) {
// If game hasn't started, remove player completely
if (lobby.state === 'LOBBY') {
lobby.players.splice(playerIndex, 1);
} else {
// Otherwise just mark as disconnected
lobby.players[playerIndex].isConnected = false;
}
}
// Remove from map
this.playerToLobby.delete(playerId);
// If it's the host leaving and game hasn't started, assign new host or delete lobby
if (lobby.hostId === playerId && lobby.state === 'LOBBY') {
if (lobby.players.length > 0) {
lobby.hostId = lobby.players[0].id;
} else {
this.lobbies.delete(lobbyId);
return null;
}
}
// If all players have left, remove the lobby
const connectedPlayers = lobby.players.filter(p => p.isConnected).length;
if (connectedPlayers === 0) {
this.lobbies.delete(lobbyId);
return null;
}
// For voting state, check if we need to progress
if (lobby.state === 'VOTING' && lobby.currentBattle) {
// Add null check for votes property
const totalVotes = lobby.currentBattle.votes?.size || 0;
const remainingPlayers = lobby.players.filter(p => p.isConnected).length;
if (totalVotes >= remainingPlayers && totalVotes > 0) {
// Determine winner
const winnerSongId = lobby.currentBattle.song1Votes > lobby.currentBattle.song2Votes
? lobby.currentBattle.song1.id
: lobby.currentBattle.song2.id;
lobby.currentBattle.winner = winnerSongId;
// Save battle to history
lobby.battles.push({
round: lobby.currentBattle.round,
song1: lobby.currentBattle.song1,
song2: lobby.currentBattle.song2,
song1Votes: lobby.currentBattle.song1Votes,
song2Votes: lobby.currentBattle.song2Votes,
winner: winnerSongId
});
// Move to next battle or finish tournament
this._moveToNextBattle(lobby);
}
}
return { lobby, lobbyId };
}
/**
* Handle player disconnection
* @param {string} playerId - ID of the disconnected player
* @returns {Object|null} Updated lobby data or null if no lobby found
*/
handleDisconnect(playerId) {
return this.leaveLobby(playerId);
}
/**
* Generate a unique lobby code
* @returns {string} 6-character lobby code
* @private
*/
_generateLobbyCode() {
const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed ambiguous characters
let result = '';
// Generate code until unique
do {
result = '';
for (let i = 0; i < 6; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
} while (this.lobbies.has(result));
return result;
}
/**
* Start the tournament by creating brackets and first battle
* @param {Object} lobby - Lobby object
* @private
*/
_startTournament(lobby) {
// Collect all submitted songs
const songs = [...lobby.songs];
// Shuffle songs
this._shuffleArray(songs);
// Create tournament brackets
const brackets = [];
let round = 0;
// Handle odd number of songs by giving a bye to one song
if (songs.length % 2 !== 0 && songs.length > 1) {
const byeSong = songs.pop();
brackets.push({
round,
song1: byeSong,
song2: null, // Bye
bye: true,
winner: byeSong.id,
song1Votes: 0,
song2Votes: 0
});
}
// Create initial bracket pairs
for (let i = 0; i < songs.length; i += 2) {
if (i + 1 < songs.length) {
brackets.push({
round,
song1: songs[i],
song2: songs[i + 1],
song1Votes: 0,
song2Votes: 0,
winner: null,
votes: new Map()
});
}
}
// Store brackets and transition to voting state
lobby.brackets = brackets;
lobby.currentBracketIndex = 0;
if (brackets.length > 0) {
// Set the first battle
lobby.state = 'VOTING';
// Ensure we create a proper battle object with all required fields
lobby.currentBattle = {
...brackets[0],
// Make sure votes is a Map (it might be serialized incorrectly)
votes: brackets[0].votes || new Map(),
voteCount: 0
};
console.log('Starting first battle:', lobby.currentBattle);
} else {
// Edge case: only one song submitted
if (songs.length === 1) {
lobby.state = 'FINISHED';
lobby.finalWinner = songs[0];
}
}
}
/**
* Move to next battle or finish tournament
* @param {Object} lobby - Lobby object
* @private
*/
_moveToNextBattle(lobby) {
// Check if there are more battles in current round
lobby.currentBracketIndex++;
if (lobby.currentBracketIndex < lobby.brackets.length) {
// Move to next battle
lobby.currentBattle = lobby.brackets[lobby.currentBracketIndex];
return;
}
// Current round complete, check if tournament is finished
const winners = lobby.brackets
.filter(b => !b.bye) // Skip byes
.map(b => {
const winningSong = b.song1.id === b.winner ? b.song1 : b.song2;
return winningSong;
});
// Add byes to winners
const byes = lobby.brackets
.filter(b => b.bye)
.map(b => b.song1);
const nextRoundSongs = [...winners, ...byes];
// If only one song remains, we have a winner
if (nextRoundSongs.length <= 1) {
lobby.state = 'FINISHED';
lobby.finalWinner = nextRoundSongs[0];
lobby.currentBattle = null;
return;
}
// Create brackets for next round
const nextRound = [];
const round = lobby.brackets[0].round + 1;
// Handle odd number of songs by giving a bye to one song
if (nextRoundSongs.length % 2 !== 0) {
const byeSong = nextRoundSongs.pop();
nextRound.push({
round,
song1: byeSong,
song2: null,
bye: true,
winner: byeSong.id,
song1Votes: 0,
song2Votes: 0
});
}
// Create pairs for next round
for (let i = 0; i < nextRoundSongs.length; i += 2) {
if (i + 1 < nextRoundSongs.length) {
nextRound.push({
round,
song1: nextRoundSongs[i],
song2: nextRoundSongs[i + 1],
song1Votes: 0,
song2Votes: 0,
winner: null,
votes: new Map()
});
}
}
// Update brackets and reset index
lobby.brackets = nextRound;
lobby.currentBracketIndex = 0;
// Set first battle of new round
if (nextRound.length > 0) {
lobby.currentBattle = nextRound[0];
}
}
/**
* Shuffle array in-place using Fisher-Yates algorithm
* @param {Array} array - Array to shuffle
* @private
*/
_shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
}
}
// Export GameManager class
module.exports = GameManager;

View File

@ -3,11 +3,12 @@ const { Server } = require("socket.io");
const http = require("http");
const app = express();
const path = require("path");
const GameManager = require("./game");
app.use(express.static(path.join(__dirname, './dist')));
app.use(express.static(path.join(__dirname, '../client/dist')));
app.disable("x-powered-by");
app.get('*', (req, res) => res.sendFile(path.join(__dirname, './dist', 'index.html')));
app.get('*', (req, res) => res.sendFile(path.join(__dirname, '../client/dist', 'index.html')));
const server = http.createServer(app);
@ -17,6 +18,326 @@ const io = new Server(server, {
pingInterval: 10000
});
// Initialize game manager
const gameManager = new GameManager();
// Socket.IO event handlers
io.on('connection', (socket) => {
console.log(`User connected: ${socket.id}`);
// Create a new game lobby
socket.on('create_lobby', ({ playerName }, callback) => {
try {
const result = gameManager.createLobby(socket.id, playerName);
// Join the socket room for this lobby
socket.join(result.lobbyId);
// Send response to client
if (callback) callback(result);
console.log(`Lobby created: ${result.lobbyId} by ${playerName}`);
} catch (error) {
console.error('Error creating lobby:', error);
if (callback) callback({ error: 'Failed to create lobby' });
}
});
// Join an existing lobby
socket.on('join_lobby', ({ lobbyId, playerName }, callback) => {
try {
const result = gameManager.joinLobby(socket.id, playerName, lobbyId);
if (result.error) {
if (callback) callback(result);
return;
}
// Join the socket room for this lobby
socket.join(lobbyId);
// Notify all players in the lobby
socket.to(lobbyId).emit('player_joined', {
playerId: socket.id,
playerName
});
// Send response to client
if (callback) callback(result);
console.log(`Player ${playerName} joined lobby ${lobbyId}`);
} catch (error) {
console.error('Error joining lobby:', error);
if (callback) callback({ error: 'Failed to join lobby' });
}
});
// Attempt to reconnect to a lobby
socket.on('reconnect_to_lobby', ({ lobbyId, playerName }, callback) => {
try {
const result = gameManager.handleReconnect(socket.id, lobbyId, playerName);
if (result.error) {
if (callback) callback(result);
return;
}
// Join the socket room for this lobby
socket.join(lobbyId);
// Notify all players in the lobby
socket.to(lobbyId).emit('player_reconnected', {
playerId: socket.id,
playerName
});
// Send response to client
if (callback) callback(result);
console.log(`Player ${playerName} reconnected to lobby ${lobbyId}`);
} catch (error) {
console.error('Error reconnecting to lobby:', error);
if (callback) callback({ error: 'Failed to reconnect to lobby' });
}
});
// Update lobby settings
socket.on('update_settings', ({ settings }, callback) => {
try {
const result = gameManager.updateSettings(socket.id, settings);
if (result.error) {
if (callback) callback(result);
return;
}
// Notify all players in the lobby
io.to(result.lobbyId).emit('settings_updated', {
settings: result.lobby.settings
});
// Send response to client
if (callback) callback(result);
} catch (error) {
console.error('Error updating settings:', error);
if (callback) callback({ error: 'Failed to update settings' });
}
});
// Start the game from lobby
socket.on('start_game', (_, callback) => {
try {
const result = gameManager.startGame(socket.id);
if (result.error) {
if (callback) callback(result);
return;
}
// Notify all players in the lobby
io.to(result.lobbyId).emit('game_started', {
state: result.lobby.state
});
// Send response to client
if (callback) callback(result);
console.log(`Game started in lobby ${result.lobbyId}`);
} catch (error) {
console.error('Error starting game:', error);
if (callback) callback({ error: 'Failed to start game' });
}
});
// Add a song
socket.on('add_song', ({ song }, callback) => {
try {
console.log(`[DEBUG] Attempting to add song: ${JSON.stringify(song)}`);
const result = gameManager.addSong(socket.id, song);
if (result.error) {
console.log(`[DEBUG] Error adding song: ${result.error}`);
if (callback) callback(result);
return;
}
// Add better error handling to prevent crash if lobby is not defined
if (!result.lobby || !result.lobbyId) {
console.log(`[DEBUG] Warning: Song added but lobby information is missing`);
if (callback) callback({ error: 'Failed to associate song with lobby' });
return;
}
console.log(`[DEBUG] Song added successfully, notifying lobby ${result.lobbyId}`);
console.log(`[DEBUG] Lobby songs: ${JSON.stringify(result.lobby.songs.map(s => s.title))}`);
// Notify all players in the lobby about updated song count
io.to(result.lobbyId).emit('songs_updated', {
lobby: result.lobby
});
// Send response to client
if (callback) callback(result);
} catch (error) {
console.error('Error adding song:', error);
if (callback) callback({ error: 'Failed to add song' });
}
});
// Remove a song
socket.on('remove_song', ({ songId }, callback) => {
try {
const result = gameManager.removeSong(socket.id, songId);
if (result.error) {
if (callback) callback(result);
return;
}
// Notify all players in the lobby
io.to(result.lobbyId).emit('songs_updated', {
lobby: result.lobby
});
// Send response to client
if (callback) callback(result);
} catch (error) {
console.error('Error removing song:', error);
if (callback) callback({ error: 'Failed to remove song' });
}
});
// Search YouTube for songs
socket.on('search_youtube', async ({ query }, callback) => {
try {
if (!query || typeof query !== 'string') {
if (callback) callback({ error: 'Invalid search query' });
return;
}
const results = await gameManager.searchYouTube(query);
// Send response to client
if (callback) callback({ results });
} catch (error) {
console.error('Error searching YouTube:', error);
if (callback) callback({ error: 'Failed to search YouTube' });
}
});
// Mark player as ready
socket.on('player_ready', (_, callback) => {
try {
const result = gameManager.setPlayerReady(socket.id);
if (result.error) {
if (callback) callback(result);
return;
}
// Notify all players in the lobby
io.to(result.lobbyId).emit('player_status_changed', {
lobby: result.lobby
});
// If the game state just changed to VOTING, notify about the first battle
if (result.lobby.state === 'VOTING' && result.lobby.currentBattle) {
console.log('Sending new_battle event to clients');
io.to(result.lobbyId).emit('new_battle', {
battle: result.lobby.currentBattle
});
}
// If tournament just started, explicitly notify about the state change and first battle
if (result.tournamentStarted && result.lobby.currentBattle) {
console.log('Tournament started, sending battle data');
// First send state change
io.to(result.lobbyId).emit('tournament_started', {
state: 'VOTING'
});
// Then send battle data with a slight delay to ensure clients process in the right order
setTimeout(() => {
io.to(result.lobbyId).emit('new_battle', {
battle: result.lobby.currentBattle
});
}, 300);
}
// If game finished (edge case where only one song was submitted)
if (result.lobby.state === 'FINISHED') {
io.to(result.lobbyId).emit('game_finished', {
winner: result.lobby.finalWinner,
battles: result.lobby.battles
});
}
// Send response to client
if (callback) callback(result);
} catch (error) {
console.error('Error setting player ready:', error);
if (callback) callback({ error: 'Failed to set player status' });
}
});
// Submit a vote in a battle
socket.on('submit_vote', ({ songId }, callback) => {
try {
const result = gameManager.submitVote(socket.id, songId);
if (result.error) {
if (callback) callback(result);
return;
}
// Notify all players about vote count
io.to(result.lobbyId).emit('vote_submitted', {
lobby: result.lobby
});
// If battle is finished, notify about new battle
if (result.lobby.currentBattle) {
io.to(result.lobbyId).emit('new_battle', {
battle: result.lobby.currentBattle
});
}
// If game is finished, notify about the winner
if (result.lobby.state === 'FINISHED') {
io.to(result.lobbyId).emit('game_finished', {
winner: result.lobby.finalWinner,
battles: result.lobby.battles
});
}
// Send response to client
if (callback) callback(result);
} catch (error) {
console.error('Error submitting vote:', error);
if (callback) callback({ error: 'Failed to submit vote' });
}
});
// Handle disconnection
socket.on('disconnect', () => {
try {
const result = gameManager.handleDisconnect(socket.id);
if (result) {
// Notify remaining players about the disconnection
io.to(result.lobbyId).emit('player_disconnected', {
playerId: socket.id,
lobby: result.lobby
});
console.log(`Player ${socket.id} disconnected from lobby ${result.lobbyId}`);
}
} catch (error) {
console.error('Error handling disconnect:', error);
}
});
});
server.on('error', (error) => {
console.error('Server error:', error);
});

129
server/youtube-api.js Normal file
View File

@ -0,0 +1,129 @@
const { google } = require('googleapis');
const axios = require('axios');
// You would need to obtain a YouTube API key from Google Cloud Console
// For now, we'll use a placeholder - you'll need to replace this with your actual API key
const API_KEY = process.env.YOUTUBE_API_KEY || 'NO';
// Initialize the YouTube API client
const youtube = google.youtube({
version: 'v3',
auth: API_KEY
});
/**
* Extract YouTube video ID from various formats of YouTube links
* @param {string} url - YouTube URL in any format
* @returns {string|null} YouTube video ID or null if invalid
*/
function extractVideoId(url) {
if (!url) return null;
// Handle different YouTube URL formats
const patterns = [
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/watch\?v=([^&]+)/,
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/embed\/([^/?]+)/,
/(?:https?:\/\/)?(?:www\.)?youtu\.be\/([^/?]+)/
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match && match[1]) {
return match[1];
}
}
return null;
}
/**
* Get video metadata from YouTube API
* @param {string} videoId - YouTube video ID
* @returns {Promise<Object>} Video metadata including title and channel
*/
async function getVideoMetadata(videoId) {
try {
// Try using the googleapis library first
try {
const response = await youtube.videos.list({
part: 'snippet',
id: videoId
});
const video = response.data.items[0];
if (video && video.snippet) {
return {
title: video.snippet.title,
artist: video.snippet.channelTitle,
thumbnail: video.snippet.thumbnails.default.url
};
}
} catch (googleApiError) {
console.log('Google API error, falling back to alternative method:', googleApiError.message);
// If googleapis fails (e.g., due to API key issues), fall back to a simplified approach
const response = await axios.get(`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`);
if (response.data) {
const title = response.data.title || '';
// Try to extract artist from title (common format is "Artist - Title")
const parts = title.split(' - ');
const artist = parts.length > 1 ? parts[0] : response.data.author_name;
return {
title: parts.length > 1 ? parts.slice(1).join(' - ') : title,
artist: artist,
thumbnail: response.data.thumbnail_url
};
}
}
// If all methods fail, return default values
return {
title: 'Unknown Title',
artist: 'Unknown Artist',
thumbnail: null
};
} catch (error) {
console.error('Error fetching video metadata:', error.message);
return {
title: 'Unknown Title',
artist: 'Unknown Artist',
thumbnail: null
};
}
}
/**
* Search for songs on YouTube
* @param {string} query - Search query for YouTube
* @returns {Promise<Array>} List of search results
*/
async function searchYouTube(query) {
try {
const response = await youtube.search.list({
part: 'snippet',
q: query,
type: 'video',
maxResults: 5,
videoCategoryId: '10' // Category ID for Music
});
return response.data.items.map(item => ({
id: item.id.videoId,
title: item.snippet.title,
artist: item.snippet.channelTitle,
thumbnail: item.snippet.thumbnails.default.url,
youtubeLink: `https://www.youtube.com/watch?v=${item.id.videoId}`
}));
} catch (error) {
console.error('Error searching YouTube:', error.message);
return [];
}
}
module.exports = {
extractVideoId,
getVideoMetadata,
searchYouTube
};