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
621 lines
22 KiB
JavaScript
621 lines
22 KiB
JavaScript
// ─── DOM-Refs ──────────────────────────────────────────────────────────────
|
|
const form = document.getElementById("upload-form");
|
|
const titleInput = document.getElementById("title");
|
|
const audioInput = document.getElementById("audio");
|
|
const submitBtn = document.getElementById("submit-btn");
|
|
const uploadProgress = document.getElementById("upload-progress");
|
|
const uploadBar = uploadProgress.querySelector(".bar-fill");
|
|
const uploadMsg = document.getElementById("upload-msg");
|
|
const jobsBody = document.getElementById("jobs-body");
|
|
const macStatus = document.getElementById("mac-status");
|
|
|
|
const appMain = document.getElementById("app");
|
|
const authOverlay = document.getElementById("auth-overlay");
|
|
const authTitle = document.getElementById("auth-title");
|
|
const authInfo = document.getElementById("auth-info");
|
|
const authForm = document.getElementById("auth-form");
|
|
const authUsername = document.getElementById("auth-username");
|
|
const authPassword = document.getElementById("auth-password");
|
|
const authSubmit = document.getElementById("auth-submit");
|
|
const authMsg = document.getElementById("auth-msg");
|
|
const userBar = document.getElementById("user-bar");
|
|
const userName = document.getElementById("user-name");
|
|
const userAdminBadge = document.getElementById("user-admin-badge");
|
|
const logoutBtn = document.getElementById("logout-btn");
|
|
|
|
const adminCard = document.getElementById("admin-card");
|
|
const userForm = document.getElementById("user-form");
|
|
const newUsername = document.getElementById("new-username");
|
|
const newPassword = document.getElementById("new-password");
|
|
const newIsAdmin = document.getElementById("new-is-admin");
|
|
const userMsg = document.getElementById("user-msg");
|
|
const usersBody = document.getElementById("users-body");
|
|
|
|
const profileLabel = document.getElementById("profile-label");
|
|
const profileSelect = document.getElementById("profile-select");
|
|
const defaultProfileName = document.getElementById("default-profile-name");
|
|
const headerProfileWrap = document.getElementById("header-profile-wrap");
|
|
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 mode = "login"; // "login" | "setup"
|
|
let availableProfiles = []; // [{ name, display_name, language }, ...]
|
|
|
|
const DOWNLOADS = [
|
|
{ kind: "docx", label: "DOCX", title: "Sitzungsprotokoll als Word-Dokument" },
|
|
{ kind: "protocol", label: "Protokoll", title: "Strukturiertes Protokoll (JSON)" },
|
|
{ kind: "summary", label: "Summary", title: "Zusammenfassung (JSON)" },
|
|
{ kind: "transcript", label: "Transkript", title: "Rohes Transkript (JSON)" },
|
|
];
|
|
|
|
// ─── Helpers ───────────────────────────────────────────────────────────────
|
|
function escapeHtml(s) {
|
|
return String(s ?? "").replace(/[&<>"']/g, (c) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
}[c]));
|
|
}
|
|
|
|
function fmtDate(s) {
|
|
const d = new Date(s);
|
|
return d.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "medium" });
|
|
}
|
|
|
|
async function api(path, opts = {}) {
|
|
const r = await fetch(path, {
|
|
credentials: "same-origin",
|
|
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
|
...opts,
|
|
});
|
|
return r;
|
|
}
|
|
|
|
// ─── Auth-Flow ─────────────────────────────────────────────────────────────
|
|
async function bootstrap() {
|
|
const me = await api("/api/auth/me");
|
|
if (me.ok) {
|
|
currentUser = await me.json();
|
|
enterApp();
|
|
return;
|
|
}
|
|
// Nicht eingeloggt — Setup nötig?
|
|
const setupCheck = await api("/api/auth/needs-setup");
|
|
const setupData = await setupCheck.json();
|
|
if (setupData.needs_setup) {
|
|
showAuth("setup");
|
|
} else {
|
|
showAuth("login");
|
|
}
|
|
}
|
|
|
|
function showAuth(which) {
|
|
mode = which;
|
|
authMsg.textContent = "";
|
|
authMsg.className = "msg";
|
|
authForm.reset();
|
|
if (which === "setup") {
|
|
authTitle.textContent = "Initialen Admin anlegen";
|
|
authInfo.textContent =
|
|
"Noch kein Benutzer vorhanden. Lege jetzt den ersten Admin an. Bestehende Jobs werden diesem Konto zugeordnet.";
|
|
authInfo.classList.remove("hidden");
|
|
authPassword.autocomplete = "new-password";
|
|
authPassword.minLength = 8;
|
|
authSubmit.textContent = "Admin anlegen";
|
|
} else {
|
|
authTitle.textContent = "Anmelden";
|
|
authInfo.classList.add("hidden");
|
|
authInfo.textContent = "";
|
|
authPassword.autocomplete = "current-password";
|
|
authPassword.minLength = 1;
|
|
authSubmit.textContent = "Anmelden";
|
|
}
|
|
authOverlay.classList.remove("hidden");
|
|
appMain.classList.add("hidden");
|
|
userBar.classList.add("hidden");
|
|
setTimeout(() => authUsername.focus(), 50);
|
|
}
|
|
|
|
function enterApp() {
|
|
authOverlay.classList.add("hidden");
|
|
appMain.classList.remove("hidden");
|
|
userBar.classList.remove("hidden");
|
|
userName.textContent = currentUser.username;
|
|
userAdminBadge.classList.toggle("hidden", !currentUser.is_admin);
|
|
adminCard.classList.toggle("hidden", !currentUser.is_admin);
|
|
loadProfiles().then(applyProfileUI);
|
|
loadJobs();
|
|
if (currentUser.is_admin) loadUsers();
|
|
}
|
|
|
|
async function loadProfiles() {
|
|
try {
|
|
const r = await api("/api/profiles");
|
|
if (!r.ok) return;
|
|
const data = await r.json();
|
|
availableProfiles = data.profiles || [];
|
|
} catch (e) {
|
|
console.error("Profile laden fehlgeschlagen", e);
|
|
availableProfiles = [];
|
|
}
|
|
}
|
|
|
|
function profileLabelOf(name) {
|
|
const p = availableProfiles.find((x) => x.name === name);
|
|
return p ? p.display_name : name;
|
|
}
|
|
|
|
function applyProfileUI() {
|
|
// Upload-Dropdown und Header-Selector nur sichtbar wenn ≥2 Profile.
|
|
const showDropdown = availableProfiles.length > 1;
|
|
profileLabel.classList.toggle("hidden", !showDropdown);
|
|
headerProfileWrap.classList.toggle("hidden", !showDropdown);
|
|
|
|
for (const sel of [profileSelect, headerProfile]) {
|
|
sel.innerHTML = "";
|
|
availableProfiles.forEach((p) => {
|
|
const o = document.createElement("option");
|
|
o.value = p.name;
|
|
o.textContent = p.display_name;
|
|
sel.appendChild(o);
|
|
});
|
|
}
|
|
const def = currentUser.default_profile || "meeting";
|
|
profileSelect.value = def;
|
|
headerProfile.value = def;
|
|
defaultProfileName.textContent = profileLabelOf(def);
|
|
}
|
|
|
|
// Sofort-Speichern, sobald der User im Header das Standard-Profil ändert.
|
|
headerProfile.addEventListener("change", async () => {
|
|
const newDefault = headerProfile.value;
|
|
headerProfile.disabled = true;
|
|
try {
|
|
const r = await api("/api/auth/me", {
|
|
method: "PATCH",
|
|
body: JSON.stringify({ default_profile: newDefault }),
|
|
});
|
|
if (!r.ok) throw new Error("save failed");
|
|
currentUser = await r.json();
|
|
// Upload-Dropdown synchron mitziehen
|
|
profileSelect.value = currentUser.default_profile;
|
|
defaultProfileName.textContent = profileLabelOf(currentUser.default_profile);
|
|
} catch (e) {
|
|
alert("Konnte Standard-Profil nicht speichern.");
|
|
headerProfile.value = currentUser.default_profile || "meeting";
|
|
} finally {
|
|
headerProfile.disabled = false;
|
|
}
|
|
});
|
|
|
|
authForm.addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
authSubmit.disabled = true;
|
|
authMsg.textContent = "";
|
|
authMsg.className = "msg";
|
|
const body = JSON.stringify({
|
|
username: authUsername.value.trim(),
|
|
password: authPassword.value,
|
|
});
|
|
const url = mode === "setup" ? "/api/auth/setup" : "/api/auth/login";
|
|
try {
|
|
const r = await api(url, { method: "POST", body });
|
|
const data = await r.json();
|
|
if (!r.ok) {
|
|
authMsg.textContent = data.detail || "Fehler bei der Anmeldung";
|
|
authMsg.className = "msg err";
|
|
return;
|
|
}
|
|
currentUser = data;
|
|
if (mode === "setup" && typeof data.migrated_jobs === "number" && data.migrated_jobs > 0) {
|
|
console.info(`${data.migrated_jobs} Bestand-Jobs wurden zugeordnet.`);
|
|
}
|
|
enterApp();
|
|
} catch (e) {
|
|
authMsg.textContent = "Netzwerkfehler";
|
|
authMsg.className = "msg err";
|
|
} finally {
|
|
authSubmit.disabled = false;
|
|
}
|
|
});
|
|
|
|
logoutBtn.addEventListener("click", async () => {
|
|
await api("/api/auth/logout", { method: "POST" });
|
|
currentUser = null;
|
|
jobsBody.innerHTML = "";
|
|
showAuth("login");
|
|
});
|
|
|
|
// ─── Mac-Status ────────────────────────────────────────────────────────────
|
|
async function checkMac() {
|
|
try {
|
|
const r = await fetch("/api/mac/health");
|
|
const j = await r.json();
|
|
if (r.ok && j.reachable) {
|
|
macStatus.textContent = "Mac-Backend: online";
|
|
macStatus.classList.add("ok");
|
|
macStatus.classList.remove("err");
|
|
} else {
|
|
macStatus.textContent = "Mac-Backend: nicht erreichbar";
|
|
macStatus.classList.add("err");
|
|
macStatus.classList.remove("ok");
|
|
}
|
|
} catch {
|
|
macStatus.textContent = "Mac-Backend: Fehler";
|
|
macStatus.classList.add("err");
|
|
}
|
|
}
|
|
|
|
// ─── Jobs ──────────────────────────────────────────────────────────────────
|
|
function row(job) {
|
|
const tr = document.createElement("tr");
|
|
const title = job.title || job.original_name;
|
|
|
|
const dlCell = document.createElement("td");
|
|
dlCell.className = "dl";
|
|
DOWNLOADS.forEach(({ kind, label, title: tip }) => {
|
|
const a = document.createElement("a");
|
|
a.className = "btn-dl";
|
|
a.textContent = label;
|
|
a.title = tip;
|
|
a.href = `/api/jobs/${job.id}/download/${kind}`;
|
|
if (!job.has[kind]) {
|
|
a.classList.add("disabled");
|
|
a.removeAttribute("href");
|
|
a.title = `${tip} — noch nicht verfügbar`;
|
|
}
|
|
dlCell.appendChild(a);
|
|
});
|
|
|
|
const ownerLine = currentUser?.is_admin && job.owner_username
|
|
? `<br><small class="muted">von ${escapeHtml(job.owner_username)}</small>`
|
|
: "";
|
|
|
|
// Profile-Tag nur sichtbar wenn ≥2 Profile in der Auswahl ODER es ein anderes als "meeting" ist.
|
|
const showProfileTag = availableProfiles.length > 1 || (job.profile && job.profile !== "meeting");
|
|
const profileTag = showProfileTag
|
|
? `<span class="tag-profile" title="Verarbeitungs-Profil">${escapeHtml(profileLabelOf(job.profile || "meeting"))}</span>`
|
|
: "";
|
|
|
|
const statusCell = `
|
|
<span class="status-pill status-${job.status}">${escapeHtml(job.status)}</span>
|
|
${job.error ? `<div class="status-error" title="${escapeHtml(job.error)}">${escapeHtml(job.error)}</div>` : ""}
|
|
`;
|
|
|
|
tr.innerHTML = `
|
|
<td class="col-id">#${job.id}</td>
|
|
<td class="col-title">${escapeHtml(title)} ${profileTag}<br><small class="muted">${escapeHtml(job.original_name)}</small>${ownerLine}</td>
|
|
<td class="col-status">${statusCell}</td>
|
|
<td class="col-progress"><div class="bar"><div class="bar-fill" style="width:${job.progress}%"></div></div><small class="muted">${job.progress}%</small></td>
|
|
<td class="col-updated"><small class="muted">${fmtDate(job.updated_at)}</small></td>
|
|
`;
|
|
tr.appendChild(dlCell);
|
|
return tr;
|
|
}
|
|
|
|
async function loadJobs() {
|
|
if (!currentUser) return;
|
|
try {
|
|
const r = await api("/api/jobs");
|
|
if (r.status === 401) { showAuth("login"); return; }
|
|
const jobs = await r.json();
|
|
jobsBody.innerHTML = "";
|
|
if (!jobs.length) {
|
|
jobsBody.innerHTML = '<tr><td colspan="6" class="muted">Noch keine Jobs.</td></tr>';
|
|
return;
|
|
}
|
|
jobs.forEach((j) => jobsBody.appendChild(row(j)));
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
form.addEventListener("submit", (ev) => {
|
|
ev.preventDefault();
|
|
// 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();
|
|
fd.append("audio", file, file.name);
|
|
fd.append("title", titleInput.value);
|
|
// Profile mitgeben — leer = Server nimmt user.default_profile
|
|
fd.append("profile", profileSelect.value || "");
|
|
|
|
submitBtn.disabled = true;
|
|
uploadProgress.classList.remove("hidden");
|
|
uploadBar.style.width = "0%";
|
|
uploadMsg.textContent = "";
|
|
uploadMsg.className = "msg";
|
|
|
|
const xhr = new XMLHttpRequest();
|
|
xhr.open("POST", "/api/jobs");
|
|
xhr.withCredentials = true;
|
|
xhr.upload.onprogress = (e) => {
|
|
if (e.lengthComputable) {
|
|
const p = Math.round((e.loaded / e.total) * 100);
|
|
uploadBar.style.width = p + "%";
|
|
}
|
|
};
|
|
xhr.onload = () => {
|
|
submitBtn.disabled = false;
|
|
uploadProgress.classList.add("hidden");
|
|
if (xhr.status === 401) { showAuth("login"); return; }
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
const j = JSON.parse(xhr.responseText);
|
|
uploadMsg.textContent = `Job #${j.id} erstellt. Verarbeitung läuft …`;
|
|
uploadMsg.className = "msg ok";
|
|
form.reset();
|
|
discardRecording();
|
|
loadJobs();
|
|
} else {
|
|
let err = "Upload fehlgeschlagen";
|
|
try { err = JSON.parse(xhr.responseText).detail || err; } catch {}
|
|
uploadMsg.textContent = err;
|
|
uploadMsg.className = "msg err";
|
|
}
|
|
};
|
|
xhr.onerror = () => {
|
|
submitBtn.disabled = false;
|
|
uploadProgress.classList.add("hidden");
|
|
uploadMsg.textContent = "Netzwerkfehler beim Upload";
|
|
uploadMsg.className = "msg err";
|
|
};
|
|
xhr.send(fd);
|
|
});
|
|
|
|
// ─── Admin: User-Verwaltung ────────────────────────────────────────────────
|
|
async function loadUsers() {
|
|
if (!currentUser?.is_admin) return;
|
|
try {
|
|
const r = await api("/api/admin/users");
|
|
if (!r.ok) return;
|
|
const users = await r.json();
|
|
usersBody.innerHTML = "";
|
|
users.forEach((u) => {
|
|
const tr = document.createElement("tr");
|
|
const isMe = u.id === currentUser.id;
|
|
tr.innerHTML = `
|
|
<td>#${u.id}</td>
|
|
<td><strong>${escapeHtml(u.username)}</strong>${isMe ? ' <span class="muted">(du)</span>' : ""}</td>
|
|
<td>${u.is_admin ? '<span class="badge admin-badge">Admin</span>' : '<span class="muted">User</span>'}</td>
|
|
<td><small class="muted">${fmtDate(u.created_at)}</small></td>
|
|
<td class="user-actions">
|
|
<button type="button" class="btn-link reset-pw" data-id="${u.id}" data-name="${escapeHtml(u.username)}">Passwort zurücksetzen</button>
|
|
${isMe ? "" : `<button type="button" class="btn-link btn-danger del-user" data-id="${u.id}" data-name="${escapeHtml(u.username)}">Löschen</button>`}
|
|
</td>
|
|
`;
|
|
usersBody.appendChild(tr);
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
}
|
|
}
|
|
|
|
userForm.addEventListener("submit", async (ev) => {
|
|
ev.preventDefault();
|
|
userMsg.textContent = "";
|
|
userMsg.className = "msg";
|
|
const body = JSON.stringify({
|
|
username: newUsername.value.trim(),
|
|
password: newPassword.value,
|
|
is_admin: newIsAdmin.checked,
|
|
});
|
|
const r = await api("/api/admin/users", { method: "POST", body });
|
|
const data = await r.json();
|
|
if (!r.ok) {
|
|
userMsg.textContent = data.detail || "Fehler beim Anlegen";
|
|
userMsg.className = "msg err";
|
|
return;
|
|
}
|
|
userMsg.textContent = `Benutzer '${data.username}' angelegt.`;
|
|
userMsg.className = "msg ok";
|
|
userForm.reset();
|
|
loadUsers();
|
|
});
|
|
|
|
usersBody.addEventListener("click", async (ev) => {
|
|
const t = ev.target;
|
|
if (t.matches(".del-user")) {
|
|
if (!confirm(`Benutzer '${t.dataset.name}' wirklich löschen?\nDie Jobs des Benutzers bleiben erhalten (gehören dann keinem mehr).`)) return;
|
|
const r = await api(`/api/admin/users/${t.dataset.id}`, { method: "DELETE" });
|
|
if (!r.ok) {
|
|
const d = await r.json().catch(() => ({}));
|
|
alert(d.detail || "Löschen fehlgeschlagen");
|
|
}
|
|
loadUsers();
|
|
} else if (t.matches(".reset-pw")) {
|
|
const pw = prompt(`Neues Passwort für '${t.dataset.name}' (min. 8 Zeichen):`);
|
|
if (!pw) return;
|
|
if (pw.length < 8) { alert("Passwort muss min. 8 Zeichen haben"); return; }
|
|
const r = await api(`/api/admin/users/${t.dataset.id}/password`, {
|
|
method: "POST", body: JSON.stringify({ password: pw }),
|
|
});
|
|
if (!r.ok) {
|
|
const d = await r.json().catch(() => ({}));
|
|
alert(d.detail || "Zurücksetzen fehlgeschlagen");
|
|
} else {
|
|
alert("Passwort gesetzt.");
|
|
}
|
|
}
|
|
});
|
|
|
|
// ─── 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 ──────────────────────────────────────────────────────────────────
|
|
bootstrap();
|
|
checkMac();
|
|
setInterval(() => { if (currentUser) loadJobs(); }, 3000);
|
|
setInterval(checkMac, 15000);
|