diff --git a/components/email/email-viewer.tsx b/components/email/email-viewer.tsx index b1500691b..f33bf24a5 100644 --- a/components/email/email-viewer.tsx +++ b/components/email/email-viewer.tsx @@ -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(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}> + {!isBodyLoading && plainView && hasHtmlBody && (
{t('shown_as_plain_text')} diff --git a/components/email/read-aloud-bar.tsx b/components/email/read-aloud-bar.tsx new file mode 100644 index 000000000..081e01bde --- /dev/null +++ b/components/email/read-aloud-bar.tsx @@ -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 ( +
+
+ ); +} diff --git a/components/icons/index.ts b/components/icons/index.ts index 4efb9bc5b..8e01d4f4b 100644 --- a/components/icons/index.ts +++ b/components/icons/index.ts @@ -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, diff --git a/components/settings/__tests__/accessibility-settings.test.tsx b/components/settings/__tests__/accessibility-settings.test.tsx index 251723983..d4907cbf3 100644 --- a/components/settings/__tests__/accessibility-settings.test.tsx +++ b/components/settings/__tests__/accessibility-settings.test.tsx @@ -10,6 +10,7 @@ import { presetChanges } from '@/lib/accessibility-presets'; vi.mock('next-intl', () => ({ useTranslations: (ns?: string) => (key: string, values?: Record) => `${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(); + 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(); + } + }); +}); diff --git a/components/settings/accessibility-settings.tsx b/components/settings/accessibility-settings.tsx index 2266fe2cf..689af64c9 100644 --- a/components/settings/accessibility-settings.tsx +++ b/components/settings/accessibility-settings.tsx @@ -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}×` }))} /> + + 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('

First sentence. Second one.

'); + 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('

One. Two.

'); + 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('

One. Two.

'); + 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'); + }); +}); diff --git a/hooks/use-read-aloud.ts b/hooks/use-read-aloud.ts new file mode 100644 index 000000000..1dda4ca5f --- /dev/null +++ b/hooks/use-read-aloud.ts @@ -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('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(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(() => 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; +} diff --git a/lib/__tests__/read-aloud.test.ts b/lib/__tests__/read-aloud.test.ts new file mode 100644 index 000000000..fafa75a34 --- /dev/null +++ b/lib/__tests__/read-aloud.test.ts @@ -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('

Hello Ann.

See the chart below.

hidden
'); + expect(collectSpeechText(el).text).toBe('Hello Ann.\nSee the chart below.\n'); + }); + + it('reads only the summary of a folded quote', () => { + const el = root('

Reply

Show quoted text

Old mail

'); + 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('

Hello dear Ann

'); + 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('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); + }); +}); diff --git a/lib/read-aloud.ts b/lib/read-aloud.ts new file mode 100644 index 000000000..1c00e94de --- /dev/null +++ b/lib/read-aloud.ts @@ -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
(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 }; + 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)); +} diff --git a/locales/ar/common.json b/locales/ar/common.json index e3462c888..96e592cf1 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -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": "الافتراضي في المتصفح" } } }, diff --git a/locales/ca/common.json b/locales/ca/common.json index 24e2a9e7f..a3121b834 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -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" } } }, diff --git a/locales/cs/common.json b/locales/cs/common.json index 44cefda80..ed0380503 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -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" } } }, diff --git a/locales/da/common.json b/locales/da/common.json index 58e8f5855..e4ac0f290 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -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" } } }, diff --git a/locales/de/common.json b/locales/de/common.json index 2c7f01b4c..2e4fc1cc1 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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" } } }, diff --git a/locales/en/common.json b/locales/en/common.json index 19abfbce1..3f0dd76f0 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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" } } }, diff --git a/locales/es/common.json b/locales/es/common.json index a3c91c828..d718282b7 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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" } } }, diff --git a/locales/et/common.json b/locales/et/common.json index 47f0e2cbd..5bc8385fa 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -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" } } }, diff --git a/locales/fa/common.json b/locales/fa/common.json index 22b4bb04b..c634f19f0 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -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": "پیش‌فرض مرورگر" } } }, diff --git a/locales/fr/common.json b/locales/fr/common.json index a830e2033..dc57681ac 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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" } } }, diff --git a/locales/he/common.json b/locales/he/common.json index eb7bf7511..4b1eaea93 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -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": "ברירת המחדל של הדפדפן" } } }, diff --git a/locales/hu/common.json b/locales/hu/common.json index df60dfc91..d49f466c1 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -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" } } }, diff --git a/locales/it/common.json b/locales/it/common.json index 6bc565645..9883d13b8 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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" } } }, diff --git a/locales/ja/common.json b/locales/ja/common.json index e6ded79bd..b951895b2 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "ブラウザーの既定" } } }, diff --git a/locales/ko/common.json b/locales/ko/common.json index 28af1e9f7..583212e5b 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -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": "브라우저 기본값" } } }, diff --git a/locales/lv/common.json b/locales/lv/common.json index 9a4b0f79b..65b4a97ba 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -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" } } }, diff --git a/locales/mn/common.json b/locales/mn/common.json index 0f9f4b714..04102f4a7 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -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": "Хөтчийн өгөгдмөл" } } }, diff --git a/locales/nb/common.json b/locales/nb/common.json index 9f0a4c431..dd51661cd 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -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" } } }, diff --git a/locales/nl/common.json b/locales/nl/common.json index 9c5966c06..1b0362eae 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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" } } }, diff --git a/locales/pl/common.json b/locales/pl/common.json index 832368ed3..b7d93be44 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -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" } } }, diff --git a/locales/pt/common.json b/locales/pt/common.json index fddbf1e28..012059a38 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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" } } }, diff --git a/locales/ro/common.json b/locales/ro/common.json index 7f4c715ef..796465908 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -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" } } }, diff --git a/locales/ru/common.json b/locales/ru/common.json index 01ee6d4f8..b2dd9928b 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -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": "По умолчанию в браузере" } } }, diff --git a/locales/sk/common.json b/locales/sk/common.json index 471f893fc..af3fe0da5 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -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" } } }, diff --git a/locales/th/common.json b/locales/th/common.json index 4a69d689d..11c46f06e 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -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": "ค่าเริ่มต้นของเบราว์เซอร์" } } }, diff --git a/locales/tr/common.json b/locales/tr/common.json index d98643637..6f64a5cae 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -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ı" } } }, diff --git a/locales/uk/common.json b/locales/uk/common.json index bdd249b15..62581f3dc 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -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": "Типовий у браузері" } } }, diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 453c404e7..e8a132779 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -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": "瀏覽器預設" } } }, diff --git a/locales/zh/common.json b/locales/zh/common.json index 146a286db..cb52b737f 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -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": "浏览器默认" } } },