Compare commits

..

22 Commits

Author SHA1 Message Date
e083b353db Update playlists
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 1m30s
2025-03-01 21:43:20 +01:00
7970fb3aa3 Update time & padding
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 1m37s
2025-03-01 20:55:52 +01:00
77bc1bfc41 Translate UI parts to german
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 1m29s
2025-03-01 20:31:52 +01:00
388ce3d04a Translate UI parts to german
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 1m32s
2025-03-01 20:01:10 +01:00
032ebc2368 Create Ending
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 1m36s
2025-03-01 18:07:38 +01:00
09fb1e3938 State reset after rounds 2025-03-01 17:39:25 +01:00
f9447b3deb Fix MusicSlider.jsx 2025-03-01 17:34:24 +01:00
7996905f30 Fix display bug 2025-03-01 17:22:59 +01:00
2ea81493ba Fix vote bug 2025-03-01 17:17:00 +01:00
9901c1a49e Add playlists 2025-03-01 16:56:11 +01:00
195980032c Fix playlist bug 2025-03-01 16:39:13 +01:00
a1193ea87f Update WaitingRoom design 2025-03-01 16:26:36 +01:00
251879a1e1 Update WaitingRoom design 2025-03-01 16:21:29 +01:00
aa10b5b2cc Add custom playlists 2025-03-01 16:11:01 +01:00
2fb5af4171 Make guessing phase while hearing
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 1m55s
2025-03-01 15:57:31 +01:00
33a1715adc Add isPlaying state to MusicSlider 2025-03-01 15:47:12 +01:00
e633a5e004 Update result & scoreboard UI 2025-03-01 15:14:45 +01:00
c31b7e35b6 Randomize answers 2025-03-01 14:41:40 +01:00
770cad27b0 Optimize code 2025-03-01 14:36:39 +01:00
6750b00f46 Fix youtube embed 2025-03-01 14:32:52 +01:00
a5579cc474 Fix chatbox 2025-03-01 13:51:05 +01:00
206988f4b7 Implement proper youtube support 2025-03-01 12:58:33 +01:00
23 changed files with 2658 additions and 772 deletions

View File

@ -5,6 +5,7 @@ import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faDrum, faGuitar, faHeadphones, faMusic} from "@fortawesome/free-solid-svg-icons";
import Home from "@/pages/Home";
import WaitingRoom from "@/pages/WaitingRoom";
import Ending from "@/pages/Ending";
const App = () => {
const {currentState} = useContext(StateContext);
@ -63,6 +64,7 @@ const App = () => {
{currentState === "WaitingRoom" && <WaitingRoom />}
{currentState === "Home" && <Home />}
{currentState === "Game" && <Game />}
{currentState === "Ending" && <Ending />}
</>
)
}

View File

@ -1,99 +0,0 @@
import {createContext, useState, useEffect, useCallback, useRef} from 'react';
import {io} from 'socket.io-client';
export const SocketContext = createContext();
export const SocketProvider = ({children}) => {
const [socket, setSocket] = useState(null);
const [connected, setConnected] = useState(false);
const connectAttempts = useRef(0);
const maxAttempts = 3;
const connect = useCallback(() => {
if (socket) return;
const serverUrl = process.env.NODE_ENV === 'production' ? window.location.origin : 'http://localhost:5287';
try {
const newSocket = io(serverUrl, {
reconnectionAttempts: 3,
timeout: 10000,
reconnectionDelay: 1000
});
newSocket.on('connect', () => {
console.log('Socket connected with ID:', newSocket.id);
setConnected(true);
connectAttempts.current = 0;
});
newSocket.on('disconnect', (reason) => {
console.log('Socket disconnected:', reason);
setConnected(false);
});
newSocket.on('connect_error', (error) => {
console.error('Connection error:', error);
connectAttempts.current += 1;
if (connectAttempts.current >= maxAttempts) {
console.error('Max connection attempts reached, giving up');
newSocket.disconnect();
}
});
setSocket(newSocket);
} catch (err) {
console.error('Error creating socket connection:', err);
}
}, [socket]);
const disconnect = useCallback(() => {
if (socket) {
socket.disconnect();
setSocket(null);
setConnected(false);
console.log('Socket manually disconnected');
}
}, [socket]);
const send = useCallback((event, data) => {
if (!socket || !connected) {
console.warn(`Cannot send event "${event}": socket not connected`);
return false;
}
try {
socket.emit(event, data);
return true;
} catch (err) {
console.error(`Error sending event "${event}":`, err);
return false;
}
}, [socket, connected]);
const on = useCallback((event, callback) => {
if (!socket) {
console.warn(`Cannot listen for event "${event}": socket not initialized`);
return () => {
};
}
socket.on(event, callback);
return () => socket.off(event, callback);
}, [socket]);
useEffect(() => {
return () => {
if (socket) {
socket.disconnect();
}
};
}, [socket]);
return (
<SocketContext.Provider value={{socket, connected, connect, disconnect, send, on}}>
{children}
</SocketContext.Provider>
);
};

View File

@ -1 +0,0 @@
export * from "./SocketContext";

View File

@ -11,5 +11,6 @@ $pink: #ff6bb3
$blue: #4d9dff
$purple: #9c6bff
$cyan: #6bffea
$orange: #ff9b6b
$yellow: #ffde6b
$mint-green: #85ffbd

View File

@ -0,0 +1,117 @@
import React, { useEffect, useRef, useState } from 'react';
import './styles.sass';
const YouTubePlayer = ({
videoId,
autoplay = false,
startTime = 45,
onReady = () => {},
className = ''
}) => {
const iframeRef = useRef(null);
const playerRef = useRef(null);
const [isLoaded, setIsLoaded] = useState(false);
const [customStartTime] = useState(startTime);
console.log("YouTubePlayer rendering with videoId:", videoId, "startTime:", startTime);
useEffect(() => {
if (!window.YT) {
const tag = document.createElement('script');
tag.src = 'https://www.youtube.com/iframe_api';
window.onYouTubeIframeAPIReady = () => {
console.log("YouTube API ready");
};
const firstScriptTag = document.getElementsByTagName('script')[0];
firstScriptTag.parentNode.insertBefore(tag, firstScriptTag);
}
}, []);
useEffect(() => {
if (!videoId) return;
const createPlayer = () => {
if (!window.YT || !window.YT.Player) {
console.log("YouTube API not ready, waiting...");
setTimeout(createPlayer, 100);
return;
}
if (playerRef.current) {
playerRef.current.destroy();
playerRef.current = null;
}
console.log("Creating YouTube player for video:", videoId);
try {
playerRef.current = new window.YT.Player(iframeRef.current, {
height: '200',
width: '300',
videoId: videoId,
playerVars: {
'playsinline': 1,
'controls': 1,
'showinfo': 0,
'rel': 0,
'autoplay': autoplay ? 1 : 0,
'start': startTime,
'modestbranding': 1,
'fs': 0,
},
events: {
'onReady': (event) => {
console.log("YouTube player ready");
if (autoplay) {
event.target.seekTo(startTime || 0);
event.target.playVideo();
}
setIsLoaded(true);
onReady();
},
'onError': (event) => {
console.error("YouTube player error:", event);
}
}
});
} catch (error) {
console.error("Error creating YouTube player:", error);
}
};
createPlayer();
return () => {
if (playerRef.current) {
try {
playerRef.current.destroy();
playerRef.current = null;
} catch (e) {
console.error("Error destroying player:", e);
}
}
};
}, [videoId, autoplay, onReady, startTime, customStartTime]);
useEffect(() => {
if (playerRef.current && isLoaded && customStartTime !== startTime) {
try {
playerRef.current.seekTo(customStartTime);
} catch (error) {
console.error("Error seeking to time:", error);
}
}
}, [customStartTime, isLoaded]);
return (
<div className={`youtube-player-container visible-player ${className}`}>
<div ref={iframeRef} className="youtube-embed" />
</div>
);
};
export default YouTubePlayer;

View File

@ -0,0 +1,29 @@
.youtube-player-container
width: 100%
margin: 10px 0
border-radius: 12px
overflow: hidden
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3)
background: #000
&.visible-player
height: 200px
max-width: 100%
opacity: 1
z-index: 1
.youtube-embed
width: 100%
height: 100%
border: 0
border-radius: 8px
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5)
.audio-player
width: 1px
height: 1px
position: absolute
top: -9999px
left: -9999px
opacity: 0
visibility: hidden

View File

@ -0,0 +1,72 @@
import { useEffect, useState, useContext } from "react";
import { StateContext } from "@/common/contexts/StateContext";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faTrophy, faMedal, faAward, faHome, faCrown } from "@fortawesome/free-solid-svg-icons";
import "./styles.sass";
export const Ending = () => {
const { setCurrentState } = useContext(StateContext);
const [finalScores, setFinalScores] = useState([]);
useEffect(() => {
const savedData = JSON.parse(localStorage.getItem('finalScores') || '{"scores":{}}');
const sortedScores = Object.entries(savedData.scores)
.map(([userId, data]) => ({
id: userId,
name: data.name,
score: data.score
}))
.sort((a, b) => b.score - a.score);
setFinalScores(sortedScores);
}, []);
const getPlayerIcon = (index) => {
switch(index) {
case 0: return <FontAwesomeIcon icon={faTrophy} className="gold" />;
case 1: return <FontAwesomeIcon icon={faMedal} className="silver" />;
case 2: return <FontAwesomeIcon icon={faAward} className="bronze" />;
default: return <FontAwesomeIcon icon={faCrown} className="normal" />;
}
};
const handleReturnHome = () => {
localStorage.removeItem('finalScores');
setCurrentState("Home");
};
return (
<div className="ending-page">
<div className="background-overlay">
<div className="rotating-gradient"></div>
</div>
<div className="ending-content">
<h1>Spiel beendet!</h1>
<div className="final-scores">
<h2>Endstand</h2>
<div className="leaderboard">
{finalScores.map((player, index) => (
<div
key={player.id}
className={`leaderboard-entry ${index < 3 ? `top-${index + 1}` : ''}`}
>
<div className="rank-icon">
{getPlayerIcon(index)}
</div>
<div className="player-info">
<span className="player-name">{player.name}</span>
<span className="player-score">{player.score} Punkte</span>
</div>
</div>
))}
</div>
</div>
<button className="return-home" onClick={handleReturnHome}>
<FontAwesomeIcon icon={faHome} />
Zurück zum Hauptmenü
</button>
</div>
</div>
);
};

View File

@ -0,0 +1 @@
export {Ending as default} from "./Ending.jsx";

View File

@ -0,0 +1,128 @@
@import "@/common/styles/colors"
.ending-page
height: 100vh
width: 100vw
display: flex
justify-content: center
align-items: center
position: relative
.ending-content
text-align: center
z-index: 2
animation: float-up 0.8s ease-out
h1
font-size: 48pt
color: $white
margin-bottom: 40px
background: linear-gradient(135deg, $yellow, $pink)
-webkit-background-clip: text
background-clip: text
-webkit-text-fill-color: transparent
animation: title-shimmer 3s infinite alternate ease-in-out
.final-scores
background: rgba(255, 255, 255, 0.1)
backdrop-filter: blur(10px)
border-radius: 20px
padding: 30px
margin-bottom: 30px
border: 1px solid rgba(255, 255, 255, 0.2)
h2
color: $white
margin-bottom: 20px
font-size: 24pt
.leaderboard
display: flex
flex-direction: column
gap: 15px
.leaderboard-entry
display: flex
align-items: center
padding: 15px 20px
background: rgba(255, 255, 255, 0.05)
border-radius: 15px
transition: all 0.3s ease
border: 1px solid rgba(255, 255, 255, 0.1)
&:hover
transform: translateY(-2px)
background: rgba(255, 255, 255, 0.1)
&.top-1
background: linear-gradient(135deg, rgba(255, 215, 0, 0.2), rgba(255, 215, 0, 0.1))
border-color: rgba(255, 215, 0, 0.3)
transform: scale(1.05)
.rank-icon
color: #FFD700
filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.5))
&.top-2
background: linear-gradient(135deg, rgba(192, 192, 192, 0.2), rgba(192, 192, 192, 0.1))
border-color: rgba(192, 192, 192, 0.3)
.rank-icon
color: #C0C0C0
filter: drop-shadow(0 0 10px rgba(192, 192, 192, 0.5))
&.top-3
background: linear-gradient(135deg, rgba(205, 127, 50, 0.2), rgba(205, 127, 50, 0.1))
border-color: rgba(205, 127, 50, 0.3)
.rank-icon
color: #CD7F32
filter: drop-shadow(0 0 10px rgba(205, 127, 50, 0.5))
.rank-icon
font-size: 24pt
margin-right: 20px
.player-info
flex: 1
display: flex
justify-content: space-between
align-items: center
.player-name
color: $white
font-size: 18pt
.player-score
color: $yellow
font-size: 16pt
font-weight: bold
.return-home
padding: 15px 30px
background: linear-gradient(135deg, $purple, $blue)
border: none
border-radius: 12px
color: $white
font-size: 16pt
cursor: pointer
transition: all 0.3s ease
display: flex
align-items: center
gap: 10px
margin: 0 auto
&:hover
transform: translateY(-3px)
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4)
svg
font-size: 18pt
@keyframes float-up
0%
opacity: 0
transform: translateY(30px)
100%
opacity: 1
transform: translateY(0)

