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
Some checks failed
Publish Docker image / Push Docker image to Docker Hub (push) Failing after 7m12s
This commit is contained in:
parent
44a75ba715
commit
22eca7d4e0
@ -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
BIN
client/public/logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
@ -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 |
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
116
client/src/common/styles/buttons.sass
Normal file
116
client/src/common/styles/buttons.sass
Normal 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)
|
@ -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
|
||||
|
89
client/src/common/styles/components/home-screen.sass
Normal file
89
client/src/common/styles/components/home-screen.sass
Normal 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
|
193
client/src/common/styles/components/results-screen.sass
Normal file
193
client/src/common/styles/components/results-screen.sass
Normal 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)
|
257
client/src/common/styles/components/song-submission-screen.sass
Normal file
257
client/src/common/styles/components/song-submission-screen.sass
Normal 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
|
199
client/src/common/styles/components/voting-screen.sass
Normal file
199
client/src/common/styles/components/voting-screen.sass
Normal 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
|
11
client/src/common/styles/components/youtube-embed.sass
Normal file
11
client/src/common/styles/components/youtube-embed.sass
Normal 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
|
181
client/src/common/styles/components/youtube-search.sass
Normal file
181
client/src/common/styles/components/youtube-search.sass
Normal 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
|
@ -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
95
client/src/components/HomeScreen.jsx
Normal file
95
client/src/components/HomeScreen.jsx
Normal 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;
|
172
client/src/components/LobbyScreen.jsx
Normal file
172
client/src/components/LobbyScreen.jsx
Normal 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;
|
160
client/src/components/ResultsScreen.jsx
Normal file
160
client/src/components/ResultsScreen.jsx
Normal 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;
|
476
client/src/components/SongSubmissionScreen.jsx
Normal file
476
client/src/components/SongSubmissionScreen.jsx
Normal 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;
|
182
client/src/components/VotingScreen.jsx
Normal file
182
client/src/components/VotingScreen.jsx
Normal 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;
|
46
client/src/components/YouTubeEmbed.jsx
Normal file
46
client/src/components/YouTubeEmbed.jsx
Normal 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;
|
471
client/src/context/GameContext.jsx
Normal file
471
client/src/context/GameContext.jsx
Normal 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 };
|
@ -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>,
|
||||
);
|
17
package.json
17
package.json
@ -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
100
pnpm-lock.yaml
generated
@ -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
853
server/game.js
Normal 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;
|
325
server/index.js
325
server/index.js
@ -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
129
server/youtube-api.js
Normal 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
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user