Lazy bookshelf

This commit is contained in:
advplyr 2021-11-28 19:36:44 -06:00
parent 3941da1144
commit 4587916c8e
11 changed files with 669 additions and 31 deletions

View file

@ -442,17 +442,6 @@ export default {
this.init()
this.initIO()
setTimeout(() => {
var ids = {}
this.audiobooks.forEach((ab) => {
if (ids[ab.id]) {
console.error('FOUDN DUPLICATE ID', ids[ab.id], ab)
} else {
ids[ab.id] = ab
}
})
}, 5000)
},
beforeDestroy() {
window.removeEventListener('resize', this.resize)

View file

@ -0,0 +1,233 @@
<template>
<div id="bookshelf" class="w-full overflow-y-auto">
<template v-for="shelf in totalShelves">
<div :key="shelf" class="w-full px-8 bookshelfRow relative" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
<div class="absolute top-0 left-0 bottom-0 p-4 z-10">
<p class="text-white text-2xl">{{ shelf }}</p>
</div>
<div class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-10" :class="`h-${shelfDividerHeightIndex}`" />
</div>
</template>
</div>
</template>
<script>
import Vue from 'vue'
import LazyBookCard from '../cards/LazyBookCard'
export default {
data() {
return {
initialized: false,
bookshelfHeight: 0,
bookshelfWidth: 0,
shelvesPerPage: 0,
booksPerShelf: 8,
currentPage: 0,
totalBooks: 0,
books: [],
pagesLoaded: {},
bookIndexesMounted: [],
bookComponentRefs: {},
bookWidth: 120,
pageLoadQueue: [],
isFetchingBooks: false,
scrollTimeout: null,
booksPerFetch: 100,
totalShelves: 0,
bookshelfMarginLeft: 0
}
},
computed: {
sortBy() {
return this.$store.getters['user/getUserSetting']('orderBy')
},
sortDesc() {
return this.$store.getters['user/getUserSetting']('orderDesc')
},
filterBy() {
return this.$store.getters['user/getUserSetting']('filterBy')
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
bookHeight() {
return this.bookWidth * 1.6
},
shelfDividerHeightIndex() {
return 6
},
shelfHeight() {
return this.bookHeight + 40
},
totalBookCardWidth() {
// Includes margin
return this.bookWidth + 24
},
booksPerPage() {
return this.shelvesPerPage * this.booksPerShelf
}
},
methods: {
async fetchBooks(page = 0) {
var startIndex = page * this.booksPerFetch
this.isFetchingBooks = true
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/books/all?sort=${this.sortBy}&desc=${this.sortDesc}&filter=${this.filterBy}&limit=${this.booksPerFetch}&page=${page}`).catch((error) => {
console.error('failed to fetch books', error)
return null
})
if (payload) {
console.log('Received payload with start index', startIndex, 'pages loaded', this.pagesLoaded)
if (!this.initialized) {
this.initialized = true
this.totalBooks = payload.total
this.totalShelves = Math.ceil(this.totalBooks / this.booksPerShelf)
this.books = new Array(this.totalBooks)
}
for (let i = 0; i < payload.results.length; i++) {
var bookIndex = i + startIndex
this.books[bookIndex] = payload.results[i]
if (this.bookComponentRefs[bookIndex]) {
this.bookComponentRefs[bookIndex].setBook(this.books[bookIndex])
}
}
}
},
loadPage(page) {
this.pagesLoaded[page] = true
this.fetchBooks(page)
},
async mountBookCard(index) {
var shelf = Math.floor(index / this.booksPerShelf)
var shelfEl = document.getElementById(`shelf-${shelf}`)
if (!shelfEl) {
console.error('invalid shelf', shelf)
return
}
this.bookIndexesMounted.push(index)
if (this.bookComponentRefs[index] && !this.bookIndexesMounted.includes(index)) {
shelfEl.appendChild(this.bookComponentRefs[index].$el)
return
}
var shelfOffsetY = 16
var row = index % this.booksPerShelf
var shelfOffsetX = row * this.totalBookCardWidth + this.bookshelfMarginLeft
var ComponentClass = Vue.extend(LazyBookCard)
var _this = this
var instance = new ComponentClass({
propsData: {
index: index,
bookWidth: this.bookWidth
},
created() {
// this.$on('action', (func) => {
// if (_this[func]) _this[func]()
// })
}
})
this.bookComponentRefs[index] = instance
instance.$mount()
instance.$el.style.transform = `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)`
shelfEl.appendChild(instance.$el)
if (this.books[index]) {
instance.setBook(this.books[index])
}
},
showHideBookPlaceholder(index, show) {
var el = document.getElementById(`book-${index}-placeholder`)
if (el) el.style.display = show ? 'flex' : 'none'
},
unmountBookCard(index) {
if (this.bookComponentRefs[index]) {
this.bookComponentRefs[index].detach()
}
},
mountBooks(fromIndex, toIndex) {
for (let i = fromIndex; i < toIndex; i++) {
this.mountBookCard(i)
}
},
handleScroll(scrollTop) {
var firstShelfIndex = Math.floor(scrollTop / this.shelfHeight)
var lastShelfIndex = Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight)
var topShelfPage = Math.floor(firstShelfIndex / this.shelvesPerPage)
var bottomShelfPage = Math.floor(lastShelfIndex / this.shelvesPerPage)
if (!this.pagesLoaded[topShelfPage]) {
this.loadPage(topShelfPage)
}
if (!this.pagesLoaded[bottomShelfPage]) {
this.loadPage(bottomShelfPage)
}
console.log('Shelves in view', firstShelfIndex, 'to', lastShelfIndex)
var firstBookIndex = firstShelfIndex * this.booksPerShelf
var lastBookIndex = lastShelfIndex * this.booksPerShelf + this.booksPerShelf
this.bookIndexesMounted = this.bookIndexesMounted.filter((_index) => {
if (_index < firstBookIndex || _index >= lastBookIndex) {
var el = document.getElementById(`book-card-${_index}`)
if (el) el.remove()
return false
}
return true
})
this.mountBooks(firstBookIndex, lastBookIndex)
},
scroll(e) {
if (!e || !e.target) return
var { scrollTop } = e.target
// clearTimeout(this.scrollTimeout)
// this.scrollTimeout = setTimeout(() => {
this.handleScroll(scrollTop)
// }, 250)
},
async init(bookshelf) {
var { clientHeight, clientWidth } = bookshelf
this.bookshelfHeight = clientHeight
this.bookshelfWidth = clientWidth
this.booksPerShelf = Math.floor((this.bookshelfWidth - 64) / this.totalBookCardWidth)
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
this.bookshelfMarginLeft = (this.bookshelfWidth - this.booksPerShelf * this.totalBookCardWidth) / 2
console.log('Shelves per page', this.shelvesPerPage, 'books per page =', this.booksPerShelf * this.shelvesPerPage)
this.pagesLoaded[0] = true
await this.fetchBooks(0)
var lastBookIndex = this.shelvesPerPage * this.booksPerShelf
this.mountBooks(0, lastBookIndex)
}
},
mounted() {
var bookshelf = document.getElementById('bookshelf')
if (bookshelf) {
this.init(bookshelf)
bookshelf.addEventListener('scroll', this.scroll)
}
},
beforeDestroy() {
var bookshelf = document.getElementById('bookshelf')
if (bookshelf) {
bookshelf.removeEventListener('scroll', this.scroll)
}
}
}
</script>
<style>
.bookshelfRow {
background-image: var(--bookshelf-texture-img);
}
.bookshelfDivider {
background: rgb(149, 119, 90);
background: var(--bookshelf-divider-bg);
box-shadow: 2px 14px 8px #111111aa;
}
</style>

View file

@ -0,0 +1,302 @@
<template>
<div ref="card" :id="`book-card-${index}`" :style="{ width: bookWidth + 'px', height: bookHeight + 'px' }" class="absolute top-0 left-0 rounded-sm z-20">
<div class="w-full h-full bg-primary relative rounded-sm">
<div class="absolute top-0 left-0 w-full flex items-center justify-center">
<p>{{ title }}/{{ index }}</p>
</div>
<img v-show="audiobook" :src="bookCoverSrc" class="w-full h-full object-contain" />
<!-- <covers-book-cover v-show="audiobook" :audiobook="audiobook" :width="bookWidth" /> -->
</div>
<!-- <div ref="overlay-wrapper" class="w-full h-full relative box-shadow-book cursor-pointer" @click="clickCard" @mouseover="mouseover" @mouseleave="isHovering = false">
<covers-book-cover :audiobook="audiobook" :width="bookWidth" />
<div v-if="false" ref="overlay">
<div v-show="isHovering || isSelectionMode || isMoreMenuOpen" class="absolute top-0 left-0 w-full h-full bg-black rounded hidden md:block z-20" :class="overlayWrapperClasslist">
<div v-show="showPlayButton" class="h-full flex items-center justify-center">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="play">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
</div>
</div>
<div v-show="showReadButton" class="h-full flex items-center justify-center">
<div class="hover:text-gray-200 hover:scale-110 transform duration-200" @click.stop.prevent="clickReadEBook">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">auto_stories</span>
</div>
</div>
<div v-if="userCanUpdate" v-show="!isSelectionMode" class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-50" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="editClick">
<span class="material-icons" :style="{ fontSize: sizeMultiplier + 'rem' }">edit</span>
</div>
<div class="absolute cursor-pointer hover:text-yellow-300 hover:scale-125 transform duration-100" :style="{ top: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="selectBtnClick">
<span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span>
</div>
<div ref="moreIcon" v-show="!isSelectionMode" class="hidden md:block absolute cursor-pointer hover:text-yellow-300" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
</div>
</div>
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">#{{ volumeNumber }}</p>
</div>
<div
v-if="showSmallEBookIcon"
class="absolute rounded-full bg-blue-500 flex items-center justify-center bg-opacity-90 hover:scale-125 transform duration-200"
:style="{ bottom: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem`, width: 1.5 * sizeMultiplier + 'rem', height: 1.5 * sizeMultiplier + 'rem' }"
@click.stop.prevent="clickReadEBook"
>
<span class="material-icons text-white" :style="{ fontSize: sizeMultiplier * 1 + 'rem' }">auto_stories</span>
</div>
<div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: bookWidth * userProgressPercent + 'px' }"></div>
<ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0">
<div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300">
<span class="material-icons text-red-100 pr-1" :style="{ fontSize: 0.875 * sizeMultiplier + 'rem' }">priority_high</span>
</div>
</ui-tooltip>
</div>
</div> -->
</div>
</template>
<script>
export default {
props: {
index: Number,
bookWidth: {
type: Number,
default: 120
}
},
data() {
return {
isAttached: false,
isHovering: false,
isMoreMenuOpen: false,
isProcessingReadUpdate: false,
overlayEl: null,
audiobook: null
}
},
computed: {
_audiobook() {
return this.audiobook || {}
},
bookCoverSrc() {
return this.store.getters['audiobooks/getBookCoverSrc'](this._audiobook, this.placeholderUrl)
},
audiobookId() {
return this._audiobook.id
},
hasEbook() {
return this._audiobook.numEbooks
},
hasTracks() {
return this._audiobook.numTracks
},
isSelectionMode() {
return !!this.selectedAudiobooks.length
},
selectedAudiobooks() {
return this.store.state.selectedAudiobooks
},
selected() {
return this.store.getters['getIsAudiobookSelected'](this.audiobookId)
},
processingBatch() {
return this.store.state.processingBatch
},
book() {
return this._audiobook.book || {}
},
bookHeight() {
return this.bookWidth * 1.6
},
sizeMultiplier() {
return this.bookWidth / 120
},
title() {
return this.book.title
},
playIconFontSize() {
return Math.max(2, 3 * this.sizeMultiplier)
},
author() {
return this.book.author
},
authorFL() {
return this.book.authorFL || this.author
},
authorLF() {
return this.book.authorLF || this.author
},
volumeNumber() {
return this.book.volumeNumber || null
},
userProgress() {
var store = this.$store || this.$nuxt.$store
return store.getters['user/getUserAudiobook'](this.audiobookId)
},
userProgressPercent() {
return this.userProgress ? this.userProgress.progress || 0 : 0
},
userIsRead() {
return this.userProgress ? !!this.userProgress.isRead : false
},
showError() {
return this.hasMissingParts || this.hasInvalidParts || this.isMissing || this.isIncomplete
},
isStreaming() {
var store = this.$store || this.$nuxt.$store
return store.getters['getAudiobookIdStreaming'] === this.audiobookId
},
showReadButton() {
return !this.isSelectionMode && this.showExperimentalFeatures && !this.showPlayButton && this.hasEbook
},
showPlayButton() {
return !this.isSelectionMode && !this.isMissing && !this.isIncomplete && this.hasTracks && !this.isStreaming
},
showSmallEBookIcon() {
return !this.isSelectionMode && this.showExperimentalFeatures && this.hasEbook
},
isMissing() {
return this.audiobook.isMissing
},
isIncomplete() {
return this.audiobook.isIncomplete
},
hasMissingParts() {
return this.audiobook.hasMissingParts
},
hasInvalidParts() {
return this.audiobook.hasInvalidParts
},
errorText() {
if (this.isMissing) return 'Audiobook directory is missing!'
else if (this.isIncomplete) return 'Audiobook has no audio tracks & ebook'
var txt = ''
if (this.hasMissingParts) {
txt = `${this.hasMissingParts} missing parts.`
}
if (this.hasInvalidParts) {
if (this.hasMissingParts) txt += ' '
txt += `${this.hasInvalidParts} invalid parts.`
}
return txt || 'Unknown Error'
},
overlayWrapperClasslist() {
var classes = []
if (this.isSelectionMode) classes.push('bg-opacity-60')
else classes.push('bg-opacity-40')
if (this.selected) {
classes.push('border-2 border-yellow-400')
}
return classes
},
store() {
return this.$store || this.$nuxt.$store
},
userCanUpdate() {
return this.store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.store.getters['user/getUserCanDelete']
},
userCanDownload() {
return this.store.getters['user/getUserCanDownload']
},
userIsRoot() {
return this.store.getters['user/getIsRoot']
},
moreMenuItems() {
var items = [
{
func: 'toggleRead',
text: `Mark as ${this.userIsRead ? 'Not Read' : 'Read'}`
},
{
func: 'openCollections',
text: 'Add to Collection'
}
]
if (this.userCanUpdate) {
if (this.hasTracks) {
items.push({
func: 'showEditModalTracks',
text: 'Tracks'
})
}
items.push({
func: 'showEditModalMatch',
text: 'Match'
})
}
if (this.userCanDownload) {
items.push({
func: 'showEditModalDownload',
text: 'Download'
})
}
if (this.userIsRoot) {
items.push({
func: 'rescan',
text: 'Re-Scan'
})
}
return items
}
},
methods: {
setBook(audiobook) {
this.audiobook = audiobook
},
clickCard(e) {
if (this.isSelectionMode) {
e.stopPropagation()
e.preventDefault()
this.selectBtnClick()
}
},
clickShowMore() {},
clickReadEBook() {},
editBtnClick() {},
selectBtnClick() {
if (this.processingBatch) return
this.store.commit('toggleAudiobookSelected', this.audiobookId)
},
play() {},
detach() {
if (!this.isAttached) return
if (this.$refs.overlay) {
this.overlayEl = this.$refs.overlay
this.overlayEl.remove()
} else if (this.overlayEl) {
this.overlayEl.remove()
}
this.isAttached = false
},
attach() {
if (this.isAttached) return
this.isAttached = true
if (this.overlayEl) {
this.$refs['overlay-wrapper'].appendChild(this.overlayEl)
}
},
mouseover() {
this.isHovering = true
},
// mouseleave() {
// this.isHovering = false
// },
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
this.$el.parentNode.removeChild(this.$el)
}
},
mounted() {}
}
</script>

View file

@ -4,8 +4,8 @@
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
<div class="w-full h-full z-0" ref="coverBg" />
</div>
<img ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
<div v-show="loading" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<img v-if="audiobook" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
<div v-show="loading && audiobook" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
<div class="absolute top-2 right-2">
<div class="la-ball-spin-clockwise la-sm">
@ -67,6 +67,7 @@ export default {
},
computed: {
book() {
if (!this.audiobook) return {}
return this.audiobook.book || {}
},
title() {
@ -92,7 +93,9 @@ export default {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
return this.$store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
if (!this.audiobook) return null
var store = this.$store || this.$nuxt.$store
return store.getters['audiobooks/getBookCoverSrc'](this.audiobook, this.placeholderUrl)
},
cover() {
return this.book.cover || this.placeholderUrl