View File

@ -1,12 +1,24 @@
import "./styles.sass";
import {SocketContext} from "@/common/contexts/SocketContext";
import {StateContext} from "@/common/contexts/StateContext";
import {useContext, useState, useEffect, useRef, useCallback} from "react";
import MusicSlider from "@/pages/Game/components/MusicSlider";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faMessage, faMusic, faHeadphones, faClock, faCrown, faPaperPlane} from "@fortawesome/free-solid-svg-icons";
import {
faMessage,
faMusic,
faHeadphones,
faClock,
faCrown,
faPaperPlane,
faCheckCircle
} from "@fortawesome/free-solid-svg-icons";
import { fetchPlaylistSongs } from "@/services/youtubeService.js";
import YouTubePlayer from "../../components/YouTubePlayer/YouTubePlayer";
export const Game = () => {
const {send, on, socket, connected, connect} = useContext(SocketContext);
const {setCurrentState} = useContext(StateContext);
useEffect(() => {
if (!connected) {
@ -34,9 +46,14 @@ export const Game = () => {
const messageEndRef = useRef(null);
const timerIntervalRef = useRef(null);
const [allSongs, setAllSongs] = useState([]);
const [songsLoading, setSongsLoading] = useState(false);
const [composerIsPlaying, setComposerIsPlaying] = useState(false);
useEffect(() => {
if (!connected) return;
const eventHandlers = {
"roles-assigned": (roles) => {
const myRole = roles[socket?.id];
@ -52,9 +69,14 @@ export const Game = () => {
},
"song-selected": setCurrentSong,
"round-started": (data) => {
setRound(data.round);
requestAnimationFrame(() => {
setPhase("composing");
setRound(data.round);
setTimeLeft(data.timeRemaining);
setSelectedSong(null);
setHasGuessed(false);
setGuessResult(null);
});
},
"guessing-phase-started": (data) => {
console.log("Guessing phase started:", data);
@ -68,7 +90,17 @@ export const Game = () => {
},
"round-results": (results) => {
setPhase("results");
setScores(results.scores);
const scoresWithNames = {};
Object.entries(results.scores).forEach(([userId, score]) => {
const user = connectedUsers.find(u => u.id === userId);
scoresWithNames[userId] = {
score: score,
name: user?.name || results.playerNames?.[userId] || "Player"
};
});
setScores(scoresWithNames);
if (!currentSong) {
setCurrentSong(results.selectedSong);
}
@ -103,6 +135,7 @@ export const Game = () => {
if (phase === "composing") {
console.log("Received frequency update:", data.frequency);
setFrequency(data.frequency);
setComposerIsPlaying(data.isPlaying);
}
},
"phase-changed": (data) => {
@ -111,7 +144,24 @@ export const Game = () => {
if (data.timeRemaining) {
setTimeLeft(data.timeRemaining);
}
},
"chat-message-confirmation": (msg) => {
setMessages(prev => prev.map(prevMsg => {
if (!prevMsg.system && prevMsg.text === msg.text && prevMsg.sender !== msg.sender) {
console.log(`Updating message sender from "${prevMsg.sender}" to "${msg.sender}"`);
return { ...prevMsg, sender: msg.sender };
}
return prevMsg;
}));
},
"song-options": (data) => {
console.log("Received song options early:", data);
setSongOptions(data.songOptions || []);
},
"game-ended": (finalData) => {
setCurrentState("Ending");
localStorage.setItem('finalScores', JSON.stringify(finalData));
},
};
const cleanupFunctions = Object.entries(eventHandlers).map(
@ -124,7 +174,7 @@ export const Game = () => {
}
return () => cleanupFunctions.forEach(cleanup => cleanup());
}, [socket, on, send, role, currentSong, phase, connected]);
}, [socket, on, send, role, currentSong, phase, connected, connectedUsers, setCurrentState, round]);
useEffect(() => {
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
@ -144,21 +194,77 @@ export const Game = () => {
messageEndRef.current?.scrollIntoView({behavior: "smooth"});
}, [messages]);
const handleFrequencyChange = useCallback((newFrequency) => {
useEffect(() => {
if (!connected || !socket) return;
const loadSongs = async () => {
try {
setSongsLoading(true);
console.log("Fetching songs with active socket:", socket?.id);
const songs = await fetchPlaylistSongs(socket);
console.log(`Successfully loaded ${songs.length} songs`);
setAllSongs(songs);
} catch (error) {
console.error("Error loading songs:", error);
} finally {
setSongsLoading(false);
}
};
loadSongs();
}, [socket, connected]);
useEffect(() => {
if (!allSongs.length || !currentSong || !currentSong.id) return;
const enhancedSong = allSongs.find(song => song.id === currentSong.id);
if (enhancedSong && enhancedSong !== currentSong) {
setCurrentSong(enhancedSong);
}
}, [allSongs, currentSong]);
useEffect(() => {
if (!allSongs.length || !songOptions.length) return;
const enhancedOptions = songOptions.map(option => {
if (option.id && !option.title) {
return allSongs.find(song => song.id === option.id) || option;
}
return option;
});
if (JSON.stringify(enhancedOptions) !== JSON.stringify(songOptions)) {
setSongOptions(enhancedOptions);
}
}, [allSongs, songOptions]);
useEffect(() => {
if (phase === 'composing') {
setSelectedSong(null);
setHasGuessed(false);
setGuessResult(null);
}
}, [phase]);
const handleFrequencyChange = useCallback((newFrequency, isPlaying) => {
setFrequency(newFrequency);
setComposerIsPlaying(isPlaying);
if (role === "composer") {
send("submit-frequency", { frequency: newFrequency });
send("submit-frequency", { frequency: newFrequency, isPlaying });
}
}, [role, send]);
const handleSendMessage = useCallback(() => {
if (inputValue.trim()) {
const messageData = { text: inputValue, sender: username };
const currentUser = connectedUsers.find(u => u.id === socket?.id);
const senderName = currentUser?.name || username || "Player";
const messageData = { text: inputValue, sender: senderName };
send("send-message", messageData);
setMessages(prev => [...prev, messageData]);
setInputValue("");
}
}, [inputValue, username, send]);
}, [inputValue, send, socket?.id, connectedUsers, username]);
const handleSongSelect = useCallback((song) => {
setSelectedSong(song);
@ -179,13 +285,14 @@ export const Game = () => {
}, [selectedSong, send, phase]);
const handleNextRound = useCallback(() => {
send("next-round");
setSelectedSong(null);
setGuessResult(null);
setTimeLeft(0);
send("next-round");
}, [send]);
// Phase-specific content rendering
const handlePlayerReady = useCallback(() => {
console.log("Player ready");
}, []);
const renderPhaseContent = () => {
switch (phase) {
case "waiting":
@ -214,16 +321,56 @@ export const Game = () => {
<div className="song-description">von {currentSong.artist}</div>
</div>
</div>
<p className="instruction">Spiele diesen Song mit dem Tonregler!</p>
<div className="song-player-container">
<YouTubePlayer
videoId={currentSong.youtubeId}
autoplay={true}
startTime={currentSong.refrainTime || 45}
onReady={handlePlayerReady}
className="song-embedded-player"
/>
</div>
<div className="music-controls">
<p className="instruction">Verändere die Frequenz des Tons, um den Song zu spielen.</p>
</div>
</div>
)}
{role === "guesser" && (
<div className="guessing-display">
<div className="listening-display">
<div className="listening-icon">
<FontAwesomeIcon icon={faHeadphones} size="4x" />
</div>
<p className="instruction">Höre genau zu und versuche, den Song zu erkennen!</p>
<p className="instruction">Hör dir die Frequenzen an und wähle den passenden Song aus.</p>
</div>
{songOptions.length > 0 && (
<div className="song-grid early-preview">
{songOptions.map(song => (
<div
key={song.id}
className={`song-option ${selectedSong?.id === song.id ? 'selected' : ''}`}
onClick={() => handleSongSelect(song)}
>
<div className="song-image">
<img src={song.coverUrl} alt={song.title} />
{selectedSong?.id === song.id && (
<div className="selection-indicator">
<FontAwesomeIcon icon={faCheckCircle} />
</div>
)}
</div>
<div className="song-details">
<div className="song-title">{song.title}</div>
<div className="song-artist">{song.artist}</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
@ -233,8 +380,8 @@ export const Game = () => {
return (
<div className="guessing-phase">
<div className="phase-header">
<h3>Runde {round}: Song erraten</h3>
<div className="timer">
<h3>Auswahlphase</h3>
<div className="timer urgent">
<FontAwesomeIcon icon={faClock} /> {timeLeft}s
</div>
</div>
@ -264,7 +411,7 @@ export const Game = () => {
<img src={song.coverUrl} alt={song.title} />
{selectedSong?.id === song.id && (
<div className="selection-indicator">
<FontAwesomeIcon icon="fa-solid fa-check-circle" />
<FontAwesomeIcon icon={faCheckCircle} />
</div>
)}
</div>
@ -285,7 +432,7 @@ export const Game = () => {
onClick={handleSubmitGuess}
disabled={!selectedSong}
>
<FontAwesomeIcon icon="fa-solid fa-paper-plane" />
<FontAwesomeIcon icon={faPaperPlane} />
Antwort einreichen
</button>
)}
@ -335,18 +482,24 @@ export const Game = () => {
<div className="scoreboard">
<h4>Punktestand</h4>
<div className="scores">
{Object.entries(scores).map(([userId, score]) => {
const user = connectedUsers.find(u => u.id === userId) || { name: 'Unbekannt' };
{Object.entries(scores).map(([userId, scoreData]) => {
const isCurrentUser = userId === socket?.id;
const isHost = connectedUsers.find(u => u.id === "user1")?.id === userId;
const playerName = typeof scoreData === 'object' ?
scoreData.name :
(connectedUsers.find(u => u.id === userId)?.name || "Player");
const playerScore = typeof scoreData === 'object' ? scoreData.score : scoreData;
return (
<div key={userId} className="score-entry">
<div key={userId} className={`score-entry ${isCurrentUser ? 'current-user' : ''}`}>
<span className="player-name">
{user.id === (socket?.id || "you") && "👉 "}
{user.name}
{userId === connectedUsers.find(u => u.id === "user1")?.id && (
{isCurrentUser && "👉 "}
{playerName}
{isHost && (
<FontAwesomeIcon icon={faCrown} className="host-icon" />
)}
</span>
<span className="player-score">{score}</span>
<span className="player-score">{playerScore}</span>
</div>
);
})}
@ -365,7 +518,7 @@ export const Game = () => {
);
default:
return <div>Unknown phase</div>;
return <div>Phasenfehler</div>;
}
};
@ -427,8 +580,15 @@ export const Game = () => {
isReadOnly={role !== "composer"}
onFrequencyChange={handleFrequencyChange}
frequency={frequency}
composerIsPlaying={composerIsPlaying}
/>
)}
{songsLoading && (
<div className="songs-loading-indicator">
Lade Songs...
</div>
)}
</div>
);
};

View File

@ -1,223 +1,256 @@
import {useEffect, useRef, useState} from "react";
import { useState, useEffect, useRef, useCallback } from 'react';
import "./styles.sass";
export const MusicSlider = ({ isReadOnly = false, onFrequencyChange, frequency: externalFrequency }) => {
const [frequency, setFrequency] = useState(externalFrequency || 440);
const [isPlaying, setIsPlaying] = useState(false);
const [dragging, setDragging] = useState(false);
const audioContextRef = useRef(null);
const oscillatorRef = useRef(null);
const gainNodeRef = useRef(null);
const frequencyToNote = (frequency) => {
const A4 = 440;
const notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const halfSteps = Math.round(12 * Math.log2(frequency / A4));
const A4Index = notes.indexOf('A');
let noteIndex = (A4Index + halfSteps) % 12;
if (noteIndex < 0) noteIndex += 12;
const octave = 4 + Math.floor((halfSteps + A4Index) / 12);
return `${notes[noteIndex]}${octave}`;
};
export const MusicSlider = ({ isReadOnly, onFrequencyChange, frequency: externalFrequency, composerIsPlaying }) => {
const [audioContext, setAudioContext] = useState(null);
const [oscillator, setOscillator] = useState(null);
const [gainNode, setGainNode] = useState(null);
const sliderRef = useRef(null);
const hasInteractedRef = useRef(false);
const frequency = useRef(externalFrequency || 440);
const isPressed = useRef(false);
const isDragging = useRef(false);
useEffect(() => {
const initAudioContext = () => {
if (!audioContextRef.current && !hasInteractedRef.current) {
hasInteractedRef.current = true;
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)();
if (!oscillatorRef.current) {
startAudio();
}
document.removeEventListener('click', initAudioContext);
document.removeEventListener('touchstart', initAudioContext);
document.removeEventListener('keydown', initAudioContext);
}
};
const initAudio = useCallback(() => {
if (audioContext) return;
document.addEventListener('click', initAudioContext);
document.addEventListener('touchstart', initAudioContext);
document.addEventListener('keydown', initAudioContext);
return () => {
document.removeEventListener('click', initAudioContext);
document.removeEventListener('touchstart', initAudioContext);
document.removeEventListener('keydown', initAudioContext);
};
}, []);
useEffect(() => {
if (externalFrequency !== undefined && !dragging) {
setFrequency(externalFrequency);
if (audioContextRef.current) {
if (!isPlaying) {
startAudio();
} else if (oscillatorRef.current) {
oscillatorRef.current.frequency.setValueAtTime(
externalFrequency,
audioContextRef.current.currentTime
);
}
}
}
}, [externalFrequency, dragging, isPlaying]);
const handleMouseDown = (e) => {
if (isReadOnly) return;
setDragging(true);
handleFrequencyChange(e);
if (!audioContextRef.current) {
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)();
}
if (!isPlaying) {
startAudio();
}
};
const handleMouseUp = () => {
setDragging(false);
};
const handleFrequencyChange = (e) => {
if (isReadOnly) return;
const rect = sliderRef.current.getBoundingClientRect();
const x = e.clientX - rect.left;
const clampedX = Math.max(0, Math.min(x, rect.width));
const newFrequency = 20 + (clampedX / rect.width) * 1980;
setFrequency(newFrequency);
if (onFrequencyChange) {
onFrequencyChange(newFrequency);
}
};
useEffect(() => {
const handleMouseMove = (e) => dragging && handleFrequencyChange(e);
const handleMouseUpGlobal = () => dragging && handleMouseUp();
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUpGlobal);
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUpGlobal);
};
}, [dragging]);
useEffect(() => {
if (isPlaying && oscillatorRef.current && audioContextRef.current) {
oscillatorRef.current.frequency.setValueAtTime(
frequency,
audioContextRef.current.currentTime
);
}
}, [frequency, isPlaying]);
const startAudio = () => {
if (!audioContextRef.current) {
try {
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)();
} catch (e) {
console.error("AudioContext could not be created:", e);
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(frequency.current, ctx.currentTime);
gain.gain.setValueAtTime(0.00001, ctx.currentTime);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
setAudioContext(ctx);
setOscillator(osc);
setGainNode(gain);
if (isReadOnly) {
gain.gain.setValueAtTime(0.00001, ctx.currentTime);
}
} catch (error) {
console.error("Audio initialization error:", error);
}
}, [audioContext, isReadOnly]);
useEffect(() => {
if (!isReadOnly || !externalFrequency) return;
if (!audioContext) {
initAudio();
return;
}
}
if (audioContextRef.current.state === 'suspended') {
audioContextRef.current.resume().catch(err => {
console.error("Could not resume AudioContext:", err);
});
}
if (oscillator && gainNode) {
frequency.current = externalFrequency;
oscillator.frequency.setValueAtTime(frequency.current, audioContext.currentTime);
try {
if (!oscillatorRef.current) {
oscillatorRef.current = audioContextRef.current.createOscillator();
oscillatorRef.current.type = 'sine';
oscillatorRef.current.frequency.setValueAtTime(
frequency,
audioContextRef.current.currentTime
);
gainNodeRef.current = audioContextRef.current.createGain();
gainNodeRef.current.gain.setValueAtTime(0.5, audioContextRef.current.currentTime);
oscillatorRef.current.connect(gainNodeRef.current);
gainNodeRef.current.connect(audioContextRef.current.destination);
oscillatorRef.current.start();
console.log("Audio started successfully");
if (composerIsPlaying) {
gainNode.gain.setTargetAtTime(0.5, audioContext.currentTime, 0.01);
} else {
oscillatorRef.current.frequency.setValueAtTime(
frequency,
audioContextRef.current.currentTime
);
gainNode.gain.setTargetAtTime(0.00001, audioContext.currentTime, 0.05);
}
setIsPlaying(true);
} catch (e) {
console.error("Error starting audio:", e);
}
}, [externalFrequency, composerIsPlaying, oscillator, audioContext, gainNode, isReadOnly, initAudio]);
useEffect(() => {
if (isReadOnly) return;
const calculateFrequency = (clientX) => {
const slider = sliderRef.current;
const rect = slider.getBoundingClientRect();
const width = rect.width;
let x = clientX - rect.left;
if (clientX < rect.left) x = 0;
if (clientX > rect.right) x = width;
const percentage = x / width;
return Math.round(220 + percentage * 880);
};
const handleMouseMove = (e) => {
if (!isPressed.current) return;
e.preventDefault();
e.stopPropagation();
const newFreq = calculateFrequency(e.clientX);
frequency.current = newFreq;
onFrequencyChange(newFreq, true);
if (oscillator && gainNode) {
oscillator.frequency.setValueAtTime(newFreq, audioContext.currentTime);
gainNode.gain.setTargetAtTime(0.5, audioContext.currentTime, 0.01);
}
};
const stopAudio = () => {
if (oscillatorRef.current) {
try {
oscillatorRef.current.stop();
oscillatorRef.current.disconnect();
oscillatorRef.current = null;
setIsPlaying(false);
console.log("Audio stopped");
} catch (e) {
console.error("Error stopping audio:", e);
}
const handleMouseDown = (e) => {
const target = e.target;
const isSlider = target.classList.contains('otamatone-neck');
const isIndicator = target.classList.contains('frequency-indicator') ||
target.closest('.frequency-indicator');
if (!isSlider && !isIndicator) return;
e.preventDefault();
e.stopPropagation();
if (!audioContext) initAudio();
isPressed.current = true;
isDragging.current = true;
if (gainNode) {
gainNode.gain.setTargetAtTime(0.5, audioContext.currentTime, 0.01);
}
document.body.style.userSelect = 'none';
const iframes = document.querySelectorAll('iframe');
iframes.forEach(iframe => {
iframe.style.pointerEvents = 'none';
});
onFrequencyChange(frequency.current, true);
handleMouseMove(e);
};
const handleMouseUp = (e) => {
if (!isPressed.current) return;
e.preventDefault();
e.stopPropagation();
isPressed.current = false;
isDragging.current = false;
if (gainNode) {
gainNode.gain.setTargetAtTime(0.00001, audioContext.currentTime, 0.05);
}
document.body.style.userSelect = '';
const iframes = document.querySelectorAll('iframe');
iframes.forEach(iframe => {
iframe.style.pointerEvents = 'auto';
});
onFrequencyChange(frequency.current, false);
};
document.addEventListener('mousemove', handleMouseMove, true);
document.addEventListener('mouseup', handleMouseUp, true);
const slider = sliderRef.current;
if (slider) {
slider.addEventListener('mousedown', handleMouseDown, true);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove, true);
document.removeEventListener('mouseup', handleMouseUp, true);
if (slider) {
slider.removeEventListener('mousedown', handleMouseDown, true);
}
document.body.style.userSelect = '';
const iframes = document.querySelectorAll('iframe');
iframes.forEach(iframe => {
iframe.style.pointerEvents = 'auto';
});
};
}, [isReadOnly, onFrequencyChange, audioContext, oscillator, gainNode, initAudio]);
useEffect(() => {
return () => {
if (oscillatorRef.current) {
if (gainNode) gainNode.gain.setValueAtTime(0, audioContext.currentTime);
if (oscillator) {
try {
oscillatorRef.current.stop();
oscillatorRef.current.disconnect();
oscillator.stop();
} catch (e) {
console.error("Error cleaning up oscillator:", e);
}
}
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
try {
audioContextRef.current.close();
} catch (e) {
console.error("Error closing AudioContext:", e);
console.warn("Oscillator already stopped");
}
}
if (audioContext) audioContext.close();
};
}, []);
}, [oscillator, audioContext, gainNode]);
useEffect(() => {
return () => {
if (isPlaying) {
stopAudio();
}
const getFrequencyPosition = () => {
const pos = ((frequency.current - 220) / 880) * 100;
return `${Math.max(0, Math.min(pos, 100))}%`;
};
}, [isPlaying]);
const getMouthSize = () => {
const minSize = 30;
const maxSize = 60;
const freqRange = 880;
const sizeRange = maxSize - minSize;
const relativeFreq = frequency.current - 220;
const size = minSize + (relativeFreq / freqRange) * sizeRange;
return Math.max(minSize, Math.min(maxSize, size));
};
const shouldShowNoteMarker = () => {
if (!isReadOnly) return isDragging.current;
return composerIsPlaying;
};
if (isReadOnly && !composerIsPlaying) {
return null;
}
return (
<div className={`otamatone-container ${isReadOnly ? 'read-only' : ''}`}>
<div
className="otamatone"
onMouseDown={handleMouseDown}
style={{ cursor: isReadOnly ? 'default' : 'pointer' }}
>
<div className="otamatone-container">
<div className="otamatone">
<div className="otamatone-face">
<div className="otamatone-mouth" style={{
height: `${10 + (frequency / 2000) * 40}px`,
width: `${10 + (frequency / 2000) * 40}px`
}}></div>
<div className="otamatone-eyes">
<div className="eye left-eye"></div>
<div className="eye right-eye"></div>
</div>
<div
className="otamatone-mouth"
style={{
width: `${getMouthSize()}px`,
height: `${getMouthSize()}px`
}}
></div>
</div>
<div className="otamatone-neck" ref={sliderRef}>
<div className="frequency-indicator" style={{left: `${(frequency - 20) / 1980 * 100}%`}}></div>
<div className="note-marker" style={{left: '10%', pointerEvents: 'none'}}></div>
<div className="note-marker" style={{left: '50%', pointerEvents: 'none'}}></div>
<div className="note-marker" style={{left: '90%', pointerEvents: 'none'}}></div>
{(!isReadOnly || composerIsPlaying) && (
<div
ref={sliderRef}
className={`otamatone-neck ${!isReadOnly ? 'interactive' : ''}`}
style={{ cursor: isReadOnly ? 'default' : 'pointer' }}
>
<div
className={`frequency-indicator ${isReadOnly ? 'read-only' : ''} ${composerIsPlaying ? 'active' : ''}`}
style={{ left: getFrequencyPosition() }}
>
{shouldShowNoteMarker() && (
<div className="note-marker">
{frequencyToNote(frequency.current)}
</div>
)}
</div>
</div>
)}
</div>
</div>
);

View File

@ -63,6 +63,10 @@
background: rgba(255, 255, 255, 0.2)
pointer-events: none
&.interactive
.frequency-indicator:hover
transform: translateX(-50%) scale(1.1)
.frequency-indicator
position: absolute
top: -20px
@ -84,13 +88,39 @@
cursor: grabbing
transform: translateX(-50%) scale(0.95)
&.read-only
pointer-events: none
cursor: default
opacity: 0.8
box-shadow: 0 0 15px rgba(255, 107, 179, 0.4)
&.active
opacity: 1
box-shadow: 0 0 30px rgba(255, 107, 179, 0.8)
transform: translateX(-50%) scale(1.1)
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)
.note-marker
position: absolute
top: -50px
font-size: 32pt
font-size: 24pt
color: $white
text-shadow: 0 0 15px rgba(255, 255, 255, 0.7)
font-weight: bold
font-family: 'Arial', sans-serif
background: rgba(0, 0, 0, 0.6)
padding: 2px 8px
border-radius: 8px
backdrop-filter: blur(5px)
min-width: 60px
text-align: center
animation: note-pop 0.2s ease-out
opacity: 1
.read-only &
background: rgba(0, 0, 0, 0.8)
box-shadow: 0 0 20px rgba(0, 0, 0, 0.4)
.otamatone-face
width: 140px
@ -152,10 +182,11 @@
border-radius: 50%
position: absolute
bottom: 30px
width: 30px
height: 30px
transition: all 0.3s ease
animation: mouth-pulse 5s infinite alternate ease-in-out
transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275)
transform-origin: center center
&.active
animation: mouth-pulse 0.5s infinite alternate ease-in-out
@keyframes blink
0%, 90%, 100%
@ -184,3 +215,13 @@
100%
transform: translateY(0)
opacity: 1
@keyframes note-pop
0%
transform: scale(0.8)
opacity: 0.5
50%
transform: scale(1.1)
100%
transform: scale(1)
opacity: 1

View File

@ -1,6 +1,6 @@
@import "@/common/styles/colors"
// Main layout improvements
.game-page
display: flex
flex-direction: column
@ -40,7 +40,6 @@
min-width: 0
margin-right: 20px
// Redesigned chat panel
.chat-panel
width: 280px
height: 100%
@ -162,7 +161,6 @@
&:active
transform: translateY(0)
// Game header improvements
.game-header
display: flex
justify-content: space-between
@ -197,7 +195,6 @@
margin-right: 8px
color: $yellow
// Improved button styles
.submit-guess-button, .next-round-button, button:not(.send-button)
background: linear-gradient(135deg, $purple, $blue)
color: #fff
@ -244,7 +241,6 @@
cursor: not-allowed
background: linear-gradient(135deg, #777, #555)
// Content component improvements
.waiting-phase
text-align: center
padding: 100px 0
@ -264,335 +260,47 @@
.song-display
text-align: center
display: flex
flex-direction: column
align-items: center
width: 100%
margin-bottom: 30px
.song-card
display: flex
flex-direction: row
align-items: center
background: rgba(255, 255, 255, 0.08)
padding: 25px
border-radius: 20px
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3)
background: rgba(40, 40, 60, 0.85)
padding: 20px
border-radius: 15px
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4)
backdrop-filter: blur(10px)
border: 1px solid rgba(255, 255, 255, 0.2)
border: 1px solid rgba(255, 255, 255, 0.15)
max-width: 500px
margin: 20px auto
transition: all 0.3s ease
&:hover
transform: translateY(-5px)
border-color: rgba(255, 255, 255, 0.4)
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4), 0 0 30px rgba(255, 255, 255, 0.1)
border-color: rgba(255, 255, 255, 0.3)
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.5), 0 0 30px rgba(255, 255, 255, 0.1)
img
width: 120px
height: 120px
object-fit: cover
border-radius: 15px
border-radius: 8px
margin-right: 20px
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5)
border: 2px solid rgba(255, 255, 255, 0.2)
.song-info
text-align: left
.song-names
font-size: 24px
color: $white
margin-bottom: 10px
.song-description
font-size: 16px
color: rgba(255, 255, 255, 0.7)
.phase-header
display: flex
justify-content: space-between
align-items: center
width: 100%
margin-bottom: 30px
h3
font-size: 24px
margin: 0
color: $white
text-shadow: 0 0 10px rgba(255, 255, 255, 0.3)
.timer
background: rgba(255, 255, 255, 0.1)
padding: 10px 20px
border-radius: 15px
font-size: 18px
display: flex
align-items: center
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2)
border: 1px solid rgba(255, 255, 255, 0.1)
svg
margin-right: 10px
color: $yellow
// Improved song selection grid
.song-selection
width: 100%
.instruction
text-align: center
font-size: 20px
margin-bottom: 30px
color: $white
.song-grid
display: grid
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))
gap: 20px
margin-top: 20px
.song-option
background: rgba(255, 255, 255, 0.1)
border-radius: 15px
overflow: hidden
cursor: pointer
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)
border: 2px solid transparent
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3)
height: 100%
&:hover:not(.disabled)
transform: translateY(-8px) scale(1.03)
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.4), 0 0 25px rgba(255, 255, 255, 0.1)
background: rgba(255, 255, 255, 0.15)
&.selected
border: 2px solid $yellow
box-shadow: 0 0 25px rgba(255, 255, 0, 0.4), 0 10px 30px rgba(0, 0, 0, 0.4)
background: rgba(255, 255, 255, 0.15)
&.disabled
opacity: 0.7
cursor: default
.song-image
position: relative
img
width: 100%
height: 150px
object-fit: cover
.selection-indicator
position: absolute
top: 10px
right: 10px
background: $yellow
width: 30px
height: 30px
border-radius: 50%
display: flex
align-items: center
justify-content: center
box-shadow: 0 0 15px rgba(255, 255, 0, 0.7)
.song-details
padding: 15px
.song-title
font-weight: bold
font-size: 16px
color: $white
.song-artist
font-size: 14px
opacity: 0.7
margin-top: 5px
// Results phase improvements
.results-phase
text-align: center
h3
font-size: 28px
margin-bottom: 40px
.round-results
background: rgba(255, 255, 255, 0.08)
backdrop-filter: blur(10px)
border-radius: 25px
padding: 30px
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4)
border: 1px solid rgba(255, 255, 255, 0.15)
max-width: 600px
margin: 0 auto
.scoreboard
.music-controls
margin-top: 30px
padding-top: 20px
border-top: 1px solid rgba(255, 255, 255, 0.1)
h4
margin-bottom: 15px
font-size: 20px
.scores
display: grid
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))
gap: 10px
.score-entry
background: rgba(255, 255, 255, 0.1)
border-radius: 10px
padding: 10px 15px
display: flex
justify-content: space-between
align-items: center
&:nth-child(1)
background: linear-gradient(135deg, rgba(255, 215, 0, 0.2), rgba(255, 215, 0, 0.1))
box-shadow: 0 0 20px rgba(255, 215, 0, 0.3)
&:nth-child(2)
background: linear-gradient(135deg, rgba(192, 192, 192, 0.2), rgba(192, 192, 192, 0.1))
&:nth-child(3)
background: linear-gradient(135deg, rgba(205, 127, 50, 0.2), rgba(205, 127, 50, 0.1))
.player-name
display: flex
align-items: center
.host-icon
color: $yellow
margin-left: 5px
// Responsive adjustments
@media (max-width: 900px)
.game-layout
flex-direction: column
.main-content
margin-right: 0
margin-bottom: 20px
.chat-panel
width: 100%
max-height: 300px
// Animations
@keyframes slide-in-right
0%
transform: translateX(30px)
opacity: 0
100%
transform: translateX(0)
opacity: 1
// ...existing animations...
.song-display
max-width: 400px
display: flex
flex-direction: column
align-items: center
justify-content: center
width: 50%
margin-right: 20px
color: $white
text-align: center
h2
font-size: 52pt
color: $white
margin-bottom: 25px
position: relative
z-index: 2
background: linear-gradient(135deg, $pink, $blue 45%, $mint-green 65%, $yellow 85%, $pink)
background-size: 300% 100%
animation: title-shimmer 10s infinite alternate ease-in-out, title-float 6s infinite ease-in-out
-webkit-background-clip: text
background-clip: text
-webkit-text-fill-color: transparent
filter: drop-shadow(0 0 15px rgba(255, 255, 255, 0.7))
letter-spacing: 0.1em
font-weight: bold
&:before
content: "ToneGuessr"
position: absolute
z-index: -1
left: 0
top: 0
background: none
-webkit-text-fill-color: transparent
filter: blur(15px) brightness(1.3)
opacity: 0.6
width: 100%
height: 100%
.song-card
display: flex
flex-direction: row
align-items: center
background: rgba(255, 255, 255, 0.08)
padding: 25px
border-radius: 20px
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2), 0 0 20px rgba(255, 255, 255, 0.1)
backdrop-filter: blur(10px)
border: 1px solid rgba(255, 255, 255, 0.2)
max-width: 500px
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)
will-change: transform, box-shadow
transform: translateZ(0)
backface-visibility: hidden
animation: card-pulse 6s infinite alternate ease-in-out
&:hover
transform: translateY(-10px) scale(1.02)
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.3), 0 0 30px rgba(255, 255, 255, 0.15)
border: 1px solid rgba(255, 255, 255, 0.4)
img
width: 120px
height: 120px
border-radius: 15px
margin-right: 25px
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4)
transition: transform 0.3s ease
will-change: transform
transform: translateZ(0)
animation: album-rotate 10s infinite alternate ease-in-out
&:hover
transform: scale(1.1) rotate(5deg)
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 40px rgba(102, 204, 255, 0.3)
.song-info
display: flex
flex-direction: column
align-items: flex-start
.song-names
font-size: 28pt
color: $white
margin-bottom: 10px
text-shadow: 0 0 10px rgba(255, 255, 255, 0.5)
animation: text-shimmer 5s infinite alternate ease-in-out
.song-description
font-size: 16pt
color: $border
opacity: 0.8
position: relative
&:after
content: ""
position: absolute
bottom: -5px
left: 0
width: 0%
height: 1px
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.7), transparent)
transition: all 0.4s ease
&:hover:after
width: 100%
.chat-window
width: 50%
@ -889,6 +597,26 @@
opacity: 0.8
animation: pulse 1.5s infinite ease-in-out
.guessing-display
display: flex
flex-direction: column
gap: 20px
margin-top: 20px
.early-preview
opacity: 0.8
transition: opacity 0.3s ease
&:hover
opacity: 1
.song-grid
margin-top: 10px
.timer.urgent
animation: urgent-pulse 1s infinite
background: rgba(255, 100, 100, 0.2)
@keyframes subtle-text-glow
0%, 100%
text-shadow: 0 0 10px rgba(255, 255, 255, 0.4)
@ -962,3 +690,387 @@
50%
opacity: 1
transform: scale(1.05)
.player-container
position: fixed
left: 20px
bottom: 20px
display: flex
flex-direction: column
align-items: flex-start
z-index: 100
background: rgba(0, 0, 0, 0.5)
padding: 10px
border-radius: 10px
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3)
max-width: 320px
.player-controls
width: 100%
margin-top: 10px
position: relative
left: auto
top: auto
right: auto
background: none
box-shadow: none
padding: 0
.volume-controls
width: 100%
display: flex
align-items: center
background: none
padding: 5px 0
position: static
box-shadow: none
span
white-space: nowrap
.volume-slider
flex: 1
margin-left: 10px
.composer-player
width: 300px
height: 169px
border-radius: 8px
overflow: hidden
.composer-player
width: 300px
height: 169px
border-radius: 8px
overflow: hidden
.position-jump-button
display: block
margin-top: 10px
padding: 8px 15px
background: linear-gradient(135deg, $yellow, $orange)
color: #fff
border: none
border-radius: 8px
font-size: 14px
font-weight: 600
cursor: pointer
transition: all 0.2s ease
width: 100%
&:hover
background: linear-gradient(135deg, lighten($yellow, 5%), lighten($orange, 5%))
transform: translateY(-2px)
box-shadow: 0 4px 15px rgba(255, 204, 0, 0.3)
&:active
transform: translateY(0)
.player-container
width: 100%
margin-top: 20px
background: rgba(20, 20, 20, 0.5)
backdrop-filter: blur(10px)
padding: 15px
border-radius: 15px
border: 1px solid rgba(255, 255, 255, 0.1)
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3)
h4
color: $white
margin: 0 0 15px 0
text-align: center
font-size: 16px
.embedded-player
width: 100%
border-radius: 8px
overflow: hidden
.song-player-container
width: 100%
max-width: 500px
margin: 20px auto
border-radius: 15px
overflow: hidden
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25)
background: rgba(0, 0, 0, 0.5)
.song-embedded-player
width: 100%
height: auto
aspect-ratio: 16 / 9
transition: pointer-events 0.1s ease
&:hover .song-embedded-player
pointer-events: auto !important
.results-phase
display: flex
flex-direction: column
align-items: center
width: 100%
h3
margin-bottom: 30px
font-size: 28px
color: $white
text-shadow: 0 2px 15px rgba(255, 255, 255, 0.3)
.round-results
width: 100%
max-width: 600px
display: flex
flex-direction: column
align-items: center
.composer-results, .guesser-results
width: 100%
margin-bottom: 20px
text-align: center
p
font-size: 18px
margin-bottom: 15px
color: $white
.correct-song
margin-top: 10px
position: relative
overflow: visible
z-index: 5
&:before
content: ""
position: absolute
top: -30px
left: -30px
right: -30px
bottom: -30px
background: rgba(20, 20, 40, 0.2)
border-radius: 40px
filter: blur(40px)
z-index: -1
.song-card.highlight
position: relative
transform: scale(1.05)
background: linear-gradient(135deg, rgba(40, 40, 60, 0.9), rgba(30, 30, 50, 0.95))
border: 2px solid $yellow
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4), 0 0 50px rgba(255, 255, 0, 0.2)
backdrop-filter: blur(30px)
padding: 25px
transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)
-webkit-backdrop-filter: blur(30px)
background: rgba(35, 35, 50, 0.5)
border-radius: 18px
&:hover
transform: translateY(-8px) scale(1.08)
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.5), 0 0 60px rgba(255, 255, 0, 0.3), inset 0 0 50px rgba(255, 255, 255, 0.08)
&:after
content: ""
position: absolute
top: -15px
right: -15px
width: 40px
height: 40px
background: linear-gradient(135deg, $yellow, darken($yellow, 15%))
color: #000
border-radius: 50%
border: 3px solid rgba(0, 0, 0, 0.5)
display: flex
align-items: center
justify-content: center
font-size: 20px
font-weight: bold
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.4), 0 0 20px rgba(255, 255, 0, 0.5)
animation: pulse-highlight 2s infinite alternate ease-in-out
&:before
content: ""
position: absolute
inset: 0
background: linear-gradient(135deg, rgba(80, 80, 120, 0.3), rgba(30, 30, 60, 0.8))
border-radius: 18px
z-index: -1
opacity: 0.8
img
width: 130px
height: 130px
border-radius: 14px
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.6)
border: 3px solid rgba(255, 255, 255, 0.3)
transform: rotate(-2deg)
transition: all 0.5s ease, border-radius 0.3s ease
&:hover
transform: rotate(0deg) scale(1.05)
border-color: rgba(255, 255, 0, 0.6)
border-radius: 12px
.song-info
margin-left: 25px
.song-names
font-size: 26px
font-weight: bold
color: $white
text-shadow: 0 2px 10px rgba(255, 255, 255, 0.5)
margin-bottom: 10px
background: linear-gradient(90deg, $white, rgba(255,255,255,0.7))
-webkit-background-clip: text
background-clip: text
-webkit-text-fill-color: transparent
.song-description
font-size: 18px
color: rgba(255, 255, 255, 0.8)
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.7)
.guess-result
margin: 20px 0
padding: 15px 20px
border-radius: 16px
font-size: 18px
font-weight: bold
animation: bounce-in 0.5s ease-out
&.correct
background: linear-gradient(135deg, rgba(50, 200, 100, 0.8), rgba(40, 160, 80, 0.8))
color: white
box-shadow: 0 4px 20px rgba(50, 200, 100, 0.4)
&.incorrect
background: linear-gradient(135deg, rgba(220, 60, 60, 0.8), rgba(180, 40, 40, 0.8))
color: white
box-shadow: 0 4px 20px rgba(220, 60, 60, 0.4)
.scoreboard
width: 100%
background: rgba(40, 40, 60, 0.6)
backdrop-filter: blur(10px)
border-radius: 20px
padding: 20px
margin-bottom: 20px
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3)
border: 1px solid rgba(255, 255, 255, 0.1)
margin-top: 5px
h4
text-align: center
font-size: 22px
color: $white
margin-top: 10px
margin-bottom: 10px
padding-bottom: 8px
.scores
display: flex
flex-direction: column
gap: 8px
.score-entry
display: flex
justify-content: space-between
align-items: center
padding: 12px 15px
border-radius: 14px
background: rgba(255, 255, 255, 0.1)
transition: all 0.2s ease
&:hover
background: rgba(255, 255, 255, 0.15)
transform: translateY(-2px)
&.current-user
background: rgba(102, 204, 255, 0.2)
border-left: 3px solid $blue
.player-name
display: flex
align-items: center
font-weight: 500
color: $white
font-size: 16px
.host-icon
color: $yellow
margin-left: 8px
filter: drop-shadow(0 0 5px rgba(255, 255, 0, 0.5))
.player-score
background: rgba(255, 255, 255, 0.2)
padding: 5px 12px
border-radius: 20px
font-weight: bold
font-size: 18px
color: $yellow
min-width: 40px
text-align: center
position: relative
overflow: hidden
&:after
content: ""
position: absolute
top: -50%
left: -50%
width: 200%
height: 200%
background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%)
opacity: 0
transition: opacity 0.5s ease
&.highlighted
animation: score-highlight 1s ease
.next-round-button
margin-top: 10px
padding: 15px 30px
font-size: 18px
.waiting-message
margin-top: 20px
font-style: italic
color: rgba(255, 255, 255, 0.7)
@keyframes score-highlight
0%
transform: scale(1)
background: rgba(255, 255, 0, 0.8)
color: #000
50%
transform: scale(1.2)
100%
transform: scale(1)
background: rgba(255, 255, 255, 0.2)
color: $yellow
@keyframes bounce-in
0%
opacity: 0
transform: scale(0.8)
50%
transform: scale(1.05)
100%
opacity: 1
transform: scale(1)
@keyframes pulse-highlight
0%, 100%
transform: scale(1)
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.4), 0 0 20px rgba(255, 255, 0, 0.3)
50%
transform: scale(1.1)
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5), 0 0 30px rgba(255, 255, 0, 0.6)
@keyframes urgent-pulse
0%, 100%
transform: scale(1)
background: rgba(255, 100, 100, 0.2)
50%
transform: scale(1.05)
background: rgba(255, 100, 100, 0.3)

