mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(books): show read-aloud sync as a compact pill only when it applies
Show the sync control only for a read-along EPUB with audio files or another EPUB copy, so lone read-along EPUBs and plain audiobooks no longer show an Unavailable card. Fold the card into a pill in the format row whose two-track meter shows the state, with the details and toggle in a popover. Your Reading now lists three recent sessions. Fixes #1845
This commit is contained in:
@@ -42,6 +42,8 @@
|
||||
--color-volume-reading: var(--volume-reading);
|
||||
--color-volume-unread: var(--volume-unread);
|
||||
--color-volume-missing: var(--volume-missing);
|
||||
--color-sync-meter-text: var(--sync-meter-text);
|
||||
--color-sync-meter-audio: var(--sync-meter-audio);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
|
||||
@@ -67,6 +67,11 @@
|
||||
--volume-unread: oklch(0.855 var(--tint-c-surface) var(--tint-h));
|
||||
--volume-missing: oklch(0.58 0.22 27);
|
||||
|
||||
/* Read-aloud sync meter: the text and audio tracks drawn in the sync pill. Fills only, so they
|
||||
have to separate from each other and from the card, not clear text contrast. */
|
||||
--sync-meter-text: oklch(0.6 0.15 235);
|
||||
--sync-meter-audio: oklch(0.62 0.21 355);
|
||||
|
||||
/* Metadata score ramp: red at 0, through orange and yellow, to green at 100. Consumers
|
||||
interpolate between these four with color-mix in oklch, so the whole spectrum follows
|
||||
from four tokens. Tuned per theme like every other text colour here: on a light card the
|
||||
@@ -401,6 +406,8 @@
|
||||
--volume-reading: oklch(0.78 0.14 75);
|
||||
--volume-unread: oklch(0.315 var(--tint-c-surface) var(--tint-h));
|
||||
--volume-missing: oklch(0.7 0.19 25);
|
||||
--sync-meter-text: oklch(0.74 0.14 230);
|
||||
--sync-meter-audio: oklch(0.72 0.18 350);
|
||||
|
||||
/* Lifted the way the pills are, with the yellow middle highest so the ramp holds an even
|
||||
weight across the spectrum on a dark card */
|
||||
|
||||
@@ -13,7 +13,7 @@ const props = withDefaults(
|
||||
/** Recent sessions listed under the summary. 0 hides the list entirely. */
|
||||
sessionRows?: number
|
||||
}>(),
|
||||
{ sessions: () => [], loading: false, sessionRows: 4 },
|
||||
{ sessions: () => [], loading: false, sessionRows: 3 },
|
||||
)
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Text and audio positions sit at the same point, joined by a marker. */
|
||||
aligned: boolean
|
||||
muted?: boolean
|
||||
}>()
|
||||
|
||||
const TRACK = 18
|
||||
const textFill = computed(() => (props.aligned ? 11 : 13))
|
||||
const audioFill = computed(() => (props.aligned ? 11 : 6))
|
||||
const textClass = computed(() => (props.muted ? 'fill-muted-foreground' : 'fill-sync-meter-text'))
|
||||
const audioClass = computed(() => (props.muted ? 'fill-muted-foreground' : 'fill-sync-meter-audio'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg :width="TRACK" height="14" :viewBox="`0 0 ${TRACK} 14`" aria-hidden="true" data-test="read-aloud-sync-meter" :data-aligned="aligned">
|
||||
<rect x="0" y="3" :width="TRACK" height="2.6" rx="1.3" class="fill-muted-foreground/35" />
|
||||
<rect x="0" y="8.4" :width="TRACK" height="2.6" rx="1.3" class="fill-muted-foreground/35" />
|
||||
<rect x="0" y="3" :width="textFill" height="2.6" rx="1.3" :class="textClass" />
|
||||
<rect x="0" y="8.4" :width="audioFill" height="2.6" rx="1.3" :class="audioClass" />
|
||||
<rect v-if="aligned" :x="textFill - 0.7" y="1" width="1.4" height="12" rx="0.7" class="fill-foreground" />
|
||||
</svg>
|
||||
</template>
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import ReadAloudSyncMeter from '../ReadAloudSyncMeter.vue'
|
||||
|
||||
function fills(wrapper: ReturnType<typeof mount>) {
|
||||
return wrapper.findAll('rect').map((rect) => ({ width: rect.attributes('width'), className: rect.attributes('class') }))
|
||||
}
|
||||
|
||||
describe('ReadAloudSyncMeter', () => {
|
||||
it('fills both tracks to the same point and joins them with a marker when aligned', () => {
|
||||
const wrapper = mount(ReadAloudSyncMeter, { props: { aligned: true } })
|
||||
const [, , text, audio, marker] = fills(wrapper)
|
||||
|
||||
expect(text!.width).toBe(audio!.width)
|
||||
expect(text!.className).toBe('fill-sync-meter-text')
|
||||
expect(audio!.className).toBe('fill-sync-meter-audio')
|
||||
expect(marker).toBeDefined()
|
||||
})
|
||||
|
||||
it('offsets the tracks without a marker when positions are not shared', () => {
|
||||
const wrapper = mount(ReadAloudSyncMeter, { props: { aligned: false } })
|
||||
const rects = fills(wrapper)
|
||||
|
||||
expect(rects).toHaveLength(4)
|
||||
expect(rects[2]!.width).not.toBe(rects[3]!.width)
|
||||
})
|
||||
|
||||
it('draws both tracks in the muted colour when sync is off', () => {
|
||||
const wrapper = mount(ReadAloudSyncMeter, { props: { aligned: false, muted: true } })
|
||||
const [, , text, audio] = fills(wrapper)
|
||||
|
||||
expect(text!.className).toBe('fill-muted-foreground')
|
||||
expect(audio!.className).toBe('fill-muted-foreground')
|
||||
})
|
||||
})
|
||||
@@ -69,6 +69,7 @@ import HardcoverBookSyncGridItem from '@/features/hardcover/components/Hardcover
|
||||
import StorygraphBookSyncGridItem from '@/features/storygraph/components/StorygraphBookSyncGridItem.vue'
|
||||
import BookEditionsCard, { type EditionProgress } from '@/features/book/components/detail/details/BookEditionsCard.vue'
|
||||
import BookReadingActivityCard from '@/features/book/components/detail/details/BookReadingActivityCard.vue'
|
||||
import ReadAloudSyncMeter from '@/features/book/components/detail/details/ReadAloudSyncMeter.vue'
|
||||
import BookMissingFormatRequest from '@/features/book-requests/components/BookMissingFormatRequest.vue'
|
||||
import { useBookReadingLog } from '@/features/book/composables/useBookReadingLog'
|
||||
import { useProviderLinkSettings } from '@/features/book/composables/useProviderLinkSettings'
|
||||
@@ -378,14 +379,13 @@ const hasMultipleFiles = computed(() => openableFiles.value.length > 1)
|
||||
const readAloudSync = ref<ReadAloudProgressSync>(props.book.readAloudSync)
|
||||
const readAloudSyncSaving = ref(false)
|
||||
const readAloudSyncError = ref<string | null>(null)
|
||||
const showReadAloudSync = computed(() => readAlongFile.value != null || hasAudioFile.value)
|
||||
const hasOtherEpubCopy = computed(() => props.book.files.filter((file) => file.format?.toLowerCase() === 'epub').length > 1)
|
||||
// Sync bridges a read-along EPUB to an audiobook or to other EPUB copies; a book with only one side has nothing to sync.
|
||||
const showReadAloudSync = computed(() => readAlongFile.value != null && (hasAudioFile.value || hasOtherEpubCopy.value))
|
||||
// Another EPUB beside the read-along file keeps its position in sync through the read-along's
|
||||
// narration even when no audiobook can be matched, so only the audiobook half is unavailable.
|
||||
const syncsEpubCopiesOnly = computed(
|
||||
() =>
|
||||
readAloudSync.value.state === 'unavailable' &&
|
||||
readAloudSync.value.unavailableReason !== 'no_media_overlay_epub' &&
|
||||
props.book.files.filter((file) => file.format?.toLowerCase() === 'epub').length > 1,
|
||||
() => readAloudSync.value.state === 'unavailable' && readAloudSync.value.unavailableReason !== 'no_media_overlay_epub' && hasOtherEpubCopy.value,
|
||||
)
|
||||
const readAloudSyncStatus = computed(() =>
|
||||
syncsEpubCopiesOnly.value
|
||||
@@ -402,6 +402,21 @@ const readAloudSyncDescription = computed(() => {
|
||||
const readAloudSyncAudiobookNote = computed(() =>
|
||||
syncsEpubCopiesOnly.value && readAloudSync.value.unavailableReason !== 'no_audio_files' ? readAloudSyncUnavailableReason() : null,
|
||||
)
|
||||
const readAloudSyncChipLabel = computed(() => {
|
||||
if (!syncsEpubCopiesOnly.value) return t(`book.detail.details.readAloudSync.chip.${readAloudSync.value.state}`)
|
||||
return readAloudSyncAudiobookNote.value ? t('book.detail.details.readAloudSync.chip.partial') : t('book.detail.details.readAloudSync.chip.enabled')
|
||||
})
|
||||
const readAloudSyncAligned = computed(
|
||||
() => readAloudSync.value.state === 'enabled' || (syncsEpubCopiesOnly.value && readAloudSyncAudiobookNote.value == null),
|
||||
)
|
||||
const readAloudSyncChipAria = computed(() => t('book.detail.details.readAloudSync.chipAria', { status: readAloudSyncChipLabel.value }))
|
||||
const readAloudSyncNeedsAttention = computed(
|
||||
() => (readAloudSync.value.state === 'unavailable' && !syncsEpubCopiesOnly.value) || readAloudSyncAudiobookNote.value != null,
|
||||
)
|
||||
const readAloudSyncToggleLabel = computed(() => {
|
||||
if (readAloudSyncSaving.value) return t('book.detail.details.readAloudSync.saving')
|
||||
return readAloudSync.value.mode === 'disabled' ? t('book.detail.details.readAloudSync.enable') : t('book.detail.details.readAloudSync.disable')
|
||||
})
|
||||
|
||||
function readAloudSyncUnavailableReason(): string {
|
||||
const reason = readAloudSync.value.unavailableReason ?? 'missing_duration'
|
||||
@@ -1627,6 +1642,52 @@ watch(
|
||||
:primary="entry.primary"
|
||||
class="rounded-xs px-2 py-0.5 text-[10px] tracking-wider"
|
||||
/>
|
||||
<Popover v-if="showReadAloudSync">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
type="button"
|
||||
data-test="read-aloud-sync-trigger"
|
||||
class="inline-flex h-6 items-center overflow-hidden rounded-md border border-border text-[11px] font-semibold text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:aria-label="readAloudSyncChipAria"
|
||||
:title="readAloudSyncChipAria"
|
||||
>
|
||||
<span class="flex size-6 items-center justify-center">
|
||||
<ReadAloudSyncMeter :aligned="readAloudSyncAligned" :muted="readAloudSync.state === 'disabled'" />
|
||||
</span>
|
||||
<span class="flex h-full items-center gap-1.5 border-l border-border/60 bg-background/50 pl-1.5 pr-1">
|
||||
{{ readAloudSyncChipLabel }}
|
||||
<span v-if="readAloudSyncNeedsAttention" data-test="read-aloud-sync-attention" class="size-1.5 shrink-0 rounded-full bg-warning" />
|
||||
<ChevronDown class="size-3 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" class="w-80 max-w-[calc(100vw-2rem)] p-3">
|
||||
<div data-test="read-aloud-sync">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<p class="text-xs font-semibold text-foreground">{{ t('book.detail.details.readAloudSync.title') }}</p>
|
||||
<span class="text-[11px] text-muted-foreground">{{ readAloudSyncStatus }}</span>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-muted-foreground">{{ readAloudSyncDescription }}</p>
|
||||
<p v-if="readAloudSyncAudiobookNote" data-test="read-aloud-sync-audiobook-note" class="mt-1 text-xs text-muted-foreground">
|
||||
{{ readAloudSyncAudiobookNote }}
|
||||
</p>
|
||||
<p v-if="readAloudSyncError" class="mt-1 text-xs text-destructive" role="status" aria-live="polite">
|
||||
{{ readAloudSyncError }}
|
||||
</p>
|
||||
<div class="mt-3 flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
data-test="read-aloud-sync-toggle"
|
||||
class="rounded-md border border-input px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="readAloudSyncSaving"
|
||||
@click="handleToggleReadAloudSync"
|
||||
>
|
||||
{{ readAloudSyncToggleLabel }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div v-if="providerLinks.length || unlinkedCommunityBadges.length" class="flex items-center flex-wrap gap-2 w-full sm:w-auto sm:shrink-0">
|
||||
<div class="hidden sm:block w-px h-3.5 bg-border" />
|
||||
<a
|
||||
@@ -1684,40 +1745,6 @@ watch(
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="showReadAloudSync" data-test="read-aloud-sync" class="rounded-lg border border-border bg-card px-3 py-2.5">
|
||||
<div class="flex items-start gap-3">
|
||||
<Headphones class="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<p class="text-xs font-semibold text-foreground">{{ t('book.detail.details.readAloudSync.title') }}</p>
|
||||
<span class="text-[11px] text-muted-foreground">{{ readAloudSyncStatus }}</span>
|
||||
</div>
|
||||
<p class="mt-0.5 text-xs text-muted-foreground">{{ readAloudSyncDescription }}</p>
|
||||
<p v-if="readAloudSyncAudiobookNote" data-test="read-aloud-sync-audiobook-note" class="mt-0.5 text-xs text-muted-foreground">
|
||||
{{ readAloudSyncAudiobookNote }}
|
||||
</p>
|
||||
<p v-if="readAloudSyncError" class="mt-1 text-xs text-destructive" role="status" aria-live="polite">
|
||||
{{ readAloudSyncError }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-test="read-aloud-sync-toggle"
|
||||
class="shrink-0 rounded-md border border-input px-2.5 py-1 text-xs font-medium text-foreground transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
:disabled="readAloudSyncSaving"
|
||||
@click="handleToggleReadAloudSync"
|
||||
>
|
||||
{{
|
||||
readAloudSyncSaving
|
||||
? t('book.detail.details.readAloudSync.saving')
|
||||
: readAloudSync.mode === 'disabled'
|
||||
? t('book.detail.details.readAloudSync.enable')
|
||||
: t('book.detail.details.readAloudSync.disable')
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Genres + Tags -->
|
||||
<div v-if="book.genres.length || book.tags.length" class="space-y-1">
|
||||
<div v-if="book.genres.length" class="relative">
|
||||
|
||||
@@ -4,6 +4,7 @@ import { defineComponent, ref } from 'vue'
|
||||
import type { BookDetail } from '@bookorbit/types'
|
||||
import DetailsTab from '../DetailsTab.vue'
|
||||
import BookReadingActivityCard from '../../details/BookReadingActivityCard.vue'
|
||||
import ReadAloudSyncMeter from '../../details/ReadAloudSyncMeter.vue'
|
||||
import { useDisplaySettings } from '@/composables/useDisplaySettings'
|
||||
import { useProviderLinkSettings } from '@/features/book/composables/useProviderLinkSettings'
|
||||
|
||||
@@ -447,6 +448,44 @@ describe('DetailsTab cover surface', () => {
|
||||
expect(activityCard.classes().some((className) => className.includes('min-h-0'))).toBe(false)
|
||||
})
|
||||
|
||||
it('lists the three most recent reading sessions', () => {
|
||||
const sessions = Array.from({ length: 5 }, (_, index) => ({
|
||||
id: index + 1,
|
||||
bookFileId: 101,
|
||||
startedAt: `2026-01-0${index + 1}T10:00:00.000Z`,
|
||||
endedAt: `2026-01-0${index + 1}T10:30:00.000Z`,
|
||||
durationSeconds: 1800,
|
||||
progressDelta: 5,
|
||||
endProgress: (index + 1) * 10,
|
||||
format: 'epub',
|
||||
source: null,
|
||||
attemptId: null,
|
||||
}))
|
||||
const wrapper = shallowMount(BookReadingActivityCard, {
|
||||
props: {
|
||||
stats: {
|
||||
totalSessions: 5,
|
||||
totalSeconds: 9000,
|
||||
avgDurationSeconds: 1800,
|
||||
firstSessionAt: sessions[0]!.startedAt,
|
||||
lastSessionAt: sessions[4]!.startedAt,
|
||||
dailySummary: [],
|
||||
paceProgressDelta: 0,
|
||||
paceDurationSeconds: 0,
|
||||
progressSummary: [],
|
||||
latestEndProgress: 50,
|
||||
bySource: [],
|
||||
longestSessionSeconds: 1800,
|
||||
longestSessionAt: sessions[0]!.startedAt,
|
||||
backtrackCount: 0,
|
||||
},
|
||||
sessions,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.findAll('li')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('summarizes pending Kobo sync state for each affected device', async () => {
|
||||
mocks.api.mockImplementation(async (input) => {
|
||||
const url = String(input)
|
||||
@@ -861,6 +900,11 @@ describe('DetailsTab cover surface', () => {
|
||||
const wrapper = mountDetails(book)
|
||||
await flushPromises()
|
||||
|
||||
const trigger = wrapper.get('[data-test="read-aloud-sync-trigger"]')
|
||||
expect(trigger.text()).toBe('Synced')
|
||||
expect(trigger.attributes('aria-label')).toBe('Read-aloud progress sync: Synced')
|
||||
expect(wrapper.getComponent(ReadAloudSyncMeter).props()).toEqual({ aligned: true, muted: false })
|
||||
expect(wrapper.find('[data-test="read-aloud-sync-attention"]').exists()).toBe(false)
|
||||
expect(wrapper.get('[data-test="read-aloud-sync"]').text()).toContain('Enabled')
|
||||
await wrapper.get('[data-test="read-aloud-sync-toggle"]').trigger('click')
|
||||
await flushPromises()
|
||||
@@ -871,6 +915,8 @@ describe('DetailsTab cover surface', () => {
|
||||
body: JSON.stringify({ mode: 'disabled' }),
|
||||
})
|
||||
expect(wrapper.get('[data-test="read-aloud-sync"]').text()).toContain('Disabled')
|
||||
expect(wrapper.get('[data-test="read-aloud-sync-trigger"]').text()).toBe('Sync off')
|
||||
expect(wrapper.getComponent(ReadAloudSyncMeter).props()).toEqual({ aligned: false, muted: true })
|
||||
expect(wrapper.emitted('saved')).toEqual([[updated]])
|
||||
})
|
||||
|
||||
@@ -904,6 +950,8 @@ describe('DetailsTab cover surface', () => {
|
||||
const wrapper = mountDetails(book)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[data-test="read-aloud-sync-trigger"]').text()).toBe('Sync issue')
|
||||
expect(wrapper.find('[data-test="read-aloud-sync-attention"]').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-test="read-aloud-sync"]').text()).toContain('audiobook 1h 6m, read-along EPUB 1h')
|
||||
mocks.api.mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) } as Response)
|
||||
await wrapper.get('[data-test="read-aloud-sync-toggle"]').trigger('click')
|
||||
@@ -945,6 +993,8 @@ describe('DetailsTab cover surface', () => {
|
||||
expect(panel).toContain('EPUB copies only')
|
||||
expect(panel).toContain('Web reader, Kobo, and KOReader positions stay in sync across the EPUB copies.')
|
||||
expect(panel).not.toContain('Unavailable')
|
||||
expect(wrapper.get('[data-test="read-aloud-sync-trigger"]').text()).toBe('Synced')
|
||||
expect(wrapper.find('[data-test="read-aloud-sync-attention"]').exists()).toBe(false)
|
||||
// A missing audiobook is already what the description asks for, so there is nothing to add.
|
||||
expect(wrapper.find('[data-test="read-aloud-sync-audiobook-note"]').exists()).toBe(false)
|
||||
})
|
||||
@@ -957,17 +1007,51 @@ describe('DetailsTab cover surface', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[data-test="read-aloud-sync"]').text()).toContain('EPUB copies only')
|
||||
expect(wrapper.get('[data-test="read-aloud-sync-trigger"]').text()).toBe('Partly synced')
|
||||
expect(wrapper.getComponent(ReadAloudSyncMeter).props()).toEqual({ aligned: false, muted: false })
|
||||
expect(wrapper.get('[data-test="read-aloud-sync-audiobook-note"]').text()).toContain('audiobook 1h 6m, read-along EPUB 1h')
|
||||
expect(wrapper.find('[data-test="read-aloud-sync-attention"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('still reports a lone read-along EPUB without an audiobook as unavailable', async () => {
|
||||
it('shows no sync control for a lone read-along EPUB, which has nothing to sync with', async () => {
|
||||
const wrapper = mountDetails(makeBook({ files: [readAlongEpub], readAloudSync: unavailable('no_audio_files', null) }))
|
||||
await flushPromises()
|
||||
|
||||
const panel = wrapper.get('[data-test="read-aloud-sync"]').text()
|
||||
expect(panel).toContain('Unavailable')
|
||||
expect(panel).toContain('Matching standalone audiobook files are required.')
|
||||
expect(panel).not.toContain('EPUB copies only')
|
||||
expect(wrapper.find('[data-test="read-aloud-sync-trigger"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-test="read-aloud-sync"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
it('shows no sync control for an audiobook without a read-along EPUB', async () => {
|
||||
const wrapper = mountDetails(
|
||||
makeBook({
|
||||
files: [
|
||||
{
|
||||
id: 102,
|
||||
format: 'm4b',
|
||||
role: 'primary',
|
||||
sizeBytes: 5000,
|
||||
absolutePath: '/books/audio.m4b',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
filename: 'audio.m4b',
|
||||
durationSeconds: 3600,
|
||||
},
|
||||
],
|
||||
readAloudSync: {
|
||||
mode: 'auto',
|
||||
state: 'unavailable',
|
||||
unavailableReason: 'no_media_overlay_epub',
|
||||
overlayFileId: null,
|
||||
audioDurationSeconds: 3600,
|
||||
overlayDurationSeconds: null,
|
||||
durationDifferenceSeconds: null,
|
||||
durationDifferenceRatio: null,
|
||||
koreaderDownloadAvailable: false,
|
||||
},
|
||||
}),
|
||||
)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('[data-test="read-aloud-sync-trigger"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6476,6 +6476,13 @@
|
||||
"unavailable": "Unavailable",
|
||||
"epubCopiesOnly": "EPUB copies only"
|
||||
},
|
||||
"chip": {
|
||||
"enabled": "Synced",
|
||||
"partial": "Partly synced",
|
||||
"disabled": "Sync off",
|
||||
"unavailable": "Sync issue"
|
||||
},
|
||||
"chipAria": "Read-aloud progress sync: {status}",
|
||||
"enabledDescription": "Audiobook, web reader, Kobo, and KOReader positions stay in sync.",
|
||||
"disabledDescription": "Each format keeps its own reading position.",
|
||||
"epubCopiesDescription": "Web reader, Kobo, and KOReader positions stay in sync across the EPUB copies. Add matching audiobook files to sync the audiobook too.",
|
||||
|
||||
Reference in New Issue
Block a user