diff --git a/README.md b/README.md index 6ded1a6..1e8e5c6 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ # 📄🔊 OpenReader -OpenReader is an open source, self-host-friendly text-to-speech document reader built with Next.js for **EPUB, PDF, TXT, MD, and DOCX** with synchronized read-along playback. +OpenReader is an open-source, self-host-friendly text-to-speech document reader built with Next.js for **EPUB, PDF, TXT, MD, and DOCX** with multilingual, synchronized read-along playback. > Previously named **OpenReader-WebUI**. @@ -22,6 +22,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader - ⏱️ **Word-by-word highlighting** via ONNX Whisper alignment through the compute worker control plane (NATS JetStream-backed). - ⚡ **Segment-based read-along** for EPUB, PDF, TXT, MD, and DOCX — sentence-aware TTS with cached audio segments, background preloading, and resumable playback. - 🎯 **Multi-provider TTS** — self-hosted OpenAI-compatible servers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI) or cloud APIs (OpenAI, Replicate, DeepInfra). +- 🌐 **Multilingual support** — choose a document language for language-aware narration and highlighting. Available languages depend on the configured TTS provider and voice. - 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing. - 🗂️ **Flexible backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync. - 🐳 **Self-host friendly** — Docker (amd64/arm64), built-in auth/session support, and automatic startup migrations. diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md index 6a43df7..8f60592 100644 --- a/docs-site/docs/introduction.md +++ b/docs-site/docs/introduction.md @@ -4,7 +4,7 @@ title: Introduction slug: / --- -OpenReader is an open source text-to-speech document reader built with Next.js. It provides a read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**. +OpenReader is an open-source text-to-speech document reader built with Next.js. It provides a multilingual read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**. > Previously named **OpenReader-WebUI**. @@ -21,6 +21,9 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c - 🎯 **Multi-Provider TTS Support** - Self-hosted: [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI) (multi-voice combinations), [**KittenTTS-FastAPI**](https://github.com/richardr1126/KittenTTS-FastAPI), [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI), or any custom OpenAI-compatible endpoint - Cloud: [**OpenAI**](https://platform.openai.com/docs/pricing#transcription-and-speech) (`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`), [**Replicate**](https://replicate.com/explore) (built-in catalog + any model ID), [**DeepInfra**](https://deepinfra.com/models/text-to-speech) (Kokoro-82M and others) +- 🌐 **Multilingual Support** + - Choose a document language for language-aware narration and highlighting + - Available languages depend on the configured provider, model, and voice - 🎧 **Audiobook Export** in `m4b`/`mp3` with resumable chapter generation - 🗂️ **Flexible Backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync - 🔐 **Auth and User Isolation** — auth is required in v4+, with optional anonymous auth sessions for guest flows diff --git a/src/app/(app)/epub/[id]/page.tsx b/src/app/(app)/epub/[id]/page.tsx index b08d4d4..38472f8 100644 --- a/src/app/(app)/epub/[id]/page.tsx +++ b/src/app/(app)/epub/[id]/page.tsx @@ -19,6 +19,7 @@ import { RateLimitBanner } from '@/components/auth/RateLimitBanner'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; +import { useDocumentLanguage } from '@/hooks/useDocumentLanguage'; import { ButtonLink } from '@/components/ui'; import { useEpubDocument } from './useEpubDocument'; @@ -36,8 +37,10 @@ export default function EPUBPage() { createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter, bookRef, + metadataLanguage, } = epubState; - const { stop } = useTTS(); + const { stop, setDocumentLanguage } = useTTS(); + const { language, updateLanguage } = useDocumentLanguage(routeDocumentId); const { isAtLimit } = useAuthRateLimit(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -106,6 +109,10 @@ export default function EPUBPage() { useUnmountCleanupRef(clearCurrDoc); + useEffect(() => { + setDocumentLanguage(language === 'auto' ? metadataLanguage ?? 'auto' : language); + }, [language, metadataLanguage, setDocumentLanguage]); + // Compute available height = viewport - (header height + tts bar height) useEffect(() => { const compute = () => { @@ -249,6 +256,11 @@ export default function EPUBPage() { epub isOpen={activeSidebar === 'settings'} setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))} + language={language} + detectedLanguage={metadataLanguage} + onLanguageChange={(nextLanguage) => { + void updateLanguage(nextLanguage); + }} /> Promise; clearCurrDoc: () => void; @@ -93,7 +95,16 @@ export interface EpubDocumentState { * Route-local EPUB reader hook. */ export function useEpubDocument(documentId?: string): EpubDocumentState { - const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS(); + const { + setText: setTTSText, + currDocPage, + currDocPages, + setCurrDocPages, + stop, + skipToLocation, + setIsEPUB, + resolvedLanguage, + } = useTTS(); // Configuration context to get TTS settings const { apiKey, @@ -107,6 +118,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { const [currDocData, setCurrDocData] = useState(); const [currDocName, setCurrDocName] = useState(); const [currDocText, setCurrDocText] = useState(); + const [metadataLanguage, setMetadataLanguage] = useState(null); const [isPlaybackReady, setIsPlaybackReady] = useState(false); // Mirror state into a ref so resolveEpubLocator (registered once with // TTSContext via a stable callback) can always read the latest page text @@ -145,6 +157,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { currentWordHighlightCfiRef: currentWordHighlightCfi, renderedTextMapsRef, wordHighlightMapCacheRef, + language: resolvedLanguage, }); useEffect(() => () => { @@ -162,6 +175,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { setCurrDocData(undefined); setCurrDocName(undefined); setCurrDocText(undefined); + setMetadataLanguage(null); setIsPlaybackReady(false); setCurrDocPages(undefined); isEPUBSetOnce.current = false; @@ -183,6 +197,9 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { const setCurrentDocument = useCallback(async (id: string): Promise => { try { setIsPlaybackReady(false); + setMetadataLanguage(null); + bookRef.current = null; + renditionRef.current = undefined; const meta = await getDocumentMetadata(id); if (!meta) { clearCurrDoc(); @@ -370,8 +387,19 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { }); const setRendition = useCallback((rendition: Rendition) => { - bookRef.current = rendition.book; + const book = rendition.book; + bookRef.current = book; renditionRef.current = rendition; + void book.loaded.metadata + .then((metadata) => { + if (bookRef.current !== book) return; + setMetadataLanguage(normalizeOptionalLanguageTag(metadata.language)); + }) + .catch((error) => { + if (bookRef.current !== book) return; + setMetadataLanguage(null); + console.warn('Failed to read EPUB language metadata:', error); + }); }, []); const handleLocationChanged = useEPUBLocationController({ @@ -396,6 +424,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { currDocPages, currDocPage, currDocText, + metadataLanguage, isPlaybackReady, clearCurrDoc, extractPageText, @@ -422,6 +451,7 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { currDocPages, currDocPage, currDocText, + metadataLanguage, isPlaybackReady, clearCurrDoc, extractPageText, diff --git a/src/app/(app)/html/[id]/page.tsx b/src/app/(app)/html/[id]/page.tsx index 09a0b45..ef0ba89 100644 --- a/src/app/(app)/html/[id]/page.tsx +++ b/src/app/(app)/html/[id]/page.tsx @@ -17,6 +17,7 @@ import { AudiobookExportModal } from '@/components/AudiobookExportModal'; import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useUnmountCleanupRef } from '@/hooks/useUnmountCleanupRef'; +import { useDocumentLanguage } from '@/hooks/useDocumentLanguage'; import { ButtonLink } from '@/components/ui'; import type { TTSAudiobookChapter } from '@/types/tts'; import type { AudiobookGenerationSettings } from '@/types/client'; @@ -26,6 +27,7 @@ export default function HTMLPage() { const canExportAudiobook = useFeatureFlag('enableAudiobookExport'); const { id } = useParams(); const router = useRouter(); + const routeDocumentId = typeof id === 'string' ? id : undefined; const htmlState = useHtmlDocument(); const { setCurrentDocument, @@ -38,7 +40,8 @@ export default function HTMLPage() { createFullAudioBook, regenerateChapter, } = htmlState; - const { stop } = useTTS(); + const { stop, setDocumentLanguage } = useTTS(); + const { language, updateLanguage } = useDocumentLanguage(routeDocumentId); const { isAtLimit } = useAuthRateLimit(); const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(true); @@ -106,6 +109,10 @@ export default function HTMLPage() { useUnmountCleanupRef(clearCurrDoc); + useEffect(() => { + setDocumentLanguage(language); + }, [language, setDocumentLanguage]); + // Compute available height = viewport - (header height + tts bar height) useEffect(() => { const compute = () => { @@ -240,6 +247,10 @@ export default function HTMLPage() { html isOpen={activeSidebar === 'settings'} setIsOpen={(isOpen) => setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))} + language={language} + onLanguageChange={(nextLanguage) => { + void updateLanguage(nextLanguage); + }} /> setActiveSidebar((prev) => isOpen ? 'settings' : (prev === 'settings' ? null : prev))} + language={documentSettings.language ?? 'auto'} + onLanguageChange={(language) => { + void updateDocumentSettings({ + ...documentSettings, + schemaVersion: 1, + language, + }); + }} pdf={{ parseStatus, parsedOverlayEnabled, diff --git a/src/app/(app)/pdf/[id]/usePdfDocument.ts b/src/app/(app)/pdf/[id]/usePdfDocument.ts index 8ec76c4..86cebf9 100644 --- a/src/app/(app)/pdf/[id]/usePdfDocument.ts +++ b/src/app/(app)/pdf/[id]/usePdfDocument.ts @@ -91,6 +91,7 @@ export interface PdfDocumentState { parsedDocument?: ParsedPdfDocument | null; locator?: TTSSegmentLocator | null; useBlockGeometryOnly?: boolean; + language?: string; }, ) => void; clearHighlights: () => void; @@ -134,6 +135,7 @@ export function usePdfDocument(): PdfDocumentState { currDocPages, setCurrDocPages, setIsEPUB, + setDocumentLanguage, registerVisualPageChangeHandler, } = useTTS(); const { @@ -156,6 +158,10 @@ export function usePdfDocument(): PdfDocumentState { const [parseProgress, setParseProgress] = useState(null); const [, setActiveParseOpId] = useState(null); const [documentSettings, setDocumentSettings] = useState(DEFAULT_DOCUMENT_SETTINGS); + useEffect(() => { + setDocumentLanguage(documentSettings.language ?? 'auto'); + lastPreparedPlaybackPageRef.current = null; + }, [documentSettings.language, setDocumentLanguage]); const [parsedOverlayEnabled, setParsedOverlayEnabled] = useState(false); const [isAudioCombining] = useState(false); const audiobookAdapter = useMemo(() => createPdfAudiobookSourceAdapter({ diff --git a/src/app/(app)/signin/page.tsx b/src/app/(app)/signin/page.tsx index b5fb29b..853f246 100644 --- a/src/app/(app)/signin/page.tsx +++ b/src/app/(app)/signin/page.tsx @@ -9,7 +9,7 @@ import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { showPrivacyModal } from '@/components/PrivacyModal'; import { GithubIcon } from '@/components/icons/Icons'; import { LoadingSpinner } from '@/components/Spinner'; -import { Button, Field, Input, Surface } from '@/components/ui'; +import { Button, Checkbox, Field, InlineButton, Input, Surface } from '@/components/ui'; function SessionExpiredLoader({ setSessionExpired }: { setSessionExpired: (v: boolean) => void }) { const searchParams = useSearchParams(); @@ -169,11 +169,9 @@ function SignInContent() { {/* Remember Me */} @@ -238,12 +236,9 @@ function SignInContent() { )}