View File

@ -15,14 +15,17 @@ export const WaitingRoom = () => {
const [isHost, setIsHost] = useState(false);
const [username, setUsername] = useState("");
const [copied, setCopied] = useState(false);
const [playlists, setPlaylists] = useState({});
const [votes, setVotes] = useState({});
const [selectedPlaylist, setSelectedPlaylist] = useState(null);
const messageEndRef = useRef(null);
useEffect(() => {
// Check if the user is a host and get other initial data
send("check-host-status");
send("get-user-info");
send("get-room-users");
send("get-room-code");
send("get-playlist-options");
const handleHostStatus = (status) => {
setIsHost(status.isHost);
@ -72,7 +75,39 @@ export const WaitingRoom = () => {
setCurrentState("Game");
};
// Register event listeners
const handlePlaylistOptions = (options) => {
const playlistsWithVotes = { ...options };
Object.keys(playlistsWithVotes).forEach(genre => {
const playlistId = playlistsWithVotes[genre].id;
playlistsWithVotes[genre].votes = votes[playlistId]?.length || 0;
});
setPlaylists(playlistsWithVotes);
};
const handleVotesUpdated = (newVotes) => {
console.log('Received updated votes:', newVotes);
setVotes(newVotes);
setPlaylists(current => {
const updated = { ...current };
Object.keys(updated).forEach(genre => {
const playlistId = updated[genre].id;
updated[genre].votes = newVotes[playlistId]?.length || 0;
});
return updated;
});
Object.entries(newVotes).forEach(([playlistId, voters]) => {
if (voters.includes(socket.id)) {
setSelectedPlaylist(playlistId);
}
});
};
const handleRoomJoined = (code) => {
setRoomCode(code);
};
const cleanupHostStatus = on("host-status", handleHostStatus);
const cleanupUserInfo = on("user-info", handleUserInfo);
const cleanupRoomUsers = on("room-users", handleRoomUsers);
@ -82,11 +117,13 @@ export const WaitingRoom = () => {
const cleanupUserDisconnected = on("user-disconnected", handleUserDisconnected);
const cleanupChatMessage = on("chat-message", handleChatMessage);
const cleanupGameStarted = on("game-started", handleGameStarted);
const cleanupPlaylistOptions = on("playlist-options", handlePlaylistOptions);
const cleanupVotesUpdated = on("playlist-votes-updated", handleVotesUpdated);
const cleanupRoomJoined = on("room-joined", handleRoomJoined);
// Add welcome message
setMessages([{
system: true,
text: "Welcome to the waiting room! Waiting for others to join..."
text: "Willkommen im Warteraum! Es wird noch auf weitere Spieler gewartet..."
}]);
return () => {
@ -99,6 +136,9 @@ export const WaitingRoom = () => {
cleanupUserDisconnected();
cleanupChatMessage();
cleanupGameStarted();
cleanupPlaylistOptions();
cleanupVotesUpdated();
cleanupRoomJoined();
};
}, [on, send, socket, setCurrentState]);
@ -125,7 +165,6 @@ export const WaitingRoom = () => {
};
const handleLeaveRoom = () => {
// Disconnect from the current room
if (socket) {
socket.disconnect();
}
@ -138,7 +177,61 @@ export const WaitingRoom = () => {
setTimeout(() => setCopied(false), 2000);
};
const minPlayersToStart = 1; // Set your minimum players requirement here
const handleVote = (playlistId) => {
setSelectedPlaylist(playlistId);
send("vote-playlist", { playlistId });
};
const getVoteCount = (playlistId) => {
return votes[playlistId]?.length || 0;
};
const renderPlaylistSection = () => (
<div className="playlist-section">
<div className="section-header">
<h2>Playlist wählen</h2>
<p className="vote-info">Die Playlist mit den meisten Stimmen wird gespielt</p>
</div>
<div className="playlists-grid">
{Object.entries(playlists).map(([genre, playlist]) => (
<div
key={playlist.id}
className={`playlist-card ${selectedPlaylist === playlist.id ? 'selected' : ''}`}
onClick={() => handleVote(playlist.id)}
>
<div className="playlist-thumbnail">
<img src={playlist.thumbnail.url} alt={playlist.title} />
<div className="playlist-overlay">
<div className="vote-count">
<span className="count">{getVoteCount(playlist.id)}</span>
<span className="vote-label">Stimmen</span>
</div>
{selectedPlaylist === playlist.id && (
<div className="your-vote">Deine Stimme</div>
)}
</div>
</div>
<div className="playlist-info">
<h3>{genre.toUpperCase()}</h3>
<div className="playlist-details">
<span>{playlist.songCount} songs</span>
<div className="vote-percentage-bar">
<div
className="fill"
style={{
width: `${(getVoteCount(playlist.id) / Math.max(1, users.length)) * 100}%`
}}
/>
</div>
</div>
</div>
</div>
))}
</div>
</div>
);
const minPlayersToStart = 1;
const canStartGame = isHost && users.length >= minPlayersToStart;
return (
@ -153,6 +246,7 @@ export const WaitingRoom = () => {
<span>Zurück zur Startseite</span>
</button>
<h1>Warteraum</h1>
{roomCode && (
<div className="room-code-container">
<div className="room-code" onClick={copyRoomCode}>
Code: <span className="code">{roomCode}</span>
@ -160,9 +254,13 @@ export const WaitingRoom = () => {
</div>
<div className="copy-hint">{copied ? "Kopiert!" : "Klicken zum Kopieren"}</div>
</div>
)}
</div>
<div className="waiting-room-content">
<div className="waiting-room-layout">
{renderPlaylistSection()}
<div className="bottom-section">
<div className="users-panel">
<div className="panel-header">
<FontAwesomeIcon icon={faUsers} />
@ -229,5 +327,6 @@ export const WaitingRoom = () => {
</div>
</div>
</div>
</div>
);
};

View File

@ -42,8 +42,8 @@
h1
font-size: 42pt
margin: 0
color: $white
margin-bottom: 15px
text-shadow: 0 0 15px rgba(255, 255, 255, 0.7)
background: linear-gradient(135deg, $pink, $blue 45%, $mint-green 65%, $yellow 85%, $pink)
background-size: 300% 100%
@ -102,10 +102,11 @@
width: 100%
max-width: 1200px
gap: 30px
height: calc(100vh - 180px)
height: calc(100vh - 140px)
z-index: 2
position: relative
animation: float-up 1.2s ease-out
overflow: hidden
.users-panel,
.chat-panel
@ -130,6 +131,7 @@
flex-grow: 1
overflow-y: auto
padding: 15px
min-height: 0
&::-webkit-scrollbar
width: 6px
@ -222,6 +224,7 @@
width: 70%
display: flex
flex-direction: column
height: 100%
.chat-messages
flex-grow: 1
@ -229,6 +232,7 @@
padding: 15px
display: flex
flex-direction: column
min-height: 0
&::-webkit-scrollbar
width: 8px
@ -330,6 +334,362 @@
font-size: 1.4rem
margin: 0
.playlist-section
background: rgba(20, 20, 30, 0.6)
backdrop-filter: blur(10px)
border-radius: 15px
padding: 25px
animation: float-up 0.8s ease-out
border: 1px solid rgba(255, 255, 255, 0.1)
transition: all 0.3s ease
&:hover
border-color: rgba(255, 255, 255, 0.2)
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3)
.section-header
background: rgba(30, 30, 40, 0.95)
padding: 20px
border-radius: 12px
margin: -25px -25px 25px -25px
border-bottom: 1px solid rgba(255, 255, 255, 0.1)
text-align: center
h2
margin: 0
font-size: 24px
color: $white
display: flex
align-items: center
justify-content: center
gap: 10px
&:before, &:after
content: ""
height: 1px
width: 50px
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent)
.vote-info
color: rgba(255, 255, 255, 0.6)
font-size: 14px
margin-top: 5px
.playlists-grid
display: grid
grid-template-columns: repeat(3, 1fr)
gap: 20px
padding: 5px
@media (max-width: 1200px)
grid-template-columns: repeat(2, 1fr)
@media (max-width: 768px)
grid-template-columns: 1fr
.playlist-card
background: rgba(255, 255, 255, 0.05)
border-radius: 12px
overflow: hidden
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)
cursor: pointer
border: 1px solid rgba(255, 255, 255, 0.1)
&:hover
transform: translateY(-5px)
background: rgba(255, 255, 255, 0.08)
border-color: rgba(255, 255, 255, 0.2)
.playlist-overlay
opacity: 1
&.selected
background: rgba($yellow, 0.1)
border-color: rgba($yellow, 0.3)
box-shadow: 0 0 30px rgba($yellow, 0.15)
.playlist-overlay
opacity: 1
background: rgba(0, 0, 0, 0.4)
.your-vote
transform: translateY(0)
opacity: 1
.playlist-info h3
color: $yellow
.playlist-thumbnail
position: relative
width: 100%
padding-top: 56.25%
img
position: absolute
top: 0
left: 0
width: 100%
height: 100%
object-fit: cover
.playlist-overlay
position: absolute
inset: 0
background: rgba(0, 0, 0, 0.3)
opacity: 0
transition: all 0.3s ease
display: flex
flex-direction: column
justify-content: center
align-items: center
.vote-count
background: rgba(20, 20, 30, 0.85)
padding: 12px 20px
border-radius: 15px
text-align: center
border: 1px solid rgba(255, 255, 255, 0.2)
backdrop-filter: blur(5px)
.count
font-size: 28px
font-weight: bold
color: $yellow
display: block
text-shadow: 0 0 10px rgba($yellow, 0.5)
.vote-label
font-size: 12px
color: rgba(255, 255, 255, 0.7)
.your-vote
position: absolute
bottom: 15px
background: $yellow
color: rgba(0, 0, 0, 0.8)
padding: 8px 16px
border-radius: 20px
font-weight: bold
font-size: 12px
transform: translateY(20px)
opacity: 0
transition: all 0.3s ease
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3)
.playlist-info
padding: 15px
background: rgba(20, 20, 30, 0.4)
border-top: 1px solid rgba(255, 255, 255, 0.05)
h3
color: $white
margin-bottom: 8px
font-size: 18px
transition: color 0.3s ease
.playlist-details
display: flex
flex-direction: column
gap: 8px
span
color: rgba(255, 255, 255, 0.6)
font-size: 13px
.vote-percentage-bar
height: 4px
background: rgba(255, 255, 255, 0.1)
border-radius: 2px
overflow: hidden
.fill
height: 100%
background: linear-gradient(90deg, $yellow, rgba($yellow, 0.5))
box-shadow: 0 0 10px rgba($yellow, 0.3)
transition: width 0.3s ease
.bottom-section
display: flex
gap: 30px
width: 100%
min-height: 0
height: 300px
margin-top: 20px
.users-panel
height: 100%
display: flex
flex-direction: column
background: rgba(20, 20, 30, 0.6)
border-radius: 15px
border: 1px solid rgba(255, 255, 255, 0.1)
overflow: hidden
transition: all 0.3s ease
&:hover
border-color: rgba(255, 255, 255, 0.2)
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3)
.panel-header
background: rgba(30, 30, 40, 0.95)
padding: 20px
border-bottom: 1px solid rgba(255, 255, 255, 0.1)
h2
font-size: 20px
display: flex
align-items: center
gap: 10px
svg
color: $yellow
filter: drop-shadow(0 0 8px rgba($yellow, 0.4))
.users-list
padding: 15px
flex: 1
overflow-y: auto
min-height: 0
.user-item
background: rgba(255, 255, 255, 0.05)
margin-bottom: 10px
padding: 12px 15px
border-radius: 10px
transition: all 0.3s ease
border: 1px solid rgba(255, 255, 255, 0.05)
&:hover
transform: translateY(-2px)
background: rgba(255, 255, 255, 0.08)
border-color: rgba(255, 255, 255, 0.1)
&.host
background: rgba($yellow, 0.1)
border-color: rgba($yellow, 0.3)
.host-badge
background: $yellow
color: #000
font-size: 12px
padding: 4px 8px
border-radius: 20px
font-weight: bold
.game-controls
padding: 15px
background: rgba(20, 20, 30, 0.4)
border-top: 1px solid rgba(255, 255, 255, 0.05)
.start-game-button
width: 100%
padding: 12px
background: linear-gradient(135deg, $purple, $blue)
border-radius: 10px
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)
&:not(.disabled):hover
transform: translateY(-2px)
filter: brightness(1.1)
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.4)
.chat-panel
flex: 1
height: 100%
background: rgba(20, 20, 30, 0.6)
border-radius: 15px
border: 1px solid rgba(255, 255, 255, 0.1)
display: flex
flex-direction: column
overflow: hidden
transition: all 0.3s ease
&:hover
border-color: rgba(255, 255, 255, 0.2)
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3)
.panel-header
background: rgba(30, 30, 40, 0.95)
padding: 20px
border-bottom: 1px solid rgba(255, 255, 255, 0.1)
h2
font-size: 20px
display: flex
align-items: center
gap: 10px
svg
color: $blue
filter: drop-shadow(0 0 8px rgba($blue, 0.4))
.chat-messages
flex: 1
padding: 20px
overflow-y: auto
display: flex
flex-direction: column
gap: 10px
min-height: 0
.message
max-width: 85%
padding: 10px 15px
border-radius: 12px
background: rgba(255, 255, 255, 0.05)
border: 1px solid rgba(255, 255, 255, 0.05)
transition: all 0.3s ease
&:hover
background: rgba(255, 255, 255, 0.08)
border-color: rgba(255, 255, 255, 0.1)
transform: translateY(-1px)
.message-sender
color: $yellow
font-weight: 600
margin-right: 8px
&.system-message
align-self: center
background: rgba(0, 0, 0, 0.2)
font-style: italic
color: rgba(255, 255, 255, 0.6)
padding: 8px 16px
border-radius: 20px
.chat-input
padding: 15px
background: rgba(20, 20, 30, 0.4)
border-top: 1px solid rgba(255, 255, 255, 0.05)
display: flex
gap: 10px
input
flex: 1
background: rgba(0, 0, 0, 0.2)
border: 1px solid rgba(255, 255, 255, 0.1)
border-radius: 8px
padding: 12px 15px
color: $white
transition: all 0.3s ease
&:focus
border-color: rgba($blue, 0.5)
box-shadow: 0 0 15px rgba($blue, 0.1)
background: rgba(0, 0, 0, 0.3)
button
padding: 12px 20px
background: linear-gradient(135deg, $purple, $blue)
border-radius: 8px
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)
&:hover
transform: translateY(-2px)
filter: brightness(1.1)
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3)
@keyframes pop
0%
transform: scale(1)

