Add screens and components for Song Battle game, including Home, Lobby, Voting, Results, and Song Submission screens; implement YouTube video embedding and styles
Some checks failed
Publish Docker image / Push Docker image to Docker Hub (push) Failing after 7m12s

This commit is contained in:
Mathias Wagner 2025-04-24 16:21:43 +02:00
parent 44a75ba715
commit 22eca7d4e0
28 changed files with 5046 additions and 402 deletions

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg" href="/logo.svg" />
<link rel="icon" type="image/png" href="/logo.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Liedkampf</title>
</head>

BIN
client/public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,24 +0,0 @@
<svg width="150" height="200" viewBox="0 0 150 200" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="noteGradient" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ff007f"/>
<stop offset="100%" stop-color="#7f00ff"/>
</linearGradient>
<radialGradient id="glow" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.5"/>
<stop offset="100%" stop-color="#000000" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- Note Stem -->
<path d="M110 20C110 12 104 6 96 6C88 6 82 12 82 20V106C76 102 66 100 56 104C40 110 32 126 38 142C44 158 60 168 76 162C90 157 96 144 94 130V62L110 58V20Z" fill="url(#noteGradient)" stroke="#000" stroke-width="3" />
<!-- Outer Glow Circle -->
<circle cx="66" cy="134" r="32" fill="url(#noteGradient)" stroke="black" stroke-width="5" />
<!-- Aggressive Tail -->
<path d="M110 58L132 50L132 26C132 16 122 8 112 8L110 20" fill="#7f00ff" stroke="#000" stroke-width="2"/>
<!-- Shine Highlight -->
<ellipse cx="92" cy="30" rx="8" ry="14" fill="white" opacity="0.2"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,9 +1,17 @@
import {useEffect, useState} from "react";
import {FontAwesomeIcon} from "@fortawesome/react-fontawesome";
import {faDrum, faGuitar, faHeadphones, faMusic} from "@fortawesome/free-solid-svg-icons";
import { useEffect, useState } from "react";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faDrum, faGuitar, faHeadphones, faMusic } from "@fortawesome/free-solid-svg-icons";
import { useGame, useSocket } from "./context/GameContext";
import LobbyScreen from "./components/LobbyScreen";
import HomeScreen from "./components/HomeScreen";
import SongSubmissionScreen from "./components/SongSubmissionScreen";
import VotingScreen from "./components/VotingScreen";
import ResultsScreen from "./components/ResultsScreen";
const App = () => {
const [cursorPos, setCursorPos] = useState({x: 0, y: 0});
const { isConnected } = useSocket();
const { lobby, error } = useGame();
const musicNotes = [
{id: 1, top: "8%", left: "20%", icon: faMusic, scale: 1.2},
@ -28,6 +36,26 @@ const App = () => {
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
// Determine which screen to show based on game state
const renderGameScreen = () => {
if (!lobby) {
return <HomeScreen />;
}
switch (lobby.state) {
case 'LOBBY':
return <LobbyScreen />;
case 'SONG_SUBMISSION':
return <SongSubmissionScreen />;
case 'VOTING':
return <VotingScreen />;
case 'FINISHED':
return <ResultsScreen />;
default:
return <HomeScreen />;
}
};
return (
<>
<div className="background-elements">
@ -54,6 +82,22 @@ const App = () => {
</div>
))}
</div>
<div className="content-container">
{!isConnected && (
<div className="connection-status">
<p>Connecting to server...</p>
</div>
)}
{error && (
<div className="error-message">
<p>{error}</p>
</div>
)}
{renderGameScreen()}
</div>
</>
)
}

View File

@ -0,0 +1,116 @@
// Button styles for the Song Battle application
// Base button style
.btn
display: inline-block
padding: 0.75rem 1.5rem
border: none
border-radius: 0.5rem
font-weight: 600
cursor: pointer
transition: all 0.3s ease
text-transform: uppercase
letter-spacing: 1px
position: relative
overflow: hidden
text-align: center
min-width: 120px
// Sheen animation on hover
&:before
content: ''
position: absolute
top: 0
left: -100%
width: 100%
height: 100%
background: linear-gradient(
120deg,
transparent,
rgba(255, 255, 255, 0.3),
transparent
)
transition: all 0.6s
&:hover:before
left: 100%
&:hover
transform: translateY(-3px)
box-shadow: 0 7px 15px rgba(0, 0, 0, 0.3)
&:active
transform: translateY(0)
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2)
&:disabled
opacity: 0.5
cursor: not-allowed
transform: none
box-shadow: none
// Primary button
.btn.primary
background: linear-gradient(45deg, darken($primary, 10%), $primary)
color: #000
box-shadow: 0 4px 15px rgba($primary, 0.5)
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.4)
&:hover
background: linear-gradient(45deg, $primary, lighten($primary, 10%))
&:focus
box-shadow: 0 0 0 2px rgba($primary, 0.5)
// Secondary button
.btn.secondary
background: linear-gradient(45deg, darken($secondary, 10%), $secondary)
color: #000
box-shadow: 0 4px 15px rgba($secondary, 0.5)
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.4)
&:hover
background: linear-gradient(45deg, $secondary, lighten($secondary, 10%))
&:focus
box-shadow: 0 0 0 2px rgba($secondary, 0.5)
// Danger button
.btn.danger
background: linear-gradient(45deg, darken($danger, 10%), $danger)
color: #fff
box-shadow: 0 4px 15px rgba($danger, 0.5)
&:hover
background: linear-gradient(45deg, $danger, lighten($danger, 10%))
&:focus
box-shadow: 0 0 0 2px rgba($danger, 0.5)
// Special game-themed buttons
.btn.game-action
background: linear-gradient(45deg, darken($accent, 10%), $accent)
color: #fff
border-radius: 2rem
padding: 0.75rem 2rem
transform-style: preserve-3d
perspective: 1000px
&:after
content: ''
position: absolute
left: 0
top: 0
width: 100%
height: 100%
background: rgba(255, 255, 255, 0.1)
border-radius: 2rem
transform: translateZ(-10px)
z-index: -1
transition: transform 0.3s
&:hover
transform: translateY(-5px) rotateX(10deg)
&:after
transform: translateZ(-5px)

View File

@ -1,16 +1,17 @@
$background: rgba(255, 255, 255, 0.14)
$border: rgba(255, 255, 255, 0.35)
$white: #ECECEC
$green: #26EE5E
$black: #000
$dark-gray: #1e1e1e
$light-gray: #aaa
$red: #ff0000
// Colors for Song Battle application
$pink: #ff6bb3
$blue: #4d9dff
$purple: #9c6bff
$cyan: #6bffea
$orange: #ff9b6b
$yellow: #ffde6b
$mint-green: #85ffbd
// Main colors
$background: #0f0f1a // Kept for compatibility
$card-bg: rgba(20, 20, 35, 0.85) // Semi-transparent dark card background
$text: #ffffff
$text-muted: rgba(255, 255, 255, 0.7) // Brighter for better contrast with gradient
// Brand colors
$primary: #f0c3ff // Light purple - complementary to gradient
$secondary: #00e5ff // Bright cyan - stands out against pink/purple
$accent: #ffcc00 // Bright gold - contrasts with pink/purple
// Status colors
$success: #00c853
$warning: #ffc107
$danger: #ff5252

View File

@ -0,0 +1,89 @@
// Home Screen styles
.home-screen
display: flex
flex-direction: column
align-items: center
justify-content: center
min-height: 100%
padding: 2rem
position: relative
.logo
display: flex
flex-direction: column
align-items: center
margin-bottom: 2rem
.logo-image
image-rendering: pixelated
width: 120px
height: auto
margin-bottom: 1rem
animation: pulse 2s infinite ease-in-out
filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.7))
h1
font-size: 3.5rem
margin: 0
color: white
text-shadow: 0 0 10px rgba(0, 0, 0, 0.5), 2px 2px 4px rgba(0, 0, 0, 0.3)
.card
background-color: $card-bg
border-radius: 1rem
padding: 2rem
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 15px rgba(255, 255, 255, 0.1)
border: 1px solid rgba(255, 255, 255, 0.1)
backdrop-filter: blur(10px)
width: 100%
max-width: 500px
.tabs
display: flex
margin-bottom: 1.5rem
border-bottom: 2px solid rgba(255, 255, 255, 0.1)
button
flex: 1
background: none
border: none
color: $text-muted
padding: 1rem
font-size: 1rem
cursor: pointer
transition: color 0.2s, border-bottom 0.2s
position: relative
&:after
content: ''
position: absolute
bottom: -2px
left: 0
width: 100%
height: 2px
background-color: transparent
transition: background-color 0.2s
&.active
color: $primary
&:after
background-color: $primary
.home-footer
margin-top: 2rem
text-align: center
color: $text-muted
font-style: italic
@keyframes pulse
0%
transform: scale(1)
opacity: 1
50%
transform: scale(1.1)
opacity: 0.8
100%
transform: scale(1)
opacity: 1

View File

@ -0,0 +1,193 @@
// Results Screen styles
.results-screen
display: flex
flex-direction: column
min-height: 100%
padding: 1.5rem
position: relative
overflow: hidden
header
display: flex
justify-content: center
margin-bottom: 2rem
h1
margin: 0
color: $primary
text-shadow: 0 0 8px rgba($primary, 0.5)
display: flex
align-items: center
gap: 0.5rem
font-size: 2.5rem
svg
color: $accent
.winner-card
background-color: $card-bg
border-radius: 1rem
padding: 1.5rem
margin-bottom: 2rem
display: flex
flex-direction: column
gap: 1.5rem
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3)
position: relative
overflow: hidden
&::after
content: ''
position: absolute
top: 0
left: 0
right: 0
height: 5px
background: linear-gradient(to right, $primary, $accent)
@media (min-width: 768px)
flex-direction: row
align-items: stretch
.winner-info
flex: 1
h2
margin: 0 0 0.5rem 0
font-size: 1.8rem
color: $text
h3
margin: 0 0 1rem 0
color: $text-muted
font-size: 1.2rem
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif
.submitter
font-size: 0.9rem
color: $text-muted
margin-top: auto
.winner-video
flex: 1
min-height: 250px
position: relative
.youtube-embed
position: absolute
top: 0
left: 0
width: 100%
height: 100%
border-radius: 0.5rem
overflow: hidden
.winner-placeholder
flex: 1
display: flex
align-items: center
justify-content: center
background-color: rgba(0, 0, 0, 0.3)
border-radius: 0.5rem
color: $accent
.results-actions
display: flex
flex-wrap: wrap
gap: 1rem
justify-content: center
margin-bottom: 2rem
.btn
padding: 0.75rem 1.5rem
display: flex
align-items: center
gap: 0.5rem
.battle-history
background-color: $card-bg
border-radius: 1rem
padding: 1.5rem
h3
margin-top: 0
color: $secondary
.battles-list
display: flex
flex-direction: column
gap: 1.5rem
.battle-item
background-color: rgba(255, 255, 255, 0.05)
border-radius: 0.5rem
padding: 1rem
.battle-header
margin-bottom: 1rem
h4
margin: 0
font-size: 1.1rem
color: $text
.battle-songs
display: flex
align-items: center
gap: 1rem
.battle-song
flex: 1
padding: 1rem
border-radius: 0.5rem
background-color: rgba(0, 0, 0, 0.2)
position: relative
&.winner
&::after
content: ''
position: absolute
top: 0
left: 0
right: 0
height: 3px
background: $success
.song-info
h5
margin: 0 0 0.25rem 0
font-size: 1rem
p
margin: 0 0 0.5rem 0
color: $text-muted
font-size: 0.9rem
.votes
display: inline-block
font-size: 0.8rem
padding: 0.25rem 0.5rem
border-radius: 1rem
background-color: rgba(255, 255, 255, 0.1)
.versus
font-family: 'Bangers', cursive
font-size: 1.5rem
color: $accent
text-shadow: 0 0 5px rgba($accent, 0.5)
// Confetti animation
.confetti
position: absolute
width: 10px
height: 20px
transform-origin: center bottom
animation: fall 5s linear forwards
z-index: -1
@keyframes fall
0%
transform: translateY(-100vh) rotate(0deg)
100%
transform: translateY(100vh) rotate(360deg)

View File

