Compare commits
8 Commits
f00ca9ba7c
...
2fb5af4171
Author | SHA1 | Date | |
---|---|---|---|
2fb5af4171 | |||
33a1715adc | |||
e633a5e004 | |||
c31b7e35b6 | |||
770cad27b0 | |||
6750b00f46 | |||
a5579cc474 | |||
206988f4b7 |
@ -1,99 +0,0 @@
|
||||
import {createContext, useState, useEffect, useCallback, useRef} from 'react';
|
||||
import {io} from 'socket.io-client';
|
||||
|
||||
export const SocketContext = createContext();
|
||||
|
||||
export const SocketProvider = ({children}) => {
|
||||
const [socket, setSocket] = useState(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const connectAttempts = useRef(0);
|
||||
const maxAttempts = 3;
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (socket) return;
|
||||
|
||||
const serverUrl = process.env.NODE_ENV === 'production' ? window.location.origin : 'http://localhost:5287';
|
||||
|
||||
try {
|
||||
const newSocket = io(serverUrl, {
|
||||
reconnectionAttempts: 3,
|
||||
timeout: 10000,
|
||||
reconnectionDelay: 1000
|
||||
});
|
||||
|
||||
newSocket.on('connect', () => {
|
||||
console.log('Socket connected with ID:', newSocket.id);
|
||||
setConnected(true);
|
||||
connectAttempts.current = 0;
|
||||
});
|
||||
|
||||
newSocket.on('disconnect', (reason) => {
|
||||
console.log('Socket disconnected:', reason);
|
||||
setConnected(false);
|
||||
});
|
||||
|
||||
newSocket.on('connect_error', (error) => {
|
||||
console.error('Connection error:', error);
|
||||
connectAttempts.current += 1;
|
||||
|
||||
if (connectAttempts.current >= maxAttempts) {
|
||||
console.error('Max connection attempts reached, giving up');
|
||||
newSocket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
setSocket(newSocket);
|
||||
} catch (err) {
|
||||
console.error('Error creating socket connection:', err);
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const disconnect = useCallback(() => {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
setSocket(null);
|
||||
setConnected(false);
|
||||
console.log('Socket manually disconnected');
|
||||
}
|
||||
}, [socket]);
|
||||
|
||||
const send = useCallback((event, data) => {
|
||||
if (!socket || !connected) {
|
||||
console.warn(`Cannot send event "${event}": socket not connected`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
socket.emit(event, data);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`Error sending event "${event}":`, err);
|
||||
return false;
|
||||
}
|
||||
}, [socket, connected]);
|
||||
|
||||
const on = useCallback((event, callback) => {
|
||||
if (!socket) {
|
||||
console.warn(`Cannot listen for event "${event}": socket not initialized`);
|
||||
return () => {
|
||||
};
|
||||
}
|
||||
|
||||
socket.on(event, callback);
|
||||
return () => socket.off(event, callback);
|
||||
}, [socket]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (socket) {
|
||||
socket.disconnect();
|
||||
}
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
return (
|
||||
<SocketContext.Provider value={{socket, connected, connect, disconnect, send, on}}>
|
||||
{children}
|
||||
</SocketContext.Provider>
|
||||
);
|
||||
};
|
@ -1 +0,0 @@
|
||||
export * from "./SocketContext";
|
@ -11,5 +11,6 @@ $pink: #ff6bb3
|
||||
$blue: #4d9dff
|
||||
$purple: #9c6bff
|
||||
$cyan: #6bffea
|
||||
$orange: #ff9b6b
|
||||
$yellow: #ffde6b
|
||||
$mint-green: #85ffbd
|
119
client/src/components/YouTubePlayer/YouTubePlayer.jsx
Normal file
119
client/src/components/YouTubePlayer/YouTubePlayer.jsx
Normal file
@ -0,0 +1,119 @@
|
||||
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");
|
||||
|
||||
// Start playback if needed
|
||||
if (autoplay) {
|
||||
event.target.seekTo(startTime || 0);
|
||||
event.target.playVideo();
|
||||
}
|
||||
|
||||
setIsLoaded(true);
|
||||
onReady();
|
||||
},
|
||||
'onError': (event) => {
|
||||
console.error("YouTube player error:", event);
|
||||
// Remove error handling
|
||||
}
|
||||
}
|
||||
});
|
||||
} 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
|
@ -3,7 +3,19 @@ import {SocketContext} from "@/common/contexts/SocketContext";
|
||||
import {useContext, useState, useEffect, useRef, useCallback} from "react";
|
||||
import MusicSlider from "@/pages/Game/components/MusicSlider";
|
||||
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
|
||||
import {faMessage, faMusic, faHeadphones, faClock, faCrown, faPaperPlane} from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faMessage,
|
||||
faMusic,
|
||||
faHeadphones,
|
||||
faClock,
|
||||
faCrown,
|
||||
faPaperPlane,
|
||||
faCheckCircle,
|
||||
faVolumeUp,
|
||||
faStepForward
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { fetchPlaylistSongs } from "@/services/youtubeService.js";
|
||||
import YouTubePlayer from "../../components/YouTubePlayer/YouTubePlayer";
|
||||
|
||||
export const Game = () => {
|
||||
const {send, on, socket, connected, connect} = useContext(SocketContext);
|
||||
@ -34,9 +46,14 @@ export const Game = () => {
|
||||
const messageEndRef = useRef(null);
|
||||
const timerIntervalRef = useRef(null);
|
||||
|
||||
const [allSongs, setAllSongs] = useState([]);
|
||||
const [songsLoading, setSongsLoading] = useState(false);
|
||||
const [composerIsPlaying, setComposerIsPlaying] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!connected) return;
|
||||
|
||||
|
||||
const eventHandlers = {
|
||||
"roles-assigned": (roles) => {
|
||||
const myRole = roles[socket?.id];
|
||||
@ -68,7 +85,17 @@ export const Game = () => {
|
||||
},
|
||||
"round-results": (results) => {
|
||||
setPhase("results");
|
||||
setScores(results.scores);
|
||||
const scoresWithNames = {};
|
||||
Object.entries(results.scores).forEach(([userId, score]) => {
|
||||
const user = connectedUsers.find(u => u.id === userId);
|
||||
scoresWithNames[userId] = {
|
||||
score: score,
|
||||
name: user?.name || results.playerNames?.[userId] || "Player"
|
||||
};
|
||||
});
|
||||
|
||||
setScores(scoresWithNames);
|
||||
|
||||
if (!currentSong) {
|
||||
setCurrentSong(results.selectedSong);
|
||||
}
|
||||
@ -103,6 +130,7 @@ export const Game = () => {
|
||||
if (phase === "composing") {
|
||||
console.log("Received frequency update:", data.frequency);
|
||||
setFrequency(data.frequency);
|
||||
setComposerIsPlaying(data.isPlaying); // Make sure isPlaying is handled
|
||||
}
|
||||
},
|
||||
"phase-changed": (data) => {
|
||||
@ -111,7 +139,20 @@ export const Game = () => {
|
||||
if (data.timeRemaining) {
|
||||
setTimeLeft(data.timeRemaining);
|
||||
}
|
||||
}
|
||||
},
|
||||
"chat-message-confirmation": (msg) => {
|
||||
setMessages(prev => prev.map(prevMsg => {
|
||||
if (!prevMsg.system && prevMsg.text === msg.text && prevMsg.sender !== msg.sender) {
|
||||
console.log(`Updating message sender from "${prevMsg.sender}" to "${msg.sender}"`);
|
||||
return { ...prevMsg, sender: msg.sender };
|
||||
}
|
||||
return prevMsg;
|
||||
}));
|
||||
},
|
||||
"song-options": (data) => {
|
||||
console.log("Received song options early:", data);
|
||||
setSongOptions(data.songOptions || []);
|
||||
},
|
||||
};
|
||||
|
||||
const cleanupFunctions = Object.entries(eventHandlers).map(
|
||||
@ -124,7 +165,7 @@ export const Game = () => {
|
||||
}
|
||||
|
||||
return () => cleanupFunctions.forEach(cleanup => cleanup());
|
||||
}, [socket, on, send, role, currentSong, phase, connected]);
|
||||
}, [socket, on, send, role, currentSong, phase, connected, connectedUsers]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timerIntervalRef.current) clearInterval(timerIntervalRef.current);
|
||||
@ -144,21 +185,69 @@ export const Game = () => {
|
||||
messageEndRef.current?.scrollIntoView({behavior: "smooth"});
|
||||
}, [messages]);
|
||||
|
||||
const handleFrequencyChange = useCallback((newFrequency) => {
|
||||
useEffect(() => {
|
||||
if (!connected || !socket) return;
|
||||
|
||||
const loadSongs = async () => {
|
||||
try {
|
||||
setSongsLoading(true);
|
||||
console.log("Fetching songs with active socket:", socket?.id);
|
||||
const songs = await fetchPlaylistSongs(socket);
|
||||
console.log(`Successfully loaded ${songs.length} songs`);
|
||||
setAllSongs(songs);
|
||||
} catch (error) {
|
||||
console.error("Error loading songs:", error);
|
||||
} finally {
|
||||
setSongsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSongs();
|
||||
}, [socket, connected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allSongs.length || !currentSong || !currentSong.id) return;
|
||||
|
||||
const enhancedSong = allSongs.find(song => song.id === currentSong.id);
|
||||
if (enhancedSong && enhancedSong !== currentSong) {
|
||||
setCurrentSong(enhancedSong);
|
||||
}
|
||||
}, [allSongs, currentSong]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allSongs.length || !songOptions.length) return;
|
||||
|
||||
const enhancedOptions = songOptions.map(option => {
|
||||
if (option.id && !option.title) {
|
||||
return allSongs.find(song => song.id === option.id) || option;
|
||||
}
|
||||
return option;
|
||||
});
|
||||
|
||||
if (JSON.stringify(enhancedOptions) !== JSON.stringify(songOptions)) {
|
||||
setSongOptions(enhancedOptions);
|
||||
}
|
||||
}, [allSongs, songOptions]);
|
||||
|
||||
const handleFrequencyChange = useCallback((newFrequency, isPlaying) => {
|
||||
setFrequency(newFrequency);
|
||||
setComposerIsPlaying(isPlaying);
|
||||
if (role === "composer") {
|
||||
send("submit-frequency", { frequency: newFrequency });
|
||||
send("submit-frequency", { frequency: newFrequency, isPlaying });
|
||||
}
|
||||
}, [role, send]);
|
||||
|
||||
const handleSendMessage = useCallback(() => {
|
||||
if (inputValue.trim()) {
|
||||
const messageData = { text: inputValue, sender: username };
|
||||
const currentUser = connectedUsers.find(u => u.id === socket?.id);
|
||||
const senderName = currentUser?.name || username || "Player";
|
||||
|
||||
const messageData = { text: inputValue, sender: senderName };
|
||||
send("send-message", messageData);
|
||||
setMessages(prev => [...prev, messageData]);
|
||||
setInputValue("");
|
||||
}
|
||||
}, [inputValue, username, send]);
|
||||
}, [inputValue, send, socket?.id, connectedUsers, username]);
|
||||
|
||||
const handleSongSelect = useCallback((song) => {
|
||||
setSelectedSong(song);
|
||||
@ -185,7 +274,10 @@ export const Game = () => {
|
||||
setTimeLeft(0);
|
||||
}, [send]);
|
||||
|
||||
// Phase-specific content rendering
|
||||
const handlePlayerReady = useCallback(() => {
|
||||
console.log("Player ready");
|
||||
}, []);
|
||||
|
||||
const renderPhaseContent = () => {
|
||||
switch (phase) {
|
||||
case "waiting":
|
||||
@ -214,16 +306,56 @@ export const Game = () => {
|
||||
<div className="song-description">von {currentSong.artist}</div>
|
||||
</div>
|
||||
</div>
|
||||
<p className="instruction">Spiele diesen Song mit dem Tonregler!</p>
|
||||
|
||||
<div className="song-player-container">
|
||||
<YouTubePlayer
|
||||
videoId={currentSong.youtubeId}
|
||||
autoplay={true}
|
||||
startTime={currentSong.refrainTime || 45}
|
||||
onReady={handlePlayerReady}
|
||||
className="song-embedded-player"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="music-controls">
|
||||
<p className="instruction">Use the player above to listen, and the tone slider to play!</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{role === "guesser" && (
|
||||
<div className="listening-display">
|
||||
<div className="listening-icon">
|
||||
<FontAwesomeIcon icon={faHeadphones} size="4x" />
|
||||
<div className="guessing-display">
|
||||
<div className="listening-display">
|
||||
<div className="listening-icon">
|
||||
<FontAwesomeIcon icon={faHeadphones} size="4x" />
|
||||
</div>
|
||||
<p className="instruction">Listen carefully and think about which song it might be!</p>
|
||||
</div>
|
||||
<p className="instruction">Höre genau zu und versuche, den Song zu erkennen!</p>
|
||||
|
||||
{songOptions.length > 0 && (
|
||||
<div className="song-grid early-preview">
|
||||
{songOptions.map(song => (
|
||||
<div
|
||||
key={song.id}
|
||||
className={`song-option ${selectedSong?.id === song.id ? 'selected' : ''}`}
|
||||
onClick={() => handleSongSelect(song)}
|
||||
>
|
||||
<div className="song-image">
|
||||
<img src={song.coverUrl} alt={song.title} />
|
||||
{selectedSong?.id === song.id && (
|
||||
<div className="selection-indicator">
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="song-details">
|
||||
<div className="song-title">{song.title}</div>
|
||||
<div className="song-artist">{song.artist}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -233,8 +365,8 @@ export const Game = () => {
|
||||
return (
|
||||
<div className="guessing-phase">
|
||||
<div className="phase-header">
|
||||
<h3>Runde {round}: Song erraten</h3>
|
||||
<div className="timer">
|
||||
<h3>Final Answer Time!</h3>
|
||||
<div className="timer urgent">
|
||||
<FontAwesomeIcon icon={faClock} /> {timeLeft}s
|
||||
</div>
|
||||
</div>
|
||||
@ -264,7 +396,7 @@ export const Game = () => {
|
||||
<img src={song.coverUrl} alt={song.title} />
|
||||
{selectedSong?.id === song.id && (
|
||||
<div className="selection-indicator">
|
||||
<FontAwesomeIcon icon="fa-solid fa-check-circle" />
|
||||
<FontAwesomeIcon icon={faCheckCircle} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -285,7 +417,7 @@ export const Game = () => {
|
||||
onClick={handleSubmitGuess}
|
||||
disabled={!selectedSong}
|
||||
>
|
||||
<FontAwesomeIcon icon="fa-solid fa-paper-plane" />
|
||||
<FontAwesomeIcon icon={faPaperPlane} />
|
||||
Antwort einreichen
|
||||
</button>
|
||||
)}
|
||||
@ -335,18 +467,24 @@ export const Game = () => {
|
||||
<div className="scoreboard">
|
||||
<h4>Punktestand</h4>
|
||||
<div className="scores">
|
||||
{Object.entries(scores).map(([userId, score]) => {
|
||||
const user = connectedUsers.find(u => u.id === userId) || { name: 'Unbekannt' };
|
||||
{Object.entries(scores).map(([userId, scoreData]) => {
|
||||
const isCurrentUser = userId === socket?.id;
|
||||
const isHost = connectedUsers.find(u => u.id === "user1")?.id === userId;
|
||||
const playerName = typeof scoreData === 'object' ?
|
||||
scoreData.name :
|
||||
(connectedUsers.find(u => u.id === userId)?.name || "Player");
|
||||
const playerScore = typeof scoreData === 'object' ? scoreData.score : scoreData;
|
||||
|
||||
return (
|
||||
<div key={userId} className="score-entry">
|
||||
<div key={userId} className={`score-entry ${isCurrentUser ? 'current-user' : ''}`}>
|
||||
<span className="player-name">
|
||||
{user.id === (socket?.id || "you") && "👉 "}
|
||||
{user.name}
|
||||
{userId === connectedUsers.find(u => u.id === "user1")?.id && (
|
||||
{isCurrentUser && "👉 "}
|
||||
{playerName}
|
||||
{isHost && (
|
||||
<FontAwesomeIcon icon={faCrown} className="host-icon" />
|
||||
)}
|
||||
</span>
|
||||
<span className="player-score">{score}</span>
|
||||
<span className="player-score">{playerScore}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -427,8 +565,15 @@ export const Game = () => {
|
||||
isReadOnly={role !== "composer"}
|
||||
onFrequencyChange={handleFrequencyChange}
|
||||
frequency={frequency}
|
||||
composerIsPlaying={composerIsPlaying}
|
||||
/>
|
||||
)}
|
||||
|
||||
{songsLoading && (
|
||||
<div className="songs-loading-indicator">
|
||||
Loading songs...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
@ -1,224 +1,217 @@
|
||||
import {useEffect, useRef, useState} from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import "./styles.sass";
|
||||
|
||||
export const MusicSlider = ({ isReadOnly = false, onFrequencyChange, frequency: externalFrequency }) => {
|
||||
const [frequency, setFrequency] = useState(externalFrequency || 440);
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const audioContextRef = useRef(null);
|
||||
const oscillatorRef = useRef(null);
|
||||
const gainNodeRef = useRef(null);
|
||||
const sliderRef = useRef(null);
|
||||
const hasInteractedRef = useRef(false);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
const initAudioContext = () => {
|
||||
if (!audioContextRef.current && !hasInteractedRef.current) {
|
||||
hasInteractedRef.current = true;
|
||||
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)();
|
||||
if (!oscillatorRef.current) {
|
||||
startAudio();
|
||||
}
|
||||
document.removeEventListener('click', initAudioContext);
|
||||
document.removeEventListener('touchstart', initAudioContext);
|
||||
document.removeEventListener('keydown', initAudioContext);
|
||||
}
|
||||
};
|
||||
const initAudio = useCallback(() => {
|
||||
if (audioContext) return;
|
||||
|
||||
document.addEventListener('click', initAudioContext);
|
||||
document.addEventListener('touchstart', initAudioContext);
|
||||
document.addEventListener('keydown', initAudioContext);
|
||||
try {
|
||||
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('click', initAudioContext);
|
||||
document.removeEventListener('touchstart', initAudioContext);
|
||||
document.removeEventListener('keydown', initAudioContext);
|
||||
};
|
||||
}, []);
|
||||
osc.type = 'sine';
|
||||
osc.frequency.setValueAtTime(frequency.current, ctx.currentTime);
|
||||
|
||||
useEffect(() => {
|
||||
if (externalFrequency !== undefined && !dragging) {
|
||||
setFrequency(externalFrequency);
|
||||
gain.gain.setValueAtTime(0.00001, ctx.currentTime);
|
||||
|
||||
if (audioContextRef.current) {
|
||||
if (!isPlaying) {
|
||||
startAudio();
|
||||
} else if (oscillatorRef.current) {
|
||||
oscillatorRef.current.frequency.setValueAtTime(
|
||||
externalFrequency,
|
||||
audioContextRef.current.currentTime
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [externalFrequency, dragging, isPlaying]);
|
||||
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;
|
||||
|
||||
// Initialize audio if not already done
|
||||
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;
|
||||
|
||||
// Calculate relative X position using window coordinates
|
||||
let x = clientX - rect.left;
|
||||
|
||||
// For positions outside the slider, calculate relative to slider bounds
|
||||
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) => {
|
||||
if (isReadOnly) return;
|
||||
const target = e.target;
|
||||
const isSlider = target.classList.contains('otamatone-neck');
|
||||
const isIndicator = target.classList.contains('frequency-indicator') ||
|
||||
target.closest('.frequency-indicator');
|
||||
|
||||
setDragging(true);
|
||||
handleFrequencyChange(e);
|
||||
if (!isSlider && !isIndicator) return;
|
||||
|
||||
if (!audioContextRef.current) {
|
||||
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)();
|
||||
}
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isPlaying) {
|
||||
startAudio();
|
||||
}
|
||||
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 = () => {
|
||||
setDragging(false);
|
||||
const handleMouseUp = (e) => {
|
||||
if (!isPressed.current) return;
|
||||
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
isPressed.current = false;
|
||||
isDragging.current = false;
|
||||
|
||||
if (gainNode) {
|
||||
gainNode.gain.setTargetAtTime(0.00001, audioContext.currentTime, 0.05);
|
||||
}
|
||||
|
||||
document.body.style.userSelect = '';
|
||||
const iframes = document.querySelectorAll('iframe');
|
||||
iframes.forEach(iframe => {
|
||||
iframe.style.pointerEvents = 'auto';
|
||||
});
|
||||
|
||||
onFrequencyChange(frequency.current, false);
|
||||
};
|
||||
|
||||
const handleFrequencyChange = (e) => {
|
||||
if (isReadOnly) return;
|
||||
document.addEventListener('mousemove', handleMouseMove, true);
|
||||
document.addEventListener('mouseup', handleMouseUp, true);
|
||||
|
||||
const rect = sliderRef.current.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const clampedX = Math.max(0, Math.min(x, rect.width));
|
||||
const newFrequency = 20 + (clampedX / rect.width) * 1980;
|
||||
setFrequency(newFrequency);
|
||||
const slider = sliderRef.current;
|
||||
if (slider) {
|
||||
slider.addEventListener('mousedown', handleMouseDown, true);
|
||||
}
|
||||
|
||||
if (onFrequencyChange) {
|
||||
onFrequencyChange(newFrequency);
|
||||
}
|
||||
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(() => {
|
||||
const handleMouseMove = (e) => dragging && handleFrequencyChange(e);
|
||||
const handleMouseUpGlobal = () => dragging && handleMouseUp();
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUpGlobal);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUpGlobal);
|
||||
};
|
||||
}, [dragging]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isPlaying && oscillatorRef.current && audioContextRef.current) {
|
||||
oscillatorRef.current.frequency.setValueAtTime(
|
||||
frequency,
|
||||
audioContextRef.current.currentTime
|
||||
);
|
||||
}
|
||||
}, [frequency, isPlaying]);
|
||||
|
||||
const startAudio = () => {
|
||||
if (!audioContextRef.current) {
|
||||
try {
|
||||
audioContextRef.current = new (window.AudioContext || window.webkitAudioContext)();
|
||||
} catch (e) {
|
||||
console.error("AudioContext could not be created:", e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (audioContextRef.current.state === 'suspended') {
|
||||
audioContextRef.current.resume().catch(err => {
|
||||
console.error("Could not resume AudioContext:", err);
|
||||
});
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (gainNode) gainNode.gain.setValueAtTime(0, audioContext.currentTime);
|
||||
if (oscillator) {
|
||||
try {
|
||||
if (!oscillatorRef.current) {
|
||||
oscillatorRef.current = audioContextRef.current.createOscillator();
|
||||
oscillatorRef.current.type = 'sine';
|
||||
oscillatorRef.current.frequency.setValueAtTime(
|
||||
frequency,
|
||||
audioContextRef.current.currentTime
|
||||
);
|
||||
|
||||
gainNodeRef.current = audioContextRef.current.createGain();
|
||||
gainNodeRef.current.gain.setValueAtTime(0.5, audioContextRef.current.currentTime);
|
||||
|
||||
oscillatorRef.current.connect(gainNodeRef.current);
|
||||
gainNodeRef.current.connect(audioContextRef.current.destination);
|
||||
oscillatorRef.current.start();
|
||||
console.log("Audio started successfully");
|
||||
} else {
|
||||
oscillatorRef.current.frequency.setValueAtTime(
|
||||
frequency,
|
||||
audioContextRef.current.currentTime
|
||||
);
|
||||
}
|
||||
setIsPlaying(true);
|
||||
oscillator.stop();
|
||||
} catch (e) {
|
||||
console.error("Error starting audio:", e);
|
||||
console.warn("Oscillator already stopped");
|
||||
}
|
||||
}
|
||||
if (audioContext) audioContext.close();
|
||||
};
|
||||
}, [oscillator, audioContext, gainNode]);
|
||||
|
||||
const stopAudio = () => {
|
||||
if (oscillatorRef.current) {
|
||||
try {
|
||||
oscillatorRef.current.stop();
|
||||
oscillatorRef.current.disconnect();
|
||||
oscillatorRef.current = null;
|
||||
setIsPlaying(false);
|
||||
console.log("Audio stopped");
|
||||
} catch (e) {
|
||||
console.error("Error stopping audio:", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
const getFrequencyPosition = () => {
|
||||
const pos = ((frequency.current - 220) / 880) * 100;
|
||||
return `${Math.max(0, Math.min(pos, 100))}%`;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (oscillatorRef.current) {
|
||||
try {
|
||||
oscillatorRef.current.stop();
|
||||
oscillatorRef.current.disconnect();
|
||||
} catch (e) {
|
||||
console.error("Error cleaning up oscillator:", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
|
||||
try {
|
||||
audioContextRef.current.close();
|
||||
} catch (e) {
|
||||
console.error("Error closing AudioContext:", e);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (isPlaying) {
|
||||
stopAudio();
|
||||
}
|
||||
};
|
||||
}, [isPlaying]);
|
||||
|
||||
return (
|
||||
<div className={`otamatone-container ${isReadOnly ? 'read-only' : ''}`}>
|
||||
<div
|
||||
className="otamatone"
|
||||
onMouseDown={handleMouseDown}
|
||||
style={{ cursor: isReadOnly ? 'default' : 'pointer' }}
|
||||
>
|
||||
<div className="otamatone-face">
|
||||
<div className="otamatone-mouth" style={{
|
||||
height: `${10 + (frequency / 2000) * 40}px`,
|
||||
width: `${10 + (frequency / 2000) * 40}px`
|
||||
}}></div>
|
||||
<div className="otamatone-eyes">
|
||||
<div className="eye left-eye"></div>
|
||||
<div className="eye right-eye"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="otamatone-neck" ref={sliderRef}>
|
||||
<div className="frequency-indicator" style={{left: `${(frequency - 20) / 1980 * 100}%`}}></div>
|
||||
<div className="note-marker" style={{left: '10%', pointerEvents: 'none'}}>♩</div>
|
||||
<div className="note-marker" style={{left: '50%', pointerEvents: 'none'}}>♪</div>
|
||||
<div className="note-marker" style={{left: '90%', pointerEvents: 'none'}}>♫</div>
|
||||
</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"></div>
|
||||
</div>
|
||||
);
|
||||
<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() }}
|
||||
>
|
||||
<div className="note-marker">
|
||||
{(!isReadOnly && isDragging.current) && Math.round(frequency.current)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
@ -63,6 +63,10 @@
|
||||
background: rgba(255, 255, 255, 0.2)
|
||||
pointer-events: none
|
||||
|
||||
&.interactive
|
||||
.frequency-indicator:hover
|
||||
transform: translateX(-50%) scale(1.1)
|
||||
|
||||
.frequency-indicator
|
||||
position: absolute
|
||||
top: -20px
|
||||
@ -84,6 +88,19 @@
|
||||
cursor: grabbing
|
||||
transform: translateX(-50%) scale(0.95)
|
||||
|
||||
&.read-only
|
||||
pointer-events: none
|
||||
cursor: default
|
||||
opacity: 0.8
|
||||
|
||||
box-shadow: 0 0 15px rgba(255, 107, 179, 0.4)
|
||||
|
||||
&.active
|
||||
opacity: 1
|
||||
box-shadow: 0 0 30px rgba(255, 107, 179, 0.8)
|
||||
transform: translateX(-50%) scale(1.1)
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)
|
||||
|
||||
.note-marker
|
||||
position: absolute
|
||||
top: -50px
|
||||
|
@ -1,6 +1,6 @@
|
||||
@import "@/common/styles/colors"
|
||||
|
||||
// Main layout improvements
|
||||
|
||||
.game-page
|
||||
display: flex
|
||||
flex-direction: column
|
||||
@ -40,7 +40,6 @@
|
||||
min-width: 0
|
||||
margin-right: 20px
|
||||
|
||||
// Redesigned chat panel
|
||||
.chat-panel
|
||||
width: 280px
|
||||
height: 100%
|
||||
@ -162,7 +161,6 @@
|
||||
&:active
|
||||
transform: translateY(0)
|
||||
|
||||
// Game header improvements
|
||||
.game-header
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
@ -197,7 +195,6 @@
|
||||
margin-right: 8px
|
||||
color: $yellow
|
||||
|
||||
// Improved button styles
|
||||
.submit-guess-button, .next-round-button, button:not(.send-button)
|
||||
background: linear-gradient(135deg, $purple, $blue)
|
||||
color: #fff
|
||||
@ -244,7 +241,6 @@
|
||||
cursor: not-allowed
|
||||
background: linear-gradient(135deg, #777, #555)
|
||||
|
||||
// Content component improvements
|
||||
.waiting-phase
|
||||
text-align: center
|
||||
padding: 100px 0
|
||||
@ -264,335 +260,47 @@
|
||||
|
||||
.song-display
|
||||
text-align: center
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
width: 100%
|
||||
margin-bottom: 30px
|
||||
|
||||
.song-card
|
||||
display: flex
|
||||
flex-direction: row
|
||||
align-items: center
|
||||
background: rgba(255, 255, 255, 0.08)
|
||||
padding: 25px
|
||||
border-radius: 20px
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3)
|
||||
background: rgba(40, 40, 60, 0.85)
|
||||
padding: 20px
|
||||
border-radius: 15px
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4)
|
||||
backdrop-filter: blur(10px)
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
border: 1px solid rgba(255, 255, 255, 0.15)
|
||||
max-width: 500px
|
||||
margin: 20px auto
|
||||
transition: all 0.3s ease
|
||||
|
||||
&:hover
|
||||
transform: translateY(-5px)
|
||||
border-color: rgba(255, 255, 255, 0.4)
|
||||
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.4), 0 0 30px rgba(255, 255, 255, 0.1)
|
||||
border-color: rgba(255, 255, 255, 0.3)
|
||||
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.5), 0 0 30px rgba(255, 255, 255, 0.1)
|
||||
|
||||
img
|
||||
width: 120px
|
||||
height: 120px
|
||||
object-fit: cover
|
||||
border-radius: 15px
|
||||
border-radius: 8px
|
||||
margin-right: 20px
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.5)
|
||||
border: 2px solid rgba(255, 255, 255, 0.2)
|
||||
|
||||
.song-info
|
||||
text-align: left
|
||||
|
||||
.song-names
|
||||
font-size: 24px
|
||||
color: $white
|
||||
margin-bottom: 10px
|
||||
|
||||
.song-description
|
||||
font-size: 16px
|
||||
color: rgba(255, 255, 255, 0.7)
|
||||
|
||||
.phase-header
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
width: 100%
|
||||
margin-bottom: 30px
|
||||
|
||||
h3
|
||||
font-size: 24px
|
||||
margin: 0
|
||||
color: $white
|
||||
text-shadow: 0 0 10px rgba(255, 255, 255, 0.3)
|
||||
|
||||
.timer
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
padding: 10px 20px
|
||||
border-radius: 15px
|
||||
font-size: 18px
|
||||
display: flex
|
||||
align-items: center
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2)
|
||||
border: 1px solid rgba(255, 255, 255, 0.1)
|
||||
|
||||
svg
|
||||
margin-right: 10px
|
||||
color: $yellow
|
||||
|
||||
// Improved song selection grid
|
||||
.song-selection
|
||||
width: 100%
|
||||
|
||||
.instruction
|
||||
text-align: center
|
||||
font-size: 20px
|
||||
margin-bottom: 30px
|
||||
color: $white
|
||||
|
||||
.song-grid
|
||||
display: grid
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))
|
||||
gap: 20px
|
||||
margin-top: 20px
|
||||
|
||||
.song-option
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
border-radius: 15px
|
||||
overflow: hidden
|
||||
cursor: pointer
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)
|
||||
border: 2px solid transparent
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3)
|
||||
height: 100%
|
||||
|
||||
&:hover:not(.disabled)
|
||||
transform: translateY(-8px) scale(1.03)
|
||||
box-shadow: 0 15px 30px rgba(0, 0, 0, 0.4), 0 0 25px rgba(255, 255, 255, 0.1)
|
||||
background: rgba(255, 255, 255, 0.15)
|
||||
|
||||
&.selected
|
||||
border: 2px solid $yellow
|
||||
box-shadow: 0 0 25px rgba(255, 255, 0, 0.4), 0 10px 30px rgba(0, 0, 0, 0.4)
|
||||
background: rgba(255, 255, 255, 0.15)
|
||||
|
||||
&.disabled
|
||||
opacity: 0.7
|
||||
cursor: default
|
||||
|
||||
.song-image
|
||||
position: relative
|
||||
|
||||
img
|
||||
width: 100%
|
||||
height: 150px
|
||||
object-fit: cover
|
||||
|
||||
.selection-indicator
|
||||
position: absolute
|
||||
top: 10px
|
||||
right: 10px
|
||||
background: $yellow
|
||||
width: 30px
|
||||
height: 30px
|
||||
border-radius: 50%
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
box-shadow: 0 0 15px rgba(255, 255, 0, 0.7)
|
||||
|
||||
.song-details
|
||||
padding: 15px
|
||||
|
||||
.song-title
|
||||
font-weight: bold
|
||||
font-size: 16px
|
||||
color: $white
|
||||
|
||||
.song-artist
|
||||
font-size: 14px
|
||||
opacity: 0.7
|
||||
margin-top: 5px
|
||||
|
||||
// Results phase improvements
|
||||
.results-phase
|
||||
text-align: center
|
||||
|
||||
h3
|
||||
font-size: 28px
|
||||
margin-bottom: 40px
|
||||
|
||||
.round-results
|
||||
background: rgba(255, 255, 255, 0.08)
|
||||
backdrop-filter: blur(10px)
|
||||
border-radius: 25px
|
||||
padding: 30px
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4)
|
||||
border: 1px solid rgba(255, 255, 255, 0.15)
|
||||
max-width: 600px
|
||||
margin: 0 auto
|
||||
|
||||
.scoreboard
|
||||
.music-controls
|
||||
margin-top: 30px
|
||||
padding-top: 20px
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1)
|
||||
|
||||
h4
|
||||
margin-bottom: 15px
|
||||
font-size: 20px
|
||||
|
||||
.scores
|
||||
display: grid
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))
|
||||
gap: 10px
|
||||
|
||||
.score-entry
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
border-radius: 10px
|
||||
padding: 10px 15px
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
|
||||
&:nth-child(1)
|
||||
background: linear-gradient(135deg, rgba(255, 215, 0, 0.2), rgba(255, 215, 0, 0.1))
|
||||
box-shadow: 0 0 20px rgba(255, 215, 0, 0.3)
|
||||
|
||||
&:nth-child(2)
|
||||
background: linear-gradient(135deg, rgba(192, 192, 192, 0.2), rgba(192, 192, 192, 0.1))
|
||||
|
||||
&:nth-child(3)
|
||||
background: linear-gradient(135deg, rgba(205, 127, 50, 0.2), rgba(205, 127, 50, 0.1))
|
||||
|
||||
.player-name
|
||||
display: flex
|
||||
align-items: center
|
||||
|
||||
.host-icon
|
||||
color: $yellow
|
||||
margin-left: 5px
|
||||
|
||||
// Responsive adjustments
|
||||
@media (max-width: 900px)
|
||||
.game-layout
|
||||
flex-direction: column
|
||||
|
||||
.main-content
|
||||
margin-right: 0
|
||||
margin-bottom: 20px
|
||||
|
||||
.chat-panel
|
||||
width: 100%
|
||||
max-height: 300px
|
||||
|
||||
// Animations
|
||||
@keyframes slide-in-right
|
||||
0%
|
||||
transform: translateX(30px)
|
||||
opacity: 0
|
||||
100%
|
||||
transform: translateX(0)
|
||||
opacity: 1
|
||||
|
||||
// ...existing animations...
|
||||
|
||||
.song-display
|
||||
width: 100%
|
||||
max-width: 400px
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
justify-content: center
|
||||
width: 50%
|
||||
margin-right: 20px
|
||||
color: $white
|
||||
text-align: center
|
||||
|
||||
h2
|
||||
font-size: 52pt
|
||||
color: $white
|
||||
margin-bottom: 25px
|
||||
position: relative
|
||||
z-index: 2
|
||||
background: linear-gradient(135deg, $pink, $blue 45%, $mint-green 65%, $yellow 85%, $pink)
|
||||
background-size: 300% 100%
|
||||
animation: title-shimmer 10s infinite alternate ease-in-out, title-float 6s infinite ease-in-out
|
||||
-webkit-background-clip: text
|
||||
background-clip: text
|
||||
-webkit-text-fill-color: transparent
|
||||
filter: drop-shadow(0 0 15px rgba(255, 255, 255, 0.7))
|
||||
letter-spacing: 0.1em
|
||||
font-weight: bold
|
||||
|
||||
&:before
|
||||
content: "ToneGuessr"
|
||||
position: absolute
|
||||
z-index: -1
|
||||
left: 0
|
||||
top: 0
|
||||
background: none
|
||||
-webkit-text-fill-color: transparent
|
||||
filter: blur(15px) brightness(1.3)
|
||||
opacity: 0.6
|
||||
width: 100%
|
||||
height: 100%
|
||||
|
||||
.song-card
|
||||
display: flex
|
||||
flex-direction: row
|
||||
align-items: center
|
||||
background: rgba(255, 255, 255, 0.08)
|
||||
padding: 25px
|
||||
border-radius: 20px
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.2), 0 0 20px rgba(255, 255, 255, 0.1)
|
||||
backdrop-filter: blur(10px)
|
||||
border: 1px solid rgba(255, 255, 255, 0.2)
|
||||
max-width: 500px
|
||||
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275)
|
||||
will-change: transform, box-shadow
|
||||
transform: translateZ(0)
|
||||
backface-visibility: hidden
|
||||
animation: card-pulse 6s infinite alternate ease-in-out
|
||||
|
||||
&:hover
|
||||
transform: translateY(-10px) scale(1.02)
|
||||
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.3), 0 0 30px rgba(255, 255, 255, 0.15)
|
||||
border: 1px solid rgba(255, 255, 255, 0.4)
|
||||
|
||||
img
|
||||
width: 120px
|
||||
height: 120px
|
||||
border-radius: 15px
|
||||
margin-right: 25px
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4)
|
||||
transition: transform 0.3s ease
|
||||
will-change: transform
|
||||
transform: translateZ(0)
|
||||
animation: album-rotate 10s infinite alternate ease-in-out
|
||||
|
||||
&:hover
|
||||
transform: scale(1.1) rotate(5deg)
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.5), 0 0 40px rgba(102, 204, 255, 0.3)
|
||||
|
||||
.song-info
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
|
||||
.song-names
|
||||
font-size: 28pt
|
||||
color: $white
|
||||
margin-bottom: 10px
|
||||
text-shadow: 0 0 10px rgba(255, 255, 255, 0.5)
|
||||
animation: text-shimmer 5s infinite alternate ease-in-out
|
||||
|
||||
.song-description
|
||||
font-size: 16pt
|
||||
color: $border
|
||||
opacity: 0.8
|
||||
position: relative
|
||||
|
||||
&:after
|
||||
content: ""
|
||||
position: absolute
|
||||
bottom: -5px
|
||||
left: 0
|
||||
width: 0%
|
||||
height: 1px
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.7), transparent)
|
||||
transition: all 0.4s ease
|
||||
|
||||
&:hover:after
|
||||
width: 100%
|
||||
|
||||
.chat-window
|
||||
width: 50%
|
||||
@ -889,6 +597,26 @@
|
||||
opacity: 0.8
|
||||
animation: pulse 1.5s infinite ease-in-out
|
||||
|
||||
.guessing-display
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 20px
|
||||
margin-top: 20px
|
||||
|
||||
.early-preview
|
||||
opacity: 0.8
|
||||
transition: opacity 0.3s ease
|
||||
|
||||
&:hover
|
||||
opacity: 1
|
||||
|
||||
.song-grid
|
||||
margin-top: 10px
|
||||
|
||||
.timer.urgent
|
||||
animation: urgent-pulse 1s infinite
|
||||
background: rgba(255, 100, 100, 0.2)
|
||||
|
||||
@keyframes subtle-text-glow
|
||||
0%, 100%
|
||||
text-shadow: 0 0 10px rgba(255, 255, 255, 0.4)
|
||||
@ -962,3 +690,387 @@
|
||||
50%
|
||||
opacity: 1
|
||||
transform: scale(1.05)
|
||||
|
||||
.player-container
|
||||
position: fixed
|
||||
left: 20px
|
||||
bottom: 20px
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: flex-start
|
||||
z-index: 100
|
||||
background: rgba(0, 0, 0, 0.5)
|
||||
padding: 10px
|
||||
border-radius: 10px
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3)
|
||||
max-width: 320px
|
||||
|
||||
.player-controls
|
||||
width: 100%
|
||||
margin-top: 10px
|
||||
position: relative
|
||||
left: auto
|
||||
top: auto
|
||||
right: auto
|
||||
background: none
|
||||
box-shadow: none
|
||||
padding: 0
|
||||
|
||||
.volume-controls
|
||||
width: 100%
|
||||
display: flex
|
||||
align-items: center
|
||||
background: none
|
||||
padding: 5px 0
|
||||
position: static
|
||||
box-shadow: none
|
||||
|
||||
span
|
||||
white-space: nowrap
|
||||
|
||||
.volume-slider
|
||||
flex: 1
|
||||
margin-left: 10px
|
||||
|
||||
.composer-player
|
||||
width: 300px
|
||||
height: 169px
|
||||
border-radius: 8px
|
||||
overflow: hidden
|
||||
|
||||
.composer-player
|
||||
width: 300px
|
||||
height: 169px
|
||||
border-radius: 8px
|
||||
overflow: hidden
|
||||
|
||||
.position-jump-button
|
||||
display: block
|
||||
margin-top: 10px
|
||||
padding: 8px 15px
|
||||
background: linear-gradient(135deg, $yellow, $orange)
|
||||
color: #fff
|
||||
border: none
|
||||
border-radius: 8px
|
||||
font-size: 14px
|
||||
font-weight: 600
|
||||
cursor: pointer
|
||||
transition: all 0.2s ease
|
||||
width: 100%
|
||||
|
||||
&:hover
|
||||
background: linear-gradient(135deg, lighten($yellow, 5%), lighten($orange, 5%))
|
||||
transform: translateY(-2px)
|
||||
box-shadow: 0 4px 15px rgba(255, 204, 0, 0.3)
|
||||
|
||||
&:active
|
||||
transform: translateY(0)
|
||||
|
||||
.player-container
|
||||
width: 100%
|
||||
margin-top: 20px
|
||||
background: rgba(20, 20, 20, 0.5)
|
||||
backdrop-filter: blur(10px)
|
||||
padding: 15px
|
||||
border-radius: 15px
|
||||
border: 1px solid rgba(255, 255, 255, 0.1)
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3)
|
||||
|
||||
h4
|
||||
color: $white
|
||||
margin: 0 0 15px 0
|
||||
text-align: center
|
||||
font-size: 16px
|
||||
|
||||
.embedded-player
|
||||
width: 100%
|
||||
border-radius: 8px
|
||||
overflow: hidden
|
||||
|
||||
.song-player-container
|
||||
width: 100%
|
||||
max-width: 500px
|
||||
margin: 20px auto
|
||||
border-radius: 15px
|
||||
overflow: hidden
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25)
|
||||
background: rgba(0, 0, 0, 0.5)
|
||||
|
||||
.song-embedded-player
|
||||
width: 100%
|
||||
height: auto
|
||||
aspect-ratio: 16 / 9
|
||||
transition: pointer-events 0.1s ease
|
||||
|
||||
&:hover .song-embedded-player
|
||||
pointer-events: auto !important
|
||||
|
||||
.results-phase
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
width: 100%
|
||||
|
||||
h3
|
||||
margin-bottom: 30px
|
||||
font-size: 28px
|
||||
color: $white
|
||||
text-shadow: 0 2px 15px rgba(255, 255, 255, 0.3)
|
||||
|
||||
.round-results
|
||||
width: 100%
|
||||
max-width: 600px
|
||||
display: flex
|
||||
flex-direction: column
|
||||
align-items: center
|
||||
|
||||
.composer-results, .guesser-results
|
||||
width: 100%
|
||||
margin-bottom: 20px
|
||||
text-align: center
|
||||
|
||||
p
|
||||
font-size: 18px
|
||||
margin-bottom: 15px
|
||||
color: $white
|
||||
|
||||
.correct-song
|
||||
margin-top: 10px
|
||||
position: relative
|
||||
overflow: visible
|
||||
z-index: 5
|
||||
|
||||
&:before
|
||||
content: ""
|
||||
position: absolute
|
||||
top: -30px
|
||||
left: -30px
|
||||
right: -30px
|
||||
bottom: -30px
|
||||
background: rgba(20, 20, 40, 0.2)
|
||||
border-radius: 40px
|
||||
filter: blur(40px)
|
||||
z-index: -1
|
||||
|
||||
.song-card.highlight
|
||||
position: relative
|
||||
transform: scale(1.05)
|
||||
background: linear-gradient(135deg, rgba(40, 40, 60, 0.9), rgba(30, 30, 50, 0.95))
|
||||
border: 2px solid $yellow
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4), 0 0 50px rgba(255, 255, 0, 0.2)
|
||||
backdrop-filter: blur(30px)
|
||||
padding: 25px
|
||||
transition: all 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275)
|
||||
-webkit-backdrop-filter: blur(30px)
|
||||
background: rgba(35, 35, 50, 0.5)
|
||||
border-radius: 18px
|
||||
|
||||
&:hover
|
||||
transform: translateY(-8px) scale(1.08)
|
||||
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.5), 0 0 60px rgba(255, 255, 0, 0.3), inset 0 0 50px rgba(255, 255, 255, 0.08)
|
||||
|
||||
&:after
|
||||
content: "✓"
|
||||
position: absolute
|
||||
top: -15px
|
||||
right: -15px
|
||||
width: 40px
|
||||
height: 40px
|
||||
background: linear-gradient(135deg, $yellow, darken($yellow, 15%))
|
||||
color: #000
|
||||
border-radius: 50%
|
||||
border: 3px solid rgba(0, 0, 0, 0.5)
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: center
|
||||
font-size: 20px
|
||||
font-weight: bold
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.4), 0 0 20px rgba(255, 255, 0, 0.5)
|
||||
animation: pulse-highlight 2s infinite alternate ease-in-out
|
||||
|
||||
&:before
|
||||
content: ""
|
||||
position: absolute
|
||||
inset: 0
|
||||
background: linear-gradient(135deg, rgba(80, 80, 120, 0.3), rgba(30, 30, 60, 0.8))
|
||||
border-radius: 18px
|
||||
z-index: -1
|
||||
opacity: 0.8
|
||||
|
||||
img
|
||||
width: 130px
|
||||
height: 130px
|
||||
border-radius: 14px
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.6)
|
||||
border: 3px solid rgba(255, 255, 255, 0.3)
|
||||
transform: rotate(-2deg)
|
||||
transition: all 0.5s ease, border-radius 0.3s ease
|
||||
|
||||
&:hover
|
||||
transform: rotate(0deg) scale(1.05)
|
||||
border-color: rgba(255, 255, 0, 0.6)
|
||||
border-radius: 12px
|
||||
|
||||
.song-info
|
||||
margin-left: 25px
|
||||
|
||||
.song-names
|
||||
font-size: 26px
|
||||
font-weight: bold
|
||||
color: $white
|
||||
text-shadow: 0 2px 10px rgba(255, 255, 255, 0.5)
|
||||
margin-bottom: 10px
|
||||
background: linear-gradient(90deg, $white, rgba(255,255,255,0.7))
|
||||
-webkit-background-clip: text
|
||||
background-clip: text
|
||||
-webkit-text-fill-color: transparent
|
||||
|
||||
.song-description
|
||||
font-size: 18px
|
||||
color: rgba(255, 255, 255, 0.8)
|
||||
text-shadow: 0 1px 5px rgba(0, 0, 0, 0.7)
|
||||
|
||||
.guess-result
|
||||
margin: 20px 0
|
||||
padding: 15px 20px
|
||||
border-radius: 16px
|
||||
font-size: 18px
|
||||
font-weight: bold
|
||||
animation: bounce-in 0.5s ease-out
|
||||
|
||||
&.correct
|
||||
background: linear-gradient(135deg, rgba(50, 200, 100, 0.8), rgba(40, 160, 80, 0.8))
|
||||
color: white
|
||||
box-shadow: 0 4px 20px rgba(50, 200, 100, 0.4)
|
||||
|
||||
&.incorrect
|
||||
background: linear-gradient(135deg, rgba(220, 60, 60, 0.8), rgba(180, 40, 40, 0.8))
|
||||
color: white
|
||||
box-shadow: 0 4px 20px rgba(220, 60, 60, 0.4)
|
||||
|
||||
.scoreboard
|
||||
width: 100%
|
||||
background: rgba(40, 40, 60, 0.6)
|
||||
backdrop-filter: blur(10px)
|
||||
border-radius: 20px
|
||||
padding: 20px
|
||||
margin-bottom: 20px
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.3)
|
||||
border: 1px solid rgba(255, 255, 255, 0.1)
|
||||
margin-top: 5px
|
||||
|
||||
h4
|
||||
text-align: center
|
||||
font-size: 22px
|
||||
color: $white
|
||||
margin-top: 10px
|
||||
margin-bottom: 10px
|
||||
padding-bottom: 8px
|
||||
|
||||
.scores
|
||||
display: flex
|
||||
flex-direction: column
|
||||
gap: 8px
|
||||
|
||||
.score-entry
|
||||
display: flex
|
||||
justify-content: space-between
|
||||
align-items: center
|
||||
padding: 12px 15px
|
||||
border-radius: 14px
|
||||
background: rgba(255, 255, 255, 0.1)
|
||||
transition: all 0.2s ease
|
||||
|
||||
&:hover
|
||||
background: rgba(255, 255, 255, 0.15)
|
||||
transform: translateY(-2px)
|
||||
|
||||
&.current-user
|
||||
background: rgba(102, 204, 255, 0.2)
|
||||
border-left: 3px solid $blue
|
||||
|
||||
.player-name
|
||||
display: flex
|
||||
align-items: center
|
||||
font-weight: 500
|
||||
color: $white
|
||||
font-size: 16px
|
||||
|
||||
.host-icon
|
||||
color: $yellow
|
||||
margin-left: 8px
|
||||
filter: drop-shadow(0 0 5px rgba(255, 255, 0, 0.5))
|
||||
|
||||
.player-score
|
||||
background: rgba(255, 255, 255, 0.2)
|
||||
padding: 5px 12px
|
||||
border-radius: 20px
|
||||
font-weight: bold
|
||||
font-size: 18px
|
||||
color: $yellow
|
||||
min-width: 40px
|
||||
text-align: center
|
||||
position: relative
|
||||
overflow: hidden
|
||||
|
||||
&:after
|
||||
content: ""
|
||||
position: absolute
|
||||
top: -50%
|
||||
left: -50%
|
||||
width: 200%
|
||||
height: 200%
|
||||
background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%)
|
||||
opacity: 0
|
||||
transition: opacity 0.5s ease
|
||||
|
||||
&.highlighted
|
||||
animation: score-highlight 1s ease
|
||||
|
||||
.next-round-button
|
||||
margin-top: 10px
|
||||
padding: 15px 30px
|
||||
font-size: 18px
|
||||
|
||||
.waiting-message
|
||||
margin-top: 20px
|
||||
font-style: italic
|
||||
color: rgba(255, 255, 255, 0.7)
|
||||
|
||||
@keyframes score-highlight
|
||||
0%
|
||||
transform: scale(1)
|
||||
background: rgba(255, 255, 0, 0.8)
|
||||
color: #000
|
||||
50%
|
||||
transform: scale(1.2)
|
||||
100%
|
||||
transform: scale(1)
|
||||
background: rgba(255, 255, 255, 0.2)
|
||||
color: $yellow
|
||||
|
||||
@keyframes bounce-in
|
||||
0%
|
||||
opacity: 0
|
||||
transform: scale(0.8)
|
||||
50%
|
||||
transform: scale(1.05)
|
||||
100%
|
||||
opacity: 1
|
||||
transform: scale(1)
|
||||
|
||||
@keyframes pulse-highlight
|
||||
0%, 100%
|
||||
transform: scale(1)
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.4), 0 0 20px rgba(255, 255, 0, 0.3)
|
||||
50%
|
||||
transform: scale(1.1)
|
||||
box-shadow: 0 5px 20px rgba(0, 0, 0, 0.5), 0 0 30px rgba(255, 255, 0, 0.6)
|
||||
|
||||
@keyframes urgent-pulse
|
||||
0%, 100%
|
||||
transform: scale(1)
|
||||
background: rgba(255, 100, 100, 0.2)
|
||||
50%
|
||||
transform: scale(1.05)
|
||||
background: rgba(255, 100, 100, 0.3)
|
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;
|
||||
}
|
||||
};
|
@ -1,18 +1,14 @@
|
||||
const roomController = require('./room');
|
||||
const youtubeService = require('../services/youtubeService');
|
||||
|
||||
const SONGS = [
|
||||
{
|
||||
id: 1,
|
||||
title: "Black Steam",
|
||||
artist: "Carrot Quest GmbH",
|
||||
coverUrl: "https://mir-s3-cdn-cf.behance.net/project_modules/1400/fe529a64193929.5aca8500ba9ab.jpg"
|
||||
},
|
||||
{id: 2, title: "Sunset Dreams", artist: "Ocean Waves", coverUrl: "https://place-hold.it/500x500/"},
|
||||
{id: 3, title: "Neon Nights", artist: "Electric Avenue", coverUrl: "https://place-hold.it/500x500/"},
|
||||
{id: 4, title: "Mountain Echo", artist: "Wild Terrain", coverUrl: "https://place-hold.it/500x500/"},
|
||||
{id: 5, title: "Urban Jungle", artist: "City Dwellers", coverUrl: "https://place-hold.it/500x500/"},
|
||||
{id: 6, title: "Cosmic Journey", artist: "Stargazers", coverUrl: "https://place-hold.it/500x500/"}
|
||||
];
|
||||
const shuffleArray = (array) => {
|
||||
const shuffled = [...array];
|
||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
||||
}
|
||||
return shuffled;
|
||||
};
|
||||
|
||||
const gameStates = {};
|
||||
|
||||
@ -30,7 +26,7 @@ const initializeGameState = (roomId) => {
|
||||
roundStartTime: null,
|
||||
phaseTimeLimit: {
|
||||
composing: 30,
|
||||
guessing: 30
|
||||
guessing: 10
|
||||
},
|
||||
lastFrequency: 440,
|
||||
};
|
||||
@ -44,7 +40,7 @@ const initializeGameState = (roomId) => {
|
||||
return gameStates[roomId];
|
||||
};
|
||||
|
||||
const startNewRound = (roomId) => {
|
||||
const startNewRound = async (roomId) => {
|
||||
const gameState = gameStates[roomId];
|
||||
if (!gameState) return false;
|
||||
|
||||
@ -59,12 +55,13 @@ const startNewRound = (roomId) => {
|
||||
|
||||
gameState.currentComposer = determineNextComposer(roomId, gameState, users);
|
||||
|
||||
const success = await selectSongAndOptions(gameState);
|
||||
if (!success) return false;
|
||||
|
||||
users.forEach(user => {
|
||||
gameState.roles[user.id] = user.id === gameState.currentComposer ? 'composer' : 'guesser';
|
||||
});
|
||||
|
||||
selectSongAndOptions(gameState);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
@ -82,16 +79,37 @@ const determineNextComposer = (roomId, gameState, users) => {
|
||||
return users[(currentIndex + 1) % users.length].id;
|
||||
};
|
||||
|
||||
const selectSongAndOptions = (gameState) => {
|
||||
gameState.selectedSong = SONGS[Math.floor(Math.random() * SONGS.length)];
|
||||
// Then modify the selectSongAndOptions function to use the shuffle:
|
||||
const selectSongAndOptions = async (gameState) => {
|
||||
try {
|
||||
const availableIds = await youtubeService.getAvailableSongIds();
|
||||
|
||||
const songOptions = [...SONGS].sort(() => Math.random() - 0.5).slice(0, 5);
|
||||
|
||||
if (!songOptions.includes(gameState.selectedSong)) {
|
||||
songOptions[Math.floor(Math.random() * songOptions.length)] = gameState.selectedSong;
|
||||
if (!availableIds || availableIds.length === 0) {
|
||||
console.error("No song IDs available for selection");
|
||||
return false;
|
||||
}
|
||||
|
||||
gameState.songOptions = songOptions;
|
||||
const selectedId = availableIds[Math.floor(Math.random() * availableIds.length)];
|
||||
gameState.selectedSong = { id: selectedId };
|
||||
|
||||
const optionIds = new Set([selectedId]);
|
||||
const attempts = Math.min(20, availableIds.length);
|
||||
|
||||
let count = 0;
|
||||
while (optionIds.size < 5 && count < attempts) {
|
||||
const randomId = availableIds[Math.floor(Math.random() * availableIds.length)];
|
||||
optionIds.add(randomId);
|
||||
count++;
|
||||
}
|
||||
|
||||
// Shuffle the song options to randomize the position of the correct answer
|
||||
gameState.songOptions = shuffleArray(Array.from(optionIds).map(id => ({ id })));
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Error selecting songs and options:", error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const getTimeRemaining = (roomId) => {
|
||||
@ -172,11 +190,17 @@ const getRoundResults = (roomId) => {
|
||||
const gameState = gameStates[roomId];
|
||||
if (!gameState) return {round: 0, scores: {}, guessResults: {}, selectedSong: null};
|
||||
|
||||
const playerNames = {};
|
||||
Object.keys(gameState.scores).forEach(userId => {
|
||||
playerNames[userId] = roomController.getUserName(userId) || "Player";
|
||||
});
|
||||
|
||||
return {
|
||||
round: gameState.round,
|
||||
selectedSong: gameState.selectedSong,
|
||||
guessResults: gameState.guessResults,
|
||||
scores: gameState.scores
|
||||
scores: gameState.scores,
|
||||
playerNames: playerNames
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -1,5 +1,6 @@
|
||||
const roomController = require("../controller/room");
|
||||
const gameController = require("../controller/game");
|
||||
const youtubeService = require('../services/youtubeService');
|
||||
|
||||
module.exports = (io) => (socket) => {
|
||||
let currentRoomId = null;
|
||||
@ -54,18 +55,22 @@ module.exports = (io) => (socket) => {
|
||||
const roles = gameController.getRoles(roomId);
|
||||
const selectedSong = gameController.getSelectedSong(roomId);
|
||||
const timeLeft = gameController.getTimeRemaining(roomId);
|
||||
const songOptions = gameController.getSongOptions(roomId);
|
||||
|
||||
io.to(roomId).emit('roles-assigned', roles);
|
||||
|
||||
Object.entries(roles).forEach(([userId, role]) => {
|
||||
if (role === 'composer') {
|
||||
io.to(userId).emit('song-selected', selectedSong);
|
||||
} else {
|
||||
io.to(userId).emit('song-options', { songOptions });
|
||||
}
|
||||
});
|
||||
|
||||
io.to(roomId).emit('round-started', {
|
||||
round: gameController.getRoundResults(roomId).round,
|
||||
timeRemaining: timeLeft
|
||||
timeRemaining: timeLeft,
|
||||
phase: 'composing'
|
||||
});
|
||||
|
||||
startPhaseTimer(roomId);
|
||||
@ -83,7 +88,8 @@ module.exports = (io) => (socket) => {
|
||||
|
||||
io.to(roomId).emit('phase-changed', {
|
||||
phase: 'guessing',
|
||||
timeRemaining: timeLeft
|
||||
timeRemaining: timeLeft,
|
||||
message: 'Time to submit your final answer!'
|
||||
});
|
||||
|
||||
Object.entries(roles).forEach(([userId, role]) => {
|
||||
@ -147,7 +153,7 @@ module.exports = (io) => (socket) => {
|
||||
currentRoomId = roomId;
|
||||
});
|
||||
|
||||
socket.on("start-game", () => {
|
||||
socket.on("start-game", async () => {
|
||||
const roomId = roomController.getUserRoom(socket.id);
|
||||
if (!roomId || !roomController.isUserHost(socket.id)) {
|
||||
return socket.emit("not-authorized");
|
||||
@ -162,15 +168,42 @@ module.exports = (io) => (socket) => {
|
||||
|
||||
if (!roomController.startGame(roomId)) return;
|
||||
gameController.initializeGameState(roomId);
|
||||
if (!gameController.startNewRound(roomId)) return;
|
||||
|
||||
io.to(roomId).emit("game-started");
|
||||
handleRoundStart(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) socket.to(roomId).emit("chat-message", messageData);
|
||||
if (!roomId) return;
|
||||
|
||||
const serverUsername = roomController.getUserName(socket.id);
|
||||
|
||||
if (!messageData.sender || messageData.sender === "Player" || messageData.sender === "Anonymous") {
|
||||
if (serverUsername) {
|
||||
console.log(`Fixing missing username for ${socket.id}: using "${serverUsername}" instead of "${messageData.sender || 'none'}"`);
|
||||
messageData.sender = serverUsername;
|
||||
} else {
|
||||
console.warn(`Could not find username for user ${socket.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
socket.to(roomId).emit("chat-message", messageData);
|
||||
|
||||
socket.emit("chat-message-confirmation", {
|
||||
...messageData,
|
||||
sender: messageData.sender
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("get-user-info", () => {
|
||||
@ -193,7 +226,7 @@ module.exports = (io) => (socket) => {
|
||||
if (currentRoomId) socket.emit("room-code", currentRoomId);
|
||||
});
|
||||
|
||||
socket.on("submit-frequency", ({ frequency }) => {
|
||||
socket.on("submit-frequency", ({ frequency, isPlaying }) => {
|
||||
const roomId = roomController.getUserRoom(socket.id);
|
||||
if (!roomId) return;
|
||||
|
||||
@ -201,7 +234,7 @@ module.exports = (io) => (socket) => {
|
||||
if (userRole !== 'composer') return;
|
||||
|
||||
if (gameController.updateFrequency(roomId, frequency)) {
|
||||
socket.to(roomId).emit("frequency-update", { frequency });
|
||||
socket.to(roomId).emit("frequency-update", { frequency, isPlaying });
|
||||
}
|
||||
});
|
||||
|
||||
@ -233,7 +266,7 @@ module.exports = (io) => (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("next-round", () => {
|
||||
socket.on("next-round", async () => {
|
||||
const roomId = roomController.getUserRoom(socket.id);
|
||||
if (!roomId || !roomController.isUserHost(socket.id)) return;
|
||||
|
||||
@ -244,8 +277,16 @@ module.exports = (io) => (socket) => {
|
||||
return socket.emit("error", { message: "At least 2 players are required" });
|
||||
}
|
||||
|
||||
if (gameController.startNewRound(roomId)) {
|
||||
handleRoundStart(roomId);
|
||||
try {
|
||||
const success = await gameController.startNewRound(roomId);
|
||||
if (success) {
|
||||
handleRoundStart(roomId);
|
||||
} else {
|
||||
socket.emit("error", { message: "Failed to start next round" });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error starting next round:", error);
|
||||
socket.emit("error", { message: "Failed to start next round due to an error" });
|
||||
}
|
||||
});
|
||||
|
||||
@ -257,4 +298,14 @@ module.exports = (io) => (socket) => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
socket.on("get-playlist-songs", async () => {
|
||||
try {
|
||||
const songs = await youtubeService.fetchPlaylistSongs();
|
||||
socket.emit("playlist-songs", { songs });
|
||||
} catch (error) {
|
||||
console.error("Error sending playlist songs:", error);
|
||||
socket.emit("playlist-songs", { songs: youtubeService.getDefaultSongs() });
|
||||
}
|
||||
});
|
||||
};
|
71
server/services/youtubeService.js
Normal file
71
server/services/youtubeService.js
Normal file
@ -0,0 +1,71 @@
|
||||
const YOUTUBE_API_KEY = process.env.YOUTUBE_API_KEY;
|
||||
const PLAYLIST_ID = "PLmXxqSJJq-yXrCPGIT2gn8b34JjOrl4Xf";
|
||||
|
||||
let cachedSongs = null;
|
||||
let lastFetchTime = 0;
|
||||
const CACHE_TTL = 3600000; // 1 hour
|
||||
|
||||
/**
|
||||
* Fetches songs from YouTube playlist and returns them
|
||||
*/
|
||||
const fetchPlaylistSongs = async () => {
|
||||
const now = Date.now();
|
||||
if (cachedSongs && cachedSongs.length > 0 && (now - lastFetchTime) < CACHE_TTL) {
|
||||
console.log("Returning cached YouTube songs:", cachedSongs.length);
|
||||
return cachedSongs;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log("Fetching songs from YouTube API...");
|
||||
const playlistUrl = `https://www.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails&maxResults=50&playlistId=${PLAYLIST_ID}&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) {
|
||||
console.error("No items found in YouTube playlist");
|
||||
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 ||
|
||||
"https://place-hold.it/500x500/333/fff?text=No%20Thumbnail",
|
||||
}));
|
||||
|
||||
cachedSongs = songs;
|
||||
lastFetchTime = now;
|
||||
console.log("Fetched and cached YouTube songs:", songs.length);
|
||||
|
||||
return songs;
|
||||
} catch (error) {
|
||||
console.error("Error fetching YouTube playlist:", error);
|
||||
|
||||
if (cachedSongs && cachedSongs.length > 0) {
|
||||
console.log("Using last cached songs due to API error");
|
||||
return cachedSongs;
|
||||
}
|
||||
|
||||
return [{
|
||||
id: 1,
|
||||
title: "Could not load songs",
|
||||
artist: "API Error",
|
||||
coverUrl: "https://place-hold.it/500x500/f00/fff?text=Error",
|
||||
youtubeId: "dQw4w9WgXcQ"
|
||||
}];
|
||||
}
|
||||
};
|
||||
|
||||
const getAvailableSongIds = async () => {
|
||||
const songs = await fetchPlaylistSongs();
|
||||
return songs.map(song => song.id);
|
||||
};
|
||||
|
||||
module.exports = {fetchPlaylistSongs, getAvailableSongIds};
|
Loading…
x
Reference in New Issue
Block a user