diff --git a/client/src/assets/theme/bridge.css b/client/src/assets/theme/bridge.css index 88673a948..e9646d182 100644 --- a/client/src/assets/theme/bridge.css +++ b/client/src/assets/theme/bridge.css @@ -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); diff --git a/client/src/assets/theme/tokens.css b/client/src/assets/theme/tokens.css index 5ae1ab0a7..046f69ab7 100644 --- a/client/src/assets/theme/tokens.css +++ b/client/src/assets/theme/tokens.css @@ -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 */ diff --git a/client/src/features/book/components/detail/details/BookReadingActivityCard.vue b/client/src/features/book/components/detail/details/BookReadingActivityCard.vue index 7bedd1f51..eb671c88c 100644 --- a/client/src/features/book/components/detail/details/BookReadingActivityCard.vue +++ b/client/src/features/book/components/detail/details/BookReadingActivityCard.vue @@ -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() diff --git a/client/src/features/book/components/detail/details/ReadAloudSyncMeter.vue b/client/src/features/book/components/detail/details/ReadAloudSyncMeter.vue new file mode 100644 index 000000000..0c33ba8b7 --- /dev/null +++ b/client/src/features/book/components/detail/details/ReadAloudSyncMeter.vue @@ -0,0 +1,25 @@ + + + diff --git a/client/src/features/book/components/detail/details/__tests__/ReadAloudSyncMeter.spec.ts b/client/src/features/book/components/detail/details/__tests__/ReadAloudSyncMeter.spec.ts new file mode 100644 index 000000000..c6a391c74 --- /dev/null +++ b/client/src/features/book/components/detail/details/__tests__/ReadAloudSyncMeter.spec.ts @@ -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) { + 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') + }) +}) diff --git a/client/src/features/book/components/detail/tabs/DetailsTab.vue b/client/src/features/book/components/detail/tabs/DetailsTab.vue index e8be046d3..c121dda03 100644 --- a/client/src/features/book/components/detail/tabs/DetailsTab.vue +++ b/client/src/features/book/components/detail/tabs/DetailsTab.vue @@ -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(props.book.readAloudSync) const readAloudSyncSaving = ref(false) const readAloudSyncError = ref(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" /> + + + + + +
+
+

{{ t('book.detail.details.readAloudSync.title') }}

+ {{ readAloudSyncStatus }} +
+

{{ readAloudSyncDescription }}

+

+ {{ readAloudSyncAudiobookNote }} +

+

+ {{ readAloudSyncError }} +

+
+ +
+
+
+
-
-
-
-
-
diff --git a/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts b/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts index 079106653..488d279c3 100644 --- a/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts +++ b/client/src/features/book/components/detail/tabs/__tests__/DetailsTab.spec.ts @@ -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) + }) }) diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 57a873e0e..6ce5163bb 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -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.",