Refactor song submission process to include YouTube metadata fetching; remove YouTube link requirement from settings for improved flexibility

This commit is contained in:
Mathias Wagner 2025-04-24 20:04:26 +02:00
parent 77df851e95
commit a7929cf144
5 changed files with 167 additions and 84 deletions

View File

@ -8,8 +8,7 @@ const LobbyScreen = () => {
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [settings, setSettings] = useState({ const [settings, setSettings] = useState({
songsPerPlayer: 4, songsPerPlayer: 4,
maxPlayers: 10, maxPlayers: 10
requireYoutubeLinks: true
}); });
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
@ -79,7 +78,6 @@ const LobbyScreen = () => {
<h3><FontAwesomeIcon icon={faGear} /> Game Settings</h3> <h3><FontAwesomeIcon icon={faGear} /> Game Settings</h3>
<p>Songs per player: {settings.songsPerPlayer}</p> <p>Songs per player: {settings.songsPerPlayer}</p>
<p>Max players: {settings.maxPlayers}</p> <p>Max players: {settings.maxPlayers}</p>
<p>YouTube links required: {settings.requireYoutubeLinks ? 'Yes' : 'No'}</p>
{isHost && ( {isHost && (
<button className="btn secondary" onClick={() => setShowSettings(true)}> <button className="btn secondary" onClick={() => setShowSettings(true)}>
@ -140,19 +138,6 @@ const LobbyScreen = () => {
/> />
</div> </div>
<div className="form-group checkbox">
<label htmlFor="requireYoutubeLinks">
<input
type="checkbox"
id="requireYoutubeLinks"
name="requireYoutubeLinks"
checked={settings.requireYoutubeLinks}
onChange={handleSettingsChange}
/>
Require YouTube links
</label>
</div>
<div className="modal-actions"> <div className="modal-actions">
<button className="btn secondary" onClick={() => setShowSettings(false)}> <button className="btn secondary" onClick={() => setShowSettings(false)}>
Cancel Cancel

View File

@ -4,7 +4,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTrash, faCheck, faMusic, faVideoCamera, faSearch, faSpinner, faExternalLinkAlt, faTimes } from '@fortawesome/free-solid-svg-icons'; import { faPlus, faTrash, faCheck, faMusic, faVideoCamera, faSearch, faSpinner, faExternalLinkAlt, faTimes } from '@fortawesome/free-solid-svg-icons';
const SongSubmissionScreen = () => { const SongSubmissionScreen = () => {
const { lobby, currentPlayer, addSong, removeSong, setPlayerReady, searchYouTube } = useGame(); const { lobby, currentPlayer, addSong, removeSong, setPlayerReady, searchYouTube, getYouTubeMetadata } = useGame();
const [songs, setSongs] = useState([]); const [songs, setSongs] = useState([]);
const [isReady, setIsReady] = useState(false); const [isReady, setIsReady] = useState(false);
const [songForm, setSongForm] = useState({ const [songForm, setSongForm] = useState({
@ -51,7 +51,7 @@ const SongSubmissionScreen = () => {
if (player.songs && Array.isArray(player.songs)) { if (player.songs && Array.isArray(player.songs)) {
console.log('Found player songs for', player.name, ':', player.songs.length); console.log('Found player songs for', player.name, ':', player.songs.length);
console.log('Songs data:', player.songs); console.log('Songs data:', player.songs);
setSongs(player.songs); setSongs(player.songs || []);
} else { } else {
console.log('No songs array for player:', player); console.log('No songs array for player:', player);
setSongs([]); setSongs([]);
@ -65,7 +65,7 @@ const SongSubmissionScreen = () => {
}; };
fetchPlayerSongs(); fetchPlayerSongs();
// Include lobby in the dependency array to ensure this runs when the lobby state changes // Important: This effect should run whenever the lobby state changes
}, [lobby, currentPlayer]); }, [lobby, currentPlayer]);
// Extract video ID from YouTube URL // Extract video ID from YouTube URL
@ -116,12 +116,34 @@ const SongSubmissionScreen = () => {
const videoId = extractVideoId(value); const videoId = extractVideoId(value);
if (videoId) { if (videoId) {
setSongForm({ youtubeLink: value }); setSongForm({ youtubeLink: value });
setSelectedVideo({
// Set basic information immediately for a responsive UI
const basicVideoInfo = {
id: videoId, id: videoId,
url: value, url: value,
thumbnail: `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` thumbnail: `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`
}); };
setSelectedVideo(basicVideoInfo);
setSearchResults([]); // Clear any existing search results setSearchResults([]); // Clear any existing search results
// Fetch additional metadata from the server
try {
setIsLoadingMetadata(true);
const metadata = await getYouTubeMetadata(videoId);
if (metadata) {
// Update selected video with full metadata
setSelectedVideo({
...basicVideoInfo,
title: metadata.title || 'Unknown Title',
artist: metadata.artist || 'Unknown Artist',
thumbnail: metadata.thumbnail || basicVideoInfo.thumbnail
});
}
} catch (error) {
console.error('Error fetching video metadata:', error);
} finally {
setIsLoadingMetadata(false);
}
} else if (value.trim()) { } else if (value.trim()) {
// Clear any previous timeout // Clear any previous timeout
if (searchTimeout.current) { if (searchTimeout.current) {
@ -175,6 +197,9 @@ const SongSubmissionScreen = () => {
// Use selected video data if available, otherwise fallback to search query or direct input // Use selected video data if available, otherwise fallback to search query or direct input
let songData; let songData;
// Generate a consistent ID format that will work for deletion later
const generateSongId = () => `song_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (selectedVideo) { if (selectedVideo) {
// We have a selected video with full details - use all available metadata // We have a selected video with full details - use all available metadata
songData = { songData = {
@ -182,7 +207,7 @@ const SongSubmissionScreen = () => {
title: selectedVideo.title, title: selectedVideo.title,
artist: selectedVideo.artist, artist: selectedVideo.artist,
thumbnail: selectedVideo.thumbnail, thumbnail: selectedVideo.thumbnail,
id: `song_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` // Generate a unique ID id: generateSongId() // Consistent ID format
}; };
console.log("Adding song with full metadata:", songData); console.log("Adding song with full metadata:", songData);
@ -199,7 +224,7 @@ const SongSubmissionScreen = () => {
youtubeLink: youtubeLink, youtubeLink: youtubeLink,
// Include the videoId to make server-side processing easier // Include the videoId to make server-side processing easier
videoId: videoId, videoId: videoId,
id: `song_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` // Generate a unique ID id: generateSongId() // Consistent ID format
}; };
console.log("Adding song with YouTube link:", songData); console.log("Adding song with YouTube link:", songData);
@ -210,19 +235,35 @@ const SongSubmissionScreen = () => {
} }
} }
// Add the song and manually update the local songs array to ensure UI reflects the change // Add the song and update UI when the server responds
addSong(songData); // We use the Promise-based approach to wait for the server-generated ID for proper deletion
// Optimistically add the song to the local state to immediately reflect changes in UI // Add the song with callback to update songs when the server responds
// Note: The server response will ultimately override this if needed addSong(songData)
setSongs(prevSongs => [...prevSongs, songData]); .then((updatedLobby) => {
console.log('Song added successfully, received updated lobby:', updatedLobby);
// Reset form if (updatedLobby && currentPlayer) {
setSongForm({ youtubeLink: '' }); const player = updatedLobby.players.find(p => p.id === currentPlayer.id);
setSearchQuery(''); if (player && player.songs) {
setSearchResults([]); console.log('Setting songs from addSong response:', player.songs);
setSelectedVideo(null); setSongs([...player.songs]); // Create a new array to ensure state update
setIsFormVisible(false); } else {
console.warn('Player or songs not found in updated lobby');
}
} else {
console.warn('Missing lobby or currentPlayer in response');
}
})
.catch(error => {
console.error('Error adding song:', error);
});
// Reset form
setSongForm({ youtubeLink: '' });
setSearchQuery('');
setSearchResults([]);
setSelectedVideo(null);
setIsFormVisible(false);
}; };
const handleRemoveSong = (songId) => { const handleRemoveSong = (songId) => {

View File

@ -86,7 +86,7 @@ export function GameProvider({ children }) {
// Save game info when lobby is joined // Save game info when lobby is joined
useEffect(() => { useEffect(() => {
if (lobby && currentPlayer) { if (lobby && currentPlayer) {
localStorage.setItem('songBattleGame', JSON.stringify({ sessionStorage.setItem('songBattleGame', JSON.stringify({
lobbyId: lobby.id, lobbyId: lobby.id,
playerName: currentPlayer.name playerName: currentPlayer.name
})); }));
@ -330,45 +330,52 @@ export function GameProvider({ children }) {
const addSong = (song) => { const addSong = (song) => {
if (!socket || !isConnected || !lobby) { if (!socket || !isConnected || !lobby) {
setError('Not connected to server or no active lobby'); setError('Not connected to server or no active lobby');
return; return Promise.reject('Not connected to server or no active lobby');
} }
console.log('Attempting to add song:', song); console.log('Attempting to add song:', song);
console.log('Current player state:', currentPlayer); console.log('Current player state:', currentPlayer);
console.log('Current lobby state before adding song:', lobby); console.log('Current lobby state before adding song:', lobby);
socket.emit('add_song', { song }, (response) => { return new Promise((resolve, reject) => {
console.log('Song addition response:', response); socket.emit('add_song', { song }, (response) => {
if (response.error) { console.log('Song addition response:', response);
console.error('Error adding song:', response.error); if (response.error) {
setError(response.error); console.error('Error adding song:', response.error);
} else if (response.lobby) { setError(response.error);
// Log detailed lobby state for debugging reject(response.error);
console.log('Song added successfully, full lobby response:', response.lobby); } else if (response.lobby) {
console.log('Current player songs:', response.lobby.players.find(p => p.id === currentPlayer.id)?.songs); // Log detailed lobby state for debugging
console.log('All players song data:', console.log('Song added successfully, full lobby response:', response.lobby);
response.lobby.players.map(p => ({ console.log('Current player songs:', response.lobby.players.find(p => p.id === currentPlayer.id)?.songs);
name: p.name, console.log('All players song data:',
id: p.id, response.lobby.players.map(p => ({
songCount: p.songCount, name: p.name,
songs: p.songs ? p.songs.map(s => ({id: s.id, title: s.title})) : 'No songs' id: p.id,
})) songCount: p.songCount,
); songs: p.songs ? p.songs.map(s => ({id: s.id, title: s.title})) : 'No songs'
}))
// Force a deep clone of the lobby to ensure React detects the change );
const updatedLobby = JSON.parse(JSON.stringify(response.lobby));
setLobby(updatedLobby); // Force a deep clone of the lobby to ensure React detects the change
const updatedLobby = JSON.parse(JSON.stringify(response.lobby));
// Verify the state was updated correctly setLobby(updatedLobby);
setTimeout(() => {
// This won't show the updated state immediately due to React's state update mechanism // Verify the state was updated correctly
console.log('Lobby state after update (may not reflect immediate changes):', lobby); setTimeout(() => {
console.log('Updated lobby that was set:', updatedLobby); // This won't show the updated state immediately due to React's state update mechanism
}, 0); console.log('Lobby state after update (may not reflect immediate changes):', lobby);
} else { console.log('Updated lobby that was set:', updatedLobby);
console.error('Song addition succeeded but no lobby data was returned'); }, 0);
setError('Failed to update song list');
} // Resolve with the updated lobby
resolve(updatedLobby);
} else {
console.error('Song addition succeeded but no lobby data was returned');
setError('Failed to update song list');
reject('Failed to update song list');
}
});
}); });
}; };
@ -391,6 +398,26 @@ export function GameProvider({ children }) {
}); });
}); });
}; };
// Get metadata for a YouTube video by ID
const getYouTubeMetadata = (videoId) => {
return new Promise((resolve, reject) => {
if (!socket || !isConnected) {
setError('Not connected to server');
reject('Not connected to server');
return;
}
socket.emit('get_video_metadata', { videoId }, (response) => {
if (response.error) {
setError(response.error);
reject(response.error);
} else {
resolve(response.metadata);
}
});
});
};
// Remove a song // Remove a song
const removeSong = (songId) => { const removeSong = (songId) => {
@ -456,7 +483,8 @@ export function GameProvider({ children }) {
setPlayerReady, setPlayerReady,
submitVote, submitVote,
leaveLobby, leaveLobby,
searchYouTube searchYouTube,
getYouTubeMetadata
}}> }}>
{children} {children}
</GameContext.Provider> </GameContext.Provider>

View File

@ -28,8 +28,7 @@ class GameManager {
settings: { settings: {
songsPerPlayer: 3, songsPerPlayer: 3,
maxPlayers: 10, maxPlayers: 10,
minPlayers: 3, minPlayers: 3
requireYoutubeLinks: false
}, },
players: [{ players: [{
id: hostId, id: hostId,
@ -300,15 +299,12 @@ class GameManager {
return { error: 'Maximum number of songs reached' }; return { error: 'Maximum number of songs reached' };
} }
// We only require the YouTube link now // If we have a YouTube link, validate it
if (!song.youtubeLink) { if (song.youtubeLink) {
return { error: 'YouTube link is required' }; const videoId = await youtubeAPI.extractVideoId(song.youtubeLink);
} if (!videoId) {
return { error: 'Invalid YouTube link' };
// 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 // Handle async metadata fetching
@ -329,6 +325,20 @@ class GameManager {
} }
} }
/**
* Get metadata for a single YouTube video
* @param {string} videoId - YouTube video ID
* @returns {Promise<Object>} Video metadata
*/
async getYouTubeVideoMetadata(videoId) {
try {
return await youtubeAPI.getVideoMetadata(videoId);
} catch (error) {
console.error('Error getting YouTube video metadata:', error);
return { error: 'Failed to get video metadata' };
}
}
/** /**
* Helper method to add a song to a player * Helper method to add a song to a player
* @param {number} playerIndex - Index of the player in the lobby * @param {number} playerIndex - Index of the player in the lobby

View File

@ -150,10 +150,11 @@ io.on('connection', (socket) => {
}); });
// Add a song // Add a song
socket.on('add_song', ({ song }, callback) => { socket.on('add_song', async ({ song }, callback) => {
try { try {
console.log(`[DEBUG] Attempting to add song: ${JSON.stringify(song)}`); console.log(`[DEBUG] Attempting to add song: ${JSON.stringify(song)}`);
const result = gameManager.addSong(socket.id, song); // Fix: Properly await the result of the async addSong function
const result = await gameManager.addSong(socket.id, song);
if (result.error) { if (result.error) {
console.log(`[DEBUG] Error adding song: ${result.error}`); console.log(`[DEBUG] Error adding song: ${result.error}`);
@ -225,6 +226,24 @@ io.on('connection', (socket) => {
} }
}); });
// Get metadata for a single YouTube video
socket.on('get_video_metadata', async ({ videoId }, callback) => {
try {
if (!videoId || typeof videoId !== 'string') {
if (callback) callback({ error: 'Invalid video ID' });
return;
}
const metadata = await gameManager.getYouTubeVideoMetadata(videoId);
// Send response to client
if (callback) callback({ metadata });
} catch (error) {
console.error('Error getting YouTube metadata:', error);
if (callback) callback({ error: 'Failed to get YouTube metadata' });
}
});
// Mark player as ready // Mark player as ready
socket.on('player_ready', (_, callback) => { socket.on('player_ready', (_, callback) => {
try { try {