Files
AquaMasterMQTT/data/leaderboard.html
Carsten Graf 2d4831349b
All checks were successful
/ build (push) Successful in 3m58s
feat(tts): browser-based MP3 announcer for finished times
Plays the just-finished time via Web Audio API on index.html and on
new top-1 entries on leaderboard.html. Snippets are pre-rendered as
German neural-TTS MP3s (numbers 0-99 spoken naturally as
"vierzehn", "sechsundneunzig" etc.) and decoded into AudioBuffers
once at page load, then chained gaplessly via start(when, offset,
duration) — leading/trailing silence in each MP3 is detected and
skipped so words flow without pauses. A floating speaker toggle
persists in localStorage and doubles as the user gesture that
unlocks the AudioContext on autoplay-restricted browsers (SmartTV,
iOS Safari).

Hundredths formatting mirrors the ESP's float-truncation via
Math.fround so the announced value always matches the displayed
string, even at hundredths boundaries where double/float rounding
diverges. Preload runs at concurrency 2 with a 2 s start delay so
the 107 MP3 fetches don't starve /api/data and freeze the live
timer.

Regenerator script: tools/generate-tts.py (requires edge-tts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 20:21:49 +02:00

249 lines
8.1 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="de">
<head>
<!-- Meta Tags -->
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/x-icon" href="/pictures/favicon.ico" />
<!-- Stylesheets -->
<link rel="stylesheet" href="leaderboard.css" />
<title>Ninjacross Timer - Leaderboard</title>
<script src="/tts.js" defer></script>
</head>
<body>
<!-- Modern Notification Toast -->
<div
id="notificationBubble"
class="notification-toast"
style="display: none"
>
<div class="notification-icon">
<span id="notificationIcon"></span>
</div>
<div class="notification-body">
<div class="notification-title" id="notificationTitle">Erfolg</div>
<div class="notification-message" id="notificationText">Bereit</div>
</div>
<button class="notification-close" onclick="hideNotification()">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path
d="M8 8.707l3.646 3.647.708-.707L8.707 8l3.647-3.646-.707-.708L8 7.293 4.354 3.646l-.707.708L7.293 8l-3.646 3.646.707.708L8 8.707z"
/>
</svg>
</button>
</div>
<!-- Zurück Button -->
<a href="/" class="back-btn">🏠</a>
<div class="container">
<!-- Header Section -->
<div class="header">
<h1>🏆 Leaderboard</h1>
</div>
<div class="content">
<!-- Leaderboard Section -->
<div id="leaderboard-container" class="leaderboard-container">
<div class="loading">Lade Leaderboard...</div>
</div>
</div>
</div>
<!-- JavaScript Code -->
<script>
let leaderboardData = [];
let lastUpdateTime = null;
// Identität (name + Zeit) des aktuellen Top-1-Eintrags. Wechselt
// diese, sagen wir „neue Bestzeit + Zeit" an. Initial null →
// beim allerersten Poll wird nur registriert, nicht angesagt.
let lastTopId = null;
// Seite laden
window.onload = function () {
loadLeaderboard();
// Leaderboard alle 5 Sekunden aktualisieren
setInterval(loadLeaderboard, 5000);
};
// Leaderboard laden
async function loadLeaderboard() {
try {
const response = await fetch("/api/leaderboard-full");
const data = await response.json();
const wasFirstLoad = leaderboardData.length === 0 && lastTopId === null;
leaderboardData = data.leaderboard || [];
lastUpdateTime = new Date();
updateLeaderboardDisplay();
announceTopIfChanged(wasFirstLoad);
} catch (error) {
console.error("Fehler beim Laden des Leaderboards:", error);
showMessage("Fehler beim Laden des Leaderboards", "error");
}
}
// Wechselt der Top-1-Eintrag, kommt eine TTS-Ansage ("Neue Zeit
// + Zeit"). Beim allerersten Laden nur den Stand merken, sonst
// würde jeder Seitenaufruf die aktuelle Bestzeit erneut ansagen.
function announceTopIfChanged(isFirstLoad) {
const top = leaderboardData[0];
const newId = top ? `${top.name}::${top.timeFormatted}` : null;
if (lastTopId !== newId) {
if (!isFirstLoad && newId && window.tts && tts.isEnabled()) {
tts.sayTime(tts.parseFormattedTime(top.timeFormatted));
}
lastTopId = newId;
}
}
// Leaderboard anzeigen
function updateLeaderboardDisplay() {
const container = document.getElementById("leaderboard-container");
container.innerHTML = "";
if (leaderboardData.length === 0) {
container.innerHTML =
'<div class="no-entries">Noch keine Zeiten erfasst</div>';
return;
}
// Alle Einträge anzeigen
const displayData = leaderboardData;
// Erstelle zwei Reihen
const row1 = document.createElement("div");
row1.className = "leaderboard-row";
const row2 = document.createElement("div");
row2.className = "leaderboard-row";
displayData.forEach((entry, index) => {
const entryDiv = document.createElement("div");
entryDiv.className = "leaderboard-entry";
// Podium-Plätze hervorheben
if (index === 0) {
entryDiv.classList.add("gold");
} else if (index === 1) {
entryDiv.classList.add("silver");
} else if (index === 2) {
entryDiv.classList.add("bronze");
}
const rankSpan = document.createElement("span");
rankSpan.className = "rank";
rankSpan.textContent = entry.rank + ".";
const nameSpan = document.createElement("span");
nameSpan.className = "name";
nameSpan.textContent = entry.name;
const timeSpan = document.createElement("span");
timeSpan.className = "time";
timeSpan.textContent = entry.timeFormatted;
entryDiv.appendChild(rankSpan);
entryDiv.appendChild(nameSpan);
entryDiv.appendChild(timeSpan);
// Erste 5 Einträge in die erste Reihe, nächste 5 in die zweite Reihe
if (index < 5) {
row1.appendChild(entryDiv);
} else {
row2.appendChild(entryDiv);
}
});
container.appendChild(row1);
if (displayData.length > 5) {
container.appendChild(row2);
}
}
// Moderne Notification anzeigen
function showMessage(message, type = "info") {
console.log("showMessage called:", message, type);
const toast = document.getElementById("notificationBubble");
const icon = document.getElementById("notificationIcon");
const title = document.getElementById("notificationTitle");
const text = document.getElementById("notificationText");
if (!toast || !icon || !title || !text) {
console.error("Notification elements not found!");
return;
}
// Clear any existing timeout
if (window.notificationTimeout) {
clearTimeout(window.notificationTimeout);
}
// Set content
text.textContent = message;
// Set type-specific styling and content
toast.className = "notification-toast";
switch (type) {
case "success":
toast.classList.add("success");
icon.textContent = "✓";
title.textContent = "Erfolg";
break;
case "error":
toast.classList.add("error");
icon.textContent = "✕";
title.textContent = "Fehler";
break;
case "info":
toast.classList.add("info");
icon.textContent = "";
title.textContent = "Information";
break;
case "warning":
toast.classList.add("warning");
icon.textContent = "⚠";
title.textContent = "Warnung";
break;
default:
toast.classList.add("info");
icon.textContent = "";
title.textContent = "Information";
}
// Show toast with animation
toast.style.display = "flex";
// Force reflow
toast.offsetHeight;
// Add show class after a small delay to ensure display is set
setTimeout(() => {
toast.classList.add("show");
}, 10);
// Auto-hide after 5 seconds
window.notificationTimeout = setTimeout(() => {
hideNotification();
}, 5000);
}
// Notification verstecken mit Animation
function hideNotification() {
const toast = document.getElementById("notificationBubble");
if (!toast) return;
// Clear timeout if exists
if (window.notificationTimeout) {
clearTimeout(window.notificationTimeout);
}
// Remove show class for animation
toast.classList.remove("show");
// Hide after animation completes
setTimeout(() => {
toast.style.display = "none";
}, 400); // Match CSS transition duration
}
</script>
</body>
</html>