mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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>
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}}
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user