diff --git a/client/src/components/YouTubePlayer/YouTubePlayer.jsx b/client/src/components/YouTubePlayer/YouTubePlayer.jsx
new file mode 100644
index 0000000..29749b6
--- /dev/null
+++ b/client/src/components/YouTubePlayer/YouTubePlayer.jsx
@@ -0,0 +1,117 @@
+import React, { useEffect, useRef, useState } from 'react';
+import './styles.sass';
+
+const YouTubePlayer = ({
+ videoId,
+ autoplay = false,
+ volume = 50,
+ startTime = 45,
+ onReady = () => {},
+ onError = () => {},
+ className = ''
+}) => {
+ const iframeRef = useRef(null);
+ const playerRef = useRef(null);
+ const [isLoaded, setIsLoaded] = useState(false);
+
+ console.log("YouTubePlayer rendering with videoId:", videoId, "volume:", volume, "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
+ },
+ events: {
+ 'onReady': (event) => {
+ console.log("YouTube player ready");
+ event.target.setVolume(volume);
+ if (autoplay) {
+ event.target.seekTo(startTime);
+ event.target.playVideo();
+ }
+ setIsLoaded(true);
+ onReady();
+ },
+ 'onError': (event) => {
+ console.error("YouTube player error:", event);
+ onError(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, onError, startTime]);
+
+ useEffect(() => {
+ if (playerRef.current && isLoaded) {
+ console.log("Setting YouTube volume to:", volume);
+ try {
+ playerRef.current.setVolume(volume);
+ } catch (error) {
+ console.error("Error setting volume:", error);
+ }
+ }
+ }, [volume, isLoaded]);
+
+ return (
+
+ );
+};
+
+export default YouTubePlayer;
diff --git a/client/src/components/YouTubePlayer/styles.sass b/client/src/components/YouTubePlayer/styles.sass
new file mode 100644
index 0000000..64781be
--- /dev/null
+++ b/client/src/components/YouTubePlayer/styles.sass
@@ -0,0 +1,32 @@
+.youtube-player-container
+ position: fixed
+ right: 20px
+ bottom: 120px
+ width: 300px
+ height: 200px
+ z-index: 100
+
+ &.hidden-player
+ width: 1px
+ height: 1px
+ opacity: 0
+ overflow: hidden
+ position: absolute
+ left: -9999px
+ top: -9999px
+
+.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
diff --git a/client/src/pages/Game/Game.jsx b/client/src/pages/Game/Game.jsx
index fe9c1f3..912d6e4 100644
--- a/client/src/pages/Game/Game.jsx
+++ b/client/src/pages/Game/Game.jsx
@@ -3,7 +3,17 @@ 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
+} 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,6 +44,11 @@ export const Game = () => {
const messageEndRef = useRef(null);
const timerIntervalRef = useRef(null);
+ const [allSongs, setAllSongs] = useState([]);
+ const [playerVolume, setPlayerVolume] = useState(30);
+ const [playbackError, setPlaybackError] = useState(null);
+ const [songsLoading, setSongsLoading] = useState(false);
+
useEffect(() => {
if (!connected) return;
@@ -144,6 +159,50 @@ export const Game = () => {
messageEndRef.current?.scrollIntoView({behavior: "smooth"});
}, [messages]);
+ 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) => {
setFrequency(newFrequency);
if (role === "composer") {
@@ -185,7 +244,27 @@ export const Game = () => {
setTimeLeft(0);
}, [send]);
- // Phase-specific content rendering
+ const handlePlayerReady = useCallback(() => {
+ setPlaybackError(null);
+ }, []);
+
+ const handlePlayerError = useCallback((error) => {
+ console.error("YouTube playback error:", error);
+ setPlaybackError("Error playing song - trying to continue...");
+ }, []);
+
+ const togglePlayback = useCallback(() => {
+ const audioElement = document.querySelector('audio');
+
+ if (audioElement) {
+ if (audioElement.paused) {
+ audioElement.play().catch(err => console.error("Play error:", err));
+ } else {
+ audioElement.pause();
+ }
+ }
+ }, []);
+
const renderPhaseContent = () => {
switch (phase) {
case "waiting":
@@ -264,7 +343,7 @@ export const Game = () => {
{selectedSong?.id === song.id && (
-
+
)}
@@ -285,7 +364,7 @@ export const Game = () => {
onClick={handleSubmitGuess}
disabled={!selectedSong}
>
-
+
Antwort einreichen
)}
@@ -429,6 +508,46 @@ export const Game = () => {
frequency={frequency}
/>
)}
+
+ {role === "composer" && currentSong && currentSong.youtubeId && (
+
+ )}
+
+ {playbackError && (
+
+ {playbackError}
+
+ )}
+
+ {phase === "composing" && role === "composer" && currentSong && (
+
+
+ Music Volume:
+ setPlayerVolume(parseInt(e.target.value))}
+ className="volume-slider"
+ />
+
+
+ )}
+
+ {songsLoading && (
+
+ Loading songs...
+
+ )}
);
};
diff --git a/client/src/pages/Game/styles.sass b/client/src/pages/Game/styles.sass
index 7c07018..017f742 100644
--- a/client/src/pages/Game/styles.sass
+++ b/client/src/pages/Game/styles.sass
@@ -961,4 +961,68 @@
transform: scale(1)
50%
opacity: 1
- transform: scale(1.05)
\ No newline at end of file
+ transform: scale(1.05)
+
+.volume-controls
+ position: fixed
+ top: 70px
+ right: 20px
+ background: rgba(0, 0, 0, 0.5)
+ padding: 10px 15px
+ border-radius: 10px
+ display: flex
+ align-items: center
+ z-index: 10
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3)
+
+ span
+ color: white
+ margin-right: 10px
+ font-size: 14px
+
+ .volume-slider
+ width: 100px
+ height: 6px
+ -webkit-appearance: none
+ appearance: none
+ background: rgba(255, 255, 255, 0.2)
+ border-radius: 3px
+ outline: none
+
+ &::-webkit-slider-thumb
+ -webkit-appearance: none
+ appearance: none
+ width: 16px
+ height: 16px
+ border-radius: 50%
+ background: $yellow
+ cursor: pointer
+ box-shadow: 0 0 10px rgba(255, 255, 0, 0.5)
+
+ &::-moz-range-thumb
+ width: 16px
+ height: 16px
+ border-radius: 50%
+ background: $yellow
+ cursor: pointer
+ box-shadow: 0 0 10px rgba(255, 255, 0, 0.5)
+
+.playback-error
+ position: fixed
+ bottom: 10px
+ left: 50%
+ transform: translateX(-50%)
+ background: rgba(255, 0, 0, 0.7)
+ color: white
+ padding: 8px 20px
+ border-radius: 20px
+ font-size: 14px
+ z-index: 1000
+ box-shadow: 0 4px 15px rgba(0, 0, 0, 0.3)
+ animation: fade-out 4s forwards
+
+@keyframes fade-out
+ 0%, 80%
+ opacity: 1
+ 100%
+ opacity: 0
\ No newline at end of file
diff --git a/client/src/services/youtubeService.js b/client/src/services/youtubeService.js
new file mode 100644
index 0000000..2ded73f
--- /dev/null
+++ b/client/src/services/youtubeService.js
@@ -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;
+ }
+};
diff --git a/server/controller/game.js b/server/controller/game.js
index 8fd5398..1eb584c 100644
--- a/server/controller/game.js
+++ b/server/controller/game.js
@@ -1,18 +1,5 @@
const roomController = require('./room');
-
-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 youtubeService = require('../services/youtubeService');
const gameStates = {};
@@ -44,7 +31,7 @@ const initializeGameState = (roomId) => {
return gameStates[roomId];
};
-const startNewRound = (roomId) => {
+const startNewRound = async (roomId) => {
const gameState = gameStates[roomId];
if (!gameState) return false;
@@ -63,9 +50,7 @@ const startNewRound = (roomId) => {
gameState.roles[user.id] = user.id === gameState.currentComposer ? 'composer' : 'guesser';
});
- selectSongAndOptions(gameState);
-
- return true;
+ return await selectSongAndOptions(gameState);
};
const determineNextComposer = (roomId, gameState, users) => {
@@ -82,16 +67,35 @@ const determineNextComposer = (roomId, gameState, users) => {
return users[(currentIndex + 1) % users.length].id;
};
-const selectSongAndOptions = (gameState) => {
- gameState.selectedSong = SONGS[Math.floor(Math.random() * SONGS.length)];
-
- const songOptions = [...SONGS].sort(() => Math.random() - 0.5).slice(0, 5);
-
- if (!songOptions.includes(gameState.selectedSong)) {
- songOptions[Math.floor(Math.random() * songOptions.length)] = gameState.selectedSong;
+const selectSongAndOptions = async (gameState) => {
+ try {
+ const availableIds = await youtubeService.getAvailableSongIds();
+
+ if (!availableIds || availableIds.length === 0) {
+ console.error("No song IDs available for selection");
+ return false;
}
- gameState.songOptions = songOptions;
+ const selectedId = availableIds[Math.floor(Math.random() * availableIds.length)];
+ gameState.selectedSong = { id: selectedId };
+
+ const optionIds = new Set([selectedId]);
+ const attempts = Math.min(20, availableIds.length);
+
+ let count = 0;
+ while (optionIds.size < 5 && count < attempts) {
+ const randomId = availableIds[Math.floor(Math.random() * availableIds.length)];
+ optionIds.add(randomId);
+ count++;
+ }
+
+ gameState.songOptions = Array.from(optionIds).map(id => ({ id }));
+
+ return true;
+ } catch (error) {
+ console.error("Error selecting songs and options:", error);
+ return false;
+ }
};
const getTimeRemaining = (roomId) => {
diff --git a/server/handler/connection.js b/server/handler/connection.js
index 85bd427..592c795 100644
--- a/server/handler/connection.js
+++ b/server/handler/connection.js
@@ -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;
@@ -147,7 +148,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,10 +163,19 @@ 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) => {
@@ -233,7 +243,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 +254,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 +275,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() });
+ }
+ });
};
\ No newline at end of file
diff --git a/server/services/youtubeService.js b/server/services/youtubeService.js
new file mode 100644
index 0000000..11887c6
--- /dev/null
+++ b/server/services/youtubeService.js
@@ -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};