Files
playground/public/scoring/index.html
T
cursor 271684ce85 fix(scoring): PWA installierbar + Mikrofon auf Android/Chrome
Web-App-Manifest, Service Worker und Icons für Install-Prompt;
Permissions-Policy erlaubt Mikrofon (self); getUserMedia vor Speech API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-05 15:14:45 +00:00

527 lines
15 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 charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f1117">
<meta name="description" content="Punktestand für zwei Spieler mit Spracheingabe">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="Scoring">
<link rel="manifest" href="manifest.webmanifest">
<link rel="icon" href="icons/icon-192.png" type="image/png">
<link rel="apple-touch-icon" href="icons/icon-192.png">
<title>Scoring — zwei Spieler</title>
<style>
:root {
--bg: #0f1117;
--surface: #1a1d27;
--border: #2a2f3d;
--text: #e8eaed;
--muted: #9aa0ad;
--p1: #6ee7b7;
--p1-dim: #34d399;
--p2: #818cf8;
--p2-dim: #6366f1;
--danger: #f87171;
--listening: #fbbf24;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100dvh;
display: flex;
flex-direction: column;
}
header {
padding: 1.25rem 1.5rem 0.75rem;
text-align: center;
}
header h1 {
font-size: 1.25rem;
font-weight: 600;
color: var(--muted);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.board {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
padding: 0.75rem 1rem 1rem;
max-width: 52rem;
margin: 0 auto;
width: 100%;
}
.player {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 1rem;
padding: 1rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
transition: box-shadow 0.2s;
}
.player.active {
box-shadow: 0 0 0 2px var(--listening);
}
.player--1 { border-top: 3px solid var(--p1); }
.player--2 { border-top: 3px solid var(--p2); }
.player-name {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px dashed var(--border);
color: inherit;
font: inherit;
font-weight: 700;
font-size: 1rem;
text-align: center;
padding: 0.35rem 0.25rem;
outline: none;
}
.player--1 .player-name:focus { border-color: var(--p1); }
.player--2 .player-name:focus { border-color: var(--p2); }
.score {
font-size: clamp(3.5rem, 18vw, 5.5rem);
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
user-select: none;
}
.player--1 .score { color: var(--p1); }
.player--2 .score { color: var(--p2); }
.controls {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
justify-content: center;
width: 100%;
}
button {
font: inherit;
cursor: pointer;
border: 1px solid var(--border);
background: var(--bg);
color: var(--text);
border-radius: 0.5rem;
padding: 0.55rem 0.85rem;
font-weight: 600;
font-size: 0.95rem;
transition: background 0.15s, transform 0.1s;
}
button:active { transform: scale(0.96); }
button.plus { border-color: var(--p1-dim); color: var(--p1); }
button.minus { border-color: #4b5563; color: var(--muted); }
.player--2 button.plus { border-color: var(--p2-dim); color: var(--p2); }
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
justify-content: center;
padding: 0 1rem 1rem;
max-width: 52rem;
margin: 0 auto;
width: 100%;
}
#mic-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
border-radius: 2rem;
font-size: 1rem;
border: 2px solid var(--border);
background: var(--surface);
}
#mic-btn.listening {
border-color: var(--listening);
color: var(--listening);
animation: pulse 1.4s ease-in-out infinite;
}
#mic-btn.unsupported {
opacity: 0.45;
cursor: not-allowed;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(251, 191, 36, 0.35); }
50% { box-shadow: 0 0 0 8px rgba(251, 191, 36, 0); }
}
#reset-btn {
color: var(--danger);
border-color: #7f1d1d;
}
#install-btn {
display: none;
border-color: var(--p2-dim);
color: var(--p2);
}
#install-btn.visible {
display: inline-flex;
}
.status-bar {
min-height: 2.75rem;
padding: 0.5rem 1.25rem 1rem;
text-align: center;
color: var(--muted);
font-size: 0.9rem;
max-width: 36rem;
margin: 0 auto;
width: 100%;
}
.status-bar strong { color: var(--text); }
.hint {
max-width: 36rem;
margin: 0 auto;
padding: 0 1.25rem 1.5rem;
font-size: 0.82rem;
color: var(--muted);
line-height: 1.5;
text-align: center;
}
.hint code {
background: var(--surface);
padding: 0.1rem 0.35rem;
border-radius: 0.25rem;
font-size: 0.8rem;
}
footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border);
color: var(--muted);
font-size: 0.8rem;
text-align: center;
}
footer a { color: var(--p1-dim); }
</style>
</head>
<body>
<header>
<h1>Scoring</h1>
</header>
<div class="board">
<article class="player player--1" id="player-1" data-player="1">
<input class="player-name" type="text" value="Spieler 1" maxlength="24" aria-label="Name Spieler 1">
<div class="score" id="score-1">0</div>
<div class="controls">
<button type="button" class="minus" data-delta="-1">1</button>
<button type="button" class="plus" data-delta="1">+1</button>
<button type="button" class="plus" data-delta="2">+2</button>
<button type="button" class="plus" data-delta="3">+3</button>
</div>
</article>
<article class="player player--2" id="player-2" data-player="2">
<input class="player-name" type="text" value="Spieler 2" maxlength="24" aria-label="Name Spieler 2">
<div class="score" id="score-2">0</div>
<div class="controls">
<button type="button" class="minus" data-delta="-1">1</button>
<button type="button" class="plus" data-delta="1">+1</button>
<button type="button" class="plus" data-delta="2">+2</button>
<button type="button" class="plus" data-delta="3">+3</button>
</div>
</article>
</div>
<div class="toolbar">
<button type="button" id="mic-btn" aria-pressed="false">
<span aria-hidden="true">🎤</span>
<span id="mic-label">Spracheingabe</span>
</button>
<button type="button" id="install-btn">App installieren</button>
<button type="button" id="reset-btn">Zurücksetzen</button>
</div>
<p class="status-bar" id="status" role="status" aria-live="polite"></p>
<p class="hint">
Sprachbefehle (de-DE): <code>eins plus drei</code>, <code>zwei minus eins</code>,
<code>spieler eins plus fünf</code>, <code>zurücksetzen</code>.
Mikrofon-Zugriff nötig; Chrome/Edge/Safari (nicht Firefox). Als App installierbar unter Android/Chrome.
</p>
<footer>
@cursor · Issue #4 ·
<a href="https://gitea.schwenk.online/froxxxy/playground/issues/4">scoring</a>
</footer>
<script>
const scores = { 1: 0, 2: 0 };
const statusEl = document.getElementById('status');
const micBtn = document.getElementById('mic-btn');
const micLabel = document.getElementById('mic-label');
const installBtn = document.getElementById('install-btn');
let installPrompt = null;
function getName(player) {
return document.querySelector(`#player-${player} .player-name`).value.trim()
|| `Spieler ${player}`;
}
function render(player) {
document.getElementById(`score-${player}`).textContent = scores[player];
}
function adjust(player, delta) {
scores[player] = Math.max(0, scores[player] + delta);
render(player);
flashPlayer(player);
setStatus(`${getName(player)}: ${delta > 0 ? '+' : ''}${delta}${scores[player]}`);
}
function flashPlayer(player) {
const el = document.getElementById(`player-${player}`);
el.classList.add('active');
setTimeout(() => el.classList.remove('active'), 400);
}
function resetScores() {
scores[1] = 0;
scores[2] = 0;
render(1);
render(2);
setStatus('Punkte zurückgesetzt.');
}
function setStatus(msg) {
statusEl.innerHTML = msg ? `<strong>Letzter Befehl:</strong> ${msg}` : '';
}
document.querySelectorAll('.controls button').forEach(btn => {
btn.addEventListener('click', () => {
const player = Number(btn.closest('.player').dataset.player);
adjust(player, Number(btn.dataset.delta));
});
});
document.getElementById('reset-btn').addEventListener('click', resetScores);
// --- Speech recognition ---
const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
let recognition = null;
let listening = false;
const NUMBER_WORDS = {
null: 0, zero: 0, eins: 1, ein: 1, eine: 1, zwei: 2, zwo: 2, drei: 3,
vier: 4, fünf: 5, funf: 5, sechs: 6, sieben: 7, acht: 8, neun: 9, zehn: 10
};
function parseNumber(token) {
if (!token) return null;
const t = token.toLowerCase().replace(/[.,]/g, '');
if (NUMBER_WORDS[t] !== undefined) return NUMBER_WORDS[t];
const n = parseInt(t, 10);
return Number.isFinite(n) ? n : null;
}
function resolvePlayer(text) {
const t = text.toLowerCase();
if (/\b(zwei|2|zwo|rechts|blau)\b/.test(t)) return 2;
if (/\b(eins|1|ein|links|grün|gruen)\b/.test(t)) return 1;
return null;
}
function parseCommand(raw) {
const text = raw.trim().toLowerCase();
if (!text) return null;
if (/\b(zurücksetzen|zuruecksetzen|reset|neu|null|nullen)\b/.test(text)) {
return { action: 'reset' };
}
const player = resolvePlayer(text);
if (!player) return null;
const minus = /\b(minus|weniger|abzüglich|abzuglich|-)\b/.test(text);
const plus = /\b(plus|mehr|dazu|\+)\b/.test(text) || !minus;
const digitMatch = text.match(/(\d+)/);
let amount = digitMatch ? parseInt(digitMatch[1], 10) : null;
if (amount === null) {
for (const word of Object.keys(NUMBER_WORDS)) {
if (word === 'null' || word === 'zero') continue;
const re = new RegExp(`\\b${word}\\b`);
if (re.test(text)) {
amount = NUMBER_WORDS[word];
break;
}
}
}
if (amount === null) amount = 1;
const delta = minus ? -amount : plus ? amount : null;
if (delta === null) return null;
return { action: 'score', player, delta };
}
function handleTranscript(text) {
const cmd = parseCommand(text);
if (!cmd) {
setStatus(`Nicht erkannt: „${text}"`);
return;
}
if (cmd.action === 'reset') {
resetScores();
return;
}
adjust(cmd.player, cmd.delta);
}
async function ensureMicPermission() {
if (!navigator.mediaDevices?.getUserMedia) return true;
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach((track) => track.stop());
return true;
} catch {
return false;
}
}
function initSpeech() {
if (!SpeechRecognition) {
micBtn.classList.add('unsupported');
micLabel.textContent = 'Sprache nicht unterstützt';
micBtn.disabled = true;
setStatus('Web Speech API nicht verfügbar (Chrome/Edge/Safari probieren).');
return;
}
recognition = new SpeechRecognition();
recognition.lang = 'de-DE';
recognition.continuous = false;
recognition.interimResults = true;
recognition.maxAlternatives = 5;
recognition.addEventListener('start', () => {
listening = true;
micBtn.classList.add('listening');
micBtn.setAttribute('aria-pressed', 'true');
micLabel.textContent = 'Hört zu …';
});
recognition.addEventListener('end', () => {
listening = false;
micBtn.classList.remove('listening');
micBtn.setAttribute('aria-pressed', 'false');
micLabel.textContent = 'Spracheingabe';
});
recognition.addEventListener('result', (event) => {
let interim = '';
let final = '';
for (let i = event.resultIndex; i < event.results.length; i++) {
const transcript = event.results[i][0].transcript;
if (event.results[i].isFinal) final += transcript;
else interim += transcript;
}
if (interim) setStatus(`Höre: „${interim.trim()}"`);
if (final) handleTranscript(final.trim());
});
recognition.addEventListener('error', (event) => {
const messages = {
'not-allowed': 'Mikrofon-Zugriff verweigert.',
'no-speech': 'Keine Sprache erkannt — nochmal versuchen.',
'aborted': '',
'network': 'Netzwerkfehler bei der Spracherkennung.'
};
const msg = messages[event.error] || `Fehler: ${event.error}`;
if (msg) setStatus(msg);
});
micBtn.addEventListener('click', async () => {
if (listening) {
recognition.stop();
return;
}
const allowed = await ensureMicPermission();
if (!allowed) {
setStatus('Mikrofon-Zugriff verweigert — in den Browser-Einstellungen erlauben.');
return;
}
try {
recognition.start();
} catch {
recognition.stop();
setTimeout(() => {
try { recognition.start(); } catch { /* ignore */ }
}, 250);
}
});
}
function initPwa() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('./sw.js', { scope: './' }).catch(() => {});
}
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault();
installPrompt = event;
installBtn.classList.add('visible');
});
installBtn.addEventListener('click', async () => {
if (!installPrompt) return;
installPrompt.prompt();
const { outcome } = await installPrompt.userChoice;
installPrompt = null;
installBtn.classList.remove('visible');
if (outcome === 'accepted') setStatus('App installiert.');
});
window.addEventListener('appinstalled', () => {
installBtn.classList.remove('visible');
installPrompt = null;
});
}
initSpeech();
initPwa();
</script>
</body>
</html>