From 22eca7d4e0efc611df9db008ad167561c4fc414d Mon Sep 17 00:00:00 2001 From: Mathias Wagner Date: Thu, 24 Apr 2025 16:21:43 +0200 Subject: [PATCH] Add screens and components for Song Battle game, including Home, Lobby, Voting, Results, and Song Submission screens; implement YouTube video embedding and styles --- client/index.html | 2 +- client/public/logo.png | Bin 0 -> 1165 bytes client/public/logo.svg | 24 - client/src/App.jsx | 50 +- client/src/common/styles/buttons.sass | 116 ++ client/src/common/styles/colors.sass | 31 +- .../common/styles/components/home-screen.sass | 89 ++ .../styles/components/lobby-screen.sass | 0 .../styles/components/results-screen.sass | 193 ++++ .../components/song-submission-screen.sass | 257 +++++ .../styles/components/voting-screen.sass | 199 ++++ .../styles/components/youtube-embed.sass | 11 + .../styles/components/youtube-search.sass | 181 +++ client/src/common/styles/forms.sass | 239 ++-- client/src/common/styles/main.sass | 1015 +++++++++++++---- client/src/components/HomeScreen.jsx | 95 ++ client/src/components/LobbyScreen.jsx | 172 +++ client/src/components/ResultsScreen.jsx | 160 +++ .../src/components/SongSubmissionScreen.jsx | 476 ++++++++ client/src/components/VotingScreen.jsx | 182 +++ client/src/components/YouTubeEmbed.jsx | 46 + client/src/context/GameContext.jsx | 471 ++++++++ client/src/main.jsx | 15 +- package.json | 17 +- pnpm-lock.yaml | 100 +- server/game.js | 853 ++++++++++++++ server/index.js | 325 +++++- server/youtube-api.js | 129 +++ 28 files changed, 5046 insertions(+), 402 deletions(-) create mode 100644 client/public/logo.png delete mode 100644 client/public/logo.svg create mode 100644 client/src/common/styles/buttons.sass create mode 100644 client/src/common/styles/components/home-screen.sass create mode 100644 client/src/common/styles/components/lobby-screen.sass create mode 100644 client/src/common/styles/components/results-screen.sass create mode 100644 client/src/common/styles/components/song-submission-screen.sass create mode 100644 client/src/common/styles/components/voting-screen.sass create mode 100644 client/src/common/styles/components/youtube-embed.sass create mode 100644 client/src/common/styles/components/youtube-search.sass create mode 100644 client/src/components/HomeScreen.jsx create mode 100644 client/src/components/LobbyScreen.jsx create mode 100644 client/src/components/ResultsScreen.jsx create mode 100644 client/src/components/SongSubmissionScreen.jsx create mode 100644 client/src/components/VotingScreen.jsx create mode 100644 client/src/components/YouTubeEmbed.jsx create mode 100644 client/src/context/GameContext.jsx create mode 100644 server/game.js create mode 100644 server/youtube-api.js 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 0000000000000000000000000000000000000000..aecb39ff8056a7056bbb024ee6b631f0499ef961 GIT binary patch literal 1165 zcmV;81akX{P)Px(ElET{RCt{2na_(HMHI(BuV*hFHVu9CU)VY7B_yZ>H;h8!q7ZWM;z5Z95f929 zGzWhK6e0lwIjAQwh$gOUOjuTvLqI%u>b?3e4D?=daJ!#}?yj!xp3L-Y^^Oy)FEle< zJ^kkW_||)`s=&j;!^6YF!^6YF!^7kM2RZ6L*c^PP0Rf_d5Fiwv_Pti)0fBk_t8@@2hG}o`@L7+~Ga`;)Iia zNACd1jLA9SN@MNs`7=SQ$QFk0?d{d%mE$6U5|Bz-i8urn;D8h*po*{*MplNmol+S- z91d&p;y7k)Z4H1CpYoynxa1BXEf|m+_vgx!Gjf1HRyX`oa5MkRHW&_vs4A+8h>(&x z>9=48L_nPNw8#qkde8FGtn<|M8;#m zfGP>j$$(aRE2}`6Bhsax0+IrOsrVfoU<_~e0d`sZfy+Et`YAw)vVvhi$E^LRKESRH zN;0KVsQ^TXv&f>cRRIwry9JjYtIy!ON=st*UoOUqvd#VHBG zgaun>EtpdNanf&reE{TT(7g{(%K@$o2xkAp4J8Bet6LRlBtE+g=vNOnInPDTbCHa| zxeOk?0}2L|PUymba_(Ct(5#-ybwEDYP2MytxD(9pXFvcgSum{+PzH}dBh*|Abc|TZ zf;f&RjzhtKr7wfED$uzTEVHIg>I1&v>s&TCH$n@_0ag;6Z_Y~ddS&%pOWu+(;7oX- zK4DebWpECKUtWDDPkdG(NI9sL!KKInw&A1XBv5U1-VPt21ZKalODDLo`nDo@z8LL4 z4OF%8_OU-|jnIG2fYSKW$+Jb8tMB){ken#Sx^0M@J@%Gn95C8H4$yMfX#cpy2lrk5 zUKp^Roa(UAS#60;gG$CIIa%v|UgsPD*SSI8M;T5ID5KwX+Z%MM{*}C(;+xSMjK|~J zR(KRed_2DHqIM0=3^uCq{hY zvuW}M0KCad#^5ixl}i+9jk%&n2XrxfHUlO_chXqa6AOAsg4GFM({M2pVIBtL`-fNA zMns6?xYibn2v^y*C;H+eBFzaD^SNuv1$D+zWs1L-l^T3cD44COM0J)^97?HCk - - - - - - - - - - - - - - - - - - - - - - - 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 && ( +
+

{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 Logo +

Song Battle

+
+ +
+
+ + +
+ +
+
+ + setPlayerName(e.target.value)} + placeholder="Enter your name" + maxLength={20} + required + /> +
+ + {!isCreateMode && ( +
+ + setLobbyId(e.target.value.toUpperCase())} + placeholder="Enter 6-letter code" + maxLength={6} + required + /> +
+ )} + + +
+
+ +
+

Let your favorite songs battle it out!

+
+
+ ); +}; + +// 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!

+
+ +
+
+

{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 ( +
+
+

Submit Your Songs

+

+ {isReady + ? 'Waiting for other players...' + : `Submit ${lobby?.settings?.songsPerPlayer || 0} songs to battle`} +

+
+ +
+
+
+
+

{songs.length} / {lobby?.settings?.songsPerPlayer || 0} songs

+
+ +
+ {songs.map((song, index) => ( +
+
+ {getYoutubeThumbnail(song.youtubeLink) ? ( + {song.title} + ) : ( +
+ +
+ )} +
+
+

{song.title}

+

{song.artist}

+
+ {!isReady && ( + + )} +
+ ))} + + {canSubmitMoreSongs && !isFormVisible && !isReady && ( + + )} +
+ + {isFormVisible && ( +
+

Add a Song

+ +
+ +
+ + {isSearching && ( + + )} + {searchQuery && !isSearching && ( + { + setSearchQuery(''); + setSearchResults([]); + setSelectedVideo(null); + setSongForm({ youtubeLink: '' }); + }} + /> + )} +
+ + {/* Show selected video without embedded player */} + {selectedVideo && ( +
+
+

Selected Song

+ + View on YouTube + +
+ +
+
+ {selectedVideo.title} +
+ +
+
+
+

{selectedVideo.title || 'Unknown Title'}

+

{selectedVideo.artist || 'Unknown Artist'}

+
+
+
+ )} + + {searchResults.length > 0 && ( +
+

Search Results

+ {searchResults.map(result => ( +
handleSelectSearchResult(result)} + > +
+ {result.thumbnail ? ( + {result.title} + ) : ( +
+ +
+ )} +
+ +
+
+
+

{result.title || 'Unknown Title'}

+

{result.artist || 'Unknown Artist'}

+
+
+ ))} +
+ )} +
+ +
+ + +
+
+ )} + +
+ {!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 ( +
+
+

+ Song Battle! +

+
+ Round {battle.round + 1} + {hasVoted && You voted!} +
+
+ +
+
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 && ( +
+ +
+ )} +
+ +
+ VS +
+
+ +
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} Updated lobby data + * @private + */ + async _addSongToPlayer(playerIndex, lobby, lobbyId, song, playerId) { + // Generate song ID + const songId = uuidv4(); + + // Prepare song data + let title = song.title; + let artist = song.artist; + let thumbnail = song.thumbnail || null; // Use client-provided thumbnail if available + + // If YouTube link is provided but title/artist are empty, try to fetch metadata + if (song.youtubeLink && (!title || !artist || title === 'Unknown' || artist === 'Unknown' || !thumbnail)) { + try { + // Extract video ID from YouTube link + const videoId = youtubeAPI.extractVideoId(song.youtubeLink); + + if (videoId) { + console.log(`Getting metadata for YouTube video: ${videoId}`); + // Fetch metadata from YouTube API + const metadata = await youtubeAPI.getVideoMetadata(videoId); + + // Only update if we don't have values or they're generic + if (!title || title === 'Unknown') title = metadata.title || 'Unknown'; + if (!artist || artist === 'Unknown') artist = metadata.artist || 'Unknown'; + if (!thumbnail) thumbnail = metadata.thumbnail; + + console.log(`Fetched metadata: "${title}" by ${artist}`); + } + } catch (error) { + console.error('Error fetching YouTube metadata:', error.message); + // Continue with user-provided data if API fails + } + } + + // Add song to player and global song list + const newSong = { + id: songId, + title: title, + artist: artist, + youtubeLink: song.youtubeLink || '', + thumbnail: thumbnail, + submittedById: playerId, + submittedByName: lobby.players[playerIndex].name + }; + + // Add song to both player's list and global list + console.log(`[DEBUG] Adding song "${title}" to player at index ${playerIndex} in lobby ${lobbyId}`); + lobby.players[playerIndex].songs.push(newSong); + lobby.players[playerIndex].songCount = lobby.players[playerIndex].songs.length; + lobby.songs.push(newSong); + + console.log(`[DEBUG] Updated player song count: ${lobby.players[playerIndex].songCount}`); + console.log(`[DEBUG] Total songs in lobby: ${lobby.songs.length}`); + + // Make sure we're returning a properly formed result with both lobby and lobbyId + const result = { lobby, lobbyId }; + + // Validate that the result contains what we expect before returning + if (!result.lobby || !result.lobbyId) { + console.log(`[DEBUG] CRITICAL ERROR: Result is missing lobby or lobbyId after adding song`); + console.log(`[DEBUG] Result keys: ${Object.keys(result)}`); + } else { + console.log(`[DEBUG] Song successfully added, returning result with valid lobby and lobbyId`); + } + + return result; + } + + /** + * Remove a song for a player + * @param {string} playerId - ID of the player + * @param {string} songId - ID of the song to remove + * @returns {Object} Updated lobby data or error + */ + removeSong(playerId, songId) { + const lobbyId = this.playerToLobby.get(playerId); + if (!lobbyId) { + return { error: 'Player not in a lobby' }; + } + + const lobby = this.lobbies.get(lobbyId); + + // Check if in correct state + if (lobby.state !== 'SONG_SUBMISSION') { + return { error: 'Cannot remove songs at this time' }; + } + + // Get player + const playerIndex = lobby.players.findIndex(p => p.id === playerId); + if (playerIndex === -1) { + return { error: 'Player not found in lobby' }; + } + + // Find song in player's songs + const songIndex = lobby.players[playerIndex].songs.findIndex(s => s.id === songId); + if (songIndex === -1) { + return { error: 'Song not found' }; + } + + // Remove song from player's list + lobby.players[playerIndex].songs.splice(songIndex, 1); + lobby.players[playerIndex].songCount = lobby.players[playerIndex].songs.length; + + // Remove from global song list + const globalSongIndex = lobby.songs.findIndex(s => s.id === songId); + if (globalSongIndex !== -1) { + lobby.songs.splice(globalSongIndex, 1); + } + + // If player was ready, set to not ready + if (lobby.players[playerIndex].isReady) { + lobby.players[playerIndex].isReady = false; + } + + return { lobby, lobbyId }; + } + + /** + * Set player ready status in song submission phase + * @param {string} playerId - ID of the player + * @returns {Object} Updated lobby data or error + */ + setPlayerReady(playerId) { + const lobbyId = this.playerToLobby.get(playerId); + if (!lobbyId) { + return { error: 'Player not in a lobby' }; + } + + const lobby = this.lobbies.get(lobbyId); + + // Check if in correct state + if (lobby.state !== 'SONG_SUBMISSION') { + return { error: 'Cannot set ready status at this time' }; + } + + // Get player + const playerIndex = lobby.players.findIndex(p => p.id === playerId); + if (playerIndex === -1) { + return { error: 'Player not found in lobby' }; + } + + // Check if player has submitted enough songs + if (lobby.players[playerIndex].songs.length < lobby.settings.songsPerPlayer) { + return { error: 'Must submit all songs before ready' }; + } + + // Set player as ready + lobby.players[playerIndex].isReady = true; + + // Check if all players are ready + const allReady = lobby.players.every(p => p.isReady || !p.isConnected); + if (allReady) { + console.log('All players ready, starting tournament...'); + // Start the tournament by creating brackets + this._startTournament(lobby); + + // Return an indicator that the tournament has started + return { + lobby, + lobbyId, + tournamentStarted: true + }; + } + + return { lobby, lobbyId }; + } + + /** + * Submit a vote for a song in a battle + * @param {string} playerId - ID of the voting player + * @param {string} songId - ID of the voted song + * @returns {Object} Updated lobby data or error + */ + submitVote(playerId, songId) { + const lobbyId = this.playerToLobby.get(playerId); + if (!lobbyId) { + return { error: 'Player not in a lobby' }; + } + + const lobby = this.lobbies.get(lobbyId); + + // Check if in correct state + if (lobby.state !== 'VOTING') { + return { error: 'Cannot vote at this time' }; + } + + // Check if there's an active battle + if (!lobby.currentBattle) { + return { error: 'No active battle' }; + } + + // Check if player has already voted in this battle + if (lobby.currentBattle.votes.has(playerId)) { + return { error: 'Already voted in this battle' }; + } + + // Check if the voted song is part of the current battle + if (songId !== lobby.currentBattle.song1.id && songId !== lobby.currentBattle.song2.id) { + return { error: 'Invalid song ID' }; + } + + // Record the vote + lobby.currentBattle.votes.set(playerId, songId); + + // Update vote counts + if (songId === lobby.currentBattle.song1.id) { + lobby.currentBattle.song1Votes++; + } else { + lobby.currentBattle.song2Votes++; + } + + // Check if all connected players have voted + const connectedPlayers = lobby.players.filter(p => p.isConnected).length; + const voteCount = lobby.currentBattle.votes.size; + + if (voteCount >= connectedPlayers) { + // Determine winner + const winnerSongId = lobby.currentBattle.song1Votes > lobby.currentBattle.song2Votes + ? lobby.currentBattle.song1.id + : lobby.currentBattle.song2.id; + + lobby.currentBattle.winner = winnerSongId; + + // Save battle to history + lobby.battles.push({ + round: lobby.currentBattle.round, + song1: lobby.currentBattle.song1, + song2: lobby.currentBattle.song2, + song1Votes: lobby.currentBattle.song1Votes, + song2Votes: lobby.currentBattle.song2Votes, + winner: winnerSongId + }); + + // Move to next battle or finish tournament + this._moveToNextBattle(lobby); + } + + return { lobby, lobbyId }; + } + + /** + * Handle player leaving a lobby + * @param {string} playerId - ID of the player + * @returns {Object} Updated lobby data or null if lobby is removed + */ + leaveLobby(playerId) { + const lobbyId = this.playerToLobby.get(playerId); + if (!lobbyId) { + return null; // Player not in a lobby + } + + const lobby = this.lobbies.get(lobbyId); + + // Remove player from lobby + const playerIndex = lobby.players.findIndex(p => p.id === playerId); + if (playerIndex !== -1) { + // If game hasn't started, remove player completely + if (lobby.state === 'LOBBY') { + lobby.players.splice(playerIndex, 1); + } else { + // Otherwise just mark as disconnected + lobby.players[playerIndex].isConnected = false; + } + } + + // Remove from map + this.playerToLobby.delete(playerId); + + // If it's the host leaving and game hasn't started, assign new host or delete lobby + if (lobby.hostId === playerId && lobby.state === 'LOBBY') { + if (lobby.players.length > 0) { + lobby.hostId = lobby.players[0].id; + } else { + this.lobbies.delete(lobbyId); + return null; + } + } + + // If all players have left, remove the lobby + const connectedPlayers = lobby.players.filter(p => p.isConnected).length; + if (connectedPlayers === 0) { + this.lobbies.delete(lobbyId); + return null; + } + + // For voting state, check if we need to progress + if (lobby.state === 'VOTING' && lobby.currentBattle) { + // Add null check for votes property + const totalVotes = lobby.currentBattle.votes?.size || 0; + const remainingPlayers = lobby.players.filter(p => p.isConnected).length; + + if (totalVotes >= remainingPlayers && totalVotes > 0) { + // Determine winner + const winnerSongId = lobby.currentBattle.song1Votes > lobby.currentBattle.song2Votes + ? lobby.currentBattle.song1.id + : lobby.currentBattle.song2.id; + + lobby.currentBattle.winner = winnerSongId; + + // Save battle to history + lobby.battles.push({ + round: lobby.currentBattle.round, + song1: lobby.currentBattle.song1, + song2: lobby.currentBattle.song2, + song1Votes: lobby.currentBattle.song1Votes, + song2Votes: lobby.currentBattle.song2Votes, + winner: winnerSongId + }); + + // Move to next battle or finish tournament + this._moveToNextBattle(lobby); + } + } + + return { lobby, lobbyId }; + } + + /** + * Handle player disconnection + * @param {string} playerId - ID of the disconnected player + * @returns {Object|null} Updated lobby data or null if no lobby found + */ + handleDisconnect(playerId) { + return this.leaveLobby(playerId); + } + + /** + * Generate a unique lobby code + * @returns {string} 6-character lobby code + * @private + */ + _generateLobbyCode() { + const characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed ambiguous characters + let result = ''; + + // Generate code until unique + do { + result = ''; + for (let i = 0; i < 6; i++) { + result += characters.charAt(Math.floor(Math.random() * characters.length)); + } + } while (this.lobbies.has(result)); + + return result; + } + + /** + * Start the tournament by creating brackets and first battle + * @param {Object} lobby - Lobby object + * @private + */ + _startTournament(lobby) { + // Collect all submitted songs + const songs = [...lobby.songs]; + + // Shuffle songs + this._shuffleArray(songs); + + // Create tournament brackets + const brackets = []; + let round = 0; + + // Handle odd number of songs by giving a bye to one song + if (songs.length % 2 !== 0 && songs.length > 1) { + const byeSong = songs.pop(); + brackets.push({ + round, + song1: byeSong, + song2: null, // Bye + bye: true, + winner: byeSong.id, + song1Votes: 0, + song2Votes: 0 + }); + } + + // Create initial bracket pairs + for (let i = 0; i < songs.length; i += 2) { + if (i + 1 < songs.length) { + brackets.push({ + round, + song1: songs[i], + song2: songs[i + 1], + song1Votes: 0, + song2Votes: 0, + winner: null, + votes: new Map() + }); + } + } + + // Store brackets and transition to voting state + lobby.brackets = brackets; + lobby.currentBracketIndex = 0; + + if (brackets.length > 0) { + // Set the first battle + lobby.state = 'VOTING'; + + // Ensure we create a proper battle object with all required fields + lobby.currentBattle = { + ...brackets[0], + // Make sure votes is a Map (it might be serialized incorrectly) + votes: brackets[0].votes || new Map(), + voteCount: 0 + }; + + console.log('Starting first battle:', lobby.currentBattle); + } else { + // Edge case: only one song submitted + if (songs.length === 1) { + lobby.state = 'FINISHED'; + lobby.finalWinner = songs[0]; + } + } + } + + /** + * Move to next battle or finish tournament + * @param {Object} lobby - Lobby object + * @private + */ + _moveToNextBattle(lobby) { + // Check if there are more battles in current round + lobby.currentBracketIndex++; + + if (lobby.currentBracketIndex < lobby.brackets.length) { + // Move to next battle + lobby.currentBattle = lobby.brackets[lobby.currentBracketIndex]; + return; + } + + // Current round complete, check if tournament is finished + const winners = lobby.brackets + .filter(b => !b.bye) // Skip byes + .map(b => { + const winningSong = b.song1.id === b.winner ? b.song1 : b.song2; + return winningSong; + }); + + // Add byes to winners + const byes = lobby.brackets + .filter(b => b.bye) + .map(b => b.song1); + + const nextRoundSongs = [...winners, ...byes]; + + // If only one song remains, we have a winner + if (nextRoundSongs.length <= 1) { + lobby.state = 'FINISHED'; + lobby.finalWinner = nextRoundSongs[0]; + lobby.currentBattle = null; + return; + } + + // Create brackets for next round + const nextRound = []; + const round = lobby.brackets[0].round + 1; + + // Handle odd number of songs by giving a bye to one song + if (nextRoundSongs.length % 2 !== 0) { + const byeSong = nextRoundSongs.pop(); + nextRound.push({ + round, + song1: byeSong, + song2: null, + bye: true, + winner: byeSong.id, + song1Votes: 0, + song2Votes: 0 + }); + } + + // Create pairs for next round + for (let i = 0; i < nextRoundSongs.length; i += 2) { + if (i + 1 < nextRoundSongs.length) { + nextRound.push({ + round, + song1: nextRoundSongs[i], + song2: nextRoundSongs[i + 1], + song1Votes: 0, + song2Votes: 0, + winner: null, + votes: new Map() + }); + } + } + + // Update brackets and reset index + lobby.brackets = nextRound; + lobby.currentBracketIndex = 0; + + // Set first battle of new round + if (nextRound.length > 0) { + lobby.currentBattle = nextRound[0]; + } + } + + /** + * Shuffle array in-place using Fisher-Yates algorithm + * @param {Array} array - Array to shuffle + * @private + */ + _shuffleArray(array) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } + } +} + +// Export GameManager class +module.exports = GameManager; diff --git a/server/index.js b/server/index.js index 42cc0f3..18bce4d 100644 --- a/server/index.js +++ b/server/index.js @@ -3,11 +3,12 @@ const { Server } = require("socket.io"); const http = require("http"); const app = express(); const path = require("path"); +const GameManager = require("./game"); -app.use(express.static(path.join(__dirname, './dist'))); +app.use(express.static(path.join(__dirname, '../client/dist'))); app.disable("x-powered-by"); -app.get('*', (req, res) => res.sendFile(path.join(__dirname, './dist', 'index.html'))); +app.get('*', (req, res) => res.sendFile(path.join(__dirname, '../client/dist', 'index.html'))); const server = http.createServer(app); @@ -17,6 +18,326 @@ const io = new Server(server, { pingInterval: 10000 }); +// Initialize game manager +const gameManager = new GameManager(); + +// Socket.IO event handlers +io.on('connection', (socket) => { + console.log(`User connected: ${socket.id}`); + + // Create a new game lobby + socket.on('create_lobby', ({ playerName }, callback) => { + try { + const result = gameManager.createLobby(socket.id, playerName); + + // Join the socket room for this lobby + socket.join(result.lobbyId); + + // Send response to client + if (callback) callback(result); + + console.log(`Lobby created: ${result.lobbyId} by ${playerName}`); + } catch (error) { + console.error('Error creating lobby:', error); + if (callback) callback({ error: 'Failed to create lobby' }); + } + }); + + // Join an existing lobby + socket.on('join_lobby', ({ lobbyId, playerName }, callback) => { + try { + const result = gameManager.joinLobby(socket.id, playerName, lobbyId); + + if (result.error) { + if (callback) callback(result); + return; + } + + // Join the socket room for this lobby + socket.join(lobbyId); + + // Notify all players in the lobby + socket.to(lobbyId).emit('player_joined', { + playerId: socket.id, + playerName + }); + + // Send response to client + if (callback) callback(result); + + console.log(`Player ${playerName} joined lobby ${lobbyId}`); + } catch (error) { + console.error('Error joining lobby:', error); + if (callback) callback({ error: 'Failed to join lobby' }); + } + }); + + // Attempt to reconnect to a lobby + socket.on('reconnect_to_lobby', ({ lobbyId, playerName }, callback) => { + try { + const result = gameManager.handleReconnect(socket.id, lobbyId, playerName); + + if (result.error) { + if (callback) callback(result); + return; + } + + // Join the socket room for this lobby + socket.join(lobbyId); + + // Notify all players in the lobby + socket.to(lobbyId).emit('player_reconnected', { + playerId: socket.id, + playerName + }); + + // Send response to client + if (callback) callback(result); + + console.log(`Player ${playerName} reconnected to lobby ${lobbyId}`); + } catch (error) { + console.error('Error reconnecting to lobby:', error); + if (callback) callback({ error: 'Failed to reconnect to lobby' }); + } + }); + + // Update lobby settings + socket.on('update_settings', ({ settings }, callback) => { + try { + const result = gameManager.updateSettings(socket.id, settings); + + if (result.error) { + if (callback) callback(result); + return; + } + + // Notify all players in the lobby + io.to(result.lobbyId).emit('settings_updated', { + settings: result.lobby.settings + }); + + // Send response to client + if (callback) callback(result); + } catch (error) { + console.error('Error updating settings:', error); + if (callback) callback({ error: 'Failed to update settings' }); + } + }); + + // Start the game from lobby + socket.on('start_game', (_, callback) => { + try { + const result = gameManager.startGame(socket.id); + + if (result.error) { + if (callback) callback(result); + return; + } + + // Notify all players in the lobby + io.to(result.lobbyId).emit('game_started', { + state: result.lobby.state + }); + + // Send response to client + if (callback) callback(result); + + console.log(`Game started in lobby ${result.lobbyId}`); + } catch (error) { + console.error('Error starting game:', error); + if (callback) callback({ error: 'Failed to start game' }); + } + }); + + // Add a song + socket.on('add_song', ({ song }, callback) => { + try { + console.log(`[DEBUG] Attempting to add song: ${JSON.stringify(song)}`); + const result = gameManager.addSong(socket.id, song); + + if (result.error) { + console.log(`[DEBUG] Error adding song: ${result.error}`); + if (callback) callback(result); + return; + } + + // Add better error handling to prevent crash if lobby is not defined + if (!result.lobby || !result.lobbyId) { + console.log(`[DEBUG] Warning: Song added but lobby information is missing`); + if (callback) callback({ error: 'Failed to associate song with lobby' }); + return; + } + + console.log(`[DEBUG] Song added successfully, notifying lobby ${result.lobbyId}`); + console.log(`[DEBUG] Lobby songs: ${JSON.stringify(result.lobby.songs.map(s => s.title))}`); + + // Notify all players in the lobby about updated song count + io.to(result.lobbyId).emit('songs_updated', { + lobby: result.lobby + }); + + // Send response to client + if (callback) callback(result); + } catch (error) { + console.error('Error adding song:', error); + if (callback) callback({ error: 'Failed to add song' }); + } + }); + + // Remove a song + socket.on('remove_song', ({ songId }, callback) => { + try { + const result = gameManager.removeSong(socket.id, songId); + + if (result.error) { + if (callback) callback(result); + return; + } + + // Notify all players in the lobby + io.to(result.lobbyId).emit('songs_updated', { + lobby: result.lobby + }); + + // Send response to client + if (callback) callback(result); + } catch (error) { + console.error('Error removing song:', error); + if (callback) callback({ error: 'Failed to remove song' }); + } + }); + + // Search YouTube for songs + socket.on('search_youtube', async ({ query }, callback) => { + try { + if (!query || typeof query !== 'string') { + if (callback) callback({ error: 'Invalid search query' }); + return; + } + + const results = await gameManager.searchYouTube(query); + + // Send response to client + if (callback) callback({ results }); + } catch (error) { + console.error('Error searching YouTube:', error); + if (callback) callback({ error: 'Failed to search YouTube' }); + } + }); + + // Mark player as ready + socket.on('player_ready', (_, callback) => { + try { + const result = gameManager.setPlayerReady(socket.id); + + if (result.error) { + if (callback) callback(result); + return; + } + + // Notify all players in the lobby + io.to(result.lobbyId).emit('player_status_changed', { + lobby: result.lobby + }); + + // If the game state just changed to VOTING, notify about the first battle + if (result.lobby.state === 'VOTING' && result.lobby.currentBattle) { + console.log('Sending new_battle event to clients'); + io.to(result.lobbyId).emit('new_battle', { + battle: result.lobby.currentBattle + }); + } + + // If tournament just started, explicitly notify about the state change and first battle + if (result.tournamentStarted && result.lobby.currentBattle) { + console.log('Tournament started, sending battle data'); + // First send state change + io.to(result.lobbyId).emit('tournament_started', { + state: 'VOTING' + }); + + // Then send battle data with a slight delay to ensure clients process in the right order + setTimeout(() => { + io.to(result.lobbyId).emit('new_battle', { + battle: result.lobby.currentBattle + }); + }, 300); + } + + // If game finished (edge case where only one song was submitted) + if (result.lobby.state === 'FINISHED') { + io.to(result.lobbyId).emit('game_finished', { + winner: result.lobby.finalWinner, + battles: result.lobby.battles + }); + } + + // Send response to client + if (callback) callback(result); + } catch (error) { + console.error('Error setting player ready:', error); + if (callback) callback({ error: 'Failed to set player status' }); + } + }); + + // Submit a vote in a battle + socket.on('submit_vote', ({ songId }, callback) => { + try { + const result = gameManager.submitVote(socket.id, songId); + + if (result.error) { + if (callback) callback(result); + return; + } + + // Notify all players about vote count + io.to(result.lobbyId).emit('vote_submitted', { + lobby: result.lobby + }); + + // If battle is finished, notify about new battle + if (result.lobby.currentBattle) { + io.to(result.lobbyId).emit('new_battle', { + battle: result.lobby.currentBattle + }); + } + + // If game is finished, notify about the winner + if (result.lobby.state === 'FINISHED') { + io.to(result.lobbyId).emit('game_finished', { + winner: result.lobby.finalWinner, + battles: result.lobby.battles + }); + } + + // Send response to client + if (callback) callback(result); + } catch (error) { + console.error('Error submitting vote:', error); + if (callback) callback({ error: 'Failed to submit vote' }); + } + }); + + // Handle disconnection + socket.on('disconnect', () => { + try { + const result = gameManager.handleDisconnect(socket.id); + + if (result) { + // Notify remaining players about the disconnection + io.to(result.lobbyId).emit('player_disconnected', { + playerId: socket.id, + lobby: result.lobby + }); + + console.log(`Player ${socket.id} disconnected from lobby ${result.lobbyId}`); + } + } catch (error) { + console.error('Error handling disconnect:', error); + } + }); +}); + server.on('error', (error) => { console.error('Server error:', error); }); diff --git a/server/youtube-api.js b/server/youtube-api.js new file mode 100644 index 0000000..ca23a23 --- /dev/null +++ b/server/youtube-api.js @@ -0,0 +1,129 @@ +const { google } = require('googleapis'); +const axios = require('axios'); + +// You would need to obtain a YouTube API key from Google Cloud Console +// For now, we'll use a placeholder - you'll need to replace this with your actual API key +const API_KEY = process.env.YOUTUBE_API_KEY || 'NO'; + +// Initialize the YouTube API client +const youtube = google.youtube({ + version: 'v3', + auth: API_KEY +}); + +/** + * Extract YouTube video ID from various formats of YouTube links + * @param {string} url - YouTube URL in any format + * @returns {string|null} YouTube video ID or null if invalid + */ +function extractVideoId(url) { + if (!url) return null; + + // Handle different YouTube URL formats + const patterns = [ + /(?:https?:\/\/)?(?:www\.)?youtube\.com\/watch\?v=([^&]+)/, + /(?:https?:\/\/)?(?:www\.)?youtube\.com\/embed\/([^/?]+)/, + /(?:https?:\/\/)?(?:www\.)?youtu\.be\/([^/?]+)/ + ]; + + for (const pattern of patterns) { + const match = url.match(pattern); + if (match && match[1]) { + return match[1]; + } + } + + return null; +} + +/** + * Get video metadata from YouTube API + * @param {string} videoId - YouTube video ID + * @returns {Promise} Video metadata including title and channel + */ +async function getVideoMetadata(videoId) { + try { + // Try using the googleapis library first + try { + const response = await youtube.videos.list({ + part: 'snippet', + id: videoId + }); + + const video = response.data.items[0]; + + if (video && video.snippet) { + return { + title: video.snippet.title, + artist: video.snippet.channelTitle, + thumbnail: video.snippet.thumbnails.default.url + }; + } + } catch (googleApiError) { + console.log('Google API error, falling back to alternative method:', googleApiError.message); + // If googleapis fails (e.g., due to API key issues), fall back to a simplified approach + const response = await axios.get(`https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=${videoId}&format=json`); + + if (response.data) { + const title = response.data.title || ''; + // Try to extract artist from title (common format is "Artist - Title") + const parts = title.split(' - '); + const artist = parts.length > 1 ? parts[0] : response.data.author_name; + + return { + title: parts.length > 1 ? parts.slice(1).join(' - ') : title, + artist: artist, + thumbnail: response.data.thumbnail_url + }; + } + } + + // If all methods fail, return default values + return { + title: 'Unknown Title', + artist: 'Unknown Artist', + thumbnail: null + }; + } catch (error) { + console.error('Error fetching video metadata:', error.message); + return { + title: 'Unknown Title', + artist: 'Unknown Artist', + thumbnail: null + }; + } +} + +/** + * Search for songs on YouTube + * @param {string} query - Search query for YouTube + * @returns {Promise} List of search results + */ +async function searchYouTube(query) { + try { + const response = await youtube.search.list({ + part: 'snippet', + q: query, + type: 'video', + maxResults: 5, + videoCategoryId: '10' // Category ID for Music + }); + + return response.data.items.map(item => ({ + id: item.id.videoId, + title: item.snippet.title, + artist: item.snippet.channelTitle, + thumbnail: item.snippet.thumbnails.default.url, + youtubeLink: `https://www.youtube.com/watch?v=${item.id.videoId}` + })); + } catch (error) { + console.error('Error searching YouTube:', error.message); + return []; + } +} + +module.exports = { + extractVideoId, + getVideoMetadata, + searchYouTube +};