From 206988f4b711757a8237840735809329556e3869 Mon Sep 17 00:00:00 2001 From: Mathias Wagner Date: Sat, 1 Mar 2025 12:58:33 +0100 Subject: [PATCH] Implement proper youtube support --- .../YouTubePlayer/YouTubePlayer.jsx | 117 ++++++++++++++++ .../src/components/YouTubePlayer/styles.sass | 32 +++++ client/src/pages/Game/Game.jsx | 127 +++++++++++++++++- client/src/pages/Game/styles.sass | 66 ++++++++- client/src/services/youtubeService.js | 112 +++++++++++++++ server/controller/game.js | 56 ++++---- server/handler/connection.js | 42 +++++- server/services/youtubeService.js | 71 ++++++++++ 8 files changed, 585 insertions(+), 38 deletions(-) create mode 100644 client/src/components/YouTubePlayer/YouTubePlayer.jsx create mode 100644 client/src/components/YouTubePlayer/styles.sass create mode 100644 client/src/services/youtubeService.js create mode 100644 server/services/youtubeService.js 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 = () => { {song.title} {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};