Compare commits

..

16 Commits

Author SHA1 Message Date
f3c87878ce Add vote progress bar and related styles to enhance voting feedback
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 3m21s
2025-05-14 22:10:27 +02:00
8f91e27ca1 Add Battle Result Screen component and associated styles for displaying battle outcomes
Some checks failed
Publish Docker image / Push Docker image to Docker Hub (push) Failing after 6m59s
2025-05-14 21:53:44 +02:00
0c543a1a01 Add player name visibility toggle and enhance display logic in lobby and results screens
Some checks failed
Publish Docker image / Push Docker image to Docker Hub (push) Failing after 3m43s
2025-05-14 21:10:26 +02:00
301e08b6e6 Add connection status component and improve socket reconnection handling
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 2m20s
2025-05-14 20:10:46 +02:00
f2712bdcec Fix automatic advance bug
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 2m3s
2025-05-14 19:27:21 +02:00
9f4ebf379f Enhance button styling and layout in Song Submission screen for improved UI experience
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 1m40s
2025-04-24 21:26:50 +02:00
4f4626260f Translate UI text to German for Lobby, Results, and Voting screens to enhance localization
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 1m44s
2025-04-24 21:19:15 +02:00
e24ecb418c Translate UI text to German for song submission and voting screens to enhance localization
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 1m38s
2025-04-24 21:05:04 +02:00
1a7d7f4df3 Update server port in Vite config and Dockerfile from 5287 to 5237 for consistency 2025-04-24 20:42:49 +02:00
f9d63c6c49 Translate UI text to German for Home, Lobby, and Song Submission screens to enhance localization
All checks were successful
Publish Docker image / Push Docker image to Docker Hub (push) Successful in 1m46s
2025-04-24 20:23:14 +02:00
a7929cf144 Refactor song submission process to include YouTube metadata fetching; remove YouTube link requirement from settings for improved flexibility 2025-04-24 20:04:26 +02:00
77df851e95 Add song form overlay styles and integrate into song submission screen for improved UX 2025-04-24 18:19:42 +02:00
6c9f1c3348 Refactor background gradient in voting screen styles for cleaner code 2025-04-24 17:57:41 +02:00
fca6baa694 Enhance Voting and Home screens with pixel art styles; add player voting status list and update button designs for improved UI experience 2025-04-24 17:56:53 +02:00
38ed69bf5b Revamp styles for Song Battle screens with pixel art theme; enhance layout, animations, and button designs for a cohesive aesthetic 2025-04-24 17:11:56 +02:00
50e245233c Enhance Voting and Results screens to handle automatic song advances in bye rounds; update voting logic and remove submitter details 2025-04-24 16:37:52 +02:00
31 changed files with 3526 additions and 739 deletions

View File

@ -25,6 +25,6 @@ RUN chown -R node:node /app
ENV NODE_ENV=production
USER node
EXPOSE 5287
EXPOSE 5237
CMD ["node", "index.js"]

View File

@ -10,7 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/bangers": "^5.1.1",
"@fontsource/press-start-2p": "^5.2.5",
"@fortawesome/fontawesome-svg-core": "^6.7.2",
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",

12
client/pnpm-lock.yaml generated
View File

@ -8,9 +8,9 @@ importers:
.:
dependencies:
'@fontsource/bangers':
specifier: ^5.1.1
version: 5.1.1
'@fontsource/press-start-2p':
specifier: ^5.2.5
version: 5.2.5
'@fortawesome/fontawesome-svg-core':
specifier: ^6.7.2
version: 6.7.2
@ -327,8 +327,8 @@ packages:
resolution: {integrity: sha512-JubJ5B2pJ4k4yGxaNLdbjrnk9d/iDz6/q8wOilpIowd6PJPgaxCuHBnBszq7Ce2TyMrywm5r4PnKm6V3iiZF+g==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@fontsource/bangers@5.1.1':
resolution: {integrity: sha512-+OzMvd6OXRipNWjGtcRTz1kVn+ML2A+4OsejqroZeUju74qncQ6lhlP3cZzu6hijXR8U9rm603lufzYdVAOdsA==}
'@fontsource/press-start-2p@5.2.5':
resolution: {integrity: sha512-MmGLqhkv0kuoyeGgGkquEMRxJP6auc6918bKd8uTWP2beXMWLZZwCfXCqmskFLf0XYbtbzxuRXLjTnQBeTwsMQ==}
'@fortawesome/fontawesome-common-types@6.7.2':
resolution: {integrity: sha512-Zs+YeHUC5fkt7Mg1l6XTniei3k4bwG/yo3iFUtZWd/pMx9g3fdvkSK9E0FOC+++phXOka78uJcYb8JaFkW52Xg==}
@ -1786,7 +1786,7 @@ snapshots:
'@eslint/core': 0.12.0
levn: 0.4.1
'@fontsource/bangers@5.1.1': {}
'@fontsource/press-start-2p@5.2.5': {}
'@fortawesome/fontawesome-common-types@6.7.2': {}

View File

@ -7,6 +7,8 @@ import HomeScreen from "./components/HomeScreen";
import SongSubmissionScreen from "./components/SongSubmissionScreen";
import VotingScreen from "./components/VotingScreen";
import ResultsScreen from "./components/ResultsScreen";
import BattleResultScreen from "./components/BattleResultScreen";
import ConnectionStatus from "./components/ConnectionStatus";
const App = () => {
const [cursorPos, setCursorPos] = useState({x: 0, y: 0});
@ -51,6 +53,8 @@ const App = () => {
return <VotingScreen />;
case 'FINISHED':
return <ResultsScreen />;
case 'BATTLE':
return <BattleResultScreen />;
default:
return <HomeScreen />;
}
@ -84,12 +88,6 @@ const App = () => {
</div>
<div className="content-container">
{!isConnected && (
<div className="connection-status">
<p>Connecting to server...</p>
</div>
)}
{error && (
<div className="error-message">
<p>{error}</p>
@ -97,6 +95,9 @@ const App = () => {
)}
{renderGameScreen()}
{/* Show connection status component */}
<ConnectionStatus />
</div>
</>
)

View File

@ -1,53 +1,83 @@
// Button styles for the Song Battle application
// Button styles for the Song Battle application - Pixel Art Theme
// Base button style
.btn
display: inline-block
padding: 0.75rem 1.5rem
border: none
border-radius: 0.5rem
border: 4px solid #000
border-radius: 0
font-family: 'Press Start 2P', monospace
font-size: 0.9rem
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
background-color: $primary
color: #000
// Enhanced pixel art style with jagged edges
image-rendering: pixelated
box-shadow: 4px 4px 0 #000, inset -4px -4px 0 darken($primary, 30%), inset 4px 4px 0 lighten($primary, 10%)
transition: all 0.1s ease
// Add decorative pixel corners
&:before, &:after
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%
width: 4px
height: 4px
background-color: #000
z-index: 2
&:before
top: -4px
left: -4px
&:after
bottom: -4px
right: -4px
&:hover
transform: translateY(-3px)
box-shadow: 0 7px 15px rgba(0, 0, 0, 0.3)
transform: translate(-2px, -2px)
box-shadow: 6px 6px 0 #000, inset -4px -4px 0 darken($primary, 30%), inset 4px 4px 0 lighten($primary, 10%)
&:active
transform: translateY(0)
box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2)
transform: translate(4px, 4px)
box-shadow: 0 0 0 #000, inset -4px -4px 0 darken($primary, 30%), inset 4px 4px 0 lighten($primary, 10%)
&:disabled
opacity: 0.5
cursor: not-allowed
transform: none
box-shadow: none
// Full width modifier
&.full-width
width: 100%
margin-top: 1rem
padding: 1rem
font-size: 1rem
// Button types
&.primary
background-color: $primary
color: #000
box-shadow: 4px 4px 0 #000, inset -4px -4px 0 darken($primary, 30%), inset 4px 4px 0 lighten($primary, 10%)
&.secondary
background-color: $secondary
color: #000
box-shadow: 4px 4px 0 #000, inset -4px -4px 0 darken($secondary, 30%), inset 4px 4px 0 lighten($secondary, 10%)
&.accent
background-color: $accent
color: #000
box-shadow: 4px 4px 0 #000, inset -4px -4px 0 darken($accent, 30%), inset 4px 4px 0 lighten($accent, 10%)
@keyframes pixel-breathe
0%, 100%
transform: scale(1)
50%
transform: scale(1.01)
// Primary button
.btn.primary
@ -55,10 +85,10 @@
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)
@ -68,10 +98,10 @@
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)
@ -80,10 +110,10 @@
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)
@ -95,7 +125,7 @@
padding: 0.75rem 2rem
transform-style: preserve-3d
perspective: 1000px
&:after
content: ''
position: absolute
@ -108,9 +138,9 @@
transform: translateZ(-10px)
z-index: -1
transition: transform 0.3s
&:hover
transform: translateY(-5px) rotateX(10deg)
&:after
transform: translateZ(-5px)
transform: translateZ(-5px)

View File

@ -1,15 +1,20 @@
// Colors for Song Battle application
// Colors for Song Battle application - Pixel Art Theme
// Main colors
$background: #0f0f1a // Kept for compatibility
$card-bg: rgba(20, 20, 35, 0.85) // Semi-transparent dark card background
$background: #1a1126 // Dark purple background for pixel art feel
$card-bg: rgba(32, 24, 48, 0.9) // Semi-transparent dark purple card background
$text: #ffffff
$text-muted: rgba(255, 255, 255, 0.7) // Brighter for better contrast with gradient
$text-muted: rgba(255, 255, 255, 0.8) // 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
// Brand colors - More saturated for pixel art aesthetic
$primary: #ff55ff // Bright magenta - pixel art style
$secondary: #00ddff // Bright cyan - pixel art style
$accent: #ffcc00 // Bright gold - pixel art style
// Pixel art accent colors
$pixel-purple: #9900ff
$pixel-blue: #0088ff
$pixel-green: #00ff66
// Status colors
$success: #00c853

View File

