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:
+6
-1
@@ -12,7 +12,12 @@ server {
|
||||
gzip_vary on;
|
||||
gzip_proxied any;
|
||||
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)$ {
|
||||
expires 1y;
|
||||
|
||||
@@ -4,5 +4,5 @@ add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" alway
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" 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;
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 506 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -2,7 +2,16 @@
|
||||
<html lang="de">
|
||||
<head>
|
||||
<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>
|
||||
<style>
|
||||
:root {
|
||||
@@ -172,6 +181,16 @@
|
||||
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;
|
||||
@@ -248,6 +267,7 @@
|
||||
<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>
|
||||
|
||||
@@ -256,7 +276,7 @@
|
||||
<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; funktioniert in Chrome/Edge/Safari (nicht Firefox).
|
||||
Mikrofon-Zugriff nötig; Chrome/Edge/Safari (nicht Firefox). Als App installierbar unter Android/Chrome.
|
||||
</p>
|
||||
|
||||
<footer>
|
||||
@@ -269,6 +289,8 @@
|
||||
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()
|
||||
@@ -387,6 +409,17 @@
|
||||
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');
|
||||
@@ -400,7 +433,7 @@
|
||||
recognition.lang = 'de-DE';
|
||||
recognition.continuous = false;
|
||||
recognition.interimResults = true;
|
||||
recognition.maxAlternatives = 3;
|
||||
recognition.maxAlternatives = 5;
|
||||
|
||||
recognition.addEventListener('start', () => {
|
||||
listening = true;
|
||||
@@ -439,21 +472,55 @@
|
||||
if (msg) setStatus(msg);
|
||||
});
|
||||
|
||||
micBtn.addEventListener('click', () => {
|
||||
micBtn.addEventListener('click', async () => {
|
||||
if (listening) {
|
||||
recognition.stop();
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
const allowed = await ensureMicPermission();
|
||||
if (!allowed) {
|
||||
setStatus('Mikrofon-Zugriff verweigert — in den Browser-Einstellungen erlauben.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
recognition.start();
|
||||
} catch {
|
||||
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();
|
||||
initPwa();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user