Files
cursor d947e4d1c3 fix(scoring): separate microphone permission prompt from speech start
Android Chrome loses the user gesture when getUserMedia is awaited before
recognition.start(). Add an explicit permission banner/button and only
start speech recognition synchronously after mic access is granted.

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

648 lines
19 KiB
HTML
Raw Permalink 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;
}
#mic-permission {
display: none;
align-items: center;
justify-content: center;
gap: 0.75rem;
flex-wrap: wrap;
margin: 0 1rem 0.75rem;
padding: 0.85rem 1rem;
border-radius: 0.75rem;
border: 1px solid #7f1d1d;
background: rgba(127, 29, 29, 0.15);
max-width: 36rem;
width: calc(100% - 2rem);
margin-left: auto;
margin-right: auto;
text-align: center;
font-size: 0.9rem;
line-height: 1.45;
}
#mic-permission.visible {
display: flex;
}
#mic-permission p {
margin: 0;
color: var(--text);
}
#mic-permission.denied {
border-color: var(--danger);
}
#mic-permission-btn {
border-color: var(--listening);
color: var(--listening);
font-weight: 700;
padding: 0.65rem 1.1rem;
}
.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="mic-permission" id="mic-permission" role="region" aria-label="Mikrofon-Berechtigung">
<p id="mic-permission-text">Für Spracheingabe braucht die App Mikrofon-Zugriff.</p>
<button type="button" id="mic-permission-btn">Mikrofon erlauben</button>
</div>
<div class="toolbar">
<button type="button" id="mic-btn" aria-pressed="false" disabled>
<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>.
Zuerst <strong>Mikrofon erlauben</strong>, dann Spracheingabe starten. Chrome/Edge/Safari (nicht Firefox).
</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 micPermissionEl = document.getElementById('mic-permission');
const micPermissionText = document.getElementById('mic-permission-text');
const micPermissionBtn = document.getElementById('mic-permission-btn');
const installBtn = document.getElementById('install-btn');
let installPrompt = null;
let micPermissionGranted = false;
let micPermissionStatus = 'prompt';
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);
}
function updateMicPermissionUi() {
const canUseMic = micPermissionGranted || micPermissionStatus === 'granted';
micBtn.disabled = !canUseMic || micBtn.classList.contains('unsupported');
if (canUseMic) {
micPermissionEl.classList.remove('visible', 'denied');
return;
}
micPermissionEl.classList.add('visible');
micPermissionEl.classList.toggle('denied', micPermissionStatus === 'denied');
if (micPermissionStatus === 'denied') {
micPermissionText.textContent =
'Mikrofon blockiert. In Chrome: Schloss-Symbol → Website-Einstellungen → Mikrofon erlauben, dann Seite neu laden.';
micPermissionBtn.textContent = 'Erneut versuchen';
} else {
micPermissionText.textContent = 'Für Spracheingabe braucht die App Mikrofon-Zugriff.';
micPermissionBtn.textContent = 'Mikrofon erlauben';
}
}
async function queryMicPermission() {
if (!navigator.permissions?.query) return;
try {
const status = await navigator.permissions.query({ name: 'microphone' });
micPermissionStatus = status.state;
micPermissionGranted = status.state === 'granted';
updateMicPermissionUi();
status.addEventListener('change', () => {
micPermissionStatus = status.state;
micPermissionGranted = status.state === 'granted';
updateMicPermissionUi();
});
} catch {
/* Permissions API for microphone not supported */
}
}
async function requestMicPermission() {
if (!navigator.mediaDevices?.getUserMedia) {
setStatus('Mikrofon-API nicht verfügbar — Chrome/Edge/Safari probieren.');
return false;
}
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
stream.getTracks().forEach((track) => track.stop());
micPermissionGranted = true;
micPermissionStatus = 'granted';
updateMicPermissionUi();
setStatus('Mikrofon freigegeben — jetzt Spracheingabe starten.');
return true;
} catch (err) {
micPermissionGranted = false;
micPermissionStatus = err?.name === 'NotAllowedError' ? 'denied' : 'prompt';
updateMicPermissionUi();
if (micPermissionStatus === 'denied') {
setStatus('Mikrofon verweigert — in den Browser-Einstellungen erlauben.');
} else {
setStatus('Mikrofon-Zugriff fehlgeschlagen — nochmal versuchen.');
}
return false;
}
}
function initSpeech() {
if (!SpeechRecognition) {
micBtn.classList.add('unsupported');
micLabel.textContent = 'Sprache nicht unterstützt';
micBtn.disabled = true;
micPermissionEl.classList.remove('visible');
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);
});
micPermissionBtn.addEventListener('click', () => {
requestMicPermission();
});
micBtn.addEventListener('click', () => {
if (listening) {
recognition.stop();
return;
}
if (!micPermissionGranted && micPermissionStatus !== 'granted') {
setStatus('Zuerst „Mikrofon erlauben“ tippen.');
micPermissionEl.classList.add('visible');
return;
}
try {
recognition.start();
} catch (err) {
if (err?.name === 'InvalidStateError') {
recognition.stop();
setTimeout(() => {
try { recognition.start(); } catch { /* ignore */ }
}, 250);
return;
}
setStatus(`Spracherkennung startet nicht: ${err?.message || 'unbekannter Fehler'}`);
}
});
queryMicPermission();
updateMicPermissionUi();
}
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();
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') queryMicPermission();
});
</script>
</body>
</html>