@ -0,0 +1,243 @@
// Battle Result Screen styles
.battle-result-screen
display: flex
flex-direction: column
min-height: 100%
padding: 1.5rem
position: relative
overflow: hidden
header
display: flex
justify-content: center
align-items: center
flex-direction: column
margin-bottom: 2rem
h1
margin: 0
color: $accent
display: flex
align-items: center
gap: 0.75rem
font-size: 2.2rem
font-family: 'Press Start 2P', monospace
text-transform: uppercase
animation: winner-pulse 2s infinite alternate
svg
color: $accent
filter: drop-shadow(2px 2px 0 #000)
.countdown
margin-top: 1rem
font-family: 'Press Start 2P', monospace
font-size: 0.9rem
color: $text-muted
padding: 0.8rem 1.2rem
background-color: rgba(0, 0, 0, 0.3)
border-radius: 1rem
animation: pulse 1s infinite alternate
display: flex
align-items: center
gap: 0.5rem
svg
color: $accent
margin-right: 0.5rem
.winner-announcement
display: flex
flex-direction: column
align-items: center
margin-bottom: 2rem
.song-cards
display: flex
flex-direction: column
width: 100%
max-width: 700px
gap: 2rem
@media (min-width: 768px)
flex-direction: row
align-items: flex-start
.song-card
position: relative
background-color: rgba(0, 0, 0, 0.3)
padding: 1.5rem
border-radius: 1rem
&.winner
flex: 3
border: 6px solid $accent
box-shadow: 0 0 20px rgba($accent, 0.3)
transform: scale(1.05)
z-index: 1
.victory-badge
position: absolute
top: -12px
right: 10px
background-color: $accent
color: white
font-family: 'Press Start 2P', monospace
font-size: 0.7rem
padding: 0.5rem 1rem
border-radius: 0.5rem
display: flex
align-items: center
gap: 0.5rem
box-shadow: 2px 2px 0 rgba(0, 0, 0, 0.3)
svg
font-size: 0.8rem
&.loser
flex: 2
opacity: 0.7
filter: saturate(0.7)
.versus
position: absolute
top: 50%
left: -30px
font-family: 'Bangers', cursive
font-size: 2.5rem
color: $accent
transform: translateY(-50%)
text-shadow: 0 0 5px rgba($accent, 0.5)
@media (max-width: 767px)
top: -25px
left: 50%
transform: translateX(-50%)
.song-info
margin-bottom: 1.5rem
h2, h3
margin: 0 0 0.5rem 0
font-family: 'Press Start 2P', monospace
h2
font-size: 1.4rem
color: $text
margin-right: 70px
h3
font-size: 1.2rem
color: $text
.artist
color: $text-muted
font-size: 1.1rem
margin-bottom: 1rem
.submitter
font-size: 0.9rem
color: $text-muted
font-style: italic
margin-bottom: 0.5rem
.vote-count
display: inline-block
padding: 0.5rem 1rem
background-color: rgba(0, 0, 0, 0.3)
border-radius: 1rem
font-family: 'Press Start 2P', monospace
font-size: 0.8rem
.votes
color: $text
.winner-video
width: 100%
aspect-ratio: 16 / 9
border-radius: 0.5rem
overflow: hidden
.no-video
width: 100%
aspect-ratio: 16 / 9
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
.pulse-icon
font-size: 2rem
margin-bottom: 1rem
animation: pulse 2s infinite
.battle-actions
display: flex
justify-content: center
margin-top: auto
padding-top: 1.5rem
.btn
padding: 1rem 2rem
font-size: 1.1rem
display: flex
align-items: center
gap: 0.5rem
&.pixelated
position: relative
.pixel-corner
position: absolute
width: 8px
height: 8px
background-color: $primary
&.tl
top: -4px
left: -4px
&.tr
top: -4px
right: -4px
&.bl
bottom: -4px
left: -4px
&.br
bottom: -4px
right: -4px
// Confetti animation
.confetti
position: absolute
width: 10px
height: 20px
transform-origin: center bottom
animation: confetti-fall 3s linear forwards
z-index: -1
@keyframes confetti-fall
0%
transform: translateY(-100vh) rotate(0deg)
100%
transform: translateY(100vh) rotate(360deg)
@keyframes winner-pulse
0%
transform: scale(1)
text-shadow: 2px 2px 0 #000, -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000
100%
transform: scale(1.05)
text-shadow: 3px 3px 0 #000, -3px -3px 0 #000, 3px -3px 0 #000, -3px 3px 0 #000
@keyframes pulse
0%
opacity: 0.7
100%
opacity: 1

View File

@ -0,0 +1,55 @@
// connection-status.sass - Component for displaying network connection status
.connection-status
position: fixed
bottom: 0
left: 0
right: 0
padding: 0.5rem 1rem
display: flex
align-items: center
justify-content: center
gap: 0.5rem
z-index: 1000
transition: all 0.3s ease
animation: slideUp 0.3s forwards
font-family: 'Press Start 2P', monospace
font-size: 0.7rem
&.connected
background-color: rgba($success, 0.9)
color: #fff
animation: slideUp 0.3s forwards, fadeOut 0.5s 2.5s forwards
&.disconnected
background-color: rgba(#f44336, 0.9)
color: #fff
&.reconnecting
background-color: rgba(#ff9800, 0.9)
color: #fff
&.offline
background-color: rgba(#f44336, 0.9)
color: #fff
.connection-icon
display: flex
align-items: center
justify-content: center
.connection-message
text-align: center
@keyframes slideUp
from
transform: translateY(100%)
to
transform: translateY(0)
@keyframes fadeOut
from
opacity: 1
to
opacity: 0
transform: translateY(100%)

View File

@ -9,73 +9,133 @@
padding: 2rem
position: relative
// Pixel art floating items in background
&:before, &:after
content: ''
position: absolute
width: 12px
height: 12px
background-color: $pixel-blue
animation: pixel-float 3s infinite alternate ease-in-out
z-index: -1
&:before
top: 15%
left: 20%
box-shadow: 0 0 10px $pixel-blue
animation-delay: 0s
&:after
bottom: 20%
right: 15%
background-color: $pixel-purple
box-shadow: 0 0 10px $pixel-purple
animation-delay: 1.5s
.logo
display: flex
flex-direction: column
align-items: center
margin-bottom: 2rem
margin-bottom: 3rem
.logo-image
image-rendering: pixelated
width: 120px
width: 180px
height: auto
margin-bottom: 1rem
animation: pulse 2s infinite ease-in-out
filter: drop-shadow(0 0 10px rgba(255, 255, 255, 0.7))
margin-bottom: 1.5rem
animation: pixel-float 3s infinite ease-in-out
filter: drop-shadow(0 0 15px rgba(255, 255, 255, 0.5))
h1
font-size: 3.5rem
font-size: 2.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)
font-family: 'Press Start 2P', monospace
color: $primary
animation: pixel-flash 3s infinite
letter-spacing: 2px
text-transform: uppercase
.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)
box-shadow: 8px 8px 0 #000
border: 4px solid #000
width: 100%
max-width: 500px
position: relative
overflow: visible
.tabs
display: flex
margin-bottom: 1.5rem
border-bottom: 2px solid rgba(255, 255, 255, 0.1)
// Pixel art decorations
&:before
content: ''
position: absolute
top: -10px
left: -10px
width: 20px
height: 20px
background-color: $accent
z-index: 1
animation: pixel-rotate 8s linear infinite
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
&:after
content: ''
position: absolute
bottom: -10px
right: -10px
width: 20px
height: 20px
background-color: $primary
z-index: 1
animation: pixel-rotate 8s linear infinite reverse
@keyframes pixel-rotate
0%
transform: rotate(0deg)
100%
transform: rotate(360deg)
.tabs
display: flex
margin-bottom: 1.5rem
border-bottom: 4px solid #000
.home-footer
margin-top: 2rem
text-align: center
button
flex: 1
background: none
border: 4px solid #000
border-bottom: none
color: $text-muted
font-style: italic
font-family: 'Press Start 2P', monospace
font-size: 0.7rem
padding: 0.75rem 0.5rem
position: relative
text-transform: uppercase
transition: all 0.2s ease
cursor: pointer
&:hover:not(.active)
background-color: rgba($primary, 0.2)
color: $text
&.active
background-color: $primary
color: #000
z-index: 1
&:after
content: ''
position: absolute
bottom: -4px
left: 0
width: 100%
height: 4px
background-color: $primary
.home-footer
margin-top: 2rem
text-align: center
color: $text-muted
font-style: italic
@keyframes pulse
0%

View File

@ -0,0 +1,205 @@
// Lobby Screen styles
.lobby-screen
display: flex
flex-direction: column
min-height: 100%
padding: 1.5rem
.lobby-header
display: flex
justify-content: space-between
align-items: center
margin-bottom: 2rem
flex-wrap: wrap
gap: 1rem
h1
margin: 0
color: $primary
font-family: 'Press Start 2P', monospace
font-size: 1.8rem
text-transform: uppercase
position: relative
letter-spacing: 2px
&:after
content: ''
position: absolute
bottom: -10px
left: 0
width: 100%
height: 4px
background-color: $primary
.lobby-code
display: flex
align-items: center
background-color: $card-bg
padding: 0.75rem 1rem
border: 4px solid #000
box-shadow: 4px 4px 0 #000
p
margin: 0 1rem 0 0
font-family: 'Press Start 2P', monospace
font-size: 0.7rem
text-transform: uppercase
.code
font-family: 'Press Start 2P', monospace
font-weight: bold
color: $secondary
font-size: 1rem
letter-spacing: 2px
background-color: rgba(0, 0, 0, 0.3)
padding: 0.5rem
border: 2px solid $secondary
.btn
padding: 0.25rem 0.5rem
font-size: 0.7rem
.lobby-content
display: flex
flex-direction: column
flex-grow: 1
gap: 1.5rem
@media (min-width: 768px)
flex-direction: row
.settings-section, .players-section
background-color: $card-bg
border: 4px solid #000
box-shadow: 8px 8px 0 #000
padding: 1.5rem
position: relative
h2
margin-top: 0
margin-bottom: 1rem
color: $secondary
font-family: 'Press Start 2P', monospace
font-size: 1.2rem
text-transform: uppercase
&:after
content: ''
display: block
width: 100%
height: 4px
background-color: $secondary
margin-top: 0.5rem
.players-list
flex: 1
background-color: $card-bg
border-radius: 1rem
padding: 1.5rem
h2
margin-top: 0
display: flex
align-items: center
gap: 0.5rem
color: $primary
ul
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
justify-content: space-between
align-items: center
&.host
border-left: 3px solid $primary
&.disconnected
opacity: 0.6
.status-disconnected
font-size: 0.8rem
color: $danger
margin-left: auto
.lobby-info
flex: 1
display: flex
flex-direction: column
.settings-preview
background-color: $card-bg
border-radius: 1rem
padding: 1.5rem
margin-bottom: 1.5rem
h3
margin-top: 0
color: $secondary
display: flex
align-items: center
gap: 0.5rem
.actions
display: flex
flex-direction: column
gap: 1rem
margin-top: auto
.warning
color: $warning
text-align: center
margin-top: 1rem
font-size: 0.9rem
// Modal overlay for settings
.modal-overlay
position: fixed
top: 0
left: 0
right: 0
bottom: 0
background-color: rgba(0, 0, 0, 0.7)
display: flex
justify-content: center
align-items: center
z-index: 100
.modal
background-color: $card-bg
border-radius: 1rem
padding: 2rem
width: 90%
max-width: 500px
border: 6px solid #000
box-shadow: 10px 10px 0 rgba(0, 0, 0, 0.5)
animation: modal-appear 0.3s ease-out
h2
margin-top: 0
color: $primary
font-family: 'Press Start 2P', monospace
font-size: 1.4rem
margin-bottom: 1.5rem
.modal-actions
display: flex
justify-content: flex-end
gap: 1rem
margin-top: 2rem
@keyframes modal-appear
0%
transform: scale(0.8)
opacity: 0
100%
transform: scale(1)
opacity: 1

View File

@ -7,73 +7,146 @@
padding: 1.5rem
position: relative
overflow: hidden
// Celebratory pixel confetti
&:before
content: ''
position: absolute
top: -20px
left: 0
width: 100%
height: 20px
background: repeating-linear-gradient(90deg, $primary 0px, $primary 10px, $accent 10px, $accent 20px, $secondary 20px, $secondary 30px)
animation: pixel-rain 8s linear infinite
@keyframes pixel-rain
0%
transform: translateY(-20px)
100%
transform: translateY(100vh)
header
display: flex
justify-content: center
margin-bottom: 2rem
margin-bottom: 3rem
position: relative
h1
margin: 0
color: $primary
text-shadow: 0 0 8px rgba($primary, 0.5)
color: $accent
display: flex
align-items: center
gap: 0.5rem
gap: 0.75rem
font-size: 2.5rem
font-family: 'Press Start 2P', monospace
text-transform: uppercase
letter-spacing: 3px
animation: winner-pulse 2s infinite alternate
svg
color: $accent
filter: drop-shadow(2px 2px 0 #000)
@keyframes winner-pulse
0%
transform: scale(1)
text-shadow: 2px 2px 0 #000, -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000
100%
transform: scale(1.05)
text-shadow: 3px 3px 0 #000, -3px -3px 0 #000, 3px -3px 0 #000, -3px 3px 0 #000
.winner-card
background-color: $card-bg
border-radius: 1rem
padding: 1.5rem
border: 8px solid $accent
padding: 2rem
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
box-shadow: 10px 10px 0 #000, 0 0 20px rgba($accent, 0.7)
// Trophy decoration
&:before
content: '🏆'
position: absolute
top: -15px
right: 20px
font-size: 2.5rem
transform: rotate(15deg)
filter: drop-shadow(2px 2px 0 #000)
z-index: 1
// Pixel border pattern
&:after
content: ''
position: absolute
top: 0
left: 0
right: 0
height: 5px
background: linear-gradient(to right, $primary, $accent)
bottom: 0
background: repeating-linear-gradient(45deg, transparent 0px, transparent 10px, rgba($accent, 0.2) 10px, rgba($accent, 0.2) 20px)
pointer-events: none
z-index: 0
@media (min-width: 768px)
flex-direction: row
align-items: stretch
.winner-content
z-index: 1
.winner-title
margin-top: 0
color: $accent
font-family: 'Press Start 2P', monospace
font-size: 1.8rem
text-transform: uppercase
margin-bottom: 1.5rem
.winner-info
margin-bottom: 1.5rem
.winner-song
font-family: 'Press Start 2P', monospace
font-size: 1.2rem
color: $text
margin-bottom: 0.5rem
.winner-player
font-family: 'Press Start 2P', monospace
font-size: 1rem
color: $primary
padding: 0.5rem
background-color: rgba($primary, 0.2)
border: 3px solid $primary
display: inline-block
.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
@ -82,7 +155,7 @@
height: 100%
border-radius: 0.5rem
overflow: hidden
.winner-placeholder
flex: 1
display: flex
@ -91,59 +164,59 @@
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: ''
@ -153,24 +226,32 @@
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)
margin-right: 0.5rem
.submitter
display: block
font-size: 0.8rem
color: $text-muted
margin-top: 0.5rem
font-style: italic
.versus
font-family: 'Bangers', cursive
font-size: 1.5rem

View File

@ -0,0 +1,54 @@
// Song Form Overlay styles
@import '../colors'
.song-form-overlay
position: fixed
top: 0
left: 0
width: 100%
height: 100%
background-color: rgba(0, 0, 0, 0.75)
z-index: 1000
display: flex
align-items: center
justify-content: center
padding: 2rem
backdrop-filter: blur(5px)
.song-form
background-color: $card-bg
border: 4px solid #000
box-shadow: 8px 8px 0 rgba(0, 0, 0, 0.5)
padding: 2rem
width: 100%
max-width: 600px
max-height: 90vh
overflow-y: auto
position: relative
image-rendering: pixelated
h3
margin-top: 0
color: $primary
font-family: 'Press Start 2P', monospace
text-transform: uppercase
letter-spacing: 1px
text-shadow: 2px 2px 0 #000
margin-bottom: 1.5rem
position: relative
&:after
content: ''
display: block
width: 100%
height: 4px
background-color: $primary
margin-top: 0.75rem
box-shadow: 2px 2px 0 #000
image-rendering: pixelated
.form-actions
display: flex
justify-content: flex-end
gap: 1rem
margin-top: 2rem

View File

@ -17,20 +17,29 @@
h1
margin: 0
color: $primary
text-shadow: 0 0 8px rgba($primary, 0.5)
display: flex
align-items: center
gap: 0.5rem
font-family: 'Press Start 2P', monospace
font-size: 1.8rem
text-transform: uppercase
letter-spacing: 2px
svg
filter: drop-shadow(2px 2px 0 #000)
.songs-counter
background-color: rgba($card-bg, 0.7)
border-radius: 0.5rem
background-color: $card-bg
border: 4px solid #000
box-shadow: 4px 4px 0 #000
padding: 0.75rem 1rem
font-size: 1.1rem
font-size: 1rem
font-family: 'Press Start 2P', monospace
.counter
font-weight: bold
color: $primary
color: $secondary
text-shadow: 1px 1px 0 #000
.submission-content
display: flex
@ -44,11 +53,79 @@
.songs-list
flex: 1.5
background-color: $card-bg
border-radius: 1rem
border: 4px solid #000
box-shadow: 8px 8px 0 #000
padding: 1.5rem
h2
margin-top: 0
color: $secondary
font-family: 'Press Start 2P', monospace
font-size: 1.2rem
text-transform: uppercase
margin-bottom: 1.5rem
text-shadow: 2px 2px 0 #000
&:after
content: ''
display: block
width: 100%
height: 4px
background-color: $secondary
margin-top: 0.5rem
box-shadow: 2px 2px 0 #000
image-rendering: pixelated
.song-item
margin-bottom: 1.5rem
padding: 1rem
background-color: rgba(0, 0, 0, 0.3)
border: 3px solid #000
position: relative
box-shadow: 4px 4px 0 rgba(0, 0, 0, 0.5)
transition: transform 0.2s ease
&:hover
transform: translateX(2px) translateY(-2px)
box-shadow: 6px 6px 0 rgba(0, 0, 0, 0.5)
.song-title
font-weight: bold
font-family: 'Press Start 2P', monospace
font-size: 0.9rem
color: $text
margin-bottom: 0.75rem
text-shadow: 1px 1px 0 #000
letter-spacing: 0.5px
.song-channel
font-size: 0.8rem
color: $text-muted
font-family: 'Press Start 2P', monospace
letter-spacing: 0.5px
text-shadow: 1px 1px 0 rgba(0,0,0,0.5)
.remove-song
position: absolute
top: 0.75rem
right: 0.75rem
background: $danger
color: #fff
border: 2px solid #000
width: 28px
height: 28px
font-family: 'Press Start 2P', monospace
font-size: 0.7rem
display: flex
align-items: center
justify-content: center
cursor: pointer
box-shadow: 2px 2px 0 #000
image-rendering: pixelated
&:hover
transform: translateY(-2px)
box-shadow: 2px 4px 0 #000
display: flex
align-items: center
gap: 0.5rem
@ -62,6 +139,7 @@
.song-card
background-color: rgba(255, 255, 255, 0.05)
border-radius: 0.5rem
box-sizing: border-box
padding: 1rem
position: relative
transition: transform 0.2s, box-shadow 0.2s
@ -112,7 +190,9 @@
cursor: pointer
gap: 0.5rem
transition: all 0.2s
margin-top: 1rem
margin-top: 1.5rem
margin-bottom: 1rem
clear: both
&:hover
background-color: rgba($primary, 0.1)

View File

@ -5,7 +5,7 @@
flex-direction: column
min-height: 100%
padding: 1.5rem
.voting-header
display: flex
justify-content: space-between
@ -13,187 +13,664 @@
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
text-transform: uppercase
font-size: 1.8rem
svg
filter: drop-shadow(2px 2px 0 #000)
.round-info
background-color: rgba($card-bg, 0.7)
border-radius: 0.5rem
background-color: $card-bg
border: 4px solid #000
box-shadow: 4px 4px 0 #000
padding: 0.75rem 1rem
display: flex
align-items: center
gap: 0.75rem
font-family: 'Press Start 2P', monospace
font-size: 0.8rem
span
font-weight: bold
.voted-badge
background-color: $success
color: #fff
border-radius: 1rem
border: 2px solid #000
padding: 0.25rem 0.75rem
font-size: 0.85rem
animation: pulse 2s infinite
.battle-container
font-size: 0.75rem
animation: pixel-pulse 2s infinite
@keyframes pixel-pulse
0%, 100%
transform: scale(1)
opacity: 1
50%
transform: scale(1.1)
opacity: 0.8
.battle-container
display: flex
flex-direction: column
align-items: center
gap: 2rem
margin-bottom: 2rem
perspective: 1000px
.song-card
box-sizing: border-box
width: 100%
max-width: 600px
background-color: $card-bg
border: 4px solid #000
box-shadow: 8px 8px 0 #000
padding: 1rem
transition: all 0.2s ease
position: relative
&.selected
border-color: $primary
box-shadow: 0 0 20px $primary, 8px 8px 0 #000
transform: translateY(-4px)
.song-spotlight
opacity: 1
&.bye-winner
border-color: $accent
box-shadow: 0 0 20px $accent, 8px 8px 0 #000
.song-spotlight
position: absolute
top: 0
left: 0
right: 0
bottom: 0
background: linear-gradient(45deg, transparent 0%, rgba($primary, 0.1) 45%, rgba($primary, 0.4) 50%, rgba($primary, 0.1) 55%, transparent 100%)
pointer-events: none
opacity: 0
transition: opacity 0.3s ease
.song-details
h3
margin-top: 0
margin-bottom: 0.5rem
font-family: 'Press Start 2P', monospace
font-size: 1.1rem
color: $secondary
p
margin-top: 0
margin-bottom: 1rem
font-family: 'Press Start 2P', monospace
font-size: 0.8rem
color: $text
.auto-advance-notice
margin-top: 1rem
padding: 0.75rem
background-color: rgba($accent, 0.2)
border: 2px solid $accent
p
margin: 0
color: $accent
font-weight: bold
text-align: center
.host-info
margin-top: 0.5rem
color: #fff
font-style: italic
font-size: 0.7rem
.video-container
margin-top: 1rem
border: 4px solid #000
position: relative
.video-overlay
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background: repeating-linear-gradient( 0deg, rgba(0, 0, 0, 0.1), rgba(0, 0, 0, 0.1) 2px, transparent 2px, transparent 4px)
pointer-events: none
opacity: 0.4
.no-video
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
justify-content: center
height: 180px
background-color: rgba(0, 0, 0, 0.3)
border: 2px dashed rgba(255, 255, 255, 0.3)
margin-top: 1rem
.pulse-icon
font-size: 2rem
color: $accent
text-shadow: 0 0 10px rgba($accent, 0.5)
display: flex
align-items: center
padding: 0 1rem
color: $text-muted
margin-bottom: 0.5rem
animation: pixel-float 2s infinite
span
color: $text-muted
font-size: 0.9rem
.vote-count
position: absolute
top: 1rem
right: 1rem
background-color: rgba(#000, 0.7)
border: 2px solid $primary
padding: 0.5rem
display: flex
flex-direction: column
align-items: center
.vote-number
font-size: 1.5rem
font-weight: bold
color: $primary
.vote-text
font-size: 0.7rem
color: $text-muted
.selection-indicator
position: absolute
top: 1rem
right: 1rem
background-color: $primary
color: #000
width: 2rem
height: 2rem
display: flex
align-items: center
justify-content: center
border: 2px solid #000
animation: pixel-pulse 1s infinite
.versus
display: flex
flex-direction: column
align-items: center
margin: 0.5rem 0
.versus-text
font-family: 'Press Start 2P', monospace
font-size: 1.5rem
font-weight: bold
color: $accent
letter-spacing: 2px
animation: pixel-flash 1.5s infinite
.versus-decoration
width: 150px
height: 4px
background-color: $accent
margin: 0.5rem 0
position: relative
&:before, &:after
content: ''
position: absolute
width: 15px
height: 15px
background-color: $accent
transform: rotate(45deg)
&:before
left: -5px
top: -5px
&:after
right: -5px
top: -5px
@keyframes pixel-flash
0%, 100%
opacity: 1
50%
opacity: 0.5
@keyframes pixel-float
0%, 100%
transform: translateY(0)
50%
transform: translateY(-5px)
// Voting action buttons area
.voting-actions
display: flex
justify-content: center
flex-direction: column
align-items: center
margin: 2rem 0
.btn
min-width: 180px
font-size: 1rem
&.offline
background-color: $secondary
position: relative
@media (min-width: 768px)
padding: 1rem
.voting-actions
.offline-notice
margin-top: 1rem
padding: 0.5rem
background-color: rgba($secondary, 0.2)
border: 2px solid $secondary
max-width: 400px
text-align: center
font-size: 0.7rem
svg
margin-right: 0.5rem
color: $secondary
.offline-vote-status
margin-top: 1rem
padding: 0.5rem
background-color: rgba($success, 0.2)
border: 2px solid $success
max-width: 400px
text-align: center
font-size: 0.7rem
color: $success
animation: pulse-opacity 2s infinite
&.error
background-color: rgba(#f44336, 0.2)
border-color: #f44336
color: #f44336
// Voting status and information
.voting-status
background-color: $card-bg
border: 4px solid #000
box-shadow: 4px 4px 0 #000
padding: 1rem
margin-top: auto
text-align: center
p
margin: 0 0 1rem 0
font-family: 'Press Start 2P', monospace
font-size: 0.9rem
color: $text
.reconnecting-notice
margin: 0.5rem auto 1rem auto
padding: 0.5rem
background-color: rgba($secondary, 0.2)
border: 2px solid $secondary
max-width: 400px
text-align: center
font-size: 0.7rem
color: $secondary
animation: pulse-opacity 2s infinite
.auto-advance-notice
margin: 1rem auto
max-width: 400px
padding: 0.75rem
background-color: rgba($secondary, 0.2)
border: 2px solid $secondary
text-align: center
color: $text-muted
font-style: italic
.votes-count
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
align-items: center
gap: 0.5rem
font-family: 'Press Start 2P', monospace
font-size: 0.8rem
margin-bottom: 1rem
span
color: $primary
font-weight: bold
span:first-child
font-weight: bold
color: $secondary
.offline-badge
color: $secondary
margin-left: 0.5rem
font-size: 0.7rem
// Player votes list styling
.player-votes
background-color: rgba(0, 0, 0, 0.2)
border: 2px dashed rgba(255, 255, 255, 0.2)
border-radius: 0.5rem
padding: 0.75rem
margin-top: 0.5rem
h4
margin: 0 0 0.75rem 0
font-family: 'Press Start 2P', monospace
font-size: 0.8rem
color: $secondary
position: relative
display: inline-block
&:before, &:after
content: '>'
position: absolute
color: $accent
animation: pixel-blink 1s infinite
&:before
left: -1rem
&:after
right: -1rem
.players-voted-list
list-style: none
padding: 0
margin: 0
display: grid
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr))
gap: 0.5rem
li
padding: 0.5rem
border: 2px solid transparent
font-size: 0.75rem
display: flex
align-items: center
justify-content: space-between
transition: all 0.2s ease
position: relative
overflow: hidden
&.voted
background-color: rgba($success, 0.1)
border-color: rgba($success, 0.5)
.vote-icon
color: $success
margin-left: 0.5rem
filter: drop-shadow(0 0 2px rgba($success, 0.8))
animation: pixel-pulse 1.5s infinite
&:before
content: ''
position: absolute
top: 0
left: 0
width: 100%
height: 100%
background: repeating-linear-gradient(45deg, transparent, transparent 5px, rgba($success, 0.1) 5px, rgba($success, 0.1) 10px)
z-index: -1
&.not-voted
background-color: rgba(255, 255, 255, 0.05)
border-color: rgba(255, 255, 255, 0.1)
opacity: 0.7
&.offline-voted
background-color: rgba($secondary, 0.1)
border-color: rgba($secondary, 0.5)
.offline-icon
color: $secondary
margin-left: 0.5rem
animation: pixel-pulse 1.5s infinite
@keyframes pulse-opacity
0%, 100%
opacity: 1
50%
opacity: 0.5
@keyframes pixel-blink
0%, 100%
opacity: 1
50%
opacity: 0.3
// Bye container for automatic advances
.bye-container
display: flex
justify-content: center
margin: 2rem 0
animation: pixel-float 3s infinite ease-in-out
// Vote Progress Bar
.vote-progress-container
width: 100%
margin-top: 1.5rem
margin-bottom: 2rem
padding: 0 0.5rem
animation: fade-in-up 0.5s ease forwards
position: relative
@keyframes fade-in-up
from
opacity: 0
transform: translateY(10px)
to
opacity: 1
transform: translateY(0)
.vote-progress-labels
display: flex
justify-content: space-between
margin-bottom: 0.5rem
font-size: 0.8rem
font-family: 'Press Start 2P', monospace
.vote-label
padding: 0.5rem 0.75rem
background-color: rgba(0, 0, 0, 0.7)
border: 2px solid #000
transform: translateY(0)
transition: transform 0.3s ease
position: relative
z-index: 2
&.left
color: $primary
border-left-color: $primary
border-top-color: $primary
border-bottom-color: $primary
box-shadow: -3px 3px 0 rgba($primary, 0.3)
&:hover
transform: translateY(-3px)
box-shadow: -5px 5px 0 rgba($primary, 0.5)
&.right
color: $accent
border-right-color: $accent
border-top-color: $accent
border-bottom-color: $accent
box-shadow: 3px 3px 0 rgba($accent, 0.3)
&:hover
transform: translateY(-3px)
box-shadow: 5px 5px 0 rgba($accent, 0.5)
&.winning
background-color: rgba(#000, 0.8)
border-width: 3px
transform: translateY(-3px)
position: relative
z-index: 3
animation: winner-pulse 1.5s infinite alternate
&.left
box-shadow: -5px 5px 0 rgba($primary, 0.7), 0 0 15px rgba($primary, 0.7)
&.right
box-shadow: 5px 5px 0 rgba($accent, 0.7), 0 0 15px rgba($accent, 0.7)
&.landslide
transform: translateY(-5px)
font-weight: bold
animation: winner-landslide-pulse 1s infinite alternate
&.left
box-shadow: -6px 6px 0 rgba($primary, 0.7), 0 0 25px rgba($primary, 0.9)
&.right
box-shadow: 6px 6px 0 rgba($accent, 0.7), 0 0 25px rgba($accent, 0.9)
@keyframes winner-landslide-pulse
from
box-shadow: -6px 6px 0 rgba($primary, 0.7), 0 0 25px rgba($primary, 0.9)
to
box-shadow: -6px 6px 0 rgba($primary, 0.7), 0 0 40px rgba($primary, 1)
.vote-progress-bar
height: 36px
background-color: #222
border: 4px solid #000
position: relative
overflow: hidden
display: flex
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3)
.vote-progress-fill
height: 100%
transition: width 1.2s cubic-bezier(0.34, 1.56, 0.64, 1)
position: relative
min-width: 0
&.song1
background: linear-gradient(to right, darken($primary, 20%), $primary)
box-shadow: 0 8px 24px rgba($primary, 0.3), 0 0 10px rgba($primary, 0.2)
animation: pulse-song1 2s infinite alternate
&.song2
background: linear-gradient(to left, darken($accent, 20%), $accent)
box-shadow: 0 8px 24px rgba($accent, 0.3), 0 0 10px rgba($accent, 0.2)
animation: pulse-song2 2s infinite alternate
.vote-progress-decoration
position: absolute
top: 0
bottom: 0
width: 20px
background-size: 4px 4px
opacity: 0.6
&.left
right: 10px
background-image: linear-gradient(45deg, rgba(255,255,255,0.4) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.4) 50%, rgba(255,255,255,0.4) 75%, transparent 75%, transparent)
animation: move-stripes-left 20s linear infinite
&.right
left: 10px
background-image: linear-gradient(-45deg, rgba(255,255,255,0.4) 25%, transparent 25%, transparent 50%, rgba(255,255,255,0.4) 50%, rgba(255,255,255,0.4) 75%, transparent 75%, transparent)
animation: move-stripes-right 20s linear infinite
&.landslide
border-width: 5px
animation: landslide-border-pulse 1.5s infinite alternate
&.song1
border-color: lighten($primary, 20%)
&.song2
border-color: lighten($accent, 20%)
.vote-progress-divider
position: absolute
top: -8px
bottom: -8px
width: 6px
background-color: #fff
transform: translateX(-50%)
z-index: 10
box-shadow: 0 0 15px rgba(255, 255, 255, 0.7), 0 0 5px rgba(0, 0, 0, 0.5)
transition: left 1.2s cubic-bezier(0.34, 1.56, 0.64, 1)
&:before, &:after
content: ''
position: absolute
left: 50%
width: 12px
height: 12px
background-color: #fff
transform: translateX(-50%) rotate(45deg)
box-shadow: 0 0 10px rgba(255, 255, 255, 0.7), 0 0 3px rgba(0, 0, 0, 0.5)
&:before
top: 0
&:after
bottom: 0
.vote-progress-percentages
display: flex
justify-content: space-between
margin-top: 0.75rem
font-family: 'Press Start 2P', monospace
font-size: 1rem
.vote-percent
font-weight: bold
text-shadow: 2px 2px 0 #000
padding: 0.4rem 0.75rem
background-color: rgba(0, 0, 0, 0.7)
border: 2px solid #000
letter-spacing: 1px
position: relative
&.left
color: $primary
border-left-color: $primary
border-bottom-color: $primary
transform: skew(-10deg)
box-shadow: -3px 3px 0 rgba($primary, 0.4)
&.right
color: $accent
border-right-color: $accent
border-bottom-color: $accent
transform: skew(10deg)
box-shadow: 3px 3px 0 rgba($accent, 0.4)
&.winning
font-size: 1.2rem
font-weight: bolder
animation: winner-text-pulse 2s infinite alternate
&.left
box-shadow: -5px 5px 0 rgba($primary, 0.5), 0 0 10px rgba($primary, 0.5)
&.right
box-shadow: 5px 5px 0 rgba($accent, 0.5), 0 0 10px rgba($accent, 0.5)
&.landslide
font-size: 1.3rem
animation: landslide-text-pulse 1s infinite alternate
&.left
box-shadow: -6px 6px 0 rgba($primary, 0.6), 0 0 15px rgba($primary, 0.6)
&.right
box-shadow: 6px 6px 0 rgba($accent, 0.6), 0 0 15px rgba($accent, 0.6)
@keyframes landslide-text-pulse
from
text-shadow: 2px 2px 0 #000, 0 0 10px currentColor
to
text-shadow: 2px 2px 0 #000, 0 0 25px currentColor, 0 0 5px #fff
// Bye container for automatic advances
.bye-container
display: flex
justify-content: center
margin: 2rem 0
animation: pixel-float 3s infinite ease-in-out

View File

@ -4,8 +4,38 @@
width: 100%
height: 100%
background-color: #000
border-radius: 0.5rem
overflow: hidden
border: 4px solid #000
position: relative
// Pixel art CRT screen effect
&:before
content: ''
position: absolute
top: 0
left: 0
right: 0
bottom: 0
background: linear-gradient(transparent 50%, rgba(0, 0, 0, 0.1) 50%)
background-size: 100% 4px
z-index: 2
pointer-events: none
opacity: 0.4
// Video glow effect
&:after
content: ''
position: absolute
top: -3px
left: -3px
right: -3px
bottom: -3px
background-color: transparent
box-shadow: 0 0 10px rgba($secondary, 0.5)
z-index: -1
pointer-events: none
iframe
border: none
z-index: 1
position: relative

View File

@ -3,99 +3,123 @@
.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)
background: rgba(0, 0, 0, 0.3)
border: 3px solid #000
color: $text
font-size: 1rem
font-family: 'Press Start 2P', monospace
font-size: 0.8rem
box-shadow: 4px 4px 0 #000
&:focus
outline: none
border-color: $primary
box-shadow: 0 0 0 3px rgba($primary, 0.3)
border-color: $secondary
box-shadow: 4px 4px 0 #000, 0 0 8px rgba($secondary, 0.5)
.spinner-icon, .clear-icon
position: absolute
top: 50%
right: 1rem
transform: translateY(-50%)
color: $text-muted
filter: drop-shadow(1px 1px 0 #000)
.clear-icon
cursor: pointer
&:hover
color: $text
transform: translateY(-50%) scale(1.2)
transition: all 0.2s ease
.selected-video
background: rgba($card-bg, 0.6)
border-radius: 0.75rem
background: $card-bg
border: 3px solid #000
box-shadow: 4px 4px 0 #000
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
.external-link
color: $secondary
cursor: pointer
text-decoration: none
font-size: 0.85rem
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
.thumbnail-container
position: relative
width: 120px
height: 90px
object-fit: cover
border-radius: 0.25rem
flex-shrink: 0
.selected-thumbnail
width: 120px
height: 90px
object-fit: cover
border-radius: 0.25rem
.play-overlay
position: absolute
top: 50%
left: 50%
transform: translate(-50%, -50%)
color: white
background: rgba(0, 0, 0, 0.6)
width: 36px
height: 36px
border-radius: 50%
display: flex
align-items: center
justify-content: center
opacity: 0.7
.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
padding-bottom: 56.25%
// 16:9 aspect ratio
height: 0
overflow: hidden
border-radius: 0.5rem
margin-top: 1rem
iframe
position: absolute
top: 0
@ -103,14 +127,19 @@
width: 100%
height: 100%
border-radius: 0.5rem
.search-results
background: rgba($card-bg, 0.6)
background: rgba($card-bg, 0.8)
border-radius: 0.75rem
padding: 1rem
max-height: 400px
max-height: 300px
overflow-y: auto
margin-bottom: 1.5rem
border: 2px solid #000
box-shadow: 4px 4px 0 #000
position: relative
z-index: 10
h4
margin-top: 0
margin-bottom: 0.75rem
@ -118,7 +147,7 @@
font-size: 0.9rem
text-transform: uppercase
letter-spacing: 0.05em
.search-result
display: flex
padding: 0.75rem
@ -127,25 +156,25 @@
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
@ -161,20 +190,20 @@
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

View File

@ -5,12 +5,17 @@
label
display: block
margin-bottom: 0.5rem
margin-bottom: 0.75rem
font-weight: 500
font-family: 'Press Start 2P', monospace
font-size: 0.8rem
color: $secondary
text-transform: uppercase
.required
color: $danger
margin-left: 0.25rem
animation: pixel-flash 1s infinite
input[type="text"],
input[type="number"],
@ -21,31 +26,53 @@
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
background-color: $card-bg
border: 3px solid #000
color: $text
font-size: 1rem
transition: all 0.3s ease
font-family: 'Press Start 2P', monospace
font-size: 0.9rem
transition: all 0.2s 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)
box-shadow: 4px 4px 0 #000
&:focus
border-color: $primary
box-shadow: 0 0 0 2px rgba($primary, 0.25)
box-shadow: 4px 4px 0 #000, 0 0 8px $primary
outline: none
&::placeholder
color: rgba(255, 255, 255, 0.4)
// Pixel art checkbox
input[type="checkbox"]
appearance: none
width: 24px
height: 24px
background-color: $card-bg
border: 3px solid #000
position: relative
cursor: pointer
box-shadow: 3px 3px 0 rgba(0, 0, 0, 0.2)
transition: all 0.2s ease
&:hover
border-color: $secondary
&:checked
background-color: rgba($primary, 0.2)
border-color: $primary
&:after
content: ''
position: absolute
left: 6px
top: 2px
width: 6px
height: 12px
border: solid $primary
border-width: 0 3px 3px 0
transform: rotate(45deg)
&.checkbox
display: flex
@ -68,6 +95,31 @@
gap: 1rem
margin-top: 1.5rem
// Checkbox group with helper text
.checkbox-group
display: flex
flex-direction: column
margin-bottom: 1.5rem
.checkbox-container
display: flex
align-items: center
margin-bottom: 0.5rem
input[type="checkbox"]
margin-right: 0.75rem
min-width: 24px
height: 24px
label
margin-bottom: 0
margin-top: 0
cursor: pointer
font-family: 'Press Start 2P', monospace
font-size: 0.8rem
text-transform: uppercase
color: $secondary
// Buttons
.btn
display: inline-flex

View File

@ -1,6 +1,7 @@
// Main styles for the Song Battle application
@import './colors'
@import './forms'
@import './buttons'
// Component styles
@import './components/home-screen'
@ -8,34 +9,52 @@
@import './components/song-submission-screen'
@import './components/voting-screen'
@import './components/results-screen'
@import './components/battle-result-screen'
@import './components/youtube-embed'
@import './components/youtube-search'
@import './components/song-form-overlay'
@import './components/connection-status'
// Global styles
html, body
margin: 0
padding: 0
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
font-family: 'Press Start 2P', 'Courier New', monospace
background: linear-gradient(45deg, #6600cc 0%, #ff0066 100%) fixed
color: $text
height: 100%
overflow-x: hidden
image-rendering: pixelated
// Pixel art border mixin
@mixin pixel-border($color: $primary, $size: 4px)
border: $size solid $color
box-shadow: 0 0 0 2px darken($color, 30%)
position: relative
#root, .app
height: 100%
width: 100%
display: flex
flex-direction: column
background-image: url('/background.svg')
background-size: 32px 32px
background-repeat: repeat
a
color: $primary
color: $secondary
text-decoration: none
font-weight: bold
text-shadow: 2px 2px 0 #000, -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000
&:hover
text-decoration: underline
color: lighten($secondary, 20%)
transform: scale(1.05)
transition: all 0.2s ease
h1, h2, h3
font-family: 'Bangers', cursive
letter-spacing: 1px
font-family: 'Press Start 2P', 'VT323', monospace
letter-spacing: 2px
text-shadow: 2px 2px 0 #000, -2px -2px 0 #000, 2px -2px 0 #000, -2px 2px 0 #000
// Background elements
.background-elements
@ -346,6 +365,7 @@ h1, h2, h3
margin-bottom: 2rem
.song-card
box-sizing: border-box
background-color: rgba($card-bg, 0.8)
backdrop-filter: blur(10px)
border-radius: 0.75rem
@ -398,6 +418,7 @@ h1, h2, h3
justify-content: center
align-items: center
cursor: pointer
color: white
transition: all 0.2s ease
&:hover

View File

@ -0,0 +1,198 @@
import { useEffect, useState } from 'react';
import { useGame } from '../context/GameContext';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTrophy, faMusic, faCrown, faClock } from '@fortawesome/free-solid-svg-icons';
import YouTubeEmbed from './YouTubeEmbed';
import { getDisplayName } from '../utils/playerUtils';
const BattleResultScreen = () => {
const { lobby, currentPlayer, isHost, proceedToNextBattle } = useGame();
const [countdown, setCountdown] = useState(10); // Auto-advance after 10 seconds
const [showConfetti, setShowConfetti] = useState(true);
// Use previousBattle from the lobby state
const previousBattle = lobby?.previousBattle;
// Extract winning song
const winnerSongId = previousBattle?.winner;
const winningSong = winnerSongId === previousBattle?.song1?.id
? previousBattle?.song1
: previousBattle?.song2;
const losingSong = winnerSongId === previousBattle?.song1?.id
? previousBattle?.song2
: previousBattle?.song1;
const winningVotes = winnerSongId === previousBattle?.song1?.id
? previousBattle?.song1Votes
: previousBattle?.song2Votes;
const losingVotes = winnerSongId === previousBattle?.song1?.id
? previousBattle?.song2Votes
: previousBattle?.song1Votes;
// Get the player who submitted the winning song
const findSubmitter = (song) => {
if (!lobby || !song) return null;
const submitter = lobby.players.find(p => p.id === song.submittedById);
return submitter;
};
const winnerSubmitter = findSubmitter(winningSong);
// YouTube video ID extraction for the winner
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;
};
const winnerVideoId = getYouTubeId(winningSong?.youtubeLink);
// Auto-advance countdown
useEffect(() => {
if (countdown > 0) {
const timer = setInterval(() => {
setCountdown(prev => {
if (prev <= 1) {
clearInterval(timer);
// Only auto-advance if host, otherwise wait for host
if (isHost) {
proceedToNextBattle();
}
return 0;
}
return prev - 1;
});
}, 1000);
return () => clearInterval(timer);
}
}, [countdown, isHost, proceedToNextBattle]);
// Confetti effect
useEffect(() => {
if (showConfetti) {
const createConfetti = () => {
const confettiContainer = document.createElement('div');
confettiContainer.className = 'confetti';
// Random position, size, 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)];
const size = Math.random() * 0.5 + 0.3; // Between 0.3 and 0.8rem
const rotation = Math.random() * 360; // Random rotation
const duration = Math.random() * 2 + 1; // Between 1 and 3 seconds
confettiContainer.style.left = `${left}%`;
confettiContainer.style.backgroundColor = color;
confettiContainer.style.width = `${size}rem`;
confettiContainer.style.height = `${size * 0.6}rem`;
confettiContainer.style.transform = `rotate(${rotation}deg)`;
confettiContainer.style.animation = `confetti-fall ${duration}s linear forwards`;
document.querySelector('.battle-result-screen').appendChild(confettiContainer);
// Remove after animation
setTimeout(() => {
confettiContainer.remove();
}, duration * 1000);
};
// Create confetti pieces
const confettiInterval = setInterval(() => {
for (let i = 0; i < 3; i++) {
createConfetti();
}
}, 300);
// Stop confetti after a few seconds
setTimeout(() => {
clearInterval(confettiInterval);
setShowConfetti(false);
}, 4000);
return () => {
clearInterval(confettiInterval);
};
}
}, [showConfetti]);
if (!previousBattle || !winningSong) {
return (
<div className="battle-result-screen">
<h2>Nächster Kampf wird vorbereitet...</h2>
</div>
);
}
return (
<div className="battle-result-screen">
<header>
<h1><FontAwesomeIcon icon={faTrophy} /> Gewinner dieser Runde</h1>
{countdown > 0 && (
<div className="countdown">
<FontAwesomeIcon icon={faClock} />
Nächster Kampf in {countdown}s
</div>
)}
</header>
<div className="winner-announcement">
<div className="song-cards">
<div className="song-card winner">
<div className="victory-badge">
<FontAwesomeIcon icon={faCrown} /> Gewinner
</div>
<div className="song-info">
<h2>{winningSong.title}</h2>
<p className="artist">{winningSong.artist}</p>
{!lobby?.settings?.hidePlayerNames && winnerSubmitter && (
<p className="submitter">
Eingereicht von: {getDisplayName(winnerSubmitter, lobby, currentPlayer)}
</p>
)}
<div className="vote-count">
<span className="votes">{winningVotes} Stimmen</span>
</div>
</div>
{winnerVideoId ? (
<div className="winner-video">
<YouTubeEmbed videoId={winnerVideoId} autoplay={true} />
</div>
) : (
<div className="no-video">
<FontAwesomeIcon icon={faMusic} className="pulse-icon" />
<span>Kein Video verfügbar</span>
</div>
)}
</div>
{losingSong && (
<div className="song-card loser">
<div className="versus">VS</div>
<div className="song-info">
<h3>{losingSong.title}</h3>
<p className="artist">{losingSong.artist}</p>
<div className="vote-count">
<span className="votes">{losingVotes} Stimmen</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
};
export default BattleResultScreen;

View File

@ -0,0 +1,70 @@
// ConnectionStatus.jsx
import { useState, useEffect } from 'react';
import { useSocket } from '../context/GameContext';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faWifi, faExclamationTriangle, faSpinner } from '@fortawesome/free-solid-svg-icons';
function ConnectionStatus() {
const { socket, isConnected, isReconnecting, reconnectionAttempts } = useSocket();
const [showStatus, setShowStatus] = useState(false);
const [offline, setOffline] = useState(!navigator.onLine);
// Monitor browser's online/offline status
useEffect(() => {
const handleOnline = () => setOffline(false);
const handleOffline = () => setOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
// Always show status when disconnected, hide after successful connection
useEffect(() => {
if (!isConnected || isReconnecting || offline) {
setShowStatus(true);
} else {
// Hide the status indicator after a delay
const timer = setTimeout(() => {
setShowStatus(false);
}, 3000);
return () => clearTimeout(timer);
}
}, [isConnected, isReconnecting, offline]);
if (!showStatus) return null;
let statusClass = 'connection-status';
let icon, message;
if (offline) {
statusClass += ' offline';
icon = <FontAwesomeIcon icon={faExclamationTriangle} />;
message = 'Keine Internetverbindung. Spielstand wird lokal gespeichert.';
} else if (!isConnected) {
statusClass += ' disconnected';
icon = <FontAwesomeIcon icon={faExclamationTriangle} />;
message = 'Verbindung zum Server verloren. Versuche neu zu verbinden...';
} else if (isReconnecting) {
statusClass += ' reconnecting';
icon = <FontAwesomeIcon icon={faSpinner} spin />;
message = `Verbindungsversuch ${reconnectionAttempts}...`;
} else {
statusClass += ' connected';
icon = <FontAwesomeIcon icon={faWifi} />;
message = 'Verbunden mit dem Server';
}
return (
<div className={statusClass}>
<div className="connection-icon">{icon}</div>
<div className="connection-message">{message}</div>
</div>
);
}
export default ConnectionStatus;

View File

@ -30,7 +30,7 @@ const HomeScreen = () => {
<div className="home-screen">
<div className="logo">
<img src="/logo.png" alt="Song Battle Logo" className="logo-image" />
<h1>Song Battle</h1>
<h1>Liedkampf</h1>
</div>
<div className="card">
@ -39,25 +39,25 @@ const HomeScreen = () => {
className={isCreateMode ? 'active' : ''}
onClick={() => setIsCreateMode(true)}
>
<FontAwesomeIcon icon={faPlus} /> Create Game
<FontAwesomeIcon icon={faPlus} /> Erstellen
</button>
<button
className={!isCreateMode ? 'active' : ''}
onClick={() => setIsCreateMode(false)}
>
<FontAwesomeIcon icon={faDoorOpen} /> Join Game
<FontAwesomeIcon icon={faDoorOpen} /> Beitreten
</button>
</div>
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="player-name">Your Name</label>
<label htmlFor="player-name">Dein Name</label>
<input
type="text"
id="player-name"
value={playerName}
onChange={(e) => setPlayerName(e.target.value)}
placeholder="Enter your name"
placeholder="Gib deinen Namen ein"
maxLength={20}
required
/>
@ -65,27 +65,31 @@ const HomeScreen = () => {
{!isCreateMode && (
<div className="form-group">
<label htmlFor="lobby-id">Game Code</label>
<label htmlFor="lobby-id">Spielcode</label>
<input
type="text"
id="lobby-id"
value={lobbyId}
onChange={(e) => setLobbyId(e.target.value.toUpperCase())}
placeholder="Enter 6-letter code"
placeholder="6-stelligen Code eingeben"
maxLength={6}
required
/>
</div>
)}
<button type="submit" className="btn primary">
{isCreateMode ? 'Create New Game' : 'Join Game'}
<button type="submit" className="btn primary pixelated full-width">
{isCreateMode ? 'Neues Spiel erstellen' : 'Spiel beitreten'}
<span className="pixel-corner tl"></span>
<span className="pixel-corner tr"></span>
<span className="pixel-corner bl"></span>
<span className="pixel-corner br"></span>
</button>
</form>
</div>
<footer className="home-footer">
<p>Let your favorite songs battle it out!</p>
<p>ik wes doch och nüsch</p>
</footer>
</div>
);

View File

@ -2,6 +2,7 @@ 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';
import { getDisplayName } from '../utils/playerUtils';
const LobbyScreen = () => {
const { lobby, currentPlayer, isHost, updateSettings, startGame, leaveLobby } = useGame();
@ -9,7 +10,7 @@ const LobbyScreen = () => {
const [settings, setSettings] = useState({
songsPerPlayer: 4,
maxPlayers: 10,
requireYoutubeLinks: true
hidePlayerNames: false
});
const [copied, setCopied] = useState(false);
@ -51,24 +52,26 @@ const LobbyScreen = () => {
return (
<div className="lobby-screen">
<header className="lobby-header">
<h1>Game Lobby</h1>
<h1>Spiellobby</h1>
<div className="lobby-code">
<p>Game Code: <span className="code">{lobby.id}</span></p>
<p>Spielcode: <span className="code">{lobby.id}</span></p>
<button className="btn icon" onClick={handleCopyCode}>
<FontAwesomeIcon icon={faCopy} />
{copied ? 'Copied!' : 'Copy'}
{copied ? 'Kopiert!' : 'Kopieren'}
</button>
</div>
</header>
<div className="lobby-content">
<div className="players-list">
<h2><FontAwesomeIcon icon={faUsers} /> Players ({lobby.players.length})</h2>
<h2><FontAwesomeIcon icon={faUsers} /> Spieler ({lobby.players.length})</h2>
<ul>
{lobby.players.map(player => (
{lobby.players.map((player, index) => (
<li key={player.id} className={`player ${!player.isConnected ? 'disconnected' : ''} ${player.id === lobby.hostId ? 'host' : ''}`}>
{player.name} {player.id === lobby.hostId && '(Host)'} {player.id === currentPlayer.id && '(You)'}
{!player.isConnected && <span className="status-disconnected">(Disconnected)</span>}
{getDisplayName(player, lobby, currentPlayer, index)}
{player.id === lobby.hostId && ' (Gastgeber)'}
{player.id === currentPlayer.id && !lobby.settings.hidePlayerNames && ' (Du)'}
{!player.isConnected && <span className="status-disconnected">(Getrennt)</span>}
</li>
))}
</ul>
@ -76,14 +79,14 @@ const LobbyScreen = () => {
<div className="lobby-info">
<div className="settings-preview">
<h3><FontAwesomeIcon icon={faGear} /> Game Settings</h3>
<p>Songs per player: {settings.songsPerPlayer}</p>
<p>Max players: {settings.maxPlayers}</p>
<p>YouTube links required: {settings.requireYoutubeLinks ? 'Yes' : 'No'}</p>
<h3><FontAwesomeIcon icon={faGear} /> Spieleinstellungen</h3>
<p>Lieder pro Spieler: {settings.songsPerPlayer}</p>
<p>Maximale Spieler: {settings.maxPlayers}</p>
<p>Spielernamen verbergen: {settings.hidePlayerNames ? 'Ja' : 'Nein'}</p>
{isHost && (
<button className="btn secondary" onClick={() => setShowSettings(true)}>
Edit Settings
Einstellungen ändern
</button>
)}
</div>
@ -95,16 +98,16 @@ const LobbyScreen = () => {
onClick={handleStartGame}
disabled={lobby.players.length < lobby.settings.minPlayers}
>
<FontAwesomeIcon icon={faPlay} /> Start Game
<FontAwesomeIcon icon={faPlay} /> Spiel starten
</button>
)}
<button className="btn danger" onClick={leaveLobby}>
<FontAwesomeIcon icon={faSignOutAlt} /> Leave Lobby
<FontAwesomeIcon icon={faSignOutAlt} /> Lobby verlassen
</button>
</div>
{lobby.players.length < lobby.settings.minPlayers && isHost && (
<p className="warning">Need at least {lobby.settings.minPlayers} players to start</p>
<p className="warning">Mindestens {lobby.settings.minPlayers} Spieler werden benötigt</p>
)}
</div>
</div>
@ -112,10 +115,10 @@ const LobbyScreen = () => {
{showSettings && (
<div className="modal-overlay">
<div className="modal">
<h2>Game Settings</h2>
<h2>Spieleinstellungen</h2>
<div className="form-group">
<label htmlFor="songsPerPlayer">Songs per player</label>
<label htmlFor="songsPerPlayer">Lieder pro Spieler</label>
<input
type="number"
id="songsPerPlayer"
@ -128,7 +131,7 @@ const LobbyScreen = () => {
</div>
<div className="form-group">
<label htmlFor="maxPlayers">Maximum players</label>
<label htmlFor="maxPlayers">Maximale Spieleranzahl</label>
<input
type="number"
id="maxPlayers"
@ -140,25 +143,25 @@ const LobbyScreen = () => {
/>
</div>
<div className="form-group checkbox">
<label htmlFor="requireYoutubeLinks">
<div className="form-group checkbox-group">
<div className="checkbox-container">
<input
type="checkbox"
id="requireYoutubeLinks"
name="requireYoutubeLinks"
checked={settings.requireYoutubeLinks}
id="hidePlayerNames"
name="hidePlayerNames"
checked={settings.hidePlayerNames}
onChange={handleSettingsChange}
/>
Require YouTube links
</label>
<label htmlFor="hidePlayerNames">Spielernamen verbergen</label>
</div>
</div>
<div className="modal-actions">
<button className="btn secondary" onClick={() => setShowSettings(false)}>
Cancel
Abbrechen
</button>
<button className="btn primary" onClick={handleSaveSettings}>
Save Settings
Mach rin
</button>
</div>
</div>

View File

@ -3,48 +3,56 @@ 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';
import { getDisplayName } from '../utils/playerUtils';
const ResultsScreen = () => {
const { lobby, currentPlayer, leaveLobby } = useGame();
const [showBattleHistory, setShowBattleHistory] = useState(false);
const [confetti, setConfetti] = useState(true);
// Winner information
// Gewinner-Informationen
const winner = lobby?.finalWinner;
const winnerVideoId = getYouTubeId(winner?.youtubeLink);
// Confetti effect for winner celebration
// Konfetti-Effekt für die Siegerfeier
useEffect(() => {
if (confetti) {
// Create confetti animation
// Konfetti-Animation erstellen
const createConfetti = () => {
const confettiContainer = document.createElement('div');
confettiContainer.className = 'confetti';
// Random position and color
// Zufällige Position, Größe und Farbe
const left = Math.random() * 100;
const colors = ['#f94144', '#f3722c', '#f8961e', '#f9c74f', '#90be6d', '#43aa8b', '#577590'];
const color = colors[Math.floor(Math.random() * colors.length)];
const size = Math.random() * 0.7 + 0.3; // Zwischen 0.3 und 1rem
const rotation = Math.random() * 360; // Zufällige Rotation
const duration = Math.random() * 3 + 2; // Zwischen 2 und 5 Sekunden
confettiContainer.style.left = `${left}%`;
confettiContainer.style.backgroundColor = color;
confettiContainer.style.width = `${size}rem`;
confettiContainer.style.height = `${size * 0.6}rem`;
confettiContainer.style.transform = `rotate(${rotation}deg)`;
confettiContainer.style.animation = `confetti-fall ${duration}s linear forwards`;
document.querySelector('.results-screen').appendChild(confettiContainer);
// Remove after animation
// Nach Animation entfernen
setTimeout(() => {
confettiContainer.remove();
}, 5000);
}, duration * 1000);
};
// Create multiple confetti pieces
// Mehrere Konfetti-Stücke erstellen
const confettiInterval = setInterval(() => {
for (let i = 0; i < 3; i++) {
for (let i = 0; i < 5; i++) {
createConfetti();
}
}, 300);
}, 200);
// Stop confetti after some time
// Konfetti nach einiger Zeit stoppen
setTimeout(() => {
clearInterval(confettiInterval);
setConfetti(false);
@ -56,7 +64,7 @@ const ResultsScreen = () => {
}
}, [confetti]);
// Get YouTube video ID from link
// YouTube-Video-ID aus Link extrahieren
function getYouTubeId(url) {
if (!url) return null;
@ -66,25 +74,38 @@ const ResultsScreen = () => {
return (match && match[2].length === 11) ? match[2] : null;
}
// Find the submitter from the player list
const findSubmitter = (submitterId) => {
if (!lobby || !lobby.players) return null;
return lobby.players.find(player => player.id === submitterId);
};
if (!winner) {
return (
<div className="results-screen">
<h2>Calculating results...</h2>
<h2>Ergebnisse werden berechnet...</h2>
</div>
);
}
// Find the player who submitted the winning song
const submitter = findSubmitter(winner.submittedBy);
return (
<div className="results-screen">
<header>
<h1><FontAwesomeIcon icon={faTrophy} /> Winner!</h1>
<h1><FontAwesomeIcon icon={faTrophy} /> Gewinner!</h1>
</header>
<div className="winner-card">
<div className="winner-info">
<h2>{winner.title}</h2>
<h3>by {winner.artist}</h3>
<p className="submitter">Submitted by: {winner.submittedByName}</p>
<h3>von {winner.artist}</h3>
<p className="submitter">
Eingereicht von: {submitter
? getDisplayName(submitter, lobby, currentPlayer)
: (lobby?.settings?.hidePlayerNames ? 'Anonymer Spieler' : winner.submittedByName)}
</p>
</div>
{winnerVideoId ? (
@ -104,28 +125,35 @@ const ResultsScreen = () => {
onClick={() => setShowBattleHistory(!showBattleHistory)}
>
<FontAwesomeIcon icon={faChartLine} />
{showBattleHistory ? 'Hide Battle History' : 'Show Battle History'}
{showBattleHistory ? 'Kampfverlauf ausblenden' : 'Kampfverlauf anzeigen'}
</button>
<button className="btn primary" onClick={leaveLobby}>
<FontAwesomeIcon icon={faHome} /> Return to Home
<FontAwesomeIcon icon={faHome} /> Zurück zum Start
</button>
</div>
{showBattleHistory && (
<div className="battle-history">
<h3>Battle History</h3>
<h3>Kampfverlauf</h3>
<div className="battles-list">
{lobby?.battles?.map((battle, index) => {
const isWinner = (battle.song1.id === battle.winner);
const song1VideoId = getYouTubeId(battle.song1.youtubeLink);
const song2VideoId = getYouTubeId(battle.song2.youtubeLink);
const song1VideoId = getYouTubeId(battle.song1?.youtubeLink);
const song2VideoId = battle.song2 ? getYouTubeId(battle.song2.youtubeLink) : null;
// Find the players who submitted the songs
const song1Submitter = findSubmitter(battle.song1?.submittedBy);
const song2Submitter = battle.song2 ? findSubmitter(battle.song2?.submittedBy) : null;
// Freilos-Runden behandeln
const isByeRound = battle.bye === true || !battle.song2;
return (
<div key={index} className="battle-item">
<div className="battle-header">
<h4>Round {battle.round + 1}, Battle {index + 1}</h4>
<h4>Runde {battle.round + 1}, Kampf {index + 1} {isByeRound ? "(Automatisches Weiterkommen)" : ""}</h4>
</div>
<div className="battle-songs">
@ -133,19 +161,42 @@ const ResultsScreen = () => {
<div className="song-info">
<h5>{battle.song1.title}</h5>
<p>{battle.song1.artist}</p>
<span className="votes">{battle.song1Votes} votes</span>
<span className="votes">{battle.song1Votes} Stimmen</span>
{!lobby?.settings?.hidePlayerNames && (
<span className="submitter">
Eingereicht von: {song1Submitter
? getDisplayName(song1Submitter, lobby, currentPlayer)
: battle.song1?.submittedByName || 'Unbekannt'}
</span>
)}
</div>
</div>
<div className="versus">VS</div>
<div className="versus">{isByeRound ? 'FREILOS' : 'VS'}</div>
<div className={`battle-song ${!isWinner ? 'winner' : ''}`}>
<div className="song-info">
<h5>{battle.song2.title}</h5>
<p>{battle.song2.artist}</p>
<span className="votes">{battle.song2Votes} votes</span>
{battle.song2 ? (
<div className={`battle-song ${!isWinner ? 'winner' : ''}`}>
<div className="song-info">
<h5>{battle.song2.title}</h5>
<p>{battle.song2.artist}</p>
<span className="votes">{battle.song2Votes} Stimmen</span>
{!lobby?.settings?.hidePlayerNames && (
<span className="submitter">
Eingereicht von: {song2Submitter
? getDisplayName(song2Submitter, lobby, currentPlayer)
: battle.song2?.submittedByName || 'Unbekannt'}
</span>
)}
</div>
</div>
</div>
) : (
<div className="battle-song bye">
<div className="song-info">
<h5>Automatisches Freilos</h5>
<p>Kein Gegner</p>
</div>
</div>
)}
</div>
</div>
);

View File

@ -2,9 +2,10 @@ 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';
import { getDisplayName } from '../utils/playerUtils';
const SongSubmissionScreen = () => {
const { lobby, currentPlayer, addSong, removeSong, setPlayerReady, searchYouTube } = useGame();
const { lobby, currentPlayer, addSong, removeSong, setPlayerReady, searchYouTube, getYouTubeMetadata } = useGame();
const [songs, setSongs] = useState([]);
const [isReady, setIsReady] = useState(false);
const [songForm, setSongForm] = useState({
@ -21,7 +22,7 @@ const SongSubmissionScreen = () => {
useEffect(() => {
if (lobby) {
// Find current player's songs
// Finde den aktuellen Spieler und seine Lieder
const player = lobby.players.find(p => p.id === currentPlayer.id);
if (player) {
setIsReady(player.isReady);
@ -29,46 +30,46 @@ const SongSubmissionScreen = () => {
}
}, [lobby, currentPlayer]);
// Get player's songs from the server
// Hole die Lieder des Spielers vom 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 })));
console.log('Lieder für Spieler abrufen:', currentPlayer);
console.log('Alle Spieler in der Lobby:', lobby.players.map(p => ({ id: p.id, name: p.name })));
// Find the current player by their ID, name, or socket ID
// Finde den aktuellen Spieler anhand seiner ID, Name oder Socket-ID
let player = lobby.players.find(p => p.id === currentPlayer.id);
// If not found by ID, try by name as fallback
// Falls nicht gefunden, versuche es mit dem Namen als Fallback
if (!player) {
player = lobby.players.find(p => p.name === currentPlayer.name);
console.log('Player not found by ID, trying by name. Found:', player);
console.log('Spieler nicht über ID gefunden, versuche es mit Namen. Gefunden:', player);
}
// If player found and has songs, update the state
// Wenn Spieler gefunden und Lieder vorhanden, aktualisiere den Zustand
if (player) {
console.log('Found player:', player);
console.log('Spieler gefunden:', 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);
console.log('Spieler-Lieder gefunden für', player.name, ':', player.songs.length);
console.log('Lieder-Daten:', player.songs);
setSongs(player.songs || []);
} else {
console.log('No songs array for player:', player);
console.log('Kein Lieder-Array für Spieler:', player);
setSongs([]);
}
} else {
console.error('Player not found in lobby! Current player:', currentPlayer.id);
console.log('Available players:', lobby.players.map(p => p.id));
console.error('Spieler nicht in Lobby gefunden! Aktueller Spieler:', currentPlayer.id);
console.log('Verfügbare Spieler:', lobby.players.map(p => p.id));
setSongs([]);
}
}
};
fetchPlayerSongs();
// Include lobby in the dependency array to ensure this runs when the lobby state changes
// Wichtig: Dieser Effekt sollte ausgeführt werden, wenn sich der Lobby-Zustand ändert
}, [lobby, currentPlayer]);
// Extract video ID from YouTube URL
// Extrahiere Video-ID aus YouTube-URL
const extractVideoId = (url) => {
if (!url) return null;
@ -88,7 +89,7 @@ const SongSubmissionScreen = () => {
return null;
};
// Debounced search function
// Verzögerte Suchfunktion
const handleSearch = async (query) => {
if (!query.trim()) {
setSearchResults([]);
@ -100,7 +101,7 @@ const SongSubmissionScreen = () => {
const results = await searchYouTube(query);
setSearchResults(results || []);
} catch (error) {
console.error('Search failed:', error);
console.error('Suche fehlgeschlagen:', error);
} finally {
setIsSearching(false);
}
@ -112,23 +113,45 @@ const SongSubmissionScreen = () => {
if (name === 'searchQuery') {
setSearchQuery(value);
// Check if the input might be a YouTube link
// Prüfe, ob die Eingabe ein YouTube-Link sein könnte
const videoId = extractVideoId(value);
if (videoId) {
setSongForm({ youtubeLink: value });
setSelectedVideo({
// Setze grundlegende Informationen sofort für eine reaktionsschnelle Benutzeroberfläche
const basicVideoInfo = {
id: videoId,
url: value,
thumbnail: `https://img.youtube.com/vi/${videoId}/mqdefault.jpg`
});
setSearchResults([]); // Clear any existing search results
};
setSelectedVideo(basicVideoInfo);
setSearchResults([]); // Lösche bestehende Suchergebnisse
// Hole zusätzliche Metadaten vom Server
try {
setIsLoadingMetadata(true);
const metadata = await getYouTubeMetadata(videoId);
if (metadata) {
// Aktualisiere ausgewähltes Video mit vollständigen Metadaten
setSelectedVideo({
...basicVideoInfo,
title: metadata.title || 'Unbekannter Titel',
artist: metadata.artist || 'Unbekannter Künstler',
thumbnail: metadata.thumbnail || basicVideoInfo.thumbnail
});
}
} catch (error) {
console.error('Fehler beim Abrufen der Video-Metadaten:', error);
} finally {
setIsLoadingMetadata(false);
}
} else if (value.trim()) {
// Clear any previous timeout
// Lösche vorherigen Timeout
if (searchTimeout.current) {
clearTimeout(searchTimeout.current);
}
// Set a new timeout to prevent too many API calls
// Setze einen neuen Timeout, um zu viele API-Aufrufe zu vermeiden
searchTimeout.current = setTimeout(() => handleSearch(value), 500);
} else {
setSearchResults([]);
@ -137,7 +160,7 @@ const SongSubmissionScreen = () => {
}
};
// Function to toggle video preview
// Funktion zum Umschalten der Videovorschau
const togglePreview = (result) => {
if (selectedVideo && selectedVideo.id === result.id) {
setPreviewVisible(!previewVisible);
@ -148,81 +171,100 @@ const SongSubmissionScreen = () => {
};
const handleSelectSearchResult = (result) => {
// Make sure we have a complete result with all required fields
// Stelle sicher, dass wir ein vollständiges Ergebnis mit allen erforderlichen Feldern haben
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',
title: result.title || 'Unbekannter Titel',
artist: result.artist || 'Unbekannter Künstler',
thumbnail: result.thumbnail || `https://img.youtube.com/vi/${result.id}/mqdefault.jpg`
};
// When a search result is selected, store the YouTube URL
// Wenn ein Suchergebnis ausgewählt wird, speichere die YouTube-URL
setSongForm({
youtubeLink: completeResult.url
});
// Store the selected video with all necessary data for submission
// Speichere das ausgewählte Video mit allen notwendigen Daten zur Übermittlung
setSelectedVideo(completeResult);
// Keep the search results visible but update the query field
// Behalte die Suchergebnisse sichtbar, aber aktualisiere das Abfragefeld
setSearchQuery(completeResult.title);
};
const handleAddSong = (e) => {
e.preventDefault();
// Use selected video data if available, otherwise fallback to search query or direct input
// Verwende die Daten des ausgewählten Videos, wenn verfügbar, sonst Fallback auf Suchanfrage oder direkte Eingabe
let songData;
// Erzeuge ein konsistentes ID-Format, das später für die Löschung funktioniert
const generateSongId = () => `song_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (selectedVideo) {
// We have a selected video with full details - use all available metadata
// Wir haben ein ausgewähltes Video mit vollständigen Details - verwende alle verfügbaren Metadaten
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
id: generateSongId() // Konsistentes ID-Format
};
console.log("Adding song with full metadata:", songData);
console.log("Füge Lied mit vollständigen Metadaten hinzu:", songData);
} else {
// Extract YouTube URL from search query or direct input
// Extrahiere YouTube-URL aus Suchanfrage oder direkter Eingabe
const youtubeLink = searchQuery.trim() || songForm.youtubeLink.trim();
if (!youtubeLink) return;
// Extract video ID to check if it's a valid YouTube link
// Extrahiere Video-ID, um zu überprüfen, ob es sich um einen gültigen YouTube-Link handelt
const videoId = extractVideoId(youtubeLink);
if (videoId) {
// It's a YouTube link, send it to the server for metadata resolution
// Es ist ein YouTube-Link, sende ihn zur Metadaten-Auflösung an den Server
songData = {
youtubeLink: youtubeLink,
// Include the videoId to make server-side processing easier
// Füge die videoId hinzu, um die serverseitige Verarbeitung zu erleichtern
videoId: videoId,
id: `song_${Date.now()}_${Math.random().toString(36).substr(2, 9)}` // Generate a unique ID
id: generateSongId() // Konsistentes ID-Format
};
console.log("Adding song with YouTube link:", songData);
console.log("Füge Lied mit YouTube-Link hinzu:", songData);
} else {
// Not a YouTube link - treat as a search query
alert("Please enter a valid YouTube link or select a search result");
// Kein YouTube-Link - behandle als Suchanfrage
alert("Bitte gib einen gültigen YouTube-Link ein oder wähle ein Suchergebnis aus");
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);
// Füge das Lied hinzu und aktualisiere die UI, wenn der Server antwortet
// Wir verwenden den Promise-basierten Ansatz, um auf die vom Server generierte ID für die ordnungsgemäße Löschung zu warten
// Füge das Lied mit Callback hinzu, um Lieder zu aktualisieren, wenn der Server antwortet
addSong(songData)
.then((updatedLobby) => {
console.log('Lied erfolgreich hinzugefügt, aktualisierte Lobby erhalten:', updatedLobby);
if (updatedLobby && currentPlayer) {
const player = updatedLobby.players.find(p => p.id === currentPlayer.id);
if (player && player.songs) {
console.log('Setze Lieder aus addSong-Antwort:', player.songs);
setSongs([...player.songs]); // Erstelle ein neues Array, um die Zustandsaktualisierung sicherzustellen
} else {
console.warn('Spieler oder Lieder nicht in aktualisierter Lobby gefunden');
}
} else {
console.warn('Fehlende Lobby oder currentPlayer in der Antwort');
}
})
.catch(error => {
console.error('Fehler beim Hinzufügen des Liedes:', error);
});
// Formular zurücksetzen
setSongForm({ youtubeLink: '' });
setSearchQuery('');
setSearchResults([]);
setSelectedVideo(null);
setIsFormVisible(false);
};
const handleRemoveSong = (songId) => {
@ -236,10 +278,10 @@ const SongSubmissionScreen = () => {
}
};
// Check if we've submitted enough songs
// Prüfe, ob wir genügend Lieder eingereicht haben
const canSubmitMoreSongs = lobby && songs.length < lobby.settings.songsPerPlayer;
// Extract YouTube video ID from various YouTube URL formats
// Extrahiere YouTube-Video-ID aus verschiedenen YouTube-URL-Formaten
const getYoutubeVideoId = (url) => {
if (!url) return null;
@ -258,7 +300,7 @@ const SongSubmissionScreen = () => {
return null;
};
// Get YouTube thumbnail URL from video URL
// Hole YouTube-Thumbnail-URL aus Video-URL
const getYoutubeThumbnail = (url) => {
const videoId = getYoutubeVideoId(url);
return videoId ? `https://img.youtube.com/vi/${videoId}/mqdefault.jpg` : null;
@ -267,11 +309,11 @@ const SongSubmissionScreen = () => {
return (
<div className="song-submission-screen">
<header className="screen-header">
<h1>Submit Your Songs</h1>
<h1>Füge deine Lieder hinzu</h1>
<p className="status">
{isReady
? 'Waiting for other players...'
: `Submit ${lobby?.settings?.songsPerPlayer || 0} songs to battle`}
? 'Warte auf andere Spieler...'
: `Füge ${lobby?.settings?.songsPerPlayer || 0} Lieder für den Kampf hinzu`}
</p>
</header>
@ -282,7 +324,7 @@ const SongSubmissionScreen = () => {
style={{ width: `${(songs.length / (lobby?.settings?.songsPerPlayer || 1)) * 100}%` }}
></div>
</div>
<p>{songs.length} / {lobby?.settings?.songsPerPlayer || 0} songs</p>
<p>{songs.length} / {lobby?.settings?.songsPerPlayer || 0} Lieder</p>
</div>
<div className="songs-list">
@ -311,28 +353,33 @@ const SongSubmissionScreen = () => {
{canSubmitMoreSongs && !isFormVisible && !isReady && (
<button
className="add-song-btn"
className="btn add-song-btn"
onClick={() => setIsFormVisible(true)}
>
<FontAwesomeIcon icon={faPlus} />
<span>Add Song</span>
<FontAwesomeIcon icon={faPlus} className="icon-margin" />
<span>Lied hinzufügen</span>
<span className="pixel-corner tl"></span>
<span className="pixel-corner tr"></span>
<span className="pixel-corner bl"></span>
<span className="pixel-corner br"></span>
</button>
)}
</div>
{isFormVisible && (
<form className="song-form" onSubmit={handleAddSong}>
<h3>Add a Song</h3>
<div className="form-group search-group">
<label>Find a song</label>
<div className="song-form-overlay">
<form className="song-form" onSubmit={handleAddSong}>
<h3>Lied hinzufügen</h3>
<div className="form-group search-group">
<label>Lied suchen</label>
<div className="search-container">
<input
type="text"
name="searchQuery"
value={searchQuery}
onChange={handleInputChange}
placeholder="Search YouTube or paste a link..."
placeholder="YouTube durchsuchen oder Link einfügen..."
className="search-input"
/>
{isSearching && (
@ -352,18 +399,18 @@ const SongSubmissionScreen = () => {
)}
</div>
{/* Show selected video without embedded player */}
{/* Zeige ausgewähltes Video ohne eingebetteten Player */}
{selectedVideo && (
<div className="selected-video">
<div className="preview-header">
<h4>Selected Song</h4>
<h4>Ausgewähltes Lied</h4>
<a
href={selectedVideo.url}
target="_blank"
rel="noopener noreferrer"
className="external-link"
>
<FontAwesomeIcon icon={faExternalLinkAlt} /> View on YouTube
<FontAwesomeIcon icon={faExternalLinkAlt} /> Auf YouTube ansehen
</a>
</div>
@ -379,8 +426,8 @@ const SongSubmissionScreen = () => {
</div>
</div>
<div className="selected-info">
<h4>{selectedVideo.title || 'Unknown Title'}</h4>
<p>{selectedVideo.artist || 'Unknown Artist'}</p>
<h4>{selectedVideo.title || 'Unbekannter Titel'}</h4>
<p>{selectedVideo.artist || 'Unbekannter Künstler'}</p>
</div>
</div>
</div>
@ -388,7 +435,7 @@ const SongSubmissionScreen = () => {
{searchResults.length > 0 && (
<div className="search-results">
<h4>Search Results</h4>
<h4>Suchergebnisse</h4>
{searchResults.map(result => (
<div
key={result.id}
@ -408,8 +455,8 @@ const SongSubmissionScreen = () => {
</div>
</div>
<div className="result-info">
<h4>{result.title || 'Unknown Title'}</h4>
<p>{result.artist || 'Unknown Artist'}</p>
<h4>{result.title || 'Unbekannter Titel'}</h4>
<p>{result.artist || 'Unbekannter Künstler'}</p>
</div>
</div>
))}
@ -423,17 +470,18 @@ const SongSubmissionScreen = () => {
setSearchQuery('');
setSearchResults([]);
}}>
Cancel
Abbrechen
</button>
<button
type="submit"
className="btn primary"
disabled={!searchQuery.trim() && !songForm.youtubeLink.trim()}
>
Add Song
Lied hinzufügen
</button>
</div>
</form>
</div>
)}
<div className="action-buttons">
@ -442,22 +490,22 @@ const SongSubmissionScreen = () => {
className="btn primary"
onClick={handleSetReady}
>
<FontAwesomeIcon icon={faCheck} /> Ready to Battle
<FontAwesomeIcon icon={faCheck} /> Kampfbereit
</button>
)}
</div>
{isReady && (
<div className="waiting-message">
<h3>Ready to Battle!</h3>
<p>Waiting for other players to submit their songs...</p>
<h3>Kampfbereit!</h3>
<p>Warte auf andere Spieler, die ihre Lieder einreichen...</p>
<div className="player-status">
<h4>Players Ready</h4>
<h4>Spieler bereit</h4>
<ul className="players-ready-list">
{lobby && lobby.players.map(player => (
{lobby && lobby.players.map((player, index) => (
<li key={player.id} className={player.isReady ? 'ready' : 'not-ready'}>
{player.name} {player.id === currentPlayer.id && '(You)'}
{getDisplayName(player, lobby, currentPlayer, index)} {player.id === currentPlayer.id && lobby.settings.hidePlayerNames && '(Du)'}
{player.isReady ? (
<FontAwesomeIcon icon={faCheck} className="ready-icon" />
) : (

View File

@ -1,45 +1,149 @@
// VotingScreen.jsx
import { useState, useEffect } from 'react';
import { useState, useEffect, useMemo } from 'react';
import { useGame } from '../context/GameContext';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faVoteYea, faTrophy, faMusic } from '@fortawesome/free-solid-svg-icons';
import { faVoteYea, faTrophy, faMusic, faCheck, faMedal, faCrown, faExclamationTriangle } from '@fortawesome/free-solid-svg-icons';
import YouTubeEmbed from './YouTubeEmbed';
import { getDisplayName } from '../utils/playerUtils';
function VotingScreen() {
const { lobby, currentPlayer, submitVote } = useGame();
const { lobby, currentPlayer, submitVote, isHost, isOffline, isReconnecting } = useGame();
const [hasVoted, setHasVoted] = useState(false);
const [selectedSong, setSelectedSong] = useState(null);
const [countdown, setCountdown] = useState(null);
// Get current battle
const [processingByeAdvance, setProcessingByeAdvance] = useState(false);
const [offlineVoteStatus, setOfflineVoteStatus] = useState(null);
// Hole aktuellen Kampf
const battle = lobby?.currentBattle || null;
// Check if player has already voted
// Berechne die Turnierphase basierend auf der Rundennummer und Gesamtliedern
const tournamentPhase = useMemo(() => {
if (!lobby || !battle) return '';
// Hole Gesamtanzahl der Lieder im Turnier
const totalSongs = lobby.songs?.length || 0;
if (totalSongs === 0) return 'Vorrunden';
// Berechne die insgesamt benötigten Runden für das Turnier
const totalRounds = Math.ceil(Math.log2(totalSongs));
const currentRound = battle.round + 1;
const roundsRemaining = totalRounds - currentRound;
if (roundsRemaining === 0) return 'Finale';
if (roundsRemaining === 1) return 'Halbfinale';
if (roundsRemaining === 2) return 'Viertelfinale';
return 'Vorrunde';
}, [lobby, battle]);
// Berechne das Verhältnis der Stimmen für den Fortschrittsbalken
const voteRatio = useMemo(() => {
if (!battle || !hasVoted) return { song1Percent: 50, song2Percent: 50 };
const totalVotes = (battle.song1Votes || 0) + (battle.song2Votes || 0);
if (totalVotes === 0) return { song1Percent: 50, song2Percent: 50 };
const song1Percent = Math.round((battle.song1Votes / totalVotes) * 100);
const song2Percent = 100 - song1Percent;
// Determine winner (for styling purposes)
const winner = song1Percent > song2Percent ? 'song1' : song1Percent < song2Percent ? 'song2' : 'tie';
// Calculate margin of victory for animation intensity
const margin = Math.abs(song1Percent - song2Percent);
const isLandslide = margin >= 30;
return { song1Percent, song2Percent, winner, totalVotes, isLandslide, margin };
}, [battle, hasVoted]);
// Prüfe, ob der Spieler bereits abgestimmt hat
useEffect(() => {
if (battle && battle.votes && currentPlayer) {
// Check if player's ID exists in votes map
setHasVoted(battle.votes.has(currentPlayer.id));
// Prüfe, ob die ID des Spielers im Stimmen-Objekt existiert
setHasVoted(
Object.prototype.hasOwnProperty.call(battle.votes, currentPlayer.id) ||
battle.votes[currentPlayer.id] !== undefined
);
} else {
setHasVoted(false);
}
}, [battle, currentPlayer]);
// Handle vote selection
// Behandle Stimmenwahl
const handleVoteSelect = (songId) => {
if (hasVoted) return;
setSelectedSong(songId);
};
// Submit final vote
const handleSubmitVote = () => {
// Sende endgültige Stimme
const handleSubmitVote = async () => {
if (!selectedSong || hasVoted) return;
submitVote(selectedSong);
setHasVoted(true);
try {
// If offline, show status but still try to submit (it will be queued)
if (isOffline) {
setOfflineVoteStatus('pending');
}
await submitVote(selectedSong);
if (isOffline) {
// In offline mode, optimistically update UI
setHasVoted(true);
setOfflineVoteStatus('queued');
// Store vote locally for later sync
try {
const savedVotes = JSON.parse(localStorage.getItem('pendingVotes') || '{}');
if (battle) {
savedVotes[`battle_${battle.round}_${battle.song1.id}`] = {
songId: selectedSong,
battleRound: battle.round,
timestamp: Date.now()
};
localStorage.setItem('pendingVotes', JSON.stringify(savedVotes));
}
} catch (e) {
console.error("Fehler beim Speichern der Offline-Stimme:", e);
}
}
// setHasVoted wird jetzt durch den useEffect behandelt, der die Stimmen prüft
} catch (error) {
console.error("Fehler bei der Stimmabgabe:", error);
setOfflineVoteStatus('error');
}
};
// Handle bye round advancement - für automatisches Weiterkommen
const handleByeAdvance = async () => {
if (processingByeAdvance || !isHost) return;
setProcessingByeAdvance(true);
try {
// If offline, show notification that the action will be queued
if (isOffline) {
setOfflineVoteStatus('byeQueued');
}
// Nur der Host kann im Bye-Modus weiterschalten
if (battle && battle.song1 && !battle.song2 && battle.song1.id) {
await submitVote(battle.song1.id);
}
} catch (error) {
console.error("Fehler beim Fortfahren:", error);
setOfflineVoteStatus('error');
} finally {
// Verzögerung, um mehrere Klicks zu verhindern
setTimeout(() => setProcessingByeAdvance(false), 1000);
}
};
// Get YouTube video IDs from links
// Hole YouTube-Video-IDs aus Links
const getYouTubeId = (url) => {
if (!url) return null;
@ -49,10 +153,71 @@ function VotingScreen() {
return (match && match[2].length === 11) ? match[2] : null;
};
if (!battle || !battle.song1 || !battle.song2) {
if (!battle || !battle.song1) {
return (
<div className="voting-screen">
<h2>Preparing the next battle...</h2>
<h2>Bereite den nächsten Kampf vor...</h2>
</div>
);
}
// Behandle "Freilos"-Runden, in denen ein Lied automatisch weiterkommt
if (battle.bye === true && battle.song1 && !battle.song2) {
const song1Id = getYouTubeId(battle.song1?.youtubeLink || '');
return (
<div className="voting-screen">
<header className="voting-header">
<h1>
<FontAwesomeIcon icon={faTrophy} /> Automatisches Weiterkommen
</h1>
<div className="round-info">
<span>Runde {battle.round + 1}</span>
</div>
</header>
<div className="bye-container">
<div className="song-card bye-winner">
<div className="song-spotlight"></div>
<div className="song-details">
<h3>{battle.song1.title}</h3>
<p>{battle.song1.artist}</p>
<div className="auto-advance-notice">
<p>Dieses Lied kommt automatisch in die nächste Runde!</p>
<p className="host-info">{isHost ? 'Du kannst als Host zum nächsten Kampf weitergehen.' : 'Warte auf den Host, um fortzufahren.'}</p>
</div>
</div>
{song1Id ? (
<div className="video-container">
<YouTubeEmbed videoId={song1Id} />
</div>
) : (
<div className="no-video">
<FontAwesomeIcon icon={faMusic} className="pulse-icon" />
<span>Kein Video verfügbar</span>
</div>
)}
</div>
</div>
<div className="voting-status">
{isHost ? (
<button
className={`btn primary pixelated full-width ${processingByeAdvance ? 'disabled' : ''}`}
onClick={handleByeAdvance}
disabled={processingByeAdvance}
>
{processingByeAdvance ? 'Wird geladen...' : 'Mach weiter'}
<span className="pixel-corner tl"></span>
<span className="pixel-corner tr"></span>
<span className="pixel-corner bl"></span>
<span className="pixel-corner br"></span>
</button>
) : (
<p className="auto-advance-notice">Warte auf den Host, um fortzufahren...</p>
)}
</div>
</div>
);
}
@ -64,11 +229,12 @@ function VotingScreen() {
<div className="voting-screen">
<header className="voting-header">
<h1>
<FontAwesomeIcon icon={faVoteYea} /> Song Battle!
<FontAwesomeIcon icon={tournamentPhase === 'Finale' ? faCrown : tournamentPhase === 'Halbfinale' ? faMedal : faVoteYea} />
{tournamentPhase}
</h1>
<div className="round-info">
<span>Round {battle.round + 1}</span>
{hasVoted && <span className="voted-badge">You voted!</span>}
<span>Runde {battle.round + 1}</span>
{hasVoted && <span className="voted-badge">Du hast abgestimmt!</span>}
</div>
</header>
@ -81,9 +247,6 @@ function VotingScreen() {
<div className="song-details">
<h3>{battle.song1.title}</h3>
<p>{battle.song1.artist}</p>
<div className="song-submitter">
<small>Submitted by: {battle.song1.submittedByName}</small>
</div>
</div>
{song1Id ? (
@ -94,14 +257,14 @@ function VotingScreen() {
) : (
<div className="no-video">
<FontAwesomeIcon icon={faMusic} className="pulse-icon" />
<span>No video available</span>
<span>Kein Video verfügbar</span>
</div>
)}
{hasVoted && (
<div className="vote-count">
<span className="vote-number">{battle.song1Votes}</span>
<span className="vote-text">votes</span>
<span className="vote-text">Stimmen</span>
</div>
)}
@ -125,9 +288,6 @@ function VotingScreen() {
<div className="song-details">
<h3>{battle.song2.title}</h3>
<p>{battle.song2.artist}</p>
<div className="song-submitter">
<small>Submitted by: {battle.song2.submittedByName}</small>
</div>
</div>
{song2Id ? (
@ -138,14 +298,14 @@ function VotingScreen() {
) : (
<div className="no-video">
<FontAwesomeIcon icon={faMusic} className="pulse-icon" />
<span>No video available</span>
<span>Kein Video verfügbar</span>
</div>
)}
{hasVoted && (
<div className="vote-count">
<span className="vote-number">{battle.song2Votes}</span>
<span className="vote-text">votes</span>
<span className="vote-text">Stimmen</span>
</div>
)}
@ -157,22 +317,128 @@ function VotingScreen() {
</div>
</div>
{/* Vote Progress Bar - Only show after voting */}
{hasVoted && (
<div className="vote-progress-container">
<div className="vote-progress-labels">
<span className={`vote-label left ${voteRatio.winner === 'song1' ? 'winning' : ''} ${voteRatio.winner === 'song1' && voteRatio.isLandslide ? 'landslide' : ''}`}>
{battle.song1Votes || 0} Stimmen
{voteRatio.winner === 'song1' && voteRatio.totalVotes > 1 &&
<span className="winner-crown"><FontAwesomeIcon icon={faCrown} /></span>
}
</span>
<span className={`vote-label right ${voteRatio.winner === 'song2' ? 'winning' : ''} ${voteRatio.winner === 'song2' && voteRatio.isLandslide ? 'landslide' : ''}`}>
{battle.song2Votes || 0} Stimmen
{voteRatio.winner === 'song2' && voteRatio.totalVotes > 1 &&
<span className="winner-crown"><FontAwesomeIcon icon={faCrown} /></span>
}
</span>
</div>
<div className={`vote-progress-bar ${voteRatio.winner !== 'tie' ? voteRatio.winner : ''} ${voteRatio.isLandslide ? 'landslide' : ''}`}>
<div
className={`vote-progress-fill song1 ${voteRatio.winner === 'song1' ? 'winning' : ''}`}
style={{ width: `${voteRatio.song1Percent}%` }}
>
{voteRatio.song1Percent > 5 && (
<div className="vote-progress-decoration left"></div>
)}
</div>
<div
className={`vote-progress-divider ${voteRatio.margin <= 10 ? 'close-race' : ''}`}
style={{ left: `${voteRatio.song1Percent}%` }}
></div>
<div
className={`vote-progress-fill song2 ${voteRatio.winner === 'song2' ? 'winning' : ''}`}
style={{ width: `${voteRatio.song2Percent}%` }}
>
{voteRatio.song2Percent > 5 && (
<div className="vote-progress-decoration right"></div>
)}
</div>
</div>
<div className="vote-progress-percentages">
<span className={`vote-percent left ${voteRatio.winner === 'song1' ? 'winning' : ''} ${voteRatio.winner === 'song1' && voteRatio.isLandslide ? 'landslide' : ''}`}>
{voteRatio.song1Percent}%
</span>
<span className={`vote-percent right ${voteRatio.winner === 'song2' ? 'winning' : ''} ${voteRatio.winner === 'song2' && voteRatio.isLandslide ? 'landslide' : ''}`}>
{voteRatio.song2Percent}%
</span>
</div>
</div>
)}
{!hasVoted && (
<div className="voting-actions">
<button
className="btn primary"
className={`btn primary ${isOffline ? 'offline' : ''}`}
onClick={handleSubmitVote}
disabled={!selectedSong}
>
Cast Vote
{isOffline ? 'Offline Abstimmen' : 'Abstimmen'}
</button>
{isOffline && (
<div className="offline-notice">
<FontAwesomeIcon icon={faExclamationTriangle} />
<span>Deine Stimme wird gespeichert und gesendet, sobald die Verbindung wiederhergestellt ist.</span>
</div>
)}
{offlineVoteStatus === 'queued' && (
<div className="offline-vote-status">
<span>Stimme gespeichert! Wird gesendet, wenn online.</span>
</div>
)}
{offlineVoteStatus === 'error' && (
<div className="offline-vote-status error">
<span>Fehler beim Speichern der Stimme.</span>
</div>
)}
</div>
)}
<div className="voting-status">
<p>{hasVoted ? 'Waiting for other players to vote...' : 'Choose your favorite!'}</p>
<p>{hasVoted ? 'Warte auf andere Spieler...' : 'Wähle deinen Favoriten!'}</p>
{isReconnecting && (
<div className="reconnecting-notice">
<span>Versuche die Verbindung wiederherzustellen...</span>
</div>
)}
<div className="votes-count">
<span>{battle.voteCount || 0}</span> of <span>{lobby?.players?.filter(p => p.isConnected).length || 0}</span> votes
<span>{battle.voteCount || 0}</span> von <span>{lobby?.players?.filter(p => p.isConnected).length || 0}</span> Stimmen
{isOffline && <span className="offline-badge"> (Offline-Modus)</span>}
</div>
{/* Liste der Spielerstimmen */}
<div className="player-votes">
<h4>Abstimmende</h4>
<ul className="players-voted-list">
{lobby?.players?.filter(p => p.isConnected).map(player => {
// Prüfe, ob dieser Spieler abgestimmt hat
const hasPlayerVoted = battle.votes &&
Object.keys(battle.votes).includes(player.id);
// Highlight current player to show they've voted offline
const isCurrentPlayerOfflineVoted = player.id === currentPlayer.id &&
isOffline &&
hasVoted;
return (
<li key={player.id} className={`${hasPlayerVoted ? 'voted' : 'not-voted'} ${isCurrentPlayerOfflineVoted ? 'offline-voted' : ''}`}>
{getDisplayName(player, lobby, currentPlayer)} {player.id === currentPlayer.id && lobby.settings.hidePlayerNames && '(Du)'}
{hasPlayerVoted &&
<FontAwesomeIcon icon={faCheck} className="vote-icon" />
}
{isCurrentPlayerOfflineVoted &&
<FontAwesomeIcon icon={faExclamationTriangle} className="offline-icon" />
}
</li>
);
})}
</ul>
</div>
</div>
</div>

View File

@ -11,35 +11,61 @@ const GameContext = createContext(null);
export function SocketProvider({ children }) {
const [socket, setSocket] = useState(null);
const [isConnected, setIsConnected] = useState(false);
const [isReconnecting, setIsReconnecting] = useState(false);
const [reconnectionAttempts, setReconnectionAttempts] = useState(0);
const [offlineActions, setOfflineActions] = useState([]);
const [lastActivityTime, setLastActivityTime] = useState(Date.now());
useEffect(() => {
// Create socket connection on component mount
// Create socket connection on component mount with improved reconnection settings
const socketInstance = io(import.meta.env.DEV ? 'http://localhost:5237' : window.location.origin, {
autoConnect: true,
reconnection: true,
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: 5
reconnectionDelayMax: 10000,
reconnectionAttempts: Infinity, // Keep trying to reconnect indefinitely
timeout: 20000, // Longer timeout for initial connection
randomizationFactor: 0.5 // Add some randomization to reconnection attempts to prevent thundering herd
});
// Socket event listeners
socketInstance.on('connect', () => {
setIsConnected(true);
setIsReconnecting(false);
setReconnectionAttempts(0);
console.log('Connected to server');
// Process any actions that were queued while offline
if (offlineActions.length > 0) {
console.log(`Processing ${offlineActions.length} queued actions from offline mode`);
// Wait a moment to ensure connection is stable
setTimeout(() => {
processOfflineActions(socketInstance);
}, 1000);
}
// Try to reconnect to game if we have saved data
const savedGameData = localStorage.getItem('songBattleGame');
if (savedGameData) {
try {
const { lobbyId, playerName } = JSON.parse(savedGameData);
const { lobbyId, playerName, lastKnownState } = JSON.parse(savedGameData);
if (lobbyId && playerName) {
console.log(`Attempting to reconnect to lobby: ${lobbyId}`);
socketInstance.emit('reconnect_to_lobby', { lobbyId, playerName }, (response) => {
socketInstance.emit('reconnect_to_lobby', {
lobbyId,
playerName,
lastKnownState // Send last known game state for server reconciliation
}, (response) => {
if (response.error) {
console.error('Reconnection failed:', response.error);
localStorage.removeItem('songBattleGame');
// Don't remove data immediately, we might try again
if (response.error === 'Lobby not found') {
localStorage.removeItem('songBattleGame');
}
} else {
console.log('Successfully reconnected to lobby');
// Update the saved game data with latest state
updateSavedGameData(response.lobby);
}
});
}
@ -50,25 +76,200 @@ export function SocketProvider({ children }) {
}
});
socketInstance.on('disconnect', () => {
socketInstance.on('disconnect', (reason) => {
setIsConnected(false);
console.log('Disconnected from server');
console.log(`Disconnected from server: ${reason}`);
// If the disconnection is expected, don't try to reconnect
if (reason === 'io client disconnect') {
console.log('Disconnection was initiated by the client');
} else {
// Start reconnection process for unexpected disconnections
setIsReconnecting(true);
}
});
socketInstance.on('connect_error', (error) => {
console.error('Connection error:', error);
setIsReconnecting(true);
});
socketInstance.on('reconnect_attempt', (attemptNumber) => {
console.log(`Reconnection attempt #${attemptNumber}`);
setReconnectionAttempts(attemptNumber);
});
socketInstance.on('reconnect', () => {
console.log('Reconnected to server after interruption');
setIsReconnecting(false);
setReconnectionAttempts(0);
});
socketInstance.on('reconnect_error', (error) => {
console.error('Reconnection error:', error);
});
socketInstance.on('reconnect_failed', () => {
console.error('Failed to reconnect to server after maximum attempts');
setIsReconnecting(false);
});
// Setup a heartbeat to detect silent disconnections
const heartbeatInterval = setInterval(() => {
if (socketInstance && isConnected) {
// Check if we've seen activity recently (within 15 seconds)
const timeSinceLastActivity = Date.now() - lastActivityTime;
if (timeSinceLastActivity > 15000) {
console.log('No recent activity, sending ping to verify connection');
socketInstance.emit('ping', () => {
// Update activity time when we get a response
setLastActivityTime(Date.now());
});
// Set a timeout to check if the ping was successful
setTimeout(() => {
const newTimeSinceLastActivity = Date.now() - lastActivityTime;
if (newTimeSinceLastActivity > 15000) {
console.warn('No response to ping, connection may be dead');
// Force a reconnection attempt
socketInstance.disconnect().connect();
}
}, 5000);
}
}
}, 30000); // Check every 30 seconds
setSocket(socketInstance);
// Clean up socket connection on unmount
// Function to process queued offline actions
const processOfflineActions = (socket) => {
if (!socket || !offlineActions.length) return;
// Process each action in sequence
const processAction = (index) => {
if (index >= offlineActions.length) {
// All actions processed, clear the queue
setOfflineActions([]);
return;
}
const { action, payload, callback } = offlineActions[index];
console.log(`Processing offline action: ${action}`, payload);
// Emit the action to the server
socket.emit(action, payload, (response) => {
if (response.error) {
console.error(`Error processing offline action ${action}:`, response.error);
} else {
console.log(`Successfully processed offline action: ${action}`);
if (callback) callback(response);
}
// Process the next action
processAction(index + 1);
});
};
// Start processing from the first action
processAction(0);
};
// Update activity timestamp on any received message
const originalOnEvent = socketInstance.onEvent;
socketInstance.onEvent = (packet) => {
setLastActivityTime(Date.now());
originalOnEvent.call(socketInstance, packet);
};
// Function to update saved game data with latest state
const updateSavedGameData = (lobby) => {
if (!lobby) return;
const currentData = localStorage.getItem('songBattleGame');
if (currentData) {
try {
const data = JSON.parse(currentData);
data.lastKnownState = {
gameState: lobby.state,
currentBattle: lobby.currentBattle,
timestamp: Date.now()
};
localStorage.setItem('songBattleGame', JSON.stringify(data));
} catch (e) {
console.error('Error updating saved game data:', e);
}
}
};
// Clean up on unmount
return () => {
clearInterval(heartbeatInterval);
socketInstance.disconnect();
};
}, []);
// Offline mode detection
const [isOffline, setIsOffline] = useState(!navigator.onLine);
// Monitor browser's online/offline status
useEffect(() => {
const handleOnline = () => setIsOffline(false);
const handleOffline = () => setIsOffline(true);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
// Add an action to the offline queue
const queueOfflineAction = (action, payload, callback) => {
console.log(`Queuing offline action: ${action}`, payload);
setOfflineActions(prev => [...prev, { action, payload, callback }]);
// Store in localStorage as fallback
try {
const offlineQueue = JSON.parse(localStorage.getItem('offlineActionQueue') || '[]');
offlineQueue.push({ action, payload, timestamp: Date.now() });
localStorage.setItem('offlineActionQueue', JSON.stringify(offlineQueue));
} catch (e) {
console.error('Error storing offline action in localStorage:', e);
}
};
// Emit an event, queue it if offline
const safeEmit = (eventName, data, callback) => {
// Update activity timestamp
setLastActivityTime(Date.now());
if (socket && isConnected && !isOffline) {
// Online - send immediately
socket.emit(eventName, data, callback);
} else {
// Offline or disconnected - queue for later
queueOfflineAction(eventName, data, callback);
// For specific actions, provide optimistic UI updates
if (eventName === 'submit_vote' && data.songId) {
// Optimistically show the vote locally
// Implementation depends on your UI update mechanism
}
}
};
return (
<SocketContext.Provider value={{ socket, isConnected }}>
<SocketContext.Provider value={{
socket,
isConnected,
isReconnecting,
reconnectionAttempts,
isOffline,
safeEmit,
queueOfflineAction
}}>
{children}
</SocketContext.Provider>
);
@ -78,7 +279,7 @@ export function SocketProvider({ children }) {
* Game state provider - manages game state and provides methods for game actions
*/
export function GameProvider({ children }) {
const { socket, isConnected } = useContext(SocketContext);
const { socket, isConnected, isOffline, isReconnecting, safeEmit } = useContext(SocketContext);
const [lobby, setLobby] = useState(null);
const [error, setError] = useState(null);
const [currentPlayer, setCurrentPlayer] = useState(null);
@ -86,12 +287,38 @@ export function GameProvider({ children }) {
// Save game info when lobby is joined
useEffect(() => {
if (lobby && currentPlayer) {
localStorage.setItem('songBattleGame', JSON.stringify({
const savedData = {
lobbyId: lobby.id,
playerName: currentPlayer.name
}));
playerName: currentPlayer.name,
lastKnownState: {
gameState: lobby.state,
currentBattle: lobby.currentBattle,
timestamp: Date.now()
}
};
localStorage.setItem('songBattleGame', JSON.stringify(savedData));
}
}, [lobby, currentPlayer]);
// Helper function to update saved game data
const updateSavedGameData = (updatedLobby) => {
if (!updatedLobby || !currentPlayer) return;
const savedData = {
lobbyId: updatedLobby.id,
playerName: currentPlayer.name,
lastKnownState: {
gameState: updatedLobby.state,
currentBattle: updatedLobby.currentBattle,
playerState: {
isReady: updatedLobby.players?.find(p => p.id === currentPlayer.id)?.isReady,
hasVoted: updatedLobby.currentBattle?.votes?.[currentPlayer.id] !== undefined
},
timestamp: Date.now()
}
};
localStorage.setItem('songBattleGame', JSON.stringify(savedData));
};
// Clear error after 5 seconds
useEffect(() => {
@ -212,6 +439,19 @@ export function GameProvider({ children }) {
});
};
// Handle battle ended and show result screen
const handleBattleEnded = (data) => {
console.log('Battle ended, showing result screen:', data);
setLobby(prevLobby => {
if (!prevLobby) return prevLobby;
return {
...prevLobby,
state: 'BATTLE',
previousBattle: data.previousBattle
};
});
};
// Game finished
const handleGameFinished = (data) => {
setLobby(prevLobby => {
@ -234,11 +474,13 @@ export function GameProvider({ children }) {
socket.on('songs_updated', handleSongsUpdated);
socket.on('player_status_changed', handlePlayerStatusChanged);
socket.on('vote_submitted', handleVoteSubmitted);
socket.on('battle_ended', handleBattleEnded);
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('battle_ended', handleBattleEnded);
socket.on('game_finished', handleGameFinished);
// Clean up listeners on unmount
@ -252,6 +494,7 @@ export function GameProvider({ children }) {
socket.off('player_status_changed', handlePlayerStatusChanged);
socket.off('vote_submitted', handleVoteSubmitted);
socket.off('new_battle', handleNewBattle);
socket.off('battle_ended', handleBattleEnded);
socket.off('game_finished', handleGameFinished);
};
}, [socket]);
@ -263,7 +506,7 @@ export function GameProvider({ children }) {
return;
}
socket.emit('create_lobby', { playerName }, (response) => {
safeEmit('create_lobby', { playerName }, (response) => {
if (response.error) {
setError(response.error);
} else {
@ -284,7 +527,7 @@ export function GameProvider({ children }) {
return;
}
socket.emit('join_lobby', { lobbyId, playerName }, (response) => {
safeEmit('join_lobby', { lobbyId, playerName }, (response) => {
if (response.error) {
setError(response.error);
} else {
@ -305,7 +548,7 @@ export function GameProvider({ children }) {
return;
}
socket.emit('update_settings', { settings }, (response) => {
safeEmit('update_settings', { settings }, (response) => {
if (response.error) {
setError(response.error);
}
@ -319,7 +562,7 @@ export function GameProvider({ children }) {
return;
}
socket.emit('start_game', {}, (response) => {
safeEmit('start_game', {}, (response) => {
if (response.error) {
setError(response.error);
}
@ -330,49 +573,58 @@ export function GameProvider({ children }) {
const addSong = (song) => {
if (!socket || !isConnected || !lobby) {
setError('Not connected to server or no active lobby');
return;
return Promise.reject('Not connected to server or no active lobby');
}
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');
}
return new Promise((resolve, reject) => {
safeEmit('add_song', { song }, (response) => {
console.log('Song addition response:', response);
if (response.error) {
console.error('Error adding song:', response.error);
setError(response.error);
reject(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);
// Store the latest state for offline reconciliation
updateSavedGameData(updatedLobby);
// Resolve with the updated lobby
resolve(updatedLobby);
} else {
console.error('Song addition succeeded but no lobby data was returned');
setError('Failed to update song list');
reject('Failed to update song list');
}
});
});
};
// Search for songs on YouTube
// Search for songs on YouTube
const searchYouTube = (query) => {
return new Promise((resolve, reject) => {
if (!socket || !isConnected) {
@ -381,7 +633,7 @@ export function GameProvider({ children }) {
return;
}
socket.emit('search_youtube', { query }, (response) => {
safeEmit('search_youtube', { query }, (response) => {
if (response.error) {
setError(response.error);
reject(response.error);
@ -392,6 +644,26 @@ export function GameProvider({ children }) {
});
};
// Get metadata for a YouTube video by ID
const getYouTubeMetadata = (videoId) => {
return new Promise((resolve, reject) => {
if (!socket || !isConnected) {
setError('Not connected to server');
reject('Not connected to server');
return;
}
safeEmit('get_video_metadata', { videoId }, (response) => {
if (response.error) {
setError(response.error);
reject(response.error);
} else {
resolve(response.metadata);
}
});
});
};
// Remove a song
const removeSong = (songId) => {
if (!socket || !isConnected || !lobby) {
@ -399,9 +671,12 @@ export function GameProvider({ children }) {
return;
}
socket.emit('remove_song', { songId }, (response) => {
safeEmit('remove_song', { songId }, (response) => {
if (response.error) {
setError(response.error);
} else if (response.lobby) {
setLobby(response.lobby);
updateSavedGameData(response.lobby);
}
});
};
@ -413,9 +688,12 @@ export function GameProvider({ children }) {
return;
}
socket.emit('player_ready', {}, (response) => {
safeEmit('player_ready', {}, (response) => {
if (response.error) {
setError(response.error);
} else if (response.lobby) {
setLobby(response.lobby);
updateSavedGameData(response.lobby);
}
});
};
@ -427,9 +705,12 @@ export function GameProvider({ children }) {
return;
}
socket.emit('submit_vote', { songId }, (response) => {
safeEmit('submit_vote', { songId }, (response) => {
if (response.error) {
setError(response.error);
} else if (response.lobby) {
setLobby(response.lobby);
updateSavedGameData(response.lobby);
}
});
};
@ -441,12 +722,31 @@ export function GameProvider({ children }) {
setCurrentPlayer(null);
};
// Proceed to next battle after the battle result screen
const proceedToNextBattle = () => {
if (!socket || !isConnected) {
setError('Not connected to server');
return;
}
safeEmit('proceed_to_next_battle', {}, (response) => {
if (response.error) {
setError(response.error);
} else if (response.lobby) {
setLobby(response.lobby);
updateSavedGameData(response.lobby);
}
});
};
return (
<GameContext.Provider value={{
lobby,
error,
currentPlayer,
isHost: currentPlayer && lobby && currentPlayer.id === lobby.hostId,
isOffline: isOffline,
isReconnecting,
createLobby,
joinLobby,
updateSettings,
@ -456,7 +756,9 @@ export function GameProvider({ children }) {
setPlayerReady,
submitVote,
leaveLobby,
searchYouTube
searchYouTube,
getYouTubeMetadata,
proceedToNextBattle
}}>
{children}
</GameContext.Provider>

View File

@ -1,7 +1,7 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App.jsx";
import "@fontsource/bangers";
import "@fontsource/press-start-2p";
import "./common/styles/main.sass";
import { SocketProvider, GameProvider } from "./context/GameContext.jsx";

View File

@ -0,0 +1,45 @@
/**
* Utility functions for player name handling
*/
/**
* Returns an anonymous name for a player when hidePlayerNames is enabled
* @param {string} playerId - The player's ID
* @param {number} index - The player's index in a list (optional)
* @param {boolean} isCurrentPlayer - Whether this is the current player
* @returns {string} - Anonymous name or indicator
*/
export const getAnonymousName = (playerId, index = null, isCurrentPlayer = false) => {
if (isCurrentPlayer) {
return 'Du';
}
// Use index-based naming if provided
if (index !== null) {
return `Spieler ${index + 1}`;
}
// Use last 4 characters of ID as fallback
const shortId = playerId.slice(-4);
return `Spieler ${shortId}`;
};
/**
* Returns the appropriate player name based on game settings
* @param {Object} player - The player object
* @param {Object} lobby - The lobby object
* @param {Object} currentPlayer - The current player
* @param {number} index - The player's index in a list (optional)
* @returns {string} - The player name or anonymous identifier
*/
export const getDisplayName = (player, lobby, currentPlayer, index = null) => {
const isCurrentPlayer = player.id === currentPlayer?.id;
// If hidePlayerNames is enabled
if (lobby?.settings?.hidePlayerNames) {
return getAnonymousName(player.id, index, isCurrentPlayer);
}
// Otherwise return the actual name
return player.name;
};

View File

@ -12,7 +12,7 @@ export default defineConfig({
server: {
proxy: {
"/socket.io": {
target: "http://localhost:5287",
target: "http://localhost:5237",
changeOrigin: true,
ws: true,
},

View File

@ -29,7 +29,7 @@ class GameManager {
songsPerPlayer: 3,
maxPlayers: 10,
minPlayers: 3,
requireYoutubeLinks: false
hidePlayerNames: false
},
players: [{
id: hostId,
@ -113,9 +113,10 @@ class GameManager {
* @param {string} playerId - New ID of the reconnecting player
* @param {string} lobbyId - ID of the lobby
* @param {string} playerName - Name of the player
* @param {Object} lastKnownState - Last known game state from client
* @returns {Object} Lobby data or error
*/
handleReconnect(playerId, lobbyId, playerName) {
handleReconnect(playerId, lobbyId, playerName, lastKnownState = null) {
// Check if lobby exists
if (!this.lobbies.has(lobbyId)) {
return { error: 'Lobby not found' };
@ -136,6 +137,65 @@ class GameManager {
// Update map
this.playerToLobby.set(playerId, lobbyId);
// Handle state reconciliation if lastKnownState is provided
if (lastKnownState && lastKnownState.gameState && lastKnownState.timestamp) {
console.log(`Reconciling player state based on last known state: ${lastKnownState.gameState} from ${new Date(lastKnownState.timestamp).toISOString()}`);
// If the client's last known state was VOTING and the current battle has votes
if (lastKnownState.gameState === 'VOTING' &&
lastKnownState.currentBattle &&
lastKnownState.playerState &&
lastKnownState.playerState.hasVoted) {
// The player already voted in this battle according to their local state
// If we still have the same battle, ensure their vote is counted
if (lobby.currentBattle &&
lastKnownState.currentBattle.round === lobby.currentBattle.round) {
console.log(`Player ${playerName} had voted in battle round ${lastKnownState.currentBattle.round}, checking if vote needs restoration`);
// Check if their vote is missing
const hasVote = lobby.currentBattle.votes &&
(lobby.currentBattle.votes.has(playerId) ||
Object.keys(lobby.currentBattle.votes).includes(playerId));
// If their vote is missing, we need to determine which song they voted for
if (!hasVote && lastKnownState.currentBattle.votes) {
// Find which song they voted for
const votedSongId = lastKnownState.currentBattle.votes[lobby.players[playerIndex].id];
if (votedSongId) {
console.log(`Restoring player ${playerName}'s vote for song ${votedSongId}`);
// Recreate their vote
this.submitVote(playerId, votedSongId, true);
}
}
}
}
// If the client's last known state was SONG_SUBMISSION and they were ready
if (lastKnownState.gameState === 'SONG_SUBMISSION' &&
lastKnownState.playerState &&
lastKnownState.playerState.isReady &&
lobby.state === 'SONG_SUBMISSION' &&
!lobby.players[playerIndex].isReady) {
console.log(`Restoring ready status for player ${playerName}`);
lobby.players[playerIndex].isReady = true;
// Check if all players are now ready
const allReady = lobby.players.every(p => p.isReady || !p.isConnected);
if (allReady) {
console.log('All players ready after reconnection, starting tournament...');
this._startTournament(lobby);
return {
lobby,
lobbyId,
tournamentStarted: true
};
}
}
}
return { lobby, lobbyId };
}
@ -166,6 +226,11 @@ class GameManager {
return { error: 'Max players must be between 3 and 20' };
}
// Validate boolean settings
if (settings.hidePlayerNames !== undefined && typeof settings.hidePlayerNames !== 'boolean') {
settings.hidePlayerNames = Boolean(settings.hidePlayerNames);
}
// Update settings
lobby.settings = {
...lobby.settings,
@ -221,7 +286,6 @@ class GameManager {
// 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
@ -267,31 +331,6 @@ class GameManager {
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' };
}
@ -300,15 +339,12 @@ class GameManager {
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' };
// If we have a YouTube link, validate it
if (song.youtubeLink) {
const videoId = await youtubeAPI.extractVideoId(song.youtubeLink);
if (!videoId) {
return { error: 'Invalid YouTube link' };
}
}
// Handle async metadata fetching
@ -329,6 +365,20 @@ class GameManager {
}
}
/**
* Get metadata for a single YouTube video
* @param {string} videoId - YouTube video ID
* @returns {Promise<Object>} Video metadata
*/
async getYouTubeVideoMetadata(videoId) {
try {
return await youtubeAPI.getVideoMetadata(videoId);
} catch (error) {
console.error('Error getting YouTube video metadata:', error);
return { error: 'Failed to get video metadata' };
}
}
/**
* Helper method to add a song to a player
* @param {number} playerIndex - Index of the player in the lobby
@ -509,9 +559,10 @@ class GameManager {
* 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
* @param {boolean} isRestoredVote - Whether this is a restored vote during reconnection
* @returns {Object} Updated lobby data or error
*/
submitVote(playerId, songId) {
submitVote(playerId, songId, isRestoredVote = false) {
const lobbyId = this.playerToLobby.get(playerId);
if (!lobbyId) {
return { error: 'Player not in a lobby' };
@ -529,29 +580,86 @@ class GameManager {
return { error: 'No active battle' };
}
// For bye battles (automatic advancement), only allow the host to vote/advance
if (lobby.currentBattle.bye === true && playerId !== lobby.hostId) {
return { error: 'Only the host can advance bye rounds' };
}
// Check if player has already voted in this battle
if (lobby.currentBattle.votes.has(playerId)) {
if (lobby.currentBattle.votes && Object.prototype.hasOwnProperty.call(lobby.currentBattle.votes, playerId) && !isRestoredVote) {
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) {
if (songId !== lobby.currentBattle.song1.id &&
(lobby.currentBattle.song2 && songId !== lobby.currentBattle.song2.id)) {
return { error: 'Invalid song ID' };
}
// Record the vote
lobby.currentBattle.votes.set(playerId, songId);
// Find player name for display purposes
const player = lobby.players.find(p => p.id === playerId);
const playerName = player ? player.name : 'Unknown Player';
// Initialize votes Object if it doesn't exist
if (!lobby.currentBattle.votes) {
lobby.currentBattle.votes = {};
}
// Record the vote with player name for UI display
lobby.currentBattle.votes[playerId] = {
songId,
playerName
};
// Update vote counts
if (songId === lobby.currentBattle.song1.id) {
lobby.currentBattle.song1Votes++;
} else {
lobby.currentBattle.song2Votes++;
lobby.currentBattle.song1Votes = (lobby.currentBattle.song1Votes || 0) + 1;
} else if (lobby.currentBattle.song2) {
lobby.currentBattle.song2Votes = (lobby.currentBattle.song2Votes || 0) + 1;
}
// Check if all connected players have voted
// Add a voteCount attribute for easier UI rendering
lobby.currentBattle.voteCount = Object.keys(lobby.currentBattle.votes).length;
// For bye battles, the host's vote is all that's needed
if (lobby.currentBattle.bye === true && playerId === lobby.hostId) {
// Determine winner (in bye battles, it's always song1)
const winnerSongId = lobby.currentBattle.song1.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: 0,
winner: winnerSongId,
bye: true
});
// Store current battle for potential future reference
lobby.previousBattle = {
...lobby.currentBattle,
winner: winnerSongId
};
// Skip battle result screen for byes - go straight to the next battle
lobby.state = 'VOTING';
const tournamentFinished = this._moveToNextBattle(lobby);
// Check if the tournament has finished
if (tournamentFinished || lobby.state === 'FINISHED') {
console.log(`Tournament has finished after bye battle! Winner: ${lobby.finalWinner ? lobby.finalWinner.title : 'No winner'}`);
}
return { lobby, lobbyId };
}
// For regular battles, check if all connected players have voted
const connectedPlayers = lobby.players.filter(p => p.isConnected).length;
const voteCount = lobby.currentBattle.votes.size;
const voteCount = Object.keys(lobby.currentBattle.votes).length;
if (voteCount >= connectedPlayers) {
// Determine winner
@ -571,8 +679,132 @@ class GameManager {
winner: winnerSongId
});
// Move to next battle or finish tournament
this._moveToNextBattle(lobby);
// Store current battle as previousBattle and set state to BATTLE_RESULT
lobby.previousBattle = {
...lobby.currentBattle,
winner: winnerSongId
};
// Change state to BATTLE to show the battle result screen
lobby.state = 'BATTLE';
}
return { lobby, lobbyId };
}
/**
* Proceed to the next battle after showing the battle result screen
* @param {string} playerId - ID of the player (should be host)
* @returns {Object} Updated lobby data or error
*/
proceedToNextBattle(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 proceed to the next battle' };
}
// Check if we're in the battle result state
// Allow proceeding from either BATTLE or VOTING state to handle race conditions
if (lobby.state !== 'BATTLE' && lobby.state !== 'VOTING') {
console.log(`Warning: Attempting to proceed to next battle from state ${lobby.state}`);
return { error: 'Not in a valid state to proceed to next battle' };
}
// If we're in VOTING state, ensure we have a winner before proceeding
if (lobby.state === 'VOTING' && lobby.currentBattle && !lobby.currentBattle.winner) {
console.log('Warning: Attempting to proceed without a winner determined');
// Check if this is a bye battle (only one song, automatic advancement)
if (lobby.currentBattle.bye === true || !lobby.currentBattle.song2) {
console.log('Processing bye battle - automatic advancement');
// For bye battles, the winner is always song1
const winnerSongId = lobby.currentBattle.song1.id;
lobby.currentBattle.winner = winnerSongId;
lobby.currentBattle.song1Votes = 1; // Add a dummy vote for song1
// Store as previous battle for history
lobby.previousBattle = {
...lobby.currentBattle,
winner: winnerSongId
};
// Add to battle history
lobby.battles.push({
round: lobby.currentBattle.round,
song1: lobby.currentBattle.song1,
song2: lobby.currentBattle.song2,
song1Votes: 1, // Always at least one vote for the bye song
song2Votes: 0,
winner: winnerSongId,
bye: true
});
}
// Determine winner based on votes if present for regular battles
else if (lobby.currentBattle.votes && Object.keys(lobby.currentBattle.votes).length > 0) {
const winnerSongId = (lobby.currentBattle.song1Votes || 0) > (lobby.currentBattle.song2Votes || 0)
? lobby.currentBattle.song1.id
: lobby.currentBattle.song2.id;
lobby.currentBattle.winner = winnerSongId;
// Store as previous battle for history
lobby.previousBattle = {
...lobby.currentBattle,
winner: winnerSongId
};
// Add to battle history
lobby.battles.push({
round: lobby.currentBattle.round,
song1: lobby.currentBattle.song1,
song2: lobby.currentBattle.song2,
song1Votes: lobby.currentBattle.song1Votes || 0,
song2Votes: lobby.currentBattle.song2Votes || 0,
winner: winnerSongId
});
} else {
// For regular battles with no votes, force a winner to avoid getting stuck
console.log('No votes recorded but proceeding anyway to avoid blocking the game');
const winnerSongId = lobby.currentBattle.song1.id; // Default to song1 as winner
lobby.currentBattle.winner = winnerSongId;
lobby.currentBattle.song1Votes = 1; // Add a dummy vote
// Store as previous battle for history
lobby.previousBattle = {
...lobby.currentBattle,
winner: winnerSongId
};
// Add to battle history
lobby.battles.push({
round: lobby.currentBattle.round,
song1: lobby.currentBattle.song1,
song2: lobby.currentBattle.song2,
song1Votes: 1,
song2Votes: 0,
winner: winnerSongId
});
}
}
// Set state to VOTING for the next battle
lobby.state = 'VOTING';
// Move to next battle or finish tournament - capture if the tournament has finished
const tournamentFinished = this._moveToNextBattle(lobby);
// Check if the game has ended after trying to move to the next battle
if (tournamentFinished || lobby.state === 'FINISHED') {
console.log(`Tournament has finished! Winner: ${lobby.finalWinner ? lobby.finalWinner.title : 'No winner'}`);
}
return { lobby, lobbyId };
@ -626,7 +858,7 @@ class GameManager {
// 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 totalVotes = lobby.currentBattle.votes ? Object.keys(lobby.currentBattle.votes).length : 0;
const remainingPlayers = lobby.players.filter(p => p.isConnected).length;
if (totalVotes >= remainingPlayers && totalVotes > 0) {
@ -708,9 +940,10 @@ class GameManager {
song1: byeSong,
song2: null, // Bye
bye: true,
winner: byeSong.id,
winner: null, // Set to null until host advances it
song1Votes: 0,
song2Votes: 0
song2Votes: 0,
votes: {} // Initialize votes as an empty object
});
}
@ -724,7 +957,7 @@ class GameManager {
song1Votes: 0,
song2Votes: 0,
winner: null,
votes: new Map()
votes: {}
});
}
}
@ -740,8 +973,8 @@ class GameManager {
// 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(),
// Make sure votes is an object (not a Map)
votes: brackets[0].votes || {},
voteCount: 0
};
@ -758,18 +991,81 @@ class GameManager {
/**
* Move to next battle or finish tournament
* @param {Object} lobby - Lobby object
* @returns {boolean} true if tournament has finished, false otherwise
* @private
*/
_moveToNextBattle(lobby) {
// If coming from BATTLE state, reset to VOTING for the next battle
if (lobby.state === 'BATTLE') {
lobby.state = 'VOTING';
}
// 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;
// Ensure votes is initialized as an object
if (!lobby.currentBattle.votes) {
lobby.currentBattle.votes = {};
}
// Initialize vote count for UI
lobby.currentBattle.voteCount = 0;
// Check if this is a bye battle that should be auto-advanced
if (lobby.currentBattle.bye === true) {
console.log('Auto-advancing bye battle');
// Auto-advance logic for bye battles
const winnerSongId = lobby.currentBattle.song1.id;
lobby.currentBattle.winner = winnerSongId;
lobby.currentBattle.song1Votes = 1; // Add a dummy vote
// Store to history
lobby.battles.push({
round: lobby.currentBattle.round,
song1: lobby.currentBattle.song1,
song2: null,
song1Votes: 1,
song2Votes: 0,
winner: winnerSongId,
bye: true
});
// Immediately advance to the next battle
return this._moveToNextBattle(lobby);
}
return false; // Tournament continues
}
// No more battles in current round, set up the next round
return this._setUpNextRound(lobby);
}
/**
* 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]];
}
}
/**
* Helper method to set up the next tournament round
* Similar logic to end of _moveToNextBattle but extracted for reuse
* @param {Object} lobby - Lobby object
* @returns {boolean} true if tournament has finished, false otherwise
* @private
*/
_setUpNextRound(lobby) {
// Current round complete, check if tournament is finished
const winners = lobby.brackets
.filter(b => !b.bye) // Skip byes
@ -788,9 +1084,10 @@ class GameManager {
// If only one song remains, we have a winner
if (nextRoundSongs.length <= 1) {
lobby.state = 'FINISHED';
lobby.finalWinner = nextRoundSongs[0];
lobby.finalWinner = nextRoundSongs.length > 0 ? nextRoundSongs[0] : null;
lobby.currentBattle = null;
return;
console.log(`Tournament has finished! Final winner: ${lobby.finalWinner ? lobby.finalWinner.title : 'No winner'}`);
return true; // Return true to indicate tournament has finished
}
// Create brackets for next round
@ -805,9 +1102,10 @@ class GameManager {
song1: byeSong,
song2: null,
bye: true,
winner: byeSong.id,
winner: null, // Set to null until host advances it
song1Votes: 0,
song2Votes: 0
song2Votes: 0,
votes: {} // Initialize votes as an object
});
}
@ -821,7 +1119,7 @@ class GameManager {
song1Votes: 0,
song2Votes: 0,
winner: null,
votes: new Map()
votes: {}
});
}
}
@ -833,19 +1131,41 @@ class GameManager {
// Set first battle of new round
if (nextRound.length > 0) {
lobby.currentBattle = nextRound[0];
// Ensure votes is initialized as an object
if (!lobby.currentBattle.votes) {
lobby.currentBattle.votes = {};
}
// Initialize vote count for UI
lobby.currentBattle.voteCount = 0;
// Check if this first battle is a bye - if so, auto-advance
if (lobby.currentBattle.bye === true) {
console.log('Auto-advancing bye battle in new round');
// Auto-advance logic similar to above
const winnerSongId = lobby.currentBattle.song1.id;
lobby.currentBattle.winner = winnerSongId;
lobby.currentBattle.song1Votes = 1;
// Store to history
lobby.battles.push({
round: lobby.currentBattle.round,
song1: lobby.currentBattle.song1,
song2: null,
song1Votes: 1,
song2Votes: 0,
winner: winnerSongId,
bye: true
});
// Move to next battle
this._moveToNextBattle(lobby);
}
}
}
/**
* 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]];
}
return false; // Tournament continues
}
}

View File

@ -73,9 +73,9 @@ io.on('connection', (socket) => {
});
// Attempt to reconnect to a lobby
socket.on('reconnect_to_lobby', ({ lobbyId, playerName }, callback) => {
socket.on('reconnect_to_lobby', ({ lobbyId, playerName, lastKnownState }, callback) => {
try {
const result = gameManager.handleReconnect(socket.id, lobbyId, playerName);
const result = gameManager.handleReconnect(socket.id, lobbyId, playerName, lastKnownState);
if (result.error) {
if (callback) callback(result);
@ -150,10 +150,11 @@ io.on('connection', (socket) => {
});
// Add a song
socket.on('add_song', ({ song }, callback) => {
socket.on('add_song', async ({ song }, callback) => {
try {
console.log(`[DEBUG] Attempting to add song: ${JSON.stringify(song)}`);
const result = gameManager.addSong(socket.id, song);
// Fix: Properly await the result of the async addSong function
const result = await gameManager.addSong(socket.id, song);
if (result.error) {
console.log(`[DEBUG] Error adding song: ${result.error}`);
@ -225,6 +226,24 @@ io.on('connection', (socket) => {
}
});
// Get metadata for a single YouTube video
socket.on('get_video_metadata', async ({ videoId }, callback) => {
try {
if (!videoId || typeof videoId !== 'string') {
if (callback) callback({ error: 'Invalid video ID' });
return;
}
const metadata = await gameManager.getYouTubeVideoMetadata(videoId);
// Send response to client
if (callback) callback({ metadata });
} catch (error) {
console.error('Error getting YouTube metadata:', error);
if (callback) callback({ error: 'Failed to get YouTube metadata' });
}
});
// Mark player as ready
socket.on('player_ready', (_, callback) => {
try {
@ -283,42 +302,73 @@ io.on('connection', (socket) => {
// Submit a vote in a battle
socket.on('submit_vote', ({ songId }, callback) => {
try {
console.log(`Player ${socket.id} voting for song ${songId}`);
const result = gameManager.submitVote(socket.id, songId);
if (result.error) {
if (callback) callback({ error: result.error });
} else {
// Broadcast updated game state to all players in the lobby
socket.to(result.lobbyId).emit('vote_submitted', result);
// Check if we've entered the BATTLE state (all votes are in and battle has ended)
const lobby = result.lobby;
if (lobby.state === 'BATTLE') {
// Broadcast battle result to all players in the lobby
io.to(result.lobbyId).emit('battle_ended', {
previousBattle: lobby.previousBattle,
state: 'BATTLE'
});
}
// Check if the game has finished (edge case - last battle with automatic winner)
else if (lobby.state === 'FINISHED') {
console.log('Game finished after vote submission');
io.to(result.lobbyId).emit('game_finished', {
winner: lobby.finalWinner,
battles: lobby.battles
});
}
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' });
if (callback) callback({ error: 'Server error while submitting vote' });
}
});
// Handle disconnection
// Proceed to next battle after the battle result screen
socket.on('proceed_to_next_battle', (data, callback) => {
try {
const result = gameManager.proceedToNextBattle(socket.id);
if (result.error) {
if (callback) callback({ error: result.error });
} else {
// Check if the game has finished
if (result.lobby.state === 'FINISHED') {
console.log('Game finished, broadcasting final winner');
io.to(result.lobbyId).emit('game_finished', {
winner: result.lobby.finalWinner,
battles: result.lobby.battles
});
} else {
// Broadcast updated game state to all players in the lobby
io.to(result.lobbyId).emit('new_battle', {
battle: result.lobby.currentBattle,
state: result.lobby.state
});
}
if (callback) callback(result);
}
} catch (error) {
console.error('Error proceeding to next battle:', error);
if (callback) callback({ error: 'Server error while proceeding to next battle' });
}
});
// Handle player disconnection
socket.on('disconnect', () => {
try {
const result = gameManager.handleDisconnect(socket.id);
@ -336,6 +386,13 @@ io.on('connection', (socket) => {
console.error('Error handling disconnect:', error);
}
});
// Simple ping handler to help with connection testing
socket.on('ping', (callback) => {
if (callback && typeof callback === 'function') {
callback({ success: true, timestamp: Date.now() });
}
});
});
server.on('error', (error) => {