Compare commits
32 Commits
33f27a278d
...
main
Author | SHA1 | Date | |
---|---|---|---|
e083b353db | |||
7970fb3aa3 | |||
77bc1bfc41 | |||
388ce3d04a | |||
032ebc2368 | |||
09fb1e3938 | |||
f9447b3deb | |||
7996905f30 | |||
2ea81493ba | |||
9901c1a49e | |||
195980032c | |||
a1193ea87f | |||
251879a1e1 | |||
aa10b5b2cc | |||
2fb5af4171 | |||
33a1715adc | |||
e633a5e004 | |||
c31b7e35b6 | |||
770cad27b0 | |||
6750b00f46 | |||
a5579cc474 | |||
206988f4b7 | |||
f00ca9ba7c | |||
6e8a235629 | |||
184aa09fcf | |||
a3daab2f84 | |||
87fc9a2f39 | |||
7d7dd263fe | |||
0a4a9a9d0e | |||
76580841af | |||
11b3b48caa | |||
5ab0b61f80 |
@ -4,6 +4,8 @@ import {StateContext} from "@/common/contexts/StateContext";
|
|||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
import {faDrum, faGuitar, faHeadphones, faMusic} from "@fortawesome/free-solid-svg-icons";
|
import {faDrum, faGuitar, faHeadphones, faMusic} from "@fortawesome/free-solid-svg-icons";
|
||||||
import Home from "@/pages/Home";
|
import Home from "@/pages/Home";
|
||||||
|
import WaitingRoom from "@/pages/WaitingRoom";
|
||||||
|
import Ending from "@/pages/Ending";
|
||||||
|
|
||||||
const App = () => {
|
const App = () => {
|
||||||
const {currentState} = useContext(StateContext);
|
const {currentState} = useContext(StateContext);
|
||||||
@ -59,8 +61,10 @@ const App = () => {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{currentState === "WaitingRoom" && <WaitingRoom />}
|
||||||
{currentState === "Home" && <Home />}
|
{currentState === "Home" && <Home />}
|
||||||
{currentState === "Game" && <Game />}
|
{currentState === "Game" && <Game />}
|
||||||
|
{currentState === "Ending" && <Ending />}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
168
client/src/common/contexts/SocketContext.jsx
Normal file
168
client/src/common/contexts/SocketContext.jsx
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
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 pendingEventHandlers = useRef({});
|
||||||
|
const isConnecting = useRef(false);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (socket || isConnecting.current) return;
|
||||||
|
|
||||||
|
isConnecting.current = true;
|
||||||
|
console.log("Connecting to socket server...");
|
||||||
|
|
||||||
|
const serverUrl = process.env.NODE_ENV === 'production'
|
||||||
|
? window.location.origin
|
||||||
|
: 'http://localhost:5287';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newSocket = io(serverUrl, {
|
||||||
|
reconnectionAttempts: 3,
|
||||||
|
timeout: 10000,
|
||||||
|
reconnectionDelay: 1000,
|
||||||
|
autoConnect: true
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('connect', () => {
|
||||||
|
console.log('Socket connected with ID:', newSocket.id);
|
||||||
|
setConnected(true);
|
||||||
|
connectAttempts.current = 0;
|
||||||
|
isConnecting.current = false;
|
||||||
|
|
||||||
|
Object.entries(pendingEventHandlers.current).forEach(([event, handlers]) => {
|
||||||
|
handlers.forEach(handler => {
|
||||||
|
console.log(`Registering pending handler for event: ${event}`);
|
||||||
|
newSocket.on(event, handler);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem('playerId', newSocket.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('disconnect', (reason) => {
|
||||||
|
console.log('Socket disconnected:', reason);
|
||||||
|
setConnected(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
newSocket.on('connect_error', (error) => {
|
||||||
|
console.error('Connection error:', error);
|
||||||
|
connectAttempts.current += 1;
|
||||||
|
isConnecting.current = false;
|
||||||
|
|
||||||
|
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);
|
||||||
|
isConnecting.current = false;
|
||||||
|
}
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket && !isConnecting.current) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
pendingEventHandlers.current = {};
|
||||||
|
};
|
||||||
|
}, [connect, socket]);
|
||||||
|
|
||||||
|
const disconnect = useCallback(() => {
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
setSocket(null);
|
||||||
|
setConnected(false);
|
||||||
|
console.log('Socket manually disconnected');
|
||||||
|
pendingEventHandlers.current = {};
|
||||||
|
}
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
const send = useCallback((event, data) => {
|
||||||
|
if (!socket) {
|
||||||
|
console.warn(`Cannot send event "${event}": socket not connected. Auto-connecting...`);
|
||||||
|
connect();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!connected) {
|
||||||
|
console.warn(`Socket exists but not connected when sending "${event}". Waiting for connection...`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
socket.emit(event, data);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`Error sending event "${event}":`, err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [socket, connected, connect]);
|
||||||
|
|
||||||
|
const on = useCallback((event, callback) => {
|
||||||
|
if (!socket) {
|
||||||
|
console.log(`Deferring registration for event "${event}" until socket is ready`);
|
||||||
|
|
||||||
|
if (!pendingEventHandlers.current[event]) {
|
||||||
|
pendingEventHandlers.current[event] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingEventHandlers.current[event].push(callback);
|
||||||
|
|
||||||
|
if (!isConnecting.current) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (pendingEventHandlers.current[event]) {
|
||||||
|
const index = pendingEventHandlers.current[event].indexOf(callback);
|
||||||
|
if (index > -1) {
|
||||||
|
pendingEventHandlers.current[event].splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.on(event, callback);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (socket) {
|
||||||
|
socket.off(event, callback);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [socket, connect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [socket]);
|
||||||
|
|
||||||
|
const contextValue = {
|
||||||
|
socket,
|
||||||
|
connected,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
send,
|
||||||
|
on,
|
||||||
|
isConnecting: isConnecting.current
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SocketContext.Provider value={contextValue}>
|
||||||
|
{children}
|
||||||
|
</SocketContext.Provider>
|
||||||
|
);
|
||||||
|
};
|
@ -1,30 +0,0 @@
|
|||||||
import {createContext} from "react";
|
|
||||||
import {io} from "socket.io-client";
|
|
||||||
|
|
||||||
export const SocketContext = createContext({});
|
|
||||||
|
|
||||||
export const SocketProvider = ({ children }) => {
|
|
||||||
const socket = io("/", {autoConnect: false});
|
|
||||||
|
|
||||||
const connect = () => {
|
|
||||||
socket.connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
const send = (event, data) => {
|
|
||||||
socket.emit(event, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const disconnect = () => {
|
|
||||||
socket.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
const addListener = (event, callback) => {
|
|
||||||
socket.on(event, callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SocketContext.Provider value={{connect, disconnect, send, addListener}}>
|
|
||||||
{children}
|
|
||||||
</SocketContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
export * from "./SocketContext";
|
|
@ -11,5 +11,6 @@ $pink: #ff6bb3
|
|||||||
$blue: #4d9dff
|
$blue: #4d9dff
|
||||||
$purple: #9c6bff
|
$purple: #9c6bff
|
||||||
$cyan: #6bffea
|
$cyan: #6bffea
|
||||||
|
$orange: #ff9b6b
|
||||||
$yellow: #ffde6b
|
$yellow: #ffde6b
|
||||||
$mint-green: #85ffbd
|
$mint-green: #85ffbd
|
117
client/src/components/YouTubePlayer/YouTubePlayer.jsx
Normal file
117
client/src/components/YouTubePlayer/YouTubePlayer.jsx
Normal 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;
|
29
client/src/components/YouTubePlayer/styles.sass
Normal file
29
client/src/components/YouTubePlayer/styles.sass
Normal 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
|
72
client/src/pages/Ending/Ending.jsx
Normal file
72
client/src/pages/Ending/Ending.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
1
client/src/pages/Ending/index.js
Normal file
1
client/src/pages/Ending/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export {Ending as default} from "./Ending.jsx";
|
128
client/src/pages/Ending/styles.sass
Normal file
128
client/src/pages/Ending/styles.sass
Normal 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)
|
@ -1,55 +1,563 @@
|
|||||||
import "./styles.sass";
|
import "./styles.sass";
|
||||||
|
import {SocketContext} from "@/common/contexts/SocketContext";
|
||||||
import {StateContext} from "@/common/contexts/StateContext";
|
import {StateContext} from "@/common/contexts/StateContext";
|
||||||
import {useContext, useState, useEffect, useRef} from "react";
|
import {useContext, useState, useEffect, useRef, useCallback} from "react";
|
||||||
import MusicSlider from "@/pages/Game/components/MusicSlider";
|
import MusicSlider from "@/pages/Game/components/MusicSlider";
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
import {faMessage} 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 = () => {
|
export const Game = () => {
|
||||||
|
const {send, on, socket, connected, connect} = useContext(SocketContext);
|
||||||
const {setCurrentState} = useContext(StateContext);
|
const {setCurrentState} = useContext(StateContext);
|
||||||
const [messages, setMessages] = useState([{sender: "Marco", text: "Hallo!"}]);
|
|
||||||
const [inputValue, setInputValue] = useState("");
|
|
||||||
const messageEndRef = useRef(null);
|
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connected) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
}, [connected, connect]);
|
||||||
|
|
||||||
|
const [role, setRole] = useState(null);
|
||||||
|
const [round, setRound] = useState(1);
|
||||||
|
const [phase, setPhase] = useState("waiting");
|
||||||
|
const [timeLeft, setTimeLeft] = useState(30);
|
||||||
|
const [frequency, setFrequency] = useState(440);
|
||||||
|
const [currentSong, setCurrentSong] = useState(null);
|
||||||
|
const [songOptions, setSongOptions] = useState([]);
|
||||||
|
const [scores, setScores] = useState({});
|
||||||
|
const [selectedSong, setSelectedSong] = useState(null);
|
||||||
|
const [guessResult, setGuessResult] = useState(null);
|
||||||
|
const [isHost, setIsHost] = useState(false);
|
||||||
|
const [hasGuessed, setHasGuessed] = useState(false);
|
||||||
|
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [connectedUsers, setConnectedUsers] = useState([]);
|
||||||
|
const [username, setUsername] = useState("");
|
||||||
|
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];
|
||||||
|
if (myRole) {
|
||||||
|
setRole(myRole);
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
system: true,
|
||||||
|
text: myRole === "composer"
|
||||||
|
? "Du bist der Komponist! Spiele den Song mit dem Tonregler."
|
||||||
|
: "Du bist ein Rater! Höre die Frequenzen und versuche, den Song zu erkennen."
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"song-selected": setCurrentSong,
|
||||||
|
"round-started": (data) => {
|
||||||
|
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);
|
||||||
|
setPhase("guessing");
|
||||||
|
setTimeLeft(data.timeRemaining);
|
||||||
|
setSongOptions(data.songOptions || []);
|
||||||
|
},
|
||||||
|
"guess-result": (result) => {
|
||||||
|
setGuessResult(result.isCorrect);
|
||||||
|
setCurrentSong(result.correctSong);
|
||||||
|
},
|
||||||
|
"round-results": (results) => {
|
||||||
|
setPhase("results");
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"room-users-update": (users) => {
|
||||||
|
setConnectedUsers(users);
|
||||||
|
const currentUser = users.find(u => u.id === socket?.id);
|
||||||
|
if (currentUser) setUsername(currentUser.name);
|
||||||
|
},
|
||||||
|
"host-status": (status) => setIsHost(status.isHost),
|
||||||
|
"chat-message": (msg) => setMessages(prev => [...prev, msg]),
|
||||||
|
"user-connected": (userData) => {
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
system: true,
|
||||||
|
text: `${userData.name} ist beigetreten`
|
||||||
|
}]);
|
||||||
|
setConnectedUsers(prev => [...prev, userData]);
|
||||||
|
},
|
||||||
|
"user-disconnected": (userId) => {
|
||||||
|
setConnectedUsers(prev => {
|
||||||
|
const user = prev.find(u => u.id === userId);
|
||||||
|
if (user) {
|
||||||
|
setMessages(prevMsgs => [...prevMsgs, {
|
||||||
|
system: true,
|
||||||
|
text: `${user.name} hat den Raum verlassen`
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
return prev.filter(u => u.id !== userId);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
"frequency-update": (data) => {
|
||||||
|
if (phase === "composing") {
|
||||||
|
console.log("Received frequency update:", data.frequency);
|
||||||
|
setFrequency(data.frequency);
|
||||||
|
setComposerIsPlaying(data.isPlaying);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"phase-changed": (data) => {
|
||||||
|
console.log("Phase changed:", data);
|
||||||
|
setPhase(data.phase);
|
||||||
|
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(
|
||||||
|
([event, handler]) => on(event, handler)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (connected) {
|
||||||
|
send("get-room-users");
|
||||||
|
send("check-host-status");
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => cleanupFunctions.forEach(cleanup => cleanup());
|
||||||
|
}, [socket, on, send, role, currentSong, phase, connected, connectedUsers, setCurrentState, round]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
|
||||||
|
|
||||||
|
if (phase !== "waiting" && phase !== "results") {
|
||||||
|
timerIntervalRef.current = setInterval(() => {
|
||||||
|
setTimeLeft(prev => Math.max(0, prev - 1));
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
|
||||||
|
};
|
||||||
|
}, [phase]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messageEndRef.current?.scrollIntoView({behavior: "smooth"});
|
messageEndRef.current?.scrollIntoView({behavior: "smooth"});
|
||||||
}, [messages]);
|
}, [messages]);
|
||||||
|
|
||||||
const handleSendMessage = () => {
|
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, isPlaying });
|
||||||
|
}
|
||||||
|
}, [role, send]);
|
||||||
|
|
||||||
|
const handleSendMessage = useCallback(() => {
|
||||||
if (inputValue.trim()) {
|
if (inputValue.trim()) {
|
||||||
setMessages([...messages, {sender: "User", text: inputValue}]);
|
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("");
|
setInputValue("");
|
||||||
}
|
}
|
||||||
|
}, [inputValue, send, socket?.id, connectedUsers, username]);
|
||||||
|
|
||||||
|
const handleSongSelect = useCallback((song) => {
|
||||||
|
setSelectedSong(song);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSubmitGuess = useCallback(() => {
|
||||||
|
if (!selectedSong || phase !== 'guessing') return;
|
||||||
|
|
||||||
|
console.log("Submitting guess:", selectedSong.id);
|
||||||
|
send("submit-guess", { songId: selectedSong.id });
|
||||||
|
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
system: true,
|
||||||
|
text: `Du hast "${selectedSong.title}" von ${selectedSong.artist} gewählt.`
|
||||||
|
}]);
|
||||||
|
|
||||||
|
setHasGuessed(true);
|
||||||
|
}, [selectedSong, send, phase]);
|
||||||
|
|
||||||
|
const handleNextRound = useCallback(() => {
|
||||||
|
setTimeLeft(0);
|
||||||
|
send("next-round");
|
||||||
|
}, [send]);
|
||||||
|
|
||||||
|
const handlePlayerReady = useCallback(() => {
|
||||||
|
console.log("Player ready");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderPhaseContent = () => {
|
||||||
|
switch (phase) {
|
||||||
|
case "waiting":
|
||||||
|
return (
|
||||||
|
<div className="waiting-phase">
|
||||||
|
<h3>Warten auf Spielstart...</h3>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "composing":
|
||||||
|
return (
|
||||||
|
<div className="composing-phase">
|
||||||
|
<div className="phase-header">
|
||||||
|
<h3>Runde {round}: {role === "composer" ? "Spielen" : "Zuhören"}</h3>
|
||||||
|
<div className="timer">
|
||||||
|
<FontAwesomeIcon icon={faClock} /> {timeLeft}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{role === "composer" && currentSong && (
|
||||||
|
<div className="song-display">
|
||||||
|
<div className="song-card">
|
||||||
|
<img src={currentSong.coverUrl} alt={currentSong.title} />
|
||||||
|
<div className="song-info">
|
||||||
|
<div className="song-names">{currentSong.title}</div>
|
||||||
|
<div className="song-description">von {currentSong.artist}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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ö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>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "guessing":
|
||||||
|
return (
|
||||||
|
<div className="guessing-phase">
|
||||||
|
<div className="phase-header">
|
||||||
|
<h3>Auswahlphase</h3>
|
||||||
|
<div className="timer urgent">
|
||||||
|
<FontAwesomeIcon icon={faClock} /> {timeLeft}s
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{role === "composer" ? (
|
||||||
|
<div className="waiting-for-guessers">
|
||||||
|
<p>Die Rater versuchen nun, deinen Song zu erraten...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="song-selection">
|
||||||
|
<p className="instruction">Welchen Song hat der Komponist gespielt?</p>
|
||||||
|
|
||||||
|
{songOptions.length === 0 ? (
|
||||||
|
<div className="loading-songs">
|
||||||
|
<p>Lade Songoptionen...</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="song-grid">
|
||||||
|
{songOptions.map(song => (
|
||||||
|
<div
|
||||||
|
key={song.id}
|
||||||
|
className={`song-option ${selectedSong?.id === song.id ? 'selected' : ''} ${hasGuessed ? 'disabled' : ''}`}
|
||||||
|
onClick={() => !hasGuessed && 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 className="guess-actions">
|
||||||
|
{hasGuessed ? (
|
||||||
|
<p className="guess-submitted">Deine Antwort wurde eingereicht!</p>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className={`submit-guess-button ${!selectedSong ? 'disabled' : ''}`}
|
||||||
|
onClick={handleSubmitGuess}
|
||||||
|
disabled={!selectedSong}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPaperPlane} />
|
||||||
|
Antwort einreichen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case "results":
|
||||||
|
return (
|
||||||
|
<div className="results-phase">
|
||||||
|
<h3>Runde {round}: Ergebnisse</h3>
|
||||||
|
|
||||||
|
<div className="round-results">
|
||||||
|
{role === "composer" ? (
|
||||||
|
<div className="composer-results">
|
||||||
|
<p>Die Rater haben versucht, deinen Song zu erraten.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="guesser-results">
|
||||||
|
{currentSong && (
|
||||||
|
<div className="correct-song">
|
||||||
|
<p>Der richtige Song war:</p>
|
||||||
|
<div className="song-card highlight">
|
||||||
|
<img src={currentSong.coverUrl} alt={currentSong.title} />
|
||||||
|
<div className="song-info">
|
||||||
|
<div className="song-names">{currentSong.title}</div>
|
||||||
|
<div className="song-description">von {currentSong.artist}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{guessResult !== null && (
|
||||||
|
<div className={`guess-result ${guessResult ? 'correct' : 'incorrect'}`}>
|
||||||
|
{guessResult
|
||||||
|
? 'Richtig! Du erhälst 10 Punkte.'
|
||||||
|
: 'Leider falsch. Kein Punkt.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="scoreboard">
|
||||||
|
<h4>Punktestand</h4>
|
||||||
|
<div className="scores">
|
||||||
|
{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 ${isCurrentUser ? 'current-user' : ''}`}>
|
||||||
|
<span className="player-name">
|
||||||
|
{isCurrentUser && "👉 "}
|
||||||
|
{playerName}
|
||||||
|
{isHost && (
|
||||||
|
<FontAwesomeIcon icon={faCrown} className="host-icon" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<span className="player-score">{playerScore}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isHost ? (
|
||||||
|
<button className="next-round-button" onClick={handleNextRound}>
|
||||||
|
Nächste Runde
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<p className="waiting-message">Warten auf Rundenwechsel durch Host...</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return <div>Phasenfehler</div>;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="game-page">
|
<div className={`game-page ${phase === "composing" ? "with-otamatone" : ""}`}>
|
||||||
<div className="background-overlay">
|
<div className="background-overlay">
|
||||||
<div className="rotating-gradient"></div>
|
<div className="rotating-gradient"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="main-content">
|
<div className="game-header">
|
||||||
<div className="song-display">
|
<div className="game-role">
|
||||||
<h2>ToneGuessr</h2>
|
<FontAwesomeIcon icon={role === "composer" ? faMusic : faHeadphones} />
|
||||||
<div className="song-card">
|
<span>{role === "composer" ? "Komponist" : "Rater"}</span>
|
||||||
<img src="https://mir-s3-cdn-cf.behance.net/project_modules/1400/fe529a64193929.5aca8500ba9ab.jpg" alt="Song"/>
|
|
||||||
<div className="song-info">
|
|
||||||
<div className="song-names">Black Steam</div>
|
|
||||||
<div className="song-description">von Carrot Quest GmbH</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-window">
|
<h2>ToneGuessr</h2>
|
||||||
|
<div className="game-round">Runde {round}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="game-layout">
|
||||||
|
<div className="main-content">
|
||||||
|
{renderPhaseContent()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-panel">
|
||||||
<div className="chat-header">
|
<div className="chat-header">
|
||||||
<FontAwesomeIcon icon={faMessage} />
|
<FontAwesomeIcon icon={faMessage} />
|
||||||
<div className="chat-title">Chat</div>
|
<div className="chat-title">Chat</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="chat-messages">
|
<div className="chat-messages">
|
||||||
{messages.map((message, index) => (
|
{messages.map((message, index) => (
|
||||||
<div key={index} className="message">
|
<div key={index} className={`message ${message.system ? 'system-message' : ''}`}>
|
||||||
<span className="message-sender">{message.sender}:</span>
|
{message.system ? (
|
||||||
<span className="message-text">{message.text}</span>
|
<span className="message-text system">{message.text}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="message-sender">{message.sender}:</span>
|
||||||
|
<span className="message-text">{message.text}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div ref={messageEndRef}></div>
|
<div ref={messageEndRef}></div>
|
||||||
@ -58,13 +566,31 @@ export const Game = () => {
|
|||||||
<input type="text" value={inputValue}
|
<input type="text" value={inputValue}
|
||||||
onChange={(e) => setInputValue(e.target.value)}
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
|
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
|
||||||
placeholder="Gib eine Nachricht ein..."
|
placeholder="Nachricht..."
|
||||||
/>
|
/>
|
||||||
<button onClick={handleSendMessage}>Send</button>
|
<button onClick={handleSendMessage} className="send-button">
|
||||||
|
<FontAwesomeIcon icon={faPaperPlane} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<MusicSlider/>
|
|
||||||
|
{phase === "composing" && (
|
||||||
|
<MusicSlider
|
||||||
|
isReadOnly={role !== "composer"}
|
||||||
|
onFrequencyChange={handleFrequencyChange}
|
||||||
|
frequency={frequency}
|
||||||
|
composerIsPlaying={composerIsPlaying}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{songsLoading && (
|
||||||
|
<div className="songs-loading-indicator">
|
||||||
|
Lade Songs...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
|
export default Game;
|
@ -1,105 +1,257 @@
|
|||||||
import {useEffect, useRef, useState} from "react";
|
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import "./styles.sass";
|
import "./styles.sass";
|
||||||
|
|
||||||
export const MusicSlider = () => {
|
const frequencyToNote = (frequency) => {
|
||||||
const [frequency, setFrequency] = useState(440);
|
const A4 = 440;
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const notes = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||||
const [dragging, setDragging] = useState(false);
|
|
||||||
const audioContextRef = useRef(null);
|
const halfSteps = Math.round(12 * Math.log2(frequency / A4));
|
||||||
const oscillatorRef = useRef(null);
|
|
||||||
const gainNodeRef = useRef(null);
|
const A4Index = notes.indexOf('A');
|
||||||
const sliderRef = useRef(null);
|
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 frequency = useRef(externalFrequency || 440);
|
||||||
|
const isPressed = useRef(false);
|
||||||
|
const isDragging = useRef(false);
|
||||||
|
|
||||||
|
const initAudio = useCallback(() => {
|
||||||
|
if (audioContext) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
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 (oscillator && gainNode) {
|
||||||
|
frequency.current = externalFrequency;
|
||||||
|
oscillator.frequency.setValueAtTime(frequency.current, audioContext.currentTime);
|
||||||
|
|
||||||
|
if (composerIsPlaying) {
|
||||||
|
gainNode.gain.setTargetAtTime(0.5, audioContext.currentTime, 0.01);
|
||||||
|
} else {
|
||||||
|
gainNode.gain.setTargetAtTime(0.00001, audioContext.currentTime, 0.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [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 handleMouseDown = (e) => {
|
const handleMouseDown = (e) => {
|
||||||
setDragging(true);
|
const target = e.target;
|
||||||
startAudio();
|
const isSlider = target.classList.contains('otamatone-neck');
|
||||||
handleFrequencyChange(e);
|
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 = () => {
|
const handleMouseUp = (e) => {
|
||||||
setDragging(false);
|
if (!isPressed.current) return;
|
||||||
stopAudio();
|
|
||||||
|
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);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFrequencyChange = (e) => {
|
document.addEventListener('mousemove', handleMouseMove, true);
|
||||||
const rect = sliderRef.current.getBoundingClientRect();
|
document.addEventListener('mouseup', handleMouseUp, true);
|
||||||
const x = e.clientX - rect.left;
|
|
||||||
const clampedX = Math.max(0, Math.min(x, rect.width));
|
const slider = sliderRef.current;
|
||||||
const newFrequency = 20 + (clampedX / rect.width) * 1980;
|
if (slider) {
|
||||||
setFrequency(newFrequency);
|
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(() => {
|
useEffect(() => {
|
||||||
const handleMouseMove = (e) => dragging && handleFrequencyChange(e);
|
return () => {
|
||||||
const handleMouseUpGlobal = () => dragging && handleMouseUp();
|
if (gainNode) gainNode.gain.setValueAtTime(0, audioContext.currentTime);
|
||||||
|
if (oscillator) {
|
||||||
document.addEventListener('mousemove', handleMouseMove);
|
try {
|
||||||
document.addEventListener('mouseup', handleMouseUpGlobal);
|
oscillator.stop();
|
||||||
|
} catch (e) {
|
||||||
return () => {
|
console.warn("Oscillator already stopped");
|
||||||
document.removeEventListener('mousemove', handleMouseMove);
|
|
||||||
document.removeEventListener('mouseup', handleMouseUpGlobal);
|
|
||||||
};
|
|
||||||
}, [dragging]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isPlaying) {
|
|
||||||
if (oscillatorRef.current) {
|
|
||||||
oscillatorRef.current.frequency.setValueAtTime(frequency, audioContextRef.current.currentTime);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [frequency, isPlaying]);
|
}
|
||||||
|
if (audioContext) audioContext.close();
|
||||||
const startAudio = () => {
|
|
||||||
if (!audioContextRef.current) {
|
|
||||||
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)();
|
|
||||||
}
|
|
||||||
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();
|
|
||||||
} else {
|
|
||||||
oscillatorRef.current.frequency.setValueAtTime(frequency, audioContextRef.current.currentTime);
|
|
||||||
}
|
|
||||||
setIsPlaying(true);
|
|
||||||
};
|
};
|
||||||
|
}, [oscillator, audioContext, gainNode]);
|
||||||
|
|
||||||
const stopAudio = () => {
|
const getFrequencyPosition = () => {
|
||||||
if (oscillatorRef.current) {
|
const pos = ((frequency.current - 220) / 880) * 100;
|
||||||
oscillatorRef.current.stop();
|
return `${Math.max(0, Math.min(pos, 100))}%`;
|
||||||
oscillatorRef.current.disconnect();
|
};
|
||||||
oscillatorRef.current = null;
|
|
||||||
}
|
|
||||||
setIsPlaying(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
const getMouthSize = () => {
|
||||||
<div className="otamatone-container">
|
const minSize = 30;
|
||||||
<div className="otamatone" onMouseDown={handleMouseDown}>
|
const maxSize = 60;
|
||||||
<div className="otamatone-face">
|
const freqRange = 880;
|
||||||
<div className="otamatone-mouth" style={{
|
const sizeRange = maxSize - minSize;
|
||||||
height: `${10 + (frequency / 2000) * 40}px`,
|
const relativeFreq = frequency.current - 220;
|
||||||
width: `${10 + (frequency / 2000) * 40}px`
|
const size = minSize + (relativeFreq / freqRange) * sizeRange;
|
||||||
}}></div>
|
return Math.max(minSize, Math.min(maxSize, size));
|
||||||
<div className="otamatone-eyes">
|
};
|
||||||
<div className="eye left-eye"></div>
|
|
||||||
<div className="eye right-eye"></div>
|
const shouldShowNoteMarker = () => {
|
||||||
</div>
|
if (!isReadOnly) return isDragging.current;
|
||||||
</div>
|
return composerIsPlaying;
|
||||||
<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>
|
if (isReadOnly && !composerIsPlaying) {
|
||||||
<div className="note-marker" style={{left: '50%', pointerEvents: 'none'}}>♪</div>
|
return null;
|
||||||
<div className="note-marker" style={{left: '90%', pointerEvents: 'none'}}>♫</div>
|
}
|
||||||
</div>
|
|
||||||
</div>
|
return (
|
||||||
|
<div className="otamatone-container">
|
||||||
|
<div className="otamatone">
|
||||||
|
<div className="otamatone-face">
|
||||||
|
<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>
|
||||||
)
|
{(!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>
|
||||||
|
);
|
||||||
|
};
|
@ -9,17 +9,19 @@
|
|||||||
right: 0
|
right: 0
|
||||||
bottom: 0
|
bottom: 0
|
||||||
padding: 20px 30px 30px 30px
|
padding: 20px 30px 30px 30px
|
||||||
background: rgba(20, 20, 30, 0.75)
|
background: rgba(20, 20, 30, 0.85)
|
||||||
backdrop-filter: blur(15px)
|
backdrop-filter: blur(15px)
|
||||||
border-radius: 30px 30px 0 0
|
border-radius: 30px 30px 0 0
|
||||||
margin: 0
|
margin: 0
|
||||||
z-index: 10
|
z-index: 100
|
||||||
box-shadow: 0 -5px 30px rgba(0, 0, 0, 0.3), 0 -2px 10px rgba(255, 255, 255, 0.1)
|
box-shadow: 0 -5px 30px rgba(0, 0, 0, 0.3), 0 -2px 10px rgba(255, 255, 255, 0.1)
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.3)
|
border-top: 1px solid rgba(255, 255, 255, 0.3)
|
||||||
border-left: 1px solid rgba(255, 255, 255, 0.2)
|
border-left: 1px solid rgba(255, 255, 255, 0.2)
|
||||||
border-right: 1px solid rgba(255, 255, 255, 0.2)
|
border-right: 1px solid rgba(255, 255, 255, 0.2)
|
||||||
transition: transform 0.3s ease
|
transition: transform 0.3s ease
|
||||||
width: 100%
|
width: 100%
|
||||||
|
height: 100px
|
||||||
|
animation: slide-in-bottom 0.4s ease-out
|
||||||
|
|
||||||
.otamatone
|
.otamatone
|
||||||
display: flex
|
display: flex
|
||||||
@ -61,6 +63,10 @@
|
|||||||
background: rgba(255, 255, 255, 0.2)
|
background: rgba(255, 255, 255, 0.2)
|
||||||
pointer-events: none
|
pointer-events: none
|
||||||
|
|
||||||
|
&.interactive
|
||||||
|
.frequency-indicator:hover
|
||||||
|
transform: translateX(-50%) scale(1.1)
|
||||||
|
|
||||||
.frequency-indicator
|
.frequency-indicator
|
||||||
position: absolute
|
position: absolute
|
||||||
top: -20px
|
top: -20px
|
||||||
@ -82,13 +88,39 @@
|
|||||||
cursor: grabbing
|
cursor: grabbing
|
||||||
transform: translateX(-50%) scale(0.95)
|
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
|
.note-marker
|
||||||
position: absolute
|
position: absolute
|
||||||
top: -50px
|
top: -50px
|
||||||
font-size: 32pt
|
font-size: 24pt
|
||||||
color: $white
|
color: $white
|
||||||
text-shadow: 0 0 15px rgba(255, 255, 255, 0.7)
|
text-shadow: 0 0 15px rgba(255, 255, 255, 0.7)
|
||||||
font-weight: bold
|
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
|
.otamatone-face
|
||||||
width: 140px
|
width: 140px
|
||||||
@ -150,10 +182,11 @@
|
|||||||
border-radius: 50%
|
border-radius: 50%
|
||||||
position: absolute
|
position: absolute
|
||||||
bottom: 30px
|
bottom: 30px
|
||||||
width: 30px
|
transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275)
|
||||||
height: 30px
|
transform-origin: center center
|
||||||
transition: all 0.3s ease
|
|
||||||
animation: mouth-pulse 5s infinite alternate ease-in-out
|
&.active
|
||||||
|
animation: mouth-pulse 0.5s infinite alternate ease-in-out
|
||||||
|
|
||||||
@keyframes blink
|
@keyframes blink
|
||||||
0%, 90%, 100%
|
0%, 90%, 100%
|
||||||
@ -173,4 +206,22 @@
|
|||||||
opacity: 0
|
opacity: 0
|
||||||
100%
|
100%
|
||||||
transform: translateY(0)
|
transform: translateY(0)
|
||||||
|
opacity: 1
|
||||||
|
|
||||||
|
@keyframes slide-in-bottom
|
||||||
|
0%
|
||||||
|
transform: translateY(100%)
|
||||||
|
opacity: 0
|
||||||
|
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
|
opacity: 1
|
@ -1,133 +1,306 @@
|
|||||||
@import "@/common/styles/colors"
|
@import "@/common/styles/colors"
|
||||||
|
|
||||||
|
|
||||||
.game-page
|
.game-page
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: column
|
flex-direction: column
|
||||||
align-items: center
|
align-items: center
|
||||||
justify-content: center
|
|
||||||
height: 100vh
|
height: 100vh
|
||||||
width: 100vw
|
width: 100vw
|
||||||
padding: 20px
|
|
||||||
position: relative
|
position: relative
|
||||||
animation: page-fade-in 1s ease-in-out
|
animation: page-fade-in 1s ease-in-out
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
.main-content
|
&.with-otamatone
|
||||||
|
.game-layout
|
||||||
|
height: calc(100vh - 280px)
|
||||||
|
padding-bottom: 20px
|
||||||
|
|
||||||
|
.chat-panel
|
||||||
|
max-height: calc(100vh - 300px)
|
||||||
|
|
||||||
|
.game-layout
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: row
|
|
||||||
align-items: flex-start
|
|
||||||
justify-content: center
|
|
||||||
width: 100%
|
width: 100%
|
||||||
padding: 20px
|
height: calc(100vh - 180px)
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: stretch
|
||||||
|
padding: 0 20px
|
||||||
z-index: 2
|
z-index: 2
|
||||||
position: relative
|
position: relative
|
||||||
animation: float-up 1.5s ease-out
|
transition: height 0.3s ease
|
||||||
|
|
||||||
.song-display
|
.main-content
|
||||||
|
flex: 1
|
||||||
|
padding: 20px 30px
|
||||||
|
overflow-y: auto
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: column
|
flex-direction: column
|
||||||
align-items: center
|
align-items: center
|
||||||
justify-content: center
|
min-width: 0
|
||||||
width: 50%
|
|
||||||
margin-right: 20px
|
margin-right: 20px
|
||||||
color: $white
|
|
||||||
text-align: center
|
|
||||||
|
|
||||||
h2
|
.chat-panel
|
||||||
font-size: 52pt
|
width: 280px
|
||||||
color: $white
|
height: 100%
|
||||||
margin-bottom: 25px
|
max-height: calc(100vh - 200px)
|
||||||
position: relative
|
background: rgba(255, 255, 255, 0.08)
|
||||||
z-index: 2
|
backdrop-filter: blur(10px)
|
||||||
background: linear-gradient(135deg, $pink, $blue 45%, $mint-green 65%, $yellow 85%, $pink)
|
border-radius: 20px
|
||||||
background-size: 300% 100%
|
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2), 0 0 20px rgba(255, 255, 255, 0.1)
|
||||||
animation: title-shimmer 10s infinite alternate ease-in-out, title-float 6s infinite ease-in-out
|
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||||
-webkit-background-clip: text
|
display: flex
|
||||||
background-clip: text
|
flex-direction: column
|
||||||
-webkit-text-fill-color: transparent
|
overflow: hidden
|
||||||
filter: drop-shadow(0 0 15px rgba(255, 255, 255, 0.7))
|
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275), max-height 0.3s ease
|
||||||
letter-spacing: 0.1em
|
animation: slide-in-right 0.5s ease-out
|
||||||
font-weight: bold
|
|
||||||
|
.chat-header
|
||||||
&: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
|
display: flex
|
||||||
flex-direction: row
|
|
||||||
align-items: center
|
align-items: center
|
||||||
background: rgba(255, 255, 255, 0.08)
|
padding: 12px 15px
|
||||||
padding: 25px
|
background: rgba(30, 30, 30, 0.5)
|
||||||
border-radius: 20px
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2)
|
||||||
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
|
svg
|
||||||
transform: translateY(-10px) scale(1.02)
|
font-size: 16px
|
||||||
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.3), 0 0 30px rgba(255, 255, 255, 0.15)
|
color: $white
|
||||||
border: 1px solid rgba(255, 255, 255, 0.4)
|
filter: drop-shadow(0 0 5px rgba(255, 255, 255, 0.5))
|
||||||
|
animation: icon-pulse 3s infinite alternate ease-in-out
|
||||||
|
|
||||||
img
|
.chat-title
|
||||||
width: 120px
|
margin-left: 10px
|
||||||
height: 120px
|
font-size: 16px
|
||||||
border-radius: 15px
|
color: $white
|
||||||
margin-right: 25px
|
text-shadow: 0 0 10px rgba(255, 255, 255, 0.3)
|
||||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4)
|
|
||||||
transition: transform 0.3s ease
|
.chat-messages
|
||||||
will-change: transform
|
flex: 1
|
||||||
transform: translateZ(0)
|
padding: 10px
|
||||||
animation: album-rotate 10s infinite alternate ease-in-out
|
overflow-y: auto
|
||||||
|
color: $white
|
||||||
|
font-size: 14px
|
||||||
|
|
||||||
|
&::-webkit-scrollbar
|
||||||
|
width: 4px
|
||||||
|
background: transparent
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb
|
||||||
|
background: rgba(255, 255, 255, 0.2)
|
||||||
|
border-radius: 2px
|
||||||
|
|
||||||
&:hover
|
&:hover
|
||||||
transform: scale(1.1) rotate(5deg)
|
background: rgba(255, 255, 255, 0.3)
|
||||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 40px rgba(102, 204, 255, 0.3)
|
|
||||||
|
|
||||||
.song-info
|
.message
|
||||||
|
margin-bottom: 8px
|
||||||
|
padding: 6px 10px
|
||||||
|
border-radius: 8px
|
||||||
|
background: rgba(255, 255, 255, 0.05)
|
||||||
|
word-break: break-word
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: rgba(255, 255, 255, 0.1)
|
||||||
|
|
||||||
|
.message-sender
|
||||||
|
font-weight: bold
|
||||||
|
color: $yellow
|
||||||
|
margin-right: 4px
|
||||||
|
font-size: 13px
|
||||||
|
|
||||||
|
.message-text
|
||||||
|
font-size: 13px
|
||||||
|
|
||||||
|
.message-text.system
|
||||||
|
font-style: italic
|
||||||
|
color: rgba(255, 255, 255, 0.6)
|
||||||
|
|
||||||
|
.system-message
|
||||||
|
text-align: center
|
||||||
|
color: rgba(255, 255, 255, 0.6)
|
||||||
|
padding: 4px 0
|
||||||
|
font-size: 12px
|
||||||
|
|
||||||
|
.chat-input
|
||||||
|
display: flex
|
||||||
|
padding: 10px
|
||||||
|
background: rgba(30, 30, 30, 0.5)
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.2)
|
||||||
|
|
||||||
|
input
|
||||||
|
flex: 1
|
||||||
|
padding: 8px 12px
|
||||||
|
border: none
|
||||||
|
border-radius: 8px
|
||||||
|
outline: none
|
||||||
|
background: rgba(0, 0, 0, 0.3)
|
||||||
|
color: $white
|
||||||
|
font-size: 14px
|
||||||
|
|
||||||
|
&:focus
|
||||||
|
box-shadow: 0 0 15px rgba(255, 255, 255, 0.2)
|
||||||
|
|
||||||
|
.send-button
|
||||||
|
margin-left: 8px
|
||||||
|
width: 36px
|
||||||
|
height: 36px
|
||||||
|
background: linear-gradient(135deg, $purple, $blue)
|
||||||
|
color: $white
|
||||||
|
border: none
|
||||||
|
border-radius: 50%
|
||||||
|
cursor: pointer
|
||||||
display: flex
|
display: flex
|
||||||
flex-direction: column
|
align-items: center
|
||||||
align-items: flex-start
|
justify-content: center
|
||||||
|
transition: all 0.2s ease
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
transform: translateY(-2px)
|
||||||
|
box-shadow: 0 4px 15px rgba(102, 204, 255, 0.5)
|
||||||
|
|
||||||
|
&:active
|
||||||
|
transform: translateY(0)
|
||||||
|
|
||||||
.song-names
|
.game-header
|
||||||
font-size: 28pt
|
display: flex
|
||||||
color: $white
|
justify-content: space-between
|
||||||
margin-bottom: 10px
|
align-items: center
|
||||||
text-shadow: 0 0 10px rgba(255, 255, 255, 0.5)
|
width: 100%
|
||||||
animation: text-shimmer 5s infinite alternate ease-in-out
|
padding: 15px 30px
|
||||||
|
margin-bottom: 10px
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1)
|
||||||
|
background: rgba(0, 0, 0, 0.2)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
|
||||||
|
h2
|
||||||
|
font-size: 32px
|
||||||
|
background: linear-gradient(135deg, $yellow, $pink)
|
||||||
|
background-clip: text
|
||||||
|
-webkit-background-clip: text
|
||||||
|
color: transparent
|
||||||
|
margin: 0
|
||||||
|
animation: pulse 3s infinite alternate ease-in-out
|
||||||
|
text-shadow: 0 0 15px rgba(255, 255, 255, 0.3)
|
||||||
|
|
||||||
|
.game-role, .game-round
|
||||||
|
background: rgba(255, 255, 255, 0.1)
|
||||||
|
padding: 8px 15px
|
||||||
|
border-radius: 20px
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
font-size: 14px
|
||||||
|
|
||||||
|
svg
|
||||||
|
margin-right: 8px
|
||||||
|
color: $yellow
|
||||||
|
|
||||||
.song-description
|
.submit-guess-button, .next-round-button, button:not(.send-button)
|
||||||
font-size: 16pt
|
background: linear-gradient(135deg, $purple, $blue)
|
||||||
color: $border
|
color: #fff
|
||||||
opacity: 0.8
|
border: none
|
||||||
position: relative
|
border-radius: 10px
|
||||||
|
padding: 12px 25px
|
||||||
&:after
|
font-size: 16px
|
||||||
content: ""
|
font-weight: bold
|
||||||
position: absolute
|
cursor: pointer
|
||||||
bottom: -5px
|
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)
|
||||||
left: 0
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3)
|
||||||
width: 0%
|
display: inline-flex
|
||||||
height: 1px
|
align-items: center
|
||||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.7), transparent)
|
justify-content: center
|
||||||
transition: all 0.4s ease
|
position: relative
|
||||||
|
overflow: hidden
|
||||||
&:hover:after
|
|
||||||
width: 100%
|
&:before
|
||||||
|
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
|
||||||
|
|
||||||
|
svg
|
||||||
|
margin-right: 10px
|
||||||
|
|
||||||
|
&:hover:not(.disabled)
|
||||||
|
transform: translateY(-5px)
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4), 0 0 20px rgba(102, 204, 255, 0.4)
|
||||||
|
|
||||||
|
&:before
|
||||||
|
opacity: 1
|
||||||
|
|
||||||
|
&:active:not(.disabled)
|
||||||
|
transform: translateY(-2px)
|
||||||
|
|
||||||
|
&.disabled
|
||||||
|
opacity: 0.5
|
||||||
|
cursor: not-allowed
|
||||||
|
background: linear-gradient(135deg, #777, #555)
|
||||||
|
|
||||||
|
.waiting-phase
|
||||||
|
text-align: center
|
||||||
|
padding: 100px 0
|
||||||
|
animation: fade-in 0.5s ease-out
|
||||||
|
|
||||||
|
h3
|
||||||
|
font-size: 24px
|
||||||
|
color: $white
|
||||||
|
margin-bottom: 20px
|
||||||
|
animation: pulse 2s infinite alternate ease-in-out
|
||||||
|
|
||||||
|
.composing-phase, .guessing-phase, .results-phase
|
||||||
|
width: 100%
|
||||||
|
max-width: 800px
|
||||||
|
margin: 0 auto
|
||||||
|
animation: fade-in 0.5s ease-out
|
||||||
|
|
||||||
|
.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(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.15)
|
||||||
|
max-width: 500px
|
||||||
|
margin: 20px auto
|
||||||
|
transition: all 0.3s ease
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
transform: translateY(-5px)
|
||||||
|
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: 8px
|
||||||
|
margin-right: 20px
|
||||||
|
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5)
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.2)
|
||||||
|
|
||||||
|
.music-controls
|
||||||
|
margin-top: 30px
|
||||||
|
width: 100%
|
||||||
|
max-width: 400px
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
|
||||||
.chat-window
|
.chat-window
|
||||||
width: 50%
|
width: 50%
|
||||||
@ -210,6 +383,16 @@
|
|||||||
.message-text
|
.message-text
|
||||||
margin-left: 5px
|
margin-left: 5px
|
||||||
|
|
||||||
|
.message-text.system
|
||||||
|
font-style: italic
|
||||||
|
color: #888
|
||||||
|
|
||||||
|
.system-message
|
||||||
|
text-align: center
|
||||||
|
font-style: italic
|
||||||
|
color: #888
|
||||||
|
padding: 5px 0
|
||||||
|
|
||||||
.chat-input
|
.chat-input
|
||||||
display: flex
|
display: flex
|
||||||
padding: 15px
|
padding: 15px
|
||||||
@ -271,6 +454,169 @@
|
|||||||
opacity: 1
|
opacity: 1
|
||||||
animation: rotate-background 5s linear infinite
|
animation: rotate-background 5s linear infinite
|
||||||
|
|
||||||
|
.phase-header
|
||||||
|
display: flex
|
||||||
|
justify-content: space-between
|
||||||
|
align-items: center
|
||||||
|
width: 100%
|
||||||
|
margin-bottom: 20px
|
||||||
|
|
||||||
|
.timer
|
||||||
|
background: rgba(255, 255, 255, 0.1)
|
||||||
|
padding: 10px 15px
|
||||||
|
border-radius: 20px
|
||||||
|
font-size: 18px
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
svg
|
||||||
|
margin-right: 8px
|
||||||
|
color: $yellow
|
||||||
|
|
||||||
|
.song-grid
|
||||||
|
display: grid
|
||||||
|
grid-template-columns: repeat(3, 1fr)
|
||||||
|
gap: 20px
|
||||||
|
margin-top: 20px
|
||||||
|
animation: fade-in 0.5s ease-out
|
||||||
|
|
||||||
|
@media (max-width: 768px)
|
||||||
|
grid-template-columns: repeat(2, 1fr)
|
||||||
|
|
||||||
|
@media (max-width: 500px)
|
||||||
|
grid-template-columns: 1fr
|
||||||
|
|
||||||
|
.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 4px 15px rgba(0, 0, 0, 0.2)
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
transform: translateY(-5px)
|
||||||
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3), 0 0 20px rgba(255, 255, 255, 0.1)
|
||||||
|
background: rgba(255, 255, 255, 0.15)
|
||||||
|
|
||||||
|
&.selected
|
||||||
|
border: 2px solid $yellow
|
||||||
|
box-shadow: 0 0 20px rgba(255, 255, 0, 0.5), 0 10px 30px rgba(0, 0, 0, 0.3)
|
||||||
|
background: rgba(255, 255, 255, 0.2)
|
||||||
|
|
||||||
|
.song-image
|
||||||
|
position: relative
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
img
|
||||||
|
width: 100%
|
||||||
|
height: 150px
|
||||||
|
object-fit: cover
|
||||||
|
transition: transform 0.3s ease
|
||||||
|
|
||||||
|
.selected &
|
||||||
|
transform: scale(1.05)
|
||||||
|
|
||||||
|
.selection-indicator
|
||||||
|
position: absolute
|
||||||
|
top: 10px
|
||||||
|
right: 10px
|
||||||
|
background-color: $yellow
|
||||||
|
color: #000
|
||||||
|
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)
|
||||||
|
animation: pulse 1.5s infinite ease-in-out
|
||||||
|
|
||||||
|
.song-details
|
||||||
|
padding: 15px
|
||||||
|
|
||||||
|
.song-title
|
||||||
|
font-weight: bold
|
||||||
|
font-size: 18px
|
||||||
|
color: #fff
|
||||||
|
margin-bottom: 5px
|
||||||
|
|
||||||
|
.song-artist
|
||||||
|
font-size: 14px
|
||||||
|
color: rgba(255, 255, 255, 0.8)
|
||||||
|
|
||||||
|
.guess-actions
|
||||||
|
margin-top: 30px
|
||||||
|
text-align: center
|
||||||
|
animation: fade-in 0.5s ease-out 0.2s both
|
||||||
|
|
||||||
|
.submit-guess-button
|
||||||
|
background: linear-gradient(135deg, $purple, $blue)
|
||||||
|
color: #fff
|
||||||
|
border: none
|
||||||
|
border-radius: 10px
|
||||||
|
padding: 12px 25px
|
||||||
|
font-size: 16px
|
||||||
|
font-weight: bold
|
||||||
|
cursor: pointer
|
||||||
|
transition: all 0.3s ease
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3)
|
||||||
|
display: inline-flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
|
||||||
|
svg
|
||||||
|
margin-right: 8px
|
||||||
|
|
||||||
|
&:hover:not(.disabled)
|
||||||
|
transform: translateY(-3px)
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4), 0 0 20px rgba(102, 204, 255, 0.4)
|
||||||
|
|
||||||
|
&:active:not(.disabled)
|
||||||
|
transform: translateY(-1px)
|
||||||
|
|
||||||
|
&.disabled
|
||||||
|
opacity: 0.5
|
||||||
|
cursor: not-allowed
|
||||||
|
background: linear-gradient(135deg, #999, #666)
|
||||||
|
|
||||||
|
.selection-hint
|
||||||
|
margin-top: 10px
|
||||||
|
color: rgba(255, 255, 255, 0.7)
|
||||||
|
font-size: 14px
|
||||||
|
font-style: italic
|
||||||
|
|
||||||
|
.loading-songs
|
||||||
|
text-align: center
|
||||||
|
padding: 40px
|
||||||
|
color: $white
|
||||||
|
|
||||||
|
p
|
||||||
|
font-size: 18px
|
||||||
|
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
|
@keyframes subtle-text-glow
|
||||||
0%, 100%
|
0%, 100%
|
||||||
text-shadow: 0 0 10px rgba(255, 255, 255, 0.4)
|
text-shadow: 0 0 10px rgba(255, 255, 255, 0.4)
|
||||||
@ -327,4 +673,404 @@
|
|||||||
filter: drop-shadow(0 0 15px rgba(255, 255, 255, 0.7))
|
filter: drop-shadow(0 0 15px rgba(255, 255, 255, 0.7))
|
||||||
50%
|
50%
|
||||||
transform: translateY(-10px) scale(1.05) rotate(1deg)
|
transform: translateY(-10px) scale(1.05) rotate(1deg)
|
||||||
filter: drop-shadow(0 0 25px rgba(255, 255, 255, 0.9)) drop-shadow(0 0 50px rgba(102, 204, 255, 0.6))
|
filter: drop-shadow(0 0 25px rgba(255, 255, 255, 0.9)) drop-shadow(0 0 50px rgba(102, 204, 255, 0.6))
|
||||||
|
|
||||||
|
@keyframes fade-in
|
||||||
|
0%
|
||||||
|
opacity: 0
|
||||||
|
transform: translateY(20px)
|
||||||
|
100%
|
||||||
|
opacity: 1
|
||||||
|
transform: translateY(0)
|
||||||
|
|
||||||
|
@keyframes pulse
|
||||||
|
0%, 100%
|
||||||
|
opacity: 0.8
|
||||||
|
transform: scale(1)
|
||||||
|
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)
|
@ -5,35 +5,75 @@ import CreateForm from "@/pages/Home/components/CreateForm";
|
|||||||
import {faArrowRightToBracket, faPlusSquare} from "@fortawesome/free-solid-svg-icons";
|
import {faArrowRightToBracket, faPlusSquare} from "@fortawesome/free-solid-svg-icons";
|
||||||
import {StateContext} from "@/common/contexts/StateContext";
|
import {StateContext} from "@/common/contexts/StateContext";
|
||||||
import {SocketContext} from "@/common/contexts/SocketContext";
|
import {SocketContext} from "@/common/contexts/SocketContext";
|
||||||
import {useContext, useState} from "react";
|
import {useContext, useState, useEffect} from "react";
|
||||||
|
|
||||||
export const Home = () => {
|
export const Home = () => {
|
||||||
const {setCurrentState} = useContext(StateContext);
|
const {setCurrentState} = useContext(StateContext);
|
||||||
const {connect, send} = useContext(SocketContext);
|
const {connect, send, on, connected} = useContext(SocketContext);
|
||||||
const [homeState, setHomeState] = useState("initial"); // initial, joining, creating
|
const [homeState, setHomeState] = useState("initial"); // initial, joining, creating
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!connected) {
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
}, [connected, connect]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
connect();
|
||||||
|
|
||||||
|
const handleRoomCreated = (roomId) => {
|
||||||
|
console.log("Room created", roomId);
|
||||||
|
setCurrentState("WaitingRoom");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoomJoined = (roomId) => {
|
||||||
|
console.log("Room joined", roomId);
|
||||||
|
setCurrentState("WaitingRoom");
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoomNotFound = (roomId) => {
|
||||||
|
setError(`Room ${roomId} not found`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoomClosed = (roomId) => {
|
||||||
|
setError(`Room ${roomId} is already in game`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupRoomCreated = on("room-created", handleRoomCreated);
|
||||||
|
const cleanupRoomJoined = on("room-joined", handleRoomJoined);
|
||||||
|
const cleanupRoomNotFound = on("room-not-found", handleRoomNotFound);
|
||||||
|
const cleanupRoomClosed = on("room-closed", handleRoomClosed);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanupRoomCreated();
|
||||||
|
cleanupRoomJoined();
|
||||||
|
cleanupRoomNotFound();
|
||||||
|
cleanupRoomClosed();
|
||||||
|
};
|
||||||
|
}, [connect, setCurrentState, on]);
|
||||||
|
|
||||||
const handleJoinClick = () => {
|
const handleJoinClick = () => {
|
||||||
setHomeState("joining");
|
setHomeState("joining");
|
||||||
|
setError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateClick = () => {
|
const handleCreateClick = () => {
|
||||||
setHomeState("creating");
|
setHomeState("creating");
|
||||||
|
setError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
setHomeState("initial");
|
setHomeState("initial");
|
||||||
|
setError("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleJoinSubmit = (name, roomCode) => {
|
const handleJoinSubmit = (name, roomCode) => {
|
||||||
connect();
|
send("join-room", {roomId: roomCode.toUpperCase(), name});
|
||||||
send("joinRoom", {name, roomCode});
|
|
||||||
setCurrentState("Game");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreateSubmit = (name) => {
|
const handleCreateSubmit = (name) => {
|
||||||
connect();
|
send("create-room", {name});
|
||||||
send("createRoom", {name});
|
|
||||||
setCurrentState("Game");
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -49,19 +89,19 @@ export const Home = () => {
|
|||||||
|
|
||||||
<div className={`content-container ${homeState !== 'initial' ? 'active' : ''}`}>
|
<div className={`content-container ${homeState !== 'initial' ? 'active' : ''}`}>
|
||||||
<div className={`action-area ${homeState !== 'initial' ? 'hidden' : ''}`}>
|
<div className={`action-area ${homeState !== 'initial' ? 'hidden' : ''}`}>
|
||||||
<ActionCard title="Join Room" icon={faArrowRightToBracket} onClick={handleJoinClick} />
|
<ActionCard title="Beitreten" icon={faArrowRightToBracket} onClick={handleJoinClick} />
|
||||||
<ActionCard title="Create Room" icon={faPlusSquare} onClick={handleCreateClick} />
|
<ActionCard title="Erstellen" icon={faPlusSquare} onClick={handleCreateClick} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`form-container ${homeState === 'joining' ? 'active' : ''}`}>
|
<div className={`form-container ${homeState === 'joining' ? 'active' : ''}`}>
|
||||||
{homeState === 'joining' && (
|
{homeState === 'joining' && (
|
||||||
<JoinForm onSubmit={handleJoinSubmit} onBack={handleBack} />
|
<JoinForm onSubmit={handleJoinSubmit} onBack={handleBack} error={error} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={`form-container ${homeState === 'creating' ? 'active' : ''}`}>
|
<div className={`form-container ${homeState === 'creating' ? 'active' : ''}`}>
|
||||||
{homeState === 'creating' && (
|
{homeState === 'creating' && (
|
||||||
<CreateForm onSubmit={handleCreateSubmit} onBack={handleBack} />
|
<CreateForm onSubmit={handleCreateSubmit} onBack={handleBack} error={error} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,13 +1,19 @@
|
|||||||
import "@/common/styles/forms.sass";
|
import "@/common/styles/forms.sass";
|
||||||
import {useState} from "react";
|
import {useState, useEffect} from "react";
|
||||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||||
import {faArrowLeft, faArrowRightToBracket} from "@fortawesome/free-solid-svg-icons";
|
import {faArrowLeft, faArrowRightToBracket} from "@fortawesome/free-solid-svg-icons";
|
||||||
import FormInput from "@/common/components/FormInput";
|
import FormInput from "@/common/components/FormInput";
|
||||||
|
|
||||||
export const JoinForm = ({onSubmit, onBack}) => {
|
export const JoinForm = ({onSubmit, onBack, error}) => {
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [roomCode, setRoomCode] = useState("");
|
const [roomCode, setRoomCode] = useState("");
|
||||||
const [errors, setErrors] = useState({});
|
const [errors, setErrors] = useState({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (error) {
|
||||||
|
setErrors(prev => ({...prev, roomCode: error}));
|
||||||
|
}
|
||||||
|
}, [error]);
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@ -21,7 +27,7 @@ export const JoinForm = ({onSubmit, onBack}) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSubmit(name, roomCode);
|
onSubmit(name, roomCode.toUpperCase());
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
332
client/src/pages/WaitingRoom/WaitingRoom.jsx
Normal file
332
client/src/pages/WaitingRoom/WaitingRoom.jsx
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
import { useContext, useEffect, useState, useRef } from "react";
|
||||||
|
import { SocketContext } from "@/common/contexts/SocketContext";
|
||||||
|
import { StateContext } from "@/common/contexts/StateContext";
|
||||||
|
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||||
|
import { faPlayCircle, faUsers, faMessage, faArrowLeft, faCopy, faCheck } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import "./styles.sass";
|
||||||
|
|
||||||
|
export const WaitingRoom = () => {
|
||||||
|
const { socket, on, send } = useContext(SocketContext);
|
||||||
|
const { setCurrentState } = useContext(StateContext);
|
||||||
|
const [users, setUsers] = useState([]);
|
||||||
|
const [messages, setMessages] = useState([]);
|
||||||
|
const [inputValue, setInputValue] = useState("");
|
||||||
|
const [roomCode, setRoomCode] = useState("");
|
||||||
|
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(() => {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserInfo = (userInfo) => {
|
||||||
|
setUsername(userInfo.name);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoomUsers = (users) => {
|
||||||
|
setUsers(users);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoomUsersUpdate = (updatedUsers) => {
|
||||||
|
setUsers(updatedUsers);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoomCode = (code) => {
|
||||||
|
setRoomCode(code);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserConnected = (userData) => {
|
||||||
|
setMessages(prev => [...prev, {
|
||||||
|
system: true,
|
||||||
|
text: `${userData.name} ist beigetreten`
|
||||||
|
}]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserDisconnected = (userId) => {
|
||||||
|
setUsers(prev => {
|
||||||
|
const user = prev.find(u => u.id === userId);
|
||||||
|
if (user) {
|
||||||
|
setMessages(prevMsgs => [...prevMsgs, {
|
||||||
|
system: true,
|
||||||
|
text: `${user.name} hat den Raum verlassen`
|
||||||
|
}]);
|
||||||
|
}
|
||||||
|
return prev.filter(u => u.id !== userId);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChatMessage = (messageData) => {
|
||||||
|
setMessages(prev => [...prev, messageData]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGameStarted = () => {
|
||||||
|
setCurrentState("Game");
|
||||||
|
};
|
||||||
|
|
||||||
|
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);
|
||||||
|
const cleanupRoomCode = on("room-code", handleRoomCode);
|
||||||
|
const cleanupRoomUsersUpdate = on("room-users-update", handleRoomUsersUpdate);
|
||||||
|
const cleanupUserConnected = on("user-connected", handleUserConnected);
|
||||||
|
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);
|
||||||
|
|
||||||
|
setMessages([{
|
||||||
|
system: true,
|
||||||
|
text: "Willkommen im Warteraum! Es wird noch auf weitere Spieler gewartet..."
|
||||||
|
}]);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cleanupHostStatus();
|
||||||
|
cleanupUserInfo();
|
||||||
|
cleanupRoomUsers();
|
||||||
|
cleanupRoomCode();
|
||||||
|
cleanupRoomUsersUpdate();
|
||||||
|
cleanupUserConnected();
|
||||||
|
cleanupUserDisconnected();
|
||||||
|
cleanupChatMessage();
|
||||||
|
cleanupGameStarted();
|
||||||
|
cleanupPlaylistOptions();
|
||||||
|
cleanupVotesUpdated();
|
||||||
|
cleanupRoomJoined();
|
||||||
|
};
|
||||||
|
}, [on, send, socket, setCurrentState]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
messageEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const handleSendMessage = () => {
|
||||||
|
if (inputValue.trim()) {
|
||||||
|
const messageData = {
|
||||||
|
text: inputValue,
|
||||||
|
sender: username
|
||||||
|
};
|
||||||
|
send("send-message", messageData);
|
||||||
|
setMessages(prev => [...prev, messageData]);
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleStartGame = () => {
|
||||||
|
if (isHost) {
|
||||||
|
send("start-game");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLeaveRoom = () => {
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
setCurrentState("Home");
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyRoomCode = () => {
|
||||||
|
navigator.clipboard.writeText(roomCode);
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="waiting-room-page">
|
||||||
|
<div className="background-overlay">
|
||||||
|
<div className="rotating-gradient"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="waiting-room-header">
|
||||||
|
<button className="back-button" onClick={handleLeaveRoom}>
|
||||||
|
<FontAwesomeIcon icon={faArrowLeft} />
|
||||||
|
<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>
|
||||||
|
<FontAwesomeIcon icon={copied ? faCheck : faCopy} className={copied ? "copied" : ""} />
|
||||||
|
</div>
|
||||||
|
<div className="copy-hint">{copied ? "Kopiert!" : "Klicken zum Kopieren"}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="waiting-room-layout">
|
||||||
|
{renderPlaylistSection()}
|
||||||
|
|
||||||
|
<div className="bottom-section">
|
||||||
|
<div className="users-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<FontAwesomeIcon icon={faUsers} />
|
||||||
|
<h2>Spieler ({users.length})</h2>
|
||||||
|
</div>
|
||||||
|
<div className="users-list">
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<div className="no-users">Keine Spieler im Raum</div>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<div key={user.id} className={`user-item ${user.creator ? 'host' : ''}`}>
|
||||||
|
{user.name} {user.creator && <span className="host-badge">Host</span>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{isHost && (
|
||||||
|
<div className="game-controls">
|
||||||
|
<button
|
||||||
|
className={`start-game-button ${canStartGame ? '' : 'disabled'}`}
|
||||||
|
onClick={handleStartGame}
|
||||||
|
disabled={!canStartGame}
|
||||||
|
>
|
||||||
|
<FontAwesomeIcon icon={faPlayCircle} />
|
||||||
|
Spiel starten
|
||||||
|
</button>
|
||||||
|
{!canStartGame && users.length < minPlayersToStart && (
|
||||||
|
<div className="start-hint">Mindestens {minPlayersToStart} Spieler benötigt</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="chat-panel">
|
||||||
|
<div className="panel-header">
|
||||||
|
<FontAwesomeIcon icon={faMessage} />
|
||||||
|
<h2>Chat</h2>
|
||||||
|
</div>
|
||||||
|
<div className="chat-messages">
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<div key={index} className={`message ${message.system ? 'system-message' : ''}`}>
|
||||||
|
{message.system ? (
|
||||||
|
<span className="message-text system">{message.text}</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="message-sender">{message.sender}:</span>
|
||||||
|
<span className="message-text">{message.text}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div ref={messageEndRef}></div>
|
||||||
|
</div>
|
||||||
|
<div className="chat-input">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(e) => setInputValue(e.target.value)}
|
||||||
|
onKeyPress={(e) => e.key === 'Enter' && handleSendMessage()}
|
||||||
|
placeholder="Gib eine Nachricht ein..."
|
||||||
|
/>
|
||||||
|
<button onClick={handleSendMessage}>Senden</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
1
client/src/pages/WaitingRoom/index.js
Normal file
1
client/src/pages/WaitingRoom/index.js
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { WaitingRoom as default } from './WaitingRoom';
|
735
client/src/pages/WaitingRoom/styles.sass
Normal file
735
client/src/pages/WaitingRoom/styles.sass
Normal file
@ -0,0 +1,735 @@
|
|||||||
|
@import "@/common/styles/colors"
|
||||||
|
|
||||||
|
.waiting-room-page
|
||||||
|
height: 100vh
|
||||||
|
width: 100vw
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
position: relative
|
||||||
|
padding: 20px
|
||||||
|
animation: page-fade-in 0.8s ease-in-out
|
||||||
|
|
||||||
|
.waiting-room-header
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
width: 100%
|
||||||
|
margin-bottom: 30px
|
||||||
|
position: relative
|
||||||
|
z-index: 2
|
||||||
|
animation: float-up 0.8s ease-out
|
||||||
|
|
||||||
|
.back-button
|
||||||
|
position: absolute
|
||||||
|
left: 20px
|
||||||
|
top: 0
|
||||||
|
background: none
|
||||||
|
border: none
|
||||||
|
color: $white
|
||||||
|
font-size: 1rem
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 10px
|
||||||
|
cursor: pointer
|
||||||
|
transition: all 0.2s ease
|
||||||
|
padding: 8px 16px
|
||||||
|
border-radius: 5px
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: rgba(255, 255, 255, 0.1)
|
||||||
|
|
||||||
|
h1
|
||||||
|
font-size: 42pt
|
||||||
|
margin: 0
|
||||||
|
color: $white
|
||||||
|
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%
|
||||||
|
animation: title-shimmer 10s infinite alternate ease-in-out
|
||||||
|
-webkit-background-clip: text
|
||||||
|
background-clip: text
|
||||||
|
-webkit-text-fill-color: transparent
|
||||||
|
|
||||||
|
.room-code-container
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
align-items: center
|
||||||
|
|
||||||
|
.room-code
|
||||||
|
font-size: 1.2rem
|
||||||
|
color: $white
|
||||||
|
background: rgba(255, 255, 255, 0.1)
|
||||||
|
padding: 10px 25px
|
||||||
|
border-radius: 10px
|
||||||
|
cursor: pointer
|
||||||
|
transition: all 0.3s ease
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||||
|
backdrop-filter: blur(5px)
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 10px
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: rgba(255, 255, 255, 0.2)
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.4)
|
||||||
|
|
||||||
|
.code
|
||||||
|
font-weight: bold
|
||||||
|
font-size: 1.3rem
|
||||||
|
color: $yellow
|
||||||
|
letter-spacing: 2px
|
||||||
|
margin-left: 5px
|
||||||
|
margin-right: 5px
|
||||||
|
|
||||||
|
svg
|
||||||
|
font-size: 1rem
|
||||||
|
transition: all 0.3s ease
|
||||||
|
|
||||||
|
&.copied
|
||||||
|
color: $mint-green
|
||||||
|
animation: pop 0.3s ease
|
||||||
|
|
||||||
|
.copy-hint
|
||||||
|
margin-top: 5px
|
||||||
|
font-size: 0.8rem
|
||||||
|
color: $border
|
||||||
|
opacity: 0.7
|
||||||
|
|
||||||
|
.waiting-room-content
|
||||||
|
display: flex
|
||||||
|
width: 100%
|
||||||
|
max-width: 1200px
|
||||||
|
gap: 30px
|
||||||
|
height: calc(100vh - 140px)
|
||||||
|
z-index: 2
|
||||||
|
position: relative
|
||||||
|
animation: float-up 1.2s ease-out
|
||||||
|
overflow: hidden
|
||||||
|
|
||||||
|
.users-panel,
|
||||||
|
.chat-panel
|
||||||
|
background: rgba(255, 255, 255, 0.08)
|
||||||
|
backdrop-filter: blur(10px)
|
||||||
|
border-radius: 20px
|
||||||
|
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2), 0 0 20px rgba(255, 255, 255, 0.1)
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
animation: card-pulse 6s infinite alternate ease-in-out
|
||||||
|
transition: all 0.3s ease
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||||
|
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3), 0 0 30px rgba(255, 255, 255, 0.15)
|
||||||
|
|
||||||
|
.users-panel
|
||||||
|
width: 30%
|
||||||
|
|
||||||
|
.users-list
|
||||||
|
flex-grow: 1
|
||||||
|
overflow-y: auto
|
||||||
|
padding: 15px
|
||||||
|
min-height: 0
|
||||||
|
|
||||||
|
&::-webkit-scrollbar
|
||||||
|
width: 6px
|
||||||
|
background: rgba(0, 0, 0, 0.2)
|
||||||
|
border-radius: 3px
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb
|
||||||
|
background: rgba(255, 255, 255, 0.2)
|
||||||
|
border-radius: 3px
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: rgba(255, 255, 255, 0.3)
|
||||||
|
|
||||||
|
.no-users
|
||||||
|
color: $border
|
||||||
|
text-align: center
|
||||||
|
padding: 20px 0
|
||||||
|
font-style: italic
|
||||||
|
|
||||||
|
.user-item
|
||||||
|
margin-bottom: 10px
|
||||||
|
padding: 12px 15px
|
||||||
|
background: rgba(255, 255, 255, 0.05)
|
||||||
|
border-radius: 10px
|
||||||
|
color: $white
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: space-between
|
||||||
|
transition: all 0.2s ease
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1)
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: rgba(255, 255, 255, 0.1)
|
||||||
|
transform: translateY(-2px)
|
||||||
|
|
||||||
|
&.host
|
||||||
|
border: 1px solid $yellow
|
||||||
|
box-shadow: 0 0 10px rgba($yellow, 0.3)
|
||||||
|
|
||||||
|
.host-badge
|
||||||
|
background: $yellow
|
||||||
|
color: #000
|
||||||
|
font-size: 0.75rem
|
||||||
|
padding: 3px 8px
|
||||||
|
border-radius: 10px
|
||||||
|
font-weight: bold
|
||||||
|
text-transform: uppercase
|
||||||
|
|
||||||
|
.game-controls
|
||||||
|
margin: 15px
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
gap: 10px
|
||||||
|
|
||||||
|
.start-game-button
|
||||||
|
padding: 15px
|
||||||
|
background: linear-gradient(135deg, $purple, $blue)
|
||||||
|
border: none
|
||||||
|
border-radius: 12px
|
||||||
|
color: $white
|
||||||
|
font-size: 1.1rem
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
gap: 10px
|
||||||
|
cursor: pointer
|
||||||
|
transition: all 0.3s ease
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3)
|
||||||
|
|
||||||
|
svg
|
||||||
|
font-size: 1.2rem
|
||||||
|
|
||||||
|
&:hover:not(.disabled)
|
||||||
|
transform: translateY(-3px)
|
||||||
|
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.4), 0 0 15px rgba($purple, 0.6)
|
||||||
|
background: linear-gradient(135deg, lighten($purple, 5%), lighten($blue, 5%))
|
||||||
|
|
||||||
|
&.disabled
|
||||||
|
background: linear-gradient(135deg, desaturate($purple, 40%), desaturate($blue, 40%))
|
||||||
|
opacity: 0.7
|
||||||
|
cursor: not-allowed
|
||||||
|
|
||||||
|
.start-hint
|
||||||
|
color: $border
|
||||||
|
text-align: center
|
||||||
|
font-size: 0.8rem
|
||||||
|
font-style: italic
|
||||||
|
|
||||||
|
.chat-panel
|
||||||
|
width: 70%
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
height: 100%
|
||||||
|
|
||||||
|
.chat-messages
|
||||||
|
flex-grow: 1
|
||||||
|
overflow-y: auto
|
||||||
|
padding: 15px
|
||||||
|
display: flex
|
||||||
|
flex-direction: column
|
||||||
|
min-height: 0
|
||||||
|
|
||||||
|
&::-webkit-scrollbar
|
||||||
|
width: 8px
|
||||||
|
background: rgba(0, 0, 0, 0.2)
|
||||||
|
border-radius: 4px
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb
|
||||||
|
background: rgba(255, 255, 255, 0.2)
|
||||||
|
border-radius: 4px
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: rgba(255, 255, 255, 0.3)
|
||||||
|
|
||||||
|
.message
|
||||||
|
margin-bottom: 10px
|
||||||
|
padding: 10px 15px
|
||||||
|
border-radius: 12px
|
||||||
|
background: rgba(255, 255, 255, 0.05)
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1)
|
||||||
|
color: $white
|
||||||
|
animation: message-fade-in 0.3s ease-out
|
||||||
|
width: fit-content
|
||||||
|
max-width: 80%
|
||||||
|
align-self: flex-start
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
background: rgba(255, 255, 255, 0.1)
|
||||||
|
|
||||||
|
.message-sender
|
||||||
|
font-weight: bold
|
||||||
|
color: $yellow
|
||||||
|
margin-right: 5px
|
||||||
|
|
||||||
|
.message-text
|
||||||
|
color: $white
|
||||||
|
|
||||||
|
&.system-message
|
||||||
|
background: rgba(0, 0, 0, 0.2)
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.05)
|
||||||
|
color: $border
|
||||||
|
padding: 8px 12px
|
||||||
|
font-style: italic
|
||||||
|
align-self: center
|
||||||
|
|
||||||
|
.message-text.system
|
||||||
|
color: $border
|
||||||
|
font-style: italic
|
||||||
|
|
||||||
|
.chat-input
|
||||||
|
display: flex
|
||||||
|
padding: 15px
|
||||||
|
gap: 10px
|
||||||
|
background: rgba(30, 30, 30, 0.5)
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.1)
|
||||||
|
border-radius: 0 0 20px 20px
|
||||||
|
|
||||||
|
input
|
||||||
|
flex: 1
|
||||||
|
padding: 12px 15px
|
||||||
|
background: rgba(0, 0, 0, 0.3)
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1)
|
||||||
|
border-radius: 8px
|
||||||
|
color: $white
|
||||||
|
outline: none
|
||||||
|
transition: all 0.2s ease
|
||||||
|
|
||||||
|
&:focus
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3)
|
||||||
|
box-shadow: 0 0 15px rgba(255, 255, 255, 0.1)
|
||||||
|
|
||||||
|
button
|
||||||
|
padding: 12px 20px
|
||||||
|
background: linear-gradient(135deg, $purple, $blue)
|
||||||
|
border: none
|
||||||
|
border-radius: 8px
|
||||||
|
color: $white
|
||||||
|
cursor: pointer
|
||||||
|
transition: all 0.3s ease
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
transform: translateY(-2px)
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.4), 0 0 10px rgba($purple, 0.5)
|
||||||
|
|
||||||
|
.panel-header
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
gap: 10px
|
||||||
|
padding: 15px
|
||||||
|
background: rgba(30, 30, 30, 0.5)
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1)
|
||||||
|
border-radius: 20px 20px 0 0
|
||||||
|
|
||||||
|
svg
|
||||||
|
color: $white
|
||||||
|
font-size: 1.4rem
|
||||||
|
|
||||||
|
h2
|
||||||
|
color: $white
|
||||||
|
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)
|
||||||
|
50%
|
||||||
|
transform: scale(1.3)
|
||||||
|
100%
|
||||||
|
transform: scale(1)
|
||||||
|
|
||||||
|
@keyframes title-shimmer
|
||||||
|
0%
|
||||||
|
background-position: 0% 50%
|
||||||
|
50%
|
||||||
|
background-position: 100% 50%
|
||||||
|
100%
|
||||||
|
background-position: 0% 50%
|
||||||
|
|
||||||
|
@keyframes message-fade-in
|
||||||
|
0%
|
||||||
|
opacity: 0
|
||||||
|
transform: translateY(5px)
|
||||||
|
100%
|
||||||
|
opacity: 1
|
||||||
|
transform: translateY(0)
|
||||||
|
|
||||||
|
@keyframes card-pulse
|
||||||
|
0%, 100%
|
||||||
|
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2), 0 0 20px rgba(255, 255, 255, 0.1)
|
||||||
|
50%
|
||||||
|
box-shadow: 0 8px 40px rgba(0, 0, 0, 0.3), 0 0 30px rgba(255, 255, 255, 0.2)
|
||||||
|
|
||||||
|
@keyframes float-up
|
||||||
|
0%
|
||||||
|
opacity: 0
|
||||||
|
transform: translateY(20px)
|
||||||
|
100%
|
||||||
|
opacity: 1
|
||||||
|
transform: translateY(0)
|
||||||
|
|
||||||
|
@keyframes page-fade-in
|
||||||
|
0%
|
||||||
|
opacity: 0
|
||||||
|
100%
|
||||||
|
opacity: 1
|
112
client/src/services/youtubeService.js
Normal file
112
client/src/services/youtubeService.js
Normal 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;
|
||||||
|
}
|
||||||
|
};
|
@ -13,6 +13,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
|
"googleapis": "^146.0.0",
|
||||||
"socket.io": "^4.8.1"
|
"socket.io": "^4.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
209
pnpm-lock.yaml
generated
209
pnpm-lock.yaml
generated
@ -11,6 +11,9 @@ importers:
|
|||||||
express:
|
express:
|
||||||
specifier: ^4.21.2
|
specifier: ^4.21.2
|
||||||
version: 4.21.2
|
version: 4.21.2
|
||||||
|
googleapis:
|
||||||
|
specifier: ^146.0.0
|
||||||
|
version: 146.0.0
|
||||||
socket.io:
|
socket.io:
|
||||||
specifier: ^4.8.1
|
specifier: ^4.8.1
|
||||||
version: 4.8.1
|
version: 4.8.1
|
||||||
@ -37,6 +40,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
|
|
||||||
|
agent-base@7.1.3:
|
||||||
|
resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
ansi-regex@5.0.1:
|
ansi-regex@5.0.1:
|
||||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -55,10 +62,16 @@ packages:
|
|||||||
balanced-match@1.0.2:
|
balanced-match@1.0.2:
|
||||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||||
|
|
||||||
|
base64-js@1.5.1:
|
||||||
|
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
|
||||||
|
|
||||||
base64id@2.0.0:
|
base64id@2.0.0:
|
||||||
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
|
||||||
engines: {node: ^4.5.0 || >= 5.9}
|
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:
|
binary-extensions@2.3.0:
|
||||||
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -74,6 +87,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1:
|
||||||
|
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||||
|
|
||||||
bytes@3.1.2:
|
bytes@3.1.2:
|
||||||
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -165,6 +181,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||||
|
|
||||||
@ -214,6 +233,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==}
|
resolution: {integrity: sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==}
|
||||||
engines: {node: '>= 0.10.0'}
|
engines: {node: '>= 0.10.0'}
|
||||||
|
|
||||||
|
extend@3.0.2:
|
||||||
|
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -238,6 +260,14 @@ packages:
|
|||||||
function-bind@1.1.2:
|
function-bind@1.1.2:
|
||||||
resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
|
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:
|
get-caller-file@2.0.5:
|
||||||
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
|
||||||
engines: {node: 6.* || 8.* || >= 10.*}
|
engines: {node: 6.* || 8.* || >= 10.*}
|
||||||
@ -254,10 +284,30 @@ packages:
|
|||||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||||
engines: {node: '>= 6'}
|
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:
|
gopd@1.2.0:
|
||||||
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
|
gtoken@7.1.0:
|
||||||
|
resolution: {integrity: sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw==}
|
||||||
|
engines: {node: '>=14.0.0'}
|
||||||
|
|
||||||
has-flag@3.0.0:
|
has-flag@3.0.0:
|
||||||
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
|
resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==}
|
||||||
engines: {node: '>=4'}
|
engines: {node: '>=4'}
|
||||||
@ -278,6 +328,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
https-proxy-agent@7.0.6:
|
||||||
|
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||||
|
engines: {node: '>= 14'}
|
||||||
|
|
||||||
iconv-lite@0.4.24:
|
iconv-lite@0.4.24:
|
||||||
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -312,6 +366,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==}
|
||||||
engines: {node: '>=0.12.0'}
|
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:
|
lodash@4.17.21:
|
||||||
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
|
||||||
|
|
||||||
@ -356,6 +423,15 @@ packages:
|
|||||||
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
|
||||||
engines: {node: '>= 0.6'}
|
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:
|
nodemon@3.1.9:
|
||||||
resolution: {integrity: sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==}
|
resolution: {integrity: sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -511,6 +587,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==}
|
resolution: {integrity: sha512-r0eojU4bI8MnHr8c5bNo7lJDdI2qXlWWJk6a9EAFG7vbhTjElYhBVS3/miuE0uOuoLdb8Mc/rVfsmm6eo5o9GA==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
tr46@0.0.3:
|
||||||
|
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
|
||||||
|
|
||||||
tree-kill@1.2.2:
|
tree-kill@1.2.2:
|
||||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -532,14 +611,27 @@ packages:
|
|||||||
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
url-template@2.0.8:
|
||||||
|
resolution: {integrity: sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw==}
|
||||||
|
|
||||||
utils-merge@1.0.1:
|
utils-merge@1.0.1:
|
||||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||||
engines: {node: '>= 0.4.0'}
|
engines: {node: '>= 0.4.0'}
|
||||||
|
|
||||||
|
uuid@9.0.1:
|
||||||
|
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
vary@1.1.2:
|
vary@1.1.2:
|
||||||
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
|
||||||
engines: {node: '>= 0.8'}
|
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:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -585,6 +677,8 @@ snapshots:
|
|||||||
mime-types: 2.1.35
|
mime-types: 2.1.35
|
||||||
negotiator: 0.6.3
|
negotiator: 0.6.3
|
||||||
|
|
||||||
|
agent-base@7.1.3: {}
|
||||||
|
|
||||||
ansi-regex@5.0.1: {}
|
ansi-regex@5.0.1: {}
|
||||||
|
|
||||||
ansi-styles@4.3.0:
|
ansi-styles@4.3.0:
|
||||||
@ -600,8 +694,12 @@ snapshots:
|
|||||||
|
|
||||||
balanced-match@1.0.2: {}
|
balanced-match@1.0.2: {}
|
||||||
|
|
||||||
|
base64-js@1.5.1: {}
|
||||||
|
|
||||||
base64id@2.0.0: {}
|
base64id@2.0.0: {}
|
||||||
|
|
||||||
|
bignumber.js@9.1.2: {}
|
||||||
|
|
||||||
binary-extensions@2.3.0: {}
|
binary-extensions@2.3.0: {}
|
||||||
|
|
||||||
body-parser@1.20.3:
|
body-parser@1.20.3:
|
||||||
@ -630,6 +728,8 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
fill-range: 7.1.1
|
fill-range: 7.1.1
|
||||||
|
|
||||||
|
buffer-equal-constant-time@1.0.1: {}
|
||||||
|
|
||||||
bytes@3.1.2: {}
|
bytes@3.1.2: {}
|
||||||
|
|
||||||
call-bind-apply-helpers@1.0.2:
|
call-bind-apply-helpers@1.0.2:
|
||||||
@ -720,6 +820,10 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
gopd: 1.2.0
|
gopd: 1.2.0
|
||||||
|
|
||||||
|
ecdsa-sig-formatter@1.0.11:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
emoji-regex@8.0.0: {}
|
emoji-regex@8.0.0: {}
|
||||||
@ -796,6 +900,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
extend@3.0.2: {}
|
||||||
|
|
||||||
fill-range@7.1.1:
|
fill-range@7.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
@ -821,6 +927,26 @@ snapshots:
|
|||||||
|
|
||||||
function-bind@1.1.2: {}
|
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-caller-file@2.0.5: {}
|
||||||
|
|
||||||
get-intrinsic@1.3.0:
|
get-intrinsic@1.3.0:
|
||||||
@ -845,8 +971,50 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
is-glob: 4.0.3
|
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: {}
|
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@3.0.0: {}
|
||||||
|
|
||||||
has-flag@4.0.0: {}
|
has-flag@4.0.0: {}
|
||||||
@ -865,6 +1033,13 @@ snapshots:
|
|||||||
statuses: 2.0.1
|
statuses: 2.0.1
|
||||||
toidentifier: 1.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:
|
iconv-lite@0.4.24:
|
||||||
dependencies:
|
dependencies:
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
@ -889,6 +1064,23 @@ snapshots:
|
|||||||
|
|
||||||
is-number@7.0.0: {}
|
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: {}
|
lodash@4.17.21: {}
|
||||||
|
|
||||||
math-intrinsics@1.1.0: {}
|
math-intrinsics@1.1.0: {}
|
||||||
@ -917,6 +1109,10 @@ snapshots:
|
|||||||
|
|
||||||
negotiator@0.6.3: {}
|
negotiator@0.6.3: {}
|
||||||
|
|
||||||
|
node-fetch@2.7.0:
|
||||||
|
dependencies:
|
||||||
|
whatwg-url: 5.0.0
|
||||||
|
|
||||||
nodemon@3.1.9:
|
nodemon@3.1.9:
|
||||||
dependencies:
|
dependencies:
|
||||||
chokidar: 3.6.0
|
chokidar: 3.6.0
|
||||||
@ -1107,6 +1303,8 @@ snapshots:
|
|||||||
|
|
||||||
touch@3.1.1: {}
|
touch@3.1.1: {}
|
||||||
|
|
||||||
|
tr46@0.0.3: {}
|
||||||
|
|
||||||
tree-kill@1.2.2: {}
|
tree-kill@1.2.2: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
@ -1122,10 +1320,21 @@ snapshots:
|
|||||||
|
|
||||||
unpipe@1.0.0: {}
|
unpipe@1.0.0: {}
|
||||||
|
|
||||||
|
url-template@2.0.8: {}
|
||||||
|
|
||||||
utils-merge@1.0.1: {}
|
utils-merge@1.0.1: {}
|
||||||
|
|
||||||
|
uuid@9.0.1: {}
|
||||||
|
|
||||||
vary@1.1.2: {}
|
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:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
|
266
server/controller/game.js
Normal file
266
server/controller/game.js
Normal file
@ -0,0 +1,266 @@
|
|||||||
|
const roomController = require('./room');
|
||||||
|
const youtubeService = require('../services/youtubeService');
|
||||||
|
|
||||||
|
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',
|
||||||
|
roles: {},
|
||||||
|
selectedSong: null,
|
||||||
|
scores: {},
|
||||||
|
songOptions: [],
|
||||||
|
guessResults: {},
|
||||||
|
currentComposer: null,
|
||||||
|
roundStartTime: null,
|
||||||
|
phaseTimeLimit: {
|
||||||
|
composing: 60,
|
||||||
|
guessing: 10
|
||||||
|
},
|
||||||
|
lastFrequency: 440,
|
||||||
|
selectedPlaylist: selectedPlaylist,
|
||||||
|
};
|
||||||
|
|
||||||
|
const users = roomController.getRoomUsers(roomId);
|
||||||
|
users.forEach(user => {
|
||||||
|
gameStates[roomId].scores[user.id] = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return gameStates[roomId];
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_ROUNDS = 5;
|
||||||
|
|
||||||
|
const startNewRound = async (roomId) => {
|
||||||
|
const gameState = gameStates[roomId];
|
||||||
|
if (!gameState) return false;
|
||||||
|
|
||||||
|
const users = roomController.getRoomUsers(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();
|
||||||
|
gameState.roles = {};
|
||||||
|
|
||||||
|
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';
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const determineNextComposer = (roomId, gameState, users) => {
|
||||||
|
if (gameState.round === 1 || !gameState.currentComposer) {
|
||||||
|
return users[Math.floor(Math.random() * users.length)].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentIndex = users.findIndex(user => user.id === gameState.currentComposer);
|
||||||
|
|
||||||
|
if (currentIndex === -1) {
|
||||||
|
return users[Math.floor(Math.random() * users.length)].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return users[(currentIndex + 1) % users.length].id;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectSongAndOptions = async (gameState) => {
|
||||||
|
try {
|
||||||
|
console.log(`Fetching songs from playlist: ${gameState.selectedPlaylist}`);
|
||||||
|
const availableIds = await youtubeService.getAvailableSongIds(gameState.selectedPlaylist);
|
||||||
|
|
||||||
|
if (!availableIds || availableIds.length === 0) {
|
||||||
|
console.error("No song IDs available for selection");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
const gameState = gameStates[roomId];
|
||||||
|
if (!gameState || !gameState.roundStartTime) return 0;
|
||||||
|
|
||||||
|
const phaseDuration = gameState.phaseTimeLimit[gameState.phase] * 1000;
|
||||||
|
const timeElapsed = Date.now() - gameState.roundStartTime;
|
||||||
|
const timeRemaining = Math.max(0, phaseDuration - timeElapsed);
|
||||||
|
|
||||||
|
return Math.ceil(timeRemaining / 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const advancePhase = (roomId) => {
|
||||||
|
const gameState = gameStates[roomId];
|
||||||
|
if (!gameState) {
|
||||||
|
console.error(`Cannot advance phase: no game state for room ${roomId}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentPhase = gameState.phase;
|
||||||
|
console.log(`Advancing phase for room ${roomId} from ${currentPhase}`);
|
||||||
|
|
||||||
|
if (currentPhase === 'composing') {
|
||||||
|
gameState.phase = 'guessing';
|
||||||
|
gameState.roundStartTime = Date.now();
|
||||||
|
console.log(`Room ${roomId} advanced to guessing phase`);
|
||||||
|
return { phase: 'guessing' };
|
||||||
|
}
|
||||||
|
else if (currentPhase === 'guessing') {
|
||||||
|
gameState.phase = 'results';
|
||||||
|
console.log(`Room ${roomId} advanced to results phase`);
|
||||||
|
return { phase: 'results' };
|
||||||
|
}
|
||||||
|
else if (currentPhase === 'results') {
|
||||||
|
console.log(`Room ${roomId} starting new round from results phase`);
|
||||||
|
return startNewRound(roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn(`Cannot advance from unknown phase "${currentPhase}" in room ${roomId}`);
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFrequency = (roomId, frequency) => {
|
||||||
|
if (!gameStates[roomId]) return false;
|
||||||
|
gameStates[roomId].lastFrequency = frequency;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitGuess = (roomId, userId, songId) => {
|
||||||
|
const gameState = gameStates[roomId];
|
||||||
|
if (!gameState || gameState.phase !== 'guessing' || gameState.roles[userId] !== 'guesser') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCorrect = gameState.selectedSong.id === parseInt(songId);
|
||||||
|
const points = isCorrect ? 10 : 0;
|
||||||
|
|
||||||
|
gameState.guessResults[userId] = {songId, isCorrect, points};
|
||||||
|
|
||||||
|
if (isCorrect) {
|
||||||
|
gameState.scores[userId] = (gameState.scores[userId] || 0) + points;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCorrect,
|
||||||
|
correctSong: gameState.selectedSong,
|
||||||
|
points
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentFrequency = (roomId) => gameStates[roomId]?.lastFrequency || 440;
|
||||||
|
const getRoles = (roomId) => gameStates[roomId]?.roles || {};
|
||||||
|
const getUserRole = (roomId, userId) => gameStates[roomId]?.roles[userId] || null;
|
||||||
|
const getSongOptions = (roomId) => gameStates[roomId]?.songOptions || [];
|
||||||
|
const getSelectedSong = (roomId) => gameStates[roomId]?.selectedSong || null;
|
||||||
|
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,
|
||||||
|
playerNames: playerNames
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanupGameState = (roomId) => {
|
||||||
|
delete gameStates[roomId];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentComposer = (roomId) => gameStates[roomId]?.currentComposer || null;
|
||||||
|
|
||||||
|
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,
|
||||||
|
getTimeRemaining,
|
||||||
|
advancePhase,
|
||||||
|
updateFrequency,
|
||||||
|
getCurrentFrequency,
|
||||||
|
submitGuess,
|
||||||
|
getRoundResults,
|
||||||
|
getRoles,
|
||||||
|
getUserRole,
|
||||||
|
getSongOptions,
|
||||||
|
getSelectedSong,
|
||||||
|
cleanupGameState,
|
||||||
|
getCurrentComposer,
|
||||||
|
getGameState,
|
||||||
|
getFinalScores,
|
||||||
|
MAX_ROUNDS
|
||||||
|
};
|
@ -1,15 +1,52 @@
|
|||||||
|
const youtubeService = require('../services/youtubeService');
|
||||||
|
|
||||||
|
let cleanupGameState;
|
||||||
|
|
||||||
|
const setCleanupGameState = (cleanupFunction) => {
|
||||||
|
cleanupGameState = cleanupFunction;
|
||||||
|
};
|
||||||
|
|
||||||
let rooms = {};
|
let rooms = {};
|
||||||
|
|
||||||
module.exports.roomExists = (roomId) => rooms[roomId] !== undefined;
|
module.exports.roomExists = (roomId) => rooms[roomId] !== undefined;
|
||||||
|
|
||||||
module.exports.connectUserToRoom = (roomId, user) => {
|
module.exports.isRoomOpen = (roomId) => rooms[roomId] && rooms[roomId].state === 'waiting';
|
||||||
|
|
||||||
|
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]) {
|
if (rooms[roomId]) {
|
||||||
rooms[roomId].members.push({...user, creator: false});
|
rooms[roomId].members.push({...user, creator: false});
|
||||||
} else {
|
} else {
|
||||||
rooms[roomId] = {members: [{...user, creator: true}], settings: {}};
|
await initializeRoom(roomId, user);
|
||||||
}
|
}
|
||||||
console.log(JSON.stringify(rooms));
|
console.log(`User ${user.name} connected to room ${roomId}`);
|
||||||
}
|
};
|
||||||
|
|
||||||
module.exports.getUserRoom = (userId) => {
|
module.exports.getUserRoom = (userId) => {
|
||||||
for (const roomId in rooms) {
|
for (const roomId in rooms) {
|
||||||
@ -18,6 +55,48 @@ module.exports.getUserRoom = (userId) => {
|
|||||||
|
|
||||||
if (memberIndex !== -1) return roomId;
|
if (memberIndex !== -1) return roomId;
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.getRoomUsers = (roomId) => {
|
||||||
|
if (rooms[roomId]) {
|
||||||
|
return rooms[roomId].members;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.getRoomCreator = (roomId) => {
|
||||||
|
if (rooms[roomId]) {
|
||||||
|
return rooms[roomId].members.find(member => member.creator);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.isUserHost = (userId) => {
|
||||||
|
for (const roomId in rooms) {
|
||||||
|
const room = rooms[roomId];
|
||||||
|
const member = room.members.find(m => m.id === userId);
|
||||||
|
if (member && member.creator) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.startGame = (roomId) => {
|
||||||
|
if (rooms[roomId]) {
|
||||||
|
rooms[roomId].state = 'playing';
|
||||||
|
console.log(`Game started in room ${roomId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.getRoomState = (roomId) => {
|
||||||
|
if (rooms[roomId]) {
|
||||||
|
return rooms[roomId].state;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.disconnectUser = (userId) => {
|
module.exports.disconnectUser = (userId) => {
|
||||||
@ -26,14 +105,124 @@ module.exports.disconnectUser = (userId) => {
|
|||||||
const memberIndex = room.members.findIndex(member => member.id === userId);
|
const memberIndex = room.members.findIndex(member => member.id === userId);
|
||||||
|
|
||||||
if (memberIndex !== -1) {
|
if (memberIndex !== -1) {
|
||||||
if (room.members[memberIndex].creator) {
|
if (room.members[memberIndex].creator && room.members.length > 1) {
|
||||||
if (room.members.length > 1) room.members[1].creator = true;
|
room.members[1].creator = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
room.members.splice(memberIndex, 1);
|
room.members.splice(memberIndex, 1);
|
||||||
if (room.members.length === 0) delete rooms[roomId];
|
console.log(`User ${userId} disconnected from room ${roomId}`);
|
||||||
|
|
||||||
|
if (room.members.length === 0) {
|
||||||
|
if (cleanupGameState) {
|
||||||
|
cleanupGameState(roomId);
|
||||||
|
}
|
||||||
|
delete rooms[roomId];
|
||||||
|
console.log(`Room ${roomId} deleted because it's empty`);
|
||||||
|
}
|
||||||
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
module.exports.validateRoomMembers = (io, roomId) => {
|
||||||
|
if (!rooms[roomId]) return [];
|
||||||
|
|
||||||
|
const validMembers = [];
|
||||||
|
const connectedSockets = io.sockets.adapter.rooms.get(roomId) || new Set();
|
||||||
|
|
||||||
|
rooms[roomId].members = rooms[roomId].members.filter(member => {
|
||||||
|
const stillConnected = connectedSockets.has(member.id);
|
||||||
|
if (stillConnected) {
|
||||||
|
validMembers.push(member);
|
||||||
|
} else {
|
||||||
|
console.log(`Removing disconnected user ${member.name} (${member.id}) from room ${roomId}`);
|
||||||
|
}
|
||||||
|
return stillConnected;
|
||||||
|
});
|
||||||
|
|
||||||
|
return validMembers;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports.setCleanupGameState = setCleanupGameState;
|
||||||
|
|
||||||
|
module.exports.getUserName = (userId) => {
|
||||||
|
for (const roomId in rooms) {
|
||||||
|
const room = rooms[roomId];
|
||||||
|
const member = room.members.find(m => m.id === userId);
|
||||||
|
if (member) {
|
||||||
|
return member.name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
};
|
@ -1,36 +1,386 @@
|
|||||||
const {connectUserToRoom, roomExists, disconnectUser, getUserRoom} = require("../controller/room");
|
const roomController = require("../controller/room");
|
||||||
|
const gameController = require("../controller/game");
|
||||||
|
const youtubeService = require('../services/youtubeService');
|
||||||
|
|
||||||
module.exports = (socket) => {
|
module.exports = (io) => (socket) => {
|
||||||
let currentRoomId = null;
|
let currentRoomId = null;
|
||||||
|
let currentUser = null;
|
||||||
|
let phaseTimers = {};
|
||||||
|
|
||||||
|
const clearRoomTimers = (roomId) => {
|
||||||
|
if (phaseTimers[roomId]) {
|
||||||
|
console.log(`Clearing timer for room ${roomId}`);
|
||||||
|
clearTimeout(phaseTimers[roomId]);
|
||||||
|
delete phaseTimers[roomId];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startPhaseTimer = (roomId) => {
|
||||||
|
clearRoomTimers(roomId);
|
||||||
|
|
||||||
|
const gameState = gameController.getGameState(roomId);
|
||||||
|
if (!gameState) {
|
||||||
|
console.error(`Cannot start timer: no game state for room ${roomId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeRemaining = gameController.getTimeRemaining(roomId) * 1000;
|
||||||
|
console.log(`Starting ${gameState.phase} phase timer for room ${roomId} with ${timeRemaining}ms`);
|
||||||
|
|
||||||
|
phaseTimers[roomId] = setTimeout(() => {
|
||||||
|
console.log(`Timer expired for room ${roomId}, advancing phase from ${gameState.phase}`);
|
||||||
|
const advanced = gameController.advancePhase(roomId);
|
||||||
|
|
||||||
|
if (!advanced) {
|
||||||
|
console.log(`Failed to advance phase for room ${roomId}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof advanced === 'boolean') {
|
||||||
|
handleRoundStart(roomId);
|
||||||
|
} else {
|
||||||
|
const newPhase = gameController.getGameState(roomId).phase;
|
||||||
|
console.log(`Advanced to ${newPhase} phase in room ${roomId}`);
|
||||||
|
|
||||||
|
if (newPhase === 'guessing') {
|
||||||
|
handleGuessingPhaseStart(roomId);
|
||||||
|
} else if (newPhase === 'results') {
|
||||||
|
handleResultsPhaseStart(roomId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, timeRemaining);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRoundStart = (roomId) => {
|
||||||
|
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,
|
||||||
|
phase: 'composing'
|
||||||
|
});
|
||||||
|
|
||||||
|
startPhaseTimer(roomId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGuessingPhaseStart = (roomId) => {
|
||||||
|
const gameState = gameController.getGameState(roomId);
|
||||||
|
if (!gameState) return;
|
||||||
|
|
||||||
|
const roles = gameController.getRoles(roomId);
|
||||||
|
const songOptions = gameController.getSongOptions(roomId);
|
||||||
|
const timeLeft = gameController.getTimeRemaining(roomId);
|
||||||
|
|
||||||
|
console.log(`Starting guessing phase for room ${roomId} with ${Object.keys(roles).length} players`);
|
||||||
|
|
||||||
|
io.to(roomId).emit('phase-changed', {
|
||||||
|
phase: 'guessing',
|
||||||
|
timeRemaining: timeLeft,
|
||||||
|
message: 'Time to submit your final answer!'
|
||||||
|
});
|
||||||
|
|
||||||
|
Object.entries(roles).forEach(([userId, role]) => {
|
||||||
|
if (role === 'guesser') {
|
||||||
|
io.to(userId).emit('guessing-phase-started', {
|
||||||
|
timeRemaining: timeLeft,
|
||||||
|
songOptions
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
startPhaseTimer(roomId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResultsPhaseStart = (roomId) => {
|
||||||
|
const results = gameController.getRoundResults(roomId);
|
||||||
|
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", () => {
|
socket.on("disconnect", () => {
|
||||||
const roomId = getUserRoom(socket.id);
|
const roomId = roomController.getUserRoom(socket.id);
|
||||||
if (roomId) socket.to(roomId).emit("user-disconnected", socket.id);
|
if (roomId) {
|
||||||
|
socket.to(roomId).emit("user-disconnected", socket.id);
|
||||||
disconnectUser(socket.id);
|
roomController.disconnectUser(socket.id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("join-room", ({roomId, name}) => {
|
socket.on("join-room", async ({roomId, name}) => {
|
||||||
if (currentRoomId) return socket.emit("already-in-room", currentRoomId);
|
if (currentRoomId) return socket.emit("already-in-room", currentRoomId);
|
||||||
|
|
||||||
|
roomId = roomId.toString().toUpperCase();
|
||||||
|
|
||||||
if (roomExists(roomId.toString())) {
|
if (roomController.roomExists(roomId)) {
|
||||||
connectUserToRoom(roomId, {id: socket.id, name: name.toString()});
|
if (!roomController.isRoomOpen(roomId)) {
|
||||||
|
return socket.emit("room-closed", roomId);
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUser = {id: socket.id, name: name.toString()};
|
||||||
|
await roomController.connectUserToRoom(roomId, currentUser);
|
||||||
socket.join(roomId);
|
socket.join(roomId);
|
||||||
socket.to(roomId).emit("user-connected", {name: name.toString(), id: socket.id});
|
|
||||||
|
const users = roomController.getRoomUsers(roomId);
|
||||||
|
const votes = roomController.getPlaylistVotes(roomId);
|
||||||
|
const availablePlaylists = roomController.getAvailablePlaylists(roomId);
|
||||||
|
|
||||||
socket.emit("room-joined", 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;
|
currentRoomId = roomId;
|
||||||
} else {
|
} else {
|
||||||
socket.emit("room-not-found", roomId.toString());
|
socket.emit("room-not-found", roomId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("create-room", ({name}) => {
|
socket.on("create-room", ({name}) => {
|
||||||
if (!name) return socket.emit("room-name-required");
|
if (!name) return socket.emit("room-name-required");
|
||||||
const roomId = Math.random().toString(36).substring(7);
|
|
||||||
connectUserToRoom(roomId, {id: socket.id, name: name?.toString()});
|
const roomId = Math.random().toString(36).substring(7).toUpperCase();
|
||||||
|
currentUser = {id: socket.id, name: name?.toString(), creator: true};
|
||||||
|
roomController.connectUserToRoom(roomId, currentUser);
|
||||||
socket.join(roomId);
|
socket.join(roomId);
|
||||||
socket.emit("room-created", roomId);
|
socket.emit("room-created", roomId);
|
||||||
currentRoomId = roomId;
|
currentRoomId = roomId;
|
||||||
});
|
});
|
||||||
|
|
||||||
}
|
socket.on("start-game", async () => {
|
||||||
|
const roomId = roomController.getUserRoom(socket.id);
|
||||||
|
if (!roomId || !roomController.isUserHost(socket.id)) {
|
||||||
|
return socket.emit("not-authorized");
|
||||||
|
}
|
||||||
|
|
||||||
|
roomController.validateRoomMembers(io, roomId);
|
||||||
|
const users = roomController.getRoomUsers(roomId);
|
||||||
|
|
||||||
|
if (users.length < 2) {
|
||||||
|
return socket.emit("error", { message: "At least 2 players are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!roomController.startGame(roomId)) return;
|
||||||
|
gameController.initializeGameState(roomId);
|
||||||
|
|
||||||
|
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) 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", () => {
|
||||||
|
if (currentUser) socket.emit("user-info", currentUser);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("get-room-users", () => {
|
||||||
|
const roomId = roomController.getUserRoom(socket.id);
|
||||||
|
if (roomId) {
|
||||||
|
const users = roomController.getRoomUsers(roomId);
|
||||||
|
socket.emit("room-users", users);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("check-host-status", () => {
|
||||||
|
socket.emit("host-status", { isHost: roomController.isUserHost(socket.id) });
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("get-room-code", () => {
|
||||||
|
if (currentRoomId) socket.emit("room-code", currentRoomId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("submit-frequency", ({ frequency, isPlaying }) => {
|
||||||
|
const roomId = roomController.getUserRoom(socket.id);
|
||||||
|
if (!roomId) return;
|
||||||
|
|
||||||
|
const userRole = gameController.getUserRole(roomId, socket.id);
|
||||||
|
if (userRole !== 'composer') return;
|
||||||
|
|
||||||
|
if (gameController.updateFrequency(roomId, frequency)) {
|
||||||
|
socket.to(roomId).emit("frequency-update", { frequency, isPlaying });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("submit-guess", ({ songId }) => {
|
||||||
|
const roomId = roomController.getUserRoom(socket.id);
|
||||||
|
if (!roomId) return;
|
||||||
|
|
||||||
|
console.log(`User ${socket.id} submitted guess: Song ID ${songId}`);
|
||||||
|
|
||||||
|
const gamePhase = gameController.getGameState(roomId)?.phase;
|
||||||
|
if (gamePhase !== 'guessing') {
|
||||||
|
console.log(`Ignoring guess: room ${roomId} is in ${gamePhase} phase, not guessing`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = gameController.submitGuess(roomId, socket.id, songId);
|
||||||
|
if (result) {
|
||||||
|
console.log(`Guess result for ${socket.id}:`, result);
|
||||||
|
socket.emit("guess-result", result);
|
||||||
|
|
||||||
|
const currentComposer = gameController.getCurrentComposer(roomId);
|
||||||
|
if (currentComposer) {
|
||||||
|
const guesserName = roomController.getUserName(socket.id) || "Someone";
|
||||||
|
io.to(currentComposer).emit("player-guessed", {
|
||||||
|
guesserName,
|
||||||
|
isCorrect: result.isCorrect
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("next-round", async () => {
|
||||||
|
const roomId = roomController.getUserRoom(socket.id);
|
||||||
|
if (!roomId || !roomController.isUserHost(socket.id)) return;
|
||||||
|
|
||||||
|
roomController.validateRoomMembers(io, roomId);
|
||||||
|
const users = roomController.getRoomUsers(roomId);
|
||||||
|
|
||||||
|
if (users.length < 2) {
|
||||||
|
return socket.emit("error", { message: "At least 2 players are required" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await handleNextRound(roomId);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on("get-current-frequency", () => {
|
||||||
|
const roomId = roomController.getUserRoom(socket.id);
|
||||||
|
if (roomId) {
|
||||||
|
socket.emit("current-frequency", {
|
||||||
|
frequency: gameController.getCurrentFrequency(roomId)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
@ -5,16 +5,44 @@ const app = express();
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
app.use(express.static(path.join(__dirname, './dist')));
|
app.use(express.static(path.join(__dirname, './dist')));
|
||||||
|
app.disable("x-powered-by");
|
||||||
|
|
||||||
app.get('*', (req, res) => res.sendFile(path.join(__dirname, './dist', 'index.html')));
|
app.get('*', (req, res) => res.sendFile(path.join(__dirname, './dist', 'index.html')));
|
||||||
|
|
||||||
app.disable("x-powered-by");
|
|
||||||
|
|
||||||
const server = http.createServer(app);
|
const server = http.createServer(app);
|
||||||
const io = new Server(server, {cors: {origin: "*"}});
|
|
||||||
|
|
||||||
io.on("connection", require("./handler/connection"));
|
const io = new Server(server, {
|
||||||
|
cors: {origin: "*"},
|
||||||
|
pingTimeout: 30000,
|
||||||
|
pingInterval: 10000
|
||||||
|
});
|
||||||
|
|
||||||
server.listen(5287, () => {
|
const roomController = require('./controller/room');
|
||||||
console.log("Server running on port 5287");
|
const gameController = require('./controller/game');
|
||||||
|
roomController.setCleanupGameState(gameController.cleanupGameState);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 5287;
|
||||||
|
server.listen(PORT, () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('Server shutting down...');
|
||||||
|
server.close(() => {
|
||||||
|
console.log('Server closed');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
});
|
});
|
227
server/services/youtubeService.js
Normal file
227
server/services/youtubeService.js
Normal 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
|
||||||
|
};
|
Reference in New Issue
Block a user