feat(post): Vorlesen-Button (Web Speech API), nur wenn unterstützt
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:
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user