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> </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 ## Live Demo
Try the live instance before you install. No account required. 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)**. 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 ## 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. 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"> <script setup lang="ts">
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { ChevronDown, ChevronUp, Headphones, Pause, Play, SkipBack, SkipForward, X } from '@lucide/vue' import { ChevronDown, ChevronUp, Headphones, Pause, Play, SkipBack, SkipForward, X } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { useMediaOverlay } from '../composables/useMediaOverlay' import { useMediaOverlay } from '../composables/useMediaOverlay'
import { useTtsMiniPlayerUi } from '@/features/tts/composables/useTtsMiniPlayerUi' import { useTtsMiniPlayerUi } from '@/features/tts/composables/useTtsMiniPlayerUi'
import TtsSpeedControl from '@/features/tts/components/TtsSpeedControl.vue' import TtsSpeedControl from '@/features/tts/components/TtsSpeedControl.vue'
import TtsSleepTimerPicker from '@/features/tts/components/TtsSleepTimerPicker.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 { isActive, isPlaying, rate, currentBook, error, sleepTimer, toggle, nextSentence, prevSentence, setRate, stop } = useMediaOverlay()
const { isReaderFooterVisible } = useTtsMiniPlayerUi() const { isReaderFooterVisible } = useTtsMiniPlayerUi()
@@ -43,45 +45,58 @@ function handleStop() {
</div> </div>
<div class="flex items-center gap-2 px-3 py-2.5"> <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"> <button
<img v-if="currentBook?.coverUrl" :src="currentBook.coverUrl" :alt="currentBook?.title" class="w-full h-full object-cover" /> 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" /> <Headphones v-else class="w-4 h-4 text-muted-foreground" />
</button> </button>
<button class="flex-1 min-w-0 text-left" @click="togglePanel"> <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 ?? 'Narration' }}</div> <div class="text-sm font-medium truncate text-foreground">{{ currentBook?.title ?? t('reader.narration.fallbackTitle') }}</div>
<div class="text-xs text-muted-foreground truncate">Audio narration - {{ rate }}x</div> <div class="text-xs text-muted-foreground truncate">{{ t('reader.narration.meta', { speed: rate }) }}</div>
</button> </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" /> <SkipBack class="w-4 h-4" />
</button> </button>
<button <button
class="p-2 rounded-full bg-primary text-primary-foreground hover:bg-primary/90" 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" @click="toggle"
> >
<Pause v-if="isPlaying" class="w-4 h-4" /> <Pause v-if="isPlaying" class="w-4 h-4" />
<Play v-else class="w-4 h-4" /> <Play v-else class="w-4 h-4" />
</button> </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" /> <SkipForward class="w-4 h-4" />
</button> </button>
<button <button
class="p-1.5 rounded-md hover:bg-accent text-muted-foreground" 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" @click="togglePanel"
> >
<ChevronDown v-if="showPanel" class="w-4 h-4" /> <ChevronDown v-if="showPanel" class="w-4 h-4" />
<ChevronUp v-else class="w-4 h-4" /> <ChevronUp v-else class="w-4 h-4" />
</button> </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" /> <X class="w-4 h-4" />
</button> </button>
</div> </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>
</div> </div>
</Transition> </Transition>
@@ -1,4 +1,5 @@
import { ref } from 'vue' import { ref } from 'vue'
import { i18n } from '@/i18n'
import { registerAudioFocusOwner, requestAudioFocus } from '@/lib/audio-focus' import { registerAudioFocusOwner, requestAudioFocus } from '@/lib/audio-focus'
import type { FoliateMediaOverlay } from '@/features/reader/epub/composables/useFoliate' import type { FoliateMediaOverlay } from '@/features/reader/epub/composables/useFoliate'
import { useTtsMediaSession } from '@/features/tts/composables/useTtsMediaSession' import { useTtsMediaSession } from '@/features/tts/composables/useTtsMediaSession'
@@ -102,7 +103,7 @@ export function useMediaOverlay() {
} }
errorListener = (e: Event) => { errorListener = (e: Event) => {
const detail = (e as CustomEvent).detail 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('highlight', highlightListener)
instance.addEventListener('error', errorListener) instance.addEventListener('error', errorListener)
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onUnmounted, ref } from 'vue' import { computed, onUnmounted, ref } from 'vue'
import { AlertCircle, Loader2, Pencil, Plus, RefreshCw, Square, Trash2, Volume2, X } from '@lucide/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 { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import * as ttsApi from './api/tts.api' import * as ttsApi from './api/tts.api'
import type { TtsDbProvider, StaticVoiceConfig } from './api/tts.api' import type { TtsDbProvider, StaticVoiceConfig } from './api/tts.api'
@@ -15,7 +16,15 @@ const emit = defineEmits<{
close: [] close: []
}>() }>()
const { t } = useI18n()
const GENDER_OPTIONS = ['', 'Male', 'Female', 'Unknown'] as const 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 voices = ref<StaticVoiceConfig[]>(props.provider.staticVoices ? [...props.provider.staticVoices] : [])
const savedVoices = 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 if (props.provider.supportsVoiceDiscovery) return null
const presetLabel = presetKey.value === 'kokoro' ? 'Kokoro' : presetKey.value === 'openai' ? 'OpenAI' : null const presetLabel = presetKey.value === 'kokoro' ? 'Kokoro' : presetKey.value === 'openai' ? 'OpenAI' : null
return presetLabel return presetLabel
? `This provider does not support voice discovery. Use the "Load ${presetLabel} preset" button instead.` ? t('settings.admin.tts.curation.importUnsupportedWithPreset', { preset: presetLabel })
: 'This provider does not support voice discovery.' : t('settings.admin.tts.curation.importUnsupported')
}) })
const emptyStateHint = computed(() => { const emptyStateMessage = computed(() => {
const canImport = props.provider.supportsVoiceDiscovery const canImport = props.provider.supportsVoiceDiscovery
const hasPreset = !!presetKey.value const hasPreset = !!presetKey.value
if (canImport && hasPreset) return 'Import from provider or load a preset.' if (canImport && hasPreset) return t('settings.admin.tts.curation.emptyImportOrPreset')
if (canImport) return 'Import from provider.' if (canImport) return t('settings.admin.tts.curation.emptyImport')
if (hasPreset) return 'Load a preset to get started.' if (hasPreset) return t('settings.admin.tts.curation.emptyPreset')
return 'Add voices manually.' return t('settings.admin.tts.curation.emptyManual')
}) })
const hasUnsavedChanges = computed(() => { const hasUnsavedChanges = computed(() => {
@@ -76,12 +85,12 @@ async function handleDiscover() {
try { try {
const result = await ttsApi.discoverVoices(props.provider.id) const result = await ttsApi.discoverVoices(props.provider.id)
if (!result.supported) { if (!result.supported) {
discoverError.value = 'This provider does not expose a voice list endpoint.' discoverError.value = t('settings.admin.tts.curation.discoveryUnsupported')
return return
} }
voices.value = mergeVoices(voices.value, result.voices) voices.value = mergeVoices(voices.value, result.voices)
} catch (e: unknown) { } 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 { } finally {
discovering.value = false discovering.value = false
} }
@@ -196,13 +205,16 @@ function handleClose() {
<!-- Header --> <!-- Header -->
<div class="flex items-center justify-between p-5 border-b border-border flex-shrink-0"> <div class="flex items-center justify-between p-5 border-b border-border flex-shrink-0">
<div> <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"> <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> </p>
</div> </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" /> <X class="w-4 h-4 text-muted-foreground" />
</button> </button>
</div> </div>
@@ -221,7 +233,7 @@ function handleClose() {
> >
<Loader2 v-if="discovering" class="w-3.5 h-3.5 animate-spin" /> <Loader2 v-if="discovering" class="w-3.5 h-3.5 animate-spin" />
<RefreshCw v-else class="w-3.5 h-3.5" /> <RefreshCw v-else class="w-3.5 h-3.5" />
Import from provider {{ t('settings.admin.tts.curation.importFromProvider') }}
</button> </button>
</span> </span>
</TooltipTrigger> </TooltipTrigger>
@@ -233,22 +245,22 @@ function handleClose() {
@click="handleLoadPreset" @click="handleLoadPreset"
> >
<Plus class="w-3.5 h-3.5" /> <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> </button>
</div> </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> </div>
<!-- Voice table --> <!-- Voice table -->
<div class="flex-1 overflow-y-auto min-h-0"> <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> <template v-else>
<div class="text-xs border-b border-border bg-muted/50"> <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 class="grid grid-cols-[1fr_1fr_80px_80px_80px] gap-2 px-4 py-2 font-medium text-muted-foreground">
<div>ID</div> <div>{{ t('settings.admin.tts.curation.columnId') }}</div>
<div>Name</div> <div>{{ t('settings.admin.tts.curation.columnName') }}</div>
<div>Locale</div> <div>{{ t('settings.admin.tts.curation.columnLocale') }}</div>
<div>Gender</div> <div>{{ t('settings.admin.tts.curation.columnGender') }}</div>
<div /> <div />
</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"> <div class="grid grid-cols-[1fr_1fr_80px_80px_80px] gap-2 px-4 py-2 items-center">
<input <input
v-model="editForm.id" 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" 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 <input
v-model="editForm.name" 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" 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 <input
v-model="editForm.locale" v-model="editForm.locale"
placeholder="en-US" 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" 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 <select
v-model="editForm.gender" 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" 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> </select>
<div class="flex items-center gap-1"> <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-primary hover:bg-accent text-xs font-medium" @click="handleSaveEdit">
<button class="p-1 rounded text-muted-foreground hover:bg-accent text-xs" aria-label="Cancel edit" @click="handleCancelEdit"> {{ 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" /> <X class="w-3.5 h-3.5" />
</button> </button>
</div> </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-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-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.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"> <div class="flex items-center gap-1">
<button <button
type="button" type="button"
class="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent" 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)" @click="handlePreview(voice)"
> >
<Loader2 v-if="previewingVoiceId === voice.id" class="w-3.5 h-3.5 animate-spin" /> <Loader2 v-if="previewingVoiceId === voice.id" class="w-3.5 h-3.5 animate-spin" />
@@ -305,12 +331,16 @@ function handleClose() {
</button> </button>
<button <button
class="p-1 rounded text-muted-foreground hover:text-foreground hover:bg-accent" 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)" @click="handleStartEdit(idx)"
> >
<Pencil class="w-3.5 h-3.5" /> <Pencil class="w-3.5 h-3.5" />
</button> </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" /> <Trash2 class="w-3.5 h-3.5" />
</button> </button>
</div> </div>
@@ -324,7 +354,7 @@ function handleClose() {
<!-- Footer --> <!-- Footer -->
<div class="flex justify-end gap-3 p-4 border-t border-border flex-shrink-0"> <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"> <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>
<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" 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" @click="handleSave"
> >
<Loader2 v-if="saving" class="w-4 h-4 animate-spin" /> <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> </button>
</div> </div>
</div> </div>
+17 -9
View File
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref } from 'vue'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useI18n } from 'vue-i18n'
import { Loader2, Save } from '@lucide/vue' import { Loader2, Save } from '@lucide/vue'
import { useTtsPreferences } from './composables/useTtsPreferences' import { useTtsPreferences } from './composables/useTtsPreferences'
import { useTtsVoices } from './composables/useTtsVoices' import { useTtsVoices } from './composables/useTtsVoices'
@@ -8,6 +9,7 @@ import TtsVoicePicker from './components/TtsVoicePicker.vue'
import TtsSpeedControl from './components/TtsSpeedControl.vue' import TtsSpeedControl from './components/TtsSpeedControl.vue'
import { formatVoiceDisplayName, formatVoiceLocaleLabel } from './lib/voice-display' import { formatVoiceDisplayName, formatVoiceLocaleLabel } from './lib/voice-display'
const { t } = useI18n()
const { userPrefs, loadUserPreferences, saveUserPreferences } = useTtsPreferences() const { userPrefs, loadUserPreferences, saveUserPreferences } = useTtsPreferences()
const { allVoices, loadProviders, loadVoices } = useTtsVoices() const { allVoices, loadProviders, loadVoices } = useTtsVoices()
@@ -51,9 +53,9 @@ async function handleSave() {
voiceId: selectedVoiceId.value ?? undefined, voiceId: selectedVoiceId.value ?? undefined,
speed: selectedSpeed.value, speed: selectedSpeed.value,
}) })
toast.success('TTS preferences saved') toast.success(t('tts.settings.saved'))
} catch { } catch {
toast.error('Failed to save TTS preferences') toast.error(t('tts.settings.saveFailed'))
} finally { } finally {
saving.value = false saving.value = false
} }
@@ -72,6 +74,10 @@ function handleSpeedUpdate(speed: number) {
selectedSpeed.value = speed selectedSpeed.value = speed
} }
function handleCloseVoicePicker() {
showVoicePicker.value = false
}
function handleToggleVoicePicker() { function handleToggleVoicePicker() {
showVoicePicker.value = !showVoicePicker.value showVoicePicker.value = !showVoicePicker.value
} }
@@ -80,24 +86,26 @@ function handleToggleVoicePicker() {
<template> <template>
<div class="space-y-4 max-w-2xl"> <div class="space-y-4 max-w-2xl">
<div> <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>
<div class="bg-card border border-border rounded-xl p-5 space-y-5"> <div class="bg-card border border-border rounded-xl p-5 space-y-5">
<div> <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 <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" 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" @click="handleToggleVoicePicker"
> >
<div class="min-w-0 text-left"> <div class="min-w-0 text-left">
<div v-if="selectedVoiceLabel" class="text-foreground truncate">{{ selectedVoiceLabel }}</div> <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"> <div v-if="selectedVoiceLocaleLabel" class="text-xs text-muted-foreground truncate mt-0.5">
{{ selectedVoiceLocaleLabel }} {{ selectedVoiceLocaleLabel }}
</div> </div>
</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> </button>
<div v-if="showVoicePicker" class="mt-2 border border-border rounded-xl overflow-hidden"> <div v-if="showVoicePicker" class="mt-2 border border-border rounded-xl overflow-hidden">
<TtsVoicePicker <TtsVoicePicker
@@ -105,13 +113,13 @@ function handleToggleVoicePicker() {
:selected-voice-id="selectedVoiceId" :selected-voice-id="selectedVoiceId"
@update:selected-voice-id="handleVoiceSelected" @update:selected-voice-id="handleVoiceSelected"
@update:selected-provider-id="handleProviderSelected" @update:selected-provider-id="handleProviderSelected"
@close="showVoicePicker = false" @close="handleCloseVoicePicker"
/> />
</div> </div>
</div> </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" /> <TtsSpeedControl :speed="selectedSpeed" @update:speed="handleSpeedUpdate" />
</div> </div>
@@ -123,7 +131,7 @@ function handleToggleVoicePicker() {
> >
<Loader2 v-if="saving" class="w-4 h-4 animate-spin" /> <Loader2 v-if="saving" class="w-4 h-4 animate-spin" />
<Save v-else class="w-4 h-4" /> <Save v-else class="w-4 h-4" />
Save {{ t('common.save') }}
</button> </button>
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Headphones, Pause, Play, ChevronDown, ChevronUp } from '@lucide/vue' import { Headphones, Pause, Play, ChevronDown, ChevronUp } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { useTtsPlayer } from '../composables/useTtsPlayer' import { useTtsPlayer } from '../composables/useTtsPlayer'
import { useTtsMiniPlayerUi } from '../composables/useTtsMiniPlayerUi' import { useTtsMiniPlayerUi } from '../composables/useTtsMiniPlayerUi'
import { useTtsVoices } from '../composables/useTtsVoices' import { useTtsVoices } from '../composables/useTtsVoices'
@@ -10,6 +11,7 @@ import { formatVoiceDisplayName } from '../lib/voice-display'
const { playbackState, currentBook, currentBlockIndex, currentChapterIndex, speed, currentProviderId, currentVoiceId, togglePlayPause } = const { playbackState, currentBook, currentBlockIndex, currentChapterIndex, speed, currentProviderId, currentVoiceId, togglePlayPause } =
useTtsPlayer() useTtsPlayer()
const { t } = useI18n()
const { mode, isReaderFooterVisible, setMode } = useTtsMiniPlayerUi() const { mode, isReaderFooterVisible, setMode } = useTtsMiniPlayerUi()
const { allVoices, loadVoices } = useTtsVoices() const { allVoices, loadVoices } = useTtsVoices()
const isVisible = computed(() => playbackState.value !== 'idle') const isVisible = computed(() => playbackState.value !== 'idle')
@@ -52,8 +54,9 @@ const selectedVoice = computed(() =>
) )
const compactMeta = computed(() => { const compactMeta = computed(() => {
const voiceName = selectedVoice.value ? formatVoiceDisplayName(selectedVoice.value) : currentVoiceId.value const voice = selectedVoice.value ? formatVoiceDisplayName(selectedVoice.value) : currentVoiceId.value
return [`Ch ${currentChapterIndex.value + 1}`, `Sent ${currentBlockIndex.value + 1}`, voiceName, `${speed.value}x`].filter(Boolean).join(' - ') 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() { function updateIsMobileViewport() {
@@ -201,13 +204,13 @@ onUnmounted(() => {
<button <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" 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'" :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" @click="togglePlayPause"
> >
<img <img
v-if="currentBook?.coverUrl" v-if="currentBook?.coverUrl"
:src="currentBook.coverUrl" :src="currentBook.coverUrl"
:alt="currentBook?.title" alt=""
class="absolute inset-0 w-full h-full object-cover rounded-full opacity-20" 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" /> <Pause v-if="playbackState === 'playing'" class="relative w-6 h-6" />
@@ -215,7 +218,7 @@ onUnmounted(() => {
</button> </button>
<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" 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')" @click="setCompactMode('mini')"
> >
<ChevronUp class="w-3.5 h-3.5" /> <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 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"> <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"> <button
<img v-if="currentBook?.coverUrl" :src="currentBook.coverUrl" :alt="currentBook?.title" class="w-full h-full object-cover" /> 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" /> <Headphones v-else class="w-4 h-4 text-muted-foreground" />
</button> </button>
<button class="flex-1 min-w-0 text-left" @click="openExpanded"> <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 ?? 'TTS Playback' }}</div> <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> <div class="text-xs text-muted-foreground truncate">{{ compactMeta }}</div>
</button> </button>
<button <button
class="p-2 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50" class="p-2 rounded-full bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
:disabled="playbackState === 'loading'" :disabled="playbackState === 'loading'"
:aria-label="playbackState === 'playing' ? t('tts.player.pause') : t('tts.player.play')"
@click="togglePlayPause" @click="togglePlayPause"
> >
<Pause v-if="playbackState === 'playing'" class="w-4 h-4" /> <Pause v-if="playbackState === 'playing'" class="w-4 h-4" />
<Play v-else class="w-4 h-4" /> <Play v-else class="w-4 h-4" />
</button> </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" /> <ChevronDown class="w-4 h-4" />
</button> </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" /> <ChevronUp class="w-4 h-4" />
</button> </button>
</div> </div>
@@ -256,7 +269,7 @@ onUnmounted(() => {
<div v-if="playbackState === 'loading'" class="h-0.5 bg-muted overflow-hidden"> <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 class="h-full bg-primary animate-pulse w-1/2" />
</div> </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> </div>
<TtsMiniPlayerExpanded v-else @close="handleCloseExpanded" /> <TtsMiniPlayerExpanded v-else @close="handleCloseExpanded" />
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { toast } from 'vue-sonner' 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 { ChevronDown, Pause, Play, SkipForward, SkipBack, Headphones, Moon, Gauge, Mic2, Loader2, Book, Star } from '@lucide/vue'
import { useTtsPlayer } from '../composables/useTtsPlayer' import { useTtsPlayer } from '../composables/useTtsPlayer'
import { useTtsPreferences } from '../composables/useTtsPreferences' import { useTtsPreferences } from '../composables/useTtsPreferences'
@@ -12,6 +13,8 @@ import { formatVoiceDisplayName, formatVoiceLocaleLabel } from '../lib/voice-dis
const emit = defineEmits<{ close: [] }>() const emit = defineEmits<{ close: [] }>()
const { t } = useI18n()
const { const {
playbackState, playbackState,
currentBook, currentBook,
@@ -43,8 +46,11 @@ const selectedVoice = computed(() =>
allVoices.value.find((voice) => voice.id === currentVoiceId.value && voice.providerId === currentProviderId.value), allVoices.value.find((voice) => voice.id === currentVoiceId.value && voice.providerId === currentProviderId.value),
) )
const selectedVoiceLabel = computed(() => { const selectedVoiceLabel = computed(() => {
if (!selectedVoice.value) return currentVoiceId.value || 'Choose voice' if (!selectedVoice.value) return currentVoiceId.value || t('tts.player.chooseVoice')
return `${formatVoiceDisplayName(selectedVoice.value)} - ${formatVoiceLocaleLabel(selectedVoice.value)}` return t('tts.voicePicker.voiceWithLocale', {
voice: formatVoiceDisplayName(selectedVoice.value),
locale: formatVoiceLocaleLabel(selectedVoice.value),
})
}) })
async function fetchBookPrefs() { async function fetchBookPrefs() {
@@ -110,6 +116,10 @@ function handleToggleSleepTimer() {
showVoicePicker.value = false showVoicePicker.value = false
} }
function handleCloseVoicePicker() {
showVoicePicker.value = false
}
function handleToggleVoicePicker() { function handleToggleVoicePicker() {
showVoicePicker.value = !showVoicePicker.value showVoicePicker.value = !showVoicePicker.value
showSpeedControl.value = false showSpeedControl.value = false
@@ -141,9 +151,9 @@ async function handleSaveBookVoice() {
providerId: currentProviderId.value, providerId: currentProviderId.value,
voiceId: currentVoiceId.value, voiceId: currentVoiceId.value,
} }
toast.success('Voice saved for this book') toast.success(t('tts.player.bookVoiceSaved'))
} catch { } catch {
toast.error('Failed to save book voice') toast.error(t('tts.player.bookVoiceSaveFailed'))
} finally { } finally {
savingBookVoice.value = false savingBookVoice.value = false
} }
@@ -157,9 +167,9 @@ async function handleSaveDefaultVoice() {
providerId: currentProviderId.value, providerId: currentProviderId.value,
voiceId: currentVoiceId.value, voiceId: currentVoiceId.value,
}) })
toast.success('Default TTS voice updated') toast.success(t('tts.player.defaultVoiceSaved'))
} catch { } catch {
toast.error('Failed to update default voice') toast.error(t('tts.player.defaultVoiceSaveFailed'))
} finally { } finally {
savingDefaultVoice.value = false 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="bg-card border border-border rounded-xl shadow-2xl p-4 space-y-4">
<div class="flex items-start gap-3"> <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"> <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" /> <Headphones v-else class="w-8 h-8 text-muted-foreground" />
</div> </div>
<div class="flex-1 min-w-0"> <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-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"> </div>
<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" /> <ChevronDown class="w-5 h-5" />
</button> </button>
</div> </div>
<div class="flex items-center justify-center gap-4 py-1"> <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" /> <SkipBack class="w-5 h-5" />
</button> </button>
<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" 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'" :disabled="playbackState === 'loading'"
:aria-label="playbackState === 'playing' ? t('tts.player.pause') : t('tts.player.play')"
@click="togglePlayPause" @click="togglePlayPause"
> >
<Pause v-if="playbackState === 'playing'" class="w-6 h-6" /> <Pause v-if="playbackState === 'playing'" class="w-6 h-6" />
<Play v-else class="w-6 h-6" /> <Play v-else class="w-6 h-6" />
</button> </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" /> <SkipForward class="w-5 h-5" />
</button> </button>
</div> </div>
@@ -207,7 +230,7 @@ async function handleSaveDefaultVoice() {
@click="handleToggleVoicePicker" @click="handleToggleVoicePicker"
> >
<Mic2 class="w-4 h-4 flex-shrink-0" /> <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>
<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" 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" @click="handleToggleSpeed"
> >
<Gauge class="w-4 h-4" /> <Gauge class="w-4 h-4" />
<span>{{ speed }}x</span> <span>{{ t('tts.speed.value', { speed }) }}</span>
</button> </button>
<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" 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" /> <Moon class="w-4 h-4" />
<span v-if="sleepTimer.activeMinutes.value !== null && sleepTimer.remainingSeconds.value !== null"> <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>
<span v-else>Sleep</span> <span v-else>{{ t('tts.player.sleep') }}</span>
</button> </button>
</div> </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="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="min-w-0 flex-1">
<div class="flex items-center gap-2 mb-0.5"> <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" /> <Loader2 v-if="voicesLoading" class="w-3 h-3 animate-spin text-muted-foreground" />
</div> </div>
<div class="truncate text-sm font-medium text-foreground">{{ selectedVoiceLabel }}</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-primary/30 text-primary bg-primary/10 hover:bg-primary/20'
: 'border-border text-muted-foreground bg-background hover:bg-accent hover:text-foreground' : '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" :disabled="savingBookVoice || !currentVoiceId"
@click="handleSaveBookVoice" @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-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' : '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" :disabled="savingDefaultVoice || !currentVoiceId"
@click="handleSaveDefaultVoice" @click="handleSaveDefaultVoice"
> >
@@ -278,7 +303,7 @@ async function handleSaveDefaultVoice() {
height-class="h-[min(52vh,360px)]" height-class="h-[min(52vh,360px)]"
@update:selected-voice-id="handleVoiceSelected" @update:selected-voice-id="handleVoiceSelected"
@update:selected-provider-id="handleProviderSelected" @update:selected-provider-id="handleProviderSelected"
@close="showVoicePicker = false" @close="handleCloseVoicePicker"
/> />
</div> </div>
</div> </div>
@@ -287,10 +312,10 @@ async function handleSaveDefaultVoice() {
<div class="flex items-center justify-between border-t border-border pt-3"> <div class="flex items-center justify-between border-t border-border pt-3">
<div class="text-xs text-muted-foreground"> <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> <span> - {{ selectedVoiceLabel }}</span>
</div> </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>
</div> </div>
</template> </template>
@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Headphones } from '@lucide/vue' import { Headphones } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { useTtsPlayer } from '../composables/useTtsPlayer' import { useTtsPlayer } from '../composables/useTtsPlayer'
const props = defineProps<{ const props = defineProps<{
@@ -13,6 +14,7 @@ const props = defineProps<{
const emit = defineEmits<{ startTts: [] }>() const emit = defineEmits<{ startTts: [] }>()
const { t } = useI18n()
const { playbackState, currentBook, isActive } = useTtsPlayer() const { playbackState, currentBook, isActive } = useTtsPlayer()
const isPlayingThisBook = () => isActive.value && currentBook.value?.bookFileId === props.bookFileId const isPlayingThisBook = () => isActive.value && currentBook.value?.bookFileId === props.bookFileId
@@ -29,6 +31,6 @@ function handleClick() {
@click="handleClick" @click="handleClick"
> >
<Headphones class="w-4 h-4" :class="{ 'animate-pulse': isPlayingThisBook() && playbackState === 'playing' }" /> <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> </button>
</template> </template>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { BookmarkCheck, Play, Trash2, X } from '@lucide/vue' import { BookmarkCheck, Play, Trash2, X } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
const props = withDefaults( const props = withDefaults(
defineProps<{ defineProps<{
@@ -14,6 +15,8 @@ const props = withDefaults(
const emit = defineEmits<{ resume: []; playFromHere: []; clearSavedPosition: []; cancel: [] }>() const emit = defineEmits<{ resume: []; playFromHere: []; clearSavedPosition: []; cancel: [] }>()
const confirmClear = ref(false) const confirmClear = ref(false)
const { t } = useI18n()
function handleResume() { function handleResume() {
emit('resume') 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="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 class="w-full flex items-start justify-between gap-2">
<div> <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"> <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>
</div> </div>
<button <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" 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" @click="handleCancel"
> >
<X class="w-4 h-4" /> <X class="w-4 h-4" />
@@ -64,14 +69,14 @@ function handleCancel() {
@click="handleResume" @click="handleResume"
> >
<BookmarkCheck class="w-4 h-4" /> <BookmarkCheck class="w-4 h-4" />
Resume {{ t('tts.resume.resume') }}
</button> </button>
<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" 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" @click="handlePlayFromHere"
> >
<Play class="w-4 h-4" /> <Play class="w-4 h-4" />
Start this page {{ t('tts.resume.startHere') }}
</button> </button>
</div> </div>
<div v-if="confirmClear" class="w-full flex gap-2"> <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" class="flex-1 px-3 py-2 rounded-lg border border-border text-sm font-medium hover:bg-accent transition-colors"
@click="handleCancelClearSavedPosition" @click="handleCancelClearSavedPosition"
> >
Cancel {{ t('common.cancel') }}
</button> </button>
<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" 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" :disabled="props.clearing"
@click="handleClearSavedPosition" @click="handleClearSavedPosition"
> >
{{ props.clearing ? 'Clearing…' : 'Confirm clear' }} {{ props.clearing ? t('tts.resume.clearing') : t('tts.resume.confirmClear') }}
</button> </button>
</div> </div>
<button <button
@@ -95,7 +100,7 @@ function handleCancel() {
@click="handleRequestClearSavedPosition" @click="handleRequestClearSavedPosition"
> >
<Trash2 class="w-4 h-4" /> <Trash2 class="w-4 h-4" />
Clear saved position {{ t('tts.resume.clearSavedPosition') }}
</button> </button>
</div> </div>
</template> </template>
@@ -1,10 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
import type { useTtsSleepTimer } from '../composables/useTtsSleepTimer' import type { useTtsSleepTimer } from '../composables/useTtsSleepTimer'
type SleepTimerInstance = ReturnType<typeof useTtsSleepTimer> type SleepTimerInstance = ReturnType<typeof useTtsSleepTimer>
const props = defineProps<{ sleepTimer: SleepTimerInstance }>() const props = defineProps<{ sleepTimer: SleepTimerInstance }>()
const { t } = useI18n()
function handlePreset(minutes: number) { function handlePreset(minutes: number) {
props.sleepTimer.startTimer(minutes) props.sleepTimer.startTimer(minutes)
} }
@@ -16,7 +19,7 @@ function handleCancel() {
<template> <template>
<div class="space-y-2 pt-2 border-t border-border"> <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"> <div class="flex flex-wrap gap-2">
<button <button
v-for="minutes in props.sleepTimer.presets" v-for="minutes in props.sleepTimer.presets"
@@ -27,20 +30,28 @@ function handleCancel() {
? 'bg-primary text-primary-foreground border-primary' ? 'bg-primary text-primary-foreground border-primary'
: 'border-border hover:bg-accent text-foreground' : '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)" @click="handlePreset(minutes)"
> >
{{ minutes }}m {{ t('tts.sleepTimer.preset', { count: minutes }) }}
</button> </button>
<button <button
v-if="props.sleepTimer.activeMinutes.value !== null" 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" 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" @click="handleCancel"
> >
Cancel {{ t('tts.sleepTimer.cancel') }}
</button> </button>
</div> </div>
<div v-if="props.sleepTimer.remainingSeconds.value !== null" class="text-sm text-muted-foreground"> <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>
</div> </div>
</template> </template>
@@ -1,8 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { useI18n } from 'vue-i18n'
const props = defineProps<{ speed: number }>() const props = defineProps<{ speed: number }>()
const emit = defineEmits<{ 'update:speed': [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 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) { function handleSliderInput(event: Event) {
const value = parseFloat((event.target as HTMLInputElement).value) const value = parseFloat((event.target as HTMLInputElement).value)
@@ -17,20 +23,31 @@ function handlePreset(value: number) {
<template> <template>
<div class="space-y-3 pt-2 border-t border-border"> <div class="space-y-3 pt-2 border-t border-border">
<div class="flex items-center justify-between text-sm text-muted-foreground"> <div class="flex items-center justify-between text-sm text-muted-foreground">
<span>0.25x</span> <span>{{ t('tts.speed.value', { speed: MIN_SPEED }) }}</span>
<span class="font-semibold text-foreground text-base">{{ props.speed }}x</span> <span class="font-semibold text-foreground text-base">{{ t('tts.speed.value', { speed: props.speed }) }}</span>
<span>4x</span> <span>{{ t('tts.speed.value', { speed: MAX_SPEED }) }}</span>
</div> </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"> <div class="flex flex-wrap gap-2 justify-center">
<button <button
v-for="preset in PRESETS" v-for="preset in PRESETS"
:key="preset" :key="preset"
class="px-2.5 py-1 rounded-full text-xs border transition-colors" 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'" :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)" @click="handlePreset(preset)"
> >
{{ preset }}x {{ t('tts.speed.value', { speed: preset }) }}
</button> </button>
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { Search, Volume2, Loader2, Check } from '@lucide/vue' import { Search, Volume2, Loader2, Check } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { useTtsVoices } from '../composables/useTtsVoices' import { useTtsVoices } from '../composables/useTtsVoices'
import type { TtsVoice } from '@bookorbit/types' import type { TtsVoice } from '@bookorbit/types'
import { formatVoiceDisplayName, parseVoiceLanguageCountry } from '../lib/voice-display' import { formatVoiceDisplayName, parseVoiceLanguageCountry } from '../lib/voice-display'
@@ -22,6 +23,7 @@ const emit = defineEmits<{
close: [] close: []
}>() }>()
const { t } = useI18n()
const { allVoices, providers, voicesLoading, loadVoices, loadProviders, previewVoice } = useTtsVoices() const { allVoices, providers, voicesLoading, loadVoices, loadProviders, previewVoice } = useTtsVoices()
const searchQuery = ref('') const searchQuery = ref('')
@@ -118,6 +120,14 @@ function handleSelectVoice(voice: TtsVoice) {
emit('update:selectedProviderId', voice.providerId) emit('update:selectedProviderId', voice.providerId)
} }
function handleSelectProvider(providerId: string) {
activeProviderId.value = providerId
}
function handleShowAllProviders() {
handleSelectProvider('')
}
function handleSearchInput(event: Event) { function handleSearchInput(event: Event) {
searchQuery.value = (event.target as HTMLInputElement).value 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" /> <Search class="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<input <input
type="text" 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" 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" :value="searchQuery"
@input="handleSearchInput" @input="handleSearchInput"
@@ -144,16 +155,18 @@ void loadVoices()
<button <button
class="px-2.5 py-1 rounded text-xs font-medium transition-colors" 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'" :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>
<button <button
v-for="provider in providers" v-for="provider in providers"
:key="provider.id" :key="provider.id"
class="px-2.5 py-1 rounded text-xs font-medium transition-colors" 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'" :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 }} {{ provider.name }}
</button> </button>
@@ -161,16 +174,18 @@ void loadVoices()
<div class="flex flex-1 items-center gap-2 min-w-[200px]"> <div class="flex flex-1 items-center gap-2 min-w-[200px]">
<select <select
v-model="languageFilter" 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" 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> <option v-for="language in languageOptions" :key="language" :value="language">{{ language }}</option>
</select> </select>
<select <select
v-model="countryFilter" 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" 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> <option v-for="country in countryOptions" :key="country" :value="country">{{ country }}</option>
</select> </select>
</div> </div>
@@ -181,7 +196,9 @@ void loadVoices()
<div v-if="voicesLoading" class="flex items-center justify-center py-12"> <div v-if="voicesLoading" class="flex items-center justify-center py-12">
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" /> <Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
</div> </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-else>
<div <div
v-for="[locale, voices] in filteredGroups" 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="text-sm font-medium truncate flex-1 min-w-0">{{ entry.displayName }}</div>
<div class="flex items-center gap-2 flex-shrink-0"> <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> <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" /> <Loader2 v-if="previewingVoiceId === entry.voice.id" class="w-4 h-4 animate-spin" />
<Volume2 v-else class="w-4 h-4" /> <Volume2 v-else class="w-4 h-4" />
</button> </button>
@@ -7,6 +7,7 @@ import { useTtsMediaSession } from './useTtsMediaSession'
import { useTtsSleepTimer } from './useTtsSleepTimer' import { useTtsSleepTimer } from './useTtsSleepTimer'
import { useTtsReadingSession } from './useTtsReadingSession' import { useTtsReadingSession } from './useTtsReadingSession'
import type { TtsCurrentBook } from '../lib/tts-state' import type { TtsCurrentBook } from '../lib/tts-state'
import { i18n } from '@/i18n'
const PREFETCH_AHEAD = 3 const PREFETCH_AHEAD = 3
const MIN_SPEED = 0.25 const MIN_SPEED = 0.25
@@ -401,7 +402,7 @@ export function useTtsPlayer() {
function handleError(err: unknown) { function handleError(err: unknown) {
flushCurrentPositionSave() 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 error.value = message
playbackState.value = 'error' playbackState.value = 'error'
mediaSession.setPlaybackState('none') mediaSession.setPlaybackState('none')
+126 -2
View File
@@ -1423,7 +1423,35 @@
"formModel": "Default model", "formModel": "Default model",
"formModelPlaceholder": "tts-1", "formModelPlaceholder": "tts-1",
"formDiscovery": "This provider can list its own voices", "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": { "serverFonts": {
"title": "Server Fonts", "title": "Server Fonts",
@@ -1444,6 +1472,7 @@
"opds": "OPDS", "opds": "OPDS",
"kobo": "Kobo", "kobo": "Kobo",
"koreader": "KOReader", "koreader": "KOReader",
"tts": "Text-to-Speech",
"integrations": "Integrations", "integrations": "Integrations",
"hardcover": "Hardcover", "hardcover": "Hardcover",
"admin": "Admin", "admin": "Admin",
@@ -9397,6 +9426,19 @@
"sheet": { "sheet": {
"expand": "Expand panel", "expand": "Expand panel",
"collapse": "Collapse 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": { "scanner": {
@@ -11443,6 +11485,88 @@
"missingResources": "Missing Resources", "missingResources": "Missing Resources",
"bookRequests": "Requests", "bookRequests": "Requests",
"bookRequestDetail": "Request", "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', path: 'tts',
name: 'settings-tts', name: 'settings-tts',
component: () => import('@/features/tts/TtsSettings.vue'), component: () => import('@/features/tts/TtsSettings.vue'),
meta: { title: 'Text-to-Speech' }, meta: { title: () => t('titles.tts') },
}, },
...PODCAST_SETTINGS_ROUTES, ...PODCAST_SETTINGS_ROUTES,
+1 -1
View File
@@ -20,7 +20,7 @@ services:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?required} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD?required}
POSTGRES_DB: ${POSTGRES_DB?required} POSTGRES_DB: ${POSTGRES_DB?required}
JWT_SECRET: ${JWT_SECRET?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} SETUP_BOOTSTRAP_TOKEN: ${SETUP_BOOTSTRAP_TOKEN?required}
APP_URL: ${APP_URL?required} APP_URL: ${APP_URL?required}
CLIENT_URL: ${CLIENT_URL:-${APP_URL?required}} CLIENT_URL: ${CLIENT_URL:-${APP_URL?required}}
+13 -4
View File
@@ -1,3 +1,5 @@
import { APP_FEATURES } from '@bookorbit/types';
import { validateEnv } from './env.validation'; import { validateEnv } from './env.validation';
const BASE_ENV = { const BASE_ENV = {
@@ -200,15 +202,22 @@ describe('validateEnv', () => {
expect(() => validateEnv({ ...BASE_ENV, NODE_ENV: 'test' })).not.toThrow(); expect(() => validateEnv({ ...BASE_ENV, NODE_ENV: 'test' })).not.toThrow();
}); });
it('requires a dedicated podcast encryption key in production', () => { const PRODUCTION_ENV = {
const productionEnv = {
...BASE_ENV, ...BASE_ENV,
NODE_ENV: 'production', NODE_ENV: 'production',
SETUP_BOOTSTRAP_TOKEN: '1234567890abcdef', SETUP_BOOTSTRAP_TOKEN: '1234567890abcdef',
}; };
expect(() => validateEnv(productionEnv)).toThrow('PODCAST_ENCRYPTION_KEY is required in production'); it.runIf(APP_FEATURES.podcasts)('requires a dedicated podcast encryption key in production while podcasts are enabled', () => {
expect(() => validateEnv({ ...productionEnv, PODCAST_ENCRYPTION_KEY: '1234567890abcdef' })).not.toThrow(); 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([ it.each([
+6 -1
View File
@@ -3,6 +3,8 @@ import { isAbsolute } from 'node:path';
import { isIP } from 'node:net'; import { isIP } from 'node:net';
import { z } from 'zod'; import { z } from 'zod';
import { APP_FEATURES } from '@bookorbit/types';
const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off']; const BOOLEAN_ENV_VALUES = ['true', 'false', '1', '0', 'yes', 'no', 'on', 'off'];
const TRUST_PROXY_BOOLEAN_VALUES = ['true', 'false', '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()) { 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'); 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'); throw new Error('Environment validation failed:\n PODCAST_ENCRYPTION_KEY: PODCAST_ENCRYPTION_KEY is required in production');
} }
return result.data; return result.data;
@@ -45,13 +45,22 @@ describe('DirectDownloadService', () => {
return new Response(body, { headers: { 'content-type': 'application/epub+zip', ...headers } }); 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() { async function settle() {
return vi.waitFor(async () => { return vi.waitFor(
async () => {
const [status] = await service.status([HASH]); const [status] = await service.status([HASH]);
expect(status.state === 'completed' || status.state === 'failed').toBe(true); expect(status.state === 'completed' || status.state === 'failed').toBe(true);
return status; return status;
}); },
{ timeout: SETTLE_MS },
);
} }
it('writes the file into its staging directory and reports where it landed', async () => { it('writes the file into its staging directory and reports where it landed', async () => {
+41 -19
View File
@@ -5,7 +5,7 @@ import { dirname, join } from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { ConfigService } from '@nestjs/config'; 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 type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { and, eq, inArray } from 'drizzle-orm'; import { and, eq, inArray } from 'drizzle-orm';
@@ -99,6 +99,16 @@ interface Personas {
} }
const currentDir = dirname(fileURLToPath(import.meta.url)); 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 routeInventory = loadRouteInventory();
const uncoveredRoutes = loadUncoveredRoutes(); const uncoveredRoutes = loadUncoveredRoutes();
@@ -111,12 +121,17 @@ function isSupportedMethod(method: string): method is SupportedHttpMethod {
function loadRouteInventory(): RouteInventory { function loadRouteInventory(): RouteInventory {
const inventoryDir = join(currentDir, 'e2e/authorization-matrix/route-inventory'); const inventoryDir = join(currentDir, 'e2e/authorization-matrix/route-inventory');
const manifest = JSON.parse(readFileSync(join(inventoryDir, 'manifest.json'), 'utf8')) as RouteInventoryManifest; 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[], (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 { return {
totalRoutes: manifest.totalRoutes, totalRoutes: manifest.totalRoutes - (inventoried.length - routes.length),
byPermission: manifest.byPermission, byPermission: manifest.byPermission,
byLibraryAccess: manifest.byLibraryAccess, byLibraryAccess: manifest.byLibraryAccess,
routes, routes,
@@ -660,7 +675,9 @@ describe('Authorization matrix (e2e)', () => {
// so they legitimately never appear on a guard. Every entry needs a service-level test // 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. // proving the capability is actually enforced; keep this list as short as it can be.
const serviceEnforcedPermissions: Permission[] = [Permission.BookRequestAutoApprove]; 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); expect(inventoryPermissions).toEqual(enumPermissions);
const probes: Record< const probes: Record<
@@ -845,7 +862,7 @@ describe('Authorization matrix (e2e)', () => {
const failures: MatrixFailure[] = []; const failures: MatrixFailure[] = [];
for (const permission of Object.values(Permission)) { for (const permission of Object.values(Permission)) {
if (serviceEnforcedPermissions.includes(permission)) continue; if (serviceEnforcedPermissions.includes(permission) || isFeatureGatedPermission(permission)) continue;
const probe = probes[permission]; const probe = probes[permission];
const inventoryRoute = routeInventory.routes.find((route) => route.httpMethod === probe.method && route.path === probe.path); const inventoryRoute = routeInventory.routes.find((route) => route.httpMethod === probe.method && route.path === probe.path);
expect(inventoryRoute).toBeTruthy(); expect(inventoryRoute).toBeTruthy();
@@ -885,19 +902,7 @@ describe('Authorization matrix (e2e)', () => {
const guardedRoutes = routeInventory.routes.filter( const guardedRoutes = routeInventory.routes.filter(
(route) => !route.isPublic && route.libraryAccess.length > 0 && isSupportedMethod(route.httpMethod), (route) => !route.isPublic && route.libraryAccess.length > 0 && isSupportedMethod(route.httpMethod),
); );
expect(guardedRoutes.map(routeLabel).sort()).toEqual( const podcastGuardedRoutes = [
[
'GET /libraries/:id',
'POST /libraries/:id/books',
'GET /libraries/:id/stats',
'GET /libraries/:id/recompute-added-at',
'POST /libraries/:id/recompute-added-at',
'POST /libraries/:id/write-metadata-to-files',
'GET /scanner/libraries/:id/scan-history',
'POST /libraries/:id/books/jump-buckets',
'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/podcasts',
'GET /podcast-libraries/:libraryId/episodes', 'GET /podcast-libraries/:libraryId/episodes',
'POST /podcast-libraries/:libraryId/queue-episodes', 'POST /podcast-libraries/:libraryId/queue-episodes',
@@ -915,6 +920,21 @@ describe('Authorization matrix (e2e)', () => {
'GET /podcast-libraries/:libraryId/activity', 'GET /podcast-libraries/:libraryId/activity',
'GET /podcast-libraries/:libraryId/jobs', 'GET /podcast-libraries/:libraryId/jobs',
'GET /podcast-libraries/:libraryId/jobs/:jobId', 'GET /podcast-libraries/:libraryId/jobs/:jobId',
];
expect(guardedRoutes.map(routeLabel).sort()).toEqual(
[
'GET /libraries/:id',
'POST /libraries/:id/books',
'GET /libraries/:id/stats',
'GET /libraries/:id/recompute-added-at',
'POST /libraries/:id/recompute-added-at',
'POST /libraries/:id/write-metadata-to-files',
'GET /scanner/libraries/:id/scan-history',
'POST /libraries/:id/books/jump-buckets',
'GET /libraries/:id/bulk-rename/preview',
'GET /libraries/:id/bulk-rename/status',
'POST /libraries/:id/bulk-rename/execute',
...(APP_FEATURES.podcasts ? podcastGuardedRoutes : []),
].sort(), ].sort(),
); );
@@ -952,12 +972,14 @@ describe('Authorization matrix (e2e)', () => {
}); });
expect(bypassResponse.statusCode).toBe(200); expect(bypassResponse.statusCode).toBe(200);
if (APP_FEATURES.podcasts) {
const podcastBypassResponse = await ctx.app.inject({ const podcastBypassResponse = await ctx.app.inject({
method: 'GET', method: 'GET',
url: `/api/v1/podcast-libraries/${podcastLibrary.libraryId}/settings`, url: `/api/v1/podcast-libraries/${podcastLibrary.libraryId}/settings`,
headers: authHeader(ctx.adminToken), headers: authHeader(ctx.adminToken),
}); });
expect(podcastBypassResponse.statusCode).toBe(200); expect(podcastBypassResponse.statusCode).toBe(200);
}
}, 120_000); }, 120_000);
it('enforces default-password lock with allow-list exceptions', async () => { 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 () => { 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' }); const response = await ctx.app.inject({ method: 'GET', url: '/api/v1/podcast-episodes/1/stream' });
expect(response.statusCode).toBe(401); expect(response.statusCode).toBe(401);
@@ -1,12 +1,12 @@
{ {
"totalRoutes": 606, "totalRoutes": 612,
"byPermission": { "byPermission": {
"(none)": 259, "(none)": 260,
"Permission.ManageMetadataConfig": 34, "Permission.ManageMetadataConfig": 34,
"Permission.EmailSend": 30, "Permission.EmailSend": 30,
"Permission.LibraryEditMetadata": 35, "Permission.LibraryEditMetadata": 35,
"Permission.BookDockAccess": 17, "Permission.BookDockAccess": 17,
"Permission.ManageAppSettings": 57, "Permission.ManageAppSettings": 62,
"Permission.ManageLibraries": 22, "Permission.ManageLibraries": 22,
"Permission.ManageUsers": 13, "Permission.ManageUsers": 13,
"Permission.ManageEmail": 8, "Permission.ManageEmail": 8,
@@ -792,6 +792,66 @@
"allowDefault": false, "allowDefault": false,
"useGuards": [] "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", "file": "server/src/modules/book-request/indexers/indexer-manager.controller.ts",
"className": "IndexerManagerController", "className": "IndexerManagerController",