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>
|
</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>
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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}}
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user