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:
neon
2026-10-20 05:06:50 +00:00
parent 58f258a5fc
commit 435a60288d
8 changed files with 232 additions and 45 deletions
+2
View File
@@ -42,6 +42,8 @@
--color-volume-reading: var(--volume-reading); --color-volume-reading: var(--volume-reading);
--color-volume-unread: var(--volume-unread); --color-volume-unread: var(--volume-unread);
--color-volume-missing: var(--volume-missing); --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-border: var(--border);
--color-input: var(--input); --color-input: var(--input);
--color-ring: var(--ring); --color-ring: var(--ring);
+7
View File
@@ -67,6 +67,11 @@
--volume-unread: oklch(0.855 var(--tint-c-surface) var(--tint-h)); --volume-unread: oklch(0.855 var(--tint-c-surface) var(--tint-h));
--volume-missing: oklch(0.58 0.22 27); --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 /* 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 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 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-reading: oklch(0.78 0.14 75);
--volume-unread: oklch(0.315 var(--tint-c-surface) var(--tint-h)); --volume-unread: oklch(0.315 var(--tint-c-surface) var(--tint-h));
--volume-missing: oklch(0.7 0.19 25); --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 /* 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 */ 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. */ /** Recent sessions listed under the summary. 0 hides the list entirely. */
sessionRows?: number sessionRows?: number
}>(), }>(),
{ sessions: () => [], loading: false, sessionRows: 4 }, { sessions: () => [], loading: false, sessionRows: 3 },
) )
const { t } = useI18n() 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>
@@ -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 StorygraphBookSyncGridItem from '@/features/storygraph/components/StorygraphBookSyncGridItem.vue'
import BookEditionsCard, { type EditionProgress } from '@/features/book/components/detail/details/BookEditionsCard.vue' import BookEditionsCard, { type EditionProgress } from '@/features/book/components/detail/details/BookEditionsCard.vue'
import BookReadingActivityCard from '@/features/book/components/detail/details/BookReadingActivityCard.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 BookMissingFormatRequest from '@/features/book-requests/components/BookMissingFormatRequest.vue'
import { useBookReadingLog } from '@/features/book/composables/useBookReadingLog' import { useBookReadingLog } from '@/features/book/composables/useBookReadingLog'
import { useProviderLinkSettings } from '@/features/book/composables/useProviderLinkSettings' 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 readAloudSync = ref<ReadAloudProgressSync>(props.book.readAloudSync)
const readAloudSyncSaving = ref(false) const readAloudSyncSaving = ref(false)
const readAloudSyncError = ref<string | null>(null) 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 // 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. // narration even when no audiobook can be matched, so only the audiobook half is unavailable.
const syncsEpubCopiesOnly = computed( const syncsEpubCopiesOnly = computed(
() => () => readAloudSync.value.state === 'unavailable' && readAloudSync.value.unavailableReason !== 'no_media_overlay_epub' && hasOtherEpubCopy.value,
readAloudSync.value.state === 'unavailable' &&
readAloudSync.value.unavailableReason !== 'no_media_overlay_epub' &&
props.book.files.filter((file) => file.format?.toLowerCase() === 'epub').length > 1,
) )
const readAloudSyncStatus = computed(() => const readAloudSyncStatus = computed(() =>
syncsEpubCopiesOnly.value syncsEpubCopiesOnly.value
@@ -402,6 +402,21 @@ const readAloudSyncDescription = computed(() => {
const readAloudSyncAudiobookNote = computed(() => const readAloudSyncAudiobookNote = computed(() =>
syncsEpubCopiesOnly.value && readAloudSync.value.unavailableReason !== 'no_audio_files' ? readAloudSyncUnavailableReason() : null, 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 { function readAloudSyncUnavailableReason(): string {
const reason = readAloudSync.value.unavailableReason ?? 'missing_duration' const reason = readAloudSync.value.unavailableReason ?? 'missing_duration'
@@ -1627,6 +1642,52 @@ watch(
:primary="entry.primary" :primary="entry.primary"
class="rounded-xs px-2 py-0.5 text-[10px] tracking-wider" 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 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" /> <div class="hidden sm:block w-px h-3.5 bg-border" />
<a <a
@@ -1684,40 +1745,6 @@ watch(
</div> </div>
</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 --> <!-- Genres + Tags -->
<div v-if="book.genres.length || book.tags.length" class="space-y-1"> <div v-if="book.genres.length || book.tags.length" class="space-y-1">
<div v-if="book.genres.length" class="relative"> <div v-if="book.genres.length" class="relative">
@@ -4,6 +4,7 @@ import { defineComponent, ref } from 'vue'
import type { BookDetail } from '@bookorbit/types' import type { BookDetail } from '@bookorbit/types'
import DetailsTab from '../DetailsTab.vue' import DetailsTab from '../DetailsTab.vue'
import BookReadingActivityCard from '../../details/BookReadingActivityCard.vue' import BookReadingActivityCard from '../../details/BookReadingActivityCard.vue'
import ReadAloudSyncMeter from '../../details/ReadAloudSyncMeter.vue'
import { useDisplaySettings } from '@/composables/useDisplaySettings' import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useProviderLinkSettings } from '@/features/book/composables/useProviderLinkSettings' 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) 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 () => { it('summarizes pending Kobo sync state for each affected device', async () => {
mocks.api.mockImplementation(async (input) => { mocks.api.mockImplementation(async (input) => {
const url = String(input) const url = String(input)
@@ -861,6 +900,11 @@ describe('DetailsTab cover surface', () => {
const wrapper = mountDetails(book) const wrapper = mountDetails(book)
await flushPromises() 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') expect(wrapper.get('[data-test="read-aloud-sync"]').text()).toContain('Enabled')
await wrapper.get('[data-test="read-aloud-sync-toggle"]').trigger('click') await wrapper.get('[data-test="read-aloud-sync-toggle"]').trigger('click')
await flushPromises() await flushPromises()
@@ -871,6 +915,8 @@ describe('DetailsTab cover surface', () => {
body: JSON.stringify({ mode: 'disabled' }), body: JSON.stringify({ mode: 'disabled' }),
}) })
expect(wrapper.get('[data-test="read-aloud-sync"]').text()).toContain('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]]) expect(wrapper.emitted('saved')).toEqual([[updated]])
}) })
@@ -904,6 +950,8 @@ describe('DetailsTab cover surface', () => {
const wrapper = mountDetails(book) const wrapper = mountDetails(book)
await flushPromises() 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') 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) mocks.api.mockResolvedValueOnce({ ok: false, status: 500, json: async () => ({}) } as Response)
await wrapper.get('[data-test="read-aloud-sync-toggle"]').trigger('click') 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('EPUB copies only')
expect(panel).toContain('Web reader, Kobo, and KOReader positions stay in sync across the EPUB copies.') expect(panel).toContain('Web reader, Kobo, and KOReader positions stay in sync across the EPUB copies.')
expect(panel).not.toContain('Unavailable') 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. // 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) expect(wrapper.find('[data-test="read-aloud-sync-audiobook-note"]').exists()).toBe(false)
}) })
@@ -957,17 +1007,51 @@ describe('DetailsTab cover surface', () => {
await flushPromises() await flushPromises()
expect(wrapper.get('[data-test="read-aloud-sync"]').text()).toContain('EPUB copies only') 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.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) })) const wrapper = mountDetails(makeBook({ files: [readAlongEpub], readAloudSync: unavailable('no_audio_files', null) }))
await flushPromises() await flushPromises()
const panel = wrapper.get('[data-test="read-aloud-sync"]').text() expect(wrapper.find('[data-test="read-aloud-sync-trigger"]').exists()).toBe(false)
expect(panel).toContain('Unavailable') expect(wrapper.find('[data-test="read-aloud-sync"]').exists()).toBe(false)
expect(panel).toContain('Matching standalone audiobook files are required.')
expect(panel).not.toContain('EPUB copies only')
}) })
}) })
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)
})
}) })
+7
View File
@@ -6476,6 +6476,13 @@
"unavailable": "Unavailable", "unavailable": "Unavailable",
"epubCopiesOnly": "EPUB copies only" "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.", "enabledDescription": "Audiobook, web reader, Kobo, and KOReader positions stay in sync.",
"disabledDescription": "Each format keeps its own reading position.", "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.", "epubCopiesDescription": "Web reader, Kobo, and KOReader positions stay in sync across the EPUB copies. Add matching audiobook files to sync the audiobook too.",