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>
This commit is contained in:
cursor
2026-07-05 15:14:45 +00:00
parent 93850eb982
commit 271684ce85
7 changed files with 146 additions and 13 deletions
+6 -1
View File
@@ -12,7 +12,12 @@ server {
gzip_vary on; gzip_vary on;
gzip_proxied any; gzip_proxied any;
gzip_comp_level 6; gzip_comp_level 6;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss application/atom+xml image/svg+xml; gzip_types text/plain text/css text/xml application/json application/javascript application/manifest+json application/xml+rss application/atom+xml image/svg+xml;
location ~* \.webmanifest$ {
default_type application/manifest+json;
add_header Cache-Control "public, max-age=86400";
}
location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|webp)$ { location ~* \.(jpg|jpeg|png|gif|ico|css|js|svg|webp)$ {
expires 1y; expires 1y;
+1 -1
View File
@@ -4,5 +4,5 @@ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" alway
add_header X-Frame-Options "SAMEORIGIN" always; add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always; add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always; add_header Permissions-Policy "geolocation=(), microphone=(self), camera=()" always;
add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; base-uri 'self'; form-action 'self'; object-src 'none'" always; add_header Content-Security-Policy "default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; base-uri 'self'; form-action 'self'; object-src 'none'" always;
Binary file not shown.

After

Width:  |  Height:  |  Size: 506 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

+74 -7
View File
@@ -2,7 +2,16 @@
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <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> <title>Scoring — zwei Spieler</title>
<style> <style>
:root { :root {
@@ -172,6 +181,16 @@
border-color: #7f1d1d; border-color: #7f1d1d;
} }
#install-btn {
display: none;
border-color: var(--p2-dim);
color: var(--p2);
}
#install-btn.visible {
display: inline-flex;
}
.status-bar { .status-bar {
min-height: 2.75rem; min-height: 2.75rem;
padding: 0.5rem 1.25rem 1rem; padding: 0.5rem 1.25rem 1rem;
@@ -248,6 +267,7 @@
<span aria-hidden="true">🎤</span> <span aria-hidden="true">🎤</span>
<span id="mic-label">Spracheingabe</span> <span id="mic-label">Spracheingabe</span>
</button> </button>
<button type="button" id="install-btn">App installieren</button>
<button type="button" id="reset-btn">Zurücksetzen</button> <button type="button" id="reset-btn">Zurücksetzen</button>
</div> </div>
@@ -256,7 +276,7 @@
<p class="hint"> <p class="hint">
Sprachbefehle (de-DE): <code>eins plus drei</code>, <code>zwei minus eins</code>, Sprachbefehle (de-DE): <code>eins plus drei</code>, <code>zwei minus eins</code>,
<code>spieler eins plus fünf</code>, <code>zurücksetzen</code>. <code>spieler eins plus fünf</code>, <code>zurücksetzen</code>.
Mikrofon-Zugriff nötig; funktioniert in Chrome/Edge/Safari (nicht Firefox). Mikrofon-Zugriff nötig; Chrome/Edge/Safari (nicht Firefox). Als App installierbar unter Android/Chrome.
</p> </p>
<footer> <footer>
@@ -269,6 +289,8 @@
const statusEl = document.getElementById('status'); const statusEl = document.getElementById('status');
const micBtn = document.getElementById('mic-btn'); const micBtn = document.getElementById('mic-btn');
const micLabel = document.getElementById('mic-label'); const micLabel = document.getElementById('mic-label');
const installBtn = document.getElementById('install-btn');
let installPrompt = null;
function getName(player) { function getName(player) {
return document.querySelector(`#player-${player} .player-name`).value.trim() return document.querySelector(`#player-${player} .player-name`).value.trim()
@@ -387,6 +409,17 @@
adjust(cmd.player, cmd.delta); 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() { function initSpeech() {
if (!SpeechRecognition) { if (!SpeechRecognition) {
micBtn.classList.add('unsupported'); micBtn.classList.add('unsupported');
@@ -400,7 +433,7 @@
recognition.lang = 'de-DE'; recognition.lang = 'de-DE';
recognition.continuous = false; recognition.continuous = false;
recognition.interimResults = true; recognition.interimResults = true;
recognition.maxAlternatives = 3; recognition.maxAlternatives = 5;
recognition.addEventListener('start', () => { recognition.addEventListener('start', () => {
listening = true; listening = true;
@@ -439,21 +472,55 @@
if (msg) setStatus(msg); if (msg) setStatus(msg);
}); });
micBtn.addEventListener('click', () => { micBtn.addEventListener('click', async () => {
if (listening) { if (listening) {
recognition.stop(); recognition.stop();
} else { return;
}
const allowed = await ensureMicPermission();
if (!allowed) {
setStatus('Mikrofon-Zugriff verweigert — in den Browser-Einstellungen erlauben.');
return;
}
try { try {
recognition.start(); recognition.start();
} catch { } catch {
recognition.stop(); recognition.stop();
setTimeout(() => recognition.start(), 200); 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(); initSpeech();
initPwa();
</script> </script>
</body> </body>
</html> </html>
+32
View File
@@ -0,0 +1,32 @@
{
"name": "Scoring — zwei Spieler",
"short_name": "Scoring",
"description": "Punktestand für zwei Spieler mit Spracheingabe",
"start_url": "./",
"scope": "./",
"display": "standalone",
"orientation": "any",
"theme_color": "#0f1117",
"background_color": "#0f1117",
"lang": "de",
"icons": [
{
"src": "icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+29
View File
@@ -0,0 +1,29 @@
const CACHE = 'scoring-v1';
const ASSETS = [
'./',
'./index.html',
'./manifest.webmanifest',
'./icons/icon-192.png',
'./icons/icon-512.png'
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(ASSETS)).then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request))
);
});