mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-04-20 01:17:45 +02:00
Lazy bookshelf
This commit is contained in:
parent
3941da1144
commit
4587916c8e
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
--bookshelf-texture-img: url(/textures/wood_default.jpg);
|
--bookshelf-texture-img: url(/textures/wood_default.jpg);
|
||||||
|
--bookshelf-divider-bg: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
|
@ -442,17 +442,6 @@ export default {
|
|||||||
|
|
||||||
this.init()
|
this.init()
|
||||||
this.initIO()
|
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() {
|
beforeDestroy() {
|
||||||
window.removeEventListener('resize', this.resize)
|
window.removeEventListener('resize', this.resize)
|
||||||
|
233
client/components/app/LazyBookshelf.vue
Normal file
233
client/components/app/LazyBookshelf.vue
Normal 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>
|
302
client/components/cards/LazyBookCard.vue
Normal file
302
client/components/cards/LazyBookCard.vue
Normal 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>
|
@ -4,8 +4,8 @@
|
|||||||
<div v-if="showCoverBg" class="bg-primary absolute top-0 left-0 w-full h-full">
|
<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 class="w-full h-full z-0" ref="coverBg" />
|
||||||
</div>
|
</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'" />
|
<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" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
<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>
|
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||||
<div class="absolute top-2 right-2">
|
<div class="absolute top-2 right-2">
|
||||||
<div class="la-ball-spin-clockwise la-sm">
|
<div class="la-ball-spin-clockwise la-sm">
|
||||||
@ -67,6 +67,7 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
book() {
|
book() {
|
||||||
|
if (!this.audiobook) return {}
|
||||||
return this.audiobook.book || {}
|
return this.audiobook.book || {}
|
||||||
},
|
},
|
||||||
title() {
|
title() {
|
||||||
@ -92,7 +93,9 @@ export default {
|
|||||||
return '/book_placeholder.jpg'
|
return '/book_placeholder.jpg'
|
||||||
},
|
},
|
||||||
fullCoverUrl() {
|
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() {
|
cover() {
|
||||||
return this.book.cover || this.placeholderUrl
|
return this.book.cover || this.placeholderUrl
|
||||||
|
@ -4,7 +4,8 @@
|
|||||||
<app-side-rail class="hidden md:block" />
|
<app-side-rail class="hidden md:block" />
|
||||||
<div class="flex-grow">
|
<div class="flex-grow">
|
||||||
<app-book-shelf-toolbar :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode.sync="viewMode" />
|
<app-book-shelf-toolbar :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode.sync="viewMode" />
|
||||||
<app-book-shelf :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode="viewMode" />
|
<app-lazy-bookshelf />
|
||||||
|
<!-- <app-book-shelf :page="id || ''" :search-results="searchResults" :search-query="searchQuery" :selected-series.sync="selectedSeries" :view-mode="viewMode" /> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
7
package-lock.json
generated
7
package-lock.json
generated
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "audiobookshelf",
|
"name": "audiobookshelf",
|
||||||
"version": "1.6.29",
|
"version": "1.6.30",
|
||||||
"lockfileVersion": 1,
|
"lockfileVersion": 1,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -673,6 +673,11 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-5.3.0.tgz",
|
||||||
"integrity": "sha512-qJhfEgCnmteSeZAeuOKQ2WEIFTX5ajrzE0xS6gCOBCoRQcU+xEzQmgYQQTpzCcqUAAzTEtu4YEih4pnLfvNtew=="
|
"integrity": "sha512-qJhfEgCnmteSeZAeuOKQ2WEIFTX5ajrzE0xS6gCOBCoRQcU+xEzQmgYQQTpzCcqUAAzTEtu4YEih4pnLfvNtew=="
|
||||||
},
|
},
|
||||||
|
"fast-sort": {
|
||||||
|
"version": "3.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sort/-/fast-sort-3.1.1.tgz",
|
||||||
|
"integrity": "sha512-EA3PVIYj8uyyJc2Mma7GHjMrE74N/ClKkBj5gVUmY+8JePrc/ognCk4bhszVGYazu9Qk2aUTHnBF38QDSHcjkg=="
|
||||||
|
},
|
||||||
"file-type": {
|
"file-type": {
|
||||||
"version": "10.11.0",
|
"version": "10.11.0",
|
||||||
"resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz",
|
"resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz",
|
||||||
|
@ -32,6 +32,7 @@
|
|||||||
"express": "^4.17.1",
|
"express": "^4.17.1",
|
||||||
"express-fileupload": "^1.2.1",
|
"express-fileupload": "^1.2.1",
|
||||||
"express-rate-limit": "^5.3.0",
|
"express-rate-limit": "^5.3.0",
|
||||||
|
"fast-sort": "^3.1.1",
|
||||||
"fluent-ffmpeg": "^2.1.2",
|
"fluent-ffmpeg": "^2.1.2",
|
||||||
"fs-extra": "^10.0.0",
|
"fs-extra": "^10.0.0",
|
||||||
"image-type": "^4.1.0",
|
"image-type": "^4.1.0",
|
||||||
|
@ -53,6 +53,7 @@ class ApiController {
|
|||||||
this.router.patch('/libraries/:id', LibraryController.update.bind(this))
|
this.router.patch('/libraries/:id', LibraryController.update.bind(this))
|
||||||
this.router.delete('/libraries/:id', LibraryController.delete.bind(this))
|
this.router.delete('/libraries/:id', LibraryController.delete.bind(this))
|
||||||
|
|
||||||
|
this.router.get('/libraries/:id/books/all', LibraryController.getBooksForLibrary2.bind(this))
|
||||||
this.router.get('/libraries/:id/books', LibraryController.getBooksForLibrary.bind(this))
|
this.router.get('/libraries/:id/books', LibraryController.getBooksForLibrary.bind(this))
|
||||||
this.router.get('/libraries/:id/search', LibraryController.search.bind(this))
|
this.router.get('/libraries/:id/search', LibraryController.search.bind(this))
|
||||||
this.router.patch('/libraries/order', LibraryController.reorder.bind(this))
|
this.router.patch('/libraries/order', LibraryController.reorder.bind(this))
|
||||||
@ -488,5 +489,45 @@ class ApiController {
|
|||||||
})
|
})
|
||||||
return listeningStats
|
return listeningStats
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
decode(text) {
|
||||||
|
return Buffer.from(decodeURIComponent(text), 'base64').toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
getFiltered(audiobooks, filterBy, user) {
|
||||||
|
var filtered = audiobooks
|
||||||
|
|
||||||
|
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress', 'narrators']
|
||||||
|
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
|
||||||
|
if (group) {
|
||||||
|
var filterVal = filterBy.replace(`${group}.`, '')
|
||||||
|
var filter = this.decode(filterVal)
|
||||||
|
if (group === 'genres') filtered = filtered.filter(ab => ab.book && ab.book.genres.includes(filter))
|
||||||
|
else if (group === 'tags') filtered = filtered.filter(ab => ab.tags.includes(filter))
|
||||||
|
else if (group === 'series') {
|
||||||
|
if (filter === 'No Series') filtered = filtered.filter(ab => ab.book && !ab.book.series)
|
||||||
|
else filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
|
||||||
|
}
|
||||||
|
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.authorFL && ab.book.authorFL.split(', ').includes(filter))
|
||||||
|
else if (group === 'narrators') filtered = filtered.filter(ab => ab.book && ab.book.narratorFL && ab.book.narratorFL.split(', ').includes(filter))
|
||||||
|
else if (group === 'progress') {
|
||||||
|
filtered = filtered.filter(ab => {
|
||||||
|
var userAudiobook = user.getAudiobookJSON(ab.id)
|
||||||
|
var isRead = userAudiobook && userAudiobook.isRead
|
||||||
|
if (filter === 'Read' && isRead) return true
|
||||||
|
if (filter === 'Unread' && !isRead) return true
|
||||||
|
if (filter === 'In Progress' && (userAudiobook && !userAudiobook.isRead && userAudiobook.progress > 0)) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (filterBy === 'issues') {
|
||||||
|
filtered = filtered.filter(ab => {
|
||||||
|
return ab.hasMissingParts || ab.hasInvalidParts || ab.isMissing || ab.isIncomplete
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
}
|
}
|
||||||
module.exports = ApiController
|
module.exports = ApiController
|
@ -1,16 +1,11 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
|
|
||||||
class BookController {
|
class BookController {
|
||||||
constructor(db, emitter, clientEmitter, streamManager, coverController) {
|
constructor() { }
|
||||||
this.db = db
|
|
||||||
this.emitter = emitter
|
|
||||||
this.clientEmitter = clientEmitter
|
|
||||||
this.streamManager = streamManager
|
|
||||||
this.coverController = coverController
|
|
||||||
}
|
|
||||||
|
|
||||||
findAll(req, res) {
|
findAll(req, res) {
|
||||||
var audiobooks = []
|
var audiobooks = []
|
||||||
|
|
||||||
if (req.query.q) {
|
if (req.query.q) {
|
||||||
audiobooks = this.db.audiobooks.filter(ab => {
|
audiobooks = this.db.audiobooks.filter(ab => {
|
||||||
return ab.isSearchMatch(req.query.q)
|
return ab.isSearchMatch(req.query.q)
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
const Logger = require('../Logger')
|
const Logger = require('../Logger')
|
||||||
const Library = require('../objects/Library')
|
const Library = require('../objects/Library')
|
||||||
|
const { sort } = require('fast-sort')
|
||||||
|
|
||||||
class LibraryController {
|
class LibraryController {
|
||||||
constructor() { }
|
constructor() { }
|
||||||
@ -91,18 +92,84 @@ class LibraryController {
|
|||||||
if (!library) {
|
if (!library) {
|
||||||
return res.status(400).send('Library does not exist')
|
return res.status(400).send('Library does not exist')
|
||||||
}
|
}
|
||||||
|
var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId)
|
||||||
|
// if (req.query.q) {
|
||||||
|
// audiobooks = this.db.audiobooks.filter(ab => {
|
||||||
|
// return ab.libraryId === libraryId && ab.isSearchMatch(req.query.q)
|
||||||
|
// }).map(ab => ab.toJSONMinified())
|
||||||
|
// } else {
|
||||||
|
// audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId).map(ab => ab.toJSONMinified())
|
||||||
|
// }
|
||||||
|
|
||||||
var audiobooks = []
|
if (req.query.filter) {
|
||||||
if (req.query.q) {
|
audiobooks = this.getFiltered(this.db.audiobooks, req.query.filter, req.user)
|
||||||
audiobooks = this.db.audiobooks.filter(ab => {
|
}
|
||||||
return ab.libraryId === libraryId && ab.isSearchMatch(req.query.q)
|
|
||||||
}).map(ab => ab.toJSONMinified())
|
|
||||||
} else {
|
if (req.query.sort) {
|
||||||
audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId).map(ab => ab.toJSONMinified())
|
var orderByNumber = req.query.sort === 'book.volumeNumber'
|
||||||
|
var direction = req.query.desc === '1' ? 'desc' : 'asc'
|
||||||
|
audiobooks = sort(audiobooks)[direction]((ab) => {
|
||||||
|
// Supports dot notation strings i.e. "book.title"
|
||||||
|
var value = req.query.sort.split('.').reduce((a, b) => a[b], ab)
|
||||||
|
if (orderByNumber && !isNaN(value)) return Number(value)
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (req.query.limit && !isNaN(req.query.limit)) {
|
||||||
|
var page = req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0
|
||||||
|
var limit = Number(req.query.limit)
|
||||||
|
var startIndex = page * limit
|
||||||
|
audiobooks = audiobooks.slice(startIndex, startIndex + limit)
|
||||||
}
|
}
|
||||||
res.json(audiobooks)
|
res.json(audiobooks)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// api/libraries/:id/books/fs
|
||||||
|
getBooksForLibrary2(req, res) {
|
||||||
|
var libraryId = req.params.id
|
||||||
|
var library = this.db.libraries.find(lib => lib.id === libraryId)
|
||||||
|
if (!library) {
|
||||||
|
return res.status(400).send('Library does not exist')
|
||||||
|
}
|
||||||
|
|
||||||
|
var audiobooks = this.db.audiobooks.filter(ab => ab.libraryId === libraryId)
|
||||||
|
var payload = {
|
||||||
|
results: [],
|
||||||
|
total: audiobooks.length,
|
||||||
|
limit: req.query.limit && !isNaN(req.query.limit) ? Number(req.query.limit) : 0,
|
||||||
|
page: req.query.page && !isNaN(req.query.page) ? Number(req.query.page) : 0,
|
||||||
|
sortBy: req.query.sort,
|
||||||
|
sortDesc: req.query.desc === '1',
|
||||||
|
filterBy: req.query.filter
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.filterBy) {
|
||||||
|
audiobooks = this.getFiltered(this.db.audiobooks, payload.filterBy, req.user)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.sortBy) {
|
||||||
|
var orderByNumber = payload.sortBy === 'book.volumeNumber'
|
||||||
|
var direction = payload.sortDesc ? 'desc' : 'asc'
|
||||||
|
audiobooks = sort(audiobooks)[direction]((ab) => {
|
||||||
|
// Supports dot notation strings i.e. "book.title"
|
||||||
|
var value = payload.sortBy.split('.').reduce((a, b) => a[b], ab)
|
||||||
|
if (orderByNumber && !isNaN(value)) return Number(value)
|
||||||
|
return value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.limit) {
|
||||||
|
var startIndex = payload.page * payload.limit
|
||||||
|
audiobooks = audiobooks.slice(startIndex, startIndex + payload.limit)
|
||||||
|
}
|
||||||
|
payload.results = audiobooks.map(ab => ab.toJSONExpanded())
|
||||||
|
console.log('returning books', audiobooks.length)
|
||||||
|
|
||||||
|
res.json(payload)
|
||||||
|
}
|
||||||
|
|
||||||
// PATCH: Change the order of libraries
|
// PATCH: Change the order of libraries
|
||||||
async reorder(req, res) {
|
async reorder(req, res) {
|
||||||
if (!req.user.isRoot) {
|
if (!req.user.isRoot) {
|
||||||
|
Loading…
Reference in New Issue
Block a user