feat(post): Vorlesen-Button (Web Speech API), nur wenn unterstützt
Deploy / verify (push) Successful in 1m17s
Deploy / deploy (push) Successful in 1m39s

PostActions: 🔊 Vorlesen liest Artikeltext (Titel+Body, versteckte Elemente
gefiltert), Toggle Pause/Weiter + Stop, deutsche Stimme, satzweise, stoppt
bei Seitenwechsel. Button erscheint nur wenn speechSynthesis verfügbar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Peter Meier
2026-07-01 09:54:12 +02:00
parent 85d6314ab4
commit 5591bd86f0
2 changed files with 99 additions and 4 deletions
+90 -1
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import Icon from "@iconify/svelte";
import { onMount } from "svelte";
import { onMount, onDestroy } from "svelte";
import "$lib/iconify-offline";
import { env } from "$env/dynamic/public";
import { useTranslate, T } from "$lib/translations";
@@ -156,6 +156,73 @@
function print() {
window.print();
}
// ── Vorlesen (Web Speech API) ───────────────────────────────────────────
// Button erscheint nur, wenn der Browser Sprachausgabe unterstützt.
let canSpeak = $state(false);
let speaking = $state(false);
let paused = $state(false);
$effect(() => {
canSpeak = typeof window !== "undefined" && "speechSynthesis" in window;
});
function getArticleText(): string {
const art = document.querySelector("article");
if (!art) return "";
const clone = art.cloneNode(true) as HTMLElement;
clone
.querySelectorAll("[hidden], .sr-only, script, style, nav, figure figcaption")
.forEach((n) => n.remove());
return (clone.textContent ?? "").replace(/\s+/g, " ").trim();
}
function speak() {
const synth = window.speechSynthesis;
synth.cancel();
const text = getArticleText();
if (!text) return;
// In Sätze zerlegen — manche Engines schneiden lange Utterances ab.
const chunks = text.match(/[^.!?]+[.!?]*(?:\s|$)/g) ?? [text];
const voice =
synth.getVoices().find((v) => v.lang?.toLowerCase().startsWith("de")) ?? null;
chunks.forEach((c, i) => {
const u = new SpeechSynthesisUtterance(c.trim());
u.lang = "de-DE";
if (voice) u.voice = voice;
if (i === chunks.length - 1)
u.onend = () => {
speaking = false;
paused = false;
};
synth.speak(u);
});
speaking = true;
paused = false;
}
function toggleSpeak() {
const synth = window.speechSynthesis;
if (!speaking) {
speak();
} else if (paused) {
synth.resume();
paused = false;
} else {
synth.pause();
paused = true;
}
}
function stopSpeak() {
window.speechSynthesis.cancel();
speaking = false;
paused = false;
}
onDestroy(() => {
if (typeof window !== "undefined" && "speechSynthesis" in window)
window.speechSynthesis.cancel();
});
</script>
<!--
@@ -194,6 +261,28 @@
/>
</button>
{/if}
{#if canSpeak}
<button
type="button"
onclick={toggleSpeak}
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors {speaking && !paused ? 'text-wald-700!' : ''}"
aria-label={!speaking ? "Vorlesen" : paused ? "Weiterlesen" : "Pause"}
title={!speaking ? "Vorlesen" : paused ? "Weiterlesen" : "Pause"}
>
<Icon icon={!speaking ? "lucide:volume-2" : paused ? "lucide:play" : "lucide:pause"} class="size-3.5" />
</button>
{#if speaking}
<button
type="button"
onclick={stopSpeak}
class="inline-flex items-center px-2.5 py-1 text-stein-600 hover:bg-stein-50 hover:text-stein-900 transition-colors"
aria-label="Vorlesen stoppen"
title="Vorlesen stoppen"
>
<Icon icon="lucide:square" class="size-3.5" />
</button>
{/if}
{/if}
<button
type="button"
onclick={copyLink}