feat: PWA + Mikrofon-Aufnahme + HTTPS via selbst-signiertem Cert

Frontend-Aufnahme (MediaRecorder):
- Aufnahme-Button im Upload-Form, große Touch-Targets
- Start / Pause / Fortsetzen / Stop mit Live-Timer
- Format-Auto-Detect (Opus/WebM, Safari nimmt M4A)
- Wake Lock damit iOS nicht ins Sleep geht
- Aufnahme-Datei landet als File-Objekt im Upload-Flow
- Beim Submit: aufgenommene Datei bevorzugt vor File-Input

PWA:
- manifest.webmanifest, icon.svg + PNG-Icons (192/512)
- apple-mobile-web-app-capable + theme-color
- "Zum Home-Bildschirm" hinzufügen verwandelt Seite in App

Mobile-Layout:
- Single-Column ab 700px, größere Buttons
- Header stapelt sich vertikal
- Tabelle scrollt horizontal statt zu zerquetschen
- Safe-Area-Insets für iPhone-Notch

HTTPS (Pflicht für Mikrofon-Zugriff):
- install.sh erzeugt selbst-signiertes Cert (4096 Bit, 825 Tage, mit
  SAN aus Hostname + LAN-IP)
- Nginx: HTTP → HTTPS Redirect, Cert unter /ssl/voice-agent.crt
  ungesichert abrufbar (für bequemen iOS-Profil-Import)

Backend:
- ALLOWED_EXTS um 'webm' erweitert
This commit is contained in:
root 2026-05-14 03:43:51 +00:00
parent 05cf2ac349
commit 11ab8b57bf
12 changed files with 451 additions and 16 deletions

View File

@ -6,7 +6,8 @@
"Write(*)", "Write(*)",
"Edit(*)", "Edit(*)",
"Glob(*)", "Glob(*)",
"Grep(*)" "Grep(*)",
"WebFetch(domain:192.168.85.11)"
] ]
} }
} }

View File

@ -61,7 +61,32 @@ systemctl daemon-reload
systemctl enable voice-agent systemctl enable voice-agent
systemctl restart voice-agent systemctl restart voice-agent
echo "[7/7] Nginx-Reverse-Proxy" echo "[7/8] Selbst-signiertes TLS-Zertifikat (für Mikrofon-Zugriff via HTTPS)"
SSL_DIR="/etc/ssl/voice-agent"
SSL_CRT="$SSL_DIR/voice-agent.crt"
SSL_KEY="$SSL_DIR/voice-agent.key"
mkdir -p "$SSL_DIR"
if [ ! -f "$SSL_CRT" ] || [ ! -f "$SSL_KEY" ]; then
apt-get install -y --no-install-recommends openssl >/dev/null
HOST_NAME="$(hostname --fqdn 2>/dev/null || hostname)"
HOST_SHORT="$(hostname)"
HOST_IP="$(hostname -I | awk '{print $1}')"
SAN="DNS:${HOST_NAME},DNS:${HOST_SHORT},DNS:voice-agent.local,IP:${HOST_IP}"
echo " Erzeuge Cert für: $SAN"
openssl req -x509 -nodes -newkey rsa:4096 -days 825 \
-keyout "$SSL_KEY" -out "$SSL_CRT" \
-subj "/CN=${HOST_NAME}" -addext "subjectAltName=${SAN}" \
-addext "extendedKeyUsage=serverAuth" \
-addext "basicConstraints=CA:FALSE" 2>/dev/null
chmod 0644 "$SSL_CRT"
chmod 0600 "$SSL_KEY"
echo " Cert erzeugt: $SSL_CRT"
else
echo " Cert bereits vorhanden, behalte: $SSL_CRT"
fi
echo "[8/8] Nginx-Reverse-Proxy (HTTPS + HTTP-Redirect)"
cp "$APP_DIR/deploy/nginx/voice-agent.conf" /etc/nginx/sites-available/voice-agent cp "$APP_DIR/deploy/nginx/voice-agent.conf" /etc/nginx/sites-available/voice-agent
ln -sf /etc/nginx/sites-available/voice-agent /etc/nginx/sites-enabled/voice-agent ln -sf /etc/nginx/sites-available/voice-agent /etc/nginx/sites-enabled/voice-agent
rm -f /etc/nginx/sites-enabled/default rm -f /etc/nginx/sites-enabled/default
@ -78,10 +103,21 @@ chmod 0755 /usr/local/bin/voice-agent-admin
echo echo
echo "Fertig. Health-Check:" echo "Fertig. Health-Check:"
echo " curl -s http://localhost/health" echo " curl -sk https://localhost/health"
echo echo
echo "Beim ersten Aufruf im Browser: Initialer Admin wird angelegt — bestehende Jobs" echo "Aufruf im Browser: https://<LXC-IP>/ (HTTP wird auf HTTPS umgeleitet)"
echo "werden diesem Account automatisch zugeordnet." echo "Beim ersten Aufruf wird der initiale Admin angelegt."
echo
echo "── Mikrofon-Aufnahme von Handys ──────────────────────────────────────"
echo "Damit iOS/Android das Mikrofon freigibt, MUSS die Seite über HTTPS laufen."
echo "Das selbst-signierte Zertifikat einmalig auf jedem Gerät installieren:"
echo
echo " iPhone: Safari öffnen, http://<LXC-IP>/ssl/voice-agent.crt aufrufen"
echo " → Profil herunterladen → Einstellungen → Profil installieren"
echo " → Allgemein → Info → Zertifikatsvertrauen → 'voice-agent' aktivieren"
echo
echo " Android: Browser öffnen, http://<LXC-IP>/ssl/voice-agent.crt aufrufen"
echo " → Cert installieren als 'VPN/Apps' (Variiert je nach Hersteller)"
echo echo
echo "Falls noch nicht geschehen: MAC_API_URL in $APP_DIR/lxc-frontend/.env setzen" echo "Falls noch nicht geschehen: MAC_API_URL in $APP_DIR/lxc-frontend/.env setzen"
echo "und Service neu starten: sudo systemctl restart voice-agent" echo "und Service neu starten: sudo systemctl restart voice-agent"