By signing in, you agree to our{' '} - +

diff --git a/src/app/(app)/signup/page.tsx b/src/app/(app)/signup/page.tsx index f2b1ad8..f5833cc 100644 --- a/src/app/(app)/signup/page.tsx +++ b/src/app/(app)/signup/page.tsx @@ -8,7 +8,7 @@ import { useAuthConfig, useAuthRateLimit } from '@/contexts/AuthRateLimitContext import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { showPrivacyModal } from '@/components/PrivacyModal'; import { LoadingSpinner } from '@/components/Spinner'; -import { Button, Field, IconButton, Input, Surface } from '@/components/ui'; +import { Button, Field, IconButton, InlineButton, Input, Surface } from '@/components/ui'; import toast from 'react-hot-toast'; export default function SignUpPage() { @@ -241,12 +241,9 @@ export default function SignUpPage() {

By creating an account, you agree to our{' '} - +

diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index a297501..89499fa 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -6,9 +6,9 @@ import { ButtonAnchor, ButtonLink } from '@/components/ui'; export const metadata: Metadata = { title: 'Open Source Read-Along Workspace', description: - 'OpenReader converts EPUB, PDF, TXT, MD, and DOCX files into synchronized read-along audio with multi-provider text-to-speech support.', + 'OpenReader converts EPUB, PDF, TXT, MD, and DOCX files into multilingual, synchronized read-along audio with multi-provider text-to-speech support.', keywords: - 'OpenReader, document reader, PDF read aloud, EPUB read aloud, text to speech, OpenAI compatible TTS, self-hosted reader', + 'OpenReader, document reader, multilingual text to speech, PDF read aloud, EPUB read aloud, OpenAI compatible TTS, self-hosted reader', alternates: { canonical: '/', }, @@ -19,7 +19,7 @@ export const metadata: Metadata = { siteName: 'OpenReader', title: 'OpenReader | Read documents with synchronized audio', description: - 'Upload documents and turn them into a synchronized listening experience with word-level highlighting and audiobook export.', + 'Upload documents and turn them into a multilingual, synchronized listening experience with word-level highlighting and audiobook export.', images: [ { url: '/web-app-manifest-512x512.png', @@ -91,7 +91,7 @@ export default async function LandingPage() {

OpenReader turns EPUB, PDF, TXT, Markdown, and DOCX into a synchronized read-along surface, reading your original file in a - native viewer with genuine text-to-speech, word-level + native viewer with multilingual text-to-speech, language-aware highlighting, and audiobook export. It’s open source, and entirely yours to self-host.

@@ -126,7 +126,7 @@ export default async function LandingPage() { wizard-of-oz.epub - Kokoro · af_sky + English · Kokoro · af_sky
@@ -203,7 +203,8 @@ export default async function LandingPage() {

Choose a provider and model: hosted OpenAI, Replicate, or DeepInfra, or your own self-hosted Kokoro, KittenTTS, or Orpheus - server. Set the speed to your pace. + server. Set the document language, choose a compatible voice, + and adjust the speed to your pace.

  • @@ -251,11 +252,11 @@ export default async function LandingPage() {
    - Voices -

    Multi-provider TTS

    + Languages +

    Multilingual support

    - Mix cloud APIs with OpenAI-compatible local servers. Bring your - own keys and endpoints, with no lock-in to a single vendor. + Choose a document language for language-aware narration, + highlighting, and compatible voice selection.

    diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 70e78f7..e63d045 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -432,7 +432,8 @@ export async function POST(request: NextRequest) { normalizedExistingSettings.nativeSpeed !== normalizedIncomingSettings.nativeSpeed || normalizedExistingSettings.postSpeed !== normalizedIncomingSettings.postSpeed || normalizedExistingSettings.format !== normalizedIncomingSettings.format || - (normalizedExistingSettings.ttsInstructions || '') !== (normalizedIncomingSettings.ttsInstructions || ''); + (normalizedExistingSettings.ttsInstructions || '') !== (normalizedIncomingSettings.ttsInstructions || '') || + (normalizedExistingSettings.language || '') !== (normalizedIncomingSettings.language || ''); if (mismatch) { return NextResponse.json({ error: 'Audiobook settings mismatch', settings: normalizedExistingSettings }, { status: 409 }); } @@ -596,6 +597,7 @@ export async function POST(request: NextRequest) { format: 'mp3', model, instructions, + language: mergedSettings?.language, provider, apiKey: openApiKey, baseUrl: openApiBaseUrl, diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index fdd6b2d..f089231 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -33,6 +33,7 @@ import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tt import { userWhisperAlignJob } from '@/lib/server/jobs/user-whisper-align-job'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { resolveTtsModelForProvider } from '@/lib/shared/tts-provider-policy'; +import { normalizeLanguageTag } from '@/lib/shared/language'; import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls'; import { createRequestLogger, errorToLog } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; @@ -62,6 +63,8 @@ function parseSettings(value: unknown): TTSSegmentSettings | null { if (typeof rec.voice !== 'string') return null; if (!Number.isFinite(Number(rec.nativeSpeed))) return null; if (rec.ttsInstructions !== undefined && typeof rec.ttsInstructions !== 'string') return null; + if (rec.language !== undefined && typeof rec.language !== 'string') return null; + if (typeof rec.language === 'string' && rec.language.length > 64) return null; return { providerRef: rec.providerRef, @@ -70,6 +73,7 @@ function parseSettings(value: unknown): TTSSegmentSettings | null { voice: rec.voice, nativeSpeed: Number(rec.nativeSpeed), ...(typeof rec.ttsInstructions === 'string' ? { ttsInstructions: rec.ttsInstructions } : {}), + ...(typeof rec.language === 'string' ? { language: normalizeLanguageTag(rec.language) } : {}), }; } @@ -406,6 +410,7 @@ export async function POST(request: NextRequest) { alignment = await userWhisperAlignJob({ audioObjectKey: existing.audioKey, text: segment.text, + lang: effectiveSettings.language, sentenceIndex: segment.original.segmentIndex, }); stageTimings.selfHealAlignMs = Date.now() - alignStartedAt; @@ -637,6 +642,7 @@ export async function POST(request: NextRequest) { format: 'mp3', model: effectiveSettings.ttsModel, instructions: effectiveSettings.ttsInstructions, + language: effectiveSettings.language, provider: requestCreds.provider, apiKey: requestCreds.apiKey || 'none', baseUrl: requestCreds.baseUrl, @@ -674,6 +680,7 @@ export async function POST(request: NextRequest) { alignment = await userWhisperAlignJob({ audioObjectKey: audioKey, text: segment.text, + lang: effectiveSettings.language, sentenceIndex: segment.original.segmentIndex, }); stageTimings.whisperAlignMs = Date.now() - alignStartedAt; diff --git a/src/app/api/tts/segments/manifest/route.ts b/src/app/api/tts/segments/manifest/route.ts index c78e602..f33155d 100644 --- a/src/app/api/tts/segments/manifest/route.ts +++ b/src/app/api/tts/segments/manifest/route.ts @@ -18,6 +18,7 @@ import type { TTSSegmentsManifestResponse, } from '@/types/client'; import { isTtsProviderType } from '@/lib/shared/tts-provider-catalog'; +import { normalizeLanguageTag } from '@/lib/shared/language'; import { resolveEffectiveProviderType } from '@/lib/shared/tts-provider-policy'; import { resolveSegmentAudioUrls } from '@/lib/server/tts/segment-audio-urls'; import { createRequestLogger } from '@/lib/server/logger'; @@ -71,9 +72,10 @@ function parseSettingsValue(value: unknown): TTSSegmentSettings | null { const nativeSpeed = Number.isFinite(Number(speedSource)) ? Number(speedSource) : 1; const instructionsSource = rec.ttsInstructions ?? rec.instructions; const ttsInstructions = typeof instructionsSource === 'string' ? instructionsSource : ''; + const language = typeof rec.language === 'string' ? normalizeLanguageTag(rec.language, 'en') : 'en'; if (!providerRef || !providerType || !ttsModel || !voice) return null; - return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions }; + return { providerRef, providerType, ttsModel, voice, nativeSpeed, ttsInstructions, language }; } function locatorFromProjection(row: ManifestGroupRow): TTSSegmentLocator | null { diff --git a/src/components/AudiobookExportModal.tsx b/src/components/AudiobookExportModal.tsx index 0bd98b5..a50a928 100644 --- a/src/components/AudiobookExportModal.tsx +++ b/src/components/AudiobookExportModal.tsx @@ -1,19 +1,19 @@ 'use client'; -import { Fragment, useState, useRef, useCallback, useEffect, useMemo } from 'react'; -import { Transition, Listbox, Menu, MenuButton } from '@headlessui/react'; +import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; import { useTimeEstimation } from '@/hooks/useTimeEstimation'; import { ProgressPopup } from '@/components/ProgressPopup'; import { ProgressCard } from '@/components/ProgressCard'; -import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, ChevronUpDownIcon, RefreshIcon, DotsVerticalIcon } from '@/components/icons/Icons'; +import { DownloadIcon, CheckCircleIcon, XCircleIcon, ClockIcon, RefreshIcon, DotsVerticalIcon } from '@/components/icons/Icons'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { useConfig } from '@/contexts/ConfigContext'; import { useTTS } from '@/contexts/TTSContext'; import { VoicesControlBase } from '@/components/player/VoicesControlBase'; import { ReaderSidebarShell } from '@/components/reader/ReaderSidebarShell'; import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; +import { getTtsLanguageCompatibilityWarnings, resolveTtsLanguage } from '@/lib/shared/language'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; -import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, RangeInput, SharedListboxButton, SharedListboxOption, SharedListboxOptions } from '@/components/ui'; +import { Button, Card, IconButton, MenuActionItem, MenuItemsSurface, MenuRoot, MenuTransition, MenuTrigger, RangeInput, Select } from '@/components/ui'; import { getAudiobookStatus, deleteAudiobookChapter, @@ -50,7 +50,7 @@ export function AudiobookExportModal({ onRegenerateChapter }: AudiobookExportModalProps) { const { isLoading, isDBReady, providerRef, providerType, ttsModel, ttsInstructions, voice: configVoice, voiceSpeed, audioPlayerSpeed } = useConfig(); - const { availableVoices } = useTTS(); + const { availableVoices, documentLanguage } = useTTS(); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const [isGenerating, setIsGenerating] = useState(false); const [chapters, setChapters] = useState([]); @@ -125,8 +125,17 @@ export function AudiobookExportModal({ postSpeed, format, ttsInstructions: providerModelPolicy.supportsInstructions ? ttsInstructions : undefined, + language: resolveTtsLanguage({ + configuredLanguage: documentLanguage, + voice: nextVoice, + }), }; - }, [savedSettings, audiobookVoice, configVoice, availableVoices, providerRef, providerType, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format, providerModelPolicy.supportsInstructions]); + }, [savedSettings, audiobookVoice, configVoice, availableVoices, providerRef, providerType, ttsModel, ttsInstructions, effectiveNativeSpeed, postSpeed, format, providerModelPolicy.supportsInstructions, documentLanguage]); + const languageWarnings = useMemo(() => getTtsLanguageCompatibilityWarnings({ + model: effectiveSettings?.ttsModel, + voice: effectiveSettings?.voice, + documentLanguage: effectiveSettings?.language, + }), [effectiveSettings]); const fetchExistingChapters = useCallback(async (soft: boolean = false) => { if (soft) { @@ -555,56 +564,35 @@ export function AudiobookExportModal({
    {chapters.length === 0 ? ( - setFormat(newFormat)} + options={['m4b', 'mp3'] as const} disabled={chapters.length > 0 || settingsLocked} - > -
    - - {format.toUpperCase()} - - - - - - - - {({ selected }) => ( - - M4B - - )} - - - {({ selected }) => ( - - MP3 - - )} - - - -
    -
    + renderValue={(option) => ( + {option.toUpperCase()} + )} + renderOption={(option, { selected }) => ( + + {option.toUpperCase()} + + )} + buttonClassName="bg-surface" + chevronClassName="h-4 w-4 text-soft" + optionInset="none" + optionItemClassName="py-2" + showCheckmark={false} + /> ) : (
    {format.toUpperCase()}
    )}
  • + {languageWarnings.map((warning) => ( +

    + {warning} +

    + ))} {/* Speed controls */} @@ -773,23 +761,15 @@ export function AudiobookExportModal({
    {((onRegenerateChapter && !isGenerating) || chapter.status === 'completed') && ( - - + - - + + {/* end of menu items */} - - + + )}
    diff --git a/src/components/ColorPicker.tsx b/src/components/ColorPicker.tsx index 374ec34..8a700d1 100644 --- a/src/components/ColorPicker.tsx +++ b/src/components/ColorPicker.tsx @@ -1,10 +1,9 @@ 'use client'; import { useRef, useState, useEffect, useCallback } from 'react'; -import { Popover, PopoverButton } from '@headlessui/react'; import { isLightColor, type CustomThemeColors } from '@/contexts/ThemeContext'; import { PaletteIcon } from '@/components/icons/Icons'; -import { IconButton, Input, PopoverSurface } from '@/components/ui'; +import { IconButton, Input, PopoverIconTrigger, PopoverRoot, PopoverSurface } from '@/components/ui'; /** * Curated swatch palettes per color role, sourced from existing themes @@ -78,8 +77,8 @@ export function ColorPicker({ value, field, label, onChange }: ColorPickerProps) const swatches = ROLE_SWATCHES[field]; return ( - - + +
    - +
    -
    + ); } diff --git a/src/components/PrivacyModal.tsx b/src/components/PrivacyModal.tsx index 600c7c7..a82e172 100644 --- a/src/components/PrivacyModal.tsx +++ b/src/components/PrivacyModal.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { updateAppConfig } from '@/lib/client/dexie'; -import { Button, ModalFrame, ModalTitle } from '@/components/ui'; +import { Button, Checkbox, ModalFrame, ModalTitle } from '@/components/ui'; interface PrivacyModalProps { isOpen: boolean; @@ -78,13 +78,11 @@ export function PrivacyModal({ isOpen, onAccept, onDismiss }: PrivacyModalProps)
    - setAgreed(e.target.checked)} - className="h-4 w-4 rounded border-line text-accent focus:ring-accent-line bg-surface" />
    diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index a5ad457..b9465ea 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -1,14 +1,10 @@ 'use client'; import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; -import { - Transition, - Listbox, -} from '@headlessui/react'; import Link from 'next/link'; import { useTheme } from '@/contexts/ThemeContext'; import { useConfig } from '@/contexts/ConfigContext'; -import { ChevronUpDownIcon, CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon, ChevronRightIcon } from '@/components/icons/Icons'; +import { CheckIcon, SettingsIcon, KeyIcon, PaletteIcon, DocumentIcon, UserIcon, DownloadIcon, ChevronRightIcon } from '@/components/icons/Icons'; import { useDocuments } from '@/contexts/DocumentContext'; import { ConfirmDialog } from '@/components/ConfirmDialog'; import { ProgressPopup } from '@/components/ProgressPopup'; @@ -50,12 +46,10 @@ import { ChoiceTile, IconButton, Input, + Textarea, ModalFrame, ModalTitle, - inputClass, - SharedListboxButton, - SharedListboxOption, - SharedListboxOptions, + Select, } from '@/components/ui'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -654,8 +648,16 @@ export function SettingsModal({ User API keys are restricted and no shared provider is configured. Ask an admin to add one.

    ) : ( - provider.id} + renderValue={(provider) => provider.name} + renderOption={(provider, { selected }) => ( + + {provider.name} + + )} onChange={(provider) => { const defaults = resolveProviderDefaults({ providerRef: provider.id, @@ -675,46 +677,7 @@ export function SettingsModal({ } setCustomModelInput(''); }} - > - - - {selectedProviderOption?.name || 'Select Provider'} - - - - - - - - {ttsProviders.map((provider) => ( - - {({ selected }) => ( - <> - - {provider.name} - - {selected && ( - - - - )} - - )} - - ))} - - - + /> )}
    {restrictUserApiKeys && ( @@ -734,7 +697,6 @@ export function SettingsModal({ value={localBaseUrl} onChange={(e) => handleInputChange('baseUrl', e.target.value)} placeholder="Using environment variable" - className={inputClass} />
    )} @@ -750,7 +712,6 @@ export function SettingsModal({ value={localApiKey} onChange={(e) => handleInputChange('apiKey', e.target.value)} placeholder="Using environment variable" - className={inputClass} />
    )} @@ -768,8 +729,30 @@ export function SettingsModal({

    )}
    - m.id === selectedModelId) || ttsModels[0]} + )}
    @@ -853,11 +780,11 @@ export function SettingsModal({ {providerModelPolicy.supportsInstructions && (
    -