@ -0,0 +1,257 @@
// Song Submission Screen styles
.song-submission-screen
display: flex
flex-direction: column
min-height: 100%
padding: 1.5rem
.song-submission-header
display: flex
justify-content: space-between
align-items: center
margin-bottom: 2rem
flex-wrap: wrap
gap: 1rem
h1
margin: 0
color: $primary
text-shadow: 0 0 8px rgba($primary, 0.5)
display: flex
align-items: center
gap: 0.5rem
.songs-counter
background-color: rgba($card-bg, 0.7)
border-radius: 0.5rem
padding: 0.75rem 1rem
font-size: 1.1rem
.counter
font-weight: bold
color: $primary
.submission-content
display: flex
flex-direction: column
flex-grow: 1
gap: 1.5rem
@media (min-width: 768px)
flex-direction: row
.songs-list
flex: 1.5
background-color: $card-bg
border-radius: 1rem
padding: 1.5rem
h2
margin-top: 0
display: flex
align-items: center
gap: 0.5rem
color: $primary
.songs-grid
display: grid
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr))
gap: 1rem
.song-card
background-color: rgba(255, 255, 255, 0.05)
border-radius: 0.5rem
padding: 1rem
position: relative
transition: transform 0.2s, box-shadow 0.2s
&:hover
transform: translateY(-3px)
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3)
h3
margin: 0 0 0.25rem 0
font-size: 1.2rem
p
margin: 0 0 1rem 0
color: $text-muted
.song-link
font-size: 0.85rem
display: inline-flex
align-items: center
gap: 0.25rem
color: $secondary
.remove-btn
position: absolute
top: 0.75rem
right: 0.75rem
background: none
border: none
color: $text-muted
cursor: pointer
padding: 0.25rem
border-radius: 50%
&:hover
color: $danger
background-color: rgba(255, 255, 255, 0.1)
.add-song-btn
display: flex
align-items: center
justify-content: center
padding: 1rem
background-color: rgba(255, 255, 255, 0.05)
border: 2px dashed rgba(255, 255, 255, 0.2)
border-radius: 0.5rem
color: $text-muted
cursor: pointer
gap: 0.5rem
transition: all 0.2s
margin-top: 1rem
&:hover
background-color: rgba($primary, 0.1)
border-color: rgba($primary, 0.4)
color: $text
.status-section
flex: 1
display: flex
flex-direction: column
gap: 1.5rem
.ready-section
background-color: $card-bg
border-radius: 1rem
padding: 1.5rem
display: flex
flex-direction: column
align-items: center
text-align: center
h3
margin-top: 0
color: $success
.ready-btn
margin-top: 1rem
padding: 1rem 2rem
.player-status
background-color: $card-bg
border-radius: 1rem
padding: 1.5rem
flex-grow: 1
h4
margin-top: 0
color: $secondary
.players-ready-list
list-style: none
padding: 0
margin: 0
li
padding: 0.75rem 1rem
margin-bottom: 0.5rem
border-radius: 0.5rem
background-color: rgba(255, 255, 255, 0.05)
display: flex
align-items: center
&.ready
border-left: 3px solid $success
&.not-ready
border-left: 3px solid $warning
.ready-icon
margin-left: auto
color: $success
.songs-count
margin-left: auto
font-size: 0.9rem
color: $warning
// Song submission form modal
.song-form
background-color: $card-bg
border-radius: 1rem
padding: 1.5rem
margin-top: 1.5rem
h3
margin-top: 0
color: $primary
.search-group
margin-bottom: 2rem
.search-container
position: relative
.spinner-icon
position: absolute
right: 1rem
top: 50%
transform: translateY(-50%)
color: $secondary
.search-input
width: 100%
padding-right: 2.5rem
.search-results
position: absolute
top: 100%
left: 0
right: 0
max-height: 300px
overflow-y: auto
background: rgba($card-bg, 0.95)
border-radius: 0.5rem
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2)
z-index: 10
backdrop-filter: blur(5px)
.search-result
display: flex
align-items: center
padding: 0.75rem
cursor: pointer
border-bottom: 1px solid rgba($text, 0.1)
transition: background-color 0.2s
&:hover
background: rgba($primary, 0.1)
.result-thumbnail
width: 60px
height: 45px
object-fit: cover
border-radius: 0.25rem
margin-right: 1rem
.result-info
flex: 1
h4
margin: 0
font-size: 0.9rem
color: $text
p
margin: 0.25rem 0 0
font-size: 0.8rem
color: $text-muted
.spinner-icon
margin-left: 0.5rem
color: $secondary

View File

@ -0,0 +1,199 @@
// Voting Screen styles
.voting-screen
display: flex
flex-direction: column
min-height: 100%
padding: 1.5rem
.voting-header
display: flex
justify-content: space-between
align-items: center
margin-bottom: 2rem
flex-wrap: wrap
gap: 1rem
h1
margin: 0
color: $primary
text-shadow: 0 0 8px rgba($primary, 0.5)
display: flex
align-items: center
gap: 0.5rem
.round-info
background-color: rgba($card-bg, 0.7)
border-radius: 0.5rem
padding: 0.75rem 1rem
display: flex
align-items: center
gap: 0.75rem
span
font-weight: bold
.voted-badge
background-color: $success
color: #fff
border-radius: 1rem
padding: 0.25rem 0.75rem
font-size: 0.85rem
animation: pulse 2s infinite
.battle-container
display: flex
flex-direction: column
align-items: center
gap: 1.5rem
margin-bottom: 2rem
perspective: 1000px
@media (min-width: 768px)
flex-direction: row
align-items: stretch
.song-card
flex: 1
background-color: $card-bg
border-radius: 1rem
padding: 1.5rem
display: flex
flex-direction: column
transition: transform 0.3s ease, box-shadow 0.3s ease, border 0.3s ease
border: 2px solid transparent
cursor: pointer
position: relative
overflow: hidden
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4)
&:hover:not(.voted)
transform: translateY(-5px)
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.6)
&.selected:not(.voted)
border-color: $secondary
box-shadow: 0 0 25px rgba($secondary, 0.4)
transform: translateY(-8px) scale(1.02)
.song-spotlight
opacity: 1
&.voted
cursor: default
.song-spotlight
position: absolute
top: -50%
left: -50%
width: 200%
height: 200%
background: radial-gradient(ellipse at center, rgba($secondary, 0.3) 0%, rgba($secondary, 0) 70%)
pointer-events: none
opacity: 0
transition: opacity 0.5s
z-index: 1
.song-details
margin-bottom: 1rem
h3
margin: 0 0 0.25rem 0
font-size: 1.5rem
p
margin: 0
color: $text-muted
.video-container
width: 100%
flex-grow: 1
min-height: 200px
position: relative
margin-bottom: 0.5rem
.youtube-embed
position: absolute
top: 0
left: 0
width: 100%
height: 100%
border-radius: 0.5rem
overflow: hidden
.no-video
flex-grow: 1
min-height: 200px
display: flex
flex-direction: column
align-items: center
justify-content: center
background-color: rgba(0, 0, 0, 0.3)
border-radius: 0.5rem
color: $text-muted
font-size: 3rem
gap: 1rem
span
font-size: 1rem
.vote-count
position: absolute
bottom: 1rem
right: 1rem
background-color: rgba($background, 0.8)
padding: 0.5rem 1rem
border-radius: 2rem
font-size: 0.9rem
&::after
content: ''
position: absolute
top: 0
left: 0
right: 0
bottom: 0
background: linear-gradient(to right, $primary, $secondary)
opacity: 0.2
border-radius: 2rem
z-index: -1
.versus
font-family: 'Bangers', cursive
font-size: 2rem
color: $accent
text-shadow: 0 0 10px rgba($accent, 0.5)
display: flex
align-items: center
padding: 0 1rem
@media (min-width: 768px)
padding: 1rem
.voting-actions
display: flex
justify-content: center
margin-bottom: 2rem
.btn
padding: 0.75rem 2rem
font-size: 1.1rem
.voting-status
margin-top: auto
text-align: center
p
color: $text-muted
font-style: italic
margin-bottom: 0.5rem
.votes-count
display: inline-block
padding: 0.5rem 1rem
background-color: rgba($card-bg, 0.7)
border-radius: 0.5rem
span:first-child
font-weight: bold
color: $secondary

View File

@ -0,0 +1,11 @@
// YouTube Embed component styles
.youtube-embed
width: 100%
height: 100%
background-color: #000
border-radius: 0.5rem
overflow: hidden
iframe
border: none

View File

@ -0,0 +1,181 @@
// YouTube Search Component Styles
@import '../colors'
.search-group
margin-bottom: 1.5rem
.search-container
position: relative
margin-bottom: 1rem
.search-input
width: 100%
padding: 0.75rem 2.5rem 0.75rem 1rem
border-radius: 0.5rem
background: rgba(255, 255, 255, 0.1)
border: 1px solid rgba(255, 255, 255, 0.2)
color: $text
font-size: 1rem
&:focus
outline: none
border-color: $primary
box-shadow: 0 0 0 3px rgba($primary, 0.3)
.spinner-icon, .clear-icon
position: absolute
top: 50%
right: 1rem
transform: translateY(-50%)
color: $text-muted
.clear-icon
cursor: pointer
&:hover
color: $text
.selected-video
background: rgba($card-bg, 0.6)
border-radius: 0.75rem
padding: 1rem
margin-bottom: 1.5rem
border: 1px solid rgba($primary, 0.3)
.preview-header
display: flex
justify-content: space-between
align-items: center
margin-bottom: 0.75rem
h4
margin: 0
color: $primary
.preview-toggle
background: transparent
border: none
color: $secondary
cursor: pointer
padding: 0.25rem 0.5rem
border-radius: 0.25rem
font-size: 0.85rem
&:hover
background: rgba($secondary, 0.15)
.video-details
display: flex
gap: 1rem
margin-bottom: 1rem
.selected-thumbnail
width: 120px
height: 90px
object-fit: cover
border-radius: 0.25rem
.selected-info
display: flex
flex-direction: column
justify-content: center
h4
margin: 0 0 0.25rem 0
font-size: 1rem
p
margin: 0
color: $text-muted
font-size: 0.9rem
.video-preview
position: relative
padding-bottom: 56.25% // 16:9 aspect ratio
height: 0
overflow: hidden
border-radius: 0.5rem
margin-top: 1rem
iframe
position: absolute
top: 0
left: 0
width: 100%
height: 100%
border-radius: 0.5rem
.search-results
background: rgba($card-bg, 0.6)
border-radius: 0.75rem
padding: 1rem
max-height: 400px
overflow-y: auto
h4
margin-top: 0
margin-bottom: 0.75rem
color: $primary
font-size: 0.9rem
text-transform: uppercase
letter-spacing: 0.05em
.search-result
display: flex
padding: 0.75rem
border-radius: 0.5rem
gap: 1rem
cursor: pointer
transition: background-color 0.2s ease
margin-bottom: 0.5rem
&:hover
background-color: rgba(255, 255, 255, 0.05)
&.selected
background-color: rgba($primary, 0.15)
border-left: 3px solid $primary
.result-thumbnail-container
position: relative
width: 120px
flex-shrink: 0
.result-thumbnail
width: 120px
height: 68px
object-fit: cover
border-radius: 0.25rem
.preview-button
position: absolute
right: 0.25rem
bottom: 0.25rem
background: rgba(0, 0, 0, 0.6)
border: none
color: white
width: 24px
height: 24px
border-radius: 50%
display: flex
align-items: center
justify-content: center
opacity: 0.7
cursor: pointer
&:hover
opacity: 1
background: rgba($secondary, 0.8)
.result-info
flex-grow: 1
h4
margin: 0 0 0.25rem 0
font-size: 0.95rem
text-transform: none
letter-spacing: normal
p
margin: 0
color: $text-muted
font-size: 0.85rem

View File

@ -1,157 +1,124 @@
@import "@/common/styles/colors"
@import "./colors"
.form-base
background: rgba(255, 255, 255, 0.1)
backdrop-filter: blur(15px)
border: 1px solid rgba(255, 255, 255, 0.2)
border-radius: 20px
padding: 2.5rem
width: 100%
position: relative
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15), 0 0 20px rgba(255, 255, 255, 0.1)
.form-group
margin-bottom: 1.5rem
h2
margin: 0 0 2rem
text-align: center
color: $white
font-size: 2.5rem
text-shadow: 0 0 10px rgba(255, 255, 255, 0.4)
form
display: flex
flex-direction: column
gap: 1.5rem
.input-group
position: relative
input
width: 100%
padding: 1.2rem 1.5rem
background: rgba(255, 255, 255, 0.07)
border: 1px solid rgba(255, 255, 255, 0.1)
border-radius: 12px
color: $white
font-family: 'Bangers', sans-serif
font-size: 1.2rem
letter-spacing: 0.15rem
transition: all 0.3s ease
outline: none
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1)
label
display: block
margin-bottom: 0.5rem
font-weight: 500
.required
color: $danger
margin-left: 0.25rem
input[type="text"],
input[type="number"],
input[type="email"],
input[type="url"],
input[type="password"],
textarea,
select
width: 100%
padding: 0.75rem
background-color: rgba(0, 0, 0, 0.4)
border: 2px solid rgba(255, 255, 255, 0.3)
border-radius: 0.5rem
color: $text
font-size: 1rem
transition: all 0.3s ease
box-sizing: border-box
box-shadow: 0 0 10px rgba(0, 0, 0, 0.2)
&:focus
border-color: $secondary
box-shadow: 0 0 15px rgba($secondary, 0.5)
outline: none
transform: scale(1.02)
&::placeholder
color: rgba(255, 255, 255, 0.5)
&:focus
border-color: rgba(255, 255, 255, 0.5)
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15), 0 0 10px rgba(255, 255, 255, 0.2)
border-color: $primary
box-shadow: 0 0 0 2px rgba($primary, 0.25)
outline: none
&.error
border-color: rgba(255, 0, 0, 0.5)
animation: shake 0.5s cubic-bezier(.36,.07,.19,.97) both
&::placeholder
color: rgba(255, 255, 255, 0.4)
.error-message
position: absolute
color: rgba(255, 100, 100, 0.9)
font-size: 0.85rem
bottom: -1.2rem
left: 0.2rem
animation: fade-in 0.3s ease
.button
border: none
border-radius: 12px
padding: 1.2rem
color: $white
font-family: 'Bangers', sans-serif
font-size: 1.3rem
letter-spacing: 0.15rem
cursor: pointer
transition: all 0.3s ease
display: flex
justify-content: center
align-items: center
gap: 0.8rem
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15)
&:hover
transform: translateY(-3px)
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2)
&:active
transform: translateY(0)
.submit-button
@extend .button
margin-top: 1rem
&.join-button
background: linear-gradient(45deg, $blue, $purple)
&.checkbox
display: flex
align-items: center
&:hover
background: linear-gradient(45deg, lighten($blue, 10%), lighten($purple, 10%))
label
display: flex
align-items: center
margin: 0
cursor: pointer
&.create-button
background: linear-gradient(45deg, $pink, $purple)
&:hover
background: linear-gradient(45deg, lighten($pink, 10%), lighten($purple, 10%))
input[type="checkbox"]
margin-right: 0.5rem
cursor: pointer
.back-button
position: absolute
top: -4rem
left: 0
background: rgba(255, 255, 255, 0.1)
border: 1px solid rgba(255, 255, 255, 0.2)
border-radius: 10px
// Form actions
.form-actions
display: flex
justify-content: flex-end
gap: 1rem
margin-top: 1.5rem
// Buttons
.btn
display: inline-flex
align-items: center
justify-content: center
gap: 0.7rem
color: rgba(255, 255, 255, 0.9)
padding: 0.75rem 1.5rem
border: none
border-radius: 0.5rem
font-size: 1rem
font-weight: 500
cursor: pointer
font-family: 'Bangers', sans-serif
font-size: 1.1rem
padding: 0.7rem 1.3rem
transition: all 0.3s ease
letter-spacing: 0.1rem
backdrop-filter: blur(10px)
transition: all 0.2s
svg
font-size: 1rem
transition: transform 0.3s ease
margin-right: 0.5rem
&:hover
color: $white
background: rgba(255, 255, 255, 0.2)
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1)
&.primary
background-color: $primary
color: white
&:hover
background-color: darken($primary, 10%)
&:active
background-color: darken($primary, 15%)
&:disabled
background-color: darken($primary, 30%)
cursor: not-allowed
opacity: 0.7
&.secondary
background-color: rgba(255, 255, 255, 0.1)
color: $text
&:hover
background-color: rgba(255, 255, 255, 0.2)
&:active
background-color: rgba(255, 255, 255, 0.25)
&.danger
background-color: rgba($danger, 0.2)
color: $danger
&:hover
background-color: $danger
color: white
&.icon
padding: 0.5rem 1rem
svg
transform: translateX(-5px)
.glassy-card
background: rgba(255, 255, 255, 0.07)
backdrop-filter: blur(10px)
border: 1px solid rgba(255, 255, 255, 0.2)
border-radius: 20px
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1), 0 0 20px rgba(255, 255, 255, 0.1)
overflow: hidden
transition: all 0.3s ease
@keyframes shake
10%, 90%
transform: translate3d(-1px, 0, 0)
20%, 80%
transform: translate3d(2px, 0, 0)
30%, 50%, 70%
transform: translate3d(-4px, 0, 0)
40%, 60%
transform: translate3d(4px, 0, 0)
@keyframes fade-in
from
opacity: 0
transform: translateY(-5px)
to
opacity: 1
transform: translateY(0)
margin-right: 0.5rem

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,95 @@
import { useState } from 'react';
import { useGame } from '../context/GameContext';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMusic, faPlus, faDoorOpen } from '@fortawesome/free-solid-svg-icons';
const HomeScreen = () => {
const { createLobby, joinLobby } = useGame();
const [playerName, setPlayerName] = useState('');
const [lobbyId, setLobbyId] = useState('');
const [isCreateMode, setIsCreateMode] = useState(true);
const handleSubmit = (e) => {
e.preventDefault();
if (!playerName.trim()) {
return;
}
if (isCreateMode) {
createLobby(playerName.trim());
} else {
if (!lobbyId.trim()) {
return;
}
joinLobby(lobbyId.trim().toUpperCase(), playerName.trim());
}
};
return (
<div className="home-screen">
<div className="logo">
<img src="/logo.png" alt="Song Battle Logo" className="logo-image" />
<h1>Song Battle</h1>
</div>
<div className="card">
<div className="tabs">
<button
className={isCreateMode ? 'active' : ''}
onClick={() => setIsCreateMode(true)}
>
<FontAwesomeIcon icon={faPlus} /> Create Game
</button>
<button
className={!isCreateMode ? 'active' : ''}
onClick={() => setIsCreateMode(false)}
>
<FontAwesomeIcon icon={faDoorOpen} /> Join Game
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="player-name">Your Name</label>
<input
type="text"
id="player-name"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
placeholder="Enter your name"
maxLength={20}
required
/>
</div>
{!isCreateMode && (
<div className="form-group">
<label htmlFor="lobby-id">Game Code</label>
<input
type="text"
id="lobby-id"
value={lobbyId}
onChange={(e) => setLobbyId(e.target.value.toUpperCase())}
placeholder="Enter 6-letter code"
maxLength={6}
required
/>
</div>
)}
<button type="submit" className="btn primary">
{isCreateMode ? 'Create New Game' : 'Join Game'}
</button>
</form>
</div>
<footer className="home-footer">
<p>Let your favorite songs battle it out!</p>
</footer>
</div>
);
};
// Make sure default export is explicit
export default HomeScreen;

