Spaces:
Running
Running
| const BACKEND = "https://monstermango-diarization.hf.space"; | |
| const $ = (sel) => document.querySelector(sel); | |
| let client = null; | |
| let schluessel = localStorage.getItem("sa_key") || ""; | |
| let hfToken = localStorage.getItem("sa_hf") || ""; | |
| let audioDatei = null; | |
| let letztesMarkdown = null; | |
| let letzteAnalyse = null; | |
| const SPRACHEN = { | |
| german: "Deutsch", english: "Englisch", french: "Französisch", | |
| spanish: "Spanisch", italian: "Italienisch", turkish: "Türkisch", | |
| russian: "Russisch", arabic: "Arabisch", dutch: "Niederländisch", | |
| polish: "Polnisch", portuguese: "Portugiesisch", ukrainian: "Ukrainisch", | |
| }; | |
| let recorder = null; | |
| let recChunks = []; | |
| let recTimer = null; | |
| let wakeLock = null; | |
| // ---------- Hilfen ---------- | |
| const zeit = (s) => { | |
| s = Math.round(s || 0); | |
| return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; | |
| }; | |
| const esc = (t) => String(t ?? "") | |
| .replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); | |
| function toast(msg) { | |
| const t = $("#toast"); | |
| t.textContent = msg; | |
| t.hidden = false; | |
| clearTimeout(t._x); | |
| t._x = setTimeout(() => (t.hidden = true), 4000); | |
| } | |
| async function verbinde() { | |
| if (!client) { | |
| const { Client } = await import( | |
| "https://cdn.jsdelivr.net/npm/@gradio/client/+esm"); | |
| // Der Client liest die Option als `token`; ältere Versionen als | |
| // `hf_token`. Beide setzen, damit das Token sicher als Bearer-Header | |
| // mitgeht — sonst läuft alles auf dem anonymen GPU-Kontingent. | |
| const optionen = hfToken ? { token: hfToken, hf_token: hfToken } : {}; | |
| client = await Client.connect(BACKEND, optionen); | |
| } | |
| return client; | |
| } | |
| async function rufe(endpunkt, daten) { | |
| const c = await verbinde(); | |
| const res = await c.predict(endpunkt, daten); | |
| return res.data[0]; | |
| } | |
| // Wie rufe(), meldet aber Zwischenstände des Backends (lange Aufnahmen). | |
| async function rufeMitFortschritt(endpunkt, daten, melde) { | |
| const c = await verbinde(); | |
| const job = c.submit(endpunkt, daten); | |
| let ergebnis = null; | |
| for await (const nachricht of job) { | |
| if (nachricht.type === "data") { | |
| ergebnis = nachricht.data[0]; | |
| } else if (nachricht.type === "status" && melde) { | |
| const stufe = (nachricht.progress_data || [])[0]; | |
| if (stufe && stufe.desc) melde(stufe.desc); | |
| else if (nachricht.stage === "pending" && nachricht.queue) melde("In der Warteschlange …"); | |
| } | |
| } | |
| if (ergebnis === null) throw new Error("Keine Antwort vom Server."); | |
| return ergebnis; | |
| } | |
| // ---------- Login ---------- | |
| async function loginPruefen(neuerKey) { | |
| const antwort = await rufe("/verlauf", [neuerKey]); | |
| if (!antwort.ok) throw new Error(antwort.fehler || "Anmeldung fehlgeschlagen"); | |
| schluessel = neuerKey; | |
| localStorage.setItem("sa_key", neuerKey); | |
| $("#login").hidden = true; | |
| einstellungenSchliessbar(); | |
| verlaufAnzeigen(antwort.eintraege); | |
| // Muss abgewartet werden: der Aufrufer meldet dem Nutzer gleich, ob das | |
| // Token greift. Ohne await läse er den Anfangswert und behauptete auch | |
| // bei einwandfreiem Token, es fehle eines. | |
| await tokenStatusPruefen(); | |
| kontingentZeigen(); | |
| fortsetzenAnbieten(); | |
| } | |
| // Fragt das Backend, ob die Anfragen mit HF-Token ankommen. Ohne Token läuft | |
| // alles auf dem sehr kleinen anonymen GPU-Kontingent. | |
| let tokenAktiv = null; | |
| // Ein nicht eingetragenes Token ist etwas anderes als ein abgelehntes — | |
| // im ersten Fall fehlt eine Eingabe, im zweiten stimmt der Wert nicht. | |
| function tokenMeldung() { | |
| if (tokenAktiv) return "HF-Token aktiv — GPU läuft über dein Konto."; | |
| if (tokenAktiv === null) return "Verbindung zum Backend gestört."; | |
| if (!hfToken) return "Kein HF-Token hinterlegt — nur kleines anonymes " | |
| + "GPU-Kontingent."; | |
| return "HF-Token wurde nicht erkannt — bitte prüfen."; | |
| } | |
| // Es gibt keine Schnittstelle für das Restkontingent — die Zahl kommt aus | |
| // der eigenen Buchführung im Backend und ist als Schätzung gekennzeichnet, | |
| // solange keine echte Kontingent-Meldung sie bestätigt hat. | |
| async function kontingentZeigen(stand) { | |
| const feld = $("#gpu-status"); | |
| try { | |
| const k = stand || await rufe("/kontingent", [schluessel]); | |
| if (!k || !k.ok && !k.prozent) { feld.hidden = true; return; } | |
| const p = Math.round(k.prozent); | |
| feld.hidden = false; | |
| feld.textContent = `GPU ${p} %${k.geschaetzt ? "*" : ""}`; | |
| feld.classList.toggle("warn", p <= 20); | |
| const rest = Math.round((k.rest_s || 0) / 60); | |
| feld.title = `Noch ${rest} min von ${Math.round(k.budget_s / 60)} min` | |
| + (k.geschaetzt ? " — selbst gezählt, nur diese App" : " — bestätigt") | |
| + (k.zuruecksetzung_in_s | |
| ? `. Zurücksetzung in ${Math.round(k.zuruecksetzung_in_s / 3600)} h` | |
| : ""); | |
| } catch (e) { | |
| feld.hidden = true; | |
| } | |
| } | |
| async function tokenStatusPruefen() { | |
| const feld = $("#token-status"); | |
| try { | |
| const s = await rufe("/status", [schluessel]); | |
| tokenAktiv = !!s.token; | |
| } catch (e) { | |
| tokenAktiv = null; | |
| } | |
| // Nur melden, wenn etwas nicht stimmt. „Alles in Ordnung“ ist der | |
| // Normalfall und braucht keinen Platz in der Kopfzeile — die stand | |
| // sonst zweizeilig da. Der Wortlaut steht weiterhin in den | |
| // Einstellungen. | |
| feld.hidden = tokenAktiv !== false; | |
| feld.textContent = hfToken ? "Token?" : "ohne Token"; | |
| feld.classList.add("warn"); | |
| $("#login-status").textContent = tokenMeldung(); | |
| } | |
| $("#btn-login").addEventListener("click", async () => { | |
| const wert = $("#key-input").value.trim(); | |
| if (!wert) { | |
| $("#login-fehler").textContent = "Bitte den Zugangsschlüssel eingeben."; | |
| return; | |
| } | |
| // Leeres Feld darf ein bereits gespeichertes Token nicht löschen. | |
| const eingabe = $("#hf-input").value.trim(); | |
| if (eingabe) { | |
| hfToken = eingabe; | |
| localStorage.setItem("sa_hf", hfToken); | |
| } | |
| client = null; | |
| $("#login-fehler").textContent = ""; | |
| $("#btn-login").disabled = true; | |
| try { | |
| await loginPruefen(wert); | |
| toast(tokenMeldung()); | |
| } catch (e) { | |
| $("#login-fehler").textContent = e.message; | |
| } finally { | |
| $("#btn-login").disabled = false; | |
| } | |
| }); | |
| $("#btn-push-test").addEventListener("click", async () => { | |
| const feld = $("#push-status"); | |
| // In Stufen, jede einzeln berichtet. Beim ersten Versuch verschluckte | |
| // ein hängender Versand alles und übrig blieb „An error occurred“ — | |
| // eine Meldung, mit der niemand etwas anfangen kann. | |
| feld.textContent = "Melde dieses Gerät an …"; | |
| const anmeldung = await benachrichtigungAnbieten(); | |
| if (anmeldung) { | |
| feld.textContent = `Anmeldung fehlgeschlagen: ${anmeldung}`; | |
| return; | |
| } | |
| try { | |
| const z = await rufe("/push_stand", [schluessel]); | |
| feld.textContent = `Bibliothek: ${z.bibliothek}, ` | |
| + `Geräte: ${(z.geraete || []).join(", ") || "keins"}. Sende Probe …`; | |
| } catch (e) { | |
| feld.textContent = "Zustand nicht abrufbar: " + e.message; | |
| return; | |
| } | |
| try { | |
| const d = await rufe("/push_pruefen", [schluessel]); | |
| if (d.ok) { | |
| feld.textContent = `Gesendet an ${d.zugestellt} von ${d.geraete} ` | |
| + `Gerät(en). Kommt jetzt keine Nachricht, liegt es an iOS, ` | |
| + `nicht an der Einrichtung.`; | |
| } else { | |
| feld.textContent = `Fehlgeschlagen: ${d.fehler || ""} ` | |
| + ((d.meldungen || []).join(" | ") || ""); | |
| } | |
| } catch (e) { | |
| feld.textContent = "Versand ohne Antwort (" + e.message | |
| + ") — vermutlich Zeitüberschreitung beim Zustelldienst."; | |
| } | |
| }); | |
| $("#key-input").addEventListener("keydown", (e) => { | |
| if (e.key === "Enter") $("#btn-login").click(); | |
| }); | |
| // Der Dialog lässt sich nur schließen, wenn bereits ein Schlüssel | |
| // hinterlegt ist — sonst stünde man vor einer unbenutzbaren App. | |
| function einstellungenSchliessbar() { | |
| $("#btn-schliessen").hidden = !schluessel; | |
| } | |
| function einstellungenOeffnen() { | |
| $("#key-input").value = schluessel; | |
| $("#hf-input").value = hfToken; | |
| $("#login-fehler").textContent = ""; | |
| einstellungenSchliessbar(); | |
| $("#login").hidden = false; | |
| } | |
| function einstellungenSchliessen() { | |
| if (!schluessel) return; | |
| $("#login").hidden = true; | |
| } | |
| $("#btn-key").addEventListener("click", einstellungenOeffnen); | |
| $("#btn-schliessen").addEventListener("click", einstellungenSchliessen); | |
| // Tippen neben das Blatt schließt ebenfalls — auf dem Handy die | |
| // naheliegendste Geste. | |
| $("#login").addEventListener("click", (e) => { | |
| if (e.target === $("#login")) einstellungenSchliessen(); | |
| }); | |
| document.addEventListener("keydown", (e) => { | |
| if (e.key === "Escape" && !$("#login").hidden) einstellungenSchliessen(); | |
| }); | |
| // ---------- Navigation ---------- | |
| // Die Kopfzeilenhöhe hängt an Schriftgröße und Sicherheitsabstand des | |
| // Geräts — messen statt schätzen, sonst klebt die Detailleiste falsch. | |
| function kopfhoeheSetzen() { | |
| const h = document.querySelector(".topbar")?.offsetHeight || 56; | |
| document.documentElement.style.setProperty("--kopfhoehe", `${h}px`); | |
| } | |
| kopfhoeheSetzen(); | |
| addEventListener("resize", kopfhoeheSetzen); | |
| addEventListener("orientationchange", kopfhoeheSetzen); | |
| document.querySelectorAll(".tab").forEach((btn) => { | |
| btn.addEventListener("click", () => { | |
| document.querySelectorAll(".tab").forEach((b) => | |
| b.classList.toggle("active", b === btn)); | |
| const ziel = btn.dataset.view; | |
| $("#view-analyse").hidden = ziel !== "analyse"; | |
| $("#view-verlauf").hidden = ziel !== "verlauf"; | |
| if (ziel === "verlauf") verlaufLaden(); | |
| }); | |
| }); | |
| // ---------- Aufnahme & Datei ---------- | |
| function setzeAudio(datei) { | |
| audioDatei = datei; | |
| const mb = datei.size / 1048576; | |
| $("#audio-name").textContent = | |
| `${datei.name} (${mb < 1 ? Math.round(datei.size / 1024) + " KB" | |
| : mb.toFixed(1) + " MB"})`; | |
| $("#audio-preview").src = URL.createObjectURL(datei); | |
| $("#audio-panel").hidden = false; | |
| $("#anleitung").hidden = true; | |
| $("#ergebnis").innerHTML = ""; | |
| letzteAnalyse = null; | |
| } | |
| $("#btn-datei").addEventListener("click", () => $("#file-input").click()); | |
| $("#file-input").addEventListener("change", (e) => { | |
| const datei = e.target.files[0]; | |
| if (!datei) return; | |
| if (!datei.size) { | |
| toast("Datei ist leer — liegt sie noch in iCloud? Erst herunterladen."); | |
| return; | |
| } | |
| setzeAudio(datei); | |
| }); | |
| $("#btn-verwerfen").addEventListener("click", () => { | |
| audioDatei = null; | |
| $("#audio-panel").hidden = true; | |
| $("#anleitung").hidden = false; | |
| $("#ergebnis").innerHTML = ""; | |
| }); | |
| $("#btn-record").addEventListener("click", async () => { | |
| if (recorder && recorder.state === "recording") { | |
| recorder.stop(); | |
| return; | |
| } | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| const mime = MediaRecorder.isTypeSupported("audio/mp4") | |
| ? "audio/mp4" : "audio/webm"; | |
| // Niedrige Bitrate: eine Stunde Sprache bleibt so bei ~20 MB. | |
| recorder = new MediaRecorder(stream, { mimeType: mime, | |
| audioBitsPerSecond: 48000 }); | |
| recChunks = []; | |
| recorder.ondataavailable = (e) => { | |
| if (e.data && e.data.size) recChunks.push(e.data); | |
| }; | |
| recorder.onstop = () => { | |
| stream.getTracks().forEach((t) => t.stop()); | |
| clearInterval(recTimer); | |
| wakeLockFreigeben(); | |
| $("#btn-record").classList.remove("aktiv"); | |
| $("#btn-record").textContent = "oder direkt hier aufnehmen"; | |
| $("#rec-status").hidden = true; | |
| $("#rec-status").classList.remove("laeuft"); | |
| const endung = mime === "audio/mp4" ? "m4a" : "webm"; | |
| setzeAudio(new File(recChunks, `aufnahme.${endung}`, { type: mime })); | |
| }; | |
| // Sekündlich Daten abholen, damit lange Aufnahmen nicht am Stück im | |
| // Arbeitsspeicher gehalten werden. | |
| recorder.start(5000); | |
| await wakeLockAnfordern(); | |
| $("#btn-record").classList.add("aktiv"); | |
| $("#btn-record").textContent = "Aufnahme stoppen"; | |
| $("#rec-status").hidden = false; | |
| $("#rec-status").classList.add("laeuft"); | |
| const startZeit = Date.now(); | |
| recTimer = setInterval(() => { | |
| const s = (Date.now() - startZeit) / 1000; | |
| $("#rec-status").textContent = `Aufnahme läuft … ${zeit(s)}`; | |
| if (s > 3600 && recorder.state === "recording") { | |
| recorder.stop(); | |
| toast("Aufnahme nach 60 Minuten automatisch beendet."); | |
| } | |
| }, 500); | |
| } catch (e) { | |
| toast("Kein Mikrofonzugriff: " + e.message); | |
| } | |
| }); | |
| // Bildschirmsperre verhindern — sonst pausiert iOS die Aufnahme. | |
| async function wakeLockAnfordern() { | |
| try { | |
| if ("wakeLock" in navigator) wakeLock = await navigator.wakeLock.request("screen"); | |
| } catch (e) { /* nicht verfügbar */ } | |
| } | |
| function wakeLockFreigeben() { | |
| if (wakeLock) { wakeLock.release().catch(() => {}); wakeLock = null; } | |
| } | |
| document.addEventListener("visibilitychange", async () => { | |
| const laeuft = (recorder && recorder.state === "recording") | |
| || !$("#progress").hidden; | |
| if (document.visibilityState === "visible" && laeuft && !wakeLock) { | |
| await wakeLockAnfordern(); | |
| } | |
| }); | |
| // ---------- Analyse ---------- | |
| // Wiederholt einen Schritt, wenn die Verbindung abbricht — etwa weil iOS die | |
| // Seite beim Verdunkeln des Bildschirms eingefroren hat. | |
| async function rufeHartnaeckig(endpunkt, daten, versuche = 3) { | |
| let letzter; | |
| for (let v = 0; v < versuche; v++) { | |
| try { | |
| return await rufe(endpunkt, daten); | |
| } catch (e) { | |
| letzter = e; | |
| client = null; | |
| await new Promise((r) => setTimeout(r, 1500 * (v + 1))); | |
| } | |
| } | |
| throw letzter; | |
| } | |
| function laufSpeichern(zustand) { | |
| localStorage.setItem("sa_lauf", JSON.stringify({ ...zustand, ts: Date.now() })); | |
| } | |
| function laufLoeschen() { | |
| localStorage.removeItem("sa_lauf"); | |
| $("#fortsetzen").hidden = true; | |
| } | |
| async function abschnitteVerarbeiten(id, abschnitte, eigenerText, ab, melde) { | |
| for (let i = ab; ; i++) { | |
| const was = eigenerText ? "Ordne Text zu" : "Transkribiere"; | |
| melde(abschnitte > 1 | |
| ? `${was} — Abschnitt ${Math.min(i + 1, abschnitte)} von ${abschnitte} …` | |
| : `${was} …`); | |
| const tr = await rufeHartnaeckig("/transkribieren", [schluessel, id, i]); | |
| if (!tr.ok) throw new Error(tr.fehler); | |
| laufSpeichern({ id, i: i + 1, abschnitte, eigenerText }); | |
| if (!tr.weiter) break; | |
| } | |
| } | |
| async function analyseRahmen(arbeit) { | |
| $("#btn-analyse").disabled = true; | |
| $("#fortsetzen").hidden = true; | |
| $("#progress").hidden = false; | |
| $("#ergebnis").innerHTML = ""; | |
| const hinweis = $("#progress-hinweis"); | |
| if (hinweis) { | |
| hinweis.textContent = "Bitte geöffnet lassen, bis die Aufnahme " | |
| + "übertragen ist."; | |
| hinweis.classList.remove("frei"); | |
| } | |
| const start = Date.now(); | |
| const ticker = setInterval(() => { | |
| $("#progress-zeit").textContent = zeit((Date.now() - start) / 1000); | |
| }, 1000); | |
| await wakeLockAnfordern(); // Bildschirm an lassen | |
| const melde = (t) => ($("#progress-text").textContent = t); | |
| try { | |
| const d = await arbeit(melde); | |
| if (!d.ok) throw new Error(d.fehler); | |
| laufLoeschen(); | |
| letzteAnalyse = d; | |
| kontingentZeigen(d.kontingent); | |
| // Der Hintergrundlauf sichert selbst — dann ist das Markdown schon | |
| // da und der Herunterladen-Knopf muss nicht auf einen Tastendruck | |
| // warten, der womöglich nie kommt. | |
| if (d.pfad && d.markdown) { | |
| letztesMarkdown = { text: d.markdown, name: d.pfad.split("/").pop() }; | |
| } | |
| ergebnisAnzeigen(d, Boolean(d.pfad && d.markdown)); | |
| // Ergebnis in den Blick holen — sonst steht es unter der Eingabe. | |
| // Etwas Vorlauf, damit die Kopfzeile nicht unter der Titelleiste liegt. | |
| window.scrollTo({ top: Math.max(0, $("#ergebnis").offsetTop - 88), | |
| behavior: "smooth" }); | |
| } catch (e) { | |
| let hinweis = ""; | |
| if (/ZeroGPU|quota|runs limit/i.test(e.message) && tokenAktiv === false) { | |
| hinweis = " — Es ist kein HF-Token hinterlegt. Über ⚙︎ eintragen, dann " | |
| + "läuft die GPU über dein Konto."; | |
| } | |
| toast("Fehler: " + e.message + hinweis); | |
| fortsetzenAnbieten(); | |
| } finally { | |
| wachEnde(); | |
| clearInterval(ticker); | |
| wakeLockFreigeben(); | |
| $("#btn-analyse").disabled = false; | |
| $("#progress").hidden = true; | |
| } | |
| } | |
| $("#btn-analyse").addEventListener("click", () => { | |
| if (!audioDatei) return; | |
| analyseRahmen(async (melde) => { | |
| const n = parseInt($("#num-speakers").value, 10) || 0; | |
| // Vor dem Hochladen, solange die Nutzergeste noch zählt. | |
| const wach = await wachHalten(); | |
| $("#progress-hinweis").textContent = wach | |
| ? "Du kannst die App jetzt verlassen — die Übertragung läuft weiter." | |
| : "Bitte geöffnet lassen, bis die Aufnahme übertragen ist."; | |
| $("#progress-hinweis").classList.toggle("frei", wach); | |
| melde("Lade Audio hoch …"); | |
| const transkript = $("#transkript").value.trim(); | |
| const kontext = $("#kontext").value.trim(); | |
| localStorage.setItem("sa_kontext", kontext); | |
| const vor = await rufeHartnaeckig( | |
| "/vorbereiten", [schluessel, audioDatei, transkript, kontext, | |
| $("#abgleich").checked]); | |
| if (!vor.ok) throw new Error(vor.fehler); | |
| const spr = $("#sprache").value; | |
| localStorage.setItem("sa_sprache", spr); | |
| // Ab hier läuft die Arbeit im Space weiter, auch wenn das Telefon | |
| // schläft oder die App geschlossen wird. Der Browser fragt nur noch | |
| // nach dem Stand. | |
| const start = await rufeHartnaeckig("/starten", | |
| [schluessel, vor.id, n, spr]); | |
| if (!start.ok) throw new Error(start.fehler); | |
| // Ohne Kennung an der Startanfrage läuft die GPU-Zeit nicht über dein | |
| // Konto. Das soll auffallen, bevor die Aufnahme durchgelaufen ist. | |
| // Kein Hinweis mehr auf das Token: die GPU-Arbeit läuft im Space unter | |
| // dessen eigener Kennung, weil die der Anfrage nach wenigen Minuten | |
| // abläuft und einen langen Lauf mittendrin abbrechen würde. | |
| auftragSpeichern(start.auftrag); | |
| // Die Spur läuft weiter — nicht mehr, um den Upload zu retten, sondern | |
| // damit Sperrbildschirm und Dynamic Island den Fortschritt zeigen. | |
| wachTexte("Analyse läuft", "Wird übertragen …"); | |
| $("#progress-hinweis").textContent = | |
| "Läuft jetzt im Space. Du kannst die App schließen; wenn sie fertig " | |
| + "ist, bekommst du eine Nachricht."; | |
| $("#progress-hinweis").classList.add("frei"); | |
| toast("Übertragen — läuft jetzt im Hintergrund."); | |
| benachrichtigungAnbieten(); | |
| return await auftragVerfolgen(start.auftrag, melde); | |
| }); | |
| }); | |
| // ---------- Am Leben halten ---------- | |
| // iOS friert JavaScript ein, sobald die App in den Hintergrund geht — der | |
| // Upload bräche dann ab. Eine laufende Tonspur hält die Seite wach; das | |
| // ist die einzige Möglichkeit, die Safari dafür bietet (Background Fetch | |
| // gibt es dort nicht). Sie läuft nur, bis der Auftrag im Space steht. | |
| let wachton = null; | |
| function stilleSpur() { | |
| const rate = 8000, n = rate; // eine Sekunde, wird geschleift | |
| const p = new ArrayBuffer(44 + n); | |
| const v = new DataView(p); | |
| const text = (pos, t) => [...t].forEach((c, i) => | |
| v.setUint8(pos + i, c.charCodeAt(0))); | |
| text(0, "RIFF"); v.setUint32(4, 36 + n, true); text(8, "WAVEfmt "); | |
| v.setUint32(16, 16, true); v.setUint16(20, 1, true); | |
| v.setUint16(22, 1, true); v.setUint32(24, rate, true); | |
| v.setUint32(28, rate, true); v.setUint16(32, 1, true); | |
| v.setUint16(34, 8, true); text(36, "data"); v.setUint32(40, n, true); | |
| for (let i = 0; i < n; i++) v.setUint8(44 + i, 128); // 8 Bit: Stille | |
| return URL.createObjectURL(new Blob([p], { type: "audio/wav" })); | |
| } | |
| async function wachHalten() { | |
| // Muss aus der Nutzergeste heraus starten, sonst verweigert iOS. | |
| try { | |
| if (!wachton) { | |
| wachton = new Audio(stilleSpur()); | |
| wachton.loop = true; | |
| wachton.setAttribute("playsinline", ""); | |
| wachton.volume = 0; | |
| } | |
| await wachton.play(); | |
| return true; | |
| } catch (e) { | |
| console.warn("Wachton nicht möglich:", e); | |
| return false; | |
| } | |
| } | |
| function wachEnde() { | |
| if (wachton) { wachton.pause(); } | |
| if ("mediaSession" in navigator) { | |
| navigator.mediaSession.playbackState = "none"; | |
| navigator.mediaSession.metadata = null; | |
| } | |
| } | |
| // Solange die stille Spur läuft, führt iOS die App als „Wiedergabe“ — | |
| // auf dem Sperrbildschirm und in der Dynamic Island. Was dort steht, | |
| // kommt aus der Media-Session. Echte Live Activities mit Fortschritts- | |
| // balken bräuchten eine native App; das hier ist, was das Web hergibt. | |
| function wachTexte(titel, zeile) { | |
| if (!("mediaSession" in navigator) || !window.MediaMetadata) return; | |
| try { | |
| navigator.mediaSession.metadata = new MediaMetadata({ | |
| title: titel, | |
| artist: zeile, | |
| album: "Sprecher-Analyse", | |
| artwork: [{ src: "icon-192.png", sizes: "192x192", type: "image/png" }, | |
| { src: "icon-512.png", sizes: "512x512", type: "image/png" }], | |
| }); | |
| navigator.mediaSession.playbackState = "playing"; | |
| // Pause beendet nur die Anzeige. Der Auftrag läuft im Space weiter — | |
| // deshalb ist das gefahrlos, und wer Musik hören will, kommt hier raus. | |
| navigator.mediaSession.setActionHandler("pause", () => { | |
| wachEnde(); | |
| toast("Anzeige beendet — die Analyse läuft weiter."); | |
| }); | |
| } catch (e) { /* Anzeige ist Zugabe */ } | |
| } | |
| // „noch 7 min“ statt „0.42“ — und ohne Nachkommastellen, die eine | |
| // Genauigkeit vortäuschen, die die Schätzung nicht hat. | |
| function restText(sekunden, art) { | |
| if (sekunden === null || sekunden === undefined) return ""; | |
| const m = Math.max(0, Math.round(sekunden / 60)); | |
| const wert = m >= 1 ? `noch ~${m} min` : "gleich fertig"; | |
| return art === "geschätzt" ? `${wert} (grob)` : wert; | |
| } | |
| // ---------- Aufträge ---------- | |
| function auftragSpeichern(jid) { | |
| localStorage.setItem("sa_auftrag", JSON.stringify({ id: jid, ts: Date.now() })); | |
| } | |
| function auftragVergessen() { | |
| localStorage.removeItem("sa_auftrag"); | |
| } | |
| async function auftragVerfolgen(jid, melde) { | |
| // Reines Nachfragen — bricht die Verbindung ab, läuft die Verarbeitung | |
| // trotzdem weiter und wird beim nächsten Öffnen wieder eingesammelt. | |
| for (;;) { | |
| let j; | |
| try { | |
| j = await rufe("/auftrag", [schluessel, jid]); | |
| } catch (e) { | |
| await new Promise((f) => setTimeout(f, 5000)); | |
| continue; | |
| } | |
| if (!j.ok) throw new Error(j.fehler); | |
| if (j.stand === "fertig") { | |
| auftragVergessen(); | |
| return j.ergebnis; | |
| } | |
| if (j.stand === "fehler") { | |
| auftragVergessen(); | |
| throw new Error(j.fehler || "Verarbeitung fehlgeschlagen"); | |
| } | |
| const von = j.von || 0, bis = j.bis || 1; | |
| const rest = restText(j.rest_s, j.rest_art); | |
| const fortschritt = bis > 1 ? `${von}/${bis}` : ""; | |
| melde([j.schritt, fortschritt, rest].filter(Boolean).join(" · ")); | |
| wachTexte(rest || "Analyse läuft", | |
| [j.schritt, fortschritt].filter(Boolean).join(" · ")); | |
| await new Promise((f) => setTimeout(f, 4000)); | |
| } | |
| } | |
| function laufenderAuftrag() { | |
| try { | |
| const a = JSON.parse(localStorage.getItem("sa_auftrag") || "null"); | |
| if (a && a.id && Date.now() - a.ts < 6 * 3600 * 1000) return a.id; | |
| } catch (e) { /* verworfen */ } | |
| return null; | |
| } | |
| // ---------- Benachrichtigung ---------- | |
| async function benachrichtigungAnbieten() { | |
| // Auf dem iPhone geht Web Push nur in der zum Home-Bildschirm | |
| // hinzugefügten App, und die Erlaubnis muss aus einer Nutzergeste | |
| // kommen. Der Start der Analyse ist genau so eine. | |
| // Gibt den Grund zurück, wenn es nicht klappt — sonst bleibt jeder | |
| // Fehlschlag unsichtbar. Leerer Rückgabewert heißt: eingerichtet. | |
| if (!("Notification" in window) || !("serviceWorker" in navigator)) { | |
| return "Dieser Browser kann keine Benachrichtigungen. Die App muss vom " | |
| + "Home-Bildschirm geöffnet sein, nicht aus Safari."; | |
| } | |
| if (Notification.permission === "denied") { | |
| return "In den iOS-Einstellungen abgelehnt."; | |
| } | |
| try { | |
| if (Notification.permission === "default") { | |
| if (await Notification.requestPermission() !== "granted") { | |
| return "Erlaubnis nicht erteilt."; | |
| } | |
| } | |
| const reg = await navigator.serviceWorker.ready; | |
| let abo = await reg.pushManager.getSubscription(); | |
| if (!abo) { | |
| const k = await rufe("/push_schluessel", [schluessel]); | |
| if (!k.ok) return "Kein Push-Schlüssel vom Backend."; | |
| abo = await reg.pushManager.subscribe({ | |
| userVisibleOnly: true, | |
| applicationServerKey: k.schluessel, | |
| }); | |
| } | |
| const a = await rufe("/push_anmelden", [schluessel, abo.toJSON()]); | |
| if (!a.ok) return a.fehler || "Anmeldung abgelehnt."; | |
| return ""; | |
| } catch (e) { | |
| // Benachrichtigungen sind Zugabe — ihr Fehlschlag darf die Analyse | |
| // nicht aufhalten. Gemeldet wird er trotzdem. | |
| console.warn("Push nicht eingerichtet:", e); | |
| return e.message || String(e); | |
| } | |
| } | |
| function fortsetzenAnbieten() { | |
| // Läuft im Space noch ein Auftrag, wird er beim Öffnen wieder | |
| // eingesammelt — auch wenn die App zwischendurch geschlossen war. | |
| if (laufenderAuftrag()) { | |
| $("#fortsetzen").hidden = false; | |
| $("#fortsetzen-text").textContent = | |
| "Eine Analyse läuft im Hintergrund weiter."; | |
| $("#btn-fortsetzen").textContent = "Stand ansehen"; | |
| return; | |
| } | |
| let lauf; | |
| try { | |
| lauf = JSON.parse(localStorage.getItem("sa_lauf") || "null"); | |
| } catch (e) { | |
| lauf = null; | |
| } | |
| if (!lauf || !lauf.id || Date.now() - lauf.ts > 2.5 * 3600 * 1000) return; | |
| $("#fortsetzen").hidden = false; | |
| } | |
| $("#btn-fortsetzen").addEventListener("click", () => { | |
| const jid = laufenderAuftrag(); | |
| if (jid) { | |
| analyseRahmen((melde) => auftragVerfolgen(jid, melde)); | |
| return; | |
| } | |
| const lauf = JSON.parse(localStorage.getItem("sa_lauf") || "null"); | |
| if (!lauf) return; | |
| analyseRahmen(async (melde) => { | |
| if (lauf.abschnitte === null) { | |
| melde("Erkenne Sprecher …"); | |
| const n = parseInt($("#num-speakers").value, 10) || 0; | |
| const dia = await rufeHartnaeckig( | |
| "/diarisieren", [schluessel, lauf.id, n, $("#sprache").value]); | |
| if (!dia.ok) throw new Error(dia.fehler); | |
| lauf.abschnitte = dia.abschnitte; | |
| lauf.i = 0; | |
| } | |
| await abschnitteVerarbeiten(lauf.id, lauf.abschnitte, lauf.eigenerText, | |
| lauf.i, melde); | |
| melde("Stelle Ergebnis zusammen …"); | |
| return await rufeHartnaeckig("/abschliessen", [schluessel, lauf.id]); | |
| }); | |
| }); | |
| function ergebnisAnzeigen(d, gespeichert) { | |
| const { daten, namen, farben } = d; | |
| const labels = Object.keys(namen).sort(); | |
| const gesamt = labels.reduce((a, lb) => a + daten.stats[lb].dauer, 0) || 1; | |
| const dauer = daten.dauer || 1; | |
| const wann = new Date(d.zeitpunkt); | |
| const chip = (lb) => | |
| `<span class="chip" style="background:${farben[lb]}">${esc(namen[lb])}</span>`; | |
| const spracheRoh = d.sprache || daten.sprache; | |
| const sprache = spracheRoh | |
| ? `<span>🌐 ${esc(SPRACHEN[spracheRoh] || spracheRoh)}</span>` : ""; | |
| const meta = | |
| `<div class="sa-meta"><span>🕒 ${zeit(daten.dauer)} min</span>` + | |
| `<span>👥 ${labels.length} Sprecher</span>${sprache}` + | |
| `<span>📅 ${wann.toLocaleString("de-DE", { dateStyle: "short", timeStyle: "short" })}</span></div>`; | |
| // Wie schnell gearbeitet wurde. Der Echtzeitfaktor ist die Zahl, an der | |
| // sich Änderungen an der Kette messen lassen — Wörter je Minute hängt | |
| // auch daran, wie dicht gesprochen wurde. | |
| const t = d.tempo || daten.tempo; | |
| const tempoZeile = t | |
| ? `<div class="sa-meta tempo"><span>📝 ${t.woerter} Wörter</span>` + | |
| `<span>⚡ ${Math.round(t.wpm)} W/min</span>` + | |
| `<span>⏱️ ${zeit(t.verarbeitung_s)} min Rechenzeit</span>` + | |
| `<span>🚀 ${t.echtzeit.toFixed(1)}× Echtzeit</span></div>` | |
| : ""; | |
| const sortiert = [...labels].sort( | |
| (a, b) => daten.stats[b].dauer - daten.stats[a].dauer); | |
| let balken = "", legende = ""; | |
| for (const lb of sortiert) { | |
| const anteil = (100 * daten.stats[lb].dauer) / gesamt; | |
| balken += `<div style="width:${anteil.toFixed(1)}%;background:${farben[lb]}"></div>`; | |
| legende += `<span class="sa-leg">${chip(lb)} <span class="dim">` + | |
| `${anteil.toFixed(0)} % · ${zeit(daten.stats[lb].dauer)} min</span></span>`; | |
| } | |
| let lanes = ""; | |
| for (const lb of labels) { | |
| let bloecke = ""; | |
| for (const s of daten.segmente) { | |
| if (s.label !== lb) continue; | |
| const links = (100 * s.start) / dauer; | |
| const breite = Math.max(0.5, (100 * (s.ende - s.start)) / dauer); | |
| bloecke += `<div style="left:${links.toFixed(2)}%;width:${breite.toFixed(2)}%;` + | |
| `background:${farben[lb]}"></div>`; | |
| } | |
| lanes += `<div class="sa-lane">${bloecke}</div>`; | |
| } | |
| const zeitleiste = `<div class="sa-timeline">${lanes}` + | |
| `<div class="sa-ticks"><span>0:00</span><span>${zeit(dauer)}</span></div></div>`; | |
| // Unsichere Wörter werden gekennzeichnet statt stillschweigend so | |
| // sicher dargestellt wie der Rest. Antippen berichtigt sie — das ist | |
| // zugleich der einzige Weg, auf dem echtes Wissen ins System kommt. | |
| const wortlaut = (s) => { | |
| const unsicher = new Set(s.unsicher || []); | |
| if (!unsicher.size) return esc(s.text); | |
| return s.text.split(/\s+/).map((w, i) => unsicher.has(i) | |
| ? `<span class="wackelig" data-wort="${esc(w)}" role="button" | |
| tabindex="0" title="Antippen zum Berichtigen">${esc(w)}</span>` | |
| : esc(w)).join(" "); | |
| }; | |
| let gespraech = `<div class="sa-h">Gespräch</div>`; | |
| for (const s of daten.segmente) { | |
| if (!s.text) continue; | |
| gespraech += | |
| `<div class="sa-msg"><div class="sa-rail" style="background:${farben[s.label]}"></div>` + | |
| `<div class="sa-msg-body"><div class="sa-msg-head">${esc(namen[s.label])} · ` + | |
| `${zeit(s.start)}–${zeit(s.ende)}</div>` + | |
| `<div class="sa-bubble">${wortlaut(s)}</div></div></div>`; | |
| } | |
| // Das Glossar pflegt sich selbst — hier wird nur nachvollziehbar, dass | |
| // und was es gelernt hat. Bedienen muss man dafür nichts. | |
| let gelernt = ""; | |
| if ((d.neue_begriffe || []).length) { | |
| const woerter = d.neue_begriffe.slice(0, 6).map(esc).join(", "); | |
| const rest = d.neue_begriffe.length > 6 | |
| ? ` und ${d.neue_begriffe.length - 6} weitere` : ""; | |
| gelernt = `<div class="gelernt">📖 Neu gemerkt: ${woerter}${rest}. ` + | |
| `Künftige Aufnahmen werden dadurch treffsicherer.</div>`; | |
| } | |
| // Korrekturen werden ausgewiesen, nicht stillschweigend vorgenommen — | |
| // wer den Wortlaut prüfen will, muss sehen, was verändert wurde. | |
| if ((daten.korrekturen || []).length) { | |
| const k = daten.korrekturen.slice(0, 6) | |
| .map((x) => `${esc(x.vorher)} → ${esc(x.nachher)}`).join(", "); | |
| const rest = daten.korrekturen.length > 6 | |
| ? ` und ${daten.korrekturen.length - 6} weitere` : ""; | |
| gelernt += `<div class="gelernt">✏️ Gegen das Glossar korrigiert: ` + | |
| `${k}${rest}.</div>`; | |
| } | |
| // Was nicht im Signal steckt, holt kein Modell zurück. Diese Grenze | |
| // war bisher unsichtbar — bei schlechtem Ergebnis blieb offen, ob das | |
| // Werkzeug oder die Aufnahme schuld war. | |
| let qualitaet = ""; | |
| const q = d.qualitaet || daten.qualitaet; | |
| if (q && q.stufe !== "gut") { | |
| qualitaet = `<div class="sa-warn w-audio">🎙️ Aufnahmequalität ${ | |
| q.stufe === "schlecht" ? "schwach" : "grenzwertig"} ` + | |
| `(Störabstand ${q.snr_db} dB). ${q.rat.map(esc).join(" ")}</div>`; | |
| } | |
| // Das Backend liefert bereits nur noch die nennenswerten Passagen. Hier | |
| // wird zusätzlich gekappt: eine Warnung, die man zu Ende scrollen muss, | |
| // liest niemand. | |
| let hinweis = ""; | |
| if (daten.overlaps.length) { | |
| const ZEIGE = 5; | |
| const stellen = daten.overlaps.slice(0, ZEIGE) | |
| .map((o) => `${zeit(o.start)}–${zeit(o.ende)}`).join(", "); | |
| const rest = daten.overlaps.length - ZEIGE; | |
| hinweis = `<div class="sa-warn w-overlap">⚠️ Hier haben mehrere gleichzeitig ` + | |
| `gesprochen — der Wortlaut ist dort unsicher: ${stellen}` + | |
| `${rest > 0 ? ` und ${rest} weitere` : ""}.</div>`; | |
| } | |
| let abschluss; | |
| if (gespeichert) { | |
| // Herunterladen ist das Ziel des Ablaufs und daher die Hauptaktion. | |
| abschluss = | |
| `<div class="md-aktionen">` + | |
| `<button id="btn-md-laden" class="primary">Markdown herunterladen</button>` + | |
| `<button id="btn-md-teilen" class="ghost breit">Teilen …</button>` + | |
| `</div>`; | |
| } else { | |
| const bekannte = d.sprecher_bekannt || []; | |
| const liste = bekannte.length | |
| ? `<datalist id="namen-liste">` + | |
| bekannte.map((n) => `<option value="${esc(n)}">`).join("") + | |
| `</datalist>` | |
| : ""; | |
| let felder = liste; | |
| for (const lb of labels) { | |
| felder += | |
| `<label class="row namen-zeile">` + | |
| `<span class="punkt" style="background:${farben[lb]}"></span>` + | |
| `<input class="namen-feld" data-label="${esc(lb)}" type="text" ` + | |
| (bekannte.length ? `list="namen-liste" ` : "") + | |
| // Wiedererkannte Stimme: Name steht schon drin, statt ihn jede | |
| // Woche neu zu tippen. | |
| ((d.vorschlag || {})[lb] ? `value="${esc(d.vorschlag[lb])}" ` : "") + | |
| `placeholder="${esc(namen[lb])}" autocomplete="off"></label>`; | |
| } | |
| abschluss = | |
| `<div class="sa-h">Sprecher benennen</div>` + | |
| `<div class="dim">Optional — leere Felder behalten den Standardnamen.</div>` + | |
| felder + | |
| `<button id="btn-speichern" class="primary">Als Markdown speichern</button>`; | |
| } | |
| // Sichern und Herunterladen stehen bewusst oben: nach einem langen | |
| // Meeting soll man dafür nicht durch das ganze Protokoll scrollen. | |
| $("#ergebnis").innerHTML = | |
| `<div class="card">${meta}<div class="sa-bar">${balken}</div>` + | |
| `<div class="sa-legs">${legende}</div>${tempoZeile}${qualitaet}${abschluss}` + | |
| `${zeitleiste}${gespraech}${hinweis}${gelernt}</div>`; | |
| if (gespeichert) { | |
| $("#btn-md-laden").addEventListener("click", () => mdHerunterladen()); | |
| const teilen = $("#btn-md-teilen"); | |
| if (navigator.share) { | |
| teilen.addEventListener("click", async () => { | |
| const datei = new File([letztesMarkdown.text], letztesMarkdown.name, | |
| { type: "text/markdown" }); | |
| try { | |
| if (navigator.canShare && navigator.canShare({ files: [datei] })) { | |
| await navigator.share({ files: [datei], title: letztesMarkdown.name }); | |
| } else { | |
| await navigator.share({ title: letztesMarkdown.name, | |
| text: letztesMarkdown.text }); | |
| } | |
| } catch (e) { /* Abbruch durch Nutzer */ } | |
| }); | |
| } else { | |
| teilen.hidden = true; | |
| } | |
| } else { | |
| $("#btn-speichern").addEventListener("click", speichern); | |
| } | |
| // Ein angetipptes Wort berichtigen. Das ist der einzige Eingang, über | |
| // den Wissen von außen ins System kommt — bisher lernte das Glossar | |
| // ausschließlich aus der eigenen Ausgabe und konnte damit nur die | |
| // eigenen Fehler bestätigen. | |
| for (const el of document.querySelectorAll(".wackelig")) { | |
| el.addEventListener("click", () => berichtigen(el)); | |
| } | |
| } | |
| async function berichtigen(el) { | |
| const falsch = el.dataset.wort; | |
| const richtig = (prompt(`Statt „${falsch}“ richtig:`, falsch) || "").trim(); | |
| if (!richtig || richtig === falsch) return; | |
| el.textContent = richtig; | |
| el.classList.remove("wackelig"); | |
| el.classList.add("berichtigt"); | |
| try { | |
| const d = await rufe("/lernen", [schluessel, falsch, richtig]); | |
| toast(d.ok ? `Gemerkt: „${falsch}“ → „${richtig}“` | |
| : (d.fehler || "Konnte nicht gemerkt werden.")); | |
| } catch (e) { | |
| toast("Konnte nicht gemerkt werden: " + e.message); | |
| } | |
| } | |
| async function speichern() { | |
| if (!letzteAnalyse) return; | |
| const eigene = {}; | |
| document.querySelectorAll(".namen-feld").forEach((f) => { | |
| if (f.value.trim()) eigene[f.dataset.label] = f.value.trim(); | |
| }); | |
| $("#btn-speichern").disabled = true; | |
| try { | |
| const d = await rufe("/speichern", [schluessel, letzteAnalyse, eigene]); | |
| if (!d.ok) throw new Error(d.fehler); | |
| letztesMarkdown = { text: d.markdown, name: d.pfad.split("/").pop() }; | |
| letzteAnalyse.pfad = d.pfad; // damit Nachbenennen dieselbe Datei trifft | |
| letzteAnalyse.namen = { ...letzteAnalyse.namen, ...eigene }; | |
| ergebnisAnzeigen(letzteAnalyse, true); | |
| toast("Als Markdown gespeichert"); | |
| } catch (e) { | |
| toast("Speichern: " + e.message); | |
| const btn = $("#btn-speichern"); | |
| if (btn) btn.disabled = false; | |
| } | |
| } | |
| function mdHerunterladen() { | |
| if (!letztesMarkdown) return; | |
| const blob = new Blob([letztesMarkdown.text], { type: "text/markdown" }); | |
| const a = document.createElement("a"); | |
| a.href = URL.createObjectURL(blob); | |
| a.download = letztesMarkdown.name; | |
| a.click(); | |
| URL.revokeObjectURL(a.href); | |
| } | |
| // ---------- Glossar ---------- | |
| let glossar = { begriffe: [], sprecher: [] }; | |
| async function glossarLaden() { | |
| try { | |
| const g = await rufe("/glossar", [schluessel]); | |
| if (!g.ok) throw new Error(g.fehler); | |
| glossar = { begriffe: g.begriffe || [], sprecher: g.sprecher || [], | |
| zaehler: g.zaehler || {} }; | |
| $("#g-begriffe").value = glossar.begriffe | |
| .map((b) => (glossar.zaehler[b] ? `${b} (${glossar.zaehler[b]})` : b)) | |
| .join("\n"); | |
| $("#g-sprecher").value = glossar.sprecher.join("\n"); | |
| } catch (e) { | |
| toast("Glossar: " + e.message); | |
| } | |
| } | |
| async function glossarSpeichern(begriffe, sprecher) { | |
| const g = await rufe("/glossar_speichern", [schluessel, begriffe, sprecher]); | |
| if (!g.ok) throw new Error(g.fehler); | |
| glossar = { begriffe: g.begriffe || [], sprecher: g.sprecher || [] }; | |
| return glossar; | |
| } | |
| $("#glossar-box").addEventListener("toggle", () => { | |
| if ($("#glossar-box").open && schluessel) glossarLaden(); | |
| }); | |
| $("#btn-glossar-speichern").addEventListener("click", async () => { | |
| const btn = $("#btn-glossar-speichern"); | |
| btn.disabled = true; | |
| try { | |
| await glossarSpeichern($("#g-begriffe").value, $("#g-sprecher").value); | |
| toast(`Glossar gespeichert (${glossar.begriffe.length} Begriffe).`); | |
| } catch (e) { | |
| toast("Glossar: " + e.message); | |
| } finally { | |
| btn.disabled = false; | |
| } | |
| }); | |
| // ---------- Verlauf ---------- | |
| async function verlaufLaden() { | |
| try { | |
| const d = await rufe("/verlauf", [schluessel]); | |
| if (!d.ok) throw new Error(d.fehler); | |
| verlaufAnzeigen(d.eintraege); | |
| } catch (e) { | |
| toast("Verlauf: " + e.message); | |
| } | |
| } | |
| function verlaufAnzeigen(eintraege) { | |
| $("#eintrag-detail").hidden = true; | |
| const liste = $("#verlauf-liste"); | |
| liste.hidden = false; | |
| if (!eintraege.length) { | |
| liste.innerHTML = `<div class="card dim">Noch keine Aufzeichnungen.</div>`; | |
| return; | |
| } | |
| liste.innerHTML = ""; | |
| for (const e of eintraege) { | |
| const btn = document.createElement("button"); | |
| btn.className = "v-card"; | |
| btn.innerHTML = | |
| `<div class="v-titel">${esc(e.titel)}</div>` + | |
| `<div class="v-meta"><span>👥 ${e.sprecher ?? "?"} Sprecher</span>` + | |
| `<span>🕒 ${zeit(e.dauer_s)} min</span></div>`; | |
| btn.addEventListener("click", () => eintragOeffnen(e.pfad)); | |
| liste.appendChild(btn); | |
| } | |
| } | |
| async function eintragOeffnen(pfad) { | |
| try { | |
| const d = await rufe("/eintrag", [schluessel, pfad]); | |
| if (!d.ok) throw new Error(d.fehler); | |
| letztesMarkdown = { text: d.markdown, name: pfad.split("/").pop() }; | |
| const { marked } = await import( | |
| "https://cdn.jsdelivr.net/npm/marked@12/+esm"); | |
| $("#eintrag-inhalt").innerHTML = marked.parse(d.body || ""); | |
| $("#verlauf-liste").hidden = true; | |
| $("#eintrag-detail").hidden = false; | |
| $("#btn-teilen-verlauf").hidden = !navigator.share; | |
| // Oben anfangen: sonst steht man mitten im vorigen Protokoll. | |
| window.scrollTo({ top: 0 }); | |
| } catch (e) { | |
| toast("Eintrag: " + e.message); | |
| } | |
| } | |
| $("#btn-teilen-verlauf").addEventListener("click", async () => { | |
| if (!letztesMarkdown || !navigator.share) return; | |
| const datei = new File([letztesMarkdown.text], letztesMarkdown.name, | |
| { type: "text/markdown" }); | |
| try { | |
| if (navigator.canShare && navigator.canShare({ files: [datei] })) { | |
| await navigator.share({ files: [datei], title: letztesMarkdown.name }); | |
| } else { | |
| await navigator.share({ title: letztesMarkdown.name, | |
| text: letztesMarkdown.text }); | |
| } | |
| } catch (e) { /* Abbruch durch Nutzer */ } | |
| }); | |
| $("#btn-zurueck").addEventListener("click", () => { | |
| $("#eintrag-detail").hidden = true; | |
| $("#verlauf-liste").hidden = false; | |
| }); | |
| $("#btn-download").addEventListener("click", () => mdHerunterladen()); | |
| // ---------- Start ---------- | |
| if ("serviceWorker" in navigator) { | |
| navigator.serviceWorker.register("sw.js").catch(() => {}); | |
| } | |
| // Direktlink-Anmeldung: ?key=...&hf=... übernimmt Schlüssel und HF-Token | |
| // und entfernt beides sofort wieder aus der Adresszeile. | |
| const urlParams = new URLSearchParams(location.search); | |
| if (urlParams.get("key")) { | |
| schluessel = urlParams.get("key").trim(); | |
| localStorage.setItem("sa_key", schluessel); | |
| } | |
| if (urlParams.get("hf")) { | |
| hfToken = urlParams.get("hf").trim(); | |
| localStorage.setItem("sa_hf", hfToken); | |
| } | |
| if (urlParams.get("key") || urlParams.get("hf")) { | |
| history.replaceState(null, "", location.pathname); | |
| } | |
| $("#transkript").addEventListener("input", () => { | |
| const n = $("#transkript").value.trim().split(/\s+/).filter(Boolean).length; | |
| $("#transkript-info").textContent = n | |
| ? `${n} Wörter — wird den Sprechern zugeordnet statt neu erkannt.` | |
| : ""; | |
| if (n) $("#transkript-box").open = true; | |
| }); | |
| const gemerkteSprache = localStorage.getItem("sa_sprache"); | |
| if (gemerkteSprache !== null) $("#sprache").value = gemerkteSprache; | |
| $("#hf-input").value = hfToken; | |
| $("#kontext").value = localStorage.getItem("sa_kontext") || ""; | |
| if (!schluessel) { | |
| $("#login").hidden = false; | |
| } else { | |
| loginPruefen(schluessel).catch(() => { | |
| localStorage.removeItem("sa_key"); | |
| schluessel = ""; | |
| $("#login").hidden = false; | |
| }); | |
| } | |