Compare commits
19 Commits
esp32thing
...
feat/tts-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d4831349b | ||
|
|
5beced0041 | ||
|
|
fd18d0cd22 | ||
| 455633178c | |||
| 9361cfdee6 | |||
| f558c64886 | |||
|
|
3400b9cc6a | ||
|
|
fa87fd0222 | ||
|
|
a6c885ee33 | ||
|
|
8acb611b9b | ||
|
|
68483c8127 | ||
|
|
781ad18c6a | ||
|
|
a875b20ba2 | ||
|
|
f6b2dceedc | ||
|
|
df95a37ca7 | ||
|
|
96fcb74c80 | ||
|
|
48ae556949 | ||
|
|
9d958c94f1 | ||
|
|
0223cceef8 |
18
.claude/settings.local.json
Normal file
18
.claude/settings.local.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(pio run:*)",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(where pio:*)",
|
||||
"Read(//c/Users/repti/.platformio/penv/Scripts/**)",
|
||||
"Bash(/c/Users/repti/.platformio/penv/Scripts/pio.exe run:*)",
|
||||
"Bash(python -c \"import reportlab\")",
|
||||
"Read(//c/Program Files/Google/Chrome/Application/**)",
|
||||
"Read(//c/Program Files \\(x86\\)/Microsoft/Edge/Application/**)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": true,
|
||||
"enabledMcpjsonServers": [
|
||||
"proxmox"
|
||||
]
|
||||
}
|
||||
BIN
AquaMaster-Update-Anleitung.pdf
Normal file
BIN
AquaMaster-Update-Anleitung.pdf
Normal file
Binary file not shown.
66
CLAUDE.md
Normal file
66
CLAUDE.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Projektüberblick
|
||||
|
||||
**AquaMaster MQTT** ist die ESP32-Firmware für die Master-Einheit eines "Aquacross / NinjaCross"-Sport-Timers (zwei Bahnen, Start/Stopp-Taster). Der Master stellt einen WiFi-AP und/oder STA bereit, hostet einen MQTT-Broker, einen Async-Webserver mit WebSocket-Live-Updates und kommuniziert mit batteriebetriebenen Funktastern. Die README.md im Repo-Root ist **veraltet/falsch** (Gitea-MCP-Text) — als Quelle für Projektkontext stattdessen `API.md`, `TODO.md`, `Bedienungsanleitung_NinjaCross_Timer.html` und den Code selbst nutzen.
|
||||
|
||||
## Build & Flash (PlatformIO)
|
||||
|
||||
Default-Environment ist `esp32thing_CI` (siehe `platformio.ini`). Weitere Envs: `wemos_d1_mini32`, `esp32thing`, `esp32thing_OTA` (OTA an `192.168.1.96`), `um_feathers3`, `um_feathers3_debug`.
|
||||
|
||||
```bash
|
||||
pio run # Default-Env bauen
|
||||
pio run -e esp32thing # Spezifisches Env
|
||||
pio run -e esp32thing -t upload # Flashen
|
||||
pio run -e esp32thing -t buildfs # SPIFFS-Image aus data/ bauen
|
||||
pio run -e esp32thing -t uploadfs # SPIFFS flashen (data/ → ESP)
|
||||
pio device monitor -b 115200 # Serieller Monitor
|
||||
pio run -e esp32thing_OTA -t upload # OTA-Upload (Ziel-IP in platformio.ini)
|
||||
```
|
||||
|
||||
Tests gibt es nicht — `test/` enthält nur ein leeres README.
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/build.yml` baut bei jedem Push `firmware.bin` und `spiffs.bin` mit `pio run -e esp32thing_CI` und erzeugt automatisch ein GitHub-Release mit Tag `esp32thing-<datum>-<sha7>`. Wenn der Build lokal funktioniert, aber CI nicht, ist `esp32thing_CI` (board=esp32dev, platform=espressif32) die maßgebliche Konfiguration.
|
||||
|
||||
## Architektur (das Wesentliche)
|
||||
|
||||
### Header-only-Pattern (wichtig!)
|
||||
|
||||
Es gibt nur **eine** `.cpp`-Datei: `src/master.cpp`. Alle anderen Module unter `src/*.h` enthalten sowohl Deklarationen *als auch* Implementierungen und definieren teilweise **globale Objekte** (z. B. `AsyncWebServer server(80)` in `webserverrouter.h`, `Preferences preferences` in `licenceing.h`, `PicoMQTT::Server mqtt` in `communication.h`). Konsequenzen:
|
||||
|
||||
- Jeder dieser Header darf **nur in `master.cpp`** inkludiert werden, sonst gibt es Multiple-Definition-Linkerfehler.
|
||||
- Header inkludieren sich gegenseitig (`master.h` ↔ `webserverrouter.h` ↔ `communication.h`). Beim Hinzufügen neuer Header die bestehende Include-Reihenfolge in `master.cpp` beibehalten.
|
||||
- Globale Timer-/Button-State-Variablen (`timerData1`, `timerData2`, `buttonConfigs`, `localTimes`, `learningMode`, `gamemode`, …) leben in `src/master.h` und werden überall direkt referenziert.
|
||||
|
||||
Wer eine neue Datei anlegt: entweder als weiteren Header dem Pattern folgen und in `master.cpp` einklinken, oder bewusst eine echte `.cpp` mit `extern`-Deklarationen erstellen.
|
||||
|
||||
### Laufzeit-Module
|
||||
|
||||
- **`master.cpp`** — `setup()`/`loop()`. Reihenfolge in `setup()` ist relevant (SPIFFS → API-Setups → `load*()` aus Preferences → WiFi → OTA → Routes → WebSocket → MQTT → RFID). `loop()` priorisiert MQTT vor WebSocket vor RFID.
|
||||
- **`communication.h`** — PicoMQTT-Broker. Tasten publishen auf `aquacross/button/<MAC>`; `readButtonJSON()` parst, ordnet die MAC einer der vier Rollen (`start1`/`stop1`/`start2`/`stop2`) zu und triggert die Timerlogik. Hält pro MAC `TimestampData` für Drift-Berechnung.
|
||||
- **`webserverrouter.h`** — `ESPAsyncWebServer` auf Port 80 + WebSocket `/ws`. Liefert statische Seiten aus SPIFFS (`/`, `/settings`, `/leaderboard`, `/rfid`) und alle `/api/...`-Endpunkte. Vollständige Routenliste in `API.md`.
|
||||
- **`wificlass.h`** — AP-Modus auf `192.168.10.1` (eindeutiger SSID-Suffix), STA-Fallback wenn gespeicherte Credentials vorhanden. Bindet `PrettyOTA` (lokale Bibliothek unter `lib/PrettyOTA/`) und mDNS ein.
|
||||
- **`preferencemanager.h`** — Persistierung in NVS (`Preferences`). Namespaces u. a. `buttons`, `leaderboard`, plus WiFi-/Location-/Settings-Slots. Beim Ändern persistierter Strukturen (z. B. `ButtonConfigs`) auf Größenkompatibilität achten — `loadButtonConfig()` lädt nur, wenn `getBytesLength == sizeof(buttonConfigs)`.
|
||||
- **`licenceing.h`** — HMAC-SHA256 (`mbedtls`) gegen `secret` über die STA-MAC; bestimmt Tier/Online-Funktionen. Lizenz wird zusammen mit jeder Backend-Anfrage als `Authorization: Bearer …` gesendet.
|
||||
- **`databasebackend.h`** — HTTPS-Client gegen `https://ninja.reptilfpv.de` (Locations, Leaderboard-Upload, Health). Funktioniert nur bei verbundenem STA + gültiger Lizenz.
|
||||
- **`rfid.h`** — Adafruit PN532 (I²C/SPI). Liest UIDs nur, wenn `isRFIDReadingActive()`; UID landet in `TimerData*::RFIDUID` und wird mit Namen aus `localUsers`/Backend verknüpft.
|
||||
- **`gamemodes.h`** — Modus `0=individual`, `1=wettkampf`; steuert, wann Timer als „bereit/armiert/laufend" gilt und wie Bestzeiten abgelegt werden (lokales `localTimes`-Vektor + optional Backend).
|
||||
- **`timesync.h`/`debug.h`/`statusled.h`/`battery.h`/`buttonassigh.h`/`helper.h`** — Hilfsmodule (NTP/Zeitzone, Debug-API, Status-LED, Akku, Lerne-Mode für Tasten-Zuordnung).
|
||||
|
||||
### Web-Frontend
|
||||
|
||||
`data/` enthält `index.html`, `settings.html`, `leaderboard.html`, `rfid.html` plus zugehörige CSS und ein `pictures/`-Verzeichnis. Diese Dateien werden via `pio run -t uploadfs` ins SPIFFS geschrieben und vom Webserver direkt ausgeliefert. **Frontend-Änderungen erfordern ein erneutes `uploadfs`** — ein normaler Firmware-Upload aktualisiert sie nicht.
|
||||
|
||||
`data/firmware.bin` wird unter `/firmware.bin` ausgeliefert (Buttons können sich darüber selbst aktualisieren).
|
||||
|
||||
## API
|
||||
|
||||
Vollständige HTTP-/WebSocket-API in `API.md` (autoritativ; `apientpoints` ist eine ältere Kurzversion). Alle POST-Routen erwarten **Form-Parameter, kein JSON-Body**. Antworten sind JSON, außer bei statischen Dateien.
|
||||
|
||||
## Sprache
|
||||
|
||||
Code-Kommentare und einige Variablennamen sind deutsch (`bahn`, `wettkampf`, „Anlernmodus"). Beim Erweitern bei der vorhandenen Sprache bleiben statt halb zu übersetzen.
|
||||
266
data/index.css
266
data/index.css
@@ -27,10 +27,12 @@ body {
|
||||
|
||||
.logo {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
/* Vertikal zentriert im 60px-Header-Bereich (top:20px, height:60px → Mitte 50px) */
|
||||
top: 50px;
|
||||
left: 20px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1000;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
@@ -43,7 +45,7 @@ body {
|
||||
}
|
||||
|
||||
.logo:hover {
|
||||
transform: scale(1.1);
|
||||
transform: translateY(-50%) scale(1.1);
|
||||
}
|
||||
|
||||
.logo img {
|
||||
@@ -105,17 +107,43 @@ body {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.live-clock {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
left: 25%;
|
||||
transform: translateX(-50%);
|
||||
height: 60px;
|
||||
min-width: 150px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 24px;
|
||||
z-index: 1000;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
border-radius: 30px;
|
||||
font-family: "Consolas", "Menlo", "Courier New", monospace;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 2px;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.heartbeat-indicators {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 160px;
|
||||
height: 60px;
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: flex-end;
|
||||
gap: 18px;
|
||||
z-index: 1000;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 25px;
|
||||
padding: 10px 20px;
|
||||
border-radius: 30px;
|
||||
padding: 0 24px 10px 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@@ -123,7 +151,8 @@ body {
|
||||
.logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
top: 15px;
|
||||
/* Mobile: Header-Band top:15px height:60px → Mitte 45px */
|
||||
top: 45px;
|
||||
left: 15px;
|
||||
padding: 3px;
|
||||
}
|
||||
@@ -142,22 +171,34 @@ body {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.live-clock {
|
||||
top: 15px;
|
||||
height: 40px;
|
||||
min-width: 100px;
|
||||
padding: 0 14px;
|
||||
font-size: 1rem;
|
||||
letter-spacing: 1px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.heartbeat-indicators {
|
||||
top: 15px;
|
||||
right: 90px;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
height: 60px;
|
||||
gap: 12px;
|
||||
padding: 0 16px 10px 16px;
|
||||
font-size: 0.8rem;
|
||||
border-radius: 30px;
|
||||
}
|
||||
|
||||
|
||||
.heartbeat-indicator {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
|
||||
.heartbeat-indicator::before {
|
||||
font-size: 8px;
|
||||
top: -20px;
|
||||
top: -14px;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
@@ -170,8 +211,8 @@ body {
|
||||
}
|
||||
|
||||
.heartbeat-indicator {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 50%;
|
||||
background: #f50f0f;
|
||||
transition: all 0.3s ease;
|
||||
@@ -181,7 +222,7 @@ body {
|
||||
.heartbeat-indicator::before {
|
||||
content: attr(data-label);
|
||||
position: absolute;
|
||||
top: -25px;
|
||||
top: -18px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 10px;
|
||||
@@ -472,10 +513,13 @@ body {
|
||||
}
|
||||
|
||||
.status.large-status.ready {
|
||||
font-size: clamp(2rem, 8vw, 8rem) !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
padding: 8px 12px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status.finished {
|
||||
@@ -538,41 +582,55 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.best-times {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 15px;
|
||||
padding: clamp(10px, 1.5vh, 15px);
|
||||
margin: 1vh 0 0 0;
|
||||
width: clamp(320px, 80vw, 960px);
|
||||
max-width: 960px;
|
||||
text-align: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: clamp(12px, 2vh, 20px);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#leaderboard-container {
|
||||
text-align: left;
|
||||
.leaderboards-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: clamp(12px, 2vh, 20px);
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: clamp(15px, 2vw, 30px);
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
padding: 0 2vw;
|
||||
margin-top: 0.5vh;
|
||||
box-sizing: border-box;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
#leaderboard-container {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
@media (max-width: 768px) {
|
||||
.leaderboards-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: clamp(15px, 3vw, 30px);
|
||||
padding: 0 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.best-times {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
backdrop-filter: blur(10px);
|
||||
border-radius: 12px;
|
||||
padding: clamp(6px, 1vh, 10px);
|
||||
text-align: center;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: clamp(4px, 0.8vh, 8px);
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.best-times--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.leaderboard-list {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: clamp(4px, 0.8vh, 8px);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.best-times h3 {
|
||||
font-size: clamp(0.9rem, 1.8vw, 1.1rem);
|
||||
font-size: clamp(0.7rem, 1.2vw, 0.85rem);
|
||||
margin: 0 auto;
|
||||
font-weight: bold;
|
||||
text-transform: uppercase;
|
||||
@@ -592,53 +650,140 @@ body {
|
||||
}
|
||||
|
||||
/* Leaderboard Styles */
|
||||
#leaderboard-container {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.leaderboard-entry {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: clamp(8px, 1vh, 12px) 0;
|
||||
font-size: clamp(1.1rem, 2.2vw, 1.4rem);
|
||||
margin: 0;
|
||||
font-size: clamp(0.7rem, 1.1vw, 0.9rem);
|
||||
font-weight: 600;
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
padding: clamp(12px, 2vh, 16px) clamp(16px, 3vw, 24px);
|
||||
border-radius: 10px;
|
||||
padding: clamp(4px, 0.8vh, 7px) clamp(8px, 1.2vw, 12px);
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||
transition: all 0.3s ease;
|
||||
min-height: 50px;
|
||||
min-height: 0;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.leaderboard-entry:hover {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.leaderboard-entry.latest {
|
||||
border: 2px solid #00ff88;
|
||||
animation: latest-pulse 1.6s ease-in-out infinite;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.leaderboard-entry.latest .name {
|
||||
color: #ffffff;
|
||||
font-weight: 800;
|
||||
text-shadow: 0 0 8px rgba(0, 255, 136, 0.7);
|
||||
}
|
||||
|
||||
.leaderboard-entry.latest .time {
|
||||
color: #ffffff;
|
||||
animation: latest-time-flash 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.latest-badge {
|
||||
display: inline-block;
|
||||
background: #00ff88;
|
||||
color: #0d1733;
|
||||
font-weight: 900;
|
||||
font-size: clamp(0.6rem, 1vw, 0.85rem);
|
||||
letter-spacing: 1px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
flex-shrink: 0;
|
||||
text-transform: uppercase;
|
||||
animation: latest-badge-pulse 1.6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes latest-pulse {
|
||||
0%,
|
||||
100% {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(0, 255, 136, 0.28) 0%,
|
||||
rgba(0, 200, 110, 0.18) 100%
|
||||
);
|
||||
box-shadow: 0 0 8px rgba(0, 255, 136, 0.35),
|
||||
inset 0 0 6px rgba(0, 255, 136, 0.18);
|
||||
border-color: #00ff88;
|
||||
}
|
||||
50% {
|
||||
background: linear-gradient(
|
||||
135deg,
|
||||
rgba(0, 255, 136, 0.5) 0%,
|
||||
rgba(0, 230, 120, 0.32) 100%
|
||||
);
|
||||
box-shadow: 0 0 16px rgba(0, 255, 136, 0.6),
|
||||
0 0 32px rgba(0, 255, 136, 0.3),
|
||||
inset 0 0 10px rgba(255, 255, 255, 0.25);
|
||||
border-color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes latest-badge-pulse {
|
||||
0%,
|
||||
100% {
|
||||
background: #00ff88;
|
||||
color: #0d1733;
|
||||
box-shadow: 0 0 5px rgba(0, 255, 136, 0.5);
|
||||
}
|
||||
50% {
|
||||
background: #ffffff;
|
||||
color: #006a3a;
|
||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.7),
|
||||
0 0 16px rgba(0, 255, 136, 0.55);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes latest-time-flash {
|
||||
0%,
|
||||
100% {
|
||||
text-shadow: 0 0 6px rgba(0, 255, 136, 0.55);
|
||||
}
|
||||
50% {
|
||||
text-shadow: 0 0 8px #ffffff, 0 0 14px rgba(0, 255, 136, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
.leaderboard-entry .rank {
|
||||
color: #ffd700;
|
||||
font-weight: bold;
|
||||
min-width: 30px;
|
||||
font-size: clamp(1.2rem, 2.4vw, 1.5rem);
|
||||
min-width: 20px;
|
||||
font-size: clamp(0.75rem, 1.2vw, 0.95rem);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.leaderboard-entry .name {
|
||||
flex: 1;
|
||||
margin: 0 15px;
|
||||
margin: 0;
|
||||
color: #ffffff;
|
||||
font-weight: 600;
|
||||
font-weight: 500;
|
||||
font-size: clamp(0.7rem, 1.1vw, 0.9rem);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.leaderboard-entry .time {
|
||||
color: #00ff88;
|
||||
font-weight: bold;
|
||||
font-family: 'Courier New', monospace;
|
||||
min-width: 80px;
|
||||
min-width: 70px;
|
||||
text-align: right;
|
||||
font-size: clamp(1rem, 1.8vw, 1.3rem);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.leaderboard-entry.gold {
|
||||
@@ -703,6 +848,7 @@ body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
|
||||
.learning-mode {
|
||||
background: rgba(245, 157, 15, 0.2);
|
||||
border: 2px solid #f59d0f;
|
||||
|
||||
493
data/index.html
493
data/index.html
@@ -24,8 +24,10 @@
|
||||
</div>
|
||||
|
||||
<img src="/pictures/erlebniss.png" class="logo" alt="NinjaCross Logo" />
|
||||
<div id="live-clock" class="live-clock">--:--:--</div>
|
||||
<a href="/leaderboard.html" class="leaderboard-btn">🏆</a>
|
||||
<a href="/settings" class="settings-btn">⚙️</a>
|
||||
<script src="/tts.js" defer></script>
|
||||
|
||||
<div class="heartbeat-indicators">
|
||||
<div
|
||||
@@ -44,7 +46,6 @@
|
||||
|
||||
<div class="header">
|
||||
<h1>🏊♀️ NinjaCross Timer</h1>
|
||||
<p>Dein professioneller Zeitmesser für Ninjacross Wettkämpfe</p>
|
||||
</div>
|
||||
|
||||
<div id="learning-display" class="learning-mode" style="display: none">
|
||||
@@ -72,9 +73,15 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="best-times">
|
||||
<h3>🏆 Lokales Leaderboard</h3>
|
||||
<div id="leaderboard-container"></div>
|
||||
<div class="leaderboards-row">
|
||||
<div class="best-times" id="best-times-1">
|
||||
<h3 id="lb-title-1">🏊♀️ Bahn 1 — Letzte Zeiten</h3>
|
||||
<div id="leaderboard-container-1" class="leaderboard-list"></div>
|
||||
</div>
|
||||
<div class="best-times" id="best-times-2">
|
||||
<h3 id="lb-title-2">🏊♂️ Bahn 2 — Letzte Zeiten</h3>
|
||||
<div id="leaderboard-container-2" class="leaderboard-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -90,7 +97,7 @@
|
||||
let learningButton = "";
|
||||
let name1 = "";
|
||||
let name2 = "";
|
||||
let leaderboardData = [];
|
||||
let leaderboardData = null;
|
||||
|
||||
// Lane Configuration
|
||||
let laneConfigType = 0; // 0=Identical, 1=Different
|
||||
@@ -162,6 +169,12 @@
|
||||
document.getElementById(indicatorId).classList.remove("active");
|
||||
}
|
||||
}
|
||||
// Hinweis: Heartbeats und echte Tastendrücke kommen im WebSocket
|
||||
// identisch als {button, mac, active: true} an. Eine optimistische
|
||||
// Status-Übernahme (z. B. running→finished bei stop1) führte daher
|
||||
// zu kurzem „Geschafft!"-Aufblitzen während des Laufs, sobald der
|
||||
// Stop-Button einen periodischen Heartbeat sendete. Der Status
|
||||
// kommt jetzt ausschließlich über syncFromBackend (1 s-Polling).
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -329,6 +342,109 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Passt "Bereit" so an, dass es die Status-Box maximal ausfüllt.
|
||||
// Nutzt echte DOM-Messung via unsichtbarem Span → berechnet den
|
||||
// Skalierungsfaktor und setzt font-size pixelgenau.
|
||||
const fitReadyCache = { 1: { w: 0, h: 0, fs: 0 }, 2: { w: 0, h: 0, fs: 0 } };
|
||||
function fitReadyText(statusEl, laneEl, laneNum) {
|
||||
// Wir messen die Status-Box selbst — sie wurde vorher bereits
|
||||
// positioniert (top/bottom/width gesetzt), hat also ihre finale Größe.
|
||||
const sw = statusEl.clientWidth;
|
||||
const sh = statusEl.clientHeight;
|
||||
if (!sw || !sh) return;
|
||||
|
||||
const cache = fitReadyCache[laneNum];
|
||||
if (cache.w === sw && cache.h === sh) {
|
||||
statusEl.style.fontSize = cache.fs + "px";
|
||||
return;
|
||||
}
|
||||
|
||||
// Innenraum der Status-Box (nach Padding)
|
||||
const cs = window.getComputedStyle(statusEl);
|
||||
const pL = parseFloat(cs.paddingLeft) || 0;
|
||||
const pR = parseFloat(cs.paddingRight) || 0;
|
||||
const pT = parseFloat(cs.paddingTop) || 0;
|
||||
const pB = parseFloat(cs.paddingBottom) || 0;
|
||||
const availW = sw - pL - pR - 6;
|
||||
const availH = sh - pT - pB - 6;
|
||||
if (availW <= 0 || availH <= 0) return;
|
||||
|
||||
// Unsichtbarer Messspan im selben Font
|
||||
let m = fitReadyText.m;
|
||||
if (!m) {
|
||||
m = document.createElement("span");
|
||||
m.style.cssText =
|
||||
"position:absolute;visibility:hidden;white-space:nowrap;" +
|
||||
"left:-99999px;top:0;font-family:'Segoe UI',Arial,sans-serif;" +
|
||||
"font-weight:600;line-height:1;padding:0;margin:0";
|
||||
m.textContent = "Bereit";
|
||||
document.body.appendChild(m);
|
||||
fitReadyText.m = m;
|
||||
}
|
||||
const refSize = 200;
|
||||
m.style.fontSize = refSize + "px";
|
||||
const textW = m.offsetWidth || 1;
|
||||
const textH = m.offsetHeight || 1;
|
||||
|
||||
// Skalierungsfaktor so wählen, dass Breite UND Höhe passen
|
||||
const scale = Math.min(availW / textW, availH / textH);
|
||||
const finalFs = Math.max(20, Math.floor(refSize * scale));
|
||||
|
||||
cache.w = sw;
|
||||
cache.h = sh;
|
||||
cache.fs = finalFs;
|
||||
statusEl.style.fontSize = finalFs + "px";
|
||||
}
|
||||
|
||||
// Passt die Timer-Zeit (Courier-Monospace) so an, dass sie den Platz
|
||||
// zwischen h2 und Status maximal ausnutzt.
|
||||
const fitTimeCache = {
|
||||
1: { len: 0, lw: 0, lh: 0, fs: 0 },
|
||||
2: { len: 0, lw: 0, lh: 0, fs: 0 },
|
||||
};
|
||||
function fitTimeText(timeEl, laneEl, laneNum) {
|
||||
const text = timeEl.textContent;
|
||||
const len = text.length;
|
||||
const lw = laneEl.clientWidth;
|
||||
const lh = laneEl.clientHeight;
|
||||
if (!lw || !lh) return;
|
||||
|
||||
const cache = fitTimeCache[laneNum];
|
||||
if (cache.len === len && cache.lw === lw && cache.lh === lh) {
|
||||
timeEl.style.fontSize = cache.fs + "px";
|
||||
return;
|
||||
}
|
||||
|
||||
let m = fitTimeText.m;
|
||||
if (!m) {
|
||||
m = document.createElement("span");
|
||||
m.style.cssText =
|
||||
"position:absolute;visibility:hidden;white-space:nowrap;" +
|
||||
"left:-99999px;top:0;font-family:'Courier New',monospace;" +
|
||||
"font-weight:bold;line-height:1;padding:0;margin:0";
|
||||
document.body.appendChild(m);
|
||||
fitTimeText.m = m;
|
||||
}
|
||||
const refSize = 200;
|
||||
m.style.fontSize = refSize + "px";
|
||||
m.textContent = text;
|
||||
const textW = m.offsetWidth || 1;
|
||||
const textH = m.offsetHeight || 1;
|
||||
|
||||
// Aggressiv: 92% Breite, 62% Höhe (h2 oben + Status unten reserviert)
|
||||
const availW = lw * 0.92;
|
||||
const availH = lh * 0.62;
|
||||
|
||||
const scale = Math.min(availW / textW, availH / textH);
|
||||
const fs = Math.max(30, Math.floor(refSize * scale));
|
||||
|
||||
cache.len = len;
|
||||
cache.lw = lw;
|
||||
cache.lh = lh;
|
||||
cache.fs = fs;
|
||||
timeEl.style.fontSize = fs + "px";
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
if (seconds === 0) return "00.00";
|
||||
|
||||
@@ -353,70 +469,232 @@
|
||||
async function loadLeaderboard() {
|
||||
try {
|
||||
const response = await fetch("/api/leaderboard");
|
||||
const data = await response.json();
|
||||
leaderboardData = data.leaderboard || [];
|
||||
leaderboardData = await response.json();
|
||||
updateLeaderboardDisplay();
|
||||
} catch (error) {
|
||||
console.error("Fehler beim Laden des Leaderboards:", error);
|
||||
}
|
||||
}
|
||||
|
||||
function updateLeaderboardDisplay() {
|
||||
const container = document.getElementById("leaderboard-container");
|
||||
container.innerHTML = "";
|
||||
function formatEndTime(epochSeconds) {
|
||||
if (!epochSeconds || epochSeconds < 1577836800) return ""; // < 2020 = kein NTP-Sync
|
||||
const d = new Date(epochSeconds * 1000);
|
||||
const hh = String(d.getHours()).padStart(2, "0");
|
||||
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||
return `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
|
||||
if (leaderboardData.length === 0) {
|
||||
container.innerHTML =
|
||||
'<div class="no-times">Noch keine Zeiten erfasst</div>';
|
||||
function createEntryElement(entry, isLatest) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "leaderboard-entry";
|
||||
if (isLatest) div.classList.add("latest");
|
||||
|
||||
if (isLatest) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "latest-badge";
|
||||
badge.textContent = "NEU";
|
||||
div.appendChild(badge);
|
||||
}
|
||||
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = "name";
|
||||
let label = entry.name || "Unbekannt";
|
||||
// Bei "Lauf N"-Einträgen die Endzeit in Klammern anhängen
|
||||
if (/^Lauf\s+\d+$/.test(label)) {
|
||||
const endTime = formatEndTime(entry.endEpoch);
|
||||
if (endTime) label += ` (${endTime})`;
|
||||
}
|
||||
nameSpan.textContent = label;
|
||||
|
||||
const timeSpan = document.createElement("span");
|
||||
timeSpan.className = "time";
|
||||
timeSpan.textContent = entry.timeFormatted;
|
||||
|
||||
div.appendChild(nameSpan);
|
||||
div.appendChild(timeSpan);
|
||||
return div;
|
||||
}
|
||||
|
||||
function fillLeaderboardContainer(container, entries) {
|
||||
container.innerHTML = "";
|
||||
if (!entries || entries.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "no-times";
|
||||
empty.textContent = "Noch keine Zeiten";
|
||||
container.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
entries.forEach((e, i) =>
|
||||
container.appendChild(createEntryElement(e, i === 0))
|
||||
);
|
||||
}
|
||||
|
||||
// -------- Fly-down Animation --------
|
||||
// Wird ausgelöst beim Status-Übergang finished -> ready (kurz vor dem
|
||||
// Auto-Reset des Backends). Damit bleibt die große Zeit oben sichtbar
|
||||
// bis der Backend resettet, und fliegt dann erst nach unten.
|
||||
|
||||
// Snapshot der Quelle einfrieren, BEVOR sie versteckt wird.
|
||||
function captureSourceSnapshot(el) {
|
||||
if (!el) return null;
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.width === 0 || rect.height === 0) return null;
|
||||
const cs = window.getComputedStyle(el);
|
||||
return {
|
||||
rect,
|
||||
fontSize: cs.fontSize,
|
||||
fontFamily: cs.fontFamily,
|
||||
fontWeight: cs.fontWeight,
|
||||
color: cs.color,
|
||||
text: el.textContent,
|
||||
};
|
||||
}
|
||||
|
||||
function flyDownFromSnapshot(srcSnap, destEl) {
|
||||
if (!srcSnap || !destEl) return;
|
||||
const dstRect = destEl.getBoundingClientRect();
|
||||
if (dstRect.width === 0 || dstRect.height === 0) return;
|
||||
|
||||
// ---- Phase 1: bestehende Einträge nach unten "schieben" ----
|
||||
// Wir verstecken den (bereits gerenderten) neuen Top-Eintrag und
|
||||
// setzen die Geschwister visuell an die Position, die sie VOR
|
||||
// dem neuen Eintrag hatten (eine Slot-Höhe nach oben). Dann
|
||||
// gleiten sie animiert in ihre natürliche Position herunter.
|
||||
const container = destEl.parentElement;
|
||||
const siblings = container
|
||||
? Array.from(
|
||||
container.querySelectorAll(".leaderboard-entry")
|
||||
).filter((e) => e !== destEl)
|
||||
: [];
|
||||
|
||||
let shiftPx = dstRect.height;
|
||||
if (container) {
|
||||
const cs = window.getComputedStyle(container);
|
||||
const gap =
|
||||
parseFloat(cs.rowGap) || parseFloat(cs.gap) || 0;
|
||||
shiftPx += gap;
|
||||
}
|
||||
|
||||
// Dest sofort verstecken (Layout-Slot bleibt erhalten)
|
||||
destEl.style.visibility = "hidden";
|
||||
|
||||
// Geschwister hochsetzen (instant, ohne Transition)
|
||||
siblings.forEach((s) => {
|
||||
s.style.transition = "none";
|
||||
s.style.transform = `translateY(-${shiftPx}px)`;
|
||||
});
|
||||
// Reflow, damit der "instant"-Setup wirkt
|
||||
if (siblings.length > 0) siblings[0].getBoundingClientRect();
|
||||
|
||||
// Slide nach unten zur natürlichen Position
|
||||
const slideMs = 280;
|
||||
siblings.forEach((s) => {
|
||||
s.style.transition = `transform ${slideMs}ms cubic-bezier(0.4, 0, 0.2, 1)`;
|
||||
s.style.transform = "translateY(0)";
|
||||
});
|
||||
|
||||
// ---- Phase 2: nach dem Slide den Ghost einfliegen lassen ----
|
||||
const flyMs = 800;
|
||||
setTimeout(() => {
|
||||
const ghost = document.createElement("div");
|
||||
ghost.textContent = srcSnap.text;
|
||||
ghost.style.position = "fixed";
|
||||
ghost.style.left = srcSnap.rect.left + "px";
|
||||
ghost.style.top = srcSnap.rect.top + "px";
|
||||
ghost.style.width = srcSnap.rect.width + "px";
|
||||
ghost.style.height = srcSnap.rect.height + "px";
|
||||
ghost.style.margin = "0";
|
||||
ghost.style.padding = "0";
|
||||
ghost.style.zIndex = "9999";
|
||||
ghost.style.pointerEvents = "none";
|
||||
ghost.style.color = srcSnap.color;
|
||||
ghost.style.fontFamily = srcSnap.fontFamily;
|
||||
ghost.style.fontWeight = srcSnap.fontWeight;
|
||||
ghost.style.fontSize = srcSnap.fontSize;
|
||||
ghost.style.lineHeight = "1";
|
||||
ghost.style.display = "flex";
|
||||
ghost.style.alignItems = "center";
|
||||
ghost.style.justifyContent = "center";
|
||||
ghost.style.textShadow = "0 0 12px rgba(0, 255, 136, 0.8)";
|
||||
ghost.style.borderRadius = "8px";
|
||||
ghost.style.transition =
|
||||
`left ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
|
||||
`top ${flyMs}ms cubic-bezier(0.55, 0.05, 0.3, 1.1),` +
|
||||
`width ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
|
||||
`height ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
|
||||
`font-size ${flyMs}ms cubic-bezier(0.55, 0, 0.3, 1),` +
|
||||
`color ${flyMs}ms ease-out,` +
|
||||
`text-shadow ${flyMs}ms ease-out`;
|
||||
document.body.appendChild(ghost);
|
||||
|
||||
// Reflow erzwingen, damit die Anfangsposition wirkt
|
||||
ghost.getBoundingClientRect();
|
||||
|
||||
// Endwerte: aktuelle Position des .time-Spans im Eintrag
|
||||
const destTimeSpan = destEl.querySelector(".time") || destEl;
|
||||
const destTimeRect = destTimeSpan.getBoundingClientRect();
|
||||
const destStyle = window.getComputedStyle(destTimeSpan);
|
||||
|
||||
ghost.style.left = destTimeRect.left + "px";
|
||||
ghost.style.top = destTimeRect.top + "px";
|
||||
ghost.style.width = destTimeRect.width + "px";
|
||||
ghost.style.height = destTimeRect.height + "px";
|
||||
ghost.style.fontSize = destStyle.fontSize;
|
||||
ghost.style.color = destStyle.color;
|
||||
ghost.style.textShadow = "0 0 6px rgba(0, 255, 136, 0.55)";
|
||||
|
||||
setTimeout(() => {
|
||||
ghost.remove();
|
||||
destEl.style.visibility = "";
|
||||
// Sibling-Transforms aufräumen (sie sind eh schon bei 0)
|
||||
siblings.forEach((s) => {
|
||||
s.style.transition = "";
|
||||
s.style.transform = "";
|
||||
});
|
||||
}, flyMs + 20);
|
||||
}, slideMs);
|
||||
}
|
||||
|
||||
// Bestimmt das Ziel-Element (erster Leaderboard-Eintrag) für eine Lane.
|
||||
function findFlyDest(lane) {
|
||||
const containerId =
|
||||
leaderboardData && leaderboardData.mode === "different"
|
||||
? "leaderboard-container-" + lane
|
||||
: "leaderboard-container-1";
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return null;
|
||||
return container.querySelector(".leaderboard-entry");
|
||||
}
|
||||
|
||||
function updateLeaderboardDisplay() {
|
||||
const box1 = document.getElementById("best-times-1");
|
||||
const box2 = document.getElementById("best-times-2");
|
||||
const container1 = document.getElementById("leaderboard-container-1");
|
||||
const container2 = document.getElementById("leaderboard-container-2");
|
||||
const title1 = document.getElementById("lb-title-1");
|
||||
const title2 = document.getElementById("lb-title-2");
|
||||
|
||||
if (!leaderboardData) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Erstelle zwei Reihen für 2x3 Layout
|
||||
const row1 = document.createElement("div");
|
||||
row1.className = "leaderboard-row";
|
||||
const row2 = document.createElement("div");
|
||||
row2.className = "leaderboard-row";
|
||||
// Reset Layout-Klassen
|
||||
box1.classList.remove("best-times--full");
|
||||
box2.style.display = "";
|
||||
|
||||
leaderboardData.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 3 Einträge in die erste Reihe, nächste 3 in die zweite Reihe
|
||||
if (index < 3) {
|
||||
row1.appendChild(entryDiv);
|
||||
} else if (index < 6) {
|
||||
row2.appendChild(entryDiv);
|
||||
}
|
||||
});
|
||||
|
||||
container.appendChild(row1);
|
||||
if (leaderboardData.length > 3) {
|
||||
container.appendChild(row2);
|
||||
if (leaderboardData.mode === "different") {
|
||||
// Unterschiedliche Lanes: eigene History pro Bahn unter jeder Lane
|
||||
title1.textContent = "🏊♀️ Bahn 1 — Letzte Zeiten";
|
||||
title2.textContent = "🏊♂️ Bahn 2 — Letzte Zeiten";
|
||||
fillLeaderboardContainer(container1, leaderboardData.lane1);
|
||||
fillLeaderboardContainer(container2, leaderboardData.lane2);
|
||||
} else {
|
||||
// Identische Lanes: ein gemeinsames Leaderboard über beide Spalten
|
||||
title1.textContent = "🏆 Letzte Zeiten";
|
||||
box1.classList.add("best-times--full");
|
||||
box2.style.display = "none";
|
||||
fillLeaderboardContainer(container1, leaderboardData.entries);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -484,7 +762,7 @@
|
||||
s1.style.display = "flex";
|
||||
s1.style.alignItems = "center";
|
||||
s1.style.justifyContent = "center";
|
||||
s1.style.fontSize = "clamp(2rem, 8vw, 8rem)";
|
||||
fitReadyText(s1, lane1Element, 1);
|
||||
} else {
|
||||
// Bei anderen Status (running, finished, etc.) zeige Zeit wieder an
|
||||
time1Element.style.display = "";
|
||||
@@ -522,12 +800,13 @@
|
||||
s1.style.fontSize = "";
|
||||
s1.style.left = "";
|
||||
s1.style.bottom = "";
|
||||
fitTimeText(time1Element, lane1Element, 1);
|
||||
}
|
||||
}
|
||||
|
||||
switch (status1) {
|
||||
case "ready":
|
||||
s1.textContent = "Bereit für den Start!";
|
||||
s1.textContent = "Bereit";
|
||||
break;
|
||||
case "running":
|
||||
s1.textContent = "Läuft - Gib alles!";
|
||||
@@ -587,7 +866,7 @@
|
||||
s2.style.display = "flex";
|
||||
s2.style.alignItems = "center";
|
||||
s2.style.justifyContent = "center";
|
||||
s2.style.fontSize = "clamp(2rem, 8vw, 8rem)";
|
||||
fitReadyText(s2, lane2Element, 2);
|
||||
} else {
|
||||
// Bei anderen Status (running, finished, etc.) zeige Zeit wieder an
|
||||
time2Element.style.display = "";
|
||||
@@ -625,12 +904,13 @@
|
||||
s2.style.fontSize = "";
|
||||
s2.style.left = "";
|
||||
s2.style.bottom = "";
|
||||
fitTimeText(time2Element, lane2Element, 2);
|
||||
}
|
||||
}
|
||||
|
||||
switch (status2) {
|
||||
case "ready":
|
||||
s2.textContent = "Bereit für den Start!";
|
||||
s2.textContent = "Bereit";
|
||||
break;
|
||||
case "running":
|
||||
s2.textContent = "Läuft - Gib alles!";
|
||||
@@ -677,20 +957,63 @@
|
||||
}
|
||||
}
|
||||
|
||||
const validStatuses = ["ready", "running", "finished", "armed"];
|
||||
|
||||
function syncFromBackend() {
|
||||
fetch("/api/data")
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
timer1 = data.time1;
|
||||
timer2 = data.time2;
|
||||
status1 = data.status1;
|
||||
status2 = data.status2;
|
||||
|
||||
// Alte Status-Werte sichern, BEVOR sie überschrieben werden
|
||||
const oldStatus1 = status1;
|
||||
const oldStatus2 = status2;
|
||||
|
||||
// Status nur übernehmen, wenn der Wert gültig ist.
|
||||
// Bei unvollständiger ESP-Response (Last) bleibt der
|
||||
// bisherige Status erhalten statt "Status unbekannt".
|
||||
if (validStatuses.includes(data.status1)) status1 = data.status1;
|
||||
if (validStatuses.includes(data.status2)) status2 = data.status2;
|
||||
best1 = data.best1;
|
||||
best2 = data.best2;
|
||||
|
||||
// TTS: bei Übergang running -> finished die Endzeit ansagen
|
||||
// ("Neue Zeit: ..."). Bahn-/Status-Phrasen werden bewusst
|
||||
// weggelassen.
|
||||
if (window.tts && tts.isEnabled()) {
|
||||
if (oldStatus1 === 'running' && status1 === 'finished' && data.time1 > 0) {
|
||||
tts.sayTime(data.time1);
|
||||
}
|
||||
if (oldStatus2 === 'running' && status2 === 'finished' && data.time2 > 0) {
|
||||
tts.sayTime(data.time2);
|
||||
}
|
||||
}
|
||||
learningMode = data.learningMode;
|
||||
learningButton = data.learningButton || "";
|
||||
lastSync = Date.now();
|
||||
updateDisplay();
|
||||
|
||||
// Übergang finished -> ready erkennen.
|
||||
// Snapshot der großen Zeit JETZT einfrieren, bevor
|
||||
// kickDisplayScheduler/updateDisplay sie versteckt.
|
||||
const fly1 =
|
||||
oldStatus1 === "finished" && status1 === "ready"
|
||||
? captureSourceSnapshot(document.getElementById("time1"))
|
||||
: null;
|
||||
const fly2 =
|
||||
oldStatus2 === "finished" && status2 === "ready"
|
||||
? captureSourceSnapshot(document.getElementById("time2"))
|
||||
: null;
|
||||
|
||||
kickDisplayScheduler();
|
||||
|
||||
// Animation auf nächsten Frame, wenn updateDisplay durch ist
|
||||
if (fly1 || fly2) {
|
||||
requestAnimationFrame(() => {
|
||||
if (fly1) flyDownFromSnapshot(fly1, findFlyDest(1));
|
||||
if (fly2) flyDownFromSnapshot(fly2, findFlyDest(2));
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((error) =>
|
||||
console.error("Fehler beim Laden deiner Daten:", error)
|
||||
@@ -739,8 +1062,24 @@
|
||||
// Sync with backend every 1 second
|
||||
setInterval(syncFromBackend, 1000);
|
||||
|
||||
// Smooth update every 50ms
|
||||
setInterval(updateDisplay, 50);
|
||||
// Adaptive Update-Rate: 50 ms wenn mindestens eine Bahn läuft,
|
||||
// sonst 500 ms. Über kickDisplayScheduler() kann der Zyklus sofort
|
||||
// neu gestartet werden (WebSocket-Start-Event, frische Sync-Daten),
|
||||
// damit beim Übergang Stand→Lauf nichts springt.
|
||||
let displayTimer = null;
|
||||
function scheduleDisplayUpdate() {
|
||||
updateDisplay();
|
||||
const anyRunning = status1 === "running" || status2 === "running";
|
||||
displayTimer = setTimeout(scheduleDisplayUpdate, anyRunning ? 50 : 500);
|
||||
}
|
||||
function kickDisplayScheduler() {
|
||||
if (displayTimer !== null) {
|
||||
clearTimeout(displayTimer);
|
||||
displayTimer = null;
|
||||
}
|
||||
scheduleDisplayUpdate();
|
||||
}
|
||||
scheduleDisplayUpdate();
|
||||
|
||||
// Heartbeat timeout check (every second)
|
||||
setInterval(() => {
|
||||
@@ -757,6 +1096,14 @@
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
window.addEventListener("resize", () => {
|
||||
fitReadyCache[1].w = 0;
|
||||
fitReadyCache[2].w = 0;
|
||||
fitTimeCache[1].lw = 0;
|
||||
fitTimeCache[2].lw = 0;
|
||||
updateDisplay();
|
||||
});
|
||||
|
||||
// Initial load
|
||||
syncFromBackend();
|
||||
loadLaneConfig();
|
||||
@@ -764,6 +1111,18 @@
|
||||
|
||||
// Leaderboard alle 5 Sekunden aktualisieren
|
||||
setInterval(loadLeaderboard, 5000);
|
||||
|
||||
// Live-Uhr im Header (HH:mm:ss, Browser-Lokalzeit)
|
||||
function updateLiveClock() {
|
||||
const now = new Date();
|
||||
const hh = String(now.getHours()).padStart(2, "0");
|
||||
const mm = String(now.getMinutes()).padStart(2, "0");
|
||||
const ss = String(now.getSeconds()).padStart(2, "0");
|
||||
const el = document.getElementById("live-clock");
|
||||
if (el) el.textContent = `${hh}:${mm}:${ss}`;
|
||||
}
|
||||
updateLiveClock();
|
||||
setInterval(updateLiveClock, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<!-- Stylesheets -->
|
||||
<link rel="stylesheet" href="leaderboard.css" />
|
||||
<title>Ninjacross Timer - Leaderboard</title>
|
||||
<script src="/tts.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Modern Notification Toast -->
|
||||
@@ -54,6 +55,10 @@
|
||||
<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 () {
|
||||
@@ -67,15 +72,31 @@
|
||||
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");
|
||||
|
||||
308
data/tts.js
Normal file
308
data/tts.js
Normal file
@@ -0,0 +1,308 @@
|
||||
// tts.js — Plays pre-rendered MP3 snippets from /tts/ gaplessly.
|
||||
// Uses the Web Audio API: each MP3 is decoded once into an AudioBuffer,
|
||||
// then chained playback is scheduled with start(when) so there is no
|
||||
// JS-callback gap between snippets. Persists enable-state in
|
||||
// localStorage. The toggle button doubles as the user gesture that
|
||||
// unlocks the AudioContext on browsers with autoplay restrictions
|
||||
// (iOS Safari, many SmartTV browsers).
|
||||
(() => {
|
||||
'use strict';
|
||||
|
||||
const BASE = '/tts/';
|
||||
const STORAGE_KEY = 'aqm_tts_enabled';
|
||||
// All snippets shipped in /tts/. Listed explicitly so we can
|
||||
// preload (and decode) them upfront before the first announcement.
|
||||
// Numbers 0-99 are spoken as natural German words ("vierzehn",
|
||||
// "sechsundneunzig"), produced by tools/generate-tts.py.
|
||||
const FILES = [
|
||||
'bereit', 'komma', 'minute', 'minuten',
|
||||
'neue_zeit', 'sekunden', 'und',
|
||||
];
|
||||
for (let i = 0; i < 100; i++) FILES.push(String(i));
|
||||
|
||||
let enabled = localStorage.getItem(STORAGE_KEY) === '1';
|
||||
let audioCtx = null;
|
||||
// For each name we cache { buffer, offset, duration } where offset
|
||||
// and duration mark the non-silent region of the decoded buffer.
|
||||
// Edge-TTS pads each MP3 with ~50-150 ms of leading/trailing
|
||||
// silence, which would otherwise stack up between snippets.
|
||||
const buffers = Object.create(null);
|
||||
let scheduled = [];
|
||||
let preloadPromise = null;
|
||||
let btn = null;
|
||||
|
||||
// Linear amplitude threshold below which a sample counts as silence
|
||||
// (~ -46 dBFS). Slightly higher than typical decoder noise floor.
|
||||
const SILENCE_THRESHOLD = 0.005;
|
||||
// Tiny grace at the start so we don't chop a soft consonant attack.
|
||||
const LEAD_GRACE_S = 0.01;
|
||||
|
||||
// Scans both channels of an AudioBuffer to find the first and last
|
||||
// sample whose absolute value exceeds SILENCE_THRESHOLD, returning
|
||||
// {offset, duration} in seconds for use with start(when, offset,
|
||||
// duration). Falls back to the full buffer if everything looks
|
||||
// silent (shouldn't happen for our snippets, but be safe).
|
||||
function findNonSilentRange(buffer) {
|
||||
const channels = buffer.numberOfChannels;
|
||||
const len = buffer.length;
|
||||
const sampleRate = buffer.sampleRate;
|
||||
let firstHit = len;
|
||||
let lastHit = -1;
|
||||
for (let c = 0; c < channels; c++) {
|
||||
const data = buffer.getChannelData(c);
|
||||
for (let i = 0; i < firstHit; i++) {
|
||||
if (Math.abs(data[i]) >= SILENCE_THRESHOLD) {
|
||||
firstHit = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = len - 1; i > lastHit; i--) {
|
||||
if (Math.abs(data[i]) >= SILENCE_THRESHOLD) {
|
||||
lastHit = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (lastHit < 0 || firstHit >= len) {
|
||||
return { offset: 0, duration: buffer.duration };
|
||||
}
|
||||
const grace = Math.floor(LEAD_GRACE_S * sampleRate);
|
||||
const start = Math.max(0, firstHit - grace);
|
||||
const end = Math.min(len, lastHit + 1);
|
||||
return {
|
||||
offset: start / sampleRate,
|
||||
duration: (end - start) / sampleRate,
|
||||
};
|
||||
}
|
||||
|
||||
// Builds the spoken-snippet sequence for a duration in seconds.
|
||||
// Examples (all sound natural in German):
|
||||
// 14.96 -> ["14","komma","96","sekunden"] "vierzehn Komma sechsundneunzig Sekunden"
|
||||
// 14.05 -> ["14","komma","0","5","sekunden"] "vierzehn Komma null fünf Sekunden"
|
||||
// 14.00 -> ["14","sekunden"] "vierzehn Sekunden"
|
||||
// 65.96 -> ["minute","und","5","komma","96","sekunden"]
|
||||
// "eine Minute und fünf Komma sechsundneunzig Sekunden"
|
||||
// 125.50 -> ["2","minuten","und","5","komma","50","sekunden"]
|
||||
//
|
||||
// Note on hundredths < 10: the leading zero is spoken digit-by-
|
||||
// digit ("null fünf" for .05) so .05 stays distinguishable from
|
||||
// .50 ("fünfzig"). For >= 10 the value is spoken as a single
|
||||
// German word ("sechsundneunzig" for .96).
|
||||
function timeToSeq(seconds) {
|
||||
const total = Math.max(0, Number(seconds) || 0);
|
||||
// Replicate the server's exact float-based formatting so the
|
||||
// announcement always matches what the user sees on screen.
|
||||
// The ESP (databasebackend.h, gamemodes.h) does:
|
||||
// float s = timeMs / 1000.0;
|
||||
// int totalSec = (int)s;
|
||||
// int hundredths = (int)((s - totalSec) * 100);
|
||||
// C++ `float` is single precision (24-bit mantissa); JS Number
|
||||
// is double precision. For times near a hundredths boundary
|
||||
// (e.g. 14.090) the two give different floor results — server
|
||||
// says "14.08" but a naive double calculation announces "14.09".
|
||||
// Math.fround forces single-precision rounding at each step so
|
||||
// the chain matches the server bit-for-bit.
|
||||
const sFloat = Math.fround(total);
|
||||
const totalSec = Math.trunc(sFloat);
|
||||
const minutes = Math.floor(totalSec / 60);
|
||||
const remSec = totalSec % 60;
|
||||
const diffFloat = Math.fround(sFloat - totalSec);
|
||||
const scaledFloat = Math.fround(diffFloat * 100);
|
||||
let hundredths = Math.trunc(scaledFloat);
|
||||
if (hundredths < 0) hundredths = 0;
|
||||
if (hundredths > 99) hundredths = 99;
|
||||
|
||||
const out = [];
|
||||
|
||||
if (minutes > 0) {
|
||||
if (minutes === 1) {
|
||||
out.push('minute'); // "eine Minute"
|
||||
} else {
|
||||
out.push(String(minutes));
|
||||
out.push('minuten');
|
||||
}
|
||||
if (remSec > 0 || hundredths > 0) out.push('und');
|
||||
}
|
||||
|
||||
if (remSec > 0 || hundredths > 0 || minutes === 0) {
|
||||
out.push(String(remSec));
|
||||
if (hundredths > 0) {
|
||||
out.push('komma');
|
||||
if (hundredths < 10) {
|
||||
out.push('0');
|
||||
out.push(String(hundredths));
|
||||
} else {
|
||||
out.push(String(hundredths));
|
||||
}
|
||||
}
|
||||
out.push('sekunden');
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// "12.34" or "01:23.45" -> seconds. timeToSeq() then re-rounds
|
||||
// the value through Math.fround to match the server's float math,
|
||||
// so the parseFloat drift is harmless here.
|
||||
function parseFormattedTime(str) {
|
||||
if (!str) return 0;
|
||||
if (str.includes(':')) {
|
||||
const [mm, rest] = str.split(':');
|
||||
return parseInt(mm, 10) * 60 + parseFloat(rest);
|
||||
}
|
||||
return parseFloat(str) || 0;
|
||||
}
|
||||
|
||||
function ensureContext() {
|
||||
if (audioCtx) return;
|
||||
const Ctx = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Ctx) {
|
||||
console.warn('TTS: Web Audio API not supported');
|
||||
return;
|
||||
}
|
||||
audioCtx = new Ctx();
|
||||
}
|
||||
|
||||
// Limit concurrent fetches: the ESP's async web server only serves
|
||||
// a handful of requests well in parallel, and the browser's 6-per-
|
||||
// host pool would otherwise starve the 1 s /api/data poll while
|
||||
// 107 MP3s come in. Two parallel fetches finishes the preload in
|
||||
// ~2 s without holding up the live timer.
|
||||
const PRELOAD_CONCURRENCY = 2;
|
||||
|
||||
async function fetchAndStore(name) {
|
||||
const res = await fetch(BASE + name + '.mp3');
|
||||
const arr = await res.arrayBuffer();
|
||||
// Older Safari only supports the callback form of decodeAudioData.
|
||||
const buffer = await new Promise((resolve, reject) => {
|
||||
const p = audioCtx.decodeAudioData(arr, resolve, reject);
|
||||
if (p && typeof p.then === 'function') p.then(resolve, reject);
|
||||
});
|
||||
const { offset, duration } = findNonSilentRange(buffer);
|
||||
buffers[name] = { buffer, offset, duration };
|
||||
}
|
||||
|
||||
function preload() {
|
||||
if (preloadPromise) return preloadPromise;
|
||||
ensureContext();
|
||||
if (!audioCtx) return Promise.resolve();
|
||||
let i = 0;
|
||||
const worker = async () => {
|
||||
while (i < FILES.length) {
|
||||
const name = FILES[i++];
|
||||
try {
|
||||
await fetchAndStore(name);
|
||||
} catch (e) {
|
||||
console.warn('TTS preload failed:', name, e);
|
||||
}
|
||||
}
|
||||
};
|
||||
const workers = [];
|
||||
for (let n = 0; n < PRELOAD_CONCURRENCY; n++) workers.push(worker());
|
||||
preloadPromise = Promise.all(workers);
|
||||
return preloadPromise;
|
||||
}
|
||||
|
||||
function stop() {
|
||||
scheduled.forEach(s => { try { s.stop(); } catch (_) {} });
|
||||
scheduled = [];
|
||||
}
|
||||
|
||||
function play(seq) {
|
||||
if (!enabled || !seq || !seq.length) return;
|
||||
ensureContext();
|
||||
if (!audioCtx) return;
|
||||
// Browsers may suspend the context until a user gesture. resume()
|
||||
// is a no-op if already running.
|
||||
if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume().catch(() => {});
|
||||
}
|
||||
stop();
|
||||
let when = audioCtx.currentTime;
|
||||
seq.forEach((name) => {
|
||||
const entry = buffers[name];
|
||||
if (!entry) {
|
||||
console.warn('TTS buffer missing:', name);
|
||||
return;
|
||||
}
|
||||
const src = audioCtx.createBufferSource();
|
||||
src.buffer = entry.buffer;
|
||||
src.connect(audioCtx.destination);
|
||||
// start(when, offset, duration) plays only the non-silent slice
|
||||
// we identified during preload, so trailing silence in each
|
||||
// snippet doesn't stack up between words.
|
||||
src.start(when, entry.offset, entry.duration);
|
||||
scheduled.push(src);
|
||||
when += entry.duration;
|
||||
});
|
||||
}
|
||||
|
||||
function setEnabled(on) {
|
||||
enabled = !!on;
|
||||
localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0');
|
||||
if (!enabled) stop();
|
||||
updateToggleUI();
|
||||
}
|
||||
|
||||
function updateToggleUI() {
|
||||
if (!btn) return;
|
||||
btn.textContent = enabled ? '🔊' : '🔇';
|
||||
btn.title = enabled ? 'Ansagen deaktivieren' : 'Ansagen aktivieren';
|
||||
btn.setAttribute('aria-pressed', enabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function injectToggle() {
|
||||
if (btn || !document.body) return;
|
||||
btn = document.createElement('button');
|
||||
btn.id = 'tts-toggle';
|
||||
btn.style.cssText =
|
||||
'position:fixed;bottom:14px;right:14px;z-index:9998;' +
|
||||
'width:54px;height:54px;border-radius:50%;border:none;' +
|
||||
'background:rgba(0,0,0,0.55);color:#fff;font-size:24px;' +
|
||||
'cursor:pointer;display:flex;align-items:center;justify-content:center;' +
|
||||
'box-shadow:0 2px 10px rgba(0,0,0,0.35);';
|
||||
btn.addEventListener('click', async () => {
|
||||
const next = !enabled;
|
||||
setEnabled(next);
|
||||
if (next) {
|
||||
// The click itself is the user gesture that lets us create
|
||||
// and resume the AudioContext. Preload then play a short ack.
|
||||
await preload();
|
||||
play(['bereit']);
|
||||
}
|
||||
});
|
||||
document.body.appendChild(btn);
|
||||
updateToggleUI();
|
||||
}
|
||||
|
||||
// Decode buffers in the background. On a fresh page load the
|
||||
// AudioContext is created in suspended state (no audio yet, just
|
||||
// decoding — allowed without a gesture), so the first real
|
||||
// announcement after the user clicks anywhere is already gapless.
|
||||
// Defer the start so the initial render and the first /api/data
|
||||
// poll go through unimpeded — otherwise the ESP's web server is
|
||||
// busy serving MP3s and the live timer freezes for several seconds.
|
||||
function eagerPreload() {
|
||||
if (!enabled) return;
|
||||
setTimeout(preload, 2000);
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
injectToggle();
|
||||
eagerPreload();
|
||||
});
|
||||
} else {
|
||||
injectToggle();
|
||||
eagerPreload();
|
||||
}
|
||||
|
||||
window.tts = {
|
||||
isEnabled: () => enabled,
|
||||
setEnabled,
|
||||
play,
|
||||
stop,
|
||||
timeToSeq,
|
||||
parseFormattedTime,
|
||||
sayTime: (sec) => play(['neue_zeit', ...timeToSeq(sec)]),
|
||||
};
|
||||
})();
|
||||
BIN
data/tts/0.mp3
Normal file
BIN
data/tts/0.mp3
Normal file
Binary file not shown.
BIN
data/tts/1.mp3
Normal file
BIN
data/tts/1.mp3
Normal file
Binary file not shown.
BIN
data/tts/10.mp3
Normal file
BIN
data/tts/10.mp3
Normal file
Binary file not shown.
BIN
data/tts/11.mp3
Normal file
BIN
data/tts/11.mp3
Normal file
Binary file not shown.
BIN
data/tts/12.mp3
Normal file
BIN
data/tts/12.mp3
Normal file
Binary file not shown.
BIN
data/tts/13.mp3
Normal file
BIN
data/tts/13.mp3
Normal file
Binary file not shown.
BIN
data/tts/14.mp3
Normal file
BIN
data/tts/14.mp3
Normal file
Binary file not shown.
BIN
data/tts/15.mp3
Normal file
BIN
data/tts/15.mp3
Normal file
Binary file not shown.
BIN
data/tts/16.mp3
Normal file
BIN
data/tts/16.mp3
Normal file
Binary file not shown.
BIN
data/tts/17.mp3
Normal file
BIN
data/tts/17.mp3
Normal file
Binary file not shown.
BIN
data/tts/18.mp3
Normal file
BIN
data/tts/18.mp3
Normal file
Binary file not shown.
BIN
data/tts/19.mp3
Normal file
BIN
data/tts/19.mp3
Normal file
Binary file not shown.
BIN
data/tts/2.mp3
Normal file
BIN
data/tts/2.mp3
Normal file
Binary file not shown.
BIN
data/tts/20.mp3
Normal file
BIN
data/tts/20.mp3
Normal file
Binary file not shown.
BIN
data/tts/21.mp3
Normal file
BIN
data/tts/21.mp3
Normal file
Binary file not shown.
BIN
data/tts/22.mp3
Normal file
BIN
data/tts/22.mp3
Normal file
Binary file not shown.
BIN
data/tts/23.mp3
Normal file
BIN
data/tts/23.mp3
Normal file
Binary file not shown.
BIN
data/tts/24.mp3
Normal file
BIN
data/tts/24.mp3
Normal file
Binary file not shown.
BIN
data/tts/25.mp3
Normal file
BIN
data/tts/25.mp3
Normal file
Binary file not shown.
BIN
data/tts/26.mp3
Normal file
BIN
data/tts/26.mp3
Normal file
Binary file not shown.
BIN
data/tts/27.mp3
Normal file
BIN
data/tts/27.mp3
Normal file
Binary file not shown.
BIN
data/tts/28.mp3
Normal file
BIN
data/tts/28.mp3
Normal file
Binary file not shown.
BIN
data/tts/29.mp3
Normal file
BIN
data/tts/29.mp3
Normal file
Binary file not shown.
BIN
data/tts/3.mp3
Normal file
BIN
data/tts/3.mp3
Normal file
Binary file not shown.
BIN
data/tts/30.mp3
Normal file
BIN
data/tts/30.mp3
Normal file
Binary file not shown.
BIN
data/tts/31.mp3
Normal file
BIN
data/tts/31.mp3
Normal file
Binary file not shown.
BIN
data/tts/32.mp3
Normal file
BIN
data/tts/32.mp3
Normal file
Binary file not shown.
BIN
data/tts/33.mp3
Normal file
BIN
data/tts/33.mp3
Normal file
Binary file not shown.
BIN
data/tts/34.mp3
Normal file
BIN
data/tts/34.mp3
Normal file
Binary file not shown.
BIN
data/tts/35.mp3
Normal file
BIN
data/tts/35.mp3
Normal file
Binary file not shown.
BIN
data/tts/36.mp3
Normal file
BIN
data/tts/36.mp3
Normal file
Binary file not shown.
BIN
data/tts/37.mp3
Normal file
BIN
data/tts/37.mp3
Normal file
Binary file not shown.
BIN
data/tts/38.mp3
Normal file
BIN
data/tts/38.mp3
Normal file
Binary file not shown.
BIN
data/tts/39.mp3
Normal file
BIN
data/tts/39.mp3
Normal file
Binary file not shown.
BIN
data/tts/4.mp3
Normal file
BIN
data/tts/4.mp3
Normal file
Binary file not shown.
BIN
data/tts/40.mp3
Normal file
BIN
data/tts/40.mp3
Normal file
Binary file not shown.
BIN
data/tts/41.mp3
Normal file
BIN
data/tts/41.mp3
Normal file
Binary file not shown.
BIN
data/tts/42.mp3
Normal file
BIN
data/tts/42.mp3
Normal file
Binary file not shown.
BIN
data/tts/43.mp3
Normal file
BIN
data/tts/43.mp3
Normal file
Binary file not shown.
BIN
data/tts/44.mp3
Normal file
BIN
data/tts/44.mp3
Normal file
Binary file not shown.
BIN
data/tts/45.mp3
Normal file
BIN
data/tts/45.mp3
Normal file
Binary file not shown.
BIN
data/tts/46.mp3
Normal file
BIN
data/tts/46.mp3
Normal file
Binary file not shown.
BIN
data/tts/47.mp3
Normal file
BIN
data/tts/47.mp3
Normal file
Binary file not shown.
BIN
data/tts/48.mp3
Normal file
BIN
data/tts/48.mp3
Normal file
Binary file not shown.
BIN
data/tts/49.mp3
Normal file
BIN
data/tts/49.mp3
Normal file
Binary file not shown.
BIN
data/tts/5.mp3
Normal file
BIN
data/tts/5.mp3
Normal file
Binary file not shown.
BIN
data/tts/50.mp3
Normal file
BIN
data/tts/50.mp3
Normal file
Binary file not shown.
BIN
data/tts/51.mp3
Normal file
BIN
data/tts/51.mp3
Normal file
Binary file not shown.
BIN
data/tts/52.mp3
Normal file
BIN
data/tts/52.mp3
Normal file
Binary file not shown.
BIN
data/tts/53.mp3
Normal file
BIN
data/tts/53.mp3
Normal file
Binary file not shown.
BIN
data/tts/54.mp3
Normal file
BIN
data/tts/54.mp3
Normal file
Binary file not shown.
BIN
data/tts/55.mp3
Normal file
BIN
data/tts/55.mp3
Normal file
Binary file not shown.
BIN
data/tts/56.mp3
Normal file
BIN
data/tts/56.mp3
Normal file
Binary file not shown.
BIN
data/tts/57.mp3
Normal file
BIN
data/tts/57.mp3
Normal file
Binary file not shown.
BIN
data/tts/58.mp3
Normal file
BIN
data/tts/58.mp3
Normal file
Binary file not shown.
BIN
data/tts/59.mp3
Normal file
BIN
data/tts/59.mp3
Normal file
Binary file not shown.
BIN
data/tts/6.mp3
Normal file
BIN
data/tts/6.mp3
Normal file
Binary file not shown.
BIN
data/tts/60.mp3
Normal file
BIN
data/tts/60.mp3
Normal file
Binary file not shown.
BIN
data/tts/61.mp3
Normal file
BIN
data/tts/61.mp3
Normal file
Binary file not shown.
BIN
data/tts/62.mp3
Normal file
BIN
data/tts/62.mp3
Normal file
Binary file not shown.
BIN
data/tts/63.mp3
Normal file
BIN
data/tts/63.mp3
Normal file
Binary file not shown.
BIN
data/tts/64.mp3
Normal file
BIN
data/tts/64.mp3
Normal file
Binary file not shown.
BIN
data/tts/65.mp3
Normal file
BIN
data/tts/65.mp3
Normal file
Binary file not shown.
BIN
data/tts/66.mp3
Normal file
BIN
data/tts/66.mp3
Normal file
Binary file not shown.
BIN
data/tts/67.mp3
Normal file
BIN
data/tts/67.mp3
Normal file
Binary file not shown.
BIN
data/tts/68.mp3
Normal file
BIN
data/tts/68.mp3
Normal file
Binary file not shown.
BIN
data/tts/69.mp3
Normal file
BIN
data/tts/69.mp3
Normal file
Binary file not shown.
BIN
data/tts/7.mp3
Normal file
BIN
data/tts/7.mp3
Normal file
Binary file not shown.
BIN
data/tts/70.mp3
Normal file
BIN
data/tts/70.mp3
Normal file
Binary file not shown.
BIN
data/tts/71.mp3
Normal file
BIN
data/tts/71.mp3
Normal file
Binary file not shown.
BIN
data/tts/72.mp3
Normal file
BIN
data/tts/72.mp3
Normal file
Binary file not shown.
BIN
data/tts/73.mp3
Normal file
BIN
data/tts/73.mp3
Normal file
Binary file not shown.
BIN
data/tts/74.mp3
Normal file
BIN
data/tts/74.mp3
Normal file
Binary file not shown.
BIN
data/tts/75.mp3
Normal file
BIN
data/tts/75.mp3
Normal file
Binary file not shown.
BIN
data/tts/76.mp3
Normal file
BIN
data/tts/76.mp3
Normal file
Binary file not shown.
BIN
data/tts/77.mp3
Normal file
BIN
data/tts/77.mp3
Normal file
Binary file not shown.
BIN
data/tts/78.mp3
Normal file
BIN
data/tts/78.mp3
Normal file
Binary file not shown.
BIN
data/tts/79.mp3
Normal file
BIN
data/tts/79.mp3
Normal file
Binary file not shown.
BIN
data/tts/8.mp3
Normal file
BIN
data/tts/8.mp3
Normal file
Binary file not shown.
BIN
data/tts/80.mp3
Normal file
BIN
data/tts/80.mp3
Normal file
Binary file not shown.
BIN
data/tts/81.mp3
Normal file
BIN
data/tts/81.mp3
Normal file
Binary file not shown.
BIN
data/tts/82.mp3
Normal file
BIN
data/tts/82.mp3
Normal file
Binary file not shown.
BIN
data/tts/83.mp3
Normal file
BIN
data/tts/83.mp3
Normal file
Binary file not shown.
BIN
data/tts/84.mp3
Normal file
BIN
data/tts/84.mp3
Normal file
Binary file not shown.
BIN
data/tts/85.mp3
Normal file
BIN
data/tts/85.mp3
Normal file
Binary file not shown.
BIN
data/tts/86.mp3
Normal file
BIN
data/tts/86.mp3
Normal file
Binary file not shown.
BIN
data/tts/87.mp3
Normal file
BIN
data/tts/87.mp3
Normal file
Binary file not shown.
BIN
data/tts/88.mp3
Normal file
BIN
data/tts/88.mp3
Normal file
Binary file not shown.
BIN
data/tts/89.mp3
Normal file
BIN
data/tts/89.mp3
Normal file
Binary file not shown.
BIN
data/tts/9.mp3
Normal file
BIN
data/tts/9.mp3
Normal file
Binary file not shown.
BIN
data/tts/90.mp3
Normal file
BIN
data/tts/90.mp3
Normal file
Binary file not shown.
BIN
data/tts/91.mp3
Normal file
BIN
data/tts/91.mp3
Normal file
Binary file not shown.
BIN
data/tts/92.mp3
Normal file
BIN
data/tts/92.mp3
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user