View File

@ -1,11 +1,39 @@
# HTTP — leitet auf HTTPS um, außer für /ssl/voice-agent.crt (Cert-Download)
server { server {
listen 80 default_server; listen 80 default_server;
server_name _; server_name _;
# Upload-Limit muss zu MAX_UPLOAD_MB in .env passen
client_max_body_size 600M; client_max_body_size 600M;
proxy_read_timeout 1800;
proxy_send_timeout 1800;
# Lange Verarbeitung — Timeouts hoch # Cert für Handy-Import bequem ohne HTTPS abholbar
location /ssl/voice-agent.crt {
alias /etc/ssl/voice-agent/voice-agent.crt;
default_type application/x-x509-ca-cert;
add_header Content-Disposition 'attachment; filename="voice-agent.crt"';
}
# Alles andere → HTTPS
location / {
return 301 https://$host$request_uri;
}
}
# HTTPS mit selbst-signiertem Cert
server {
listen 443 ssl default_server;
listen [::]:443 ssl default_server;
http2 on;
server_name _;
ssl_certificate /etc/ssl/voice-agent/voice-agent.crt;
ssl_certificate_key /etc/ssl/voice-agent/voice-agent.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers on;
client_max_body_size 600M;
proxy_read_timeout 1800; proxy_read_timeout 1800;
proxy_send_timeout 1800; proxy_send_timeout 1800;

View File

@ -14,7 +14,7 @@ MAC_API_TIMEOUT=1800
# Limits # Limits
MAX_UPLOAD_MB=500 MAX_UPLOAD_MB=500
ALLOWED_EXTS=mp3,wav,m4a,mp4,ogg,flac ALLOWED_EXTS=mp3,wav,m4a,mp4,ogg,flac,webm
# Session-Cookie-Signierung — wird vom install.sh automatisch generiert. # Session-Cookie-Signierung — wird vom install.sh automatisch generiert.
# NIE committen. Bei Wechsel werden alle Sessions invalidiert. # NIE committen. Bei Wechsel werden alle Sessions invalidiert.

View File

@ -17,7 +17,7 @@ class Settings(BaseSettings):
mac_api_timeout: int = 1800 mac_api_timeout: int = 1800
max_upload_mb: int = 500 max_upload_mb: int = 500
allowed_exts: str = "mp3,wav,m4a,mp4,ogg,flac" allowed_exts: str = "mp3,wav,m4a,mp4,ogg,flac,webm"
# Wird beim Deploy automatisch generiert (siehe install.sh). # Wird beim Deploy automatisch generiert (siehe install.sh).
session_secret: str = "CHANGE-ME-SET-IN-ENV" session_secret: str = "CHANGE-ME-SET-IN-ENV"

View File

@ -37,6 +37,17 @@ const defaultProfileName = document.getElementById("default-profile-name");
const headerProfileWrap = document.getElementById("header-profile-wrap"); const headerProfileWrap = document.getElementById("header-profile-wrap");
const headerProfile = document.getElementById("header-profile"); const headerProfile = document.getElementById("header-profile");
const recordToggle = document.getElementById("record-toggle");
const recordToggleLabel = document.getElementById("record-toggle-label");
const recordPane = document.getElementById("record-pane");
const recordTimer = document.getElementById("record-timer");
const recordStatus = document.getElementById("record-status");
const recPauseBtn = document.getElementById("rec-pause");
const recStopBtn = document.getElementById("rec-stop");
const recordResult = document.getElementById("record-result");
const recordFilename = document.getElementById("record-filename");
const recordDiscardBtn = document.getElementById("record-discard");
let currentUser = null; // { id, username, is_admin, default_profile } let currentUser = null; // { id, username, is_admin, default_profile }
let mode = "login"; // "login" | "setup" let mode = "login"; // "login" | "setup"
let availableProfiles = []; // [{ name, display_name, language }, ...] let availableProfiles = []; // [{ name, display_name, language }, ...]
@ -310,9 +321,15 @@ async function loadJobs() {
form.addEventListener("submit", (ev) => { form.addEventListener("submit", (ev) => {
ev.preventDefault(); ev.preventDefault();
if (!audioInput.files.length) return; // Quelle: Aufnahme bevorzugt, sonst File-Input
const file = recordedFile || (audioInput.files.length ? audioInput.files[0] : null);
if (!file) {
uploadMsg.textContent = "Bitte Datei wählen oder Aufnahme erstellen.";
uploadMsg.className = "msg err";
return;
}
const fd = new FormData(); const fd = new FormData();
fd.append("audio", audioInput.files[0]); fd.append("audio", file, file.name);
fd.append("title", titleInput.value); fd.append("title", titleInput.value);
// Profile mitgeben — leer = Server nimmt user.default_profile // Profile mitgeben — leer = Server nimmt user.default_profile
fd.append("profile", profileSelect.value || ""); fd.append("profile", profileSelect.value || "");
@ -341,6 +358,7 @@ form.addEventListener("submit", (ev) => {
uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`; uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`;
uploadMsg.className = "msg ok"; uploadMsg.className = "msg ok";
form.reset(); form.reset();
discardRecording();
loadJobs(); loadJobs();
} else { } else {
let err = "Upload fehlgeschlagen"; let err = "Upload fehlgeschlagen";
@ -434,6 +452,167 @@ usersBody.addEventListener("click", async (ev) => {
} }
}); });
// ─── Audio-Aufnahme (MediaRecorder) ────────────────────────────────────────
let mediaRecorder = null;
let recordedChunks = [];
let recordedFile = null; // File-Objekt nach Stop
let recStream = null; // MediaStream zum sauberen Schließen
let recStartedAt = 0;
let recPausedAt = 0; // wenn paused: Zeitpunkt des Pausierens
let recElapsedBeforePause = 0; // aufaddierte Aufnahme-Sekunden vor letztem Resume
let timerInterval = null;
let wakeLock = null;
function pickMimeType() {
const candidates = [
"audio/webm;codecs=opus",
"audio/webm",
"audio/mp4", // Safari iOS
"audio/mpeg",
"", // Browser-Default
];
for (const m of candidates) {
if (m === "" || (window.MediaRecorder && MediaRecorder.isTypeSupported(m))) return m;
}
return "";
}
function extFromMime(mime) {
if (!mime) return "webm";
if (mime.includes("webm")) return "webm";
if (mime.includes("mp4")) return "m4a";
if (mime.includes("mpeg")) return "mp3";
if (mime.includes("ogg")) return "ogg";
return "webm";
}
function fmtTimer(s) {
const mm = String(Math.floor(s / 60)).padStart(2, "0");
const ss = String(Math.floor(s % 60)).padStart(2, "0");
return `${mm}:${ss}`;
}
function tickTimer() {
if (!mediaRecorder) return;
let elapsed = recElapsedBeforePause;
if (mediaRecorder.state === "recording") elapsed += (Date.now() - recStartedAt) / 1000;
recordTimer.textContent = fmtTimer(elapsed);
}
async function acquireWakeLock() {
try {
if ("wakeLock" in navigator) {
wakeLock = await navigator.wakeLock.request("screen");
}
} catch { /* nicht kritisch */ }
}
async function releaseWakeLock() {
try { if (wakeLock) await wakeLock.release(); } catch {}
wakeLock = null;
}
async function startRecording() {
if (!navigator.mediaDevices?.getUserMedia) {
alert("Dein Browser unterstützt kein Mikrofon — oder die Seite läuft nicht über HTTPS.");
return;
}
try {
recStream = await navigator.mediaDevices.getUserMedia({
audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true },
});
} catch (e) {
alert("Mikrofon-Zugriff verweigert oder fehlgeschlagen: " + e.message);
return;
}
const mime = pickMimeType();
recordedChunks = [];
try {
mediaRecorder = mime ? new MediaRecorder(recStream, { mimeType: mime }) : new MediaRecorder(recStream);
} catch (e) {
alert("MediaRecorder konnte nicht starten: " + e.message);
cleanupStream();
return;
}
mediaRecorder.ondataavailable = (e) => { if (e.data && e.data.size > 0) recordedChunks.push(e.data); };
mediaRecorder.onstop = () => {
const type = mediaRecorder.mimeType || mime || "audio/webm";
const ext = extFromMime(type);
const blob = new Blob(recordedChunks, { type });
const ts = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
recordedFile = new File([blob], `aufnahme-${ts}.${ext}`, { type });
onRecordingFinalized();
};
mediaRecorder.start(1000); // alle 1s ein dataavailable-Event
recStartedAt = Date.now();
recPausedAt = 0;
recElapsedBeforePause = 0;
recordTimer.textContent = "00:00";
recordStatus.textContent = "Aufnahme läuft";
recPauseBtn.textContent = "Pause";
recordPane.classList.remove("hidden");
recordResult.classList.add("hidden");
recordToggle.classList.add("hidden");
audioInput.value = ""; // konkurrierende Datei-Auswahl löschen
timerInterval = setInterval(tickTimer, 250);
acquireWakeLock();
}
function pauseRecording() {
if (!mediaRecorder) return;
if (mediaRecorder.state === "recording") {
mediaRecorder.pause();
recElapsedBeforePause += (Date.now() - recStartedAt) / 1000;
recPausedAt = Date.now();
recordStatus.textContent = "Pausiert";
recPauseBtn.textContent = "Fortsetzen";
} else if (mediaRecorder.state === "paused") {
mediaRecorder.resume();
recStartedAt = Date.now();
recordStatus.textContent = "Aufnahme läuft";
recPauseBtn.textContent = "Pause";
}
}
function stopRecording() {
if (!mediaRecorder) return;
if (mediaRecorder.state !== "inactive") mediaRecorder.stop();
if (timerInterval) { clearInterval(timerInterval); timerInterval = null; }
cleanupStream();
releaseWakeLock();
}
function cleanupStream() {
if (recStream) {
recStream.getTracks().forEach((t) => t.stop());
recStream = null;
}
}
function onRecordingFinalized() {
recordPane.classList.add("hidden");
recordResult.classList.remove("hidden");
recordToggle.classList.remove("hidden");
recordToggleLabel.textContent = "Neue Aufnahme starten";
const sizeKB = Math.round(recordedFile.size / 1024);
recordFilename.textContent = `${recordedFile.name} (${sizeKB} KB)`;
}
function discardRecording() {
recordedFile = null;
recordedChunks = [];
recordResult.classList.add("hidden");
recordToggleLabel.textContent = "Aufnahme starten";
recordFilename.textContent = "";
}
recordToggle.addEventListener("click", startRecording);
recPauseBtn.addEventListener("click", pauseRecording);
recStopBtn.addEventListener("click", stopRecording);
recordDiscardBtn.addEventListener("click", discardRecording);
// ─── Init ────────────────────────────────────────────────────────────────── // ─── Init ──────────────────────────────────────────────────────────────────
bootstrap(); bootstrap();
checkMac(); checkMac();

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" rx="96" fill="#0f1115"/>
<g transform="translate(256 256)">
<!-- Mikrofon -->
<rect x="-56" y="-160" width="112" height="200" rx="56" fill="#4f8cff"/>
<!-- Bügel -->
<path d="M -120 0 a 120 120 0 0 0 240 0" stroke="#4f8cff" stroke-width="20" fill="none" stroke-linecap="round"/>
<!-- Standfuß -->
<line x1="0" y1="120" x2="0" y2="170" stroke="#4f8cff" stroke-width="20" stroke-linecap="round"/>
<line x1="-50" y1="170" x2="50" y2="170" stroke="#4f8cff" stroke-width="20" stroke-linecap="round"/>
<!-- Aufnahme-Punkt -->
<circle cx="100" cy="-100" r="30" fill="#e74c3c"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 721 B

View File

@ -2,9 +2,17 @@
<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.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#0f1115" />
<title>Voice-Agent — Transkription</title> <title>Voice-Agent — Transkription</title>
<link rel="stylesheet" href="/static/style.css" /> <link rel="stylesheet" href="/static/style.css" />
<link rel="manifest" href="/static/manifest.webmanifest" />
<link rel="icon" href="/static/icon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/static/icon-192.png" />
<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="Voice-Agent" />
<meta name="mobile-web-app-capable" content="yes" />
</head> </head>
<body> <body>
<header> <header>
@ -63,10 +71,33 @@
<select id="profile-select"></select> <select id="profile-select"></select>
<small class="muted">Standard: <span id="default-profile-name"></span> (in Einstellungen änderbar)</small> <small class="muted">Standard: <span id="default-profile-name"></span> (in Einstellungen änderbar)</small>
</label> </label>
<label> <div class="audio-source">
Audiodatei (MP3, WAV, M4A, MP4, OGG, FLAC) <label class="audio-file-label">
<input type="file" name="audio" id="audio" accept=".mp3,.wav,.m4a,.mp4,.ogg,.flac" required /> <span>Audiodatei wählen <small class="muted">(MP3, WAV, M4A, MP4, OGG, FLAC, WebM)</small></span>
<input type="file" name="audio" id="audio" accept="audio/*,.mp3,.wav,.m4a,.mp4,.ogg,.flac,.webm" />
</label> </label>
<div class="audio-or muted">— oder —</div>
<button type="button" id="record-toggle" class="btn-record">
<span class="rec-dot"></span>
<span id="record-toggle-label">Aufnahme starten</span>
</button>
</div>
<div id="record-pane" class="record-pane hidden">
<div class="record-timer" id="record-timer">00:00</div>
<div class="record-status" id="record-status">Aufnahme läuft</div>
<div class="record-controls">
<button type="button" id="rec-pause" class="btn-record-ctrl">Pause</button>
<button type="button" id="rec-stop" class="btn-record-ctrl danger">Stoppen</button>
</div>
</div>
<div id="record-result" class="record-result hidden">
<span class="muted">Aufnahme bereit:</span>
<span id="record-filename"></span>
<button type="button" id="record-discard" class="btn-link btn-danger">Verwerfen</button>
</div>
<button type="submit" id="submit-btn">Hochladen &amp; verarbeiten</button> <button type="submit" id="submit-btn">Hochladen &amp; verarbeiten</button>
</form> </form>
<div id="upload-progress" class="hidden"> <div id="upload-progress" class="hidden">

View File

@ -0,0 +1,17 @@
{
"name": "Voice-Agent",
"short_name": "Voice-Agent",
"description": "Sitzungsaufnahmen lokal transkribieren und protokollieren",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "portrait",
"background_color": "#0f1115",
"theme_color": "#0f1115",
"lang": "de",
"icons": [
{ "src": "/static/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" },
{ "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

View File

@ -281,6 +281,135 @@ select {
} }
.header-profile:disabled { opacity: .5; } .header-profile:disabled { opacity: .5; }
/* ── Audio-Quelle: Datei-Upload vs. Aufnahme ───────────────────── */
.audio-source {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 12px;
}
.audio-file-label {
display: grid;
gap: 6px;
}
.audio-or {
font-size: 12px;
text-transform: uppercase;
letter-spacing: .5px;
text-align: center;
}
/* Aufnahme-Button (groß, rot, touch-tauglich) */
.btn-record {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 18px;
background: rgba(231,76,60,.12);
color: var(--err);
border: 1px solid rgba(231,76,60,.5);
border-radius: 10px;
font-size: 15px;
font-weight: 600;
cursor: pointer;
min-height: 48px;
}
.btn-record:hover { background: rgba(231,76,60,.22); }
.rec-dot {
width: 12px; height: 12px;
background: var(--err);
border-radius: 50%;
}
/* Aufnahme-Bühne */
.record-pane {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 20px;
border: 1px solid rgba(231,76,60,.4);
background: rgba(231,76,60,.06);
border-radius: 12px;
margin-top: 6px;
}
.record-timer {
font-size: 44px;
font-variant-numeric: tabular-nums;
font-weight: 600;
color: var(--err);
letter-spacing: .04em;
}
.record-status { color: var(--muted); font-size: 13px; }
.record-status::before {
content: "● ";
color: var(--err);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse { 50% { opacity: .25; } }
.record-controls { display: flex; gap: 10px; margin-top: 6px; }
.btn-record-ctrl {
padding: 12px 22px;
border-radius: 8px;
background: var(--card);
color: var(--text);
border: 1px solid var(--border);
font-size: 15px;
cursor: pointer;
min-height: 44px;
min-width: 100px;
}
.btn-record-ctrl.danger {
background: var(--err);
color: white;
border-color: var(--err);
}
/* Aufnahme-Ergebnis */
.record-result {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: rgba(46,204,113,.08);
border: 1px solid rgba(46,204,113,.4);
border-radius: 8px;
font-size: 13px;
}
/* ── Mobile-Optimierung ────────────────────────────────────────── */
@media (max-width: 700px) {
header { padding: 16px 12px 12px; }
header h1 { font-size: 22px; }
.sub { font-size: 13px; }
main { padding: 0 10px; gap: 14px; }
.card { padding: 14px 16px; border-radius: 10px; }
.card h2 { font-size: 16px; }
.header-row { flex-direction: column; align-items: stretch; gap: 10px; }
.header-right { align-items: stretch; }
.user-bar { flex-wrap: wrap; justify-content: flex-start; gap: 6px; font-size: 12px; }
.header-profile { max-width: 140px; }
.audio-source { grid-template-columns: 1fr; gap: 8px; }
.audio-or { padding: 4px 0; }
.btn-record { padding: 18px; font-size: 17px; }
/* Tabelle horizontal scrollbar statt zerquetscht */
.card section, .card { overflow-x: auto; }
table { min-width: 720px; font-size: 13px; }
button[type="submit"] { padding: 14px 20px; font-size: 15px; min-height: 48px; }
.record-timer { font-size: 56px; }
.btn-record-ctrl { padding: 16px 24px; font-size: 17px; }
}
/* iOS PWA: oberer Sicherheitsbereich (Notch) respektieren */
@supports (padding: max(0px)) {
header { padding-top: max(16px, env(safe-area-inset-top)); }
}
/* Profile-Tag in der Job-Zeile */ /* Profile-Tag in der Job-Zeile */
.tag-profile { .tag-profile {
display: inline-block; display: inline-block;