Implement proper youtube support

This commit is contained in:
Mathias Wagner 2025-03-01 12:58:33 +01:00
parent f00ca9ba7c
commit 206988f4b7
8 changed files with 585 additions and 38 deletions

View File

@ -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 (
<div className={`youtube-player-container ${className}`}>
<div ref={iframeRef} className="youtube-embed" />
</div>
);
};
export default YouTubePlayer;

View File

@ -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

View File

@ -3,7 +3,17 @@ import {SocketContext} from "@/common/contexts/SocketContext";
import {useContext, useState, useEffect, useRef, useCallback} from "react"; import {useContext, useState, useEffect, useRef, useCallback} from "react";
import MusicSlider from "@/pages/Game/components/MusicSlider"; import MusicSlider from "@/pages/Game/components/MusicSlider";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faMessage, 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 = () => { export const Game = () => {
const {send, on, socket, connected, connect} = useContext(SocketContext); const {send, on, socket, connected, connect} = useContext(SocketContext);
@ -34,6 +44,11 @@ export const Game = () => {
const messageEndRef = useRef(null); const messageEndRef = useRef(null);
const timerIntervalRef = 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(() => { useEffect(() => {
if (!connected) return; if (!connected) return;
@ -144,6 +159,50 @@ export const Game = () => {
messageEndRef.current?.scrollIntoView({behavior: "smooth"}); messageEndRef.current?.scrollIntoView({behavior: "smooth"});
}, [messages]); }, [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) => { const handleFrequencyChange = useCallback((newFrequency) => {
setFrequency(newFrequency); setFrequency(newFrequency);
if (role === "composer") { if (role === "composer") {
@ -185,7 +244,27 @@ export const Game = () => {
setTimeLeft(0); setTimeLeft(0);
}, [send]); }, [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 = () => { const renderPhaseContent = () => {
switch (phase) { switch (phase) {
case "waiting": case "waiting":
@ -264,7 +343,7 @@ export const Game = () => {
<img src={song.coverUrl} alt={song.title} /> <img src={song.coverUrl} alt={song.title} />
{selectedSong?.id === song.id && ( {selectedSong?.id === song.id && (
<div className="selection-indicator"> <div className="selection-indicator">
<FontAwesomeIcon icon="fa-solid fa-check-circle" /> <FontAwesomeIcon icon={faCheckCircle} />
</div> </div>
)} )}
</div> </div>
@ -285,7 +364,7 @@ export const Game = () => {
onClick={handleSubmitGuess} onClick={handleSubmitGuess}
disabled={!selectedSong} disabled={!selectedSong}
> >
<FontAwesomeIcon icon="fa-solid fa-paper-plane" /> <FontAwesomeIcon icon={faPaperPlane} />
Antwort einreichen Antwort einreichen
</button> </button>
)} )}
@ -429,6 +508,46 @@ export const Game = () => {
frequency={frequency} frequency={frequency}
/> />
)} )}
{role === "composer" && currentSong && currentSong.youtubeId && (
<YouTubePlayer
videoId={currentSong.youtubeId}
autoplay={phase === "composing"}
volume={playerVolume}
startTime={currentSong.refrainTime || 45}
onReady={handlePlayerReady}
onError={handlePlayerError}
className={phase === "composing" ? "" : "hidden-player"}
/>
)}
{playbackError && (
<div className="playback-error">
{playbackError}
</div>
)}
{phase === "composing" && role === "composer" && currentSong && (
<div className="player-controls">
<div className="volume-controls">
<span>Music Volume:</span>
<input
type="range"
min="0"
max="100"
value={playerVolume}
onChange={(e) => setPlayerVolume(parseInt(e.target.value))}
className="volume-slider"
/>
</div>
</div>
)}
{songsLoading && (
<div className="songs-loading-indicator">
Loading songs...
</div>
)}
</div> </div>
); );
}; };

View File

@ -961,4 +961,68 @@
transform: scale(1) transform: scale(1)
50% 50%
opacity: 1 opacity: 1
transform: scale(1.05) 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

View File

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

View File

@ -1,18 +1,5 @@
const roomController = require('./room'); 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 gameStates = {}; const gameStates = {};
@ -44,7 +31,7 @@ const initializeGameState = (roomId) => {
return gameStates[roomId]; return gameStates[roomId];
}; };
const startNewRound = (roomId) => { const startNewRound = async (roomId) => {
const gameState = gameStates[roomId]; const gameState = gameStates[roomId];
if (!gameState) return false; if (!gameState) return false;
@ -63,9 +50,7 @@ const startNewRound = (roomId) => {
gameState.roles[user.id] = user.id === gameState.currentComposer ? 'composer' : 'guesser'; gameState.roles[user.id] = user.id === gameState.currentComposer ? 'composer' : 'guesser';
}); });
selectSongAndOptions(gameState); return await selectSongAndOptions(gameState);
return true;
}; };
const determineNextComposer = (roomId, gameState, users) => { const determineNextComposer = (roomId, gameState, users) => {
@ -82,16 +67,35 @@ const determineNextComposer = (roomId, gameState, users) => {
return users[(currentIndex + 1) % users.length].id; return users[(currentIndex + 1) % users.length].id;
}; };
const selectSongAndOptions = (gameState) => { const selectSongAndOptions = async (gameState) => {
gameState.selectedSong = SONGS[Math.floor(Math.random() * SONGS.length)]; try {
const availableIds = await youtubeService.getAvailableSongIds();
const songOptions = [...SONGS].sort(() => Math.random() - 0.5).slice(0, 5);
if (!availableIds || availableIds.length === 0) {
if (!songOptions.includes(gameState.selectedSong)) { console.error("No song IDs available for selection");
songOptions[Math.floor(Math.random() * songOptions.length)] = gameState.selectedSong; 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) => { const getTimeRemaining = (roomId) => {

View File

@ -1,5 +1,6 @@
const roomController = require("../controller/room"); const roomController = require("../controller/room");
const gameController = require("../controller/game"); const gameController = require("../controller/game");
const youtubeService = require('../services/youtubeService');
module.exports = (io) => (socket) => { module.exports = (io) => (socket) => {
let currentRoomId = null; let currentRoomId = null;
@ -147,7 +148,7 @@ module.exports = (io) => (socket) => {
currentRoomId = roomId; currentRoomId = roomId;
}); });
socket.on("start-game", () => { socket.on("start-game", async () => {
const roomId = roomController.getUserRoom(socket.id); const roomId = roomController.getUserRoom(socket.id);
if (!roomId || !roomController.isUserHost(socket.id)) { if (!roomId || !roomController.isUserHost(socket.id)) {
return socket.emit("not-authorized"); return socket.emit("not-authorized");
@ -162,10 +163,19 @@ module.exports = (io) => (socket) => {
if (!roomController.startGame(roomId)) return; if (!roomController.startGame(roomId)) return;
gameController.initializeGameState(roomId); gameController.initializeGameState(roomId);
if (!gameController.startNewRound(roomId)) return;
io.to(roomId).emit("game-started"); try {
handleRoundStart(roomId); 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) => { 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); const roomId = roomController.getUserRoom(socket.id);
if (!roomId || !roomController.isUserHost(socket.id)) return; 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" }); return socket.emit("error", { message: "At least 2 players are required" });
} }
if (gameController.startNewRound(roomId)) { try {
handleRoundStart(roomId); 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() });
}
});
}; };

View 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};