This commit is contained in:
k
2026-09-24 11:37:02 +02:00
parent 8d72defb82
commit 30918f33e7
7 changed files with 129 additions and 34 deletions
+26 -10
View File
@@ -348,6 +348,7 @@ function VoicePreviewsCard() {
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const [serverVoices, setServerVoices] = useState<{ id: string; name: string }[]>([]);
useEffect(() => {
api.adminGetSettings()
@@ -356,6 +357,7 @@ function VoicePreviewsCard() {
setLoaded(true);
})
.catch(() => setLoaded(true));
api.ttsInfo().then((r) => setServerVoices(r.voices)).catch(() => {});
}, []);
async function save(next: VoicePreview[]) {
@@ -453,16 +455,30 @@ function VoicePreviewsCard() {
</span>
<span className="w-40">
<Field label={t("vfeat.voice")} hint={t("vfeat.voiceHint")}>
<Input
value={line.voice}
disabled={busy}
maxLength={64}
placeholder={t("vfeat.voicePh")}
aria-label={t("vfeat.voice")}
onChange={(e) => patch(i, { voice: e.target.value })}
onBlur={() => commit(i)}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
/>
{serverVoices.length > 0 ? (
<Picker
ariaLabel={t("vfeat.voice")}
value={line.voice}
onChange={(v) => {
patch(i, { voice: v });
const next = lines.map((l, j) => (j === i ? { ...l, voice: v } : l));
if (next[i]!.text.trim() !== "") void save(next);
}}
align="left"
options={[{ value: "", label: t("vfeat.voicePh") }, ...serverVoices.map((v) => ({ value: v.id, label: v.name }))]}
/>
) : (
<Input
value={line.voice}
disabled={busy}
maxLength={64}
placeholder={t("vfeat.voicePh")}
aria-label={t("vfeat.voice")}
onChange={(e) => patch(i, { voice: e.target.value })}
onBlur={() => commit(i)}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); }}
/>
)}
</Field>
</span>
<label className="min-w-32 flex-1 text-xs opacity-80">
+3 -3
View File
@@ -877,7 +877,7 @@ function VoiceSection() {
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>([]);
const [previews, setPreviews] = useState<VoicePreview[]>([]);
const [playing, setPlaying] = useState<number | null>(null);
const [ttsServer, setTtsServer] = useState<{ available: boolean; provider: string | null; voices: string[] } | null>(null);
const [ttsServer, setTtsServer] = useState<{ available: boolean; provider: string | null; voices: { id: string; name: string }[] } | null>(null);
useEffect(() => {
let alive = true;
@@ -979,7 +979,7 @@ function VoiceSection() {
</Field>
{(prefs.engine !== "browser") && (
ttsServer?.available ? (
ttsServer.provider === "openai" ? (
ttsServer.voices.length > 0 ? (
<Field label={t("voice.serverVoice")}>
<Picker
ariaLabel={t("voice.serverVoice")}
@@ -987,7 +987,7 @@ function VoiceSection() {
value={prefs.serverVoice ?? ""}
onChange={(v) => update({ serverVoice: v || null })}
align="left"
options={[{ value: "", label: t("voice.defaultVoice") }, ...ttsServer.voices.map((v) => ({ value: v, label: v }))]}
options={[{ value: "", label: t("voice.defaultVoice") }, ...ttsServer.voices.map((v) => ({ value: v.id, label: v.name }))]}
/>
</Field>
) : ttsServer.provider === "elevenlabs" ? (
+26 -6
View File
@@ -123,21 +123,23 @@ function mixHex(hex: string, target: number, amount: number): string {
}
/**
* Favicon follows the accent: a mini flower (petals in the accent ramp,
* amber core like the logo) swapped in as an SVG data URL, so the tab icon
* always matches the platform dress.
* Favicon follows the accent AND the theme: the tile background is
* theme-owned (white by day, near-black by night) while the flower itself
* wears the accent ramp — never an accent tile.
*/
export function paintFavicon(baseHex: string): void {
export function paintFavicon(baseHex: string, dark?: boolean): void {
if (typeof document === "undefined") return;
const base = /^#?[0-9a-f]{6}$/i.test(baseHex.trim()) ? baseHex.trim().replace(/^#?/, "#") : "#10b981";
const dark = mixHex(base, 0, 0.35);
const isDark = dark ?? document.documentElement.classList.contains("dark");
const tile = isDark ? "#09090b" : "#ffffff";
const ring = isDark ? "#27272a" : "#e7e5e4";
const light = mixHex(base, 255, 0.55);
const petals = [8, 80, 151, 223, 295].map(
(a) => `<ellipse cx="50" cy="28" rx="13" ry="20" fill="${base}" transform="rotate(${a} 50 50)"/>`,
).join("");
const svg =
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">` +
`<rect width="100" height="100" rx="22" fill="${dark}"/>${petals}` +
`<rect width="100" height="100" rx="22" fill="${tile}" stroke="${ring}" stroke-width="3"/>${petals}` +
`<ellipse cx="50" cy="28" rx="13" ry="20" fill="${light}" opacity="0.45" transform="rotate(8 50 50)"/>` +
`<circle cx="50" cy="50" r="10" fill="#fbbf24"/></svg>`;
const url = `data:image/svg+xml,${encodeURIComponent(svg)}`;
@@ -153,6 +155,24 @@ export function paintFavicon(baseHex: string): void {
}
}
/** Last accent base, so theme flips can repaint without knowing the color. */
let lastBase = "#10b981";
function repaint(): void {
paintFavicon(lastBase);
}
if (typeof document !== "undefined" && typeof MutationObserver !== "undefined") {
new MutationObserver((muts) => {
for (const m of muts) {
if (m.attributeName === "class") {
repaint();
break;
}
}
}).observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
}
// Restore the saved accent on load.
if (typeof document !== "undefined") {
applyAccent(currentAccent());
+1 -1
View File
@@ -446,7 +446,7 @@ export const api = {
tools: () => req<{ tools: { name: string; description: string; available: boolean; reason: string | null }[]; reportsEnabled: boolean; usernameChangeEnabled: boolean; voicePreviews: VoicePreview[] }>("/api/tools"),
/** Neural TTS provider status (null = browser speech fallback). */
ttsInfo: () => req<{ provider: "elevenlabs" | "openai" | "piper" | null; voices: string[]; available: boolean; reason: string | null }>("/api/tools/tts/info"),
ttsInfo: () => req<{ provider: "elevenlabs" | "openai" | "piper" | null; voices: { id: string; name: string }[]; available: boolean; reason: string | null }>("/api/tools/tts/info"),
/** Synthesize text server-side (neural voice). Returns audio bytes. */
ttsSpeak: async (text: string, voice?: string): Promise<Blob> => {