feat: read a message aloud with the browser's voice, highlighting what it reads

This commit is contained in:
Linus Rath
2026-10-29 17:53:58 +01:00
parent bb78697981
commit af3019ea2b
38 changed files with 976 additions and 30 deletions
+13
View File
@@ -11,6 +11,8 @@ import { htmlToReadableText } from "@/lib/html-to-readable-text";
import { pauseAnimatedImages } from "@/lib/animated-images";
import { applyMailTextSize, mailFitOptions } from "@/lib/mail-reading";
import { useMailReading } from "@/hooks/use-mail-reading";
import { useReadAloud } from "@/hooks/use-read-aloud";
import { ReadAloudBar } from "./read-aloud-bar";
import { collectReferencedCids, isEmbeddedInBody, normalizeCid, rewriteCidReferences } from "@/lib/attachment-visibility";
import { collapsePlainTextQuotes, setupQuoteCollapse } from "@/lib/quote-collapse";
import { DARK_PENDING_CLASS, applyEmailDarkMode, emailColorScheme, emailDarkModeCss, emailHasNativeDarkMode as htmlHasNativeDarkMode, resolveColorSchemeMedia } from "@/lib/email-dark-mode";
@@ -105,6 +107,7 @@ import {
Maximize2,
Minimize2,
Type,
Volume2,
ArrowLeft,
Pencil,
} from "@/components/icons";
@@ -3333,6 +3336,12 @@ export function EmailViewer({
const iframeReady = iframeReadyFor !== null && iframeReadyFor === emailIframeSrcDoc;
// The plain-text body, where reader-above-quote mounts between text and quote.
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
// Read aloud (browser speech): the body as shown, HTML frame or plain text.
const getReadAloudRoot = useCallback(
() => (effectiveEmailContent.isHtml ? iframeRef.current?.contentDocument?.body ?? null : plainBodyEl),
[effectiveEmailContent.isHtml, plainBodyEl],
);
const readAloud = useReadAloud({ getRoot: getReadAloudRoot, lang: bodyLanguage, resetKey: effectiveEmailContent.isHtml ? emailIframeSrcDoc : effectiveEmailContent.html });
// A plain-text body sits in the page: watch the page's selection for it.
useEffect(() => {
if (!plainBodyEl || !hasDetailSidebar) return;
@@ -4156,6 +4165,9 @@ export function EmailViewer({
// The reader's own entries after the message's: the view toggle, Import
// (into the open folder) and, with a keyboard, the shortcuts.
const readerExtras: MessageActionEntry[] = [
...(readAloud.supported && !isBodyLoading
? [{ kind: 'item' as const, key: 'extra:read_aloud' as const, icon: Volume2, label: t('read_aloud'), run: readAloud.start }]
: []),
...(hasHtmlBody
? [{ kind: 'item' as const, key: 'extra:plain_text' as const, icon: plainView ? Type : FileText, label: plainView ? t('show_formatted') : t('show_as_plain_text'), run: () => setPlainViewOverride(!plainView) }]
: []),
@@ -6219,6 +6231,7 @@ export function EmailViewer({
!isDark && resolvedTheme === 'dark' ? "bg-white email-content-light" : "bg-background"
)}
style={isDark ? { backgroundColor: '#121212' } : undefined}>
<ReadAloudBar state={readAloud.state} onPause={readAloud.pause} onResume={readAloud.resume} onStop={readAloud.stop} />
{!isBodyLoading && plainView && hasHtmlBody && (
<div className="flex flex-wrap items-center gap-x-2 px-6 pt-3 text-sm text-muted-foreground" data-testid="plain-view-note">
<span>{t('shown_as_plain_text')}</span>
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useTranslations } from "next-intl";
import { Pause, Play, Stop, Volume2 } from "@/components/icons";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ReadAloudState } from "@/hooks/use-read-aloud";
interface ReadAloudBarProps {
state: ReadAloudState;
onPause: () => void;
onResume: () => void;
onStop: () => void;
className?: string;
}
/** Pause, resume and stop while a message is read aloud. */
export function ReadAloudBar({ state, onPause, onResume, onStop, className }: ReadAloudBarProps) {
const t = useTranslations('email_viewer');
if (state === 'idle') return null;
return (
<div
role="toolbar"
aria-label={t('read_aloud')}
className={cn("flex flex-wrap items-center gap-2 px-4 py-2 border-b border-border bg-muted/60 text-sm", className)}
data-testid="read-aloud-bar"
>
<Volume2 className="w-4 h-4 text-muted-foreground shrink-0" aria-hidden="true" />
<span className="me-auto text-foreground" role="status">
{state === 'paused' ? t('read_aloud_paused') : t('read_aloud_reading')}
</span>
{state === 'paused' ? (
<Button variant="outline" size="xs" onClick={onResume}>
<Play className="w-4 h-4 me-1" aria-hidden="true" />
{t('read_aloud_resume')}
</Button>
) : (
<Button variant="outline" size="xs" onClick={onPause}>
<Pause className="w-4 h-4 me-1" aria-hidden="true" />
{t('read_aloud_pause')}
</Button>
)}
<Button variant="outline" size="xs" onClick={onStop}>
<Stop className="w-4 h-4 me-1" aria-hidden="true" />
{t('read_aloud_stop')}
</Button>
</div>
);
}
+2
View File
@@ -171,6 +171,8 @@ export {
IconLayoutSidebarRightCollapse as PanelRightClose,
IconLayoutSidebarRightExpand as PanelRightOpen,
IconPaperclip as Paperclip,
IconPlayerPause as Pause,
IconPlayerStop as Stop,
IconPencil as PenLine,
IconEdit as PenSquare,
IconVector as PenTool,
@@ -10,6 +10,7 @@ import { presetChanges } from '@/lib/accessibility-presets';
vi.mock('next-intl', () => ({
useTranslations: (ns?: string) => (key: string, values?: Record<string, unknown>) =>
`${ns ? `${ns}.` : ''}${key}${values ? ` ${JSON.stringify(values)}` : ''}`,
useLocale: () => 'de',
}));
const settings = () => useSettingsStore.getState();
@@ -99,3 +100,22 @@ describe('toast duration', () => {
expect(values).toEqual(['20', '60', '0']);
});
});
describe('read aloud voice', () => {
const voice = (name: string, lang: string) => ({ name, lang, voiceURI: `uri:${name}`, default: false, localService: true });
it('offers the browser default and the voices for the interface language', () => {
const voices = [voice('Anna', 'de-DE'), voice('Daniel', 'en-GB'), voice('Markus', 'de-AT')];
vi.stubGlobal('SpeechSynthesisUtterance', class {});
vi.stubGlobal('speechSynthesis', { getVoices: () => voices, addEventListener() {}, removeEventListener() {} });
try {
render(<AccessibilitySettings />);
const select = screen.getByRole('combobox', { name: 'settings.accessibility.read_aloud_voice.label' }) as HTMLSelectElement;
expect(Array.from(select.options).map((o) => o.textContent)).toEqual(['settings.accessibility.read_aloud_voice.default', 'Anna', 'Markus']);
fireEvent.change(select, { target: { value: 'uri:Markus' } });
expect(settings().readAloudVoice).toBe('uri:Markus');
} finally {
vi.unstubAllGlobals();
}
});
});
+23 -1
View File
@@ -1,7 +1,7 @@
"use client";
import { useState } from 'react';
import { useTranslations } from 'next-intl';
import { useLocale, useTranslations } from 'next-intl';
import { useSettingsStore } from '@/stores/settings-store';
import { usePolicyStore } from '@/stores/policy-store';
import { SettingsSection, SettingItem, RadioGroup, Select, ToggleSwitch } from './settings-section';
@@ -10,6 +10,8 @@ import { ChevronRight, Keyboard, Volume2 } from '@/components/icons';
import { IS_LITE } from '@/lib/lite';
import { useNoThirdPartyTraffic } from '@/hooks/use-third-party-mode';
import { openKeyboardShortcuts } from '@/lib/app-commands';
import { voicesForLocale } from '@/lib/read-aloud';
import { useSpeechVoices } from '@/hooks/use-read-aloud';
import { NOTIFICATION_SOUNDS, playNotificationSound, type NotificationSoundChoice } from '@/lib/notification-sound';
import {
ACCESSIBILITY_DEFAULTS,
@@ -66,6 +68,8 @@ export function AccessibilitySettings() {
const faviconsAllowed = !useNoThirdPartyTraffic();
const [applied, setApplied] = useState<{ id: AccessibilityPresetId; changes: PresetChange[] } | null>(null);
const [resetDone, setResetDone] = useState(false);
const locale = useLocale();
const speechVoices = useSpeechVoices();
const toastMin = getRestriction('toastDuration')?.min;
const allowsValue = (key: string, value: unknown) =>
@@ -108,6 +112,17 @@ export function AccessibilitySettings() {
/>
);
// Voices for the interface language, plus the one already chosen even if
// this browser doesn't have it (another device may).
const localeVoices = voicesForLocale(speechVoices, locale);
const readAloudVoiceOptions = [
{ value: '', label: t('read_aloud_voice.default') },
...localeVoices.map((voice) => ({ value: voice.voiceURI, label: voice.name })),
...(settings.readAloudVoice && !localeVoices.some((voice) => voice.voiceURI === settings.readAloudVoice)
? [{ value: settings.readAloudVoice, label: speechVoices.find((voice) => voice.voiceURI === settings.readAloudVoice)?.name ?? settings.readAloudVoice }]
: []),
];
const toastOptions = TOAST_DURATIONS
.filter((value) => allowsValue('toastDuration', value))
.map((value) => ({
@@ -426,6 +441,13 @@ export function AccessibilitySettings() {
options={READ_ALOUD_RATES.map((rate) => ({ value: String(rate), label: `${rate}×` }))}
/>
</SettingItem>
<SettingItem label={t('read_aloud_voice.label')} description={t('read_aloud_voice.description')}>
<Select
value={settings.readAloudVoice}
onChange={(value) => set('readAloudVoice', value)}
options={readAloudVoiceOptions}
/>
</SettingItem>
<SettingItem label={t('compose_font.label')} description={t('compose_font.description')}>
<Select
value={settings.composeFontFamily}
+89
View File
@@ -0,0 +1,89 @@
import { act, renderHook } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useReadAloud } from '@/hooks/use-read-aloud';
import { useSettingsStore } from '@/stores/settings-store';
class FakeUtterance {
text: string;
rate = 1;
lang = '';
voice: unknown = null;
onstart: (() => void) | null = null;
onend: (() => void) | null = null;
onerror: ((e: { error: string }) => void) | null = null;
onboundary: ((e: { name: string; charIndex: number; charLength?: number }) => void) | null = null;
constructor(text: string) {
this.text = text;
}
}
const spoken: FakeUtterance[] = [];
const synth = {
speak: vi.fn((u: FakeUtterance) => { spoken.push(u); }),
cancel: vi.fn(),
pause: vi.fn(),
resume: vi.fn(),
getVoices: () => [{ name: 'Anna', lang: 'de-DE', voiceURI: 'uri:anna' }],
};
beforeEach(() => {
spoken.length = 0;
vi.stubGlobal('SpeechSynthesisUtterance', FakeUtterance);
vi.stubGlobal('speechSynthesis', synth);
useSettingsStore.setState({ readAloudRate: 1.25, readAloudVoice: 'uri:anna' } as never);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
function body(html: string) {
const el = document.createElement('div');
el.innerHTML = html;
document.body.appendChild(el);
return el;
}
describe('useReadAloud', () => {
it('reads sentence by sentence at the chosen speed and voice', () => {
const el = body('<p>First sentence. Second one.</p>');
const { result } = renderHook(() => useReadAloud({ getRoot: () => el, lang: 'de' }));
expect(result.current.supported).toBe(true);
act(() => result.current.start());
expect(result.current.state).toBe('speaking');
expect(spoken.map((u) => u.text)).toEqual(['First sentence.']);
expect(spoken[0].rate).toBe(1.25);
expect(spoken[0].voice).toMatchObject({ name: 'Anna' });
act(() => spoken[0].onend!());
expect(spoken.map((u) => u.text)).toEqual(['First sentence.', 'Second one.']);
act(() => spoken[1].onend!());
expect(result.current.state).toBe('idle');
});
it('pauses, resumes and stops', () => {
const el = body('<p>One. Two.</p>');
const { result } = renderHook(() => useReadAloud({ getRoot: () => el }));
act(() => result.current.start());
act(() => result.current.pause());
expect(synth.pause).toHaveBeenCalled();
expect(result.current.state).toBe('paused');
act(() => result.current.resume());
expect(result.current.state).toBe('speaking');
act(() => result.current.stop());
expect(synth.cancel).toHaveBeenCalled();
expect(result.current.state).toBe('idle');
// A late end event from the cancelled run starts nothing.
act(() => spoken[0].onend!());
expect(spoken).toHaveLength(1);
});
it('stops when another message opens', () => {
const el = body('<p>One. Two.</p>');
const { result, rerender } = renderHook(({ id }) => useReadAloud({ getRoot: () => el, resetKey: id }), { initialProps: { id: 'a' } });
act(() => result.current.start());
synth.cancel.mockClear();
rerender({ id: 'b' });
expect(synth.cancel).toHaveBeenCalled();
expect(result.current.state).toBe('idle');
});
});
+152
View File
@@ -0,0 +1,152 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useSettingsStore } from "@/stores/settings-store";
import {
SENTENCE_HIGHLIGHT,
WORD_HIGHLIGHT,
collectSpeechText,
pickVoice,
rangeForOffsets,
setSpeechHighlight,
splitSentences,
wordEnd,
type SpeechText,
} from "@/lib/read-aloud";
export type ReadAloudState = 'idle' | 'speaking' | 'paused';
interface Options {
/** The element whose text is read; looked up when reading starts. */
getRoot: () => Element | null;
/** The text's language (BCP 47), when known; otherwise the voice decides. */
lang?: string | null;
/** Reading stops when this changes (another message opened). */
resetKey?: unknown;
}
function speech(): SpeechSynthesis | null {
return typeof window !== 'undefined' && 'speechSynthesis' in window && typeof SpeechSynthesisUtterance !== 'undefined'
? window.speechSynthesis
: null;
}
/**
* Reads a message aloud with the browser's speech synthesis, at the speed
* and in the voice from Settings > Accessibility, one sentence per utterance.
* The sentence being read is highlighted, and the word too where the browser
* sends word boundary events.
*/
export function useReadAloud({ getRoot, lang, resetKey }: Options) {
const rate = useSettingsStore((state) => state.readAloudRate);
const voiceName = useSettingsStore((state) => state.readAloudVoice);
const [state, setState] = useState<ReadAloudState>('idle');
const supported = speech() !== null;
// Bumped by every start and stop, so callbacks of an old run do nothing.
const runRef = useRef(0);
const docRef = useRef<Document | null>(null);
const clearHighlights = useCallback(() => {
const doc = docRef.current;
if (!doc) return;
setSpeechHighlight(doc, SENTENCE_HIGHLIGHT, null);
setSpeechHighlight(doc, WORD_HIGHLIGHT, null);
}, []);
const stop = useCallback(() => {
runRef.current += 1;
speech()?.cancel();
clearHighlights();
setState('idle');
}, [clearHighlights]);
const start = useCallback(() => {
const synth = speech();
const root = getRoot();
if (!synth || !root) return;
synth.cancel();
clearHighlights();
const run = ++runRef.current;
const doc = root.ownerDocument;
docRef.current = doc;
const text: SpeechText = collectSpeechText(root);
const chunks = splitSentences(text.text, lang ?? undefined);
if (chunks.length === 0) {
setState('idle');
return;
}
const voice = pickVoice(synth.getVoices(), voiceName);
const speakChunk = (index: number) => {
if (run !== runRef.current) return;
if (index >= chunks.length) {
clearHighlights();
setState('idle');
return;
}
const chunk = chunks[index];
const utterance = new SpeechSynthesisUtterance(text.text.slice(chunk.start, chunk.end));
utterance.rate = rate;
if (voice) {
utterance.voice = voice;
utterance.lang = voice.lang;
} else if (lang) {
utterance.lang = lang;
}
utterance.onstart = () => {
if (run !== runRef.current) return;
const range = rangeForOffsets(text, chunk.start, chunk.end);
setSpeechHighlight(doc, SENTENCE_HIGHLIGHT, range);
setSpeechHighlight(doc, WORD_HIGHLIGHT, null);
// Keep the sentence on screen without animating the scroll.
const anchor = range?.startContainer.parentElement;
anchor?.scrollIntoView?.({ block: 'nearest', inline: 'nearest' });
};
utterance.onboundary = (event) => {
if (run !== runRef.current || event.name !== 'word') return;
const from = chunk.start + event.charIndex;
const to = event.charLength ? from + event.charLength : wordEnd(text.text, from);
setSpeechHighlight(doc, WORD_HIGHLIGHT, rangeForOffsets(text, from, to));
};
utterance.onend = () => speakChunk(index + 1);
utterance.onerror = (event) => {
// "interrupted"/"canceled" come from our own stop.
if (run !== runRef.current) return;
if (event.error === 'interrupted' || event.error === 'canceled') return;
stop();
};
synth.speak(utterance);
};
setState('speaking');
speakChunk(0);
}, [getRoot, lang, rate, voiceName, clearHighlights, stop]);
const pause = useCallback(() => {
speech()?.pause();
setState('paused');
}, []);
const resume = useCallback(() => {
speech()?.resume();
setState('speaking');
}, []);
// Another message, or the reader closed: stop talking.
useEffect(() => stop, [resetKey, stop]);
return { supported, state, start, pause, resume, stop };
}
/** The browser's speech voices, updated when the browser finishes loading them. */
export function useSpeechVoices(): SpeechSynthesisVoice[] {
const [voices, setVoices] = useState<SpeechSynthesisVoice[]>(() => speech()?.getVoices() ?? []);
useEffect(() => {
const synth = speech();
if (!synth) return;
const update = () => setVoices(synth.getVoices());
update();
synth.addEventListener?.('voiceschanged', update);
return () => synth.removeEventListener?.('voiceschanged', update);
}, []);
return voices;
}
+72
View File
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import { collectSpeechText, pickVoice, rangeForOffsets, splitSentences, voicesForLocale, wordEnd } from '@/lib/read-aloud';
function root(html: string): HTMLElement {
const el = document.createElement('div');
el.innerHTML = html;
document.body.appendChild(el);
return el;
}
describe('collectSpeechText', () => {
it('reads what is on screen, block by block, with alt text', () => {
const el = root('<p>Hello <b>Ann</b>.</p><p>See <img alt="the chart"> below.</p><div style="display:none">hidden</div><button>Play</button>');
expect(collectSpeechText(el).text).toBe('Hello Ann.\nSee the chart below.\n');
});
it('reads only the summary of a folded quote', () => {
const el = root('<p>Reply</p><details><summary>Show quoted text</summary><p>Old mail</p></details>');
expect(collectSpeechText(el).text).not.toContain('Old mail');
});
});
describe('splitSentences', () => {
it('splits sentences and lines, dropping empty ones', () => {
const text = 'Hi Ann. How are you?\n\nBest,\nBob';
expect(splitSentences(text, 'en').map(({ start, end }) => text.slice(start, end))).toEqual(['Hi Ann.', 'How are you?', 'Best,', 'Bob']);
});
it('cuts long sentences so no utterance gets too long', () => {
const text = `${'word '.repeat(120)}end.`;
const chunks = splitSentences(text, 'en');
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.every(({ start, end }) => end - start <= 220)).toBe(true);
expect(chunks.map(({ start, end }) => text.slice(start, end)).join(' ')).toBe(text.trim());
});
});
describe('rangeForOffsets', () => {
it('maps spoken offsets back to the text nodes', () => {
const el = root('<p>Hello <b>dear</b> Ann</p>');
const speech = collectSpeechText(el);
const start = speech.text.indexOf('dear');
const range = rangeForOffsets(speech, start, start + 8)!;
expect(range.toString()).toBe('dear Ann');
});
it('is null for text that is not on the page', () => {
const el = root('<img alt="logo">');
const speech = collectSpeechText(el);
expect(rangeForOffsets(speech, 0, speech.text.length)).toBeNull();
});
});
describe('voices', () => {
const voice = (name: string, lang: string) => ({ name, lang, voiceURI: `uri:${name}` }) as SpeechSynthesisVoice;
const voices = [voice('Zoe', 'en-US'), voice('Anna', 'de-DE'), voice('Max', 'de_AT')];
it('lists the voices for the interface language', () => {
expect(voicesForLocale(voices, 'de').map((v) => v.name)).toEqual(['Anna', 'Max']);
});
it('picks the chosen voice, or none for the browser default', () => {
expect(pickVoice(voices, 'uri:Max')?.name).toBe('Max');
expect(pickVoice(voices, 'Zoe')?.name).toBe('Zoe');
expect(pickVoice(voices, '')).toBeNull();
expect(pickVoice(voices, 'uri:gone')).toBeNull();
});
it('finds the end of a word', () => {
expect(wordEnd('say hello world', 4)).toBe(9);
});
});
+208
View File
@@ -0,0 +1,208 @@
/**
* Read aloud: the text of a message body, split into sentences for the
* browser's speech synthesis, with a map back to the DOM so the sentence and
* word being spoken can be highlighted.
*
* Runs entirely in the browser (Web Speech API), so it works in Lite and
* without a server.
*/
/** Where a piece of the spoken text came from; `node` is null for text we added (line breaks, alt text). */
export interface SpeechSegment {
node: Text | null;
start: number;
end: number;
}
export interface SpeechText {
text: string;
segments: SpeechSegment[];
}
const BLOCK = /^(ADDRESS|ARTICLE|ASIDE|BLOCKQUOTE|DD|DIV|DL|DT|FIGCAPTION|FIGURE|FOOTER|H[1-6]|HEADER|HR|LI|MAIN|NAV|OL|P|PRE|SECTION|TABLE|TR|UL|TD|TH)$/;
const SKIP = /^(SCRIPT|STYLE|TEMPLATE|NOSCRIPT|BUTTON|SVG|CANVAS|SELECT|TEXTAREA)$/;
/**
* The text of `root` as it reads on screen: hidden parts left out, a line
* break after each block, image alt text in place of the image.
*/
export function collectSpeechText(root: Element): SpeechText {
const view = root.ownerDocument.defaultView;
const segments: SpeechSegment[] = [];
let text = '';
const add = (value: string, node: Text | null) => {
if (!value) return;
segments.push({ node, start: text.length, end: text.length + value.length });
text += value;
};
const breakLine = () => {
if (text && !text.endsWith('\n')) add('\n', null);
};
const walk = (node: Node) => {
if (node.nodeType === 3) {
add(node.nodeValue ?? '', node as Text);
return;
}
if (node.nodeType !== 1) return;
const el = node as Element;
const tag = el.tagName.toUpperCase();
if (SKIP.test(tag) || el.getAttribute('aria-hidden') === 'true') return;
if (view) {
const style = view.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return;
}
// A closed <details> (the folded quote) shows only its summary.
if (tag === 'DETAILS' && !(el as HTMLDetailsElement).open) {
const summary = el.querySelector(':scope > summary');
if (summary) walk(summary);
return;
}
if (tag === 'BR') {
add('\n', null);
return;
}
if (tag === 'IMG') {
const alt = el.getAttribute('alt')?.trim();
if (alt) add(` ${alt} `, null);
return;
}
const block = BLOCK.test(tag);
if (block) breakLine();
el.childNodes.forEach(walk);
if (block) breakLine();
};
walk(root);
return { text, segments };
}
export interface SpeechChunk {
start: number;
end: number;
}
/** Longest chunk handed to one utterance; Chrome stops long ones midway. */
const MAX_CHUNK = 220;
/**
* Sentences of `text` as offset ranges, skipping blank ones. Long sentences
* are cut at a comma or space so no utterance gets too long.
*/
export function splitSentences(text: string, locale?: string): SpeechChunk[] {
const raw: SpeechChunk[] = [];
const Segmenter = (Intl as unknown as { Segmenter?: new (l?: string, o?: { granularity: string }) => { segment(t: string): Iterable<{ index: number; segment: string }> } }).Segmenter;
if (Segmenter) {
for (const { index, segment } of new Segmenter(locale, { granularity: 'sentence' }).segment(text)) {
raw.push({ start: index, end: index + segment.length });
}
} else {
const re = /[^.!?\n]+(?:[.!?]+|\n|$)/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
if (!m[0]) break;
raw.push({ start: m.index, end: m.index + m[0].length });
}
}
// Line breaks end a sentence too (headings, list items, table rows).
const lines: SpeechChunk[] = [];
for (const chunk of raw) {
let start = chunk.start;
for (let i = chunk.start; i < chunk.end; i++) {
if (text[i] === '\n') {
lines.push({ start, end: i });
start = i + 1;
}
}
lines.push({ start, end: chunk.end });
}
const out: SpeechChunk[] = [];
for (const chunk of lines) {
let { start } = chunk;
while (chunk.end - start > MAX_CHUNK) {
const window = text.slice(start, start + MAX_CHUNK);
const cut = Math.max(window.lastIndexOf(', '), window.lastIndexOf('; '));
const at = cut > MAX_CHUNK / 3 ? cut + 1 : Math.max(window.lastIndexOf(' '), MAX_CHUNK / 2);
out.push({ start, end: start + at });
start += at;
}
out.push({ start, end: chunk.end });
}
// Trim whitespace and drop chunks with nothing to say.
return out
.map(({ start, end }) => {
while (start < end && /\s/.test(text[start])) start++;
while (end > start && /\s/.test(text[end - 1])) end--;
return { start, end };
})
.filter(({ start, end }) => end > start && /[\p{L}\p{N}]/u.test(text.slice(start, end)));
}
/** A DOM range over the text between two offsets, or null if none of it is page text. */
export function rangeForOffsets(speech: SpeechText, start: number, end: number): Range | null {
let first: { node: Text; offset: number } | null = null;
let last: { node: Text; offset: number } | null = null;
for (const seg of speech.segments) {
if (!seg.node || seg.end <= start || seg.start >= end) continue;
if (!first) first = { node: seg.node, offset: Math.max(0, start - seg.start) };
last = { node: seg.node, offset: Math.min(seg.end, end) - seg.start };
}
if (!first || !last) return null;
const range = first.node.ownerDocument.createRange();
try {
range.setStart(first.node, first.offset);
range.setEnd(last.node, last.offset);
} catch {
return null;
}
return range;
}
/** End of the word starting at `index`, for browsers whose boundary events carry no length. */
export function wordEnd(text: string, index: number): number {
const match = /^[^\s]+/.exec(text.slice(index));
return index + (match ? match[0].length : 0);
}
export const SENTENCE_HIGHLIGHT = 'bulwark-read-aloud-sentence';
export const WORD_HIGHLIGHT = 'bulwark-read-aloud-word';
const HIGHLIGHT_STYLE = `::highlight(${SENTENCE_HIGHLIGHT}){background-color:rgba(255,213,0,.35);color:inherit}::highlight(${WORD_HIGHLIGHT}){background-color:#ffd500;color:#000}`;
interface HighlightWindow {
CSS?: { highlights?: Map<string, unknown> };
Highlight?: new (...ranges: Range[]) => unknown;
}
/**
* Marks `range` with a CSS custom highlight in its own document (a message
* frame has its own registry). Does nothing where the browser has no
* Highlight API; null clears it.
*/
export function setSpeechHighlight(doc: Document, name: string, range: Range | null): void {
const win = doc.defaultView as unknown as HighlightWindow | null;
const registry = win?.CSS?.highlights;
if (!registry || !win?.Highlight) return;
if (!doc.head.querySelector('style[data-bulwark-read-aloud]')) {
const style = doc.createElement('style');
style.setAttribute('data-bulwark-read-aloud', '');
style.textContent = HIGHLIGHT_STYLE;
doc.head.appendChild(style);
}
if (range) registry.set(name, new win.Highlight(range));
else registry.delete(name);
}
/**
* The voice for `wanted` (a voiceURI or name) among the browser's voices, or
* null for the browser default.
*/
export function pickVoice(voices: readonly SpeechSynthesisVoice[], wanted: string): SpeechSynthesisVoice | null {
if (!wanted) return null;
return voices.find((v) => v.voiceURI === wanted) ?? voices.find((v) => v.name === wanted) ?? null;
}
/** The browser's voices that speak `locale` ("de" matches "de-DE", "de-AT"). */
export function voicesForLocale(voices: readonly SpeechSynthesisVoice[], locale: string): SpeechSynthesisVoice[] {
const primary = locale.toLowerCase().split(/[-_]/)[0];
return voices
.filter((v) => v.lang.toLowerCase().replace('_', '-').split('-')[0] === primary)
.sort((a, b) => a.name.localeCompare(b.name));
}
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "العرض منسقًا",
"shown_as_plain_text": "تُعرض هذه الرسالة كنص عادي.",
"play_animation": "تشغيل الرسوم المتحركة",
"pause_animation": "إيقاف الرسوم المتحركة مؤقتًا"
"pause_animation": "إيقاف الرسوم المتحركة مؤقتًا",
"read_aloud": "القراءة بصوت عالٍ",
"read_aloud_reading": "تجري القراءة بصوت عالٍ",
"read_aloud_paused": "القراءة متوقفة مؤقتًا",
"read_aloud_pause": "إيقاف مؤقت",
"read_aloud_resume": "استئناف",
"read_aloud_stop": "إيقاف القراءة"
},
"external_mail": {
"sender_label": "خارجي",
@@ -3154,6 +3160,11 @@
"description": "يعيد كل إعدادات هذه الصفحة إلى قيمتها الافتراضية، أو إلى القيمة التي حددتها مؤسستك. الإعدادات المشتركة مثل عرض المحادثات تبقى كما هي.",
"button": "إعادة تعيين",
"done": "تمت إعادة تعيين إعدادات إمكانية الوصول."
},
"read_aloud_voice": {
"label": "صوت القراءة بصوت عالٍ",
"description": "الصوت الذي يقرأ الرسائل بصوت عالٍ. تعرض القائمة الأصوات المتوفرة في متصفحك للغة الواجهة.",
"default": "الافتراضي في المتصفح"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Mostra amb format",
"shown_as_plain_text": "Aquest missatge es mostra com a text sense format.",
"play_animation": "Reprodueix l'animació",
"pause_animation": "Posa en pausa l'animació"
"pause_animation": "Posa en pausa l'animació",
"read_aloud": "Llegeix en veu alta",
"read_aloud_reading": "S'està llegint en veu alta",
"read_aloud_paused": "Lectura en pausa",
"read_aloud_pause": "Pausa",
"read_aloud_resume": "Reprèn",
"read_aloud_stop": "Atura la lectura"
},
"external_mail": {
"sender_label": "Extern",
@@ -3154,6 +3160,11 @@
"description": "Torna totes les opcions d'aquesta pàgina al valor per defecte, o al que hagi fixat la teva organització. Les opcions compartides, com la vista de converses, no canvien.",
"button": "Restableix",
"done": "S'han restablert les opcions d'accessibilitat."
},
"read_aloud_voice": {
"label": "Veu de la lectura en veu alta",
"description": "La veu que llegeix els missatges. La llista mostra les veus que té el navegador per a la llengua de la interfície.",
"default": "Predeterminada del navegador"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Zobrazit formátovaně",
"shown_as_plain_text": "Tato zpráva se zobrazuje jako prostý text.",
"play_animation": "Přehrát animaci",
"pause_animation": "Pozastavit animaci"
"pause_animation": "Pozastavit animaci",
"read_aloud": "Předčítat",
"read_aloud_reading": "Předčítá se",
"read_aloud_paused": "Předčítání pozastaveno",
"read_aloud_pause": "Pozastavit",
"read_aloud_resume": "Pokračovat",
"read_aloud_stop": "Ukončit předčítání"
},
"external_mail": {
"sender_label": "Externí",
@@ -3154,6 +3160,11 @@
"description": "Vrátí všechna nastavení na této stránce na výchozí hodnoty nebo na hodnoty, které nastavila vaše organizace. Sdílená nastavení, například zobrazení konverzací, zůstanou beze změny.",
"button": "Obnovit",
"done": "Nastavení přístupnosti bylo obnoveno."
},
"read_aloud_voice": {
"label": "Hlas předčítání",
"description": "Hlas, který předčítá zprávy. Seznam ukazuje hlasy, které má prohlížeč pro jazyk rozhraní.",
"default": "Výchozí v prohlížeči"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Vis formateret",
"shown_as_plain_text": "Denne besked vises som almindelig tekst.",
"play_animation": "Afspil animation",
"pause_animation": "Sæt animation på pause"
"pause_animation": "Sæt animation på pause",
"read_aloud": "Læs op",
"read_aloud_reading": "Læser op",
"read_aloud_paused": "Oplæsning sat på pause",
"read_aloud_pause": "Pause",
"read_aloud_resume": "Fortsæt",
"read_aloud_stop": "Stop oplæsning"
},
"external_mail": {
"sender_label": "Ekstern",
@@ -3154,6 +3160,11 @@
"description": "Sætter alle indstillinger på denne side tilbage til standard eller til det, din organisation har valgt. Delte indstillinger som samtalevisning forbliver, som de er.",
"button": "Nulstil",
"done": "Tilgængelighedsindstillingerne er nulstillet."
},
"read_aloud_voice": {
"label": "Oplæsningsstemme",
"description": "Stemmen, der læser beskeder op. Listen viser de stemmer, din browser har til grænsefladens sprog.",
"default": "Browserens standard"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Formatiert anzeigen",
"shown_as_plain_text": "Diese Nachricht wird als reiner Text angezeigt.",
"play_animation": "Animation abspielen",
"pause_animation": "Animation anhalten"
"pause_animation": "Animation anhalten",
"read_aloud": "Vorlesen",
"read_aloud_reading": "Wird vorgelesen",
"read_aloud_paused": "Vorlesen angehalten",
"read_aloud_pause": "Anhalten",
"read_aloud_resume": "Fortsetzen",
"read_aloud_stop": "Vorlesen beenden"
},
"external_mail": {
"sender_label": "Extern",
@@ -3154,6 +3160,11 @@
"description": "Setzt alle Einstellungen auf dieser Seite auf den Standard zurück, oder auf den Wert, den Ihre Organisation festgelegt hat. Gemeinsame Einstellungen wie die Konversationsansicht bleiben, wie sie sind.",
"button": "Zurücksetzen",
"done": "Barrierefreiheit zurückgesetzt."
},
"read_aloud_voice": {
"label": "Vorlesestimme",
"description": "Die Stimme, die Nachrichten vorliest. Die Liste zeigt die Stimmen, die Ihr Browser für die Sprache der Oberfläche hat.",
"default": "Standard des Browsers"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Show formatted",
"shown_as_plain_text": "This message is shown as plain text.",
"play_animation": "Play animation",
"pause_animation": "Pause animation"
"pause_animation": "Pause animation",
"read_aloud": "Read aloud",
"read_aloud_reading": "Reading aloud",
"read_aloud_paused": "Reading paused",
"read_aloud_pause": "Pause",
"read_aloud_resume": "Resume",
"read_aloud_stop": "Stop reading"
},
"external_mail": {
"sender_label": "External",
@@ -3154,6 +3160,11 @@
"description": "Puts every setting on this page back to its default, or to the one your organisation set. Shared settings such as conversation view stay as they are.",
"button": "Reset",
"done": "Accessibility settings reset."
},
"read_aloud_voice": {
"label": "Read aloud voice",
"description": "The voice that reads messages aloud. The list shows the voices your browser has for the interface language.",
"default": "Browser default"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Mostrar con formato",
"shown_as_plain_text": "Este mensaje se muestra como texto sin formato.",
"play_animation": "Reproducir animación",
"pause_animation": "Pausar animación"
"pause_animation": "Pausar animación",
"read_aloud": "Leer en voz alta",
"read_aloud_reading": "Leyendo en voz alta",
"read_aloud_paused": "Lectura en pausa",
"read_aloud_pause": "Pausar",
"read_aloud_resume": "Reanudar",
"read_aloud_stop": "Detener la lectura"
},
"external_mail": {
"sender_label": "Externo",
@@ -3154,6 +3160,11 @@
"description": "Devuelve todos los ajustes de esta página a su valor predeterminado, o al que haya fijado tu organización. Los ajustes compartidos, como la vista de conversaciones, no cambian.",
"button": "Restablecer",
"done": "Ajustes de accesibilidad restablecidos."
},
"read_aloud_voice": {
"label": "Voz de lectura en voz alta",
"description": "La voz que lee los mensajes en voz alta. La lista muestra las voces que tiene tu navegador para el idioma de la interfaz.",
"default": "Predeterminada del navegador"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Näita vormindatult",
"shown_as_plain_text": "Seda kirja näidatakse lihttekstina.",
"play_animation": "Esita animatsioon",
"pause_animation": "Peata animatsioon"
"pause_animation": "Peata animatsioon",
"read_aloud": "Loe ette",
"read_aloud_reading": "Loen ette",
"read_aloud_paused": "Ettelugemine on peatatud",
"read_aloud_pause": "Peata",
"read_aloud_resume": "Jätka",
"read_aloud_stop": "Lõpeta ettelugemine"
},
"external_mail": {
"sender_label": "Väline",
@@ -3154,6 +3160,11 @@
"description": "Taastab kõik selle lehe seaded vaikeväärtustele või need, mille sinu organisatsioon on määranud. Ühised seaded, nagu vestlusvaade, jäävad samaks.",
"button": "Lähtesta",
"done": "Juurdepääsetavuse seaded on lähtestatud."
},
"read_aloud_voice": {
"label": "Ettelugemise hääl",
"description": "Hääl, mis kirju ette loeb. Loendis on hääled, mis brauseril liidese keele jaoks on.",
"default": "Brauseri vaikeväärtus"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "نمایش قالب‌بندی‌شده",
"shown_as_plain_text": "این پیام به‌صورت متن ساده نمایش داده می‌شود.",
"play_animation": "پخش پویانمایی",
"pause_animation": "توقف پویانمایی"
"pause_animation": "توقف پویانمایی",
"read_aloud": "خواندن با صدا",
"read_aloud_reading": "در حال خواندن با صدا",
"read_aloud_paused": "خواندن متوقف شد",
"read_aloud_pause": "توقف",
"read_aloud_resume": "ادامه",
"read_aloud_stop": "پایان خواندن"
},
"external_mail": {
"sender_label": "خارجی",
@@ -3154,6 +3160,11 @@
"description": "همهٔ تنظیمات این صفحه را به پیش‌فرض یا به مقداری که سازمان شما تعیین کرده برمی‌گرداند. تنظیمات مشترک مانند نمای مکالمه همان‌طور که هستند می‌مانند.",
"button": "بازنشانی",
"done": "تنظیمات دسترس‌پذیری بازنشانی شد."
},
"read_aloud_voice": {
"label": "صدای خواندن با صدا",
"description": "صدایی که پیام‌ها را می‌خواند. فهرست، صداهایی را نشان می‌دهد که مرورگر شما برای زبان رابط کاربری دارد.",
"default": "پیش‌فرض مرورگر"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Afficher la mise en forme",
"shown_as_plain_text": "Ce message est affiché en texte brut.",
"play_animation": "Lire l'animation",
"pause_animation": "Mettre l'animation en pause"
"pause_animation": "Mettre l'animation en pause",
"read_aloud": "Lire à voix haute",
"read_aloud_reading": "Lecture à voix haute en cours",
"read_aloud_paused": "Lecture en pause",
"read_aloud_pause": "Pause",
"read_aloud_resume": "Reprendre",
"read_aloud_stop": "Arrêter la lecture"
},
"external_mail": {
"sender_label": "Externe",
@@ -3154,6 +3160,11 @@
"description": "Remet tous les paramètres de cette page à leur valeur par défaut, ou à celle définie par votre organisation. Les paramètres partagés, comme l'affichage en conversations, restent inchangés.",
"button": "Réinitialiser",
"done": "Paramètres d'accessibilité réinitialisés."
},
"read_aloud_voice": {
"label": "Voix de lecture à voix haute",
"description": "La voix qui lit les messages. La liste montre les voix de votre navigateur pour la langue de l'interface.",
"default": "Voix par défaut du navigateur"
}
}
},
+12 -1
View File
@@ -741,7 +741,13 @@
"show_formatted": "הצגה מעוצבת",
"shown_as_plain_text": "הודעה זו מוצגת כטקסט פשוט.",
"play_animation": "הפעלת האנימציה",
"pause_animation": "השהיית האנימציה"
"pause_animation": "השהיית האנימציה",
"read_aloud": "הקראה",
"read_aloud_reading": "מקריא",
"read_aloud_paused": "ההקראה מושהית",
"read_aloud_pause": "השהיה",
"read_aloud_resume": "המשך",
"read_aloud_stop": "עצירת ההקראה"
},
"external_mail": {
"sender_label": "חיצוני",
@@ -3120,6 +3126,11 @@
"description": "מחזיר את כל ההגדרות בעמוד זה לברירת המחדל, או לערך שהארגון שלכם קבע. הגדרות משותפות, כמו תצוגת שיחות, נשארות כמו שהן.",
"button": "איפוס",
"done": "הגדרות הנגישות אופסו."
},
"read_aloud_voice": {
"label": "קול ההקראה",
"description": "הקול שמקריא הודעות. הרשימה מציגה את הקולות שיש לדפדפן שלך לשפת הממשק.",
"default": "ברירת המחדל של הדפדפן"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Megjelenítés formázva",
"shown_as_plain_text": "Ez az üzenet egyszerű szövegként jelenik meg.",
"play_animation": "Animáció lejátszása",
"pause_animation": "Animáció szüneteltetése"
"pause_animation": "Animáció szüneteltetése",
"read_aloud": "Felolvasás",
"read_aloud_reading": "Felolvasás folyamatban",
"read_aloud_paused": "Felolvasás szüneteltetve",
"read_aloud_pause": "Szünet",
"read_aloud_resume": "Folytatás",
"read_aloud_stop": "Felolvasás leállítása"
},
"external_mail": {
"sender_label": "Külső",
@@ -3154,6 +3160,11 @@
"description": "Az oldal minden beállítását visszaállítja az alapértelmezettre, vagy arra, amelyet a szervezete megadott. A közös beállítások, például a beszélgetésnézet, változatlanok maradnak.",
"button": "Visszaállítás",
"done": "A kisegítő beállítások visszaállítva."
},
"read_aloud_voice": {
"label": "Felolvasási hang",
"description": "A hang, amely felolvassa az üzeneteket. A lista a böngésző által a felület nyelvéhez kínált hangokat mutatja.",
"default": "A böngésző alapértelmezése"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Mostra formattato",
"shown_as_plain_text": "Questo messaggio è mostrato come testo semplice.",
"play_animation": "Riproduci animazione",
"pause_animation": "Metti in pausa l'animazione"
"pause_animation": "Metti in pausa l'animazione",
"read_aloud": "Leggi ad alta voce",
"read_aloud_reading": "Lettura ad alta voce in corso",
"read_aloud_paused": "Lettura in pausa",
"read_aloud_pause": "Pausa",
"read_aloud_resume": "Riprendi",
"read_aloud_stop": "Interrompi la lettura"
},
"external_mail": {
"sender_label": "Esterno",
@@ -3154,6 +3160,11 @@
"description": "Riporta ogni impostazione di questa pagina al valore predefinito, o a quello scelto dalla tua organizzazione. Le impostazioni condivise, come la vista a conversazioni, restano invariate.",
"button": "Ripristina",
"done": "Impostazioni di accessibilità ripristinate."
},
"read_aloud_voice": {
"label": "Voce della lettura ad alta voce",
"description": "La voce che legge i messaggi. L'elenco mostra le voci che il browser ha per la lingua dell'interfaccia.",
"default": "Predefinita del browser"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "書式付きで表示",
"shown_as_plain_text": "このメッセージはプレーンテキストで表示されています。",
"play_animation": "アニメーションを再生",
"pause_animation": "アニメーションを一時停止"
"pause_animation": "アニメーションを一時停止",
"read_aloud": "読み上げ",
"read_aloud_reading": "読み上げ中",
"read_aloud_paused": "読み上げを一時停止中",
"read_aloud_pause": "一時停止",
"read_aloud_resume": "再開",
"read_aloud_stop": "読み上げを停止"
},
"external_mail": {
"sender_label": "外部",
@@ -3154,6 +3160,11 @@
"description": "このページのすべての設定を、既定値または組織が設定した値に戻します。会話表示などの共有設定は変わりません。",
"button": "リセット",
"done": "アクセシビリティ設定をリセットしました。"
},
"read_aloud_voice": {
"label": "読み上げの音声",
"description": "メッセージを読み上げる音声です。一覧には、ブラウザーが画面の言語用に持っている音声が表示されます。",
"default": "ブラウザーの既定"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "서식 있게 보기",
"shown_as_plain_text": "이 메시지는 일반 텍스트로 표시됩니다.",
"play_animation": "애니메이션 재생",
"pause_animation": "애니메이션 일시 중지"
"pause_animation": "애니메이션 일시 중지",
"read_aloud": "소리 내어 읽기",
"read_aloud_reading": "소리 내어 읽는 중",
"read_aloud_paused": "읽기 일시 중지됨",
"read_aloud_pause": "일시 중지",
"read_aloud_resume": "다시 시작",
"read_aloud_stop": "읽기 중지"
},
"external_mail": {
"sender_label": "외부",
@@ -3154,6 +3160,11 @@
"description": "이 페이지의 모든 설정을 기본값 또는 조직에서 정한 값으로 되돌려요. 대화 보기처럼 공유되는 설정은 그대로 유지돼요.",
"button": "초기화",
"done": "접근성 설정을 초기화했어요."
},
"read_aloud_voice": {
"label": "소리 내어 읽기 음성",
"description": "메시지를 읽어 주는 음성이에요. 목록에는 브라우저에 있는 인터페이스 언어용 음성이 표시돼요.",
"default": "브라우저 기본값"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Rādīt formatētu",
"shown_as_plain_text": "Šis ziņojums tiek rādīts kā vienkāršs teksts.",
"play_animation": "Atskaņot animāciju",
"pause_animation": "Pauzēt animāciju"
"pause_animation": "Pauzēt animāciju",
"read_aloud": "Nolasīt",
"read_aloud_reading": "Notiek nolasīšana",
"read_aloud_paused": "Nolasīšana pauzēta",
"read_aloud_pause": "Pauzēt",
"read_aloud_resume": "Turpināt",
"read_aloud_stop": "Beigt nolasīšanu"
},
"external_mail": {
"sender_label": "Ārējs",
@@ -3154,6 +3160,11 @@
"description": "Atgriež visus šīs lapas iestatījumus noklusējuma vērtībās vai tajās, ko iestatījusi jūsu organizācija. Koplietojami iestatījumi, piemēram, sarunu skats, paliek, kā bijuši.",
"button": "Atiestatīt",
"done": "Pieejamības iestatījumi atiestatīti."
},
"read_aloud_voice": {
"label": "Nolasīšanas balss",
"description": "Balss, kas nolasa ziņas. Sarakstā ir balsis, kas pārlūkam ir saskarnes valodai.",
"default": "Pārlūka noklusējums"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Форматтайгаар харуулах",
"shown_as_plain_text": "Энэ захидлыг энгийн текстээр харуулж байна.",
"play_animation": "Хөдөлгөөнт зургийг тоглуулах",
"pause_animation": "Хөдөлгөөнт зургийг түр зогсоох"
"pause_animation": "Хөдөлгөөнт зургийг түр зогсоох",
"read_aloud": "Чангаар унших",
"read_aloud_reading": "Чангаар уншиж байна",
"read_aloud_paused": "Уншихыг түр зогсоосон",
"read_aloud_pause": "Түр зогсоох",
"read_aloud_resume": "Үргэлжлүүлэх",
"read_aloud_stop": "Уншихыг зогсоох"
},
"external_mail": {
"sender_label": "Гадаад",
@@ -3154,6 +3160,11 @@
"description": "Энэ хуудасны бүх тохиргоог өгөгдмөл утга эсвэл байгууллагын тогтоосон утга руу буцаана. Харилцан ярианы харагдац зэрэг нийтлэг тохиргоо хэвээр үлдэнэ.",
"button": "Дахин тохируулах",
"done": "Хүртээмжийн тохиргоог дахин тохирууллаа."
},
"read_aloud_voice": {
"label": "Чангаар унших хоолой",
"description": "Мессежийг чангаар унших хоолой. Жагсаалтад таны хөтөчид интерфейсийн хэлэнд байгаа хоолойнууд харагдана.",
"default": "Хөтчийн өгөгдмөл"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Vis formatert",
"shown_as_plain_text": "Denne meldingen vises som ren tekst.",
"play_animation": "Spill av animasjon",
"pause_animation": "Sett animasjon på pause"
"pause_animation": "Sett animasjon på pause",
"read_aloud": "Les opp",
"read_aloud_reading": "Leser opp",
"read_aloud_paused": "Opplesning satt på pause",
"read_aloud_pause": "Pause",
"read_aloud_resume": "Fortsett",
"read_aloud_stop": "Stopp opplesning"
},
"external_mail": {
"sender_label": "Ekstern",
@@ -3154,6 +3160,11 @@
"description": "Setter alle innstillinger på denne siden tilbake til standard, eller til det organisasjonen din har satt. Delte innstillinger, som samtalevisning, blir som de er.",
"button": "Tilbakestill",
"done": "Tilgjengelighetsinnstillingene er tilbakestilt."
},
"read_aloud_voice": {
"label": "Opplesningsstemme",
"description": "Stemmen som leser opp meldinger. Listen viser stemmene nettleseren har for språket i grensesnittet.",
"default": "Nettleserens standard"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Opgemaakt weergeven",
"shown_as_plain_text": "Dit bericht wordt als platte tekst weergegeven.",
"play_animation": "Animatie afspelen",
"pause_animation": "Animatie pauzeren"
"pause_animation": "Animatie pauzeren",
"read_aloud": "Voorlezen",
"read_aloud_reading": "Wordt voorgelezen",
"read_aloud_paused": "Voorlezen gepauzeerd",
"read_aloud_pause": "Pauzeren",
"read_aloud_resume": "Hervatten",
"read_aloud_stop": "Voorlezen stoppen"
},
"external_mail": {
"sender_label": "Extern",
@@ -3154,6 +3160,11 @@
"description": "Zet elke instelling op deze pagina terug naar de standaardwaarde, of naar de waarde die je organisatie heeft ingesteld. Gedeelde instellingen zoals de gespreksweergave blijven zoals ze zijn.",
"button": "Herstellen",
"done": "Toegankelijkheidsinstellingen hersteld."
},
"read_aloud_voice": {
"label": "Voorleesstem",
"description": "De stem die berichten voorleest. De lijst toont de stemmen die je browser heeft voor de taal van de interface.",
"default": "Standaard van de browser"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Pokaż z formatowaniem",
"shown_as_plain_text": "Ta wiadomość jest wyświetlana jako zwykły tekst.",
"play_animation": "Odtwórz animację",
"pause_animation": "Wstrzymaj animację"
"pause_animation": "Wstrzymaj animację",
"read_aloud": "Czytaj na głos",
"read_aloud_reading": "Czytanie na głos",
"read_aloud_paused": "Czytanie wstrzymane",
"read_aloud_pause": "Wstrzymaj",
"read_aloud_resume": "Wznów",
"read_aloud_stop": "Zakończ czytanie"
},
"external_mail": {
"sender_label": "Zewnętrzny",
@@ -3154,6 +3160,11 @@
"description": "Przywraca wszystkie ustawienia na tej stronie do domyślnych lub do wartości ustawionych przez Twoją organizację. Ustawienia wspólne, takie jak widok konwersacji, pozostają bez zmian.",
"button": "Zresetuj",
"done": "Ustawienia dostępności zresetowano."
},
"read_aloud_voice": {
"label": "Głos czytania na głos",
"description": "Głos, który czyta wiadomości. Lista pokazuje głosy, które przeglądarka ma dla języka interfejsu.",
"default": "Domyślny przeglądarki"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Mostrar formatado",
"shown_as_plain_text": "Esta mensagem é mostrada como texto simples.",
"play_animation": "Reproduzir animação",
"pause_animation": "Pausar animação"
"pause_animation": "Pausar animação",
"read_aloud": "Ler em voz alta",
"read_aloud_reading": "A ler em voz alta",
"read_aloud_paused": "Leitura em pausa",
"read_aloud_pause": "Pausar",
"read_aloud_resume": "Retomar",
"read_aloud_stop": "Parar a leitura"
},
"external_mail": {
"sender_label": "Externo",
@@ -3154,6 +3160,11 @@
"description": "Volta todas as configurações desta página ao padrão ou ao valor definido pela sua organização. Configurações compartilhadas, como a visualização por conversa, não mudam.",
"button": "Redefinir",
"done": "Configurações de acessibilidade redefinidas."
},
"read_aloud_voice": {
"label": "Voz da leitura em voz alta",
"description": "A voz que lê as mensagens em voz alta. A lista mostra as vozes que o navegador tem para o idioma da interface.",
"default": "Padrão do navegador"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Afișați formatat",
"shown_as_plain_text": "Acest mesaj este afișat ca text simplu.",
"play_animation": "Redați animația",
"pause_animation": "Întrerupeți animația"
"pause_animation": "Întrerupeți animația",
"read_aloud": "Citire cu voce tare",
"read_aloud_reading": "Se citește cu voce tare",
"read_aloud_paused": "Citire întreruptă",
"read_aloud_pause": "Pauză",
"read_aloud_resume": "Reluați",
"read_aloud_stop": "Opriți citirea"
},
"external_mail": {
"sender_label": "Extern",
@@ -3154,6 +3160,11 @@
"description": "Readuce toate setările de pe această pagină la valorile implicite sau la cele stabilite de organizația dvs. Setările comune, precum vizualizarea pe conversații, rămân neschimbate.",
"button": "Resetează",
"done": "Setările de accesibilitate au fost resetate."
},
"read_aloud_voice": {
"label": "Vocea pentru citire cu voce tare",
"description": "Vocea care citește mesajele. Lista arată vocile pe care browserul le are pentru limba interfeței.",
"default": "Implicită a browserului"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Показать с форматированием",
"shown_as_plain_text": "Это письмо показано как обычный текст.",
"play_animation": "Воспроизвести анимацию",
"pause_animation": "Приостановить анимацию"
"pause_animation": "Приостановить анимацию",
"read_aloud": "Прочитать вслух",
"read_aloud_reading": "Идёт чтение вслух",
"read_aloud_paused": "Чтение приостановлено",
"read_aloud_pause": "Пауза",
"read_aloud_resume": "Продолжить",
"read_aloud_stop": "Остановить чтение"
},
"external_mail": {
"sender_label": "Внешнее",
@@ -3154,6 +3160,11 @@
"description": "Возвращает все настройки на этой странице к значениям по умолчанию или к тем, что задала ваша организация. Общие настройки, например режим разговоров, не меняются.",
"button": "Сбросить",
"done": "Настройки специальных возможностей сброшены."
},
"read_aloud_voice": {
"label": "Голос для чтения вслух",
"description": "Голос, которым читаются письма. В списке голоса вашего браузера для языка интерфейса.",
"default": "По умолчанию в браузере"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Zobraziť formátovane",
"shown_as_plain_text": "Táto správa sa zobrazuje ako obyčajný text.",
"play_animation": "Prehrať animáciu",
"pause_animation": "Pozastaviť animáciu"
"pause_animation": "Pozastaviť animáciu",
"read_aloud": "Čítať nahlas",
"read_aloud_reading": "Číta sa nahlas",
"read_aloud_paused": "Čítanie pozastavené",
"read_aloud_pause": "Pozastaviť",
"read_aloud_resume": "Pokračovať",
"read_aloud_stop": "Zastaviť čítanie"
},
"external_mail": {
"sender_label": "Externé",
@@ -3154,6 +3160,11 @@
"description": "Vráti všetky nastavenia na tejto stránke na predvolené hodnoty alebo na hodnoty, ktoré určila vaša organizácia. Spoločné nastavenia, napríklad zobrazenie konverzácií, zostanú nezmenené.",
"button": "Obnoviť",
"done": "Nastavenia prístupnosti boli obnovené."
},
"read_aloud_voice": {
"label": "Hlas čítania nahlas",
"description": "Hlas, ktorý číta správy nahlas. Zoznam ukazuje hlasy, ktoré má prehliadač pre jazyk rozhrania.",
"default": "Predvolený v prehliadači"
}
}
},
+12 -1
View File
@@ -1706,7 +1706,13 @@
"show_formatted": "แสดงแบบมีรูปแบบ",
"shown_as_plain_text": "ข้อความนี้แสดงเป็นข้อความธรรมดา",
"play_animation": "เล่นภาพเคลื่อนไหว",
"pause_animation": "หยุดภาพเคลื่อนไหวชั่วคราว"
"pause_animation": "หยุดภาพเคลื่อนไหวชั่วคราว",
"read_aloud": "อ่านออกเสียง",
"read_aloud_reading": "กำลังอ่านออกเสียง",
"read_aloud_paused": "หยุดการอ่านชั่วคราว",
"read_aloud_pause": "หยุดชั่วคราว",
"read_aloud_resume": "อ่านต่อ",
"read_aloud_stop": "หยุดอ่าน"
},
"external_mail": {
"sender_label": "ภายนอก",
@@ -5567,6 +5573,11 @@
"description": "คืนทุกการตั้งค่าในหน้านี้เป็นค่าเริ่มต้น หรือค่าที่องค์กรของคุณกำหนด การตั้งค่าที่ใช้ร่วมกัน เช่น มุมมองการสนทนา จะไม่เปลี่ยน",
"button": "รีเซ็ต",
"done": "รีเซ็ตการตั้งค่าการเข้าถึงแล้ว"
},
"read_aloud_voice": {
"label": "เสียงอ่านออกเสียง",
"description": "เสียงที่ใช้อ่านข้อความ รายการแสดงเสียงที่เบราว์เซอร์ของคุณมีสำหรับภาษาของหน้าจอ",
"default": "ค่าเริ่มต้นของเบราว์เซอร์"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Biçimli göster",
"shown_as_plain_text": "Bu ileti düz metin olarak gösteriliyor.",
"play_animation": "Animasyonu oynat",
"pause_animation": "Animasyonu duraklat"
"pause_animation": "Animasyonu duraklat",
"read_aloud": "Sesli oku",
"read_aloud_reading": "Sesli okunuyor",
"read_aloud_paused": "Okuma duraklatıldı",
"read_aloud_pause": "Duraklat",
"read_aloud_resume": "Devam et",
"read_aloud_stop": "Okumayı durdur"
},
"external_mail": {
"sender_label": "Harici",
@@ -3154,6 +3160,11 @@
"description": "Bu sayfadaki tüm ayarları varsayılana veya kuruluşunuzun belirlediği değere döndürür. Konuşma görünümü gibi ortak ayarlar olduğu gibi kalır.",
"button": "Sıfırla",
"done": "Erişilebilirlik ayarları sıfırlandı."
},
"read_aloud_voice": {
"label": "Sesli okuma sesi",
"description": "İletileri sesli okuyan ses. Liste, tarayıcınızda arayüz dili için bulunan sesleri gösterir.",
"default": "Tarayıcı varsayılanı"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "Показати з форматуванням",
"shown_as_plain_text": "Цей лист показано як звичайний текст.",
"play_animation": "Відтворити анімацію",
"pause_animation": "Призупинити анімацію"
"pause_animation": "Призупинити анімацію",
"read_aloud": "Прочитати вголос",
"read_aloud_reading": "Триває читання вголос",
"read_aloud_paused": "Читання призупинено",
"read_aloud_pause": "Пауза",
"read_aloud_resume": "Продовжити",
"read_aloud_stop": "Зупинити читання"
},
"external_mail": {
"sender_label": "Зовнішнє",
@@ -3154,6 +3160,11 @@
"description": "Повертає всі налаштування на цій сторінці до типових значень або до тих, що задала ваша організація. Спільні налаштування, як-от режим розмов, не змінюються.",
"button": "Скинути",
"done": "Налаштування доступності скинуто."
},
"read_aloud_voice": {
"label": "Голос для читання вголос",
"description": "Голос, яким читаються листи. У списку голоси вашого браузера для мови інтерфейсу.",
"default": "Типовий у браузері"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "以格式顯示",
"shown_as_plain_text": "此郵件以純文字顯示。",
"play_animation": "播放動畫",
"pause_animation": "暫停動畫"
"pause_animation": "暫停動畫",
"read_aloud": "朗讀",
"read_aloud_reading": "正在朗讀",
"read_aloud_paused": "朗讀已暫停",
"read_aloud_pause": "暫停",
"read_aloud_resume": "繼續",
"read_aloud_stop": "停止朗讀"
},
"external_mail": {
"sender_label": "外部",
@@ -3154,6 +3160,11 @@
"description": "將此頁面的所有設定還原為預設值,或還原為您所屬組織設定的值。對話檢視等共用設定維持不變。",
"button": "重設",
"done": "無障礙設定已重設。"
},
"read_aloud_voice": {
"label": "朗讀語音",
"description": "朗讀郵件所用的語音。清單顯示瀏覽器為介面語言提供的語音。",
"default": "瀏覽器預設"
}
}
},
+12 -1
View File
@@ -776,7 +776,13 @@
"show_formatted": "以格式显示",
"shown_as_plain_text": "此邮件以纯文本显示。",
"play_animation": "播放动画",
"pause_animation": "暂停动画"
"pause_animation": "暂停动画",
"read_aloud": "朗读",
"read_aloud_reading": "正在朗读",
"read_aloud_paused": "朗读已暂停",
"read_aloud_pause": "暂停",
"read_aloud_resume": "继续",
"read_aloud_stop": "停止朗读"
},
"external_mail": {
"sender_label": "外部",
@@ -3154,6 +3160,11 @@
"description": "把此页面的所有设置恢复为默认值,或恢复为您所在组织设定的值。会话视图等共享设置保持不变。",
"button": "重置",
"done": "无障碍设置已重置。"
},
"read_aloud_voice": {
"label": "朗读语音",
"description": "朗读邮件所用的语音。列表显示浏览器为界面语言提供的语音。",
"default": "浏览器默认"
}
}
},