diff --git a/client/index.html b/client/index.html
index a07a8a5..214db0c 100644
--- a/client/index.html
+++ b/client/index.html
@@ -2,7 +2,7 @@
-
+
Liedkampf
diff --git a/client/public/logo.png b/client/public/logo.png
new file mode 100644
index 0000000..aecb39f
Binary files /dev/null and b/client/public/logo.png differ
diff --git a/client/public/logo.svg b/client/public/logo.svg
deleted file mode 100644
index ce928be..0000000
--- a/client/public/logo.svg
+++ /dev/null
@@ -1,24 +0,0 @@
-
diff --git a/client/src/App.jsx b/client/src/App.jsx
index 5f8354a..9b4d45d 100644
--- a/client/src/App.jsx
+++ b/client/src/App.jsx
@@ -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 ;
+ }
+
+ switch (lobby.state) {
+ case 'LOBBY':
+ return ;
+ case 'SONG_SUBMISSION':
+ return ;
+ case 'VOTING':
+ return ;
+ case 'FINISHED':
+ return ;
+ default:
+ return ;
+ }
+ };
+
return (
<>
@@ -54,6 +82,22 @@ const App = () => {
))}
+
+
+ {!isConnected && (
+
+
Connecting to server...
+
+ )}
+
+ {error && (
+
+ )}
+
+ {renderGameScreen()}
+
>
)
}
diff --git a/client/src/common/styles/buttons.sass b/client/src/common/styles/buttons.sass
new file mode 100644
index 0000000..46984c9
--- /dev/null
+++ b/client/src/common/styles/buttons.sass
@@ -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)
diff --git a/client/src/common/styles/colors.sass b/client/src/common/styles/colors.sass
index 8b1f658..3977397 100644
--- a/client/src/common/styles/colors.sass
+++ b/client/src/common/styles/colors.sass
@@ -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
\ No newline at end of file
+// 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
diff --git a/client/src/common/styles/components/home-screen.sass b/client/src/common/styles/components/home-screen.sass
new file mode 100644
index 0000000..737c3fa
--- /dev/null
+++ b/client/src/common/styles/components/home-screen.sass
@@ -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
diff --git a/client/src/common/styles/components/lobby-screen.sass b/client/src/common/styles/components/lobby-screen.sass
new file mode 100644
index 0000000..e69de29
diff --git a/client/src/common/styles/components/results-screen.sass b/client/src/common/styles/components/results-screen.sass
new file mode 100644
index 0000000..70ddaa1
--- /dev/null
+++ b/client/src/common/styles/components/results-screen.sass
@@ -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)
diff --git a/client/src/common/styles/components/song-submission-screen.sass b/client/src/common/styles/components/song-submission-screen.sass
new file mode 100644
index 0000000..8c2e951
--- /dev/null
+++ b/client/src/common/styles/components/song-submission-screen.sass
@@ -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
diff --git a/client/src/common/styles/components/voting-screen.sass b/client/src/common/styles/components/voting-screen.sass
new file mode 100644
index 0000000..09d7497
--- /dev/null
+++ b/client/src/common/styles/components/voting-screen.sass
@@ -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
diff --git a/client/src/common/styles/components/youtube-embed.sass b/client/src/common/styles/components/youtube-embed.sass
new file mode 100644
index 0000000..4988afa
--- /dev/null
+++ b/client/src/common/styles/components/youtube-embed.sass
@@ -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
diff --git a/client/src/common/styles/components/youtube-search.sass b/client/src/common/styles/components/youtube-search.sass
new file mode 100644
index 0000000..d5e6307
--- /dev/null
+++ b/client/src/common/styles/components/youtube-search.sass
@@ -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
diff --git a/client/src/common/styles/forms.sass b/client/src/common/styles/forms.sass
index 69e339a..a0d3849 100644
--- a/client/src/common/styles/forms.sass
+++ b/client/src/common/styles/forms.sass
@@ -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
diff --git a/client/src/common/styles/main.sass b/client/src/common/styles/main.sass
index 7041d64..9beceaa 100644
--- a/client/src/common/styles/main.sass
+++ b/client/src/common/styles/main.sass
@@ -1,230 +1,847 @@
-@use "@/common/styles/colors" as *
+// Main styles for the Song Battle application
+@import './colors'
+@import './forms'
-*
- box-sizing: border-box
+// Component styles
+@import './components/home-screen'
+@import './components/lobby-screen'
+@import './components/song-submission-screen'
+@import './components/voting-screen'
+@import './components/results-screen'
+@import './components/youtube-embed'
+@import './components/youtube-search'
-body
+// Global styles
+html, body
margin: 0
padding: 0
- letter-spacing: 0.2rem
- color: #E0E0E0
- font-family: 'Bangers', sans-serif
- height: 100vh
- width: 100vw
- background-size: 300% 300%
- animation: shifting-background 30s ease infinite
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif
background: linear-gradient(45deg, #AE00FF 0%, #FF007F 100%) fixed
- user-select: none
- overflow: hidden
- position: relative
-
- &:before
- content: ""
- position: fixed
- top: 0
- left: 0
- right: 0
- bottom: 0
- background-image: url("/background.svg")
+ color: $text
+ height: 100%
+ overflow-x: hidden
- .background-overlay
- position: fixed
- top: 0
- left: 0
- width: 100vw
- height: 100vh
- z-index: 0
- pointer-events: none
-
- .rotating-gradient
- position: absolute
- top: 50%
- left: 50%
- width: 250vh
- height: 250vh
- transform: translate(-50%, -50%)
- background: conic-gradient(from 0deg, rgba(255, 102, 196, 0.2), rgba(102, 204, 255, 0.2), rgba(255, 209, 128, 0.2), rgba(133, 255, 189, 0.2), rgba(255, 102, 196, 0.2))
- border-radius: 50%
- animation: rotate-background 180s linear infinite
- will-change: transform
- transform-origin: center center
- opacity: 0.7
- filter: blur(40px)
+#root, .app
+ height: 100%
+ width: 100%
+ display: flex
+ flex-direction: column
- &:after
- content: ""
- position: fixed
- top: 0
- left: 0
- right: 0
- bottom: 0
- background: radial-gradient(circle at center, transparent 0%, rgba(0, 0, 0, 0.1) 60%, rgba(0, 0, 0, 0.4) 100%)
- z-index: 1
- pointer-events: none
-
-::-webkit-scrollbar
- width: 8px
- background-color: rgba(0, 0, 0, 0.2)
- border-radius: 4px
-
-::-webkit-scrollbar-thumb
- background-color: rgba(255, 255, 255, 0.15)
- border-radius: 4px
- border: 1px solid rgba(255, 255, 255, 0.05)
-
+a
+ color: $primary
+ text-decoration: none
&:hover
- background-color: rgba(255, 255, 255, 0.25)
+ text-decoration: underline
-.glassy
- background: $background
- backdrop-filter: blur(10px)
- border: 2px solid $border
- border-radius: 0.8rem
+h1, h2, h3
+ font-family: 'Bangers', cursive
+ letter-spacing: 1px
+// Background elements
.background-elements
position: fixed
top: 0
left: 0
- width: 100vw
- height: 100vh
+ width: 100%
+ height: 100%
+ z-index: -1
overflow: hidden
- z-index: 2
- pointer-events: none
- .glow-point
- position: absolute
- width: 250px
- height: 250px
- border-radius: 50%
- filter: blur(100px)
- opacity: 0.4
- will-change: transform
- transform: translateZ(0)
+.glow-point
+ position: absolute
+ border-radius: 50%
+ opacity: 0.15
+ filter: blur(100px)
+ transition: transform 0.3s ease
+
+ &.point-1
+ background-color: $primary
+ width: 40vh
+ height: 40vh
+ top: 10vh
+ left: 10vw
- &.point-1
- top: 20%
- left: 10%
- background-color: rgba(255, 102, 196, 0.8)
- animation: float-glow 15s infinite alternate ease-in-out
-
- &.point-2
- top: 70%
- left: 80%
- background-color: rgba(102, 204, 255, 0.8)
- animation: float-glow 18s infinite alternate-reverse ease-in-out
-
- &.point-3
- top: 80%
- left: 20%
- background-color: rgba(133, 255, 189, 0.8)
- animation: float-glow 12s infinite alternate ease-in-out 2s
+ &.point-2
+ background-color: $secondary
+ width: 50vh
+ height: 50vh
+ bottom: 5vh
+ right: 5vw
+
+ &.point-3
+ background-color: $accent
+ width: 30vh
+ height: 30vh
+ top: 40vh
+ right: 30vw
- .music-note
- position: absolute
- font-size: 60pt
+.music-note
+ position: absolute
+ color: rgba(255, 255, 255, 0.1)
+ transition: transform 0.3s ease
+
+// Content container
+.content-container
+ flex: 1
+ display: flex
+ flex-direction: column
+ padding: 1rem
+ position: relative
+ z-index: 1
+ max-width: 1200px
+ margin: 0 auto
+ width: 100%
+ box-sizing: border-box
+
+// Connection status
+.connection-status
+ position: fixed
+ bottom: 1rem
+ left: 1rem
+ background-color: rgba($danger, 0.9)
+ color: white
+ padding: 0.5rem 1rem
+ border-radius: 2rem
+ font-size: 0.9rem
+ animation: pulse 2s infinite
+
+// Error message
+.error-message
+ position: fixed
+ top: 1rem
+ left: 50%
+ transform: translateX(-50%)
+ background-color: rgba($danger, 0.9)
+ color: white
+ padding: 0.5rem 1rem
+ border-radius: 0.5rem
+ z-index: 100
+ animation: fade-in-out 5s forwards
+ max-width: 80%
+ text-align: center
+
+@keyframes pulse
+ 0%
opacity: 0.7
- will-change: transform, opacity, filter
- filter: drop-shadow(0 0 15px rgba(255, 255, 255, 0.7))
- transform: translateZ(0)
- backface-visibility: hidden
+ 50%
+ opacity: 1
+ 100%
+ opacity: 0.7
+
+@keyframes fade-in-out
+ 0%
+ opacity: 0
+ transform: translate(-50%, -20px)
+ 10%
+ opacity: 1
+ transform: translate(-50%, 0)
+ 80%
+ opacity: 1
+ 100%
+ opacity: 0
+
+// HomeScreen styles
+.home-screen
+ display: flex
+ flex-direction: column
+ justify-content: center
+ align-items: center
+ height: 100%
+ padding: 1rem
+
+ .logo
+ text-align: center
+ margin-bottom: 2rem
- @for $i from 1 through 5
- &.note-#{$i}
- animation-name: float-note-#{$i}, pulse-note
- animation-duration: #{10 + ($i * 1)}s, 5s
- animation-timing-function: ease-in-out, ease-in-out
- animation-iteration-count: infinite, infinite
- animation-direction: alternate, alternate
- animation-delay: #{$i * 0.7}s, #{$i * 0.4}s
+ .logo-icon
+ font-size: 4rem
+ color: $primary
+ margin-bottom: 1rem
+ animation: bounce 2s infinite
+
+ h1
+ font-size: 3.5rem
+ margin: 0
+ color: white
+ text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.3)
+
+ .card
+ background-color: rgba($card-bg, 0.8)
+ backdrop-filter: blur(10px)
+ border-radius: 1rem
+ padding: 2rem
+ width: 100%
+ max-width: 400px
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1)
+
+ .tabs
+ display: flex
+ margin-bottom: 1.5rem
+
+ button
+ flex: 1
+ background: transparent
+ border: none
+ padding: 0.75rem
+ color: $text-muted
+ font-weight: 600
+ cursor: pointer
- @if $i % 4 == 0
- color: rgba(255, 102, 196, 0.8)
- @else if $i % 4 == 1
- color: rgba(102, 204, 255, 0.8)
- @else if $i % 4 == 2
- color: rgba(255, 209, 128, 0.8)
- @else
- color: rgba(133, 255, 189, 0.8)
+ &.active
+ color: $primary
+ box-shadow: inset 0 -2px 0 $primary
+
+ .home-footer
+ margin-top: 2rem
+ text-align: center
+ color: $text-muted
-.card-element
- 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 cubic-bezier(0.175, 0.885, 0.32, 1.275)
- animation: card-float 6s infinite ease-in-out alternate
-
- &:hover
- transform: translateY(-10px) scale(1.02)
- box-shadow: 0 15px 50px rgba(0, 0, 0, 0.2), 0 0 40px rgba(255, 255, 255, 0.2)
- border: 1px solid rgba(255, 255, 255, 0.4)
-
-@keyframes shifting-background
- 0%
- background-position: 0% 0%
- 50%
- background-position: 100% 100%
- 100%
- background-position: 0% 0%
-
-@keyframes rotate-background
- 0%
- transform: translate(-50%, -50%) rotate(0deg)
- 100%
- transform: translate(-50%, -50%) rotate(360deg)
-
-@keyframes float-glow
- 0%, 100%
- transform: translate(0, 0) scale(1)
- filter: blur(100px)
- 50%
- transform: translate(30px, -20px) scale(1.2)
- filter: blur(80px)
-
-@keyframes card-float
- 0%, 100%
+@keyframes bounce
+ 0%, 20%, 50%, 80%, 100%
transform: translateY(0)
- 50%
- transform: translateY(-8px)
+ 40%
+ transform: translateY(-20px)
+ 60%
+ transform: translateY(-10px)
-@keyframes float-note-1
- 0%, 100%
- transform: translateY(0) rotate(0deg)
- 50%
- transform: translateY(-40px) rotate(10deg)
+// LobbyScreen styles
+.lobby-screen
+ display: flex
+ flex-direction: column
+ height: 100%
-@keyframes float-note-2
- 0%, 100%
- transform: translateY(0) rotate(0deg)
- 50%
- transform: translateY(-30px) rotate(-8deg)
+ .lobby-header
+ text-align: center
+ margin-bottom: 2rem
+
+ h1
+ margin-bottom: 0.5rem
+
+ .lobby-code
+ display: flex
+ align-items: center
+ justify-content: center
+
+ .code
+ font-weight: bold
+ font-size: 1.5rem
+ color: $primary
+ margin: 0 0.5rem
+
+ .lobby-content
+ display: flex
+ flex-direction: column
+ flex: 1
+
+ @media (min-width: 768px)
+ flex-direction: row
+
+ .players-list
+ flex: 1
+ background-color: rgba($card-bg, 0.8)
+ backdrop-filter: blur(10px)
+ border-radius: 1rem
+ padding: 1.5rem
+ margin-bottom: 1rem
+
+ @media (min-width: 768px)
+ margin-right: 1rem
+ margin-bottom: 0
+
+ h2
+ margin-top: 0
+ display: flex
+ align-items: center
+
+ svg
+ margin-right: 0.5rem
+ color: $primary
+
+ ul
+ list-style: none
+ padding: 0
+ margin: 0
+
+ li
+ padding: 0.75rem
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1)
+
+ &.host
+ color: $primary
+
+ &.disconnected
+ opacity: 0.5
+
+ .lobby-info
+ flex: 1
+ display: flex
+ flex-direction: column
+
+ .settings-preview
+ background-color: rgba($card-bg, 0.8)
+ backdrop-filter: blur(10px)
+ border-radius: 1rem
+ padding: 1.5rem
+ margin-bottom: 1rem
+
+ h3
+ margin-top: 0
+ display: flex
+ align-items: center
+
+ svg
+ margin-right: 0.5rem
+ color: $primary
+
+ .actions
+ display: flex
+ flex-direction: column
+ gap: 1rem
+ margin-bottom: 1rem
+
+ .status-disconnected
+ color: $danger
+ font-size: 0.9rem
+ margin-left: 0.5rem
+
+ .warning
+ color: $warning
+ text-align: center
-@keyframes float-note-3
- 0%, 100%
- transform: translateY(0) rotate(0deg)
- 50%
- transform: translateY(-50px) rotate(15deg)
+// SongSubmissionScreen styles
+.song-submission-screen
+ display: flex
+ flex-direction: column
+ height: 100%
-@keyframes float-note-4
- 0%, 100%
- transform: translateY(0) rotate(0deg)
- 33%
- transform: translateY(-25px) rotate(-5deg)
- 66%
- transform: translateY(-40px) rotate(5deg)
+ .screen-header
+ text-align: center
+ margin-bottom: 2rem
+
+ h1
+ margin-bottom: 0.5rem
+
+ .status
+ color: $text-muted
+
+ .submission-progress
+ margin-bottom: 2rem
+
+ .progress-bar
+ height: 10px
+ background-color: rgba($card-bg, 0.5)
+ border-radius: 5px
+ margin-bottom: 0.5rem
+
+ .progress-fill
+ height: 100%
+ background-color: $primary
+ border-radius: 5px
+ transition: width 0.3s ease
+
+ .songs-list
+ display: grid
+ grid-template-columns: repeat(auto-fill, minmax(250px, 1fr))
+ gap: 1rem
+ margin-bottom: 2rem
+
+ .song-card
+ background-color: rgba($card-bg, 0.8)
+ backdrop-filter: blur(10px)
+ border-radius: 0.75rem
+ overflow: hidden
+ display: flex
+ flex-direction: column
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1)
+
+ .song-thumbnail
+ height: 140px
+ overflow: hidden
+
+ img
+ width: 100%
+ height: 100%
+ object-fit: cover
+
+ .thumbnail-placeholder
+ width: 100%
+ height: 100%
+ display: flex
+ justify-content: center
+ align-items: center
+ background-color: rgba(0, 0, 0, 0.2)
+
+ svg
+ font-size: 3rem
+ opacity: 0.5
+
+ .song-info
+ padding: 1rem
+
+ h3
+ margin: 0
+ margin-bottom: 0.25rem
+
+ p
+ margin: 0
+ color: $text-muted
+
+ .add-song-btn
+ background-color: rgba($card-bg, 0.5)
+ backdrop-filter: blur(5px)
+ border: 2px dashed rgba(255, 255, 255, 0.2)
+ border-radius: 0.75rem
+ height: 100%
+ min-height: 200px
+ display: flex
+ flex-direction: column
+ justify-content: center
+ align-items: center
+ cursor: pointer
+ transition: all 0.2s ease
+
+ &:hover
+ background-color: rgba($card-bg, 0.8)
+ border-color: rgba($primary, 0.5)
+
+ svg
+ font-size: 2rem
+ margin-bottom: 0.5rem
+ color: $primary
+
+ .song-form
+ background-color: rgba($card-bg, 0.9)
+ backdrop-filter: blur(10px)
+ border-radius: 1rem
+ padding: 2rem
+ max-width: 500px
+ width: 100%
+ margin: 0 auto 2rem
+
+ h3
+ margin-top: 0
+ text-align: center
+
+ .action-buttons
+ display: flex
+ justify-content: center
+ margin-top: 1rem
+
+ .waiting-message
+ background-color: rgba($card-bg, 0.8)
+ backdrop-filter: blur(10px)
+ border-radius: 1rem
+ padding: 2rem
+ max-width: 500px
+ width: 100%
+ margin: 0 auto
+ text-align: center
+
+ h3
+ margin-top: 0
+
+ .player-status
+ margin-top: 2rem
+
+ h4
+ margin-bottom: 1rem
+
+ .players-ready-list
+ list-style: none
+ padding: 0
+
+ li
+ display: flex
+ justify-content: space-between
+ padding: 0.75rem
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1)
+
+ &.ready
+ color: $success
+
+ .ready-icon
+ color: $success
+
+ &.not-ready
+ color: $text-muted
+
+ .songs-count
+ color: $warning
-@keyframes float-note-5
- 0%, 100%
- transform: translateY(0) rotate(0deg)
- 50%
- transform: translateY(-35px) rotate(-12deg)
+// VotingScreen styles
+.voting-screen
+ display: flex
+ flex-direction: column
+ height: 100%
-@keyframes pulse-note
+ .screen-header
+ text-align: center
+ margin-bottom: 2rem
+
+ h1
+ margin-bottom: 0.5rem
+
+ .battle-info
+ display: flex
+ align-items: center
+ justify-content: center
+
+ .battle-count
+ margin-right: 0.5rem
+
+ svg
+ color: $primary
+
+ .battle-container
+ display: flex
+ flex-direction: column
+ gap: 2rem
+ margin-bottom: 2rem
+
+ @media (min-width: 768px)
+ flex-direction: row
+
+ .song-battle-card
+ flex: 1
+ background-color: rgba($card-bg, 0.8)
+ backdrop-filter: blur(10px)
+ border-radius: 1rem
+ padding: 1.5rem
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1)
+ transition: all 0.2s ease
+ position: relative
+
+ &.selected
+ box-shadow: 0 0 0 2px $primary, 0 8px 32px rgba(0, 0, 0, 0.1)
+
+ .song-title
+ margin-top: 0
+ text-align: center
+ font-size: 1.8rem
+
+ .song-artist
+ text-align: center
+ color: $text-muted
+ margin-bottom: 1.5rem
+
+ .song-video
+ height: 0
+ padding-bottom: 56.25% // 16:9 aspect ratio
+ position: relative
+ margin-bottom: 1.5rem
+ border-radius: 0.5rem
+ overflow: hidden
+
+ .youtube-embed, .video-placeholder
+ position: absolute
+ top: 0
+ left: 0
+ width: 100%
+ height: 100%
+
+ iframe
+ width: 100%
+ height: 100%
+
+ .video-placeholder
+ display: flex
+ flex-direction: column
+ justify-content: center
+ align-items: center
+ background-color: rgba(0, 0, 0, 0.2)
+
+ svg
+ font-size: 3rem
+ opacity: 0.5
+ margin-bottom: 0.5rem
+
+ .vote-btn
+ width: 100%
+
+ .voted-badge
+ position: absolute
+ top: 1rem
+ right: 1rem
+ background-color: $primary
+ color: white
+ padding: 0.25rem 0.75rem
+ border-radius: 2rem
+ font-weight: bold
+ font-size: 0.9rem
+
+ .vs-container
+ display: flex
+ justify-content: center
+ align-items: center
+
+ @media (min-width: 768px)
+ flex-direction: column
+
+ .vs-badge
+ background-color: rgba($card-bg, 0.9)
+ backdrop-filter: blur(10px)
+ border-radius: 50%
+ width: 60px
+ height: 60px
+ display: flex
+ align-items: center
+ justify-content: center
+ font-weight: bold
+ font-size: 1.5rem
+ color: $primary
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15)
+
+ .voting-status
+ text-align: center
+ margin-top: auto
+ padding-top: 1rem
+
+ p
+ margin-bottom: 0.5rem
+
+ .votes-count
+ color: $text-muted
+ font-size: 0.9rem
+
+ span
+ color: $primary
+
+ .loading-battle
+ background-color: rgba($card-bg, 0.8)
+ backdrop-filter: blur(10px)
+ border-radius: 1rem
+ padding: 3rem 2rem
+ text-align: center
+ max-width: 400px
+ margin: auto
+
+ h2
+ margin-top: 0
+ margin-bottom: 2rem
+
+// ResultsScreen styles
+.results-screen
+ display: flex
+ flex-direction: column
+ height: 100%
+ position: relative
+ overflow: hidden
+
+ .confetti-container
+ position: absolute
+ top: 0
+ left: 0
+ width: 100%
+ height: 100%
+ pointer-events: none
+
+ .confetti
+ position: absolute
+ width: 10px
+ height: 10px
+ background-color: $primary
+ top: -10px
+ animation: fall 5s linear infinite
+
+ .results-header
+ margin-bottom: 2rem
+
+ h1
+ margin-bottom: 0.5rem
+
+ .trophy-icon
+ font-size: 3rem
+ color: gold
+ filter: drop-shadow(0 0 10px rgba(255, 215, 0, 0.5))
+ animation: shine 3s infinite
+
+ .winner-container
+ text-align: center
+ margin-bottom: 3rem
+
+ h2
+ margin-bottom: 2rem
+ font-size: 2rem
+
+ .winner-card
+ background-color: rgba($card-bg, 0.8)
+ backdrop-filter: blur(10px)
+ border-radius: 1rem
+ padding: 2rem
+ max-width: 700px
+ margin: 0 auto
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15)
+
+ .winner-title
+ font-size: 2.5rem
+ margin-top: 0
+ margin-bottom: 0.5rem
+ color: $primary
+
+ .winner-artist
+ color: $text-muted
+ font-size: 1.2rem
+ margin-bottom: 2rem
+
+ .winner-video
+ height: 0
+ padding-bottom: 56.25% // 16:9 aspect ratio
+ position: relative
+ margin-bottom: 2rem
+ border-radius: 0.5rem
+ overflow: hidden
+
+ .youtube-embed, .video-placeholder
+ position: absolute
+ top: 0
+ left: 0
+ width: 100%
+ height: 100%
+
+ .video-placeholder
+ display: flex
+ justify-content: center
+ align-items: center
+ background-color: rgba(0, 0, 0, 0.2)
+
+ .winner-submitted-by
+ color: $text-muted
+
+ strong
+ color: $text
+
+ .results-actions
+ display: flex
+ gap: 1rem
+ justify-content: center
+ margin-bottom: 2rem
+ flex-wrap: wrap
+
+ .battle-history
+ background-color: rgba($card-bg, 0.8)
+ backdrop-filter: blur(10px)
+ border-radius: 1rem
+ padding: 1.5rem
+ max-width: 800px
+ margin: 0 auto 2rem
+
+ h3
+ margin-top: 0
+ text-align: center
+ margin-bottom: 1.5rem
+
+ .battles-list
+ display: flex
+ flex-direction: column
+ gap: 1rem
+
+ .battle-result
+ padding: 1rem
+ background-color: rgba($card-bg, 0.7)
+ border-radius: 0.5rem
+
+ &.final-round
+ border: 2px solid $primary
+
+ .battle-round
+ text-align: center
+ font-weight: bold
+ margin-bottom: 0.5rem
+ color: $primary
+
+ .battle-songs
+ display: flex
+ align-items: center
+ justify-content: space-between
+ gap: 1rem
+
+ &.left-won .battle-song:first-child
+ color: $success
+ font-weight: bold
+
+ &.right-won .battle-song:last-child
+ color: $success
+ font-weight: bold
+
+ .battle-song
+ flex: 1
+ text-align: center
+
+ &.winner
+ color: $success
+
+ &.loser
+ color: $text-muted
+
+ p
+ margin-bottom: 0.25rem
+ white-space: nowrap
+ overflow: hidden
+ text-overflow: ellipsis
+
+ .votes
+ font-size: 0.9rem
+
+ .vs
+ padding: 0.5rem
+ font-size: 0.8rem
+
+// Modal styles
+.modal-overlay
+ position: fixed
+ top: 0
+ left: 0
+ width: 100%
+ height: 100%
+ background-color: rgba(0, 0, 0, 0.7)
+ display: flex
+ align-items: center
+ justify-content: center
+ z-index: 1000
+
+ .modal
+ background-color: $card-bg
+ border-radius: 1rem
+ padding: 2rem
+ width: 90%
+ max-width: 500px
+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15)
+
+ h2
+ margin-top: 0
+ margin-bottom: 1.5rem
+ text-align: center
+
+ .modal-actions
+ display: flex
+ justify-content: flex-end
+ gap: 1rem
+ margin-top: 2rem
+
+// Loader animation
+.loader
+ width: 50px
+ height: 50px
+ border-radius: 50%
+ border: 5px solid rgba(255, 255, 255, 0.1)
+ border-top-color: $primary
+ animation: spin 1s infinite linear
+ margin: 0 auto
+
+@keyframes spin
+ 0%
+ transform: rotate(0deg)
+ 100%
+ transform: rotate(360deg)
+
+@keyframes fall
+ 0%
+ transform: translateY(-10px) rotate(0deg)
+ 100%
+ transform: translateY(calc(100vh + 10px)) rotate(360deg)
+
+@keyframes shine
0%, 100%
- filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.5))
- opacity: 0.6
+ filter: drop-shadow(0 0 5px rgba(255, 215, 0, 0.5))
50%
- filter: drop-shadow(0 0 20px rgba(255, 255, 255, 0.7))
- opacity: 0.9
\ No newline at end of file
+ filter: drop-shadow(0 0 20px rgba(255, 215, 0, 0.8))
diff --git a/client/src/components/HomeScreen.jsx b/client/src/components/HomeScreen.jsx
new file mode 100644
index 0000000..f40bcbd
--- /dev/null
+++ b/client/src/components/HomeScreen.jsx
@@ -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 (
+
+
+

+
Song Battle
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+// Make sure default export is explicit
+export default HomeScreen;
diff --git a/client/src/components/LobbyScreen.jsx b/client/src/components/LobbyScreen.jsx
new file mode 100644
index 0000000..ceef042
--- /dev/null
+++ b/client/src/components/LobbyScreen.jsx
@@ -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 (
+
+
+ Game Lobby
+
+
Game Code: {lobby.id}
+
+
+
+
+
+
+
Players ({lobby.players.length})
+
+ {lobby.players.map(player => (
+ -
+ {player.name} {player.id === lobby.hostId && '(Host)'} {player.id === currentPlayer.id && '(You)'}
+ {!player.isConnected && (Disconnected)}
+
+ ))}
+
+
+
+
+
+
Game Settings
+
Songs per player: {settings.songsPerPlayer}
+
Max players: {settings.maxPlayers}
+
YouTube links required: {settings.requireYoutubeLinks ? 'Yes' : 'No'}
+
+ {isHost && (
+
+ )}
+
+
+
+ {isHost && (
+
+ )}
+
+
+
+ {lobby.players.length < lobby.settings.minPlayers && isHost && (
+
Need at least {lobby.settings.minPlayers} players to start
+ )}
+
+
+
+ {showSettings && (
+
+
+
Game Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+// Ensure explicit default export
+export default LobbyScreen;
diff --git a/client/src/components/ResultsScreen.jsx b/client/src/components/ResultsScreen.jsx
new file mode 100644
index 0000000..69ce4b6
--- /dev/null
+++ b/client/src/components/ResultsScreen.jsx
@@ -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 (
+
+
Calculating results...
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
{winner.title}
+
by {winner.artist}
+
Submitted by: {winner.submittedByName}
+
+
+ {winnerVideoId ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+
+
+
+
+
+
+ {showBattleHistory && (
+
+
Battle History
+
+
+ {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 (
+
+
+
Round {battle.round + 1}, Battle {index + 1}
+
+
+
+
+
+
{battle.song1.title}
+
{battle.song1.artist}
+
{battle.song1Votes} votes
+
+
+
+
VS
+
+
+
+
{battle.song2.title}
+
{battle.song2.artist}
+
{battle.song2Votes} votes
+
+
+
+
+ );
+ })}
+
+
+ )}
+
+ );
+};
+
+export default ResultsScreen;
diff --git a/client/src/components/SongSubmissionScreen.jsx b/client/src/components/SongSubmissionScreen.jsx
new file mode 100644
index 0000000..200ba1e
--- /dev/null
+++ b/client/src/components/SongSubmissionScreen.jsx
@@ -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 (
+
+
+
+
+
+
{songs.length} / {lobby?.settings?.songsPerPlayer || 0} songs
+
+
+
+ {songs.map((song, index) => (
+
+
+ {getYoutubeThumbnail(song.youtubeLink) ? (
+
})
+ ) : (
+
+
+
+ )}
+
+
+
{song.title}
+
{song.artist}
+
+ {!isReady && (
+
+ )}
+
+ ))}
+
+ {canSubmitMoreSongs && !isFormVisible && !isReady && (
+
+ )}
+
+
+ {isFormVisible && (
+
+ )}
+
+
+ {!isReady && songs.length === lobby?.settings?.songsPerPlayer && (
+
+ )}
+
+
+ {isReady && (
+
+
Ready to Battle!
+
Waiting for other players to submit their songs...
+
+
+
Players Ready
+
+ {lobby && lobby.players.map(player => (
+ -
+ {player.name} {player.id === currentPlayer.id && '(You)'}
+ {player.isReady ? (
+
+ ) : (
+ {player.songCount}/{lobby?.settings?.songsPerPlayer}
+ )}
+
+ ))}
+
+
+
+ )}
+
+ );
+};
+
+export default SongSubmissionScreen;
diff --git a/client/src/components/VotingScreen.jsx b/client/src/components/VotingScreen.jsx
new file mode 100644
index 0000000..81c56a2
--- /dev/null
+++ b/client/src/components/VotingScreen.jsx
@@ -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 (
+
+
Preparing the next battle...
+
+ );
+ }
+
+ const song1Id = getYouTubeId(battle.song1?.youtubeLink || '');
+ const song2Id = getYouTubeId(battle.song2?.youtubeLink || '');
+
+ return (
+
+
+
+
+
handleVoteSelect(battle.song1.id)}
+ >
+
+
+
{battle.song1.title}
+
{battle.song1.artist}
+
+ Submitted by: {battle.song1.submittedByName}
+
+
+
+ {song1Id ? (
+
+ ) : (
+
+
+ No video available
+
+ )}
+
+ {hasVoted && (
+
+ {battle.song1Votes}
+ votes
+
+ )}
+
+ {selectedSong === battle.song1.id && !hasVoted && (
+
+
+
+ )}
+
+
+
+
+
handleVoteSelect(battle.song2.id)}
+ >
+
+
+
{battle.song2.title}
+
{battle.song2.artist}
+
+ Submitted by: {battle.song2.submittedByName}
+
+
+
+ {song2Id ? (
+
+ ) : (
+
+
+ No video available
+
+ )}
+
+ {hasVoted && (
+
+ {battle.song2Votes}
+ votes
+
+ )}
+
+ {selectedSong === battle.song2.id && !hasVoted && (
+
+
+
+ )}
+
+
+
+ {!hasVoted && (
+
+
+
+ )}
+
+
+
{hasVoted ? 'Waiting for other players to vote...' : 'Choose your favorite!'}
+
+ {battle.voteCount || 0} of {lobby?.players?.filter(p => p.isConnected).length || 0} votes
+
+
+
+ );
+}
+
+export default VotingScreen;
diff --git a/client/src/components/YouTubeEmbed.jsx b/client/src/components/YouTubeEmbed.jsx
new file mode 100644
index 0000000..8332628
--- /dev/null
+++ b/client/src/components/YouTubeEmbed.jsx
@@ -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 (
+
+ {/* YouTube iframe will be inserted here */}
+
+ );
+};
+
+export default YouTubeEmbed;
diff --git a/client/src/context/GameContext.jsx b/client/src/context/GameContext.jsx
new file mode 100644
index 0000000..038d93d
--- /dev/null
+++ b/client/src/context/GameContext.jsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+/**
+ * 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 (
+
+ {children}
+
+ );
+}
+
+// Custom hooks for using contexts
+export const useSocket = () => useContext(SocketContext);
+export const useGame = () => useContext(GameContext);
+
+// Re-export for better module compatibility
+export { SocketContext, GameContext };
diff --git a/client/src/main.jsx b/client/src/main.jsx
index 77ae9e5..e1fde98 100644
--- a/client/src/main.jsx
+++ b/client/src/main.jsx
@@ -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(
-
-
-
+
+
+
+
+
,
);
\ No newline at end of file
diff --git a/package.json b/package.json
index 04bb4be..ab13e34 100644
--- a/package.json
+++ b/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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 6d7fec9..d8003df 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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:
diff --git a/server/game.js b/server/game.js
new file mode 100644
index 0000000..425eed0
--- /dev/null
+++ b/server/game.js
@@ -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} 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