View File

@ -0,0 +1,172 @@
import { useState, useEffect } from 'react';
import { useGame } from '../context/GameContext';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUsers, faGear, faPlay, faCopy, faSignOutAlt } from '@fortawesome/free-solid-svg-icons';
const LobbyScreen = () => {
const { lobby, currentPlayer, isHost, updateSettings, startGame, leaveLobby } = useGame();
const [showSettings, setShowSettings] = useState(false);
const [settings, setSettings] = useState({
songsPerPlayer: 4,
maxPlayers: 10,
requireYoutubeLinks: true
});
const [copied, setCopied] = useState(false);
useEffect(() => {
if (lobby && lobby.settings) {
setSettings(lobby.settings);
}
}, [lobby]);
const handleCopyCode = () => {
if (lobby) {
navigator.clipboard.writeText(lobby.id);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const handleSettingsChange = (e) => {
const { name, value, type, checked } = e.target;
const newValue = type === 'checkbox' ? checked : type === 'number' ? parseInt(value) : value;
setSettings({
...settings,
[name]: newValue
});
};
const handleSaveSettings = () => {
updateSettings(settings);
setShowSettings(false);
};
const handleStartGame = () => {
startGame();
};
if (!lobby) return null;
return (
<div className="lobby-screen">
<header className="lobby-header">
<h1>Game Lobby</h1>
<div className="lobby-code">
<p>Game Code: <span className="code">{lobby.id}</span></p>
<button className="btn icon" onClick={handleCopyCode}>
<FontAwesomeIcon icon={faCopy} />
{copied ? 'Copied!' : 'Copy'}
</button>
</div>
</header>
<div className="lobby-content">
<div className="players-list">
<h2><FontAwesomeIcon icon={faUsers} /> Players ({lobby.players.length})</h2>
<ul>
{lobby.players.map(player => (
<li key={player.id} className={`player ${!player.isConnected ? 'disconnected' : ''} ${player.id === lobby.hostId ? 'host' : ''}`}>
{player.name} {player.id === lobby.hostId && '(Host)'} {player.id === currentPlayer.id && '(You)'}
{!player.isConnected && <span className="status-disconnected">(Disconnected)</span>}
</li>
))}
</ul>
</div>
<div className="lobby-info">
<div className="settings-preview">
<h3><FontAwesomeIcon icon={faGear} /> Game Settings</h3>
<p>Songs per player: {settings.songsPerPlayer}</p>
<p>Max players: {settings.maxPlayers}</p>
<p>YouTube links required: {settings.requireYoutubeLinks ? 'Yes' : 'No'}</p>
{isHost && (
<button className="btn secondary" onClick={() => setShowSettings(true)}>
Edit Settings
</button>
)}
</div>
<div className="actions">
{isHost && (
<button
className="btn primary"
onClick={handleStartGame}
disabled={lobby.players.length < lobby.settings.minPlayers}
>
<FontAwesomeIcon icon={faPlay} /> Start Game
</button>
)}
<button className="btn danger" onClick={leaveLobby}>
<FontAwesomeIcon icon={faSignOutAlt} /> Leave Lobby
</button>
</div>
{lobby.players.length < lobby.settings.minPlayers && isHost && (
<p className="warning">Need at least {lobby.settings.minPlayers} players to start</p>
)}
</div>
</div>
{showSettings && (
<div className="modal-overlay">
<div className="modal">
<h2>Game Settings</h2>
<div className="form-group">
<label htmlFor="songsPerPlayer">Songs per player</label>
<input
type="number"
id="songsPerPlayer"
name="songsPerPlayer"
min="1"
max="10"
value={settings.songsPerPlayer}
onChange={handleSettingsChange}
/>
</div>
<div className="form-group">
<label htmlFor="maxPlayers">Maximum players</label>
<input
type="number"
id="maxPlayers"
name="maxPlayers"
min="3"
max="20"
value={settings.maxPlayers}
onChange={handleSettingsChange}
/>
</div>
<div className="form-group checkbox">
<label htmlFor="requireYoutubeLinks">
<input
type="checkbox"
id="requireYoutubeLinks"
name="requireYoutubeLinks"
checked={settings.requireYoutubeLinks}
onChange={handleSettingsChange}
/>
Require YouTube links
</label>
</div>
<div className="modal-actions">
<button className="btn secondary" onClick={() => setShowSettings(false)}>
Cancel
</button>
<button className="btn primary" onClick={handleSaveSettings}>
Save Settings
</button>
</div>
</div>
</div>
)}
</div>
);
};
// Ensure explicit default export
export default LobbyScreen;

View File

@ -0,0 +1,160 @@
import { useEffect, useState } from 'react';
import { useGame } from '../context/GameContext';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrophy, faHome, faRedo, faChartLine } from '@fortawesome/free-solid-svg-icons';
import YouTubeEmbed from './YouTubeEmbed';
const ResultsScreen = () => {
const { lobby, currentPlayer, leaveLobby } = useGame();
const [showBattleHistory, setShowBattleHistory] = useState(false);
const [confetti, setConfetti] = useState(true);
// Winner information
const winner = lobby?.finalWinner;
const winnerVideoId = getYouTubeId(winner?.youtubeLink);
// Confetti effect for winner celebration
useEffect(() => {
if (confetti) {
// Create confetti animation
const createConfetti = () => {
const confettiContainer = document.createElement('div');
confettiContainer.className = 'confetti';
// Random position and color
const left = Math.random() * 100;
const colors = ['#f94144', '#f3722c', '#f8961e', '#f9c74f', '#90be6d', '#43aa8b', '#577590'];
const color = colors[Math.floor(Math.random() * colors.length)];
confettiContainer.style.left = `${left}%`;
confettiContainer.style.backgroundColor = color;
document.querySelector('.results-screen').appendChild(confettiContainer);
// Remove after animation
setTimeout(() => {
confettiContainer.remove();
}, 5000);
};
// Create multiple confetti pieces
const confettiInterval = setInterval(() => {
for (let i = 0; i < 3; i++) {
createConfetti();
}
}, 300);
// Stop confetti after some time
setTimeout(() => {
clearInterval(confettiInterval);
setConfetti(false);
}, 10000);
return () => {
clearInterval(confettiInterval);
};
}
}, [confetti]);
// Get YouTube video ID from link
function getYouTubeId(url) {
if (!url) return null;
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const match = url.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
}
if (!winner) {
return (
<div className="results-screen">
<h2>Calculating results...</h2>
</div>
);
}
return (
<div className="results-screen">
<header>
<h1><FontAwesomeIcon icon={faTrophy} /> Winner!</h1>
</header>
<div className="winner-card">
<div className="winner-info">
<h2>{winner.title}</h2>
<h3>by {winner.artist}</h3>
<p className="submitter">Submitted by: {winner.submittedByName}</p>
</div>
{winnerVideoId ? (
<div className="winner-video">
<YouTubeEmbed videoId={winnerVideoId} autoplay={true} />
</div>
) : (
<div className="winner-placeholder">
<FontAwesomeIcon icon={faTrophy} size="3x" />
</div>
)}
</div>
<div className="results-actions">
<button
className="btn secondary"
onClick={() => setShowBattleHistory(!showBattleHistory)}
>
<FontAwesomeIcon icon={faChartLine} />
{showBattleHistory ? 'Hide Battle History' : 'Show Battle History'}
</button>
<button className="btn primary" onClick={leaveLobby}>
<FontAwesomeIcon icon={faHome} /> Return to Home
</button>
</div>
{showBattleHistory && (
<div className="battle-history">
<h3>Battle History</h3>
<div className="battles-list">
{lobby?.battles?.map((battle, index) => {
const isWinner = (battle.song1.id === battle.winner);
const song1VideoId = getYouTubeId(battle.song1.youtubeLink);
const song2VideoId = getYouTubeId(battle.song2.youtubeLink);
return (
<div key={index} className="battle-item">
<div className="battle-header">
<h4>Round {battle.round + 1}, Battle {index + 1}</h4>
</div>
<div className="battle-songs">
<div className={`battle-song ${isWinner ? 'winner' : ''}`}>
<div className="song-info">
<h5>{battle.song1.title}</h5>
<p>{battle.song1.artist}</p>
<span className="votes">{battle.song1Votes} votes</span>
</div>
</div>
<div className="versus">VS</div>
<div className={`battle-song ${!isWinner ? 'winner' : ''}`}>
<div className="song-info">
<h5>{battle.song2.title}</h5>
<p>{battle.song2.artist}</p>
<span className="votes">{battle.song2Votes} votes</span>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
)}
</div>
);
};
export default ResultsScreen;

View File

@ -0,0 +1,476 @@
import { useState, useEffect, useRef } from 'react';
import { useGame } from '../context/GameContext';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTrash, faCheck, faMusic, faVideoCamera, faSearch, faSpinner, faExternalLinkAlt, faTimes } from '@fortawesome/free-solid-svg-icons';
const SongSubmissionScreen = () => {
const { lobby, currentPlayer, addSong, removeSong, setPlayerReady, searchYouTube } = useGame();
const [songs, setSongs] = useState([]);
const [isReady, setIsReady] = useState(false);
const [songForm, setSongForm] = useState({
youtubeLink: ''
});
const [isFormVisible, setIsFormVisible] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const [isLoadingMetadata, setIsLoadingMetadata] = useState(false);
const [selectedVideo, setSelectedVideo] = useState(null);
const [previewVisible, setPreviewVisible] = useState(false);
const searchTimeout = useRef(null);
useEffect(() => {
if (lobby) {
// Find current player's songs
const player = lobby.players.find(p => p.id === currentPlayer.id);
if (player) {
setIsReady(player.isReady);
}
}
}, [lobby, currentPlayer]);
// Get player's songs from the server
useEffect(() => {
const fetchPlayerSongs = async () => {
if (lobby && currentPlayer) {
console.log('Fetching songs for player:', currentPlayer);
console.log('All players in lobby:', lobby.players.map(p => ({ id: p.id, name: p.name })));
// Find the current player by their ID, name, or socket ID
let player = lobby.players.find(p => p.id === currentPlayer.id);
// If not found by ID, try by name as fallback
if (!player) {
player = lobby.players.find(p => p.name === currentPlayer.name);
console.log('Player not found by ID, trying by name. Found:', player);
}
// If player found and has songs, update the state
if (player) {
console.log('Found player:', player);
if (player.songs && Array.isArray(player.songs)) {
console.log('Found player songs for', player.name, ':', player.songs.length);
console.log('Songs data:', player.songs);
setSongs(player.songs);
} else {
console.log('No songs array for player:', player);
setSongs([]);
}
} else {
console.error('Player not found in lobby! Current player:', currentPlayer.id);
console.log('Available players:', lobby.players.map(p => p.id));
setSongs([]);
}
}
};
fetchPlayerSongs();
// Include lobby in the dependency array to ensure this runs when the lobby state changes
}, [lobby, currentPlayer]);
// Extract video ID from YouTube URL
const extractVideoId = (url) => {
if (!url) return null;
const patterns = [
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/watch\?v=([^&]+)/,
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/embed\/([^/?]+)/,
/(?:https?:\/\/)?(?:www\.)?youtu\.be\/([^/?]+)/
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match && match[1]) {
return match[1];
}
}
return null;
};
// Debounced search function
const handleSearch = async (query) => {
if (!query.trim()) {
setSearchResults([]);
return;
}
setIsSearching(true);
try {
const results = await searchYouTube(query);
setSearchResults(results || []);
} catch (error) {
console.error('Search failed:', error);
} finally {
setIsSearching(false);
}
};
const handleInputChange = async (e) => {
const { name, value } = e.target;
if (name === 'searchQuery') {
setSearchQuery(value);
// Check if the input might be a YouTube link
const videoId = extractVideoId(value);
if (videoId) {
setSongForm({ youtubeLink: value });
setSelectedVideo({
id: videoId,
url: value,
thumbnail: `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`
});
setSearchResults([]); // Clear any existing search results
} else if (value.trim()) {
// Clear any previous timeout
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
}
// Set a new timeout to prevent too many API calls
searchTimeout.current = setTimeout(() => handleSearch(value), 500);
} else {
setSearchResults([]);
setSelectedVideo(null);
}
}
};
// Function to toggle video preview
const togglePreview = (result) => {
if (selectedVideo && selectedVideo.id === result.id) {
setPreviewVisible(!previewVisible);
} else {
setSelectedVideo(result);
setPreviewVisible(true);
}
};
const handleSelectSearchResult = (result) => {
// Make sure we have a complete result with all required fields
const completeResult = {
id: result.id,
url: result.url || `https://www.youtube.com/watch?v=${result.id}`,
title: result.title || 'Unknown Title',
artist: result.artist || 'Unknown Artist',
thumbnail: result.thumbnail || `https://img.youtube.com/vi/${result.id}/mqdefault.jpg`
};
// When a search result is selected, store the YouTube URL
setSongForm({
youtubeLink: completeResult.url
});
// Store the selected video with all necessary data for submission
setSelectedVideo(completeResult);
// Keep the search results visible but update the query field
setSearchQuery(completeResult.title);
};
const handleAddSong = (e) => {
e.preventDefault();
// Use selected video data if available, otherwise fallback to search query or direct input
let songData;
if (selectedVideo) {
// We have a selected video with full details - use all available metadata
songData = {
youtubeLink: selectedVideo.url,
title: selectedVideo.title,
artist: selectedVideo.artist,
thumbnail: selectedVideo.thumbnail,
id: `song_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` // Generate a unique ID
};
console.log("Adding song with full metadata:", songData);
} else {
// Extract YouTube URL from search query or direct input
const youtubeLink = searchQuery.trim() || songForm.youtubeLink.trim();
if (!youtubeLink) return;
// Extract video ID to check if it's a valid YouTube link
const videoId = extractVideoId(youtubeLink);
if (videoId) {
// It's a YouTube link, send it to the server for metadata resolution
songData = {
youtubeLink: youtubeLink,
// Include the videoId to make server-side processing easier
videoId: videoId,
id: `song_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` // Generate a unique ID
};
console.log("Adding song with YouTube link:", songData);
} else {
// Not a YouTube link - treat as a search query
alert("Please enter a valid YouTube link or select a search result");
return;
}
}
// Add the song and manually update the local songs array to ensure UI reflects the change
addSong(songData);
// Optimistically add the song to the local state to immediately reflect changes in UI
// Note: The server response will ultimately override this if needed
setSongs(prevSongs => [...prevSongs, songData]);
// Reset form
setSongForm({ youtubeLink: '' });
setSearchQuery('');
setSearchResults([]);
setSelectedVideo(null);
setIsFormVisible(false);
};
const handleRemoveSong = (songId) => {
removeSong(songId);
};
const handleSetReady = () => {
if (songs.length === lobby.settings.songsPerPlayer) {
setPlayerReady();
setIsReady(true);
}
};
// Check if we've submitted enough songs
const canSubmitMoreSongs = lobby && songs.length < lobby.settings.songsPerPlayer;
// Extract YouTube video ID from various YouTube URL formats
const getYoutubeVideoId = (url) => {
if (!url) return null;
try {
const urlObj = new URL(url);
if (urlObj.hostname.includes('youtube.com')) {
return new URLSearchParams(urlObj.search).get('v');
} else if (urlObj.hostname.includes('youtu.be')) {
return urlObj.pathname.slice(1);
}
} catch (e) {
return null;
}
return null;
};
// Get YouTube thumbnail URL from video URL
const getYoutubeThumbnail = (url) => {
const videoId = getYoutubeVideoId(url);
return videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : null;
};
return (
<div className="song-submission-screen">
<header className="screen-header">
<h1>Submit Your Songs</h1>
<p className="status">
{isReady
? 'Waiting for other players...'
: `Submit ${lobby?.settings?.songsPerPlayer || 0} songs to battle`}
</p>
</header>
<div className="submission-progress">
<div className="progress-bar">
<div
className="progress-fill"
style={{ width: `${(songs.length / (lobby?.settings?.songsPerPlayer || 1)) * 100}%` }}
></div>
</div>
<p>{songs.length} / {lobby?.settings?.songsPerPlayer || 0} songs</p>
</div>
<div className="songs-list">
{songs.map((song, index) => (
<div key={song.id || index} className="song-card">
<div className="song-thumbnail">
{getYoutubeThumbnail(song.youtubeLink) ? (
<img src={getYoutubeThumbnail(song.youtubeLink)} alt={song.title} />
) : (
<div className="thumbnail-placeholder">
<FontAwesomeIcon icon={faMusic} />
</div>
)}
</div>
<div className="song-info">
<h3>{song.title}</h3>
<p>{song.artist}</p>
</div>
{!isReady && (
<button className="btn icon danger" onClick={() => handleRemoveSong(song.id)}>
<FontAwesomeIcon icon={faTrash} />
</button>
)}
</div>
))}
{canSubmitMoreSongs && !isFormVisible && !isReady && (
<button
className="add-song-btn"
onClick={() => setIsFormVisible(true)}
>
<FontAwesomeIcon icon={faPlus} />
<span>Add Song</span>
</button>
)}
</div>
{isFormVisible && (
<form className="song-form" onSubmit={handleAddSong}>
<h3>Add a Song</h3>
<div className="form-group search-group">
<label>Find a song</label>
<div className="search-container">
<input
type="text"
name="searchQuery"
value={searchQuery}
onChange={handleInputChange}
placeholder="Search YouTube or paste a link..."
className="search-input"
/>
{isSearching && (
<FontAwesomeIcon icon={faSpinner} className="spinner-icon" spin />
)}
{searchQuery && !isSearching && (
<FontAwesomeIcon
icon={faTimes}
className="clear-icon"
onClick={() => {
setSearchQuery('');
setSearchResults([]);
setSelectedVideo(null);
setSongForm({ youtubeLink: '' });
}}
/>
)}
</div>
{/* Show selected video without embedded player */}
{selectedVideo && (
<div className="selected-video">
<div className="preview-header">
<h4>Selected Song</h4>
<a
href={selectedVideo.url}
target="_blank"
rel="noopener noreferrer"
className="external-link"
>
<FontAwesomeIcon icon={faExternalLinkAlt} /> View on YouTube
</a>
</div>
<div className="video-details">
<div className="thumbnail-container">
<img
src={selectedVideo.thumbnail}
alt={selectedVideo.title}
className="selected-thumbnail"
/>
<div className="play-overlay">
<FontAwesomeIcon icon={faVideoCamera} />
</div>
</div>
<div className="selected-info">
<h4>{selectedVideo.title || 'Unknown Title'}</h4>
<p>{selectedVideo.artist || 'Unknown Artist'}</p>
</div>
</div>
</div>
)}
{searchResults.length > 0 && (
<div className="search-results">
<h4>Search Results</h4>
{searchResults.map(result => (
<div
key={result.id}
className={`search-result ${selectedVideo && selectedVideo.id === result.id ? 'selected' : ''}`}
onClick={() => handleSelectSearchResult(result)}
>
<div className="result-thumbnail-container">
{result.thumbnail ? (
<img src={result.thumbnail} alt={result.title} className="result-thumbnail" />
) : (
<div className="thumbnail-placeholder">
<FontAwesomeIcon icon={faMusic} />
</div>
)}
<div className="thumbnail-overlay">
<FontAwesomeIcon icon={faVideoCamera} />
</div>
</div>
<div className="result-info">
<h4>{result.title || 'Unknown Title'}</h4>
<p>{result.artist || 'Unknown Artist'}</p>
</div>
</div>
))}
</div>
)}
</div>
<div className="form-actions">
<button type="button" className="btn secondary" onClick={() => {
setIsFormVisible(false);
setSearchQuery('');
setSearchResults([]);
}}>
Cancel
</button>
<button
type="submit"
className="btn primary"
disabled={!searchQuery.trim() && !songForm.youtubeLink.trim()}
>
Add Song
</button>
</div>
</form>
)}
<div className="action-buttons">
{!isReady && songs.length === lobby?.settings?.songsPerPlayer && (
<button
className="btn primary"
onClick={handleSetReady}
>
<FontAwesomeIcon icon={faCheck} /> Ready to Battle
</button>
)}
</div>
{isReady && (
<div className="waiting-message">
<h3>Ready to Battle!</h3>
<p>Waiting for other players to submit their songs...</p>
<div className="player-status">
<h4>Players Ready</h4>
<ul className="players-ready-list">
{lobby && lobby.players.map(player => (
<li key={player.id} className={player.isReady ? 'ready' : 'not-ready'}>
{player.name} {player.id === currentPlayer.id && '(You)'}
{player.isReady ? (
<FontAwesomeIcon icon={faCheck} className="ready-icon" />
) : (
<span className="songs-count">{player.songCount}/{lobby?.settings?.songsPerPlayer}</span>
)}
</li>
))}
</ul>
</div>
</div>
)}
</div>
);
};
export default SongSubmissionScreen;

View File

@ -0,0 +1,182 @@
// VotingScreen.jsx
import { useState, useEffect } from 'react';
import { useGame } from '../context/GameContext';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faVoteYea, faTrophy, faMusic } from '@fortawesome/free-solid-svg-icons';
import YouTubeEmbed from './YouTubeEmbed';
function VotingScreen() {
const { lobby, currentPlayer, submitVote } = useGame();
const [hasVoted, setHasVoted] = useState(false);
const [selectedSong, setSelectedSong] = useState(null);
const [countdown, setCountdown] = useState(null);
// Get current battle
const battle = lobby?.currentBattle || null;
// Check if player has already voted
useEffect(() => {
if (battle && battle.votes && currentPlayer) {
// Check if player's ID exists in votes map
setHasVoted(battle.votes.has(currentPlayer.id));
} else {
setHasVoted(false);
}
}, [battle, currentPlayer]);
// Handle vote selection
const handleVoteSelect = (songId) => {
if (hasVoted) return;
setSelectedSong(songId);
};
// Submit final vote
const handleSubmitVote = () => {
if (!selectedSong || hasVoted) return;
submitVote(selectedSong);
setHasVoted(true);
};
// Get YouTube video IDs from links
const getYouTubeId = (url) => {
if (!url) return null;
const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/;
const match = url.match(regExp);
return (match && match[2].length === 11) ? match[2] : null;
};
if (!battle || !battle.song1 || !battle.song2) {
return (
<div className="voting-screen">
<h2>Preparing the next battle...</h2>
</div>
);
}
const song1Id = getYouTubeId(battle.song1?.youtubeLink || '');
const song2Id = getYouTubeId(battle.song2?.youtubeLink || '');
return (
<div className="voting-screen">
<header className="voting-header">
<h1>
<FontAwesomeIcon icon={faVoteYea} /> Song Battle!
</h1>
<div className="round-info">
<span>Round {battle.round + 1}</span>
{hasVoted && <span className="voted-badge">You voted!</span>}
</div>
</header>
<div className="battle-container">
<div
className={`song-card ${selectedSong === battle.song1.id ? 'selected' : ''} ${hasVoted ? 'voted' : ''}`}
onClick={() => handleVoteSelect(battle.song1.id)}
>
<div className="song-spotlight"></div>
<div className="song-details">
<h3>{battle.song1.title}</h3>
<p>{battle.song1.artist}</p>
<div className="song-submitter">
<small>Submitted by: {battle.song1.submittedByName}</small>
</div>
</div>
{song1Id ? (
<div className="video-container">
<YouTubeEmbed videoId={song1Id} />
<div className="video-overlay"></div>
</div>
) : (
<div className="no-video">
<FontAwesomeIcon icon={faMusic} className="pulse-icon" />
<span>No video available</span>
</div>
)}
{hasVoted && (
<div className="vote-count">
<span className="vote-number">{battle.song1Votes}</span>
<span className="vote-text">votes</span>
</div>
)}
{selectedSong === battle.song1.id && !hasVoted && (
<div className="selection-indicator">
<FontAwesomeIcon icon={faCheck} className="check-icon" />
</div>
)}
</div>
<div className="versus">
<span className="versus-text">VS</span>
<div className="versus-decoration"></div>
</div>
<div
className={`song-card ${selectedSong === battle.song2.id ? 'selected' : ''} ${hasVoted ? 'voted' : ''}`}
onClick={() => handleVoteSelect(battle.song2.id)}
>
<div className="song-spotlight"></div>
<div className="song-details">
<h3>{battle.song2.title}</h3>
<p>{battle.song2.artist}</p>
<div className="song-submitter">
<small>Submitted by: {battle.song2.submittedByName}</small>
</div>
</div>
{song2Id ? (
<div className="video-container">
<YouTubeEmbed videoId={song2Id} />
<div className="video-overlay"></div>
</div>
) : (
<div className="no-video">
<FontAwesomeIcon icon={faMusic} className="pulse-icon" />
<span>No video available</span>
</div>
)}
{hasVoted && (
<div className="vote-count">
<span className="vote-number">{battle.song2Votes}</span>
<span className="vote-text">votes</span>
</div>
)}
{selectedSong === battle.song2.id && !hasVoted && (
<div className="selection-indicator">
<FontAwesomeIcon icon={faCheck} className="check-icon" />
</div>
)}
</div>
</div>
{!hasVoted && (
<div className="voting-actions">
<button
className="btn primary"
onClick={handleSubmitVote}
disabled={!selectedSong}
>
Cast Vote
</button>
</div>
)}
<div className="voting-status">
<p>{hasVoted ? 'Waiting for other players to vote...' : 'Choose your favorite!'}</p>
<div className="votes-count">
<span>{battle.voteCount || 0}</span> of <span>{lobby?.players?.filter(p => p.isConnected).length || 0}</span> votes
</div>
</div>
</div>
);
}
export default VotingScreen;

View File

@ -0,0 +1,46 @@
import { useEffect, useRef } from 'react';
/**
* YouTube video embedding component
* @param {Object} props
* @param {string} props.videoId - YouTube video ID to embed
* @param {boolean} props.autoplay - Whether to autoplay the video (default: false)
*/
const YouTubeEmbed = ({ videoId, autoplay = false }) => {
const containerRef = useRef(null);
useEffect(() => {
if (!videoId || !containerRef.current) return;
// Create iframe element
const iframe = document.createElement('iframe');
// Set iframe attributes
iframe.width = '100%';
iframe.height = '100%';
iframe.src = `https://www.youtube.com/embed/${videoId}?rel=0&showinfo=0${autoplay ? '&autoplay=1' : ''}`;
iframe.title = 'YouTube video player';
iframe.frameBorder = '0';
iframe.allow = 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture';
iframe.allowFullscreen = true;
// Clear container and append iframe
containerRef.current.innerHTML = '';
containerRef.current.appendChild(iframe);
// Cleanup function
return () => {
if (containerRef.current) {
containerRef.current.innerHTML = '';
}
};
}, [videoId, autoplay]);
return (
<div className="youtube-embed" ref={containerRef}>
{/* YouTube iframe will be inserted here */}
</div>
);
};
export default YouTubeEmbed;

View File

@ -0,0 +1,471 @@
// Socket.IO client instance for real-time communication
import { io } from 'socket.io-client';
import { createContext, useContext, useEffect, useState } from 'react';
// Create a Socket.IO context for use throughout the app
const SocketContext = createContext(null);
// Game context for sharing game state
const GameContext = createContext(null);
export function SocketProvider({ children }) {
const [socket, setSocket] = useState(null);
const [isConnected, setIsConnected] = useState(false);
useEffect(() => {
// Create socket connection on component mount
const socketInstance = io(import.meta.env.DEV ? 'http://localhost:5237' : window.location.origin, {
autoConnect: true,
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 5
});
// Socket event listeners
socketInstance.on('connect', () => {
setIsConnected(true);
console.log('Connected to server');
// Try to reconnect to game if we have saved data
const savedGameData = localStorage.getItem('songBattleGame');
if (savedGameData) {
try {
const { lobbyId, playerName } = JSON.parse(savedGameData);
if (lobbyId && playerName) {
console.log(`Attempting to reconnect to lobby: ${lobbyId}`);
socketInstance.emit('reconnect_to_lobby', { lobbyId, playerName }, (response) => {
if (response.error) {
console.error('Reconnection failed:', response.error);
localStorage.removeItem('songBattleGame');
} else {
console.log('Successfully reconnected to lobby');
}
});
}
} catch (e) {
console.error('Error parsing saved game data:', e);
localStorage.removeItem('songBattleGame');
}
}
});
socketInstance.on('disconnect', () => {
setIsConnected(false);
console.log('Disconnected from server');
});
socketInstance.on('connect_error', (error) => {
console.error('Connection error:', error);
});
setSocket(socketInstance);
// Clean up socket connection on unmount
return () => {
socketInstance.disconnect();
};
}, []);
return (
<SocketContext.Provider value={{ socket, isConnected }}>
{children}
</SocketContext.Provider>
);
}
/**
* Game state provider - manages game state and provides methods for game actions
*/
export function GameProvider({ children }) {
const { socket, isConnected } = useContext(SocketContext);
const [lobby, setLobby] = useState(null);
const [error, setError] = useState(null);
const [currentPlayer, setCurrentPlayer] = useState(null);
// Save game info when lobby is joined
useEffect(() => {
if (lobby && currentPlayer) {
localStorage.setItem('songBattleGame', JSON.stringify({
lobbyId: lobby.id,
playerName: currentPlayer.name
}));
}
}, [lobby, currentPlayer]);
// Clear error after 5 seconds
useEffect(() => {
if (error) {
const timer = setTimeout(() => {
setError(null);
}, 5000);
return () => clearTimeout(timer);
}
}, [error]);
// Socket event handlers for game updates
useEffect(() => {
if (!socket) return;
// Player joined the lobby
const handlePlayerJoined = (data) => {
setLobby(prevLobby => {
if (!prevLobby) return prevLobby;
// Update the lobby with the new player
const updatedPlayers = [...prevLobby.players, {
id: data.playerId,
name: data.playerName,
isConnected: true,
isReady: false,
songCount: 0
}];
return {
...prevLobby,
players: updatedPlayers
};
});
};
// Player reconnected to the lobby
const handlePlayerReconnected = (data) => {
setLobby(prevLobby => {
if (!prevLobby) return prevLobby;
// Update the lobby with the reconnected player
const updatedPlayers = prevLobby.players.map(player => {
if (player.name === data.playerName && !player.isConnected) {
return { ...player, id: data.playerId, isConnected: true };
}
return player;
});
return {
...prevLobby,
players: updatedPlayers
};
});
};
// Player disconnected from the lobby
const handlePlayerDisconnected = (data) => {
setLobby(data.lobby);
};
// Game settings were updated
const handleSettingsUpdated = (data) => {
setLobby(prevLobby => {
if (!prevLobby) return prevLobby;
return {
...prevLobby,
settings: data.settings
};
});
};
// Game started
const handleGameStarted = (data) => {
setLobby(prevLobby => {
if (!prevLobby) return prevLobby;
return {
...prevLobby,
state: data.state
};
});
};
// Songs were updated
const handleSongsUpdated = (data) => {
setLobby(data.lobby);
};
// Player status changed (ready/not ready)
const handlePlayerStatusChanged = (data) => {
console.log('Player status changed, new lobby state:', data.lobby.state);
setLobby(data.lobby);
// If the state is VOTING and we have a current battle, explicitly log it
if (data.lobby.state === 'VOTING' && data.lobby.currentBattle) {
console.log('Battle ready in player_status_changed:', data.lobby.currentBattle);
}
};
// Vote was submitted
const handleVoteSubmitted = (data) => {
console.log('Vote submitted, updating lobby');
setLobby(data.lobby);
};
// New battle started
const handleNewBattle = (data) => {
console.log('New battle received:', data.battle);
setLobby(prevLobby => {
if (!prevLobby) return prevLobby;
// Ensure we update both the currentBattle and the state
return {
...prevLobby,
currentBattle: data.battle,
state: 'VOTING'
};
});
};
// Game finished
const handleGameFinished = (data) => {
setLobby(prevLobby => {
if (!prevLobby) return prevLobby;
return {
...prevLobby,
state: 'FINISHED',
finalWinner: data.winner,
battles: data.battles
};
});
};
// Register socket event listeners
socket.on('player_joined', handlePlayerJoined);
socket.on('player_reconnected', handlePlayerReconnected);
socket.on('player_disconnected', handlePlayerDisconnected);
socket.on('settings_updated', handleSettingsUpdated);
socket.on('game_started', handleGameStarted);
socket.on('songs_updated', handleSongsUpdated);
socket.on('player_status_changed', handlePlayerStatusChanged);
socket.on('vote_submitted', handleVoteSubmitted);
socket.on('tournament_started', data => {
console.log('Tournament started event received:', data);
setLobby(prevLobby => prevLobby ? {...prevLobby, state: data.state} : prevLobby);
});
socket.on('new_battle', handleNewBattle);
socket.on('game_finished', handleGameFinished);
// Clean up listeners on unmount
return () => {
socket.off('player_joined', handlePlayerJoined);
socket.off('player_reconnected', handlePlayerReconnected);
socket.off('player_disconnected', handlePlayerDisconnected);
socket.off('settings_updated', handleSettingsUpdated);
socket.off('game_started', handleGameStarted);
socket.off('songs_updated', handleSongsUpdated);
socket.off('player_status_changed', handlePlayerStatusChanged);
socket.off('vote_submitted', handleVoteSubmitted);
socket.off('new_battle', handleNewBattle);
socket.off('game_finished', handleGameFinished);
};
}, [socket]);
// Create a lobby
const createLobby = (playerName) => {
if (!socket || !isConnected) {
setError('Not connected to server');
return;
}
socket.emit('create_lobby', { playerName }, (response) => {
if (response.error) {
setError(response.error);
} else {
setLobby(response.lobby);
setCurrentPlayer({
id: socket.id,
name: playerName,
isHost: true
});
}
});
};
// Join a lobby
const joinLobby = (lobbyId, playerName) => {
if (!socket || !isConnected) {
setError('Not connected to server');
return;
}
socket.emit('join_lobby', { lobbyId, playerName }, (response) => {
if (response.error) {
setError(response.error);
} else {
setLobby(response.lobby);
setCurrentPlayer({
id: socket.id,
name: playerName,
isHost: response.lobby.hostId === socket.id
});
}
});
};
// Update lobby settings
const updateSettings = (settings) => {
if (!socket || !isConnected || !lobby) {
setError('Not connected to server or no active lobby');
return;
}
socket.emit('update_settings', { settings }, (response) => {
if (response.error) {
setError(response.error);
}
});
};
// Start the game
const startGame = () => {
if (!socket || !isConnected || !lobby) {
setError('Not connected to server or no active lobby');
return;
}
socket.emit('start_game', {}, (response) => {
if (response.error) {
setError(response.error);
}
});
};
// Add a song
const addSong = (song) => {
if (!socket || !isConnected || !lobby) {
setError('Not connected to server or no active lobby');
return;
}
console.log('Attempting to add song:', song);
console.log('Current player state:', currentPlayer);
console.log('Current lobby state before adding song:', lobby);
socket.emit('add_song', { song }, (response) => {
console.log('Song addition response:', response);
if (response.error) {
console.error('Error adding song:', response.error);
setError(response.error);
} else if (response.lobby) {
// Log detailed lobby state for debugging
console.log('Song added successfully, full lobby response:', response.lobby);
console.log('Current player songs:', response.lobby.players.find(p => p.id === currentPlayer.id)?.songs);
console.log('All players song data:',
response.lobby.players.map(p => ({
name: p.name,
id: p.id,
songCount: p.songCount,
songs: p.songs ? p.songs.map(s => ({id: s.id, title: s.title})) : 'No songs'
}))
);
// Force a deep clone of the lobby to ensure React detects the change
const updatedLobby = JSON.parse(JSON.stringify(response.lobby));
setLobby(updatedLobby);
// Verify the state was updated correctly
setTimeout(() => {
// This won't show the updated state immediately due to React's state update mechanism
console.log('Lobby state after update (may not reflect immediate changes):', lobby);
console.log('Updated lobby that was set:', updatedLobby);
}, 0);
} else {
console.error('Song addition succeeded but no lobby data was returned');
setError('Failed to update song list');
}
});
};
// Search for songs on YouTube
const searchYouTube = (query) => {
return new Promise((resolve, reject) => {
if (!socket || !isConnected) {
setError('Not connected to server');
reject('Not connected to server');
return;
}
socket.emit('search_youtube', { query }, (response) => {
if (response.error) {
setError(response.error);
reject(response.error);
} else {
resolve(response.results);
}
});
});
};
// Remove a song
const removeSong = (songId) => {
if (!socket || !isConnected || !lobby) {
setError('Not connected to server or no active lobby');
return;
}
socket.emit('remove_song', { songId }, (response) => {
if (response.error) {
setError(response.error);
}
});
};
// Set player ready
const setPlayerReady = () => {
if (!socket || !isConnected || !lobby) {
setError('Not connected to server or no active lobby');
return;
}
socket.emit('player_ready', {}, (response) => {
if (response.error) {
setError(response.error);
}
});
};
// Submit a vote
const submitVote = (songId) => {
if (!socket || !isConnected || !lobby) {
setError('Not connected to server or no active lobby');
return;
}
socket.emit('submit_vote', { songId }, (response) => {
if (response.error) {
setError(response.error);
}
});
};
// Leave lobby and clear saved game state
const leaveLobby = () => {
localStorage.removeItem('songBattleGame');
setLobby(null);
setCurrentPlayer(null);
};
return (
<GameContext.Provider value={{
lobby,
error,
currentPlayer,
isHost: currentPlayer && lobby && currentPlayer.id === lobby.hostId,
createLobby,
joinLobby,
updateSettings,
startGame,
addSong,
removeSong,
setPlayerReady,
submitVote,
leaveLobby,
searchYouTube
}}>
{children}
</GameContext.Provider>
);
}
// Custom hooks for using contexts
export const useSocket = () => useContext(SocketContext);
export const useGame = () => useContext(GameContext);
// Re-export for better module compatibility
export { SocketContext, GameContext };

View File

@ -2,14 +2,17 @@ import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "@fontsource/bangers";
import "@/common/styles/main.sass";
import "./common/styles/main.sass";
import { SocketProvider, GameProvider } from "./context/GameContext.jsx";
ReactDOM.createRoot(document.getElementById("root")).render(
<React.StrictMode>
<div className="app">
<App/>
</div>
<SocketProvider>
<GameProvider>
<div className="app">
<App/>
</div>
</GameProvider>
</SocketProvider>
</React.StrictMode>,
);

View File

@ -1,20 +1,23 @@
{
"name": "toneguessr",
"version": "1.0.0-ALPHA",
"description": "The server of the ToneGuessr game",
"name": "song-battle",
"version": "1.0.0",
"description": "Song Battle game with tournament-style voting for favorite songs",
"main": "server/index.js",
"repository": "https://git.gnm.dev/WebsiteProjects/ToneGuessr",
"author": "Mathias Wagner",
"license": "MIT",
"scripts": {
"webui": "cd client && yarn dev",
"server": "nodemon server",
"dev": "concurrently --kill-others \"yarn server\" \"yarn webui\""
"dev": "concurrently --kill-others \"yarn server\" \"yarn webui\"",
"build": "cd client && yarn build",
"start": "node server/index.js"
},
"dependencies": {
"axios": "^1.8.4",
"express": "^4.21.2",
"googleapis": "^146.0.0",
"socket.io": "^4.8.1"
"googleapis": "^148.0.0",
"socket.io": "^4.8.1",
"uuid": "^9.0.1"
},
"devDependencies": {
"concurrently": "^9.1.2",

100
pnpm-lock.yaml generated
View File

@ -8,15 +8,21 @@ importers:
.:
dependencies:
axios:
specifier: ^1.8.4
version: 1.8.4
express:
specifier: ^4.21.2
version: 4.21.2
googleapis:
specifier: ^146.0.0
version: 146.0.0
specifier: ^148.0.0
version: 148.0.0
socket.io:
specifier: ^4.8.1
version: 4.8.1
uuid:
specifier: ^9.0.1
version: 9.0.1
devDependencies:
concurrently:
specifier: ^9.1.2
@ -59,6 +65,12 @@ packages:
array-flatten@1.1.1:
resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
axios@1.8.4:
resolution: {integrity: sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==}
balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
@ -69,8 +81,8 @@ packages:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
bignumber.js@9.1.2:
resolution: {integrity: sha512-2/mKyZH9K85bzOEfhXDBFZTGd1CTs+5IHpeFQo9luiBG7hghdC851Pj2WAhb6E3R6b9tZj/XKhbg4fum+Kepug==}
bignumber.js@9.3.0:
resolution: {integrity: sha512-EM7aMFTXbptt/wZdMlBv2t8IViwQL+h6SLHosp8Yf0dqJMTnY6iL32opnAB6kAdL0SZPuvcAzFr31o0c/R3/RA==}
binary-extensions@2.3.0:
resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==}
@ -121,6 +133,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
concat-map@0.0.1:
resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==}
@ -169,6 +185,10 @@ packages:
supports-color:
optional: true
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
depd@2.0.0:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
@ -218,6 +238,10 @@ packages:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
es-set-tostringtag@2.1.0:
resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
engines: {node: '>= 0.4'}
escalade@3.2.0:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
@ -244,6 +268,19 @@ packages:
resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==}
engines: {node: '>= 0.8'}
follow-redirects@1.15.9:
resolution: {integrity: sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
form-data@4.0.2:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
engines: {node: '>= 6'}
forwarded@0.2.0:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
@ -296,8 +333,8 @@ packages:
resolution: {integrity: sha512-/fhDZEJZvOV3X5jmD+fKxMqma5q2Q9nZNSF3kn1F18tpxmA86BcTxAGBQdM0N89Z3bEaIs+HVznSmFJEAmMTjA==}
engines: {node: '>=14.0.0'}
googleapis@146.0.0:
resolution: {integrity: sha512-NewqvhnBZOJsugCAOo636O0BGE/xY7Cg/v8Rjm1+5LkJCjcqAzLleJ6igd5vrRExJLSKrY9uHy9iKE7r0PrfhQ==}
googleapis@148.0.0:
resolution: {integrity: sha512-8PDG5VItm6E1TdZWDqtRrUJSlBcNwz0/MwCa6AL81y/RxPGXJRUwKqGZfCoVX1ZBbfr3I4NkDxBmeTyOAZSWqw==}
engines: {node: '>=14.0.0'}
gopd@1.2.0:
@ -320,6 +357,10 @@ packages:
resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
engines: {node: '>= 0.4'}
has-tostringtag@1.0.2:
resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
engines: {node: '>= 0.4'}
hasown@2.0.2:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
@ -468,6 +509,9 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
pstree.remy@1.1.8:
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
@ -692,13 +736,23 @@ snapshots:
array-flatten@1.1.1: {}
asynckit@0.4.0: {}
axios@1.8.4:
dependencies:
follow-redirects: 1.15.9
form-data: 4.0.2
proxy-from-env: 1.1.0
transitivePeerDependencies:
- debug
balanced-match@1.0.2: {}
base64-js@1.5.1: {}
base64id@2.0.0: {}
bignumber.js@9.1.2: {}
bignumber.js@9.3.0: {}
binary-extensions@2.3.0: {}
@ -771,6 +825,10 @@ snapshots:
color-name@1.1.4: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
concat-map@0.0.1: {}
concurrently@9.1.2:
@ -810,6 +868,8 @@ snapshots:
optionalDependencies:
supports-color: 5.5.0
delayed-stream@1.0.0: {}
depd@2.0.0: {}
destroy@1.2.0: {}
@ -858,6 +918,13 @@ snapshots:
dependencies:
es-errors: 1.3.0
es-set-tostringtag@2.1.0:
dependencies:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
escalade@3.2.0: {}
escape-html@1.0.3: {}
@ -918,6 +985,15 @@ snapshots:
transitivePeerDependencies:
- supports-color
follow-redirects@1.15.9: {}
form-data@4.0.2:
dependencies:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
forwarded@0.2.0: {}
fresh@0.5.2: {}
@ -997,7 +1073,7 @@ snapshots:
- encoding
- supports-color
googleapis@146.0.0:
googleapis@148.0.0:
dependencies:
google-auth-library: 9.15.1
googleapis-common: 7.2.0
@ -1021,6 +1097,10 @@ snapshots:
has-symbols@1.1.0: {}
has-tostringtag@1.0.2:
dependencies:
has-symbols: 1.1.0
hasown@2.0.2:
dependencies:
function-bind: 1.1.2
@ -1068,7 +1148,7 @@ snapshots:
json-bigint@1.0.0:
dependencies:
bignumber.js: 9.1.2
bignumber.js: 9.3.0
jwa@2.0.0:
dependencies:
@ -1147,6 +1227,8 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
proxy-from-env@1.1.0: {}
pstree.remy@1.1.8: {}
qs@6.13.0:

853
server/game.js Normal file
View File

@ -0,0 +1,853 @@
const { v4: uuidv4 } = require('uuid');
const youtubeAPI = require('./youtube-api');
/**
* Game state and logic manager for Song Battle application
*/
class GameManager {
constructor() {
this.lobbies = new Map(); // Map of lobbyId -> lobby object
this.playerToLobby = new Map(); // Map of playerId -> lobbyId for quick lookup
}
/**
* Create a new game lobby
* @param {string} hostId - ID of the host player
* @param {string} hostName - Name of the host player
* @returns {Object} New lobby data and ID
*/
createLobby(hostId, hostName) {
// Generate a simple 6-character lobby code
const lobbyId = this._generateLobbyCode();
// Create lobby object
const lobby = {
id: lobbyId,
hostId: hostId,
state: 'LOBBY', // LOBBY -> SONG_SUBMISSION -> VOTING -> FINISHED
settings: {
songsPerPlayer: 3,
maxPlayers: 10,
minPlayers: 3,
requireYoutubeLinks: false
},
players: [{
id: hostId,
name: hostName,
isConnected: true,
isReady: false,
songs: [],
songCount: 0
}],
songs: [], // All submitted songs
currentBattle: null,
battles: [], // History of all battles
finalWinner: null
};
// Store lobby and player-to-lobby mapping
this.lobbies.set(lobbyId, lobby);
this.playerToLobby.set(hostId, lobbyId);
return { lobby, lobbyId };
}
/**
* Join an existing lobby
* @param {string} playerId - ID of joining player
* @param {string} playerName - Name of joining player
* @param {string} lobbyId - ID of lobby to join
* @returns {Object} Lobby data or error
*/
joinLobby(playerId, playerName, lobbyId) {
// Check if lobby exists
if (!this.lobbies.has(lobbyId)) {
return { error: 'Lobby not found' };
}
const lobby = this.lobbies.get(lobbyId);
// Check if lobby is in correct state
if (lobby.state !== 'LOBBY') {
return { error: 'Cannot join: game already in progress' };
}
// Check if player limit is reached
if (lobby.players.length >= lobby.settings.maxPlayers) {
return { error: 'Lobby is full' };
}
// Check if player name is already taken
if (lobby.players.some(p => p.name === playerName && p.isConnected)) {
return { error: 'Name already taken' };
}
// Check if player is rejoining
const existingPlayerIndex = lobby.players.findIndex(p => p.name === playerName && !p.isConnected);
if (existingPlayerIndex >= 0) {
// Update player ID and connection status
lobby.players[existingPlayerIndex].id = playerId;
lobby.players[existingPlayerIndex].isConnected = true;
this.playerToLobby.set(playerId, lobbyId);
return { lobby, lobbyId };
}
// Add new player
lobby.players.push({
id: playerId,
name: playerName,
isConnected: true,
isReady: false,
songs: [],
songCount: 0
});
// Update map
this.playerToLobby.set(playerId, lobbyId);
return { lobby, lobbyId };
}
/**
* Handle player reconnection to a lobby
* @param {string} playerId - New ID of the reconnecting player
* @param {string} lobbyId - ID of the lobby
* @param {string} playerName - Name of the player
* @returns {Object} Lobby data or error
*/
handleReconnect(playerId, lobbyId, playerName) {
// Check if lobby exists
if (!this.lobbies.has(lobbyId)) {
return { error: 'Lobby not found' };
}
const lobby = this.lobbies.get(lobbyId);
// Find player by name
const playerIndex = lobby.players.findIndex(p => p.name === playerName);
if (playerIndex === -1) {
return { error: 'Player not found in lobby' };
}
// Update player ID and connection status
lobby.players[playerIndex].id = playerId;
lobby.players[playerIndex].isConnected = true;
// Update map
this.playerToLobby.set(playerId, lobbyId);
return { lobby, lobbyId };
}
/**
* Update lobby settings
* @param {string} playerId - ID of the host player
* @param {Object} settings - New settings object
* @returns {Object} Updated lobby data or error
*/
updateSettings(playerId, settings) {
const lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
return { error: 'Player not in a lobby' };
}
const lobby = this.lobbies.get(lobbyId);
// Check if player is the host
if (lobby.hostId !== playerId) {
return { error: 'Only the host can update settings' };
}
// Validate settings
if (settings.songsPerPlayer < 1 || settings.songsPerPlayer > 10) {
return { error: 'Songs per player must be between 1 and 10' };
}
if (settings.maxPlayers < 3 || settings.maxPlayers > 20) {
return { error: 'Max players must be between 3 and 20' };
}
// Update settings
lobby.settings = {
...lobby.settings,
...settings
};
return { lobby, lobbyId };
}
/**
* Start the game from lobby state
* @param {string} playerId - ID of the host player
* @returns {Object} Updated lobby data or error
*/
startGame(playerId) {
const lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
return { error: 'Player not in a lobby' };
}
const lobby = this.lobbies.get(lobbyId);
// Check if player is the host
if (lobby.hostId !== playerId) {
return { error: 'Only the host can start the game' };
}
// Check if in correct state
if (lobby.state !== 'LOBBY') {
return { error: 'Game already started' };
}
// Check if enough players
if (lobby.players.length < lobby.settings.minPlayers) {
return { error: `Need at least ${lobby.settings.minPlayers} players to start` };
}
// Move to song submission state
lobby.state = 'SONG_SUBMISSION';
return { lobby, lobbyId };
}
/**
* Add a song for a player
* @param {string} playerId - ID of the player
* @param {Object} song - Song data (title, artist, youtubeLink)
* @returns {Object} Updated lobby data or error
*/
async addSong(playerId, song) {
console.log(`[DEBUG] addSong called for player ID: ${playerId}`);
// Check if player is in a lobby
let lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
// If no mapping exists, try to find the player in any lobby
console.log(`[DEBUG] No lobby mapping found for player ID: ${playerId}, trying to locate player...`);
// Search all lobbies for this player
let foundLobby = null;
let foundPlayerIndex = -1;
for (const [id, lobby] of this.lobbies.entries()) {
const playerIndex = lobby.players.findIndex(p => p.id === playerId);
if (playerIndex !== -1) {
foundLobby = lobby;
lobbyId = id;
foundPlayerIndex = playerIndex;
console.log(`[DEBUG] Found player in lobby ${id} at index ${playerIndex}`);
// Fix the mapping for future requests
this.playerToLobby.set(playerId, id);
break;
}
}
if (!foundLobby) {
console.log(`[DEBUG] Player not found in any lobby. Available lobbies: ${Array.from(this.lobbies.keys())}`);
return { error: 'Player not in a lobby' };
}
}
const lobby = this.lobbies.get(lobbyId);
if (!lobby) {
console.log(`[DEBUG] Lobby ID ${lobbyId} not found`);
return { error: 'Lobby not found' };
}
// Log lobby state for debugging
console.log(`[DEBUG] Lobby ${lobbyId} state: ${lobby.state}`);
console.log(`[DEBUG] Lobby players: ${JSON.stringify(lobby.players.map(p => ({id: p.id, name: p.name})))}`);
// Check if in correct state
if (lobby.state !== 'SONG_SUBMISSION') {
return { error: 'Cannot add songs at this time' };
}
// Get player
const playerIndex = lobby.players.findIndex(p => p.id === playerId);
if (playerIndex === -1) {
console.log(`[DEBUG] Player ID ${playerId} not found in lobby ${lobbyId}.`);
// First try: Find the host if this is a host request
if (playerId === lobby.hostId || lobby.players.some(p => p.id === lobby.hostId)) {
console.log('[DEBUG] This appears to be the host. Looking for host player...');
const hostIndex = lobby.players.findIndex(p => p.id === lobby.hostId);
if (hostIndex !== -1) {
console.log(`[DEBUG] Found host at index ${hostIndex}`);
return this._addSongToPlayer(hostIndex, lobby, lobbyId, song, playerId);
}
}
// Second try: Match any connected player as fallback
console.log('[DEBUG] Trying to find any connected player...');
const connectedIndex = lobby.players.findIndex(p => p.isConnected);
if (connectedIndex !== -1) {
console.log(`[DEBUG] Found connected player at index ${connectedIndex}`);
return this._addSongToPlayer(connectedIndex, lobby, lobbyId, song, playerId);
}
// If we still can't find a match, use the first player as last resort
if (lobby.players.length > 0) {
console.log('[DEBUG] Using first player as last resort');
return this._addSongToPlayer(0, lobby, lobbyId, song, playerId);
}
return { error: 'Player not found in lobby' };
}
// Check if player can add more songs
if (lobby.players[playerIndex].songs.length >= lobby.settings.songsPerPlayer) {
return { error: 'Maximum number of songs reached' };
}
// We only require the YouTube link now
if (!song.youtubeLink) {
return { error: 'YouTube link is required' };
}
// If the YouTube link isn't valid, return an error
const videoId = await youtubeAPI.extractVideoId(song.youtubeLink);
if (!videoId) {
return { error: 'Invalid YouTube link' };
}
// Handle async metadata fetching
return this._addSongToPlayer(playerIndex, lobby, lobbyId, song, playerId);
}
/**
* Search for songs on YouTube
* @param {string} query - Search query
* @returns {Promise<Array>} Search results
*/
async searchYouTube(query) {
try {
return await youtubeAPI.searchYouTube(query);
} catch (error) {
console.error('Error searching YouTube:', error);
return { error: 'Failed to search YouTube' };
}
}
/**
* Helper method to add a song to a player
* @param {number} playerIndex - Index of the player in the lobby
* @param {Object} lobby - The lobby object
* @param {string} lobbyId - ID of the lobby
* @param {Object} song - Song data to add
* @param {string} playerId - ID of the player adding the song
* @returns {Object|Promise<Object>} Updated lobby data
* @private
*/
async _addSongToPlayer(playerIndex, lobby, lobbyId, song, playerId) {
// Generate song ID
const songId = uuidv4();
// Prepare song data
let title = song.title;
let artist = song.artist;
let thumbnail = song.thumbnail || null; // Use client-provided thumbnail if available
// If YouTube link is provided but title/artist are empty, try to fetch metadata
if (song.youtubeLink && (!title || !artist || title === 'Unknown' || artist === 'Unknown' || !thumbnail)) {
try {
// Extract video ID from YouTube link
const videoId = youtubeAPI.extractVideoId(song.youtubeLink);
if (videoId) {
console.log(`Getting metadata for YouTube video: ${videoId}`);
// Fetch metadata from YouTube API
const metadata = await youtubeAPI.getVideoMetadata(videoId);
// Only update if we don't have values or they're generic
if (!title || title === 'Unknown') title = metadata.title || 'Unknown';
if (!artist || artist === 'Unknown') artist = metadata.artist || 'Unknown';
if (!thumbnail) thumbnail = metadata.thumbnail;
console.log(`Fetched metadata: "${title}" by ${artist}`);
}
} catch (error) {
console.error('Error fetching YouTube metadata:', error.message);
// Continue with user-provided data if API fails
}
}
// Add song to player and global song list
const newSong = {
id: songId,
title: title,
artist: artist,
youtubeLink: song.youtubeLink || '',
thumbnail: thumbnail,
submittedById: playerId,
submittedByName: lobby.players[playerIndex].name
};
// Add song to both player's list and global list
console.log(`[DEBUG] Adding song "${title}" to player at index ${playerIndex} in lobby ${lobbyId}`);
lobby.players[playerIndex].songs.push(newSong);
lobby.players[playerIndex].songCount = lobby.players[playerIndex].songs.length;
lobby.songs.push(newSong);
console.log(`[DEBUG] Updated player song count: ${lobby.players[playerIndex].songCount}`);
console.log(`[DEBUG] Total songs in lobby: ${lobby.songs.length}`);
// Make sure we're returning a properly formed result with both lobby and lobbyId
const result = { lobby, lobbyId };
// Validate that the result contains what we expect before returning
if (!result.lobby || !result.lobbyId) {
console.log(`[DEBUG] CRITICAL ERROR: Result is missing lobby or lobbyId after adding song`);
console.log(`[DEBUG] Result keys: ${Object.keys(result)}`);
} else {
console.log(`[DEBUG] Song successfully added, returning result with valid lobby and lobbyId`);
}
return result;
}
/**
* Remove a song for a player
* @param {string} playerId - ID of the player
* @param {string} songId - ID of the song to remove
* @returns {Object} Updated lobby data or error
*/
removeSong(playerId, songId) {
const lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
return { error: 'Player not in a lobby' };
}
const lobby = this.lobbies.get(lobbyId);
// Check if in correct state
if (lobby.state !== 'SONG_SUBMISSION') {
return { error: 'Cannot remove songs at this time' };
}
// Get player
const playerIndex = lobby.players.findIndex(p => p.id === playerId);
if (playerIndex === -1) {
return { error: 'Player not found in lobby' };
}
// Find song in player's songs
const songIndex = lobby.players[playerIndex].songs.findIndex(s => s.id === songId);
if (songIndex === -1) {
return { error: 'Song not found' };
}
// Remove song from player's list
lobby.players[playerIndex].songs.splice(songIndex, 1);
lobby.players[playerIndex].songCount = lobby.players[playerIndex].songs.length;
// Remove from global song list
const globalSongIndex = lobby.songs.findIndex(s => s.id === songId);
if (globalSongIndex !== -1) {
lobby.songs.splice(globalSongIndex, 1);
}
// If player was ready, set to not ready
if (lobby.players[playerIndex].isReady) {
lobby.players[playerIndex].isReady = false;
}
return { lobby, lobbyId };
}
/**
* Set player ready status in song submission phase
* @param {string} playerId - ID of the player
* @returns {Object} Updated lobby data or error
*/
setPlayerReady(playerId) {
const lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
return { error: 'Player not in a lobby' };
}
const lobby = this.lobbies.get(lobbyId);
// Check if in correct state
if (lobby.state !== 'SONG_SUBMISSION') {
return { error: 'Cannot set ready status at this time' };
}
// Get player
const playerIndex = lobby.players.findIndex(p => p.id === playerId);
if (playerIndex === -1) {
return { error: 'Player not found in lobby' };
}
// Check if player has submitted enough songs
if (lobby.players[playerIndex].songs.length < lobby.settings.songsPerPlayer) {
return { error: 'Must submit all songs before ready' };
}
// Set player as ready
lobby.players[playerIndex].isReady = true;
// Check if all players are ready
const allReady = lobby.players.every(p => p.isReady || !p.isConnected);
if (allReady) {
console.log('All players ready, starting tournament...');
// Start the tournament by creating brackets
this._startTournament(lobby);
// Return an indicator that the tournament has started
return {
lobby,
lobbyId,
tournamentStarted: true
};
}
return { lobby, lobbyId };
}
/**
* Submit a vote for a song in a battle
* @param {string} playerId - ID of the voting player
* @param {string} songId - ID of the voted song
* @returns {Object} Updated lobby data or error
*/
submitVote(playerId, songId) {
const lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
return { error: 'Player not in a lobby' };
}
const lobby = this.lobbies.get(lobbyId);
// Check if in correct state
if (lobby.state !== 'VOTING') {
return { error: 'Cannot vote at this time' };
}
// Check if there's an active battle
if (!lobby.currentBattle) {
return { error: 'No active battle' };
}
// Check if player has already voted in this battle
if (lobby.currentBattle.votes.has(playerId)) {
return { error: 'Already voted in this battle' };
}
// Check if the voted song is part of the current battle
if (songId !== lobby.currentBattle.song1.id && songId !== lobby.currentBattle.song2.id) {
return { error: 'Invalid song ID' };
}
// Record the vote
lobby.currentBattle.votes.set(playerId, songId);
// Update vote counts
if (songId === lobby.currentBattle.song1.id) {
lobby.currentBattle.song1Votes++;
} else {
lobby.currentBattle.song2Votes++;
}
// Check if all connected players have voted
const connectedPlayers = lobby.players.filter(p => p.isConnected).length;
const voteCount = lobby.currentBattle.votes.size;
if (voteCount >= connectedPlayers) {
// Determine winner
const winnerSongId = lobby.currentBattle.song1Votes > lobby.currentBattle.song2Votes
? lobby.currentBattle.song1.id
: lobby.currentBattle.song2.id;
lobby.currentBattle.winner = winnerSongId;
// Save battle to history
lobby.battles.push({
round: lobby.currentBattle.round,
song1: lobby.currentBattle.song1,
song2: lobby.currentBattle.song2,
song1Votes: lobby.currentBattle.song1Votes,
song2Votes: lobby.currentBattle.song2Votes,
winner: winnerSongId
});
// Move to next battle or finish tournament
this._moveToNextBattle(lobby);
}
return { lobby, lobbyId };
}
/**
* Handle player leaving a lobby
* @param {string} playerId - ID of the player
* @returns {Object} Updated lobby data or null if lobby is removed
*/
leaveLobby(playerId) {
const lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
return null; // Player not in a lobby
}
const lobby = this.lobbies.get(lobbyId);
// Remove player from lobby
const playerIndex = lobby.players.findIndex(p => p.id === playerId);
if (playerIndex !== -1) {
// If game hasn't started, remove player completely
if (lobby.state === 'LOBBY') {
lobby.players.splice(playerIndex, 1);
} else {
// Otherwise just mark as disconnected
lobby.players[playerIndex].isConnected = false;
}
}
// Remove from map
this.playerToLobby.delete(playerId);
// If it's the host leaving and game hasn't started, assign new host or delete lobby
if (lobby.hostId === playerId && lobby.state === 'LOBBY') {
if (lobby.players.length > 0) {
lobby.hostId = lobby.players[0].id;
} else {
this.lobbies.delete(lobbyId);
return null;
}
}
// If all players have left, remove the lobby
const connectedPlayers = lobby.players.filter(p => p.isConnected).length;
if (connectedPlayers === 0) {
this.lobbies.delete(lobbyId);
return null;
}
// For voting state, check if we need to progress
if (lobby.state === 'VOTING' && lobby.currentBattle) {
// Add null check for votes property
const totalVotes = lobby.currentBattle.votes?.size || 0;
const remainingPlayers = lobby.players.filter(p => p.isConnected).length;
if (totalVotes >= remainingPlayers && totalVotes > 0) {
// Determine winner
const winnerSongId = lobby.currentBattle.song1Votes > lobby.currentBattle.song2Votes
? lobby.currentBattle.song1.id
: lobby.currentBattle.song2.id;
lobby.currentBattle.winner = winnerSongId;
// Save battle to history
lobby.battles.push({
round: lobby.currentBattle.round,
song1: lobby.currentBattle.song1,
song2: lobby.currentBattle.song2,
song1Votes: lobby.currentBattle.song1Votes,
song2Votes: lobby.currentBattle.song2Votes,
winner: winnerSongId
});
// Move to next battle or finish tournament
this._moveToNextBattle(lobby);
}
}
return { lobby, lobbyId };
}
/**
* Handle player disconnection
* @param {string} playerId - ID of the disconnected player
* @returns {Object|null} Updated lobby data or null if no lobby found
*/
handleDisconnect(playerId) {
return this.leaveLobby(playerId);
}
/**
* Generate a unique lobby code
* @returns {string} 6-character lobby code
* @private
*/
_generateLobbyCode() {
const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed ambiguous characters
let result = '';
// Generate code until unique
do {
result = '';
for (let i = 0; i < 6; i++) {
result += characters.charAt(Math.floor(Math.random() * characters.length));
}
} while (this.lobbies.has(result));
return result;
}
/**
* Start the tournament by creating brackets and first battle
* @param {Object} lobby - Lobby object
* @private
*/
_startTournament(lobby) {
// Collect all submitted songs
const songs = [...lobby.songs];
// Shuffle songs
this._shuffleArray(songs);
// Create tournament brackets
const brackets = [];
let round = 0;
// Handle odd number of songs by giving a bye to one song
if (songs.length % 2 !== 0 && songs.length > 1) {
const byeSong = songs.pop();
brackets.push({
round,
song1: byeSong,
song2: null, // Bye
bye: true,
winner: byeSong.id,
song1Votes: 0,
song2Votes: 0
});
}
// Create initial bracket pairs
for (let i = 0; i < songs.length; i += 2) {
if (i + 1 < songs.length) {
brackets.push({
round,
song1: songs[i],
song2: songs[i + 1],
song1Votes: 0,
song2Votes: 0,
winner: null,
votes: new Map()
});
}
}
// Store brackets and transition to voting state
lobby.brackets = brackets;
lobby.currentBracketIndex = 0;
if (brackets.length > 0) {
// Set the first battle
lobby.state = 'VOTING';
// Ensure we create a proper battle object with all required fields
lobby.currentBattle = {
...brackets[0],
// Make sure votes is a Map (it might be serialized incorrectly)
votes: brackets[0].votes || new Map(),
voteCount: 0
};
console.log('Starting first battle:', lobby.currentBattle);
} else {
// Edge case: only one song submitted
if (songs.length === 1) {
lobby.state = 'FINISHED';
lobby.finalWinner = songs[0];
}
}
}
/**
* Move to next battle or finish tournament
* @param {Object} lobby - Lobby object
* @private
*/
_moveToNextBattle(lobby) {
// Check if there are more battles in current round
lobby.currentBracketIndex++;
if (lobby.currentBracketIndex < lobby.brackets.length) {
// Move to next battle
lobby.currentBattle = lobby.brackets[lobby.currentBracketIndex];
return;
}
// Current round complete, check if tournament is finished
const winners = lobby.brackets
.filter(b => !b.bye) // Skip byes
.map(b => {
const winningSong = b.song1.id === b.winner ? b.song1 : b.song2;
return winningSong;
});
// Add byes to winners
const byes = lobby.brackets
.filter(b => b.bye)
.map(b => b.song1);
const nextRoundSongs = [...winners, ...byes];
// If only one song remains, we have a winner
if (nextRoundSongs.length <= 1) {
lobby.state = 'FINISHED';
lobby.finalWinner = nextRoundSongs[0];
lobby.currentBattle = null;
return;
}
// Create brackets for next round
const nextRound = [];
const round = lobby.brackets[0].round + 1;
// Handle odd number of songs by giving a bye to one song
if (nextRoundSongs.length % 2 !== 0) {
const byeSong = nextRoundSongs.pop();
nextRound.push({
round,
song1: byeSong,
song2: null,
bye: true,
winner: byeSong.id,
song1Votes: 0,
song2Votes: 0
});
}
// Create pairs for next round
for (let i = 0; i < nextRoundSongs.length; i += 2) {
if (i + 1 < nextRoundSongs.length) {
nextRound.push({
round,
song1: nextRoundSongs[i],
song2: nextRoundSongs[i + 1],
song1Votes: 0,
song2Votes: 0,
winner: null,
votes: new Map()
});
}
}
// Update brackets and reset index
lobby.brackets = nextRound;
lobby.currentBracketIndex = 0;
// Set first battle of new round
if (nextRound.length > 0) {
lobby.currentBattle = nextRound[0];
}
}
/**
* Shuffle array in-place using Fisher-Yates algorithm
* @param {Array} array - Array to shuffle
* @private
*/
_shuffleArray(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
}
}
// Export GameManager class
module.exports = GameManager;

