fix(client): localize the read-aloud interface

Replace the hardcoded English in the TTS and narration components with
message keys, label every icon-only control, and add settings.common.nav.tts,
which the settings sidebar rendered as a raw key.

Stop requiring PODCAST_ENCRYPTION_KEY while APP_FEATURES.podcasts is off, and
skip podcast routes and permissions in the authorization matrix for the same
reason.

Pass the direct-download settle() helper the SETTLE_MS budget the file already
defines: vi.waitFor's one-second default cannot cover six DNS-resolved redirect
hops on a cold CI resolver.
This commit is contained in:
neon
2026-09-21 04:35:57 +00:00
parent 7c7e23806e
commit 67ef7f29a5
22 changed files with 550 additions and 171 deletions
+8 -8
View File
@@ -63,14 +63,6 @@ Take your library anywhere with the official native BookOrbit app. Read ebooks,
</details>
## How I Actually Use BookOrbit
One read-along EPUB3 per book: listened to on iPhone, carried offline to Apple Watch for runs, read
on a Kobo through KOReader, and picked up again on the laptop. One file, one reading position, no
reconciling an audiobook against an ebook.
Read the full walkthrough at **[bookorbit.app/my-workflow](https://bookorbit.app/my-workflow)**.
## Live Demo
Try the live instance before you install. No account required.
@@ -136,6 +128,14 @@ Open `http://your-server-ip:3000` and complete setup using your `SETUP_BOOTSTRAP
For the full installation guide including reverse proxy setup, file permissions on NAS, secrets from mounted files, external databases, OIDC hardening, and environment variable reference, see **[bookorbit.app/installation](https://bookorbit.app/installation)**.
## How I Actually Use BookOrbit
One read-along EPUB3 per book: listened to on iPhone, carried offline to Apple Watch for runs, read
on a Kobo through KOReader, and picked up again on the laptop. One file, one reading position, no
reconciling an audiobook against an ebook.
Read the full walkthrough at **[bookorbit.app/my-workflow](https://bookorbit.app/my-workflow)**.
## KOReader Plugin
The BookOrbit plugin for KOReader adds progress sync, two-way annotation sync, and a native catalog browser: navigate, search, and download books from your library without leaving the device.
@@ -1,11 +1,13 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { ChevronDown, ChevronUp, Headphones, Pause, Play, SkipBack, SkipForward, X } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { useMediaOverlay } from '../composables/useMediaOverlay'
import { useTtsMiniPlayerUi } from '@/features/tts/composables/useTtsMiniPlayerUi'
import TtsSpeedControl from '@/features/tts/components/TtsSpeedControl.vue'
import TtsSleepTimerPicker from '@/features/tts/components/TtsSleepTimerPicker.vue'
const { t } = useI18n()
const { isActive, isPlaying, rate, currentBook, error, sleepTimer, toggle, nextSentence, prevSentence, setRate, stop } = useMediaOverlay()
const { isReaderFooterVisible } = useTtsMiniPlayerUi()
@@ -43,45 +45,58 @@ function handleStop() {
</div>
<div class="flex items-center gap-2 px-3 py-2.5">
<button class="flex-shrink-0 w-8 h-10 rounded bg-muted overflow-hidden flex items-center justify-center" @click="togglePanel">
<img v-if="currentBook?.coverUrl" :src="currentBook.coverUrl" :alt="currentBook?.title" class="w-full h-full object-cover" />
<button
class="flex-shrink-0 w-8 h-10 rounded bg-muted overflow-hidden flex items-center justify-center"
:aria-label="t('reader.narration.openControls')"
:aria-expanded="showPanel"
@click="togglePanel"
>
<img v-if="currentBook?.coverUrl" :src="currentBook.coverUrl" alt="" class="w-full h-full object-cover" />
<Headphones v-else class="w-4 h-4 text-muted-foreground" />
</button>
<button class="flex-1 min-w-0 text-left" @click="togglePanel">
<div class="text-sm font-medium truncate text-foreground">{{ currentBook?.title ?? 'Narration' }}</div>
<div class="text-xs text-muted-foreground truncate">Audio narration - {{ rate }}x</div>
<button class="flex-1 min-w-0 text-left" :aria-label="t('reader.narration.openControls')" :aria-expanded="showPanel" @click="togglePanel">
<div class="text-sm font-medium truncate text-foreground">{{ currentBook?.title ?? t('reader.narration.fallbackTitle') }}</div>
<div class="text-xs text-muted-foreground truncate">{{ t('reader.narration.meta', { speed: rate }) }}</div>
</button>
<button class="p-1.5 rounded-md hover:bg-accent text-muted-foreground" aria-label="Previous sentence" @click="prevSentence">
<button
class="p-1.5 rounded-md hover:bg-accent text-muted-foreground"
:aria-label="t('reader.narration.previousSentence')"
@click="prevSentence"
>
<SkipBack class="w-4 h-4" />
</button>
<button
class="p-2 rounded-full bg-primary text-primary-foreground hover:bg-primary/90"
:aria-label="isPlaying ? 'Pause narration' : 'Play narration'"
:aria-label="isPlaying ? t('reader.narration.pause') : t('reader.narration.play')"
@click="toggle"
>
<Pause v-if="isPlaying" class="w-4 h-4" />
<Play v-else class="w-4 h-4" />
</button>
<button class="p-1.5 rounded-md hover:bg-accent text-muted-foreground" aria-label="Next sentence" @click="nextSentence">
<button
class="p-1.5 rounded-md hover:bg-accent text-muted-foreground"
:aria-label="t('reader.narration.nextSentence')"
@click="nextSentence"
>
<SkipForward class="w-4 h-4" />
</button>
<button
class="p-1.5 rounded-md hover:bg-accent text-muted-foreground"
:aria-label="showPanel ? 'Hide controls' : 'Show controls'"
:aria-label="showPanel ? t('reader.narration.hideControls') : t('reader.narration.showControls')"
@click="togglePanel"
>
<ChevronDown v-if="showPanel" class="w-4 h-4" />
<ChevronUp v-else class="w-4 h-4" />
</button>
<button class="p-1.5 rounded-md hover:bg-accent text-muted-foreground" aria-label="Stop narration" @click="handleStop">
<button class="p-1.5 rounded-md hover:bg-accent text-muted-foreground" :aria-label="t('reader.narration.stop')" @click="handleStop">
<X class="w-4 h-4" />
</button>
</div>
<div v-if="error" class="px-3 pb-2 text-xs text-destructive">{{ error }}</div>
<div v-if="error" role="alert" class="px-3 pb-2 text-xs text-destructive">{{ error }}</div>
</div>
</div>
</Transition>
@@ -1,4 +1,5 @@
import { ref } from 'vue'
import { i18n } from '@/i18n'
import { registerAudioFocusOwner, requestAudioFocus } from '@/lib/audio-focus'
import type { FoliateMediaOverlay } from '@/features/reader/epub/composables/useFoliate'
import { useTtsMediaSession } from '@/features/tts/composables/useTtsMediaSession'
@@ -102,7 +103,7 @@ export function useMediaOverlay() {
}
errorListener = (e: Event) => {
const detail = (e as CustomEvent).detail
error.value = detail instanceof Error ? detail.message : 'Narration playback error'
error.value = detail instanceof Error ? detail.message : i18n.global.t('reader.narration.error')
}
instance.addEventListener('highlight', highlightListener)
instance.addEventListener('error', errorListener)
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue'
import { AlertCircle, Loader2, Pencil, Plus, RefreshCw, Square, Trash2, Volume2, X } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import * as ttsApi from './api/tts.api'
import type { TtsDbProvider, StaticVoiceConfig } from './api/tts.api'
@@ -15,7 +16,15 @@ const emit = defineEmits<{
close: []
}>()
const { t } = useI18n()
const GENDER_OPTIONS = ['', 'Male', 'Female', 'Unknown'] as const
function genderLabel(gender: string): string {
if (gender === 'Male') return t('settings.admin.tts.curation.genderMale')
if (gender === 'Female') return t('settings.admin.tts.curation.genderFemale')
if (gender === 'Unknown') return t('settings.admin.tts.curation.genderUnknown')
return '-'
}
const voices = ref<StaticVoiceConfig[]>(props.provider.staticVoices ? [...props.provider.staticVoices] : [])
const savedVoices = ref<StaticVoiceConfig[]>(props.provider.staticVoices ? [...props.provider.staticVoices] : [])
@@ -40,17 +49,17 @@ const importDisabledReason = computed<string | null>(() => {
if (props.provider.supportsVoiceDiscovery) return null
const presetLabel = presetKey.value === 'kokoro' ? 'Kokoro' : presetKey.value === 'openai' ? 'OpenAI' : null
return presetLabel
? `This provider does not support voice discovery. Use the "Load ${presetLabel} preset" button instead.`
: 'This provider does not support voice discovery.'
? t('settings.admin.tts.curation.importUnsupportedWithPreset', { preset: presetLabel })
: t('settings.admin.tts.curation.importUnsupported')
})
const emptyStateHint = computed(() => {
const emptyStateMessage = computed(() => {
const canImport = props.provider.supportsVoiceDiscovery
const hasPreset = !!presetKey.value
if (canImport && hasPreset) return 'Import from provider or load a preset.'
if (canImport) return 'Import from provider.'
if (hasPreset) return 'Load a preset to get started.'
return 'Add voices manually.'
if (canImport && hasPreset) return t('settings.admin.tts.curation.emptyImportOrPreset')
if (canImport) return t('settings.admin.tts.curation.emptyImport')
if (hasPreset) return t('settings.admin.tts.curation.emptyPreset')
return t('settings.admin.tts.curation.emptyManual')
})
const hasUnsavedChanges = computed(() => {
@@ -76,12 +85,12 @@ async function handleDiscover() {
try {
const result = await ttsApi.discoverVoices(props.provider.id)
if (!result.supported) {
discoverError.value = 'This provider does not expose a voice list endpoint.'
discoverError.value = t('settings.admin.tts.curation.discoveryUnsupported')
return
}
voices.value = mergeVoices(voices.value, result.voices)
} catch (e: unknown) {
discoverError.value = e instanceof Error ? e.message : 'Failed to discover voices'
discoverError.value = e instanceof Error ? e.message : t('settings.admin.tts.curation.discoverFailed')
} finally {
discovering.value = false
}
@@ -196,13 +205,16 @@ function handleClose() {
<!-- Header -->
<div class="flex items-center justify-between p-5 border-b border-border flex-shrink-0">
<div>
<h2 class="font-semibold text-foreground">Manage Voices - {{ provider.name }}</h2>
<h2 class="font-semibold text-foreground">{{ t('settings.admin.tts.curation.title', { provider: provider.name }) }}</h2>
<p class="text-xs text-muted-foreground mt-0.5">
{{ voices.length }} voice{{ voices.length === 1 ? '' : 's' }} configured
<span v-if="hasUnsavedChanges"> - Unsaved changes</span>
{{
hasUnsavedChanges
? t('settings.admin.tts.curation.summaryUnsaved', { count: voices.length })
: t('settings.admin.tts.curation.summary', { count: voices.length })
}}
</p>
</div>
<button class="p-1.5 rounded-lg hover:bg-accent" @click="handleClose">
<button class="p-1.5 rounded-lg hover:bg-accent" :aria-label="t('common.close')" @click="handleClose">
<X class="w-4 h-4 text-muted-foreground" />
</button>
</div>
@@ -221,7 +233,7 @@ function handleClose() {
>
<Loader2 v-if="discovering" class="w-3.5 h-3.5 animate-spin" />
<RefreshCw v-else class="w-3.5 h-3.5" />
Import from provider
{{ t('settings.admin.tts.curation.importFromProvider') }}
</button>
</span>
</TooltipTrigger>
@@ -233,22 +245,22 @@ function handleClose() {
@click="handleLoadPreset"
>
<Plus class="w-3.5 h-3.5" />
Load {{ presetKey === 'kokoro' ? 'Kokoro' : 'OpenAI' }} preset
{{ t('settings.admin.tts.curation.loadPreset', { preset: presetKey === 'kokoro' ? 'Kokoro' : 'OpenAI' }) }}
</button>
</div>
<p v-if="discoverError" class="text-xs text-destructive">{{ discoverError }}</p>
<p v-if="discoverError" role="alert" class="text-xs text-destructive">{{ discoverError }}</p>
</div>
<!-- Voice table -->
<div class="flex-1 overflow-y-auto min-h-0">
<div v-if="voices.length === 0" class="text-sm text-muted-foreground text-center py-10">No voices configured. {{ emptyStateHint }}</div>
<div v-if="voices.length === 0" class="text-sm text-muted-foreground text-center py-10">{{ emptyStateMessage }}</div>
<template v-else>
<div class="text-xs border-b border-border bg-muted/50">
<div class="grid grid-cols-[1fr_1fr_80px_80px_80px] gap-2 px-4 py-2 font-medium text-muted-foreground">
<div>ID</div>
<div>Name</div>
<div>Locale</div>
<div>Gender</div>
<div>{{ t('settings.admin.tts.curation.columnId') }}</div>
<div>{{ t('settings.admin.tts.curation.columnName') }}</div>
<div>{{ t('settings.admin.tts.curation.columnLocale') }}</div>
<div>{{ t('settings.admin.tts.curation.columnGender') }}</div>
<div />
</div>
</div>
@@ -259,26 +271,36 @@ function handleClose() {
<div class="grid grid-cols-[1fr_1fr_80px_80px_80px] gap-2 px-4 py-2 items-center">
<input
v-model="editForm.id"
:aria-label="t('settings.admin.tts.curation.columnId')"
class="px-2 py-1 text-xs bg-background border border-border rounded text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
<input
v-model="editForm.name"
:aria-label="t('settings.admin.tts.curation.columnName')"
class="px-2 py-1 text-xs bg-background border border-border rounded text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
<input
v-model="editForm.locale"
placeholder="en-US"
:aria-label="t('settings.admin.tts.curation.columnLocale')"
class="px-2 py-1 text-xs bg-background border border-border rounded text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
/>
<select
v-model="editForm.gender"
:aria-label="t('settings.admin.tts.curation.columnGender')"
class="px-2 py-1 text-xs bg-background border border-border rounded text-foreground focus:outline-none focus:ring-1 focus:ring-ring"
>
<option v-for="g in GENDER_OPTIONS" :key="g" :value="g">{{ g || '-' }}</option>
<option v-for="g in GENDER_OPTIONS" :key="g" :value="g">{{ genderLabel(g) }}</option>
</select>
<div class="flex items-center gap-1">
<button class="p-1 rounded text-primary hover:bg-accent text-xs font-medium" @click="handleSaveEdit">Save</button>
<button class="p-1 rounded text-muted-foreground hover:bg-accent text-xs" aria-label="Cancel edit" @click="handleCancelEdit">
<button class="p-1 rounded text-primary hover:bg-accent text-xs font-medium" @click="handleSaveEdit">
{{ t('common.save') }}
</button>
<button
class="p-1 rounded text-muted-foreground hover:bg-accent text-xs"
:aria-label="t('settings.admin.tts.curation.cancelEdit')"
@click="handleCancelEdit"
>
<X class="w-3.5 h-3.5" />
</button>
</div>
@@ -290,12 +312,16 @@ function handleClose() {
<span class="text-xs text-muted-foreground font-mono truncate" :title="voice.id">{{ voice.id }}</span>
<span class="text-xs text-foreground truncate" :title="voice.name">{{ voice.name }}</span>
<span class="text-xs text-muted-foreground">{{ voice.locale || '-' }}</span>
<span class="text-xs text-muted-foreground">{{ voice.gender || '-' }}</span>
<span class="text-xs text-muted-foreground">{{ genderLabel(voice.gender ?? '') }}</span>
<div class="flex items-center gap-1">
<button
type="button"
class="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent"
:aria-label="playingVoiceId === voice.id ? `Stop preview for ${voice.name}` : `Preview ${voice.name}`"
:aria-label="
playingVoiceId === voice.id
? t('settings.admin.tts.curation.stopPreview', { name: voice.name })
: t('settings.admin.tts.curation.previewVoice', { name: voice.name })
"
@click="handlePreview(voice)"
>
<Loader2 v-if="previewingVoiceId === voice.id" class="w-3.5 h-3.5 animate-spin" />
@@ -305,12 +331,16 @@ function handleClose() {
</button>
<button
class="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent"
:aria-label="`Edit ${voice.name}`"
:aria-label="t('settings.admin.tts.curation.editVoice', { name: voice.name })"
@click="handleStartEdit(idx)"
>
<Pencil class="w-3.5 h-3.5" />
</button>
<button class="p-1 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10" @click="handleDeleteVoice(idx)">
<button
class="p-1 rounded text-muted-foreground hover:text-destructive hover:bg-destructive/10"
:aria-label="t('settings.admin.tts.curation.deleteVoice', { name: voice.name })"
@click="handleDeleteVoice(idx)"
>
<Trash2 class="w-3.5 h-3.5" />
</button>
</div>
@@ -324,7 +354,7 @@ function handleClose() {
<!-- Footer -->
<div class="flex justify-end gap-3 p-4 border-t border-border flex-shrink-0">
<button class="px-4 py-2 rounded-lg border border-border text-sm font-medium text-foreground hover:bg-accent" @click="handleClose">
Cancel
{{ t('common.cancel') }}
</button>
<button
class="flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
@@ -332,7 +362,7 @@ function handleClose() {
@click="handleSave"
>
<Loader2 v-if="saving" class="w-4 h-4 animate-spin" />
Save {{ voices.length }} voice{{ voices.length === 1 ? '' : 's' }}
{{ t('settings.admin.tts.curation.saveVoices', { count: voices.length }) }}
</button>
</div>
</div>
+17 -9
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { toast } from 'vue-sonner'
import { useI18n } from 'vue-i18n'
import { Loader2, Save } from '@lucide/vue'
import { useTtsPreferences } from './composables/useTtsPreferences'
import { useTtsVoices } from './composables/useTtsVoices'
@@ -8,6 +9,7 @@ import TtsVoicePicker from './components/TtsVoicePicker.vue'
import TtsSpeedControl from './components/TtsSpeedControl.vue'
import { formatVoiceDisplayName, formatVoiceLocaleLabel } from './lib/voice-display'
const { t } = useI18n()
const { userPrefs, loadUserPreferences, saveUserPreferences } = useTtsPreferences()
const { allVoices, loadProviders, loadVoices } = useTtsVoices()
@@ -51,9 +53,9 @@ async function handleSave() {
voiceId: selectedVoiceId.value ?? undefined,
speed: selectedSpeed.value,
})
toast.success('TTS preferences saved')
toast.success(t('tts.settings.saved'))
} catch {
toast.error('Failed to save TTS preferences')
toast.error(t('tts.settings.saveFailed'))
} finally {
saving.value = false
}
@@ -72,6 +74,10 @@ function handleSpeedUpdate(speed: number) {
selectedSpeed.value = speed
}
function handleCloseVoicePicker() {
showVoicePicker.value = false
}
function handleToggleVoicePicker() {
showVoicePicker.value = !showVoicePicker.value
}
@@ -80,24 +86,26 @@ function handleToggleVoicePicker() {
<template>
<div class="space-y-4 max-w-2xl">
<div>
<p class="text-sm text-muted-foreground mt-1">Configure your default TTS voice and playback settings.</p>
<p class="text-sm text-muted-foreground mt-1">{{ t('tts.settings.description') }}</p>
</div>
<div class="bg-card border border-border rounded-xl p-5 space-y-5">
<div>
<label class="block text-sm font-medium text-foreground mb-1">Default Voice</label>
<label id="tts-default-voice-label" class="block text-sm font-medium text-foreground mb-1">{{ t('tts.settings.defaultVoice') }}</label>
<button
class="w-full flex items-center justify-between px-3 py-2.5 rounded-lg border border-border bg-background hover:bg-accent text-sm"
aria-labelledby="tts-default-voice-label"
:aria-expanded="showVoicePicker"
@click="handleToggleVoicePicker"
>
<div class="min-w-0 text-left">
<div v-if="selectedVoiceLabel" class="text-foreground truncate">{{ selectedVoiceLabel }}</div>
<div v-else class="text-muted-foreground">Select a voice...</div>
<div v-else class="text-muted-foreground">{{ t('tts.settings.selectVoice') }}</div>
<div v-if="selectedVoiceLocaleLabel" class="text-xs text-muted-foreground truncate mt-0.5">
{{ selectedVoiceLocaleLabel }}
</div>
</div>
<span class="text-muted-foreground text-xs">{{ showVoicePicker ? 'Hide' : 'Change' }}</span>
<span class="text-muted-foreground text-xs">{{ showVoicePicker ? t('common.hide') : t('tts.settings.change') }}</span>
</button>
<div v-if="showVoicePicker" class="mt-2 border border-border rounded-xl overflow-hidden">
<TtsVoicePicker
@@ -105,13 +113,13 @@ function handleToggleVoicePicker() {
:selected-voice-id="selectedVoiceId"
@update:selected-voice-id="handleVoiceSelected"
@update:selected-provider-id="handleProviderSelected"
@close="showVoicePicker = false"
@close="handleCloseVoicePicker"
/>
</div>
</div>
<div>
<label class="block text-sm font-medium text-foreground mb-3">Default Speed</label>
<p class="block text-sm font-medium text-foreground mb-3">{{ t('tts.settings.defaultSpeed') }}</p>
<TtsSpeedControl :speed="selectedSpeed" @update:speed="handleSpeedUpdate" />
</div>
@@ -123,7 +131,7 @@ function handleToggleVoicePicker() {
>
<Loader2 v-if="saving" class="w-4 h-4 animate-spin" />
<Save v-else class="w-4 h-4" />
Save
{{ t('common.save') }}
</button>
</div>
</div>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Headphones, Pause, Play, ChevronDown, ChevronUp } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { useTtsPlayer } from '../composables/useTtsPlayer'
import { useTtsMiniPlayerUi } from '../composables/useTtsMiniPlayerUi'
import { useTtsVoices } from '../composables/useTtsVoices'
@@ -10,6 +11,7 @@ import { formatVoiceDisplayName } from '../lib/voice-display'
const { playbackState, currentBook, currentBlockIndex, currentChapterIndex, speed, currentProviderId, currentVoiceId, togglePlayPause } =
useTtsPlayer()
const { t } = useI18n()
const { mode, isReaderFooterVisible, setMode } = useTtsMiniPlayerUi()
const { allVoices, loadVoices } = useTtsVoices()
const isVisible = computed(() => playbackState.value !== 'idle')
@@ -52,8 +54,9 @@ const selectedVoice = computed(() =>
)
const compactMeta = computed(() => {
const voiceName = selectedVoice.value ? formatVoiceDisplayName(selectedVoice.value) : currentVoiceId.value
return [`Ch ${currentChapterIndex.value + 1}`, `Sent ${currentBlockIndex.value + 1}`, voiceName, `${speed.value}x`].filter(Boolean).join(' - ')
const voice = selectedVoice.value ? formatVoiceDisplayName(selectedVoice.value) : currentVoiceId.value
const values = { chapter: currentChapterIndex.value + 1, sentence: currentBlockIndex.value + 1, speed: speed.value }
return voice ? t('tts.player.meta', { ...values, voice }) : t('tts.player.metaWithoutVoice', values)
})
function updateIsMobileViewport() {
@@ -201,13 +204,13 @@ onUnmounted(() => {
<button
class="w-14 h-14 rounded-full border border-border bg-card text-foreground shadow-2xl flex items-center justify-center hover:bg-accent disabled:opacity-50"
:disabled="playbackState === 'loading'"
:aria-label="playbackState === 'playing' ? 'Pause TTS' : 'Play TTS'"
:aria-label="playbackState === 'playing' ? t('tts.player.pause') : t('tts.player.play')"
@click="togglePlayPause"
>
<img
v-if="currentBook?.coverUrl"
:src="currentBook.coverUrl"
:alt="currentBook?.title"
alt=""
class="absolute inset-0 w-full h-full object-cover rounded-full opacity-20"
/>
<Pause v-if="playbackState === 'playing'" class="relative w-6 h-6" />
@@ -215,7 +218,7 @@ onUnmounted(() => {
</button>
<button
class="absolute -top-1 -left-1 w-6 h-6 rounded-full border border-border bg-card text-muted-foreground shadow-md hover:text-foreground hover:bg-accent flex items-center justify-center"
aria-label="Open mini player"
:aria-label="t('tts.player.openMiniPlayer')"
@click="setCompactMode('mini')"
>
<ChevronUp class="w-3.5 h-3.5" />
@@ -227,28 +230,38 @@ onUnmounted(() => {
<div v-else-if="isMiniMode" class="bg-card border border-border rounded-xl shadow-2xl overflow-hidden">
<div class="flex items-center gap-2 px-3 py-2.5">
<button class="flex-shrink-0 w-8 h-10 rounded bg-muted overflow-hidden flex items-center justify-center" @click="openExpanded">
<img v-if="currentBook?.coverUrl" :src="currentBook.coverUrl" :alt="currentBook?.title" class="w-full h-full object-cover" />
<button
class="flex-shrink-0 w-8 h-10 rounded bg-muted overflow-hidden flex items-center justify-center"
:aria-label="t('tts.player.openDetails')"
@click="openExpanded"
>
<img v-if="currentBook?.coverUrl" :src="currentBook.coverUrl" alt="" class="w-full h-full object-cover" />
<Headphones v-else class="w-4 h-4 text-muted-foreground" />
</button>
<button class="flex-1 min-w-0 text-left" @click="openExpanded">
<div class="text-sm font-medium truncate text-foreground">{{ currentBook?.title ?? 'TTS Playback' }}</div>
<button class="flex-1 min-w-0 text-left" :aria-label="t('tts.player.openDetails')" @click="openExpanded">
<div class="text-sm font-medium truncate text-foreground">{{ currentBook?.title ?? t('tts.player.fallbackTitle') }}</div>
<div class="text-xs text-muted-foreground truncate">{{ compactMeta }}</div>
</button>
<button
class="p-2 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
:disabled="playbackState === 'loading'"
:aria-label="playbackState === 'playing' ? t('tts.player.pause') : t('tts.player.play')"
@click="togglePlayPause"
>
<Pause v-if="playbackState === 'playing'" class="w-4 h-4" />
<Play v-else class="w-4 h-4" />
</button>
<button v-if="isMobileViewport" class="p-1.5 rounded-md hover:bg-accent text-muted-foreground" @click="setCompactMode('micro')">
<button
v-if="isMobileViewport"
class="p-1.5 rounded-md hover:bg-accent text-muted-foreground"
:aria-label="t('tts.player.shrinkPlayer')"
@click="setCompactMode('micro')"
>
<ChevronDown class="w-4 h-4" />
</button>
<button class="p-1.5 rounded-md hover:bg-accent text-muted-foreground" @click="openExpanded">
<button class="p-1.5 rounded-md hover:bg-accent text-muted-foreground" :aria-label="t('tts.player.expandPlayer')" @click="openExpanded">
<ChevronUp class="w-4 h-4" />
</button>
</div>
@@ -256,7 +269,7 @@ onUnmounted(() => {
<div v-if="playbackState === 'loading'" class="h-0.5 bg-muted overflow-hidden">
<div class="h-full bg-primary animate-pulse w-1/2" />
</div>
<div v-if="playbackState === 'error'" class="px-3 pb-2 text-xs text-destructive">Playback error - tap play to retry</div>
<div v-if="playbackState === 'error'" role="alert" class="px-3 pb-2 text-xs text-destructive">{{ t('tts.player.playbackError') }}</div>
</div>
<TtsMiniPlayerExpanded v-else @close="handleCloseExpanded" />
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner'
import { useI18n } from 'vue-i18n'
import { ChevronDown, Pause, Play, SkipForward, SkipBack, Headphones, Moon, Gauge, Mic2, Loader2, Book, Star } from '@lucide/vue'
import { useTtsPlayer } from '../composables/useTtsPlayer'
import { useTtsPreferences } from '../composables/useTtsPreferences'
@@ -12,6 +13,8 @@ import { formatVoiceDisplayName, formatVoiceLocaleLabel } from '../lib/voice-dis
const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const {
playbackState,
currentBook,
@@ -43,8 +46,11 @@ const selectedVoice = computed(() =>
allVoices.value.find((voice) => voice.id === currentVoiceId.value && voice.providerId === currentProviderId.value),
)
const selectedVoiceLabel = computed(() => {
if (!selectedVoice.value) return currentVoiceId.value || 'Choose voice'
return `${formatVoiceDisplayName(selectedVoice.value)} - ${formatVoiceLocaleLabel(selectedVoice.value)}`
if (!selectedVoice.value) return currentVoiceId.value || t('tts.player.chooseVoice')
return t('tts.voicePicker.voiceWithLocale', {
voice: formatVoiceDisplayName(selectedVoice.value),
locale: formatVoiceLocaleLabel(selectedVoice.value),
})
})
async function fetchBookPrefs() {
@@ -110,6 +116,10 @@ function handleToggleSleepTimer() {
showVoicePicker.value = false
}
function handleCloseVoicePicker() {
showVoicePicker.value = false
}
function handleToggleVoicePicker() {
showVoicePicker.value = !showVoicePicker.value
showSpeedControl.value = false
@@ -141,9 +151,9 @@ async function handleSaveBookVoice() {
providerId: currentProviderId.value,
voiceId: currentVoiceId.value,
}
toast.success('Voice saved for this book')
toast.success(t('tts.player.bookVoiceSaved'))
} catch {
toast.error('Failed to save book voice')
toast.error(t('tts.player.bookVoiceSaveFailed'))
} finally {
savingBookVoice.value = false
}
@@ -157,9 +167,9 @@ async function handleSaveDefaultVoice() {
providerId: currentProviderId.value,
voiceId: currentVoiceId.value,
})
toast.success('Default TTS voice updated')
toast.success(t('tts.player.defaultVoiceSaved'))
} catch {
toast.error('Failed to update default voice')
toast.error(t('tts.player.defaultVoiceSaveFailed'))
} finally {
savingDefaultVoice.value = false
}
@@ -170,32 +180,45 @@ async function handleSaveDefaultVoice() {
<div class="bg-card border border-border rounded-xl shadow-2xl p-4 space-y-4">
<div class="flex items-start gap-3">
<div class="flex-shrink-0 w-16 h-20 rounded-lg bg-muted overflow-hidden flex items-center justify-center">
<img v-if="currentBook?.coverUrl" :src="currentBook.coverUrl" :alt="currentBook?.title" class="w-full h-full object-cover" />
<img v-if="currentBook?.coverUrl" :src="currentBook.coverUrl" alt="" class="w-full h-full object-cover" />
<Headphones v-else class="w-8 h-8 text-muted-foreground" />
</div>
<div class="flex-1 min-w-0">
<div class="font-semibold text-foreground truncate">{{ currentBook?.title ?? 'TTS Playback' }}</div>
<div class="font-semibold text-foreground truncate">{{ currentBook?.title ?? t('tts.player.fallbackTitle') }}</div>
<div class="text-sm text-muted-foreground">{{ currentBook?.author ?? '' }}</div>
<div class="text-xs text-muted-foreground mt-1">Chapter {{ currentChapterIndex + 1 }} - Sentence {{ currentBlockIndex + 1 }}</div>
<div class="text-xs text-muted-foreground mt-1">
{{ t('tts.player.position', { chapter: currentChapterIndex + 1, sentence: currentBlockIndex + 1 }) }}
</div>
</div>
<button class="p-1.5 rounded-md hover:bg-accent text-muted-foreground" @click="handleCollapse">
<button class="p-1.5 rounded-md hover:bg-accent text-muted-foreground" :aria-label="t('tts.player.collapsePlayer')" @click="handleCollapse">
<ChevronDown class="w-5 h-5" />
</button>
</div>
<div class="flex items-center justify-center gap-4 py-1">
<button class="p-2 rounded-lg hover:bg-accent text-foreground disabled:opacity-50" :disabled="playbackState === 'loading'" @click="prevBlock">
<button
class="p-2 rounded-lg hover:bg-accent text-foreground disabled:opacity-50"
:disabled="playbackState === 'loading'"
:aria-label="t('tts.player.previousSentence')"
@click="prevBlock"
>
<SkipBack class="w-5 h-5" />
</button>
<button
class="p-3 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50 w-12 h-12 flex items-center justify-center"
:disabled="playbackState === 'loading'"
:aria-label="playbackState === 'playing' ? t('tts.player.pause') : t('tts.player.play')"
@click="togglePlayPause"
>
<Pause v-if="playbackState === 'playing'" class="w-6 h-6" />
<Play v-else class="w-6 h-6" />
</button>
<button class="p-2 rounded-lg hover:bg-accent text-foreground disabled:opacity-50" :disabled="playbackState === 'loading'" @click="nextBlock">
<button
class="p-2 rounded-lg hover:bg-accent text-foreground disabled:opacity-50"
:disabled="playbackState === 'loading'"
:aria-label="t('tts.player.nextSentence')"
@click="nextBlock"
>
<SkipForward class="w-5 h-5" />
</button>
</div>
@@ -207,7 +230,7 @@ async function handleSaveDefaultVoice() {
@click="handleToggleVoicePicker"
>
<Mic2 class="w-4 h-4 flex-shrink-0" />
<span class="truncate">{{ selectedVoice ? formatVoiceDisplayName(selectedVoice) : 'Voice' }}</span>
<span class="truncate">{{ selectedVoice ? formatVoiceDisplayName(selectedVoice) : t('tts.player.voice') }}</span>
</button>
<button
class="flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-sm hover:bg-accent text-foreground border border-transparent"
@@ -215,7 +238,7 @@ async function handleSaveDefaultVoice() {
@click="handleToggleSpeed"
>
<Gauge class="w-4 h-4" />
<span>{{ speed }}x</span>
<span>{{ t('tts.speed.value', { speed }) }}</span>
</button>
<button
class="flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-sm hover:bg-accent text-foreground border border-transparent"
@@ -224,9 +247,9 @@ async function handleSaveDefaultVoice() {
>
<Moon class="w-4 h-4" />
<span v-if="sleepTimer.activeMinutes.value !== null && sleepTimer.remainingSeconds.value !== null">
{{ Math.ceil(sleepTimer.remainingSeconds.value / 60) }}m
{{ t('tts.player.sleepRemaining', { count: Math.ceil(sleepTimer.remainingSeconds.value / 60) }) }}
</span>
<span v-else>Sleep</span>
<span v-else>{{ t('tts.player.sleep') }}</span>
</button>
</div>
@@ -234,7 +257,7 @@ async function handleSaveDefaultVoice() {
<div class="rounded-lg border border-border bg-muted/20 p-2.5 flex items-center justify-between gap-3">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-0.5">
<div class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">Current Voice</div>
<div class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{{ t('tts.player.currentVoice') }}</div>
<Loader2 v-if="voicesLoading" class="w-3 h-3 animate-spin text-muted-foreground" />
</div>
<div class="truncate text-sm font-medium text-foreground">{{ selectedVoiceLabel }}</div>
@@ -247,7 +270,8 @@ async function handleSaveDefaultVoice() {
? 'border-primary/30 text-primary bg-primary/10 hover:bg-primary/20'
: 'border-border text-muted-foreground bg-background hover:bg-accent hover:text-foreground'
"
title="Save for this book"
:title="t('tts.player.saveForBook')"
:aria-label="t('tts.player.saveForBook')"
:disabled="savingBookVoice || !currentVoiceId"
@click="handleSaveBookVoice"
>
@@ -261,7 +285,8 @@ async function handleSaveDefaultVoice() {
? 'border-amber-500/30 text-amber-600 dark:text-amber-400 bg-amber-500/10 hover:bg-amber-500/20'
: 'border-border text-muted-foreground bg-background hover:bg-accent hover:text-foreground'
"
title="Set as global default"
:title="t('tts.player.setAsDefault')"
:aria-label="t('tts.player.setAsDefault')"
:disabled="savingDefaultVoice || !currentVoiceId"
@click="handleSaveDefaultVoice"
>
@@ -278,7 +303,7 @@ async function handleSaveDefaultVoice() {
height-class="h-[min(52vh,360px)]"
@update:selected-voice-id="handleVoiceSelected"
@update:selected-provider-id="handleProviderSelected"
@close="showVoicePicker = false"
@close="handleCloseVoicePicker"
/>
</div>
</div>
@@ -287,10 +312,10 @@ async function handleSaveDefaultVoice() {
<div class="flex items-center justify-between border-t border-border pt-3">
<div class="text-xs text-muted-foreground">
<span class="font-medium text-foreground">{{ speed }}x</span>
<span class="font-medium text-foreground">{{ t('tts.speed.value', { speed }) }}</span>
<span> - {{ selectedVoiceLabel }}</span>
</div>
<button class="px-3 py-1.5 rounded-lg text-sm hover:bg-accent text-destructive" @click="handleStop">Stop</button>
<button class="px-3 py-1.5 rounded-lg text-sm hover:bg-accent text-destructive" @click="handleStop">{{ t('tts.player.stop') }}</button>
</div>
</div>
</template>
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { Headphones } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { useTtsPlayer } from '../composables/useTtsPlayer'
const props = defineProps<{
@@ -13,6 +14,7 @@ const props = defineProps<{
const emit = defineEmits<{ startTts: [] }>()
const { t } = useI18n()
const { playbackState, currentBook, isActive } = useTtsPlayer()
const isPlayingThisBook = () => isActive.value && currentBook.value?.bookFileId === props.bookFileId
@@ -29,6 +31,6 @@ function handleClick() {
@click="handleClick"
>
<Headphones class="w-4 h-4" :class="{ 'animate-pulse': isPlayingThisBook() && playbackState === 'playing' }" />
<span>{{ isPlayingThisBook() ? 'Playing' : 'Listen' }}</span>
<span>{{ isPlayingThisBook() ? t('tts.readerControls.playing') : t('tts.readerControls.listen') }}</span>
</button>
</template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue'
import { BookmarkCheck, Play, Trash2, X } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
const props = withDefaults(
defineProps<{
@@ -14,6 +15,8 @@ const props = withDefaults(
const emit = defineEmits<{ resume: []; playFromHere: []; clearSavedPosition: []; cancel: [] }>()
const confirmClear = ref(false)
const { t } = useI18n()
function handleResume() {
emit('resume')
}
@@ -45,14 +48,16 @@ function handleCancel() {
<div class="flex flex-col items-center gap-3 p-4 bg-card border border-border rounded-xl shadow-lg max-w-sm mx-auto">
<div class="w-full flex items-start justify-between gap-2">
<div>
<div class="font-semibold text-foreground">Resume TTS?</div>
<div class="font-semibold text-foreground">{{ t('tts.resume.title') }}</div>
<div class="text-sm text-muted-foreground mt-1">
You have a saved TTS position
<span v-if="props.chapterIndex !== null">(Chapter {{ props.chapterIndex + 1 }})</span>
{{
props.chapterIndex !== null ? t('tts.resume.descriptionWithChapter', { chapter: props.chapterIndex + 1 }) : t('tts.resume.description')
}}
</div>
</div>
<button
class="flex-shrink-0 flex items-center justify-center w-7 h-7 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
:aria-label="t('tts.resume.dismiss')"
@click="handleCancel"
>
<X class="w-4 h-4" />
@@ -64,14 +69,14 @@ function handleCancel() {
@click="handleResume"
>
<BookmarkCheck class="w-4 h-4" />
Resume
{{ t('tts.resume.resume') }}
</button>
<button
class="flex-1 flex items-center justify-center gap-2 px-3 py-2 rounded-lg border border-border text-sm font-medium hover:bg-accent transition-colors"
@click="handlePlayFromHere"
>
<Play class="w-4 h-4" />
Start this page
{{ t('tts.resume.startHere') }}
</button>
</div>
<div v-if="confirmClear" class="w-full flex gap-2">
@@ -79,14 +84,14 @@ function handleCancel() {
class="flex-1 px-3 py-2 rounded-lg border border-border text-sm font-medium hover:bg-accent transition-colors"
@click="handleCancelClearSavedPosition"
>
Cancel
{{ t('common.cancel') }}
</button>
<button
class="flex-1 px-3 py-2 rounded-lg bg-destructive text-destructive-foreground text-sm font-medium hover:bg-destructive/90 transition-colors disabled:opacity-60"
:disabled="props.clearing"
@click="handleClearSavedPosition"
>
{{ props.clearing ? 'Clearing…' : 'Confirm clear' }}
{{ props.clearing ? t('tts.resume.clearing') : t('tts.resume.confirmClear') }}
</button>
</div>
<button
@@ -95,7 +100,7 @@ function handleCancel() {
@click="handleRequestClearSavedPosition"
>
<Trash2 class="w-4 h-4" />
Clear saved position
{{ t('tts.resume.clearSavedPosition') }}
</button>
</div>
</template>
@@ -1,10 +1,13 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { useTtsSleepTimer } from '../composables/useTtsSleepTimer'
type SleepTimerInstance = ReturnType<typeof useTtsSleepTimer>
const props = defineProps<{ sleepTimer: SleepTimerInstance }>()
const { t } = useI18n()
function handlePreset(minutes: number) {
props.sleepTimer.startTimer(minutes)
}
@@ -16,7 +19,7 @@ function handleCancel() {
<template>
<div class="space-y-2 pt-2 border-t border-border">
<div class="text-xs text-muted-foreground font-medium">Sleep Timer</div>
<div class="text-xs text-muted-foreground font-medium">{{ t('tts.sleepTimer.title') }}</div>
<div class="flex flex-wrap gap-2">
<button
v-for="minutes in props.sleepTimer.presets"
@@ -27,20 +30,28 @@ function handleCancel() {
? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:bg-accent text-foreground'
"
:aria-label="t('tts.sleepTimer.presetLabel', { count: minutes })"
:aria-pressed="props.sleepTimer.activeMinutes.value === minutes"
@click="handlePreset(minutes)"
>
{{ minutes }}m
{{ t('tts.sleepTimer.preset', { count: minutes }) }}
</button>
<button
v-if="props.sleepTimer.activeMinutes.value !== null"
class="px-3 py-1.5 rounded-full text-sm border border-border hover:bg-accent text-destructive"
:aria-label="t('tts.sleepTimer.cancelLabel')"
@click="handleCancel"
>
Cancel
{{ t('tts.sleepTimer.cancel') }}
</button>
</div>
<div v-if="props.sleepTimer.remainingSeconds.value !== null" class="text-sm text-muted-foreground">
Stops in {{ Math.floor(props.sleepTimer.remainingSeconds.value / 60) }}m {{ props.sleepTimer.remainingSeconds.value % 60 }}s
{{
t('tts.sleepTimer.remaining', {
minutes: Math.floor(props.sleepTimer.remainingSeconds.value / 60),
seconds: props.sleepTimer.remainingSeconds.value % 60,
})
}}
</div>
</div>
</template>
@@ -1,8 +1,14 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
const props = defineProps<{ speed: number }>()
const emit = defineEmits<{ 'update:speed': [speed: number] }>()
const PRESETS = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 3.0]
const MIN_SPEED = 0.25
const MAX_SPEED = 4
const { t } = useI18n()
function handleSliderInput(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value)
@@ -17,20 +23,31 @@ function handlePreset(value: number) {
<template>
<div class="space-y-3 pt-2 border-t border-border">
<div class="flex items-center justify-between text-sm text-muted-foreground">
<span>0.25x</span>
<span class="font-semibold text-foreground text-base">{{ props.speed }}x</span>
<span>4x</span>
<span>{{ t('tts.speed.value', { speed: MIN_SPEED }) }}</span>
<span class="font-semibold text-foreground text-base">{{ t('tts.speed.value', { speed: props.speed }) }}</span>
<span>{{ t('tts.speed.value', { speed: MAX_SPEED }) }}</span>
</div>
<input type="range" min="0.25" max="4" step="0.25" :value="props.speed" class="w-full accent-primary" @input="handleSliderInput" />
<input
type="range"
:min="MIN_SPEED"
:max="MAX_SPEED"
step="0.25"
:value="props.speed"
class="w-full accent-primary"
:aria-label="t('tts.speed.label')"
:aria-valuetext="t('tts.speed.value', { speed: props.speed })"
@input="handleSliderInput"
/>
<div class="flex flex-wrap gap-2 justify-center">
<button
v-for="preset in PRESETS"
:key="preset"
class="px-2.5 py-1 rounded-full text-xs border transition-colors"
:class="props.speed === preset ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-accent text-foreground'"
:aria-pressed="props.speed === preset"
@click="handlePreset(preset)"
>
{{ preset }}x
{{ t('tts.speed.value', { speed: preset }) }}
</button>
</div>
</div>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { Search, Volume2, Loader2, Check } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { useTtsVoices } from '../composables/useTtsVoices'
import type { TtsVoice } from '@bookorbit/types'
import { formatVoiceDisplayName, parseVoiceLanguageCountry } from '../lib/voice-display'
@@ -22,6 +23,7 @@ const emit = defineEmits<{
close: []
}>()
const { t } = useI18n()
const { allVoices, providers, voicesLoading, loadVoices, loadProviders, previewVoice } = useTtsVoices()
const searchQuery = ref('')
@@ -118,6 +120,14 @@ function handleSelectVoice(voice: TtsVoice) {
emit('update:selectedProviderId', voice.providerId)
}
function handleSelectProvider(providerId: string) {
activeProviderId.value = providerId
}
function handleShowAllProviders() {
handleSelectProvider('')
}
function handleSearchInput(event: Event) {
searchQuery.value = (event.target as HTMLInputElement).value
}
@@ -133,7 +143,8 @@ void loadVoices()
<Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input
type="text"
placeholder="Search voices..."
:placeholder="t('tts.voicePicker.search')"
:aria-label="t('tts.voicePicker.search')"
class="w-full pl-9 pr-3 py-1.5 text-sm bg-background border border-border rounded-md focus:outline-none focus:ring-2 focus:ring-primary transition-shadow"
:value="searchQuery"
@input="handleSearchInput"
@@ -144,16 +155,18 @@ void loadVoices()
<button
class="px-2.5 py-1 rounded text-xs font-medium transition-colors"
:class="activeProviderId === '' ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="activeProviderId = ''"
:aria-pressed="activeProviderId === ''"
@click="handleShowAllProviders"
>
All
{{ t('tts.voicePicker.allProviders') }}
</button>
<button
v-for="provider in providers"
:key="provider.id"
class="px-2.5 py-1 rounded text-xs font-medium transition-colors"
:class="activeProviderId === provider.id ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground'"
@click="activeProviderId = provider.id"
:aria-pressed="activeProviderId === provider.id"
@click="handleSelectProvider(provider.id)"
>
{{ provider.name }}
</button>
@@ -161,16 +174,18 @@ void loadVoices()
<div class="flex flex-1 items-center gap-2 min-w-[200px]">
<select
v-model="languageFilter"
:aria-label="t('tts.voicePicker.filterByLanguage')"
class="flex-1 w-full px-2 py-1.5 text-xs bg-background border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">All languages</option>
<option value="">{{ t('tts.voicePicker.allLanguages') }}</option>
<option v-for="language in languageOptions" :key="language" :value="language">{{ language }}</option>
</select>
<select
v-model="countryFilter"
:aria-label="t('tts.voicePicker.filterByCountry')"
class="flex-1 w-full px-2 py-1.5 text-xs bg-background border border-border rounded-md text-foreground focus:outline-none focus:ring-2 focus:ring-primary"
>
<option value="">All countries</option>
<option value="">{{ t('tts.voicePicker.allCountries') }}</option>
<option v-for="country in countryOptions" :key="country" :value="country">{{ country }}</option>
</select>
</div>
@@ -181,7 +196,9 @@ void loadVoices()
<div v-if="voicesLoading" class="flex items-center justify-center py-12">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
</div>
<div v-else-if="filteredGroups.size === 0" class="text-center py-12 text-muted-foreground text-sm">No voices found</div>
<div v-else-if="filteredGroups.size === 0" class="text-center py-12 text-muted-foreground text-sm">
{{ t('tts.voicePicker.empty') }}
</div>
<div v-else>
<div
v-for="[locale, voices] in filteredGroups"
@@ -202,7 +219,12 @@ void loadVoices()
<div class="text-sm font-medium truncate flex-1 min-w-0">{{ entry.displayName }}</div>
<div class="flex items-center gap-2 flex-shrink-0">
<span v-if="entry.voice.gender" class="text-xs text-muted-foreground">{{ entry.voice.gender }}</span>
<button class="p-1.5 rounded-md hover:bg-background text-muted-foreground" @click.stop="handlePreview(entry.voice)">
<button
type="button"
class="p-1.5 rounded-md hover:bg-background text-muted-foreground"
:aria-label="t('tts.voicePicker.preview', { name: entry.displayName })"
@click.stop="handlePreview(entry.voice)"
>
<Loader2 v-if="previewingVoiceId === entry.voice.id" class="w-4 h-4 animate-spin" />
<Volume2 v-else class="w-4 h-4" />
</button>
@@ -7,6 +7,7 @@ import { useTtsMediaSession } from './useTtsMediaSession'
import { useTtsSleepTimer } from './useTtsSleepTimer'
import { useTtsReadingSession } from './useTtsReadingSession'
import type { TtsCurrentBook } from '../lib/tts-state'
import { i18n } from '@/i18n'
const PREFETCH_AHEAD = 3
const MIN_SPEED = 0.25
@@ -401,7 +402,7 @@ export function useTtsPlayer() {
function handleError(err: unknown) {
flushCurrentPositionSave()
const message = err instanceof Error ? err.message : 'TTS error'
const message = err instanceof Error ? err.message : i18n.global.t('tts.errors.generic')
error.value = message
playbackState.value = 'error'
mediaSession.setPlaybackState('none')
+126 -2
View File
@@ -1423,7 +1423,35 @@
"formModel": "Default model",
"formModelPlaceholder": "tts-1",
"formDiscovery": "This provider can list its own voices",
"formRequired": "Name and base URL are required"
"formRequired": "Name and base URL are required",
"curation": {
"title": "Manage Voices - {provider}",
"summary": "{count, plural, one {# voice configured} other {# voices configured}}",
"summaryUnsaved": "{count, plural, one {# voice configured} other {# voices configured}} - Unsaved changes",
"importFromProvider": "Import from provider",
"loadPreset": "Load {preset} preset",
"importUnsupported": "This provider does not support voice discovery.",
"importUnsupportedWithPreset": "This provider does not support voice discovery. Use the “Load {preset} preset” button instead.",
"discoveryUnsupported": "This provider does not expose a voice list endpoint.",
"discoverFailed": "Failed to discover voices",
"emptyImportOrPreset": "No voices configured. Import from provider or load a preset.",
"emptyImport": "No voices configured. Import from provider.",
"emptyPreset": "No voices configured. Load a preset to get started.",
"emptyManual": "No voices configured. Add voices manually.",
"columnId": "ID",
"columnName": "Name",
"columnLocale": "Locale",
"columnGender": "Gender",
"genderMale": "Male",
"genderFemale": "Female",
"genderUnknown": "Unknown",
"cancelEdit": "Cancel edit",
"editVoice": "Edit {name}",
"deleteVoice": "Delete {name}",
"previewVoice": "Preview {name}",
"stopPreview": "Stop preview for {name}",
"saveVoices": "{count, plural, one {Save # voice} other {Save # voices}}"
}
},
"serverFonts": {
"title": "Server Fonts",
@@ -1444,6 +1472,7 @@
"opds": "OPDS",
"kobo": "Kobo",
"koreader": "KOReader",
"tts": "Text-to-Speech",
"integrations": "Integrations",
"hardcover": "Hardcover",
"admin": "Admin",
@@ -9397,6 +9426,19 @@
"sheet": {
"expand": "Expand panel",
"collapse": "Collapse panel"
},
"narration": {
"fallbackTitle": "Narration",
"meta": "Audio narration - {speed}x",
"openControls": "Open narration controls",
"previousSentence": "Previous sentence",
"nextSentence": "Next sentence",
"play": "Play narration",
"pause": "Pause narration",
"showControls": "Show controls",
"hideControls": "Hide controls",
"stop": "Stop narration",
"error": "Narration playback error"
}
},
"scanner": {
@@ -11443,6 +11485,88 @@
"missingResources": "Missing Resources",
"bookRequests": "Requests",
"bookRequestDetail": "Request",
"bookRequestReleases": "Pick a release"
"bookRequestReleases": "Pick a release",
"tts": "Text-to-Speech"
},
"tts": {
"readerControls": {
"listen": "Listen",
"playing": "Playing"
},
"player": {
"fallbackTitle": "TTS Playback",
"play": "Play TTS",
"pause": "Pause TTS",
"openMiniPlayer": "Open mini player",
"shrinkPlayer": "Shrink player",
"expandPlayer": "Expand player",
"collapsePlayer": "Collapse player",
"openDetails": "Open player details",
"previousSentence": "Previous sentence",
"nextSentence": "Next sentence",
"meta": "Ch {chapter} - Sent {sentence} - {voice} - {speed}x",
"metaWithoutVoice": "Ch {chapter} - Sent {sentence} - {speed}x",
"position": "Chapter {chapter} - Sentence {sentence}",
"playbackError": "Playback error - tap play to retry",
"voice": "Voice",
"sleep": "Sleep",
"sleepRemaining": "{count}m",
"currentVoice": "Current Voice",
"chooseVoice": "Choose voice",
"saveForBook": "Save for this book",
"setAsDefault": "Set as global default",
"bookVoiceSaved": "Voice saved for this book",
"bookVoiceSaveFailed": "Failed to save book voice",
"defaultVoiceSaved": "Default TTS voice updated",
"defaultVoiceSaveFailed": "Failed to update default voice",
"stop": "Stop"
},
"speed": {
"label": "Playback speed",
"value": "{speed}x"
},
"sleepTimer": {
"title": "Sleep Timer",
"preset": "{count}m",
"presetLabel": "Stop playback after {count} minutes",
"cancel": "Cancel",
"cancelLabel": "Cancel sleep timer",
"remaining": "Stops in {minutes}m {seconds}s"
},
"resume": {
"title": "Resume TTS?",
"description": "You have a saved TTS position",
"descriptionWithChapter": "You have a saved TTS position (Chapter {chapter})",
"resume": "Resume",
"startHere": "Start this page",
"dismiss": "Dismiss",
"clearing": "Clearing...",
"confirmClear": "Confirm clear",
"clearSavedPosition": "Clear saved position"
},
"voicePicker": {
"search": "Search voices...",
"allProviders": "All",
"allLanguages": "All languages",
"allCountries": "All countries",
"filterByLanguage": "Filter by language",
"filterByCountry": "Filter by country",
"empty": "No voices found",
"select": "Select {name}",
"preview": "Preview {name}",
"voiceWithLocale": "{voice} - {locale}"
},
"settings": {
"description": "Configure your default TTS voice and playback settings.",
"defaultVoice": "Default Voice",
"selectVoice": "Select a voice...",
"change": "Change",
"defaultSpeed": "Default Speed",
"saved": "TTS preferences saved",
"saveFailed": "Failed to save TTS preferences"
},
"errors": {
"generic": "TTS error"
}
}
}
+1 -1
View File
@@ -457,7 +457,7 @@ export const routes: RouteRecordRaw[] = [
path: 'tts',
name: 'settings-tts',
component: () => import('@/features/tts/TtsSettings.vue'),
meta: { title: 'Text-to-Speech' },
meta: { title: () => t('titles.tts') },
},
...PODCAST_SETTINGS_ROUTES,
+1 -1
View File
@@ -20,7 +20,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?required}
POSTGRES_DB: ${POSTGRES_DB?required}
JWT_SECRET: ${JWT_SECRET?required}
PODCAST_ENCRYPTION_KEY: ${PODCAST_ENCRYPTION_KEY?required}
PODCAST_ENCRYPTION_KEY: ${PODCAST_ENCRYPTION_KEY:-}
SETUP_BOOTSTRAP_TOKEN: ${SETUP_BOOTSTRAP_TOKEN?required}
APP_URL: ${APP_URL?required}
CLIENT_URL: ${CLIENT_URL:-${APP_URL?required}}
+17 -8
View File
@@ -1,3 +1,5 @@
import { APP_FEATURES } from '@bookorbit/types';
import { validateEnv } from './env.validation';
const BASE_ENV = {
@@ -200,15 +202,22 @@ describe('validateEnv', () => {
expect(() => validateEnv({ ...BASE_ENV, NODE_ENV: 'test' })).not.toThrow();
});
it('requires a dedicated podcast encryption key in production', () => {
const productionEnv = {
...BASE_ENV,
NODE_ENV: 'production',
SETUP_BOOTSTRAP_TOKEN: '1234567890abcdef',
};
const PRODUCTION_ENV = {
...BASE_ENV,
NODE_ENV: 'production',
SETUP_BOOTSTRAP_TOKEN: '1234567890abcdef',
};
expect(() => validateEnv(productionEnv)).toThrow('PODCAST_ENCRYPTION_KEY is required in production');
expect(() => validateEnv({ ...productionEnv, PODCAST_ENCRYPTION_KEY: '1234567890abcdef' })).not.toThrow();
it.runIf(APP_FEATURES.podcasts)('requires a dedicated podcast encryption key in production while podcasts are enabled', () => {
expect(() => validateEnv(PRODUCTION_ENV)).toThrow('PODCAST_ENCRYPTION_KEY is required in production');
});
it.skipIf(APP_FEATURES.podcasts)('starts in production without a podcast encryption key while podcasts are disabled', () => {
expect(() => validateEnv(PRODUCTION_ENV)).not.toThrow();
});
it('accepts a dedicated podcast encryption key in production either way', () => {
expect(() => validateEnv({ ...PRODUCTION_ENV, PODCAST_ENCRYPTION_KEY: '1234567890abcdef' })).not.toThrow();
});
it.each([
+6 -1
View File
@@ -3,6 +3,8 @@ import { isAbsolute } from 'node:path';
import { isIP } from 'node:net';
import { z } from 'zod';
import { APP_FEATURES } from '@bookorbit/types';
const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'];
const TRUST_PROXY_BOOLEAN_VALUES = ['true', 'false', 'yes', 'no', 'on', 'off'];
@@ -146,7 +148,10 @@ export function validateEnv(config: Record<string, unknown>) {
if (result.data.NODE_ENV === 'production' && !result.data.SETUP_BOOTSTRAP_TOKEN?.trim()) {
throw new Error('Environment validation failed:\n SETUP_BOOTSTRAP_TOKEN: SETUP_BOOTSTRAP_TOKEN is required in production');
}
if (result.data.NODE_ENV === 'production' && !result.data.PODCAST_ENCRYPTION_KEY) {
// Only gate startup on the key once podcast controllers are actually registered. Requiring it
// while the feature is off turns every existing deployment's upgrade into a boot failure for a
// feature its users cannot reach, and podcastConfig already falls back when it is absent.
if (APP_FEATURES.podcasts && result.data.NODE_ENV === 'production' && !result.data.PODCAST_ENCRYPTION_KEY) {
throw new Error('Environment validation failed:\n PODCAST_ENCRYPTION_KEY: PODCAST_ENCRYPTION_KEY is required in production');
}
return result.data;
@@ -45,13 +45,22 @@ describe('DirectDownloadService', () => {
return new Response(body, { headers: { 'content-type': 'application/epub+zip', ...headers } });
}
/** Waits for the fire-and-forget download, which `add` deliberately does not await. */
/**
* Waits for the fire-and-forget download, which `add` deliberately does not await.
*
* The budget has to be passed: `vi.waitFor` defaults to one second, and a redirect chain pays a
* real `dns.lookup` per hop through `ensureSafeUrl`, so six hops on a slow resolver outlast it
* and the case fails in CI while passing on a warm cache.
*/
async function settle() {
return vi.waitFor(async () => {
const [status] = await service.status([HASH]);
expect(status.state === 'completed' || status.state === 'failed').toBe(true);
return status;
});
return vi.waitFor(
async () => {
const [status] = await service.status([HASH]);
expect(status.state === 'completed' || status.state === 'failed').toBe(true);
return status;
},
{ timeout: SETTLE_MS },
);
}
it('writes the file into its staging directory and reports where it landed', async () => {
+51 -29
View File
@@ -5,7 +5,7 @@ import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
import { ConfigService } from '@nestjs/config';
import { Permission } from '@bookorbit/types';
import { APP_FEATURES, Permission } from '@bookorbit/types';
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { and, eq, inArray } from 'drizzle-orm';
@@ -99,6 +99,16 @@ interface Personas {
}
const currentDir = dirname(fileURLToPath(import.meta.url));
const PODCAST_MODULE_DIR = 'server/src/modules/podcast/';
/**
* Podcast permissions are only ever carried by PodcastModule's guards, so while
* APP_FEATURES.podcasts is off no registered route can prove them and the inventory holds none.
*/
function isFeatureGatedPermission(permission: Permission): boolean {
return !APP_FEATURES.podcasts && permission.startsWith('podcast_');
}
const routeInventory = loadRouteInventory();
const uncoveredRoutes = loadUncoveredRoutes();
@@ -111,12 +121,17 @@ function isSupportedMethod(method: string): method is SupportedHttpMethod {
function loadRouteInventory(): RouteInventory {
const inventoryDir = join(currentDir, 'e2e/authorization-matrix/route-inventory');
const manifest = JSON.parse(readFileSync(join(inventoryDir, 'manifest.json'), 'utf8')) as RouteInventoryManifest;
const routes = manifest.routeChunks.flatMap(
const inventoried = manifest.routeChunks.flatMap(
(chunkFile) => JSON.parse(readFileSync(join(inventoryDir, chunkFile), 'utf8')) as RouteInventoryRoute[],
);
// PodcastModule registers its controllers only when APP_FEATURES.podcasts is on, so with the
// feature off Nest never mounts these paths and every probe below would read the resulting 404
// as a missing guard. The inventory keeps the entries so they return with the feature.
const routes = APP_FEATURES.podcasts ? inventoried : inventoried.filter((route) => !route.file.startsWith(PODCAST_MODULE_DIR));
return {
totalRoutes: manifest.totalRoutes,
totalRoutes: manifest.totalRoutes - (inventoried.length - routes.length),
byPermission: manifest.byPermission,
byLibraryAccess: manifest.byLibraryAccess,
routes,
@@ -660,7 +675,9 @@ describe('Authorization matrix (e2e)', () => {
// so they legitimately never appear on a guard. Every entry needs a service-level test
// proving the capability is actually enforced; keep this list as short as it can be.
const serviceEnforcedPermissions: Permission[] = [Permission.BookRequestAutoApprove];
const enumPermissions = [...Object.values(Permission)].filter((permission) => !serviceEnforcedPermissions.includes(permission)).sort();
const enumPermissions = [...Object.values(Permission)]
.filter((permission) => !serviceEnforcedPermissions.includes(permission) && !isFeatureGatedPermission(permission))
.sort();
expect(inventoryPermissions).toEqual(enumPermissions);
const probes: Record<
@@ -845,7 +862,7 @@ describe('Authorization matrix (e2e)', () => {
const failures: MatrixFailure[] = [];
for (const permission of Object.values(Permission)) {
if (serviceEnforcedPermissions.includes(permission)) continue;
if (serviceEnforcedPermissions.includes(permission) || isFeatureGatedPermission(permission)) continue;
const probe = probes[permission];
const inventoryRoute = routeInventory.routes.find((route) => route.httpMethod === probe.method && route.path === probe.path);
expect(inventoryRoute).toBeTruthy();
@@ -885,6 +902,25 @@ describe('Authorization matrix (e2e)', () => {
const guardedRoutes = routeInventory.routes.filter(
(route) => !route.isPublic && route.libraryAccess.length > 0 && isSupportedMethod(route.httpMethod),
);
const podcastGuardedRoutes = [
'GET /podcast-libraries/:libraryId/podcasts',
'GET /podcast-libraries/:libraryId/episodes',
'POST /podcast-libraries/:libraryId/queue-episodes',
'POST /podcast-libraries/:libraryId/feed-preview',
'POST /podcast-libraries/:libraryId/podcasts',
'POST /podcast-libraries/:libraryId/opml/import',
'POST /podcast-libraries/:libraryId/import-scan',
'GET /podcast-libraries/:libraryId/import-scan/latest',
'POST /podcast-libraries/:libraryId/shows/bulk-purge-preview',
'POST /podcast-libraries/:libraryId/shows/bulk-delete',
'GET /podcast-libraries/:libraryId/opml/export',
'GET /podcast-libraries/:libraryId/settings',
'PATCH /podcast-libraries/:libraryId/settings',
'GET /podcast-libraries/:libraryId/health',
'GET /podcast-libraries/:libraryId/activity',
'GET /podcast-libraries/:libraryId/jobs',
'GET /podcast-libraries/:libraryId/jobs/:jobId',
];
expect(guardedRoutes.map(routeLabel).sort()).toEqual(
[
'GET /libraries/:id',
@@ -898,23 +934,7 @@ describe('Authorization matrix (e2e)', () => {
'GET /libraries/:id/bulk-rename/preview',
'GET /libraries/:id/bulk-rename/status',
'POST /libraries/:id/bulk-rename/execute',
'GET /podcast-libraries/:libraryId/podcasts',
'GET /podcast-libraries/:libraryId/episodes',
'POST /podcast-libraries/:libraryId/queue-episodes',
'POST /podcast-libraries/:libraryId/feed-preview',
'POST /podcast-libraries/:libraryId/podcasts',
'POST /podcast-libraries/:libraryId/opml/import',
'POST /podcast-libraries/:libraryId/import-scan',
'GET /podcast-libraries/:libraryId/import-scan/latest',
'POST /podcast-libraries/:libraryId/shows/bulk-purge-preview',
'POST /podcast-libraries/:libraryId/shows/bulk-delete',
'GET /podcast-libraries/:libraryId/opml/export',
'GET /podcast-libraries/:libraryId/settings',
'PATCH /podcast-libraries/:libraryId/settings',
'GET /podcast-libraries/:libraryId/health',
'GET /podcast-libraries/:libraryId/activity',
'GET /podcast-libraries/:libraryId/jobs',
'GET /podcast-libraries/:libraryId/jobs/:jobId',
...(APP_FEATURES.podcasts ? podcastGuardedRoutes : []),
].sort(),
);
@@ -952,12 +972,14 @@ describe('Authorization matrix (e2e)', () => {
});
expect(bypassResponse.statusCode).toBe(200);
const podcastBypassResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/podcast-libraries/${podcastLibrary.libraryId}/settings`,
headers: authHeader(ctx.adminToken),
});
expect(podcastBypassResponse.statusCode).toBe(200);
if (APP_FEATURES.podcasts) {
const podcastBypassResponse = await ctx.app.inject({
method: 'GET',
url: `/api/v1/podcast-libraries/${podcastLibrary.libraryId}/settings`,
headers: authHeader(ctx.adminToken),
});
expect(podcastBypassResponse.statusCode).toBe(200);
}
}, 120_000);
it('enforces default-password lock with allow-list exceptions', async () => {
@@ -989,7 +1011,7 @@ describe('Authorization matrix (e2e)', () => {
});
});
describe('custom public guards - podcast stream tickets', () => {
describe.skipIf(!APP_FEATURES.podcasts)('custom public guards - podcast stream tickets', () => {
it('rejects an unauthenticated stream request that carries no ticket', async () => {
const response = await ctx.app.inject({ method: 'GET', url: '/api/v1/podcast-episodes/1/stream' });
expect(response.statusCode).toBe(401);
@@ -1,12 +1,12 @@
{
"totalRoutes": 606,
"totalRoutes": 612,
"byPermission": {
"(none)": 259,
"(none)": 260,
"Permission.ManageMetadataConfig": 34,
"Permission.EmailSend": 30,
"Permission.LibraryEditMetadata": 35,
"Permission.BookDockAccess": 17,
"Permission.ManageAppSettings": 57,
"Permission.ManageAppSettings": 62,
"Permission.ManageLibraries": 22,
"Permission.ManageUsers": 13,
"Permission.ManageEmail": 8,
@@ -792,6 +792,66 @@
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
"className": "IndexerController",
"methodName": "listPluginUpdates",
"httpMethod": "GET",
"path": "/admin/request-indexers/plugins/updates",
"permissions": ["Permission.ManageAppSettings"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
"className": "IndexerController",
"methodName": "checkPluginUpdates",
"httpMethod": "POST",
"path": "/admin/request-indexers/plugins/updates/check",
"permissions": ["Permission.ManageAppSettings"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
"className": "IndexerController",
"methodName": "inspectPluginUpdate",
"httpMethod": "POST",
"path": "/admin/request-indexers/plugins/:type/update/inspect",
"permissions": ["Permission.ManageAppSettings"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
"className": "IndexerController",
"methodName": "installPluginUpdate",
"httpMethod": "POST",
"path": "/admin/request-indexers/plugins/:type/update",
"permissions": ["Permission.ManageAppSettings"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/indexers/indexer.controller.ts",
"className": "IndexerController",
"methodName": "setPluginAutomaticUpdate",
"httpMethod": "PUT",
"path": "/admin/request-indexers/plugins/:type/auto-update",
"permissions": ["Permission.ManageAppSettings"],
"libraryAccess": [],
"isPublic": false,
"allowDefault": false,
"useGuards": []
},
{
"file": "server/src/modules/book-request/indexers/indexer-manager.controller.ts",
"className": "IndexerManagerController",