View File

@ -0,0 +1,112 @@
let cachedSongs = null;
let fetchPromise = null;
export const fetchPlaylistSongs = async (socketInstance) => {
if (cachedSongs && cachedSongs.length > 0) {
console.log("Returning cached songs:", cachedSongs.length);
return cachedSongs;
}
if (fetchPromise) {
console.log("Request already in progress, waiting for response...");
return fetchPromise;
}
console.log("Starting new fetch request for playlist songs");
fetchPromise = new Promise(async (resolve, reject) => {
try {
let socket = socketInstance;
if (!socket) {
try {
const socketModule = await import('@/common/contexts/SocketContext');
socket = socketModule.socket;
} catch (error) {
console.error("Failed to import socket:", error);
}
}
if (!socket) {
throw new Error("Socket not available");
}
if (!socket.connected) {
console.warn("Socket not connected - attempting to connect");
socket.connect();
await new Promise(resolve => setTimeout(resolve, 500));
if (!socket.connected) {
throw new Error("Could not connect to server");
}
}
console.log("Socket connected, requesting songs...");
const responsePromise = new Promise((resolve, reject) => {
const handleResponse = (data) => {
console.log("Received playlist-songs response");
if (data.error) {
console.error("Server returned error:", data.error);
reject(new Error(data.error));
return;
}
if (data && data.songs && Array.isArray(data.songs) && data.songs.length > 0) {
cachedSongs = data.songs;
resolve(data.songs);
} else {
console.error("Invalid song data in response", data);
reject(new Error("Invalid song data"));
}
};
socket.once("playlist-songs", handleResponse);
setTimeout(() => {
socket.off("playlist-songs", handleResponse);
reject(new Error("Request timed out"));
}, 10000);
});
socket.emit("get-playlist-songs");
const songs = await responsePromise;
fetchPromise = null;
resolve(songs);
} catch (error) {
console.error("Error fetching playlist songs:", error);
fetchPromise = null;
reject(error);
}
});
return fetchPromise.catch(error => {
console.error("Failed to fetch playlist songs:", error);
return [{
id: 1,
title: "Connection Error",
artist: "Please refresh the page",
coverUrl: "https://place-hold.it/500x500/f00/fff?text=Error",
youtubeId: "dQw4w9WgXcQ"
}];
});
};
/**
* Gets a single song by ID
*/
export const getSongById = async (id, socketInstance) => {
try {
const songs = await fetchPlaylistSongs(socketInstance);
return songs.find(song => song.id === id) || null;
} catch (error) {
console.error("Error getting song by ID:", error);
return null;
}
};