View File

@ -3,11 +3,12 @@ const { Server } = require("socket.io");
const http = require("http");
const app = express();
const path = require("path");
const GameManager = require("./game");
app.use(express.static(path.join(__dirname, './dist')));
app.use(express.static(path.join(__dirname, '../client/dist')));
app.disable("x-powered-by");
app.get('*', (req, res) => res.sendFile(path.join(__dirname, './dist', 'index.html')));
app.get('*', (req, res) => res.sendFile(path.join(__dirname, '../client/dist', 'index.html')));
const server = http.createServer(app);
@ -17,6 +18,326 @@ const io = new Server(server, {
pingInterval: 10000
});
// Initialize game manager
const gameManager = new GameManager();
// Socket.IO event handlers
io.on('connection', (socket) => {
console.log(`User connected: ${socket.id}`);
// Create a new game lobby
socket.on('create_lobby', ({ playerName }, callback) => {
try {
const result = gameManager.createLobby(socket.id, playerName);
// Join the socket room for this lobby
socket.join(result.lobbyId);
// Send response to client
if (callback) callback(result);
console.log(`Lobby created: ${result.lobbyId} by ${playerName}`);
} catch (error) {
console.error('Error creating lobby:', error);
if (callback) callback({ error: 'Failed to create lobby' });
}
});
// Join an existing lobby
socket.on('join_lobby', ({ lobbyId, playerName }, callback) => {
try {
const result = gameManager.joinLobby(socket.id, playerName, lobbyId);
if (result.error) {
if (callback) callback(result);
return;
}
// Join the socket room for this lobby
socket.join(lobbyId);
// Notify all players in the lobby
socket.to(lobbyId).emit('player_joined', {
playerId: socket.id,
playerName
});
// Send response to client
if (callback) callback(result);
console.log(`Player ${playerName} joined lobby ${lobbyId}`);
} catch (error) {
console.error('Error joining lobby:', error);
if (callback) callback({ error: 'Failed to join lobby' });
}
});
// Attempt to reconnect to a lobby
socket.on('reconnect_to_lobby', ({ lobbyId, playerName }, callback) => {
try {
const result = gameManager.handleReconnect(socket.id, lobbyId, playerName);
if (result.error) {
if (callback) callback(result);
return;
}
// Join the socket room for this lobby
socket.join(lobbyId);
// Notify all players in the lobby
socket.to(lobbyId).emit('player_reconnected', {
playerId: socket.id,
playerName
});
// Send response to client
if (callback) callback(result);
console.log(`Player ${playerName} reconnected to lobby ${lobbyId}`);
} catch (error) {
console.error('Error reconnecting to lobby:', error);
if (callback) callback({ error: 'Failed to reconnect to lobby' });
}
});
// Update lobby settings
socket.on('update_settings', ({ settings }, callback) => {
try {
const result = gameManager.updateSettings(socket.id, settings);
if (result.error) {
if (callback) callback(result);
return;
}
// Notify all players in the lobby
io.to(result.lobbyId).emit('settings_updated', {
settings: result.lobby.settings
});
// Send response to client
if (callback) callback(result);
} catch (error) {
console.error('Error updating settings:', error);
if (callback) callback({ error: 'Failed to update settings' });
}
});
// Start the game from lobby
socket.on('start_game', (_, callback) => {
try {
const result = gameManager.startGame(socket.id);
if (result.error) {
if (callback) callback(result);
return;
}
// Notify all players in the lobby
io.to(result.lobbyId).emit('game_started', {
state: result.lobby.state
});
// Send response to client
if (callback) callback(result);
console.log(`Game started in lobby ${result.lobbyId}`);
} catch (error) {
console.error('Error starting game:', error);
if (callback) callback({ error: 'Failed to start game' });
}
});
// Add a song
socket.on('add_song', ({ song }, callback) => {
try {
console.log(`[DEBUG] Attempting to add song: ${JSON.stringify(song)}`);
const result = gameManager.addSong(socket.id, song);
if (result.error) {
console.log(`[DEBUG] Error adding song: ${result.error}`);
if (callback) callback(result);
return;
}
// Add better error handling to prevent crash if lobby is not defined
if (!result.lobby || !result.lobbyId) {
console.log(`[DEBUG] Warning: Song added but lobby information is missing`);
if (callback) callback({ error: 'Failed to associate song with lobby' });
return;
}
console.log(`[DEBUG] Song added successfully, notifying lobby ${result.lobbyId}`);
console.log(`[DEBUG] Lobby songs: ${JSON.stringify(result.lobby.songs.map(s => s.title))}`);
// Notify all players in the lobby about updated song count
io.to(result.lobbyId).emit('songs_updated', {
lobby: result.lobby
});
// Send response to client
if (callback) callback(result);
} catch (error) {
console.error('Error adding song:', error);
if (callback) callback({ error: 'Failed to add song' });
}
});
// Remove a song
socket.on('remove_song', ({ songId }, callback) => {
try {
const result = gameManager.removeSong(socket.id, songId);
if (result.error) {
if (callback) callback(result);
return;
}
// Notify all players in the lobby
io.to(result.lobbyId).emit('songs_updated', {
lobby: result.lobby
});
// Send response to client
if (callback) callback(result);
} catch (error) {
console.error('Error removing song:', error);
if (callback) callback({ error: 'Failed to remove song' });
}
});
// Search YouTube for songs
socket.on('search_youtube', async ({ query }, callback) => {
try {
if (!query || typeof query !== 'string') {
if (callback) callback({ error: 'Invalid search query' });
return;
}
const results = await gameManager.searchYouTube(query);
// Send response to client
if (callback) callback({ results });
} catch (error) {
console.error('Error searching YouTube:', error);
if (callback) callback({ error: 'Failed to search YouTube' });
}
});
// Mark player as ready
socket.on('player_ready', (_, callback) => {
try {
const result = gameManager.setPlayerReady(socket.id);
if (result.error) {
if (callback) callback(result);
return;
}
// Notify all players in the lobby
io.to(result.lobbyId).emit('player_status_changed', {
lobby: result.lobby
});
// If the game state just changed to VOTING, notify about the first battle
if (result.lobby.state === 'VOTING' && result.lobby.currentBattle) {
console.log('Sending new_battle event to clients');
io.to(result.lobbyId).emit('new_battle', {
battle: result.lobby.currentBattle
});
}
// If tournament just started, explicitly notify about the state change and first battle
if (result.tournamentStarted && result.lobby.currentBattle) {
console.log('Tournament started, sending battle data');
// First send state change
io.to(result.lobbyId).emit('tournament_started', {
state: 'VOTING'
});
// Then send battle data with a slight delay to ensure clients process in the right order
setTimeout(() => {
io.to(result.lobbyId).emit('new_battle', {
battle: result.lobby.currentBattle
});
}, 300);
}
// If game finished (edge case where only one song was submitted)
if (result.lobby.state === 'FINISHED') {
io.to(result.lobbyId).emit('game_finished', {
winner: result.lobby.finalWinner,
battles: result.lobby.battles
});
}
// Send response to client
if (callback) callback(result);
} catch (error) {
console.error('Error setting player ready:', error);
if (callback) callback({ error: 'Failed to set player status' });
}
});
// Submit a vote in a battle
socket.on('submit_vote', ({ songId }, callback) => {
try {
const result = gameManager.submitVote(socket.id, songId);
if (result.error) {
if (callback) callback(result);
return;
}
// Notify all players about vote count
io.to(result.lobbyId).emit('vote_submitted', {
lobby: result.lobby
});
// If battle is finished, notify about new battle
if (result.lobby.currentBattle) {
io.to(result.lobbyId).emit('new_battle', {
battle: result.lobby.currentBattle
});
}
// If game is finished, notify about the winner
if (result.lobby.state === 'FINISHED') {
io.to(result.lobbyId).emit('game_finished', {
winner: result.lobby.finalWinner,
battles: result.lobby.battles
});
}
// Send response to client
if (callback) callback(result);
} catch (error) {
console.error('Error submitting vote:', error);
if (callback) callback({ error: 'Failed to submit vote' });
}
});
// Handle disconnection
socket.on('disconnect', () => {
try {
const result = gameManager.handleDisconnect(socket.id);
if (result) {
// Notify remaining players about the disconnection
io.to(result.lobbyId).emit('player_disconnected', {
playerId: socket.id,
lobby: result.lobby
});
console.log(`Player ${socket.id} disconnected from lobby ${result.lobbyId}`);
}
} catch (error) {
console.error('Error handling disconnect:', error);
}
});
});
server.on('error', (error) => {
console.error('Server error:', error);
});

