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_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;
|
||||||
|
|||||||
@@ -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 |
@@ -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>
|
||||||
|
|||||||
@@ -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