View File

@ -13,6 +13,7 @@
},
"dependencies": {
"express": "^4.21.2",
"googleapis": "^146.0.0",
"socket.io": "^4.8.1"
},
"devDependencies": {

209
pnpm-lock.yaml generated
View File

@ -11,6 +11,9 @@ importers:
express:
specifier: ^4.21.2
version: 4.21.2
googleapis:
specifier: ^146.0.0
version: 146.0.0
socket.io:
specifier: ^4.8.1
version: 4.8.1
@ -37,6 +40,10 @@ packages:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
engines: {node: '>= 0.6'}
agent-base@7.1.3:
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
engines: {node: '>= 14'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@ -55,10 +62,16 @@ packages:
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
base64id@2.0.0:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
bignumber.js@9.1.2:
resolution: {integrity: sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
engines: {node: '>=8'}
@ -74,6 +87,9 @@ packages:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
buffer-equal-constant-time@1.0.1:
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
bytes@3.1.2:
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
engines: {node: '>= 0.8'}
@ -165,6 +181,9 @@ packages:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
ecdsa-sig-formatter@1.0.11:
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
ee-first@1.1.1:
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
@ -214,6 +233,9 @@ packages:
resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==}
engines: {node: '>= 0.10.0'}
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
@ -238,6 +260,14 @@ packages:
function-bind@1.1.2:
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
gaxios@6.7.1:
resolution: {integrity: sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ==}
engines: {node: '>=14'}
gcp-metadata@6.1.1:
resolution: {integrity: sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==}
engines: {node: '>=14'}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
@ -254,10 +284,30 @@ packages:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
google-auth-library@9.15.1:
resolution: {integrity: sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng==}
engines: {node: '>=14'}
google-logging-utils@0.0.2:
resolution: {integrity: sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==}
engines: {node: '>=14'}
googleapis-common@7.2.0:
resolution: {integrity: sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==}
engines: {node: '>=14.0.0'}
googleapis@146.0.0:
resolution: {integrity: sha512-NewqvhnBZOJsugCAOo636O0BGE/xY7Cg/v8Rjm1+5LkJCjcqAzLleJ6igd5vrRExJLSKrY9uHy9iKE7r0PrfhQ==}
engines: {node: '>=14.0.0'}
gopd@1.2.0:
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
engines: {node: '>= 0.4'}
gtoken@7.1.0:
resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
engines: {node: '>=14.0.0'}
has-flag@3.0.0:
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
engines: {node: '>=4'}
@ -278,6 +328,10 @@ packages:
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
engines: {node: '>= 0.8'}
https-proxy-agent@7.0.6:
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
engines: {node: '>= 14'}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@ -312,6 +366,19 @@ packages:
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
engines: {node: '>=0.12.0'}
is-stream@2.0.1:
resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
engines: {node: '>=8'}
json-bigint@1.0.0:
resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==}
jwa@2.0.0:
resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==}
jws@4.0.0:
resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==}
lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
@ -356,6 +423,15 @@ packages:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
peerDependencies:
encoding: ^0.1.0
peerDependenciesMeta:
encoding:
optional: true
nodemon@3.1.9:
resolution: {integrity: sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==}
engines: {node: '>=10'}
@ -511,6 +587,9 @@ packages:
resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==}
hasBin: true
tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
tree-kill@1.2.2:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
@ -532,14 +611,27 @@ packages:
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
engines: {node: '>= 0.8'}
url-template@2.0.8:
resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==}
utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
hasBin: true
vary@1.1.2:
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
engines: {node: '>= 0.8'}
webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@ -585,6 +677,8 @@ snapshots:
mime-types: 2.1.35
negotiator: 0.6.3
agent-base@7.1.3: {}
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
@ -600,8 +694,12 @@ snapshots:
balanced-match@1.0.2: {}
base64-js@1.5.1: {}
base64id@2.0.0: {}
bignumber.js@9.1.2: {}
binary-extensions@2.3.0: {}
body-parser@1.20.3:
@ -630,6 +728,8 @@ snapshots:
dependencies:
fill-range: 7.1.1
buffer-equal-constant-time@1.0.1: {}
bytes@3.1.2: {}
call-bind-apply-helpers@1.0.2:
@ -720,6 +820,10 @@ snapshots:
es-errors: 1.3.0
gopd: 1.2.0
ecdsa-sig-formatter@1.0.11:
dependencies:
safe-buffer: 5.2.1
ee-first@1.1.1: {}
emoji-regex@8.0.0: {}
@ -796,6 +900,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
extend@3.0.2: {}
fill-range@7.1.1:
dependencies:
to-regex-range: 5.0.1
@ -821,6 +927,26 @@ snapshots:
function-bind@1.1.2: {}
gaxios@6.7.1:
dependencies:
extend: 3.0.2
https-proxy-agent: 7.0.6
is-stream: 2.0.1
node-fetch: 2.7.0
uuid: 9.0.1
transitivePeerDependencies:
- encoding
- supports-color
gcp-metadata@6.1.1:
dependencies:
gaxios: 6.7.1
google-logging-utils: 0.0.2
json-bigint: 1.0.0
transitivePeerDependencies:
- encoding
- supports-color
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
@ -845,8 +971,50 @@ snapshots:
dependencies:
is-glob: 4.0.3
google-auth-library@9.15.1:
dependencies:
base64-js: 1.5.1
ecdsa-sig-formatter: 1.0.11
gaxios: 6.7.1
gcp-metadata: 6.1.1
gtoken: 7.1.0
jws: 4.0.0
transitivePeerDependencies:
- encoding
- supports-color
google-logging-utils@0.0.2: {}
googleapis-common@7.2.0:
dependencies:
extend: 3.0.2
gaxios: 6.7.1
google-auth-library: 9.15.1
qs: 6.13.0
url-template: 2.0.8
uuid: 9.0.1
transitivePeerDependencies:
- encoding
- supports-color
googleapis@146.0.0:
dependencies:
google-auth-library: 9.15.1
googleapis-common: 7.2.0
transitivePeerDependencies:
- encoding
- supports-color
gopd@1.2.0: {}
gtoken@7.1.0:
dependencies:
gaxios: 6.7.1
jws: 4.0.0
transitivePeerDependencies:
- encoding
- supports-color
has-flag@3.0.0: {}
has-flag@4.0.0: {}
@ -865,6 +1033,13 @@ snapshots:
statuses: 2.0.1
toidentifier: 1.0.1
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.3
debug: 4.3.7(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
@ -889,6 +1064,23 @@ snapshots:
is-number@7.0.0: {}
is-stream@2.0.1: {}
json-bigint@1.0.0:
dependencies:
bignumber.js: 9.1.2
jwa@2.0.0:
dependencies:
buffer-equal-constant-time: 1.0.1
ecdsa-sig-formatter: 1.0.11
safe-buffer: 5.2.1
jws@4.0.0:
dependencies:
jwa: 2.0.0
safe-buffer: 5.2.1
lodash@4.17.21: {}
math-intrinsics@1.1.0: {}
@ -917,6 +1109,10 @@ snapshots:
negotiator@0.6.3: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
nodemon@3.1.9:
dependencies:
chokidar: 3.6.0
@ -1107,6 +1303,8 @@ snapshots:
touch@3.1.1: {}
tr46@0.0.3: {}
tree-kill@1.2.2: {}
tslib@2.8.1: {}
@ -1122,10 +1320,21 @@ snapshots:
unpipe@1.0.0: {}
url-template@2.0.8: {}
utils-merge@1.0.1: {}
uuid@9.0.1: {}
vary@1.1.2: {}
webidl-conversions@3.0.1: {}
whatwg-url@5.0.0:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0

View File

@ -1,23 +1,22 @@
const roomController = require('./room');
const youtubeService = require('../services/youtubeService');
const SONGS = [
{
id: 1,
title: "Black Steam",
artist: "Carrot Quest GmbH",
coverUrl: "https://mir-s3-cdn-cf.behance.net/project_modules/1400/fe529a64193929.5aca8500ba9ab.jpg"
},
{id: 2, title: "Sunset Dreams", artist: "Ocean Waves", coverUrl: "https://place-hold.it/500x500/"},
{id: 3, title: "Neon Nights", artist: "Electric Avenue", coverUrl: "https://place-hold.it/500x500/"},
{id: 4, title: "Mountain Echo", artist: "Wild Terrain", coverUrl: "https://place-hold.it/500x500/"},
{id: 5, title: "Urban Jungle", artist: "City Dwellers", coverUrl: "https://place-hold.it/500x500/"},
{id: 6, title: "Cosmic Journey", artist: "Stargazers", coverUrl: "https://place-hold.it/500x500/"}
];
const shuffleArray = (array) => {
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
};
const gameStates = {};
const initializeGameState = (roomId) => {
if (!gameStates[roomId]) {
const selectedPlaylist = roomController.getWinningPlaylist(roomId);
console.log(`Initializing game with winning playlist: ${selectedPlaylist}`);
gameStates[roomId] = {
round: 0,
phase: 'waiting',
@ -29,10 +28,11 @@ const initializeGameState = (roomId) => {
currentComposer: null,
roundStartTime: null,
phaseTimeLimit: {
composing: 30,
guessing: 30
composing: 60,
guessing: 10
},
lastFrequency: 440,
selectedPlaylist: selectedPlaylist,
};
const users = roomController.getRoomUsers(roomId);
@ -44,7 +44,9 @@ const initializeGameState = (roomId) => {
return gameStates[roomId];
};
const startNewRound = (roomId) => {
const MAX_ROUNDS = 5;
const startNewRound = async (roomId) => {
const gameState = gameStates[roomId];
if (!gameState) return false;
@ -52,6 +54,11 @@ const startNewRound = (roomId) => {
if (users.length < 2) return false;
gameState.round += 1;
if (gameState.round > MAX_ROUNDS) {
return { gameEnd: true, finalScores: gameState.scores };
}
gameState.phase = 'composing';
gameState.guessResults = {};
gameState.roundStartTime = Date.now();
@ -59,12 +66,13 @@ const startNewRound = (roomId) => {
gameState.currentComposer = determineNextComposer(roomId, gameState, users);
const success = await selectSongAndOptions(gameState);
if (!success) return false;
users.forEach(user => {
gameState.roles[user.id] = user.id === gameState.currentComposer ? 'composer' : 'guesser';
});
selectSongAndOptions(gameState);
return true;
};
@ -82,16 +90,36 @@ const determineNextComposer = (roomId, gameState, users) => {
return users[(currentIndex + 1) % users.length].id;
};
const selectSongAndOptions = (gameState) => {
gameState.selectedSong = SONGS[Math.floor(Math.random() * SONGS.length)];
const selectSongAndOptions = async (gameState) => {
try {
console.log(`Fetching songs from playlist: ${gameState.selectedPlaylist}`);
const availableIds = await youtubeService.getAvailableSongIds(gameState.selectedPlaylist);
const songOptions = [...SONGS].sort(() => Math.random() - 0.5).slice(0, 5);
if (!songOptions.includes(gameState.selectedSong)) {
songOptions[Math.floor(Math.random() * songOptions.length)] = gameState.selectedSong;
if (!availableIds || availableIds.length === 0) {
console.error("No song IDs available for selection");
return false;
}
gameState.songOptions = songOptions;
const selectedId = availableIds[Math.floor(Math.random() * availableIds.length)];
gameState.selectedSong = { id: selectedId };
const optionIds = new Set([selectedId]);
const attempts = Math.min(20, availableIds.length);
let count = 0;
while (optionIds.size < 5 && count < attempts) {
const randomId = availableIds[Math.floor(Math.random() * availableIds.length)];
optionIds.add(randomId);
count++;
}
gameState.songOptions = shuffleArray(Array.from(optionIds).map(id => ({ id })));
return true;
} catch (error) {
console.error("Error selecting songs and options:", error);
return false;
}
};
const getTimeRemaining = (roomId) => {
@ -172,11 +200,17 @@ const getRoundResults = (roomId) => {
const gameState = gameStates[roomId];
if (!gameState) return {round: 0, scores: {}, guessResults: {}, selectedSong: null};
const playerNames = {};
Object.keys(gameState.scores).forEach(userId => {
playerNames[userId] = roomController.getUserName(userId) || "Player";
});
return {
round: gameState.round,
selectedSong: gameState.selectedSong,
guessResults: gameState.guessResults,
scores: gameState.scores
scores: gameState.scores,
playerNames: playerNames
};
};
@ -190,6 +224,27 @@ const getGameState = (roomId) => {
return gameStates[roomId] || null;
};
const getFinalScores = (roomId) => {
const gameState = gameStates[roomId];
if (!gameState) return null;
const users = roomController.getRoomUsers(roomId);
const finalScores = {};
Object.entries(gameState.scores).forEach(([userId, score]) => {
const user = users.find(u => u.id === userId);
finalScores[userId] = {
score: score,
name: user?.name || "Player"
};
});
return {
scores: finalScores,
lastRound: gameState.round
};
};
module.exports = {
initializeGameState,
startNewRound,
@ -205,5 +260,7 @@ module.exports = {
getSelectedSong,
cleanupGameState,
getCurrentComposer,
getGameState
getGameState,
getFinalScores,
MAX_ROUNDS
};

View File

@ -1,3 +1,5 @@
const youtubeService = require('../services/youtubeService');
let cleanupGameState;
const setCleanupGameState = (cleanupFunction) => {
@ -10,19 +12,41 @@ module.exports.roomExists = (roomId) => rooms[roomId] !== undefined;
module.exports.isRoomOpen = (roomId) => rooms[roomId] && rooms[roomId].state === 'waiting';
module.exports.connectUserToRoom = (roomId, user) => {
const initializeRoom = async (roomId, user) => {
try {
const randomPlaylists = await youtubeService.getRandomPlaylists(3);
rooms[roomId] = {
members: [{...user, creator: true}],
settings: {},
state: 'waiting',
playlistVotes: {},
selectedPlaylist: null,
availablePlaylists: randomPlaylists
};
} catch (error) {
console.error("Error initializing room playlists:", error);
rooms[roomId] = {
members: [{...user, creator: true}],
settings: {},
state: 'waiting',
playlistVotes: {},
selectedPlaylist: null,
availablePlaylists: {
seventies: youtubeService.PLAYLISTS.seventies
}
};
}
};
module.exports.connectUserToRoom = async (roomId, user) => {
roomId = roomId.toUpperCase();
if (rooms[roomId]) {
rooms[roomId].members.push({...user, creator: false});
} else {
rooms[roomId] = {
members: [{...user, creator: true}],
settings: {},
state: 'waiting'
};
await initializeRoom(roomId, user);
}
console.log(`User ${user.name} connected to room ${roomId}`);
}
};
module.exports.getUserRoom = (userId) => {
for (const roomId in rooms) {
@ -82,7 +106,6 @@ module.exports.disconnectUser = (userId) => {
if (memberIndex !== -1) {
if (room.members[memberIndex].creator && room.members.length > 1) {
// Transfer host status to the next user
room.members[1].creator = true;
}
@ -133,3 +156,73 @@ module.exports.getUserName = (userId) => {
}
return null;
};
module.exports.voteForPlaylist = (roomId, userId, playlistId) => {
if (!rooms[roomId]) return false;
const room = rooms[roomId];
if (room.state !== 'waiting') return false;
if (!room.playlistVotes) {
room.playlistVotes = {};
}
Object.entries(room.playlistVotes).forEach(([pid, voters]) => {
room.playlistVotes[pid] = voters.filter(id => id !== userId);
});
if (!room.playlistVotes[playlistId]) {
room.playlistVotes[playlistId] = [];
}
room.playlistVotes[playlistId].push(userId);
console.log(`Updated votes for room ${roomId}:`, room.playlistVotes);
return true;
};
module.exports.getPlaylistVotes = (roomId) => {
return rooms[roomId]?.playlistVotes || {};
};
module.exports.getWinningPlaylist = (roomId) => {
const room = rooms[roomId];
if (!room || !room.playlistVotes) {
console.log(`No votes found for room ${roomId}, using default playlist`);
return room.availablePlaylists[Object.keys(room.availablePlaylists)[0]];
}
let maxVotes = 0;
let winningPlaylist = null;
console.log(`Calculating winning playlist for room ${roomId}`);
console.log('Current votes:', room.playlistVotes);
Object.entries(room.playlistVotes).forEach(([playlistId, voters]) => {
console.log(`Playlist ${playlistId} has ${voters.length} votes`);
if (voters.length > maxVotes) {
maxVotes = voters.length;
winningPlaylist = playlistId;
}
});
if (!winningPlaylist) {
console.log('No winning playlist found, using first available');
winningPlaylist = room.availablePlaylists[Object.keys(room.availablePlaylists)[0]];
}
console.log(`Selected winning playlist: ${winningPlaylist}`);
return winningPlaylist;
};
module.exports.getAvailablePlaylists = (roomId) => {
return rooms[roomId]?.availablePlaylists || {};
};
module.exports.updateAvailablePlaylists = (roomId, newPlaylists) => {
if (rooms[roomId]) {
rooms[roomId].availablePlaylists = newPlaylists;
rooms[roomId].playlistVotes = {};
return true;
}
return false;
};

View File

@ -1,5 +1,6 @@
const roomController = require("../controller/room");
const gameController = require("../controller/game");
const youtubeService = require('../services/youtubeService');
module.exports = (io) => (socket) => {
let currentRoomId = null;
@ -54,18 +55,22 @@ module.exports = (io) => (socket) => {
const roles = gameController.getRoles(roomId);
const selectedSong = gameController.getSelectedSong(roomId);
const timeLeft = gameController.getTimeRemaining(roomId);
const songOptions = gameController.getSongOptions(roomId);
io.to(roomId).emit('roles-assigned', roles);
Object.entries(roles).forEach(([userId, role]) => {
if (role === 'composer') {
io.to(userId).emit('song-selected', selectedSong);
} else {
io.to(userId).emit('song-options', { songOptions });
}
});
io.to(roomId).emit('round-started', {
round: gameController.getRoundResults(roomId).round,
timeRemaining: timeLeft
timeRemaining: timeLeft,
phase: 'composing'
});
startPhaseTimer(roomId);
@ -83,7 +88,8 @@ module.exports = (io) => (socket) => {
io.to(roomId).emit('phase-changed', {
phase: 'guessing',
timeRemaining: timeLeft
timeRemaining: timeLeft,
message: 'Time to submit your final answer!'
});
Object.entries(roles).forEach(([userId, role]) => {
@ -103,6 +109,23 @@ module.exports = (io) => (socket) => {
io.to(roomId).emit('round-results', results);
};
const handleNextRound = async (roomId) => {
try {
const result = await gameController.startNewRound(roomId);
if (result.gameEnd) {
const finalData = gameController.getFinalScores(roomId);
io.to(roomId).emit('game-ended', finalData);
return;
}
handleRoundStart(roomId);
} catch (error) {
console.error("Error starting next round:", error);
socket.emit("error", { message: "Failed to start next round due to an error" });
}
};
socket.on("disconnect", () => {
const roomId = roomController.getUserRoom(socket.id);
if (roomId) {
@ -111,7 +134,7 @@ module.exports = (io) => (socket) => {
}
});
socket.on("join-room", ({roomId, name}) => {
socket.on("join-room", async ({roomId, name}) => {
if (currentRoomId) return socket.emit("already-in-room", currentRoomId);
roomId = roomId.toString().toUpperCase();
@ -122,14 +145,35 @@ module.exports = (io) => (socket) => {
}
currentUser = {id: socket.id, name: name.toString()};
roomController.connectUserToRoom(roomId, currentUser);
await roomController.connectUserToRoom(roomId, currentUser);
socket.join(roomId);
const users = roomController.getRoomUsers(roomId);
io.to(roomId).emit("room-users-update", users);
socket.to(roomId).emit("user-connected", currentUser);
const votes = roomController.getPlaylistVotes(roomId);
const availablePlaylists = roomController.getAvailablePlaylists(roomId);
socket.emit("room-joined", roomId);
socket.emit("room-users", users);
socket.emit("playlist-votes-updated", votes);
try {
const details = await youtubeService.getPlaylistDetails(availablePlaylists);
Object.keys(details).forEach(genre => {
const playlistId = details[genre].id;
details[genre].votes = votes[playlistId]?.length || 0;
});
socket.emit("playlist-options", details);
} catch (error) {
console.error("Error sending playlist details to new user:", error);
socket.emit("error", {
message: "Failed to load playlists",
details: error.message
});
}
socket.to(roomId).emit("user-connected", currentUser);
io.to(roomId).emit("room-users-update", users);
currentRoomId = roomId;
} else {
socket.emit("room-not-found", roomId);
@ -147,7 +191,7 @@ module.exports = (io) => (socket) => {
currentRoomId = roomId;
});
socket.on("start-game", () => {
socket.on("start-game", async () => {
const roomId = roomController.getUserRoom(socket.id);
if (!roomId || !roomController.isUserHost(socket.id)) {
return socket.emit("not-authorized");
@ -162,15 +206,42 @@ module.exports = (io) => (socket) => {
if (!roomController.startGame(roomId)) return;
gameController.initializeGameState(roomId);
if (!gameController.startNewRound(roomId)) return;
try {
const success = await gameController.startNewRound(roomId);
if (!success) {
return socket.emit("error", { message: "Failed to start game - could not load songs" });
}
io.to(roomId).emit("game-started");
handleRoundStart(roomId);
} catch (error) {
console.error("Error starting game:", error);
socket.emit("error", { message: "Failed to start game due to an error" });
}
});
socket.on("send-message", (messageData) => {
const roomId = roomController.getUserRoom(socket.id);
if (roomId) socket.to(roomId).emit("chat-message", messageData);
if (!roomId) return;
const serverUsername = roomController.getUserName(socket.id);
if (!messageData.sender || messageData.sender === "Player" || messageData.sender === "Anonymous") {
if (serverUsername) {
console.log(`Fixing missing username for ${socket.id}: using "${serverUsername}" instead of "${messageData.sender || 'none'}"`);
messageData.sender = serverUsername;
} else {
console.warn(`Could not find username for user ${socket.id}`);
}
}
socket.to(roomId).emit("chat-message", messageData);
socket.emit("chat-message-confirmation", {
...messageData,
sender: messageData.sender
});
});
socket.on("get-user-info", () => {
@ -193,7 +264,7 @@ module.exports = (io) => (socket) => {
if (currentRoomId) socket.emit("room-code", currentRoomId);
});
socket.on("submit-frequency", ({ frequency }) => {
socket.on("submit-frequency", ({ frequency, isPlaying }) => {
const roomId = roomController.getUserRoom(socket.id);
if (!roomId) return;
@ -201,7 +272,7 @@ module.exports = (io) => (socket) => {
if (userRole !== 'composer') return;
if (gameController.updateFrequency(roomId, frequency)) {
socket.to(roomId).emit("frequency-update", { frequency });
socket.to(roomId).emit("frequency-update", { frequency, isPlaying });
}
});
@ -233,7 +304,7 @@ module.exports = (io) => (socket) => {
}
});
socket.on("next-round", () => {
socket.on("next-round", async () => {
const roomId = roomController.getUserRoom(socket.id);
if (!roomId || !roomController.isUserHost(socket.id)) return;
@ -244,9 +315,7 @@ module.exports = (io) => (socket) => {
return socket.emit("error", { message: "At least 2 players are required" });
}
if (gameController.startNewRound(roomId)) {
handleRoundStart(roomId);
}
await handleNextRound(roomId);
});
socket.on("get-current-frequency", () => {
@ -257,4 +326,61 @@ module.exports = (io) => (socket) => {
});
}
});
socket.on("get-playlist-songs", async () => {
try {
const roomId = roomController.getUserRoom(socket.id);
if (!roomId) {
throw new Error("User not in a room");
}
const gameState = gameController.getGameState(roomId);
const playlistId = gameState?.selectedPlaylist || null;
console.log(`Fetching songs for playlist ${playlistId}`);
const songs = await youtubeService.fetchPlaylistSongs(playlistId);
socket.emit("playlist-songs", { songs });
} catch (error) {
console.error("Error sending playlist songs:", error);
socket.emit("playlist-songs", { songs: [] });
}
});
socket.on("get-playlist-options", async () => {
try {
const roomId = roomController.getUserRoom(socket.id);
if (!roomId) {
throw new Error("User not in a room");
}
const availablePlaylists = roomController.getAvailablePlaylists(roomId);
const details = await youtubeService.getPlaylistDetails(availablePlaylists);
if (Object.keys(details).length === 0) {
const newPlaylists = await youtubeService.getRandomPlaylists(3);
const newDetails = await youtubeService.getPlaylistDetails(newPlaylists);
roomController.updateAvailablePlaylists(roomId, newPlaylists);
socket.emit("playlist-options", newDetails);
} else {
socket.emit("playlist-options", details);
}
} catch (error) {
console.error("Error fetching playlist options:", error);
socket.emit("error", {
message: "Failed to load playlists",
details: error.message
});
}
});
socket.on("vote-playlist", ({ playlistId }) => {
const roomId = roomController.getUserRoom(socket.id);
if (!roomId) return;
if (roomController.voteForPlaylist(roomId, socket.id, playlistId)) {
const votes = roomController.getPlaylistVotes(roomId);
io.to(roomId).emit("playlist-votes-updated", votes);
}
});
};

View File

@ -21,8 +21,14 @@ const roomController = require('./controller/room');
const gameController = require('./controller/game');
roomController.setCleanupGameState(gameController.cleanupGameState);
// Handle socket connections
io.on("connection", require("./handler/connection")(io));
io.on("connection", (socket) => {
try {
require("./handler/connection")(io)(socket);
} catch (error) {
console.error('Error handling socket connection:', error);
socket.emit('error', { message: 'Internal server error' });
}
});
server.on('error', (error) => {
console.error('Server error:', error);

View File

@ -0,0 +1,227 @@
const { google } = require('googleapis');
const youtube = google.youtube('v3');
const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY;
const PLAYLISTS = {
seventies: 'PLmXxqSJJq-yXrCPGIT2gn8b34JjOrl4Xf',
eighties: 'PLmXxqSJJq-yUvMWKuZQAB_8yxnjZaOZUp',
nineties: 'PLmXxqSJJq-yUF3jbzjF_pa--kuBuMlyQQ',
pop: 'PLxA687tYuMWhkqYjvAGtW_heiEL4Hk_Lx',
marco: 'PLSTnYsLCH0WKpUvzrytqfvlhDTlqK_zj-'
};
let validatedPlaylists = {};
const VALIDATION_TTL = 3600000; // 1 hour
const validatePlaylist = async (playlistId) => {
try {
const response = await youtube.playlists.list({
key: API_KEY,
part: 'snippet,contentDetails',
id: playlistId
});
if (!response.data.items || response.data.items.length === 0) {
console.log(`Playlist ${playlistId} not found or empty`);
return false;
}
validatedPlaylists[playlistId] = {
timestamp: Date.now(),
valid: true
};
return true;
} catch (error) {
console.error(`Failed to validate playlist ${playlistId}:`, error);
return false;
}
};
const validateAndCleanPlaylists = async () => {
const now = Date.now();
const validPlaylistIds = [];
for (const [genre, playlistId] of Object.entries(PLAYLISTS)) {
if (validatedPlaylists[playlistId] &&
(now - validatedPlaylists[playlistId].timestamp) < VALIDATION_TTL) {
if (validatedPlaylists[playlistId].valid) {
validPlaylistIds.push([genre, playlistId]);
}
continue;
}
const isValid = await validatePlaylist(playlistId);
if (isValid) {
validPlaylistIds.push([genre, playlistId]);
} else {
console.log(`Removing invalid playlist: ${genre} (${playlistId})`);
delete PLAYLISTS[genre];
}
}
return validPlaylistIds;
};
const API_KEY = process.env.YOUTUBE_API_KEY;
const cachedSongsByPlaylist = {};
const CACHE_TTL = 3600000; // 1 hour
/**
* Fetches songs from YouTube playlist and returns them
*/
const fetchPlaylistSongs = async (playlistId = null) => {
if (!playlistId) {
console.warn("No playlist ID provided, using default");
playlistId = PLAYLISTS.eighties;
}
const now = Date.now();
if (cachedSongsByPlaylist[playlistId] &&
cachedSongsByPlaylist[playlistId].songs.length > 0 &&
(now - cachedSongsByPlaylist[playlistId].timestamp) < CACHE_TTL) {
console.log(`Using cached songs for playlist ${playlistId}`);
return cachedSongsByPlaylist[playlistId].songs;
}
try {
console.log(`Fetching fresh songs from YouTube API for playlist ${playlistId}...`);
const playlistUrl = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails&maxResults=50&playlistId=${playlistId}&key=${YOUTUBE_API_KEY}`;
const response = await fetch(playlistUrl);
const data = await response.json();
if (data.error) {
console.error("YouTube API error:", data.error);
throw new Error(data.error.message);
}
if (!data.items || !data.items.length) {
throw new Error("No songs found in the playlist");
}
const songs = data.items.map((item, index) => ({
id: index + 1,
youtubeId: item.contentDetails.videoId,
title: item.snippet.title,
artist: item.snippet.videoOwnerChannelTitle || "Unknown Artist",
coverUrl: item.snippet.thumbnails.high?.url || item.snippet.thumbnails.default?.url,
playlistId: playlistId
}));
cachedSongsByPlaylist[playlistId] = {
songs,
timestamp: now
};
return songs;
} catch (error) {
console.error(`Error fetching YouTube playlist ${playlistId}:`, error);
return cachedSongsByPlaylist[playlistId]?.songs || getDefaultSongs();
}
};
const getAvailableSongIds = async (playlistId) => {
const songs = await fetchPlaylistSongs(playlistId);
return songs.map(song => song.id);
};
async function getPlaylistDetails(availablePlaylists = null) {
try {
const playlistsToUse = availablePlaylists || await getRandomPlaylists(3);
const details = {};
const validationPromises = [];
for (const [genre, playlistId] of Object.entries(playlistsToUse)) {
validationPromises.push(
youtube.playlists.list({
key: API_KEY,
part: 'snippet,contentDetails',
id: playlistId
}).then(response => {
if (response.data.items?.[0]) {
const playlist = response.data.items[0];
details[genre] = {
id: playlistId,
title: playlist.snippet.title,
description: playlist.snippet.description,
thumbnail: playlist.snippet.thumbnails.maxres || playlist.snippet.thumbnails.high,
songCount: playlist.contentDetails.itemCount,
votes: 0
};
} else {
console.warn(`Playlist not found: ${genre} (${playlistId})`);
}
}).catch(error => {
console.error(`Error fetching playlist ${playlistId}:`, error);
})
);
}
await Promise.all(validationPromises);
if (Object.keys(details).length === 0) {
const response = await youtube.playlists.list({
key: API_KEY,
part: 'snippet,contentDetails',
id: PLAYLISTS.seventies
});
if (response.data.items?.[0]) {
const playlist = response.data.items[0];
details.seventies = {
id: PLAYLISTS.seventies,
title: playlist.snippet.title,
description: playlist.snippet.description,
thumbnail: playlist.snippet.thumbnails.maxres || playlist.snippet.thumbnails.high,
songCount: playlist.contentDetails.itemCount,
votes: 0
};
}
}
if (Object.keys(details).length === 0) {
throw new Error("No valid playlists found");
}
return details;
} catch (error) {
console.error('Error fetching playlist details:', error);
throw error;
}
}
const getRandomPlaylists = async (count = 3) => {
try {
const allPlaylists = Object.entries(PLAYLISTS);
if (allPlaylists.length === 0) {
throw new Error("No playlists configured");
}
const shuffled = [...allPlaylists].sort(() => Math.random() - 0.5);
const selected = shuffled.slice(0, Math.min(count, allPlaylists.length));
const result = {};
for (const [genre, playlistId] of selected) {
result[genre] = playlistId;
}
return result;
} catch (error) {
console.error("Error getting random playlists:", error);
return {
seventies: PLAYLISTS.seventies
};
}
};
module.exports = {
fetchPlaylistSongs,
getAvailableSongIds,
PLAYLISTS,
getPlaylistDetails,
getRandomPlaylists,
validateAndCleanPlaylists,
validatePlaylist
};