129
server/youtube-api.js Normal file
View File

@ -0,0 +1,129 @@
const { google } = require('googleapis');
const axios = require('axios');
// You would need to obtain a YouTube API key from Google Cloud Console
// For now, we'll use a placeholder - you'll need to replace this with your actual API key
const API_KEY = process.env.YOUTUBE_API_KEY || 'NO';
// Initialize the YouTube API client
const youtube = google.youtube({
version: 'v3',
auth: API_KEY
});
/**
* Extract YouTube video ID from various formats of YouTube links
* @param {string} url - YouTube URL in any format
* @returns {string|null} YouTube video ID or null if invalid
*/
function extractVideoId(url) {
if (!url) return null;
// Handle different YouTube URL formats
const patterns = [
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/watch\?v=([^&]+)/,
/(?:https?:\/\/)?(?:www\.)?youtube\.com\/embed\/([^/?]+)/,
/(?:https?:\/\/)?(?:www\.)?youtu\.be\/([^/?]+)/
];
for (const pattern of patterns) {
const match = url.match(pattern);
if (match && match[1]) {
return match[1];
}
}
return null;
}
/**
* Get video metadata from YouTube API
* @param {string} videoId - YouTube video ID
* @returns {Promise<Object>} Video metadata including title and channel
*/
async function getVideoMetadata(videoId) {
try {
// Try using the googleapis library first
try {
const response = await youtube.videos.list({
part: 'snippet',
id: videoId
});
const video = response.data.items[0];
if (video && video.snippet) {
return {
title: video.snippet.title,
artist: video.snippet.channelTitle,
thumbnail: video.snippet.thumbnails.default.url
};
}
} catch (googleApiError) {
console.log('Google API error, falling back to alternative method:', googleApiError.message);
// If googleapis fails (e.g., due to API key issues), fall back to a simplified approach
const response = await axios.get(`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`);
if (response.data) {
const title = response.data.title || '';
// Try to extract artist from title (common format is "Artist - Title")
const parts = title.split(' - ');
const artist = parts.length > 1 ? parts[0] : response.data.author_name;
return {
title: parts.length > 1 ? parts.slice(1).join(' - ') : title,
artist: artist,
thumbnail: response.data.thumbnail_url
};
}
}
// If all methods fail, return default values
return {
title: 'Unknown Title',
artist: 'Unknown Artist',
thumbnail: null
};
} catch (error) {
console.error('Error fetching video metadata:', error.message);
return {
title: 'Unknown Title',
artist: 'Unknown Artist',
thumbnail: null
};
}
}
/**
* Search for songs on YouTube
* @param {string} query - Search query for YouTube
* @returns {Promise<Array>} List of search results
*/
async function searchYouTube(query) {
try {
const response = await youtube.search.list({
part: 'snippet',
q: query,
type: 'video',
maxResults: 5,
videoCategoryId: '10' // Category ID for Music
});
return response.data.items.map(item => ({
id: item.id.videoId,
title: item.snippet.title,
artist: item.snippet.channelTitle,
thumbnail: item.snippet.thumbnails.default.url,
youtubeLink: `https://www.youtube.com/watch?v=${item.id.videoId}`
}));
} catch (error) {
console.error('Error searching YouTube:', error.message);
return [];
}
}
module.exports = {
extractVideoId,
getVideoMetadata,
searchYouTube
};