All checks were successful
Build and Push Docker Image / docker (push) Successful in 13s
383 lines
12 KiB
JavaScript
383 lines
12 KiB
JavaScript
import {
|
||
$answerResult,
|
||
$guessArtist,
|
||
$guessTitle,
|
||
$npArtist,
|
||
$npTitle,
|
||
$npYear,
|
||
} from "./dom.js";
|
||
import { state } from "./state.js";
|
||
import { cacheLastRoomId, cacheSessionId, clearSessionId, sendMsg } from "./ws.js";
|
||
import { renderRoom } from "./render.js";
|
||
import { applySync, loadTrack, getSound, stopAudioPlayback } from "./audio.js";
|
||
import { playCorrect, playWrong, playWinner, playTurnStart, playCoinEarned } from "./sfx.js";
|
||
import { showReaction, celebrateCorrect } from "./reactions.js";
|
||
|
||
function updatePlayerIdFromRoom(r) {
|
||
try {
|
||
if (r?.players?.length === 1) {
|
||
const only = r.players[0];
|
||
if (only && only.id && only.id !== state.playerId) {
|
||
state.playerId = only.id;
|
||
try {
|
||
localStorage.setItem("playerId", only.id);
|
||
} catch { }
|
||
}
|
||
}
|
||
} catch { }
|
||
}
|
||
|
||
function shortName(id) {
|
||
if (!id) return "-";
|
||
const p = state.room?.players.find((x) => x.id === id);
|
||
return p ? p.name : id.slice(0, 4);
|
||
}
|
||
|
||
export function handleConnected(msg) {
|
||
state.playerId = msg.playerId;
|
||
try {
|
||
if (msg.playerId) localStorage.setItem("playerId", msg.playerId);
|
||
} catch { }
|
||
if (msg.sessionId) {
|
||
const existing = localStorage.getItem("sessionId");
|
||
if (!existing) cacheSessionId(msg.sessionId);
|
||
}
|
||
|
||
// lazy import to avoid cycle
|
||
import("./session.js").then(({ reusePlayerName, reconnectLastRoom }) => {
|
||
reusePlayerName();
|
||
reconnectLastRoom();
|
||
});
|
||
|
||
if (state.room) {
|
||
try {
|
||
updatePlayerIdFromRoom(state.room);
|
||
renderRoom(state.room);
|
||
} catch { }
|
||
}
|
||
}
|
||
|
||
export function handleRoomUpdate(msg) {
|
||
if (msg?.room?.id) cacheLastRoomId(msg.room.id);
|
||
const r = msg.room;
|
||
updatePlayerIdFromRoom(r);
|
||
renderRoom(r);
|
||
}
|
||
|
||
export function handlePlayTrack(msg) {
|
||
const t = msg.track;
|
||
state.lastTrack = t;
|
||
state.revealed = false;
|
||
$npTitle.textContent = "???";
|
||
$npArtist.textContent = "";
|
||
$npYear.textContent = "";
|
||
if ($guessTitle) $guessTitle.value = "";
|
||
if ($guessArtist) $guessArtist.value = "";
|
||
if ($answerResult) {
|
||
$answerResult.textContent = "";
|
||
$answerResult.className = "mt-1 text-sm";
|
||
}
|
||
|
||
// Load track with Howler, passing the filename for format detection
|
||
const sound = loadTrack(t.url, t.file);
|
||
|
||
const pf = document.getElementById("progressFill");
|
||
if (pf) pf.style.width = "0%";
|
||
const rd = document.getElementById("recordDisc");
|
||
if (rd) {
|
||
rd.classList.remove("spin-record");
|
||
rd.src = "/hitstar.png";
|
||
}
|
||
|
||
const { startAt, serverNow } = msg;
|
||
const now = Date.now();
|
||
const offsetMs = startAt - serverNow;
|
||
const localStart = now + offsetMs;
|
||
const delay = Math.max(0, localStart - now);
|
||
|
||
setTimeout(() => {
|
||
if (sound && sound === getSound() && !sound.playing()) {
|
||
sound.play();
|
||
const disc = document.getElementById("recordDisc");
|
||
if (disc) disc.classList.add("spin-record");
|
||
}
|
||
}, delay);
|
||
|
||
// Play turn start sound if it's the current player's turn
|
||
if (state.room?.state?.currentGuesser === state.playerId) {
|
||
playTurnStart();
|
||
}
|
||
|
||
if (state.room) renderRoom(state.room);
|
||
}
|
||
|
||
export function handleSync(msg) {
|
||
applySync(msg.startAt, msg.serverNow);
|
||
}
|
||
|
||
export function handleControl(msg) {
|
||
const { action, startAt, serverNow } = msg;
|
||
const sound = getSound();
|
||
|
||
if (!sound) return;
|
||
|
||
if (action === "pause") {
|
||
sound.pause();
|
||
const disc = document.getElementById("recordDisc");
|
||
if (disc) disc.classList.remove("spin-record");
|
||
sound.rate(1.0);
|
||
} else if (action === "play") {
|
||
if (startAt && serverNow) {
|
||
const now = Date.now();
|
||
const elapsed = (now - startAt) / 1000;
|
||
sound.seek(Math.max(0, elapsed));
|
||
}
|
||
sound.play();
|
||
const disc = document.getElementById("recordDisc");
|
||
if (disc) disc.classList.add("spin-record");
|
||
}
|
||
}
|
||
|
||
export function handleReveal(msg) {
|
||
const { result, track } = msg;
|
||
$npTitle.textContent = track.title || track.id || "Track";
|
||
$npArtist.textContent = track.artist ? ` – ${track.artist}` : "";
|
||
$npYear.textContent = track.year ? ` (${track.year})` : "";
|
||
state.revealed = true;
|
||
const $rb = document.getElementById("revealBanner");
|
||
if ($rb) {
|
||
if (result.correct) {
|
||
$rb.textContent = "Richtig!";
|
||
$rb.className =
|
||
"inline-block rounded-md bg-emerald-600 text-white px-3 py-1 text-sm font-medium";
|
||
playCorrect();
|
||
celebrateCorrect(); // Auto 🎉 animation
|
||
} else {
|
||
$rb.textContent = "Falsch!";
|
||
$rb.className =
|
||
"inline-block rounded-md bg-rose-600 text-white px-3 py-1 text-sm font-medium";
|
||
playWrong();
|
||
}
|
||
}
|
||
// Note: placeArea visibility is now controlled by renderRoom() based on game phase
|
||
const rd = document.getElementById("recordDisc");
|
||
if (rd && track?.file) {
|
||
// Use track.file instead of track.id to include playlist folder prefix
|
||
const coverUrl = `/cover/${encodeURIComponent(track.file)}`;
|
||
const coverUrlWithTimestamp = `${coverUrl}?t=${Date.now()}`;
|
||
const img = new Image();
|
||
img.onload = () => {
|
||
rd.src = coverUrlWithTimestamp;
|
||
};
|
||
img.onerror = () => {
|
||
/* keep default logo */
|
||
};
|
||
img.src = coverUrlWithTimestamp;
|
||
}
|
||
}
|
||
|
||
export function handleGameEnded(msg) {
|
||
// Play winner fanfare
|
||
playWinner();
|
||
// Create and show the winner popup with confetti
|
||
showWinnerPopup(msg);
|
||
}
|
||
|
||
function showWinnerPopup(msg) {
|
||
const winnerName = msg.winnerName || shortName(msg.winner);
|
||
const score = msg.score ?? 0;
|
||
const timeline = msg.timeline || [];
|
||
|
||
// Create the popup overlay
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'winnerOverlay';
|
||
overlay.className = 'fixed inset-0 z-50 flex items-center justify-center p-4';
|
||
overlay.style.cssText = 'background: rgba(0,0,0,0.75); backdrop-filter: blur(4px);';
|
||
|
||
// Create timeline cards HTML
|
||
const timelineHtml = timeline.length > 0
|
||
? timeline.map(t => `
|
||
<div class="flex items-start gap-3 bg-white/10 rounded-lg px-3 py-2 w-full">
|
||
<div class="flex-shrink-0 font-bold tabular-nums bg-indigo-600 text-white rounded-md px-2 py-1 min-w-[48px] text-center text-sm">${t.year ?? '?'}</div>
|
||
<div class="flex-1 min-w-0 leading-tight text-left overflow-hidden">
|
||
<div class="font-semibold text-white text-sm break-words" style="word-break: break-word; hyphens: auto;">${escapeHtmlSimple(t.title || 'Unknown')}</div>
|
||
<div class="text-xs text-white/70 break-words" style="word-break: break-word;">${escapeHtmlSimple(t.artist || '')}</div>
|
||
</div>
|
||
</div>
|
||
`).join('')
|
||
: '<p class="text-white/60 text-sm">Keine Karten</p>';
|
||
|
||
overlay.innerHTML = `
|
||
<div id="confettiContainer" class="fixed inset-0 pointer-events-none overflow-hidden"></div>
|
||
<div class="relative bg-gradient-to-br from-indigo-600 via-purple-600 to-pink-600 rounded-2xl shadow-2xl max-w-md w-full p-6 text-center animate-popup">
|
||
<div class="absolute -top-6 left-1/2 -translate-x-1/2 text-6xl animate-bounce-slow">🏆</div>
|
||
<h2 class="text-3xl font-bold text-white mt-4 mb-2">Gewinner!</h2>
|
||
<p class="text-4xl font-extrabold text-yellow-300 mb-2">${escapeHtmlSimple(winnerName)}</p>
|
||
<p class="text-lg text-white/90 mb-4">Score: <span class="font-bold text-2xl">${score}</span> Karten</p>
|
||
<div class="bg-black/20 rounded-xl p-3 max-h-60 overflow-y-auto">
|
||
<h3 class="text-sm font-semibold text-white/80 mb-2">Zeitleiste</h3>
|
||
<div class="flex flex-col gap-2">
|
||
${timelineHtml}
|
||
</div>
|
||
</div>
|
||
<div class="mt-6 flex flex-col sm:flex-row gap-3 justify-center">
|
||
<button id="rematchBtn" class="px-6 py-3 bg-emerald-500 text-white font-bold rounded-xl hover:bg-emerald-600 transition-colors shadow-lg flex items-center justify-center gap-2">
|
||
🔄 Rematch
|
||
</button>
|
||
<button id="closeWinnerBtn" class="px-6 py-3 bg-white text-indigo-600 font-bold rounded-xl hover:bg-indigo-100 transition-colors shadow-lg">
|
||
Schließen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
|
||
document.body.appendChild(overlay);
|
||
|
||
// Start confetti animation
|
||
createConfetti();
|
||
|
||
// Rematch button handler
|
||
document.getElementById('rematchBtn').addEventListener('click', () => {
|
||
stopAudioPlayback(); // Stop the music
|
||
sendMsg({ type: 'rematch' });
|
||
overlay.remove();
|
||
});
|
||
|
||
// Close button handler
|
||
document.getElementById('closeWinnerBtn').addEventListener('click', () => {
|
||
overlay.remove();
|
||
});
|
||
|
||
// Close on overlay click (outside popup)
|
||
overlay.addEventListener('click', (e) => {
|
||
if (e.target === overlay) {
|
||
overlay.remove();
|
||
}
|
||
});
|
||
}
|
||
|
||
function createConfetti() {
|
||
const container = document.getElementById('confettiContainer');
|
||
if (!container) return;
|
||
|
||
const colors = ['#FFD700', '#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8'];
|
||
const confettiCount = 100;
|
||
|
||
for (let i = 0; i < confettiCount; i++) {
|
||
const confetti = document.createElement('div');
|
||
confetti.className = 'confetti-piece';
|
||
confetti.style.cssText = `
|
||
position: absolute;
|
||
width: ${Math.random() * 10 + 5}px;
|
||
height: ${Math.random() * 10 + 5}px;
|
||
background: ${colors[Math.floor(Math.random() * colors.length)]};
|
||
left: ${Math.random() * 100}%;
|
||
top: -20px;
|
||
border-radius: ${Math.random() > 0.5 ? '50%' : '2px'};
|
||
animation: confetti-fall ${Math.random() * 3 + 2}s linear forwards;
|
||
animation-delay: ${Math.random() * 0.5}s;
|
||
transform: rotate(${Math.random() * 360}deg);
|
||
`;
|
||
container.appendChild(confetti);
|
||
}
|
||
|
||
// Clean up confetti after animation
|
||
setTimeout(() => {
|
||
container.innerHTML = '';
|
||
}, 4000);
|
||
}
|
||
|
||
function escapeHtmlSimple(s) {
|
||
return String(s).replace(
|
||
/[&<>"']/g,
|
||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]
|
||
);
|
||
}
|
||
|
||
export function onMessage(ev) {
|
||
const msg = JSON.parse(ev.data);
|
||
switch (msg.type) {
|
||
case "resume_result": {
|
||
if (msg.ok) {
|
||
if (msg.playerId) {
|
||
state.playerId = msg.playerId;
|
||
try {
|
||
localStorage.setItem("playerId", msg.playerId);
|
||
} catch { }
|
||
}
|
||
const code =
|
||
msg.roomId || state.room?.id || localStorage.getItem("lastRoomId");
|
||
if (code) sendMsg({ type: "join_room", roomId: code });
|
||
if (state.room) {
|
||
try {
|
||
renderRoom(state.room);
|
||
} catch { }
|
||
}
|
||
// Restore player name after successful resume
|
||
import("./session.js").then(({ reusePlayerName }) => {
|
||
reusePlayerName();
|
||
});
|
||
} else {
|
||
// Resume failed - session expired or server restarted
|
||
// Clear stale session ID so next connect gets a fresh one
|
||
clearSessionId();
|
||
// Still try to join the last room
|
||
const code = state.room?.id || localStorage.getItem("lastRoomId");
|
||
if (code) sendMsg({ type: "join_room", roomId: code });
|
||
// Restore player name even on failed resume (new player, same name)
|
||
import("./session.js").then(({ reusePlayerName }) => {
|
||
reusePlayerName();
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
case "connected":
|
||
return handleConnected(msg);
|
||
case "room_update":
|
||
return handleRoomUpdate(msg);
|
||
case "play_track":
|
||
return handlePlayTrack(msg);
|
||
case "sync":
|
||
return handleSync(msg);
|
||
case "control":
|
||
return handleControl(msg);
|
||
case "reveal":
|
||
return handleReveal(msg);
|
||
case "game_ended":
|
||
return handleGameEnded(msg);
|
||
case "answer_result": {
|
||
if ($answerResult) {
|
||
if (!msg.ok) {
|
||
$answerResult.textContent =
|
||
"⛔ Eingabe ungültig oder gerade nicht möglich";
|
||
$answerResult.className = "mt-1 text-sm text-rose-600";
|
||
} else {
|
||
const okBoth = !!(msg.correctTitle && msg.correctArtist);
|
||
const parts = [];
|
||
parts.push(msg.correctTitle ? "Titel ✓" : "Titel ✗");
|
||
parts.push(msg.correctArtist ? "Künstler ✓" : "Künstler ✗");
|
||
let coin = "";
|
||
if (msg.awarded) {
|
||
coin = " +1 Token";
|
||
playCoinEarned();
|
||
} else if (msg.alreadyAwarded) {
|
||
coin = " (bereits erhalten)";
|
||
}
|
||
$answerResult.textContent = `${parts.join(" · ")}${coin}`;
|
||
$answerResult.className = okBoth
|
||
? "mt-1 text-sm text-emerald-600"
|
||
: "mt-1 text-sm text-amber-600";
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
default:
|
||
return;
|
||
case "reaction": {
|
||
// Show reaction from another player
|
||
showReaction(msg.emoji, msg.playerName);
|
||
return;
|
||
}
|
||
}
|
||
}
|