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-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);
+7
View File
@@ -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>
@@ -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)
})
})
+7
View File
@@ -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.",