feat(cover): show each book's cover in the shape of its primary format

Pick the face slot from the primary file's medium instead of the library's
ratio, and send that frame per book on cards, detail, notebook labels and
recommendations. Grids keep uniform tiles, and the library setting becomes
Grid shape.
This commit is contained in:
neon
2026-10-08 06:19:00 +00:00
parent bd141802bd
commit 01b5638ea7
41 changed files with 419 additions and 140 deletions
@@ -1,17 +1,17 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { X } from '@lucide/vue'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import type { BookDetail, CoverMedium } from '@bookorbit/types'
import { useCoverVersions } from '../composables/useCoverVersions'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { useBookCoverAspectRatio } from '../composables/useBookCoverAspectRatio'
import { faceMedium } from '../lib/cover-slots'
import CoverMediumSwitch from './CoverMediumSwitch.vue'
const props = defineProps<{
open: boolean
book: Pick<BookDetail, 'id' | 'title' | 'covers' | 'coverVersion'>
book: Pick<BookDetail, 'id' | 'title' | 'covers' | 'coverVersion' | 'coverAspectRatio'>
/** The slot to open on. Without one the lightbox opens on the face, as the page shows it. */
medium?: CoverMedium | null
/** Unsaved editor images, shown in place of the saved slot they would replace. */
@@ -21,7 +21,7 @@ const emit = defineEmits<{ 'update:open': [open: boolean] }>()
const { t } = useI18n()
const { coverUrl } = useCoverVersions()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const coverAspectRatio = useBookCoverAspectRatio(() => props.book)
function hasImage(medium: CoverMedium): boolean {
return Boolean(props.previews?.[medium]) || props.book.covers[medium] !== null
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, ExternalLink, Eye, Folder, FolderPlus, Headphones, Pencil, Star, Trash2, X } from '@lucide/vue'
import { usePermissions } from '@/features/auth/composables/usePermissions'
@@ -15,7 +15,7 @@ import BookFormatChip from './BookFormatChip.vue'
import { bookFormatEntries, fileFormatKey } from '../lib/book-formats'
import { displayPublishedDate } from '../lib/published-date'
import { FORMAT_TO_GROUP, getPrimaryBookFile } from '@bookorbit/types'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { useBookCoverAspectRatio } from '../composables/useBookCoverAspectRatio'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
@@ -77,7 +77,7 @@ const providerLinks = computed(() => (detail.value ? createBookProviderLinks(det
const safeDescription = useSafeHtml(() => detail.value?.description)
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const coverAspectRatio = useBookCoverAspectRatio(() => detail.value)
const { bookCoverDisplayMode } = useDisplaySettings()
const quickViewCoverAspectRatio = computed(() => {
if (
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { getBookMediaProfile, type BookCard } from '@bookorbit/types'
import { getBookMediaProfile, type BookCard, type CoverAspectRatio } from '@bookorbit/types'
import { Library } from '@lucide/vue'
import BookCoverArtwork from './BookCoverArtwork.vue'
import BookCoverPlaceholder from './BookCoverPlaceholder.vue'
@@ -16,6 +16,8 @@ const STACK_COVER_STEP_PCT = 8
const props = defineProps<{
book: BookCard
/** A grid's own tile shape, which wins over the book's. */
coverAspectRatio?: CoverAspectRatio
showLabel?: boolean
selectionMode?: boolean
}>()
@@ -23,7 +25,7 @@ const props = defineProps<{
const route = useRoute()
const router = useRouter()
const injectedCoverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const coverAspectRatio = computed(() => props.book.coverAspectRatio ?? injectedCoverAspectRatio.value)
const effectiveCoverAspectRatio = computed(() => props.coverAspectRatio ?? props.book.coverAspectRatio ?? injectedCoverAspectRatio.value)
const { coverUrl } = useCoverVersions()
const { seriesCardCoverMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, cardOverlays } = useDisplaySettings()
@@ -31,7 +33,7 @@ const collapsed = computed(() => props.book.collapsedSeries!)
const seriesName = computed(() => props.book.seriesName ?? '')
const authorLine = computed(() => props.book.authors.join(', ') || null)
const authorQuery = computed(() => props.book.authors[0] ?? null)
const hoverTitleClampClass = computed(() => (coverAspectRatio.value === '1/1' ? 'line-clamp-1' : 'line-clamp-2'))
const hoverTitleClampClass = computed(() => (effectiveCoverAspectRatio.value === '1/1' ? 'line-clamp-1' : 'line-clamp-2'))
const showHoverText = computed(() => cardInfoMode.value === 'hover-overlay')
const overlayBackground = computed(() => (cardInfoMode.value === 'hover-overlay' ? 'bg-black/70' : 'bg-black/20'))
const showProgressBar = computed(() => cardOverlays.value.includes('progress-bar'))
@@ -172,7 +174,7 @@ const stackCoverStyles = computed(() =>
right: `${STACK_COVER_STEP_PCT * index}%`,
bottom: `${STACK_COVER_STEP_PCT * index}%`,
width: `${coverWidth}%`,
aspectRatio: coverAspectRatio.value,
aspectRatio: effectiveCoverAspectRatio.value,
zIndex: 100 - index,
boxShadow:
index === 0
@@ -211,7 +213,7 @@ const secondaryLabelText = computed(() => resolveSeriesLabel(gridCardSecondaryLa
interactive
:disable-spine="isAudiobook"
:is-comic="isComic"
:style="{ aspectRatio: coverAspectRatio, ...(isStack ? { backgroundColor: 'transparent', boxShadow: 'none' } : {}) }"
:style="{ aspectRatio: effectiveCoverAspectRatio, ...(isStack ? { backgroundColor: 'transparent', boxShadow: 'none' } : {}) }"
>
<div v-if="isStack" class="absolute inset-0 isolate overflow-hidden" data-testid="series-cover-stack">
<div class="absolute inset-x-[12%] bottom-[5%] h-5 rounded-full bg-black/12 blur-2xl opacity-45" />
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import { mount } from '@vue/test-utils'
import type { BookCard } from '@bookorbit/types'
import { COVER_ASPECT_RATIO_KEY } from '../lib/cover-aspect-ratio'
import VirtualBookGrid from './VirtualBookGrid.vue'
const scrollToItemMock = vi.fn<(index: number, options?: { align?: string }) => void>()
@@ -38,9 +39,9 @@ vi.mock('./BookCoverCard.vue', () => ({
vi.mock('./CollapsedSeriesCard.vue', () => ({
default: {
name: 'CollapsedSeriesCard',
props: ['book', 'showLabel', 'selectionMode'],
props: ['book', 'showLabel', 'selectionMode', 'coverAspectRatio'],
template:
'<div data-testid="collapsed-series-card" :data-selection-mode="String(selectionMode)">{{ book.id }}<span v-if="showLabel" data-testid="series-card-label-slot" /></div>',
'<div data-testid="collapsed-series-card" :data-selection-mode="String(selectionMode)" :data-cover-ratio="coverAspectRatio || \'\'">{{ book.id }}<span v-if="showLabel" data-testid="series-card-label-slot" /></div>',
},
}))
@@ -178,6 +179,24 @@ describe('VirtualBookGrid', () => {
expect(updatedBook?.title).toBe('Updated')
})
it("gives every virtualized tile the grid's shape, whatever the book's own", () => {
const series = makeBook(3, {
coverAspectRatio: '2/3',
collapsedSeries: { bookCount: 2, readCount: 0, coverBookIds: [3], seriesLatestAddedAt: null },
})
const wrapper = mount(VirtualBookGrid, {
props: {
books: [makeBook(1, { coverAspectRatio: '2/3' }), makeBook(2, { coverAspectRatio: '1/1' }), series],
coverSize: 120,
gridGap: 12,
},
global: { provide: { [COVER_ASPECT_RATIO_KEY as symbol]: ref('1/1') } },
})
expect(wrapper.findAll('[data-testid="book-card"]').map((card) => card.attributes('data-cover-ratio'))).toEqual(['1/1', '1/1'])
expect(wrapper.get('[data-testid="collapsed-series-card"]').attributes('data-cover-ratio')).toBe('1/1')
})
it.each([false, true])('forwards selection mode to collapsed series cards when virtualized=%s', (virtualized) => {
const seriesBook = makeBook(4, {
collapsedSeries: {
@@ -328,15 +328,18 @@ defineExpose({ scrollToIndex })
<template #default="{ item }">
<div class="book-grid-cell" :class="{ 'book-grid-cell--new': props.newBookIds.has(item.id) }">
<BookCoverSkeleton v-if="isBookPlaceholder(item)" />
<!-- Every tile takes the grid's shape; art of the other shape follows the cover display setting. -->
<CollapsedSeriesCard
v-else-if="asBook(item).collapsedSeries"
:book="asBook(item)"
:cover-aspect-ratio="coverAspectRatio"
:show-label="showLabel"
:selection-mode="selectionMode"
/>
<BookCoverCard
v-else
:book="asBook(item)"
:cover-aspect-ratio="coverAspectRatio"
:show-label="showLabel"
:selection-mode="selectionMode"
:selected="isSelected?.(item.id) ?? false"
@@ -16,7 +16,7 @@ vi.mock('../../composables/useCoverVersions', () => ({
}),
}))
type LightboxBook = Pick<BookDetail, 'id' | 'title' | 'covers' | 'coverVersion'>
type LightboxBook = Pick<BookDetail, 'id' | 'title' | 'covers' | 'coverVersion' | 'coverAspectRatio'>
function slot(updatedAt: string): BookCoverSlot {
return { source: 'extracted', updatedAt, width: null, height: null }
@@ -71,6 +71,13 @@ describe('BookCoverLightbox', () => {
expect(image().getAttribute('src')).toBe('/api/v1/books/3/cover?t=e1&medium=ebook')
})
it("opens on the book's own face rather than the surrounding view's shape", async () => {
mountLightbox({ book: { ...bothSlots, coverAspectRatio: '1/1' } }, '2/3')
await flushPromises()
expect(image().getAttribute('src')).toBe('/api/v1/books/3/cover?t=a1&medium=audio')
})
it('moves between the slots with the arrow keys', async () => {
mountLightbox()
await flushPromises()
@@ -15,6 +15,7 @@ export interface CarouselBook {
title: string | null
coverAspectRatio: CoverAspectRatio
updatedAt?: string | null
coverVersion?: string
seriesIndex?: string | null
hasCover: boolean
authors: string[]
@@ -157,7 +158,7 @@ defineExpose({ scroll })
:style="{ aspectRatio: cardAspectRatio(book) }"
>
<BookCoverArtwork
:src="coverUrl(book.id, 'thumbnail', book.updatedAt)"
:src="coverUrl(book.id, 'thumbnail', book.coverVersion ?? book.updatedAt)"
:has-cover="book.hasCover"
:title="book.title"
:author-line="book.authors.length > 0 ? book.authors.join(', ') : null"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject, nextTick, onUnmounted, reactive, ref, useId, watch } from 'vue'
import { computed, nextTick, onUnmounted, reactive, ref, useId, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { Image, ImagePlus, Link, Loader2, Lock, LockOpen, Maximize2, RotateCcw, Search, Upload } from '@lucide/vue'
import type { BookDetail, BookMetadataLockField, CoverMedium } from '@bookorbit/types'
@@ -7,7 +7,7 @@ import { hideOnError } from '../../../lib/metadata-fetch'
import { useCoverEditor } from '../../../composables/useCoverEditor'
import { useCoverVersions } from '../../../composables/useCoverVersions'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../../lib/cover-aspect-ratio'
import { useBookCoverAspectRatio } from '../../../composables/useBookCoverAspectRatio'
import { coverFieldMedium, coverLockField, coverTileState, editorTiles } from '../../../lib/cover-slots'
import { nextRovingIndex } from '@/lib/roving-index'
import BookCoverLightbox from '@/features/book/components/BookCoverLightbox.vue'
@@ -23,7 +23,7 @@ const emit = defineEmits<{ coverChanged: [medium: CoverMedium | null]; toggleLoc
const { t } = useI18n()
const { coverUrl } = useCoverVersions()
const { hasPermission } = usePermissions()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const coverAspectRatio = useBookCoverAspectRatio(() => props.book)
const bookIdRef = computed(() => props.book.id)
// One editor per slot, so a pending image on one tile survives selecting the other.
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { formatDate as formatLocaleDate } from '@/i18n/formatters'
import { formatBytes as formatFileSize } from '@/lib/formatting'
@@ -31,7 +31,7 @@ import { faceMedium } from '@/features/book/lib/cover-slots'
import { readingDateToDateKey } from '@/features/book/lib/reading-date'
import { getProviderColor, PROVIDER_SHORT_LABELS } from '@/lib/provider-colors'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
import { useBookCoverAspectRatio } from '@/features/book/composables/useBookCoverAspectRatio'
import { FORMAT_TO_GROUP, getPrimaryBookFile, READER_OPENABLE_FORMATS } from '@bookorbit/types'
import type { BookDetail, BookKoboState, CustomMetadataBookValue, ReadAloudProgressSync, ReadStatus, UserBookStatus } from '@bookorbit/types'
import { STATUS_OPTIONS, STATUS_ICONS, STATUS_COLORS, useBookStatus } from '@/features/book/composables/useBookStatus'
@@ -341,7 +341,7 @@ watch([coverColumnEl, coverActionsEl], ([column, actions]) => {
measureCoverLayout()
})
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const coverAspectRatio = useBookCoverAspectRatio(() => props.book)
const { bookCoverDisplayMode } = useDisplaySettings()
const detailCoverAspectRatio = computed(() => {
if (bookCoverDisplayMode.value !== 'natural-bottom' || !hasCover.value || !coverLoaded.value || coverFailed.value || !coverImageRatio.value) {
@@ -354,7 +354,7 @@ const primaryFile = computed(() => getPrimaryBookFile(props.book.files))
const readAlongFile = computed(() => props.book.files.find((file) => hasReadAlong(file)) ?? null)
const hasAudioFile = computed(() => props.book.files.some((file) => file.format != null && FORMAT_TO_GROUP[file.format] === 'audio'))
const isPrimaryAudio = computed(() => primaryFile.value?.format != null && FORMAT_TO_GROUP[primaryFile.value.format] === 'audio')
// The hero shows the face, whose shape follows the library rather than the primary file once slots exist.
// The hero shows the face: the primary file's slot, or the other one when that slot has no art.
const isFaceAudio = computed(() => {
if (!props.book.covers.ebook && !props.book.covers.audio) return isPrimaryAudio.value
return faceMedium(props.book, coverAspectRatio.value) === 'audio'
@@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed, inject, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { ArrowLeft, Loader2, Pencil, X } from '@lucide/vue'
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
import { DialogClose, DialogContent, DialogDescription, DialogOverlay, DialogPortal, DialogRoot, DialogTitle } from 'reka-ui'
import type { BookCard } from '@bookorbit/types'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../../lib/cover-aspect-ratio'
import { useBookCoverAspectRatio } from '../../composables/useBookCoverAspectRatio'
import { useBookDetail } from '../../composables/useBookDetail'
import { useMetadataLocks } from '../../composables/useMetadataLocks'
import { useCoverVersions } from '../../composables/useCoverVersions'
@@ -31,10 +31,10 @@ const mode = ref<'view' | 'edit'>('view')
const { detail, loading, fetch } = useBookDetail()
const { lockedFields, toggle, load: loadLocks } = useMetadataLocks()
const { coverUrl } = useCoverVersions()
const coverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const editor = ref<InstanceType<typeof CoverEditorPanel> | null>(null)
const currentDetail = computed(() => (detail.value && detail.value.id === props.book?.id ? detail.value : null))
const coverAspectRatio = useBookCoverAspectRatio(() => currentDetail.value ?? props.book)
const hasCoverResolved = computed(() => (currentDetail.value ? currentDetail.value.coverSource !== null : (props.book?.hasCover ?? false)))
const coverVersion = computed(() => currentDetail.value?.coverVersion ?? props.book?.coverVersion ?? null)
@@ -0,0 +1,13 @@
import { computed, inject, ref, type ComputedRef } from 'vue'
import type { CoverAspectRatio } from '@bookorbit/types'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
/**
* The frame of a book's face. The server sends it per book, following the primary file's medium;
* the surrounding view's shape (the library's) serves only until the book has loaded.
*/
export function useBookCoverAspectRatio(book: () => { coverAspectRatio?: CoverAspectRatio } | null | undefined): ComputedRef<CoverAspectRatio> {
const injected = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
return computed(() => book()?.coverAspectRatio ?? injected.value)
}
@@ -119,6 +119,13 @@ describe('mergeBookCardWithDetail', () => {
expect(result.metadataScore).toBe(95)
})
it("takes the detail's cover frame, keeping the card's when the detail has none", () => {
const book = makeBook({ coverAspectRatio: '2/3' })
expect(mergeBookCardWithDetail(book, makeDetail({ coverAspectRatio: '1/1' })).coverAspectRatio).toBe('1/1')
expect(mergeBookCardWithDetail(book, makeDetail()).coverAspectRatio).toBe('2/3')
})
it('maps authors from detail.authors[].name array', () => {
const book = makeBook()
const detail = makeDetail({
@@ -35,6 +35,7 @@ export function mergeBookCardWithDetail(book: BookCard, detail: BookDetail): Boo
readStatus: detail.readStatus,
metadataScore: detail.metadataScore,
hasCover: detail.coverSource !== null,
coverAspectRatio: detail.coverAspectRatio ?? book.coverAspectRatio,
coverVersion: detail.coverVersion,
hasMetadataLocks: detail.lockedFields.length > 0,
lockedFields: detail.lockedFields,
+4 -1
View File
@@ -26,7 +26,10 @@ export function coverLockField(medium: CoverMedium | null): Extract<BookMetadata
return medium === 'audio' ? 'audioCover' : 'cover'
}
/** Mirrors the server's face rule: a square library shows the audio slot, any other shape the ebook slot. */
/**
* The slot a frame of this shape shows. Given the book's own `coverAspectRatio`, which the server
* derives from the slot it serves, this is the server's face.
*/
export function faceMedium(book: Pick<SlotBook, 'covers'>, aspectRatio: CoverAspectRatio | string): CoverMedium {
const preferred: CoverMedium = aspectRatio === '1/1' ? 'audio' : 'ebook'
if (book.covers[preferred]) return preferred
@@ -58,14 +58,14 @@ const iconChoices = computed(() => (props.icon && !SUGGESTED_ICONS.includes(prop
const coverOptions = computed<{ value: CoverAspectRatio; title: string; hint: string; tiles: string[] }[]>(() => [
{
value: '2/3',
title: t('library.creator.details.cover.portraitTitle'),
hint: t('library.creator.details.cover.portraitHint'),
title: t('library.creator.details.gridShape.tallTitle'),
hint: t('library.creator.details.gridShape.tallHint'),
tiles: ['var(--format-ebook)', 'var(--format-document)', 'var(--format-audio)'],
},
{
value: '1/1',
title: t('library.creator.details.cover.squareTitle'),
hint: t('library.creator.details.cover.squareHint'),
title: t('library.creator.details.gridShape.squareTitle'),
hint: t('library.creator.details.gridShape.squareHint'),
tiles: ['var(--format-kindle)', 'var(--format-comic)', 'var(--format-audio)'],
},
])
@@ -193,9 +193,9 @@ function selectPodcastsType() {
</div>
</LibraryCreatorCard>
<LibraryCreatorCard v-if="type === 'books'" :label="t('library.creator.details.coverStyle.title')">
<LibraryCreatorCard v-if="type === 'books'" :label="t('library.creator.details.gridShape.title')">
<fieldset aria-describedby="cover-style-description">
<legend class="sr-only">{{ t('library.creator.details.coverStyle.title') }}</legend>
<legend class="sr-only">{{ t('library.creator.details.gridShape.title') }}</legend>
<div class="grid gap-2.5 @md:grid-cols-2">
<label
v-for="option in coverOptions"
@@ -231,7 +231,7 @@ function selectPodcastsType() {
</span>
</label>
</div>
<p id="cover-style-description" class="mt-2.5 text-xs text-muted-foreground">{{ t('library.creator.details.cover.slotsHint') }}</p>
<p id="cover-style-description" class="mt-2.5 text-xs text-muted-foreground">{{ t('library.creator.details.gridShape.hint') }}</p>
</fieldset>
</LibraryCreatorCard>
</div>
@@ -214,9 +214,9 @@ async function copyPath(folderId: number, path: string) {
</dd>
</div>
<div class="flex items-center gap-3 border-t border-border py-1.5">
<dt class="shrink-0 text-[12.5px] text-muted-foreground">{{ t('settings.admin.libraries.detail.coverShape') }}</dt>
<dt class="shrink-0 text-[12.5px] text-muted-foreground">{{ t('settings.admin.libraries.detail.gridShape') }}</dt>
<dd class="ms-auto text-[12.5px] font-medium text-foreground">
{{ t(`settings.admin.libraries.detail.coverShapeValue.${library.coverAspectRatio === '1/1' ? 'square' : 'portrait'}`) }}
{{ t(`settings.admin.libraries.detail.gridShapeValue.${library.coverAspectRatio === '1/1' ? 'square' : 'tall'}`) }}
</dd>
</div>
</dl>
+13
View File
@@ -1216,6 +1216,11 @@
"portrait": "2:3 portrait",
"square": "1:1 square"
},
"gridShape": "Grid shape",
"gridShapeValue": {
"tall": "Tall cells",
"square": "Square cells"
},
"none": "None",
"allSupported": "All supported",
"patternCount": "{count, plural, one {# pattern} other {# patterns}}",
@@ -8817,6 +8822,14 @@
"squareTitle": "Square 1:1",
"squareHint": "Audiobook-only libraries",
"slotsHint": "A book with both an ebook and an audiobook shows the cover that fits this shape."
},
"gridShape": {
"title": "Grid shape",
"tallTitle": "Tall cells",
"tallHint": "Best for ebooks or mixed libraries",
"squareTitle": "Square cells",
"squareHint": "Best for audiobook libraries",
"hint": "Each book shows the cover of its primary format, square for an audiobook. A cover that doesn't match the tile follows your cover display setting."
}
},
"folders": {
+1
View File
@@ -35,6 +35,7 @@ const { libraries } = useLibraries()
provide(
COVER_ASPECT_RATIO_KEY,
computed(() => {
if (detail.value?.coverAspectRatio) return detail.value.coverAspectRatio
const libraryId = detail.value?.libraryId
const library = libraryId != null ? libraries.value.find((l) => l.id === libraryId) : null
return library?.coverAspectRatio ?? DEFAULT_COVER_ASPECT_RATIO