fix(cover): disable spine overlay for audiobook covers

Respect audiobook presentation by forcing spine effects off for audio items.
Keep the selected spine mode unchanged for non-audiobook covers.

Ref #147
This commit is contained in:
neon
2026-05-30 02:03:42 +00:00
parent f096f55ca6
commit 0ab342d80b
12 changed files with 140 additions and 12 deletions
@@ -282,6 +282,7 @@ async function handleSetStatus(status: ReadStatus) {
class="book-cover-surface--spine-fitted relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform,ring] duration-150 will-change-transform"
:class="isMissing || selectionMode ? '' : 'group-hover:scale-[1.02]'"
:interactive="!isMissing && !selectionMode"
:disable-spine="isAudiobook"
:style="[{ aspectRatio: coverAspectRatio }, !book.hasCover || !coverLoaded || coverFailed ? coverStyle : {}]"
>
<!-- Missing border overlay: mirror selected overlay pattern so border is never clipped/hidden -->
@@ -309,7 +310,11 @@ async function handleSetStatus(status: ReadStatus) {
@error="coverFailed = true"
/>
<div v-if="book.hasCover && coverLoaded && !coverFailed" class="book-cover-spine-layer absolute z-[3]" :style="fittedCoverSpineStyle" />
<div
v-if="!isAudiobook && book.hasCover && coverLoaded && !coverFailed"
class="book-cover-spine-layer absolute z-[3]"
:style="fittedCoverSpineStyle"
/>
<!-- Skeleton shimmer while a known-cover loads -->
<div v-if="book.hasCover && !coverLoaded && !coverFailed" class="absolute inset-0 animate-pulse bg-white/10" />
@@ -7,16 +7,25 @@ const props = withDefaults(
size?: 'default' | 'mini'
interactive?: boolean
tag?: string
disableSpine?: boolean
}>(),
{
size: 'default',
interactive: false,
tag: 'div',
disableSpine: false,
},
)
const { bookSpineOverlay, bookShadowStrength } = useDisplaySettings()
const spineOverlayMode = computed(() => {
if (props.disableSpine) return 'off'
return bookSpineOverlay?.value ?? 'off'
})
const shadowStrengthMode = computed(() => bookShadowStrength?.value ?? 'default')
const classes = computed(() => [
'book-cover-surface',
props.size === 'mini' ? 'book-cover-surface--mini' : '',
@@ -29,8 +38,8 @@ const classes = computed(() => [
:is="tag"
:class="classes"
:data-cover-size="size"
:data-cover-shadow="bookShadowStrength"
:data-cover-spine="bookSpineOverlay"
:data-cover-shadow="shadowStrengthMode"
:data-cover-spine="spineOverlayMode"
:data-cover-interactive="interactive ? 'true' : 'false'"
>
<slot />
@@ -157,6 +157,7 @@ function openAuthorBrowse() {
<BookCoverSurface
size="mini"
class="book-cover-surface--spine-fitted w-16 rounded shrink-0 overflow-hidden relative"
:disable-spine="isAudiobook"
:class="isMissing ? 'opacity-50 grayscale' : ''"
:style="[{ aspectRatio: coverAspectRatio }, !book.hasCover || !coverLoaded || coverFailed ? coverStyle : {}]"
>
@@ -178,7 +179,11 @@ function openAuthorBrowse() {
@load="handleCoverLoad"
@error="coverFailed = true"
/>
<div v-if="book.hasCover && coverLoaded && !coverFailed" class="book-cover-spine-layer absolute z-[3]" :style="fittedCoverSpineStyle" />
<div
v-if="!isAudiobook && book.hasCover && coverLoaded && !coverFailed"
class="book-cover-spine-layer absolute z-[3]"
:style="fittedCoverSpineStyle"
/>
<BookCoverPlaceholder
v-if="!book.hasCover || coverFailed"
:title="book.title"
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, inject, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import type { BookCard } from '@bookorbit/types'
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
import BookCoverSurface from './BookCoverSurface.vue'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
@@ -25,6 +25,8 @@ const coverIds = computed(() => collapsed.value.coverBookIds.filter((bookId) =>
const tileCount = computed(() => Math.max(coverIds.value.length, 1))
const failedCovers = ref(new Set<number>())
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
function handleCoverError(bookId: number) {
failedCovers.value = new Set([...failedCovers.value, bookId])
@@ -48,6 +50,7 @@ function tileClass(index: number): string {
<BookCoverSurface
class="relative w-full rounded-sm overflow-hidden transition-[box-shadow,transform] duration-150 will-change-transform group-hover:scale-[1.02]"
interactive
:disable-spine="isAudiobook"
:style="{ aspectRatio: coverAspectRatio }"
>
<!-- Adaptive cover mosaic -->
@@ -247,6 +247,18 @@ describe('BookCoverCard — cover style preferences', () => {
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
expect(coverDiv.attributes('data-cover-shadow')).toBe('strong')
})
it('forces spine overlay off for audiobooks even when global spine mode is enabled', async () => {
const wrapper = mountCard({
...presentBook,
files: [{ id: 12, format: 'm4b', role: 'primary', sizeBytes: null }],
})
bookSpineOverlay.value = 'strong'
await nextTick()
const coverDiv = wrapper.find('[style*="aspect-ratio"]')
expect(coverDiv.attributes('data-cover-spine')).toBe('off')
})
})
describe('BookCoverCard — placeholder state', () => {
@@ -44,4 +44,13 @@ describe('BookCoverSurface', () => {
expect(wrapper.attributes('type')).toBe('button')
expect(wrapper.attributes('aria-label')).toBe('cover')
})
it('forces spine overlay off when disableSpine is true', () => {
bookSpineOverlay.value = 'strong'
const wrapper = mount(BookCoverSurface, {
props: { disableSpine: true },
})
expect(wrapper.attributes('data-cover-spine')).toBe('off')
})
})
@@ -1,7 +1,8 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import { afterEach, describe, it, expect, vi } from 'vitest'
import BookListRow from '../BookListRow.vue'
import type { BookCard } from '@bookorbit/types'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
vi.mock('vue-router', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-router')>()
@@ -35,6 +36,12 @@ const globalStubs = {
},
}
const { bookSpineOverlay } = useDisplaySettings()
afterEach(() => {
bookSpineOverlay.value = 'off'
})
const missingBook: BookCard = {
id: 1,
status: 'missing',
@@ -145,4 +152,20 @@ describe('BookListRow — present state', () => {
expect(wrapper.findAll('.fill-lime-400')).toHaveLength(4)
})
it('forces spine overlay off for audiobook rows even when global spine mode is enabled', () => {
bookSpineOverlay.value = 'strong'
const wrapper = mount(BookListRow, {
props: {
book: {
...presentBook,
files: [{ id: 22, format: 'm4b', role: 'primary', sizeBytes: null }],
},
},
global: globalStubs,
})
const cover = wrapper.find('.book-cover-surface')
expect(cover.attributes('data-cover-spine')).toBe('off')
})
})
@@ -1,7 +1,8 @@
import { mount } from '@vue/test-utils'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { BookCard } from '@bookorbit/types'
import CollapsedSeriesCard from '../CollapsedSeriesCard.vue'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
const mockRouterPush = vi.fn<(...args: unknown[]) => unknown>()
@@ -55,11 +56,17 @@ function makeBook(overrides?: Partial<BookCard>): BookCard {
}
}
const { bookSpineOverlay } = useDisplaySettings()
describe('CollapsedSeriesCard', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
bookSpineOverlay.value = 'off'
})
it('renders four cover tiles when all books are represented by covers', () => {
const wrapper = mount(CollapsedSeriesCard, {
props: {
@@ -193,4 +200,18 @@ describe('CollapsedSeriesCard', () => {
expect(wrapper.findAll('[data-testid="series-cover-tile"]')).toHaveLength(4)
expect(wrapper.findAll('[data-testid="series-cover-fallback"]')).toHaveLength(0)
})
it('forces spine overlay off for audiobook series cards', () => {
bookSpineOverlay.value = 'strong'
const wrapper = mount(CollapsedSeriesCard, {
props: {
book: makeBook({
files: [{ id: 5, format: 'm4b', role: 'primary', sizeBytes: null }],
}),
},
})
const cover = wrapper.find('.book-cover-surface')
expect(cover.attributes('data-cover-spine')).toBe('off')
})
})
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Lock, LockOpen } from 'lucide-vue-next'
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
import BookTableCoverCell from './BookTableCoverCell.vue'
import BookTableTextCell from './BookTableTextCell.vue'
import BookTableNumberCell from './BookTableNumberCell.vue'
@@ -14,7 +15,6 @@ import BookTableActionsCell from './BookTableActionsCell.vue'
import BookTableProgressCell from './BookTableProgressCell.vue'
import BookTableMetadataScoreCell from './BookTableMetadataScoreCell.vue'
import BookTableLockableCell from './BookTableLockableCell.vue'
import type { BookCard } from '@bookorbit/types'
import type { CellType, ColumnId } from '@/features/book/composables/tableColumnSchema'
type NavigationDirection = 'next' | 'prev' | 'rowUp' | 'rowDown'
@@ -71,6 +71,9 @@ const lockStateClass = computed(() => {
if (props.lockedFieldCount > 0) return 'text-amber-600/90 hover:text-amber-700 dark:text-amber-400 dark:hover:text-amber-300'
return 'text-muted-foreground/70 hover:text-foreground'
})
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
</script>
<template>
@@ -91,6 +94,7 @@ const lockStateClass = computed(() => {
:book-id="book.id"
:title="book.title"
:has-cover="book.hasCover"
:is-audio="isAudiobook"
@cover-click="emit('coverClick')"
/>
@@ -2,7 +2,7 @@
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { ChevronRight } from 'lucide-vue-next'
import type { BookCard } from '@bookorbit/types'
import { FORMAT_TO_GROUP, type BookCard } from '@bookorbit/types'
import BookCoverSurface from '../BookCoverSurface.vue'
const props = defineProps<{
@@ -18,6 +18,8 @@ const seriesName = computed(() => props.book.seriesName ?? '')
const readCount = computed(() => collapsed.value.readCount)
const bookCount = computed(() => collapsed.value.bookCount)
const coverIds = computed(() => collapsed.value.coverBookIds.filter((id) => id > 0).slice(0, 4))
const primaryFile = computed(() => props.book.files.find((file) => file.role === 'primary') ?? props.book.files[0] ?? null)
const isAudiobook = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
function handleClick() {
if (props.selectionMode) return
@@ -34,10 +36,21 @@ function thumbnailUrl(bookId: number): string {
<div class="flex items-center gap-3 min-w-0">
<!-- Cover row: up to 4 thumbnails -->
<div class="flex shrink-0 gap-0.5">
<BookCoverSurface v-for="coverId in coverIds" :key="coverId" size="mini" class="h-8 w-6 rounded-sm overflow-hidden">
<BookCoverSurface
v-for="coverId in coverIds"
:key="coverId"
size="mini"
:disable-spine="isAudiobook"
class="h-8 w-6 rounded-sm overflow-hidden"
>
<img :src="thumbnailUrl(coverId)" class="h-full w-full object-cover" loading="lazy" alt="" />
</BookCoverSurface>
<BookCoverSurface v-if="coverIds.length === 0" size="mini" class="h-8 w-6 rounded-sm bg-muted flex items-center justify-center">
<BookCoverSurface
v-if="coverIds.length === 0"
size="mini"
:disable-spine="isAudiobook"
class="h-8 w-6 rounded-sm bg-muted flex items-center justify-center"
>
<span class="text-[8px] text-muted-foreground">?</span>
</BookCoverSurface>
</div>
@@ -10,6 +10,7 @@ const props = defineProps<{
bookId: number
title: string | null
hasCover: boolean
isAudio: boolean
}>()
const emit = defineEmits<{ 'cover-click': [] }>()
@@ -67,6 +68,7 @@ const adjustedTop = computed(() => {
<div class="relative" @mouseenter="handleMouseEnter" @mouseleave="handleMouseLeave">
<BookCoverSurface
size="mini"
:disable-spine="isAudio"
tabindex="0"
role="button"
aria-label="View cover"
@@ -1,7 +1,8 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { BookCard } from '@bookorbit/types'
import BookTableCollapsedSeriesRow from '../BookTableCollapsedSeriesRow.vue'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
const mockRouterPush = vi.fn<(...args: unknown[]) => unknown>()
@@ -46,11 +47,17 @@ function makeBook(overrides: Partial<BookCard> = {}): BookCard {
}
}
const { bookSpineOverlay } = useDisplaySettings()
describe('BookTableCollapsedSeriesRow', () => {
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
bookSpineOverlay.value = 'off'
})
it('renders the series name and book count', () => {
const wrapper = mount(BookTableCollapsedSeriesRow, {
props: { book: makeBook(), colspan: 6 },
@@ -153,4 +160,19 @@ describe('BookTableCollapsedSeriesRow', () => {
expect(mockRouterPush).not.toHaveBeenCalled()
})
it('forces spine overlay off for audiobook collapsed rows', () => {
bookSpineOverlay.value = 'strong'
const wrapper = mount(BookTableCollapsedSeriesRow, {
props: {
book: makeBook({
files: [{ id: 9, format: 'm4b', role: 'primary', sizeBytes: null }],
}),
colspan: 6,
},
})
const cover = wrapper.find('.book-cover-surface')
expect(cover.attributes('data-cover-spine')).toBe('off')
})
})