Add:Lazy bookshelf

This commit is contained in:
advplyr 2021-12-04 19:56:29 -06:00
parent 446c6756ed
commit 37d3021302
43 changed files with 2264 additions and 666 deletions

View file

@ -14,6 +14,7 @@ class Server extends EventEmitter {
this.user = null this.user = null
this.connected = false this.connected = false
this.initialized = false
this.stream = null this.stream = null
@ -231,6 +232,8 @@ class Server extends EventEmitter {
console.log('[SOCKET] Socket Disconnected: ' + reason) console.log('[SOCKET] Socket Disconnected: ' + reason)
this.connected = false this.connected = false
this.emit('connected', false) this.emit('connected', false)
this.emit('initialized', false)
this.initialized = false
this.store.commit('setSocketConnected', false) this.store.commit('setSocketConnected', false)
// this.socket.removeAllListeners() // this.socket.removeAllListeners()
@ -246,6 +249,11 @@ class Server extends EventEmitter {
this.store.commit('setStreamAudiobook', data.stream.audiobook) this.store.commit('setStreamAudiobook', data.stream.audiobook)
this.emit('initialStream', data.stream) this.emit('initialStream', data.stream)
} }
if (data.serverSettings) {
this.store.commit('setServerSettings', data.serverSettings)
}
this.initialized = true
this.emit('initialized', true)
}) })
this.socket.on('user_updated', (user) => { this.socket.on('user_updated', (user) => {

View file

@ -19,6 +19,9 @@
.box-shadow-book { .box-shadow-book {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166; box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
} }
.shadow-height {
height: calc(100% - 4px);
}
.bookshelfRow { .bookshelfRow {
background-image: url(/wood_panels.jpg); background-image: url(/wood_panels.jpg);
@ -42,4 +45,15 @@ Bookshelf Label
border-color: rgba(255, 244, 182, 0.6); border-color: rgba(255, 244, 182, 0.6);
border-style: solid; border-style: solid;
color: #fce3a6; color: #fce3a6;
}
.cover-bg {
width: calc(100% + 40px);
height: calc(100% + 40px);
top: -20px;
left: -20px;
background-size: 100% 100%;
background-position: center;
opacity: 1;
filter: blur(20px);
} }

View file

@ -22,8 +22,6 @@
<!-- <span class="material-icons cursor-pointer mx-4" :class="hasDownloadsFolder ? '' : 'text-warning'" @click="$store.commit('downloads/setShowModal', true)">source</span> --> <!-- <span class="material-icons cursor-pointer mx-4" :class="hasDownloadsFolder ? '' : 'text-warning'" @click="$store.commit('downloads/setShowModal', true)">source</span> -->
<!-- <widgets-connection-icon /> -->
<nuxt-link class="h-7 mx-2" to="/search"> <nuxt-link class="h-7 mx-2" to="/search">
<span class="material-icons" style="font-size: 1.75rem">search</span> <span class="material-icons" style="font-size: 1.75rem">search</span>
</nuxt-link> </nuxt-link>

View file

@ -54,7 +54,7 @@ export default {
var booksPerShelf = Math.floor(this.pageWidth / (this.cardWidth + 32)) var booksPerShelf = Math.floor(this.pageWidth / (this.cardWidth + 32))
var groupedBooks = [] var groupedBooks = []
var audiobooksSorted = this.$store.getters['audiobooks/getFilteredAndSorted']() var audiobooksSorted = []
this.currFilterOrderKey = this.filterOrderKey this.currFilterOrderKey = this.filterOrderKey
var numGroups = Math.ceil(audiobooksSorted.length / booksPerShelf) var numGroups = Math.ceil(audiobooksSorted.length / booksPerShelf)
@ -86,7 +86,6 @@ export default {
if (currentLibrary) { if (currentLibrary) {
this.$store.commit('libraries/setCurrentLibrary', currentLibrary.id) this.$store.commit('libraries/setCurrentLibrary', currentLibrary.id)
} }
this.$store.dispatch('audiobooks/load')
}, },
socketConnected(isConnected) { socketConnected(isConnected) {
if (isConnected) { if (isConnected) {

View file

@ -51,9 +51,7 @@ export default {
mobileFilterBy: 'all' mobileFilterBy: 'all'
}) })
}, },
calcShelves() { calcShelves() {},
this.audiobooks = this.$store.getters['audiobooks/getFilteredAndSorted']()
},
audiobooksUpdated() { audiobooksUpdated() {
this.calcShelves() this.calcShelves()
}, },
@ -76,7 +74,6 @@ export default {
if (currentLibrary) { if (currentLibrary) {
this.$store.commit('libraries/setCurrentLibrary', currentLibrary.id) this.$store.commit('libraries/setCurrentLibrary', currentLibrary.id)
} }
this.$store.dispatch('audiobooks/load')
}, },
socketConnected(isConnected) { socketConnected(isConnected) {
if (isConnected) { if (isConnected) {

View file

@ -28,15 +28,25 @@
</template> </template>
<script> <script>
import TouchEvent from '@/objects/TouchEvent'
export default { export default {
data() { data() {
return {} return {
touchEvent: null
}
}, },
watch: { watch: {
$route: { $route: {
handler() { handler() {
this.show = false this.show = false
} }
},
show: {
handler(newVal) {
if (newVal) this.registerListener()
else this.removeListener()
}
} }
}, },
computed: { computed: {
@ -106,6 +116,25 @@ export default {
this.$store.commit('audiobooks/reset') this.$store.commit('audiobooks/reset')
this.$store.dispatch('audiobooks/useDownloaded') this.$store.dispatch('audiobooks/useDownloaded')
},
touchstart(e) {
this.touchEvent = new TouchEvent(e)
},
touchend(e) {
if (!this.touchEvent) return
this.touchEvent.setEndEvent(e)
if (this.touchEvent.isSwipeRight()) {
this.show = false
}
this.touchEvent = null
},
registerListener() {
document.addEventListener('touchstart', this.touchstart)
document.addEventListener('touchend', this.touchend)
},
removeListener() {
document.removeEventListener('touchstart', this.touchstart)
document.removeEventListener('touchend', this.touchend)
} }
}, },
mounted() {}, mounted() {},

View file

@ -0,0 +1,400 @@
<template>
<div id="bookshelf" class="w-full max-w-full h-full min-h-screen">
<template v-for="shelf in totalShelves">
<div :key="shelf" class="w-full px-2 bookshelfRow relative" :id="`shelf-${shelf - 1}`" :style="{ height: shelfHeight + 'px' }">
<div class="bookshelfDivider w-full absolute bottom-0 left-0 right-0 z-20" :class="`h-${shelfDividerHeightIndex}`" />
</div>
</template>
<!-- <div class="w-full h-full px-4 py-2" v-show="isListView">
<template v-for="book in books">
<app-bookshelf-list-row :key="book.id" :audiobook="book" :page-width="pageWidth" class="my-2" />
</template>
</div> -->
<div v-show="!entities.length && initialized" class="w-full py-16 text-center text-xl">
<div class="py-4 capitalize">No {{ entityName }}</div>
<ui-btn v-if="hasFilter" @click="clearFilter">Clear Filter</ui-btn>
</div>
</div>
</template>
<script>
import bookshelfCardsHelpers from '@/mixins/bookshelfCardsHelpers'
export default {
props: {
page: String,
seriesId: String
},
mixins: [bookshelfCardsHelpers],
data() {
return {
bookshelfHeight: 0,
bookshelfWidth: 0,
bookshelfMarginLeft: 0,
shelvesPerPage: 0,
entitiesPerShelf: 8,
currentPage: 0,
currentBookWidth: 0,
booksPerFetch: 20,
initialized: false,
currentSFQueryString: null,
isFetchingEntities: false,
entities: [],
totalEntities: 0,
totalShelves: 0,
entityComponentRefs: {},
entityIndexesMounted: [],
pagesLoaded: {},
isFirstInit: false,
pendingReset: false
}
},
computed: {
isSocketConnected() {
return this.$store.state.socketConnected
},
isBookEntity() {
return this.entityName === 'books' || this.entityName === 'series-books'
},
shelfDividerHeightIndex() {
if (this.isBookEntity) return 4
return 6
},
entityName() {
return this.page
},
bookshelfView() {
return this.$store.state.bookshelfView
},
hasFilter() {
return this.filterBy !== 'all'
},
isListView() {
return this.bookshelfView === 'list'
},
books() {
return this.$store.getters['downloads/getAudiobooks']
},
orderBy() {
return this.$store.getters['user/getUserSetting']('mobileOrderBy')
},
orderDesc() {
return this.$store.getters['user/getUserSetting']('mobileOrderDesc')
},
filterBy() {
return this.$store.getters['user/getUserSetting']('mobileFilterBy')
},
coverAspectRatio() {
return this.$store.getters['getServerSetting']('coverAspectRatio')
},
isCoverSquareAspectRatio() {
return this.coverAspectRatio === this.$constants.BookCoverAspectRatio.SQUARE
},
bookCoverAspectRatio() {
return this.isCoverSquareAspectRatio ? 1 : 1.6
},
bookWidth() {
// var coverSize = this.$store.getters['user/getUserSetting']('bookshelfCoverSize')
var coverSize = 100
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
return coverSize
},
bookHeight() {
if (this.isCoverSquareAspectRatio) return this.bookWidth
return this.bookWidth * 1.6
},
entityWidth() {
if (this.isBookEntity) return this.bookWidth
return this.bookWidth * 2
},
entityHeight() {
return this.bookHeight
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
shelfHeight() {
return this.entityHeight + 40
},
totalEntityCardWidth() {
// Includes margin
return this.entityWidth + 24
},
downloadedBooks() {
return this.$store.getters['downloads/getAudiobooks']
}
},
methods: {
clearFilter() {
this.$store.dispatch('user/updateUserSettings', {
mobileFilterBy: 'all'
})
},
async fetchEntities(page) {
var startIndex = page * this.booksPerFetch
this.isFetchingEntities = true
if (!this.initialized) {
this.currentSFQueryString = this.buildSearchParams()
}
var entityPath = this.entityName === 'books' ? `books/all` : this.entityName
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
var queryString = `?${sfQueryString}&limit=${this.booksPerFetch}&page=${page}`
if (this.entityName === 'series-books') {
entityPath = `series/${this.seriesId}`
queryString = ''
}
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${queryString}`).catch((error) => {
console.error('failed to fetch books', error)
return null
})
this.isFetchingEntities = false
if (this.pendingReset) {
this.pendingReset = false
this.resetEntities()
return
}
if (payload && payload.results) {
console.log('Received payload', payload)
if (!this.initialized) {
this.initialized = true
this.totalEntities = payload.total
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
this.entities = new Array(this.totalEntities)
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
}
for (let i = 0; i < payload.results.length; i++) {
var index = i + startIndex
this.entities[index] = payload.results[i]
if (this.entityComponentRefs[index]) {
this.entityComponentRefs[index].setEntity(this.entities[index])
}
}
}
},
async loadPage(page) {
this.pagesLoaded[page] = true
await this.fetchEntities(page)
},
mountEntites(fromIndex, toIndex) {
for (let i = fromIndex; i < toIndex; i++) {
if (!this.entityIndexesMounted.includes(i)) {
this.cardsHelpers.mountEntityCard(i)
}
}
},
handleScroll(scrollTop) {
this.currScrollTop = scrollTop
var firstShelfIndex = Math.floor(scrollTop / this.shelfHeight)
var lastShelfIndex = Math.ceil((scrollTop + this.bookshelfHeight) / this.shelfHeight)
lastShelfIndex = Math.min(this.totalShelves - 1, lastShelfIndex)
var firstBookIndex = firstShelfIndex * this.entitiesPerShelf
var lastBookIndex = lastShelfIndex * this.entitiesPerShelf + this.entitiesPerShelf
lastBookIndex = Math.min(this.totalEntities, lastBookIndex)
var firstBookPage = Math.floor(firstBookIndex / this.booksPerFetch)
var lastBookPage = Math.floor(lastBookIndex / this.booksPerFetch)
if (!this.pagesLoaded[firstBookPage]) {
console.log('Must load next batch', firstBookPage, 'book index', firstBookIndex)
this.loadPage(firstBookPage)
}
if (!this.pagesLoaded[lastBookPage]) {
console.log('Must load last next batch', lastBookPage, 'book index', lastBookIndex)
this.loadPage(lastBookPage)
}
this.entityIndexesMounted = this.entityIndexesMounted.filter((_index) => {
if (_index < firstBookIndex || _index >= lastBookIndex) {
var el = document.getElementById(`book-card-${_index}`)
if (el) el.remove()
return false
}
return true
})
this.mountEntites(firstBookIndex, lastBookIndex)
},
destroyEntityComponents() {
for (const key in this.entityComponentRefs) {
if (this.entityComponentRefs[key] && this.entityComponentRefs[key].destroy) {
this.entityComponentRefs[key].destroy()
}
}
},
setDownloads() {
// TODO: Check entityName
this.entities = this.downloadedBooks.map((db) => ({ ...db }))
// TOOD: Sort and filter here
this.totalEntities = this.entities.length
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
this.entities = new Array(this.totalEntities)
this.$eventBus.$emit('bookshelf-total-entities', this.totalEntities)
},
async resetEntities() {
if (this.isFetchingEntities) {
this.pendingReset = true
return
}
this.destroyEntityComponents()
this.entityIndexesMounted = []
this.entityComponentRefs = {}
this.pagesLoaded = {}
this.entities = []
this.totalShelves = 0
this.totalEntities = 0
this.currentPage = 0
this.initialized = false
this.initSizeData()
if (this.isSocketConnected) {
await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex)
} else {
this.setDownloads()
this.mountEntites(0, this.totalEntities - 1)
}
},
initSizeData() {
var bookshelf = document.getElementById('bookshelf')
if (!bookshelf) {
console.error('Failed to init size data')
return
}
var entitiesPerShelfBefore = this.entitiesPerShelf
var { clientHeight, clientWidth } = bookshelf
this.bookshelfHeight = clientHeight
this.bookshelfWidth = clientWidth
this.entitiesPerShelf = Math.floor((this.bookshelfWidth - 16) / this.totalEntityCardWidth)
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
this.currentBookWidth = this.bookWidth
if (this.totalEntities) {
this.totalShelves = Math.ceil(this.totalEntities / this.entitiesPerShelf)
}
return entitiesPerShelfBefore < this.entitiesPerShelf // Books per shelf has changed
},
async init(bookshelf) {
if (this.isFirstInit) return
this.isFirstInit = true
this.initSizeData(bookshelf)
await this.loadPage(0)
var lastBookIndex = Math.min(this.totalEntities, this.shelvesPerPage * this.entitiesPerShelf)
this.mountEntites(0, lastBookIndex)
},
scroll(e) {
if (!e || !e.target) return
if (!this.isSocketConnected) return // Offline books are all mounted at once
var { scrollTop } = e.target
this.handleScroll(scrollTop)
},
socketInit(isConnected) {
if (isConnected) {
this.init()
} else {
this.isFirstInit = false
this.resetEntities()
}
},
buildSearchParams() {
let searchParams = new URLSearchParams()
if (this.filterBy && this.filterBy !== 'all') {
searchParams.set('filter', this.filterBy)
}
if (this.orderBy) {
searchParams.set('sort', this.orderBy)
searchParams.set('desc', this.orderDesc ? 1 : 0)
}
return searchParams.toString()
},
checkUpdateSearchParams() {
var newSearchParams = this.buildSearchParams()
var currentQueryString = window.location.search
if (currentQueryString && currentQueryString.startsWith('?')) currentQueryString = currentQueryString.slice(1)
if (newSearchParams === '') {
return false
}
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
window.history.replaceState({ path: newurl }, '', newurl)
return true
}
return false
},
settingsUpdated(settings) {
var wasUpdated = this.checkUpdateSearchParams()
if (wasUpdated) {
this.resetEntities()
}
},
libraryChanged(libid) {
if (this.hasFilter) {
this.clearFilter()
} else {
this.resetEntities()
}
},
initListeners() {
var bookshelf = document.getElementById('bookshelf-wrapper')
if (bookshelf) {
bookshelf.addEventListener('scroll', this.scroll)
}
// this.$eventBus.$on('bookshelf-clear-selection', this.clearSelectedEntities)
// this.$eventBus.$on('bookshelf-select-all', this.selectAllEntities)
// this.$eventBus.$on('bookshelf-keyword-filter', this.updateKeywordFilter)
this.$eventBus.$on('library-changed', this.libraryChanged)
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
// if (this.$root.socket) {
// this.$root.socket.on('audiobook_updated', this.audiobookUpdated)
// this.$root.socket.on('audiobook_added', this.audiobookAdded)
// this.$root.socket.on('audiobook_removed', this.audiobookRemoved)
// this.$root.socket.on('audiobooks_updated', this.audiobooksUpdated)
// this.$root.socket.on('audiobooks_added', this.audiobooksAdded)
// } else {
// console.error('Bookshelf - Socket not initialized')
// }
},
removeListeners() {
var bookshelf = document.getElementById('bookshelf-wrapper')
if (bookshelf) {
bookshelf.removeEventListener('scroll', this.scroll)
}
this.$eventBus.$off('library-changed', this.libraryChanged)
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
// if (this.$root.socket) {
// this.$root.socket.off('audiobook_updated', this.audiobookUpdated)
// this.$root.socket.off('audiobook_added', this.audiobookAdded)
// this.$root.socket.off('audiobook_removed', this.audiobookRemoved)
// this.$root.socket.off('audiobooks_updated', this.audiobooksUpdated)
// this.$root.socket.off('audiobooks_added', this.audiobooksAdded)
// } else {
// console.error('Bookshelf - Socket not initialized')
// }
}
},
mounted() {
if (this.$server.initialized) {
this.init()
}
this.$server.on('initialized', this.socketInit)
this.initListeners()
},
beforeDestroy() {
this.$server.off('initialized', this.socketInit)
this.removeListeners()
}
}
</script>

View file

@ -1,8 +1,10 @@
<template> <template>
<div class="w-full relative"> <div class="w-full relative">
<div class="bookshelfRow h-44 flex items-end px-3 max-w-full overflow-x-auto"> <div class="bookshelfRow flex items-end px-3 max-w-full overflow-x-auto" :style="{ height: shelfHeight + 'px' }">
<template v-for="book in books"> <template v-for="(entity, index) in entities">
<cards-book-card :key="book.id" :audiobook="book" :width="100" class="mx-2" /> <!-- <cards-book-card v-if="type === 'books'" :key="entity.id" :audiobook="entity" :width="bookWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" class="mx-2" /> -->
<cards-lazy-book-card v-if="type === 'books'" :key="entity.id" :index="index" :book-mount="entity" :width="bookWidth" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
<cards-lazy-series-card v-else-if="type === 'series'" :key="entity.id" :index="index" :series-mount="entity" :width="bookWidth * 2" :height="entityHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" is-categorized class="mx-2 relative" />
</template> </template>
</div> </div>
@ -19,7 +21,8 @@
export default { export default {
props: { props: {
label: String, label: String,
books: { type: String,
entities: {
type: Array, type: Array,
default: () => [] default: () => []
} }
@ -27,7 +30,26 @@ export default {
data() { data() {
return {} return {}
}, },
computed: {}, computed: {
shelfHeight() {
return this.entityHeight + 40
},
bookWidth() {
var coverSize = 100
if (this.bookCoverAspectRatio === 1) return coverSize * 1.6
return coverSize
},
bookHeight() {
if (this.bookCoverAspectRatio === 1) return this.bookWidth
return this.bookWidth * 1.6
},
entityHeight() {
return this.bookHeight
},
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
}
},
methods: {}, methods: {},
mounted() {} mounted() {}
} }

View file

@ -0,0 +1,46 @@
<template>
<div class="flex h-full px-1 overflow-hidden shadow-sm">
<!-- <img src="/icons/NoUserPhoto.png" class="w-40 h-40 max-h-40 object-contain" style="max-height: 40px; max-width: 40px" /> -->
<div style="max-height: 48px; max-width: 48px" class="w-12 h-12 bg-primary overflow-hidden rounded">
<svg width="140%" height="140%" style="margin-left: -20%; margin-top: -20%; opacity: 0.6" viewBox="0 0 177 266" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill="white" d="M40.7156 165.47C10.2694 150.865 -31.5407 148.629 -38.0532 155.529L63.3191 204.159L76.9443 190.899C66.828 181.394 54.006 171.846 40.7156 165.47Z" stroke="white" stroke-width="4" transform="translate(-2 -1)" />
<path d="M-38.0532 155.529C-31.5407 148.629 10.2694 150.865 40.7156 165.47C54.006 171.846 66.828 181.394 76.9443 190.899L95.0391 173.37C80.6681 159.403 64.7526 149.155 51.5747 142.834C21.3549 128.337 -46.2471 114.563 -60.6897 144.67L-71.5489 167.307L44.5864 223.019L63.3191 204.159L-38.0532 155.529Z" fill="white" />
<path
d="M105.87 29.6508C80.857 17.6515 50.8784 28.1923 38.879 53.2056C26.8797 78.219 37.4205 108.198 62.4338 120.197C87.4472 132.196 117.426 121.656 129.425 96.6422C141.425 71.6288 130.884 41.6502 105.87 29.6508ZM106.789 85.783C112.761 73.3329 107.461 58.2599 95.0112 52.2874C82.5611 46.3148 67.4881 51.6147 61.5156 64.0648C55.543 76.5149 60.8429 91.5879 73.293 97.5604C85.7431 103.533 100.816 98.2331 106.789 85.783Z"
fill="white"
/>
<path
d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01ZM181.725 108.497C179.624 108.491 177.436 109.326 175.835 110.918L160.415 126.257L191.848 157.856L207.268 142.517C210.554 139.248 210.568 133.954 207.299 130.667L187.685 110.95C186.009 109.264 183.91 108.502 181.725 108.497ZM151.399 135.226L58.2034 227.931L58.1203 259.447L89.6359 259.53L182.831 166.825L151.399 135.226Z"
fill="white"
/>
<path d="M151.336 159.01L159.048 166.762L82.7048 242.703L74.973 242.683L74.9934 234.951L151.336 159.01Z" fill="white" stroke="white" stroke-width="10px" />
</svg>
</div>
<div class="flex-grow px-2 authorSearchCardContent h-full">
<p class="truncate text-sm">{{ author }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
author: String
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>
<style>
.authorSearchCardContent {
width: calc(100% - 50px);
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View file

@ -1,17 +1,9 @@
<template> <template>
<div class="relative"> <div class="relative">
<!-- New Book Flag -->
<div v-if="isNew" class="absolute top-4 left-0 w-4 h-10 pr-2 bg-darkgreen box-shadow-xl">
<div class="absolute top-0 left-0 w-full h-full transform -rotate-90 flex items-center justify-center">
<p class="text-center text-sm">New</p>
</div>
<div class="absolute -bottom-4 left-0 triangle-right" />
</div>
<div class="rounded-sm h-full overflow-hidden relative box-shadow-book"> <div class="rounded-sm h-full overflow-hidden relative box-shadow-book">
<nuxt-link :to="`/audiobook/${audiobookId}`" class="cursor-pointer"> <nuxt-link :to="`/audiobook/${audiobookId}`" class="cursor-pointer">
<div class="w-full relative" :style="{ height: height + 'px' }"> <div class="w-full relative" :style="{ height: height + 'px' }">
<cards-book-cover :audiobook="audiobook" :download-cover="downloadCover" :author-override="authorFormat" :width="width" /> <covers-book-cover :audiobook="audiobook" :download-cover="downloadCover" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div v-if="download" class="absolute" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }"> <div v-if="download" class="absolute" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }">
<span class="material-icons text-success" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">download_done</span> <span class="material-icons text-success" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">download_done</span>
@ -38,15 +30,13 @@ export default {
width: { width: {
type: Number, type: Number,
default: 140 default: 140
} },
bookCoverAspectRatio: Number
}, },
data() { data() {
return {} return {}
}, },
computed: { computed: {
isNew() {
return this.tags.includes('new')
},
tags() { tags() {
return this.audiobook.tags || [] return this.audiobook.tags || []
}, },
@ -57,29 +47,11 @@ export default {
return this.audiobook.book || {} return this.audiobook.book || {}
}, },
height() { height() {
return this.width * 1.6 return this.width * this.bookCoverAspectRatio
}, },
sizeMultiplier() { sizeMultiplier() {
return this.width / 120 if (this.bookCoverAspectRatio === 1) return this.width / 160
}, return this.width / 100
paddingX() {
return 16 * this.sizeMultiplier
},
author() {
return this.book.author
},
authorFL() {
return this.book.authorFL || this.author
},
authorLF() {
return this.book.authorLF || this.author
},
authorFormat() {
if (!this.orderBy || !this.orderBy.startsWith('book.author')) return null
return this.orderBy === 'book.authorLF' ? this.authorLF : this.authorFL
},
orderBy() {
return this.$store.getters['user/getUserSetting']('mobileOrderBy')
}, },
mostRecentUserProgress() { mostRecentUserProgress() {
return this.$store.getters['user/getUserAudiobookData'](this.audiobookId) return this.$store.getters['user/getUserAudiobookData'](this.audiobookId)

View file

@ -0,0 +1,89 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<covers-book-cover :audiobook="audiobook" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="flex-grow px-2 h-full audiobookSearchCardContent">
<p v-if="matchKey !== 'title'" class="truncate text-sm">{{ title }}</p>
<p v-else class="truncate text-sm" v-html="matchHtml" />
<p v-if="matchKey === 'subtitle'" class="truncate text-xs text-gray-300">{{ matchHtml }}</p>
<p v-if="matchKey !== 'authorFL'" class="text-xs text-gray-200 truncate">by {{ authorFL }}</p>
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
<div v-if="matchKey === 'series' || matchKey === 'tags'" class="m-0 p-0 truncate" v-html="matchHtml" />
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
},
search: String,
matchKey: String,
matchText: String
},
data() {
return {}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
coverWidth() {
if (this.bookCoverAspectRatio === 1) return 50 * 1.2
return 50
},
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
title() {
return this.book ? this.book.title : 'No Title'
},
subtitle() {
return this.book ? this.book.subtitle : ''
},
authorFL() {
return this.book ? this.book.authorFL : 'Unknown'
},
matchHtml() {
if (!this.matchText || !this.search) return ''
if (this.matchKey === 'subtitle') return ''
var matchSplit = this.matchText.toLowerCase().split(this.search.toLowerCase().trim())
if (matchSplit.length < 2) return ''
var html = ''
var totalLenSoFar = 0
for (let i = 0; i < matchSplit.length - 1; i++) {
var indexOf = matchSplit[i].length
var firstPart = this.matchText.substr(totalLenSoFar, indexOf)
var actualWasThere = this.matchText.substr(totalLenSoFar + indexOf, this.search.length)
totalLenSoFar += indexOf + this.search.length
html += `${firstPart}<strong class="text-warning">${actualWasThere}</strong>`
}
var lastPart = this.matchText.substr(totalLenSoFar)
html += lastPart
if (this.matchKey === 'tags') return `<p class="truncate">Tags: ${html}</p>`
if (this.matchKey === 'authorFL') return `by ${html}`
if (this.matchKey === 'series') return `<p class="truncate">Series: ${html}</p>`
return `${html}`
}
},
methods: {},
mounted() {}
}
</script>
<style>
.audiobookSearchCardContent {
width: calc(100% - 80px);
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View file

@ -0,0 +1,264 @@
<template>
<div ref="card" :id="`book-card-${index}`" :style="{ width: width + 'px', minWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 bg-primary cursor-pointer box-shadow-book" @click="clickCard">
<!-- When cover image does not fill -->
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
<div v-show="audiobook && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
</div>
<img v-show="audiobook" ref="cover" :src="bookCoverSrc" class="w-full h-full object-contain transition-opacity duration-300" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
<!-- Placeholder Cover Title & Author -->
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
<div class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<div v-if="showError" :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>
<div v-if="volumeNumber && showVolumeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :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>
</template>
<script>
export default {
props: {
index: Number,
width: {
type: Number,
default: 120
},
height: {
type: Number,
default: 192
},
bookCoverAspectRatio: Number,
showVolumeNumber: Boolean,
bookMount: {
type: Object,
default: () => null
}
},
data() {
return {
isHovering: false,
isMoreMenuOpen: false,
isProcessingReadUpdate: false,
audiobook: null,
imageReady: false,
rescanning: false,
selected: false,
isSelectionMode: false,
showCoverBg: false
}
},
computed: {
_audiobook() {
return this.audiobook || {}
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
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
},
book() {
return this._audiobook.book || {}
},
hasCover() {
return !!this.book.cover
},
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120
return this.width / baseSize
},
title() {
return this.book.title || ''
},
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() {
return this.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.isInvalid
},
isStreaming() {
return this.store.getters['getAudiobookIdStreaming'] === this.audiobookId
},
isMissing() {
return this._audiobook.isMissing
},
isInvalid() {
return this._audiobook.isInvalid
},
hasMissingParts() {
return this._audiobook.hasMissingParts
},
hasInvalidParts() {
return this._audiobook.hasInvalidParts
},
errorText() {
if (this.isMissing) return 'Audiobook directory is missing!'
else if (this.isInvalid) 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'
},
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']
},
_socket() {
return this.$root.socket || this.$nuxt.$root.socket
},
titleFontSize() {
return 0.75 * this.sizeMultiplier
},
authorFontSize() {
return 0.6 * this.sizeMultiplier
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
authorBottom() {
return 0.75 * this.sizeMultiplier
},
titleCleaned() {
if (!this.title) return ''
if (this.title.length > 60) {
return this.title.slice(0, 57) + '...'
}
return this.title
},
authorCleaned() {
if (!this.authorFL) return ''
if (this.authorFL.length > 30) {
return this.authorFL.slice(0, 27) + '...'
}
return this.authorFL
}
},
methods: {
setSelectionMode(val) {
this.isSelectionMode = val
if (!val) this.selected = false
},
setEntity(audiobook) {
this.audiobook = audiobook
},
clickCard(e) {
if (this.isSelectionMode) {
e.stopPropagation()
e.preventDefault()
this.selectBtnClick()
} else {
var router = this.$router || this.$nuxt.$router
if (router) router.push(`/audiobook/${this.audiobookId}`)
}
},
selectBtnClick() {
this.selected = !this.selected
this.$emit('select', this.audiobook)
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
},
setCoverBg() {
if (this.$refs.coverBg) {
this.$refs.coverBg.style.backgroundImage = `url("${this.bookCoverSrc}")`
}
},
imageLoaded() {
this.imageReady = true
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
this.showCoverBg = true
this.$nextTick(this.setCoverBg)
} else {
this.showCoverBg = false
}
}
}
},
mounted() {
if (this.bookMount) {
this.setEntity(this.bookMount)
}
}
}
</script>

View file

@ -0,0 +1,82 @@
<template>
<div ref="card" :id="`collection-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm cursor-pointer z-30" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
width: Number,
height: Number,
bookCoverAspectRatio: Number
},
data() {
return {
collection: null,
isSelectionMode: false,
selected: false
}
},
computed: {
labelFontSize() {
if (this.width < 160) return 0.75
return 0.875
},
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
title() {
return this.collection ? this.collection.name : ''
},
books() {
return this.collection ? this.collection.books || [] : []
},
store() {
return this.$store || this.$nuxt.$store
},
currentLibraryId() {
return this.store.state.libraries.currentLibraryId
}
},
methods: {
setEntity(_collection) {
this.collection = _collection
},
setSelectionMode(val) {
this.isSelectionMode = val
},
clickCard() {
if (!this.collection) return
var router = this.$router || this.$nuxt.$router
router.push(`/collection/${this.collection.id}`)
},
clickEdit() {
this.$emit('edit', this.collection)
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
}
},
mounted() {}
}
</script>

View file

@ -0,0 +1,100 @@
<template>
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm cursor-pointer z-30" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="title" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
index: Number,
width: Number,
height: Number,
bookCoverAspectRatio: Number,
seriesMount: {
type: Object,
default: () => null
},
isCategorized: Boolean
},
data() {
return {
series: null,
isSelectionMode: false,
selected: false,
imageReady: false
}
},
computed: {
labelFontSize() {
if (this.width < 160) return 0.75
return 0.875
},
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
title() {
return this.series ? this.series.name : ''
},
books() {
return this.series ? this.series.books || [] : []
},
store() {
return this.$store || this.$nuxt.$store
},
currentLibraryId() {
return this.store.state.libraries.currentLibraryId
},
seriesId() {
return this.series ? this.$encode(this.series.id) : null
},
hasValidCovers() {
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
return !!validCovers.length
}
},
methods: {
setEntity(_series) {
this.series = _series
},
setSelectionMode(val) {
this.isSelectionMode = val
},
clickCard() {
if (!this.series) return
var router = this.$router || this.$nuxt.$router
router.push(`/bookshelf/series/${this.seriesId}`)
},
imageLoaded() {
this.imageReady = true
},
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
}
},
mounted() {
if (this.seriesMount) {
this.setEntity(this.seriesMount)
}
},
beforeDestroy() {}
}
</script>

View file

@ -0,0 +1,39 @@
<template>
<div class="flex h-full px-1 overflow-hidden">
<covers-group-cover :name="series" :book-items="bookItems" :width="80" :height="60" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="flex-grow px-2 seriesSearchCardContent h-full">
<p class="truncate text-sm">{{ series }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
series: String,
bookItems: {
type: Array,
default: () => []
}
},
data() {
return {}
},
computed: {
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
}
},
methods: {},
mounted() {}
}
</script>
<style>
.seriesSearchCardContent {
width: calc(100% - 80px);
display: flex;
flex-direction: column;
justify-content: center;
}
</style>

View file

@ -0,0 +1,383 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ height: height + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
<div class="w-full h-full relative bg-bg">
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" />
</div>
<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 duration-300 transition-opacity" :style="{ opacity: imageReady ? '1' : '0' }" :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">
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
<div></div>
</div>
</div>
</div>
</div>
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
<img src="/Logo.png" loading="lazy" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
</div>
</div>
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem' }">
<div>
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
</div>
</div>
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
audiobook: {
type: Object,
default: () => {}
},
authorOverride: String,
width: {
type: Number,
default: 120
},
bookCoverAspectRatio: Number,
downloadCover: String
},
data() {
return {
loading: true,
imageFailed: false,
showCoverBg: false,
imageReady: false
}
},
watch: {
cover() {
this.imageFailed = false
}
},
computed: {
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
height() {
return this.width * this.bookCoverAspectRatio
},
book() {
if (!this.audiobook) return {}
return this.audiobook.book || {}
},
title() {
return this.book.title || 'No Title'
},
titleCleaned() {
if (this.title.length > 60) {
return this.title.slice(0, 57) + '...'
}
return this.title
},
author() {
if (this.authorOverride) return this.authorOverride
return this.book.author || 'Unknown'
},
authorCleaned() {
if (this.author.length > 30) {
return this.author.slice(0, 27) + '...'
}
return this.author
},
placeholderUrl() {
return '/book_placeholder.jpg'
},
fullCoverUrl() {
if (this.downloadCover) return this.downloadCover
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
},
hasCover() {
return !!this.book.cover
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120
return this.width / baseSize
},
titleFontSize() {
return 0.75 * this.sizeMultiplier
},
authorFontSize() {
return 0.6 * this.sizeMultiplier
},
placeholderCoverPadding() {
return 0.8 * this.sizeMultiplier
},
authorBottom() {
return 0.75 * this.sizeMultiplier
},
userToken() {
return this.$store.getters['user/getToken']
}
},
methods: {
setCoverBg() {
if (this.$refs.coverBg) {
this.$refs.coverBg.style.backgroundImage = `url("${this.fullCoverUrl}")`
}
},
hideCoverBg() {},
imageLoaded() {
this.loading = false
this.$nextTick(() => {
this.imageReady = true
})
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
this.showCoverBg = true
this.$nextTick(this.setCoverBg)
} else {
this.showCoverBg = false
}
}
},
imageError(err) {
this.loading = false
console.error('ImgError', err)
this.imageFailed = true
}
},
mounted() {}
}
</script>
<style>
/*!
* Load Awesome v1.1.0 (http://github.danielcardoso.net/load-awesome/)
* Copyright 2015 Daniel Cardoso <@DanielCardoso>
* Licensed under MIT
*/
.la-ball-spin-clockwise,
.la-ball-spin-clockwise > div {
position: relative;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
}
.la-ball-spin-clockwise {
display: block;
font-size: 0;
color: #fff;
}
.la-ball-spin-clockwise.la-dark {
color: #262626;
}
.la-ball-spin-clockwise > div {
display: inline-block;
float: none;
background-color: currentColor;
border: 0 solid currentColor;
}
.la-ball-spin-clockwise {
width: 32px;
height: 32px;
}
.la-ball-spin-clockwise > div {
position: absolute;
top: 50%;
left: 50%;
width: 8px;
height: 8px;
margin-top: -4px;
margin-left: -4px;
border-radius: 100%;
-webkit-animation: ball-spin-clockwise 1s infinite ease-in-out;
-moz-animation: ball-spin-clockwise 1s infinite ease-in-out;
-o-animation: ball-spin-clockwise 1s infinite ease-in-out;
animation: ball-spin-clockwise 1s infinite ease-in-out;
}
.la-ball-spin-clockwise > div:nth-child(1) {
top: 5%;
left: 50%;
-webkit-animation-delay: -0.875s;
-moz-animation-delay: -0.875s;
-o-animation-delay: -0.875s;
animation-delay: -0.875s;
}
.la-ball-spin-clockwise > div:nth-child(2) {
top: 18.1801948466%;
left: 81.8198051534%;
-webkit-animation-delay: -0.75s;
-moz-animation-delay: -0.75s;
-o-animation-delay: -0.75s;
animation-delay: -0.75s;
}
.la-ball-spin-clockwise > div:nth-child(3) {
top: 50%;
left: 95%;
-webkit-animation-delay: -0.625s;
-moz-animation-delay: -0.625s;
-o-animation-delay: -0.625s;
animation-delay: -0.625s;
}
.la-ball-spin-clockwise > div:nth-child(4) {
top: 81.8198051534%;
left: 81.8198051534%;
-webkit-animation-delay: -0.5s;
-moz-animation-delay: -0.5s;
-o-animation-delay: -0.5s;
animation-delay: -0.5s;
}
.la-ball-spin-clockwise > div:nth-child(5) {
top: 94.9999999966%;
left: 50.0000000005%;
-webkit-animation-delay: -0.375s;
-moz-animation-delay: -0.375s;
-o-animation-delay: -0.375s;
animation-delay: -0.375s;
}
.la-ball-spin-clockwise > div:nth-child(6) {
top: 81.8198046966%;
left: 18.1801949248%;
-webkit-animation-delay: -0.25s;
-moz-animation-delay: -0.25s;
-o-animation-delay: -0.25s;
animation-delay: -0.25s;
}
.la-ball-spin-clockwise > div:nth-child(7) {
top: 49.9999750815%;
left: 5.0000051215%;
-webkit-animation-delay: -0.125s;
-moz-animation-delay: -0.125s;
-o-animation-delay: -0.125s;
animation-delay: -0.125s;
}
.la-ball-spin-clockwise > div:nth-child(8) {
top: 18.179464974%;
left: 18.1803700518%;
-webkit-animation-delay: 0s;
-moz-animation-delay: 0s;
-o-animation-delay: 0s;
animation-delay: 0s;
}
.la-ball-spin-clockwise.la-sm {
width: 16px;
height: 16px;
}
.la-ball-spin-clockwise.la-sm > div {
width: 4px;
height: 4px;
margin-top: -2px;
margin-left: -2px;
}
.la-ball-spin-clockwise.la-2x {
width: 64px;
height: 64px;
}
.la-ball-spin-clockwise.la-2x > div {
width: 16px;
height: 16px;
margin-top: -8px;
margin-left: -8px;
}
.la-ball-spin-clockwise.la-3x {
width: 96px;
height: 96px;
}
.la-ball-spin-clockwise.la-3x > div {
width: 24px;
height: 24px;
margin-top: -12px;
margin-left: -12px;
}
/*
* Animation
*/
@-webkit-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-webkit-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-webkit-transform: scale(0);
transform: scale(0);
}
}
@-moz-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-moz-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-moz-transform: scale(0);
transform: scale(0);
}
}
@-o-keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-o-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-o-transform: scale(0);
transform: scale(0);
}
}
@keyframes ball-spin-clockwise {
0%,
100% {
opacity: 1;
-webkit-transform: scale(1);
-moz-transform: scale(1);
-o-transform: scale(1);
transform: scale(1);
}
20% {
opacity: 1;
}
80% {
opacity: 0;
-webkit-transform: scale(0);
-moz-transform: scale(0);
-o-transform: scale(0);
transform: scale(0);
}
}
</style>

View file

@ -0,0 +1,61 @@
<template>
<div class="relative rounded-sm overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
<div v-if="hasOwnCover" class="w-full h-full relative rounded-sm">
<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" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" />
</div>
<div v-else-if="books.length" class="flex justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<covers-book-cover :audiobook="books[0]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<covers-book-cover v-if="books.length > 1" :audiobook="books[1]" :width="width / 2" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
<p class="font-book text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
</div>
</div>
</template>
<script>
export default {
props: {
bookItems: {
type: Array,
default: () => []
},
width: Number,
height: Number,
bookCoverAspectRatio: Number
},
data() {
return {
imageFailed: false,
showCoverBg: false
}
},
computed: {
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
hasOwnCover() {
return false
},
fullCoverUrl() {
return null
},
books() {
return this.bookItems || []
}
},
methods: {
imageError() {},
imageLoaded() {}
},
mounted() {}
}
</script>

View file

@ -0,0 +1,225 @@
<template>
<div ref="wrapper" :style="{ height: height + 'px', width: width + 'px' }" class="relative">
<div v-if="noValidCovers" class="absolute top-0 left-0 w-full h-full flex items-center justify-center box-shadow-book" :style="{ padding: `${sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier + 'rem' }">{{ name }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
id: String,
name: String,
bookItems: {
type: Array,
default: () => []
},
width: Number,
height: Number,
bookCoverAspectRatio: Number
},
data() {
return {
noValidCovers: false,
coverDiv: null,
isHovering: false,
coverWrapperEl: null,
coverImageEls: [],
coverWidth: 0,
offsetIncrement: 0,
isFannedOut: false,
isDetached: false,
isAttaching: false,
windowWidth: 0,
isInit: false
}
},
watch: {
bookItems: {
immediate: true,
handler(newVal) {
if (newVal) {
// ensure wrapper is initialized
this.$nextTick(this.init)
}
}
}
},
computed: {
sizeMultiplier() {
if (this.bookCoverAspectRatio === 1) return this.width / (100 * 1.6 * 2)
return this.width / 200
},
store() {
return this.$store || this.$nuxt.$store
},
router() {
return this.$router || this.$nuxt.$router
}
},
methods: {
detchCoverWrapper() {
if (!this.coverWrapperEl || !this.$refs.wrapper || this.isDetached) return
this.coverWrapperEl.remove()
this.isDetached = true
document.body.appendChild(this.coverWrapperEl)
this.coverWrapperEl.addEventListener('mouseleave', this.mouseleaveCover)
this.coverWrapperEl.style.position = 'absolute'
this.coverWrapperEl.style.zIndex = 40
this.updatePosition()
},
attachCoverWrapper() {
if (!this.coverWrapperEl || !this.$refs.wrapper || !this.isDetached) return
this.coverWrapperEl.remove()
this.coverWrapperEl.style.position = 'relative'
this.coverWrapperEl.style.left = 'unset'
this.coverWrapperEl.style.top = 'unset'
this.coverWrapperEl.style.width = this.$refs.wrapper.clientWidth + 'px'
this.$refs.wrapper.appendChild(this.coverWrapperEl)
this.isDetached = false
},
updatePosition() {
var rect = this.$refs.wrapper.getBoundingClientRect()
this.coverWrapperEl.style.top = rect.top + window.scrollY + 'px'
this.coverWrapperEl.style.left = rect.left + window.scrollX + 4 + 'px'
this.coverWrapperEl.style.height = rect.height + 'px'
this.coverWrapperEl.style.width = rect.width + 'px'
},
getCoverUrl(book) {
return this.store.getters['audiobooks/getBookCoverSrc'](book, '')
},
async buildCoverImg(coverData, bgCoverWidth, offsetLeft, zIndex, forceCoverBg = false) {
var src = coverData.coverUrl
var showCoverBg =
forceCoverBg ||
(await new Promise((resolve) => {
var image = new Image()
image.onload = () => {
var { naturalWidth, naturalHeight } = image
var aspectRatio = naturalHeight / naturalWidth
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
if (arDiff > 0.15) {
resolve(true)
} else {
resolve(false)
}
}
image.onerror = (err) => {
console.error(err)
resolve(false)
}
image.src = src
}))
var imgdiv = document.createElement('div')
imgdiv.style.height = this.height + 'px'
imgdiv.style.width = bgCoverWidth + 'px'
imgdiv.style.left = offsetLeft + 'px'
imgdiv.style.zIndex = zIndex
imgdiv.dataset.audiobookId = coverData.id
imgdiv.dataset.volumeNumber = coverData.volumeNumber || ''
imgdiv.className = 'absolute top-0 box-shadow-book transition-transform'
imgdiv.style.boxShadow = '4px 0px 4px #11111166'
// imgdiv.style.transform = 'skew(0deg, 15deg)'
if (showCoverBg) {
var coverbgwrapper = document.createElement('div')
coverbgwrapper.className = 'absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary'
var coverbg = document.createElement('div')
coverbg.className = 'absolute cover-bg'
coverbg.style.backgroundImage = `url("${src}")`
coverbgwrapper.appendChild(coverbg)
imgdiv.appendChild(coverbgwrapper)
}
var img = document.createElement('img')
img.src = src
img.className = 'absolute top-0 left-0 w-full h-full'
img.style.objectFit = showCoverBg ? 'contain' : 'cover'
imgdiv.appendChild(img)
return imgdiv
},
async init() {
if (this.isInit) return
this.isInit = true
if (this.coverDiv) {
this.coverDiv.remove()
this.coverDiv = null
}
var validCovers = this.bookItems
.map((bookItem) => {
return {
id: bookItem.id,
volumeNumber: bookItem.book ? bookItem.book.volumeNumber : null,
coverUrl: this.getCoverUrl(bookItem)
}
})
.filter((b) => b.coverUrl !== '')
if (!validCovers.length) {
this.noValidCovers = true
return
}
this.noValidCovers = false
var coverWidth = this.width
var widthPer = this.width
if (validCovers.length > 1) {
coverWidth = this.height / this.bookCoverAspectRatio
widthPer = (this.width - coverWidth) / (validCovers.length - 1)
}
this.coverWidth = coverWidth
this.offsetIncrement = widthPer
var outerdiv = document.createElement('div')
outerdiv.id = `group-cover-${this.id || this.$encode(this.name)}`
this.coverWrapperEl = outerdiv
outerdiv.className = 'w-full h-full relative box-shadow-book'
var coverImageEls = []
var offsetLeft = 0
for (let i = 0; i < validCovers.length; i++) {
offsetLeft = widthPer * i
var zIndex = validCovers.length - i
var img = await this.buildCoverImg(validCovers[i], coverWidth, offsetLeft, zIndex, validCovers.length === 1)
outerdiv.appendChild(img)
coverImageEls.push(img)
}
this.coverImageEls = coverImageEls
if (this.$refs.wrapper) {
this.coverDiv = outerdiv
this.$refs.wrapper.appendChild(outerdiv)
}
}
},
mounted() {
this.windowWidth = window.innerWidth
},
beforeDestroy() {
if (this.coverWrapperEl) this.coverWrapperEl.remove()
if (this.coverImageEls && this.coverImageEls.length) {
this.coverImageEls.forEach((el) => el.remove())
}
if (this.coverDiv) this.coverDiv.remove()
}
}
</script>

View file

@ -5,9 +5,8 @@
<nuxt-link to="/bookshelf/series" v-if="selectedSeriesName" class="pt-1"> <nuxt-link to="/bookshelf/series" v-if="selectedSeriesName" class="pt-1">
<span class="material-icons">arrow_back</span> <span class="material-icons">arrow_back</span>
</nuxt-link> </nuxt-link>
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ numEntities }} {{ entityTitle }}</p> <p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p>
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }}</p> <p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
<div class="flex-grow" /> <div class="flex-grow" />
<template v-if="page === 'library'"> <template v-if="page === 'library'">
<span class="material-icons px-2" @click="changeView">{{ viewIcon }}</span> <span class="material-icons px-2" @click="changeView">{{ viewIcon }}</span>
@ -32,12 +31,13 @@ export default {
showSortModal: false, showSortModal: false,
showFilterModal: false, showFilterModal: false,
settings: {}, settings: {},
isListView: false isListView: false,
totalEntities: 0
} }
}, },
computed: { computed: {
hasFilters() { hasFilters() {
return this.$store.getters['user/getUserSetting']('filterBy') !== 'all' return this.$store.getters['user/getUserSetting']('mobileFilterBy') !== 'all'
}, },
page() { page() {
var routeName = this.$route.name || '' var routeName = this.$route.name || ''
@ -47,42 +47,17 @@ export default {
return this.$route.query || {} return this.$route.query || {}
}, },
entityTitle() { entityTitle() {
if (this.page === 'library') return 'Audiobooks' if (this.page === 'library') return 'Books'
else if (this.page === 'series') { else if (this.page === 'series') {
if (this.selectedSeriesName) return 'Books in ' + this.selectedSeriesName
return 'Series' return 'Series'
} else if (this.page === 'collections') { } else if (this.page === 'collections') {
return 'Collections' return 'Collections'
} }
return '' return ''
}, },
numEntities() {
if (this.page === 'library') return this.numAudiobooks
else if (this.page === 'series') {
if (this.selectedSeriesName) return this.numBooksInSeries
return this.series.length
} else if (this.page === 'collections') return this.numCollections
return 0
},
series() {
return this.$store.getters['audiobooks/getSeriesGroups']() || []
},
numCollections() {
return (this.$store.state.user.collections || []).length
},
numAudiobooks() {
return this.$store.getters['audiobooks/getFiltered']().length
},
numBooksInSeries() {
return this.selectedSeries ? (this.selectedSeries.books || []).length : 0
},
selectedSeries() {
if (!this.selectedSeriesName) return null
return this.series.find((s) => s.name === this.selectedSeriesName)
},
selectedSeriesName() { selectedSeriesName() {
if (this.page === 'series' && this.routeQuery.series) { if (this.page === 'series' && this.$route.params.id) {
return this.$decode(this.routeQuery.series) return this.$decode(this.$route.params.id)
} }
return null return null
}, },
@ -120,13 +95,18 @@ export default {
for (const key in settings) { for (const key in settings) {
this.settings[key] = settings[key] this.settings[key] = settings[key]
} }
},
setTotalEntities(total) {
this.totalEntities = total
} }
}, },
mounted() { mounted() {
this.init() this.init()
this.$eventBus.$on('bookshelf-total-entities', this.setTotalEntities)
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated }) this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
}, },
beforeDestroy() { beforeDestroy() {
this.$eventBus.$off('bookshelf-total-entities', this.setTotalEntities)
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar') this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
} }
} }

View file

@ -81,10 +81,20 @@ export default {
value: 'authors', value: 'authors',
sublist: true sublist: true
}, },
{
text: 'Narrator',
value: 'narrators',
sublist: true
},
{ {
text: 'Progress', text: 'Progress',
value: 'progress', value: 'progress',
sublist: true sublist: true
},
{
text: 'Issues',
value: 'issues',
sublist: false
} }
] ]
} }
@ -118,16 +128,19 @@ export default {
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
}, },
genres() { genres() {
return this.$store.getters['audiobooks/getGenresUsed'] return this.filterData.genres || []
}, },
tags() { tags() {
return this.$store.state.audiobooks.tags return this.filterData.tags || []
}, },
series() { series() {
return this.$store.state.audiobooks.series return this.filterData.series || []
}, },
authors() { authors() {
return this.$store.getters['audiobooks/getUniqueAuthors'] return this.filterData.authors || []
},
narrators() {
return this.filterData.narrators || []
}, },
progress() { progress() {
return ['Read', 'Unread', 'In Progress'] return ['Read', 'Unread', 'In Progress']
@ -139,6 +152,9 @@ export default {
value: this.$encode(item) value: this.$encode(item)
} }
}) })
},
filterData() {
return this.$store.state.libraries.filterData || {}
} }
}, },
methods: { methods: {

View file

@ -53,10 +53,8 @@ export default {
methods: { methods: {
async clickedOption(lib) { async clickedOption(lib) {
this.show = false this.show = false
await this.$store.dispatch('libraries/fetch', lib.id)
this.$store.commit('libraries/setCurrentLibrary', lib.id) this.$eventBus.$emit('library-changed', lib.id)
await this.$store.dispatch('audiobooks/load')
this.$localStore.setCurrentLibrary(lib) this.$localStore.setCurrentLibrary(lib)
} }
}, },

View file

@ -9,7 +9,7 @@
<p v-if="totalDuration">{{ totalDurationPretty }}</p> <p v-if="totalDuration">{{ totalDurationPretty }}</p>
</div> </div>
<template v-for="book in booksCopy"> <template v-for="book in booksCopy">
<tables-collection-book-table-row :key="book.id" :book="book" :collection-id="collectionId" class="item" :class="drag ? '' : 'collection-book-item'" @edit="editBook" /> <tables-collection-book-table-row :key="book.id" :book="book" :collection-id="collectionId" class="item collection-book-item" @edit="editBook" />
</template> </template>
</div> </div>
</template> </template>

View file

@ -1,117 +0,0 @@
<template>
<div>
<nuxt-link v-if="isConnected" to="/account" class="p-2 bg-white bg-opacity-10 border border-white border-opacity-40 rounded-full h-11 w-11 flex items-center justify-center">
<span class="material-icons">person</span>
</nuxt-link>
<div v-else-if="processing" class="relative p-2 bg-warning bg-opacity-10 border border-warning border-opacity-40 rounded-full h-11 w-11 flex items-center justify-center">
<div class="loader-dots block relative w-10 h-2.5">
<div class="absolute top-0 mt-0.5 w-1.5 h-1.5 rounded-full bg-warning"></div>
<div class="absolute top-0 mt-0.5 w-1.5 h-1.5 rounded-full bg-warning"></div>
<div class="absolute top-0 mt-0.5 w-1.5 h-1.5 rounded-full bg-warning"></div>
<div class="absolute top-0 mt-0.5 w-1.5 h-1.5 rounded-full bg-warning"></div>
</div>
</div>
<nuxt-link v-else to="/connect" class="relative p-2 bg-warning bg-opacity-10 border border-warning border-opacity-40 rounded-full h-11 w-11 flex items-center justify-center">
<span class="material-icons">{{ networkIcon }}</span>
<!-- <div class="absolute top-0 left-0"> -->
<!-- <div class="absolute -top-5 -right-5 overflow-hidden">
<svg class="w-20 h-20 animate-spin" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<path clip-rule="evenodd" d="M15.165 8.53a.5.5 0 01-.404.58A7 7 0 1023 16a.5.5 0 011 0 8 8 0 11-9.416-7.874.5.5 0 01.58.404z" fill="currentColor" fill-rule="evenodd" />
</svg>
</div> -->
</nuxt-link>
</div>
</template>
<script>
export default {
props: {},
data() {
return {
processing: false,
serverUrl: null,
isConnected: false
}
},
watch: {
networkConnected(newVal) {
if (newVal) {
this.init()
}
}
},
computed: {
user() {
return this.$store.state.user.user
},
networkIcon() {
if (!this.networkConnected) return 'signal_wifi_connected_no_internet_4'
return 'cloud_off'
},
networkConnected() {
return this.$store.state.networkConnected
}
},
methods: {
socketConnected(val) {
this.processing = false
this.isConnected = val
},
async init() {
if (this.isConnected) {
return
}
if (!this.$server) {
console.error('Invalid server not initialized')
return
}
if (!this.networkConnected) return
var localServerUrl = await this.$localStore.getServerUrl()
var localUserToken = await this.$localStore.getToken()
if (localServerUrl) {
this.serverUrl = localServerUrl
// Server and Token are stored
if (localUserToken) {
this.processing = true
var isSocketAlreadyEstablished = this.$server.socket
var success = await this.$server.connect(localServerUrl, localUserToken)
if (!success && !this.$server.url) {
this.processing = false
this.serverUrl = null
} else if (!success) {
this.processing = false
} else if (isSocketAlreadyEstablished) {
// No need to wait for connect event
this.processing = false
}
} else {
// Server only is stored
var success = await this.$server.check(this.serverUrl)
if (!success) {
console.error('Invalid server')
this.$server.setServerUrl(null)
}
}
}
}
},
mounted() {
if (!this.$server) {
console.error('Server not initalized in connection icon')
return
}
if (this.$server.connected) {
this.isConnected = true
}
this.$server.on('connected', this.socketConnected)
this.init()
},
beforeDestroy() {
if (this.$server) this.$server.off('connected', this.socketConnected)
}
}
</script>

View file

@ -39,6 +39,12 @@ export default {
}, },
networkConnected() { networkConnected() {
return this.$store.state.networkConnected return this.$store.state.networkConnected
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
isSocketConnected() {
return this.$store.state.socketConnected
} }
}, },
methods: { methods: {
@ -49,6 +55,7 @@ export default {
// Load libraries // Load libraries
this.$store.dispatch('libraries/load') this.$store.dispatch('libraries/load')
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
} }
}, },
socketConnectionFailed(err) { socketConnectionFailed(err) {
@ -223,23 +230,23 @@ export default {
var mediaFolder = mediaFolders.find((mf) => mf.name === download.folderName) var mediaFolder = mediaFolders.find((mf) => mf.name === download.folderName)
if (mediaFolder) { if (mediaFolder) {
console.log('Found download ' + download.folderName) console.log('Found download ' + download.folderName)
if (download.isMissing) {
if (download.isPreparing || download.isDownloading) { download.isMissing = false
download.isIncomplete = true this.$store.commit('downloads/addUpdateDownload', download)
download.isPreparing = false
download.isDownloading = false
} }
this.$store.commit('downloads/addUpdateDownload', download)
this.$store.commit('audiobooks/addUpdate', download.audiobook)
} else { } else {
console.error('Download not found ' + download.folderName) console.error('Download not found ' + download.folderName)
download.isMissing = true if (!download.isMissing) {
download.isPreparing = false download.isMissing = true
download.isDownloading = false this.$store.commit('downloads/addUpdateDownload', download)
this.$store.commit('downloads/addUpdateDownload', download) }
} }
}) })
// Match media scanned folders with books from server
if (this.isSocketConnected) {
await this.$store.dispatch('downloads/linkOrphanDownloads')
}
}, },
async initMediaStore() { async initMediaStore() {
// Request and setup listeners for media files on native // Request and setup listeners for media files on native
@ -253,7 +260,8 @@ export default {
this.onDownloadProgress(data) this.onDownloadProgress(data)
}) })
var downloads = (await this.$sqlStore.getAllDownloads()) || [] // var downloads = (await this.$sqlStore.getAllDownloads()) || []
var downloads = await this.$store.dispatch('downloads/loadFromStorage')
var downloadFolder = await this.$localStore.getDownloadFolder() var downloadFolder = await this.$localStore.getDownloadFolder()
if (downloadFolder) { if (downloadFolder) {

View file

@ -0,0 +1,87 @@
import Vue from 'vue'
import LazyBookCard from '@/components/cards/LazyBookCard'
import LazySeriesCard from '@/components/cards/LazySeriesCard'
import LazyCollectionCard from '@/components/cards/LazyCollectionCard'
export default {
data() {
return {
cardsHelpers: {
mountEntityCard: this.mountEntityCard
}
}
},
methods: {
getComponentClass() {
if (this.entityName === 'series') return Vue.extend(LazySeriesCard)
if (this.entityName === 'collections') return Vue.extend(LazyCollectionCard)
return Vue.extend(LazyBookCard)
},
async mountEntityCard(index) {
var shelf = Math.floor(index / this.entitiesPerShelf)
var shelfEl = document.getElementById(`shelf-${shelf}`)
if (!shelfEl) {
console.error('invalid shelf', shelf, 'book index', index)
return
}
this.entityIndexesMounted.push(index)
if (this.entityComponentRefs[index]) {
var bookComponent = this.entityComponentRefs[index]
shelfEl.appendChild(bookComponent.$el)
if (this.isSelectionMode) {
bookComponent.setSelectionMode(true)
if (this.selectedAudiobooks.includes(bookComponent.audiobookId) || this.isSelectAll) {
bookComponent.selected = true
} else {
bookComponent.selected = false
}
} else {
bookComponent.setSelectionMode(false)
}
bookComponent.isHovering = false
return
}
var shelfOffsetY = this.isBookEntity ? 24 : 16
var row = index % this.entitiesPerShelf
var shelfOffsetX = row * this.totalEntityCardWidth + this.bookshelfMarginLeft
var ComponentClass = this.getComponentClass()
var props = {
index,
width: this.entityWidth,
height: this.entityHeight,
bookCoverAspectRatio: this.bookCoverAspectRatio
}
if (this.entityName === 'series-books') props.showVolumeNumber = true
var _this = this
var instance = new ComponentClass({
propsData: props,
created() {
// this.$on('edit', (entity) => {
// if (_this.editEntity) _this.editEntity(entity)
// })
// this.$on('select', (entity) => {
// if (_this.selectEntity) _this.selectEntity(entity)
// })
}
})
this.entityComponentRefs[index] = instance
instance.$mount()
instance.$el.style.transform = `translate3d(${shelfOffsetX}px, ${shelfOffsetY}px, 0px)`
instance.$el.classList.add('absolute', 'top-0', 'left-0', 'mx-3')
shelfEl.appendChild(instance.$el)
if (this.entities[index]) {
instance.setEntity(this.entities[index])
}
if (this.isSelectionMode) {
instance.setSelectionMode(true)
if (instance.audiobookId && this.selectedAudiobooks.includes(instance.audiobookId) || this.isSelectAll) {
instance.selected = true
}
}
},
}
}

View file

@ -43,7 +43,8 @@ export default {
'@/plugins/my-native-audio.js', '@/plugins/my-native-audio.js',
'@/plugins/audio-downloader.js', '@/plugins/audio-downloader.js',
'@/plugins/storage-manager.js', '@/plugins/storage-manager.js',
'@/plugins/toast.js' '@/plugins/toast.js',
'@/plugins/constants.js'
], ],
components: true, components: true,

65
objects/TouchEvent.js Normal file
View file

@ -0,0 +1,65 @@
// SOURCE: https://stackoverflow.com/a/69617795/7431543
export default class TouchEvent {
static SWPIE_THRESHOLD = 50 // Minumum difference in pixels at which a swipe gesture is detected
static SWIPE_LEFT = 1
static SWIPE_RIGHT = 2
static SWIPE_UP = 3
static SWIPE_DOWN = 4
constructor(startEvent, endEvent) {
this.startEvent = startEvent
this.endEvent = endEvent || null
}
isSwipeLeft() {
return this.getSwipeDirection() == TouchEvent.SWIPE_LEFT
}
isSwipeRight() {
return this.getSwipeDirection() == TouchEvent.SWIPE_RIGHT
}
isSwipeUp() {
return this.getSwipeDirection() == TouchEvent.SWIPE_UP
}
isSwipeDown() {
return this.getSwipeDirection() == TouchEvent.SWIPE_DOWN
}
getSwipeDirection() {
let start = this.startEvent.changedTouches[0]
let end = this.endEvent.changedTouches[0]
if (!start || !end) {
return null
}
let horizontalDifference = start.screenX - end.screenX
let verticalDifference = start.screenY - end.screenY
// Horizontal difference dominates
if (Math.abs(horizontalDifference) > Math.abs(verticalDifference)) {
if (horizontalDifference >= TouchEvent.SWPIE_THRESHOLD) {
return TouchEvent.SWIPE_LEFT
} else if (horizontalDifference <= -TouchEvent.SWPIE_THRESHOLD) {
return TouchEvent.SWIPE_RIGHT
}
// Verical or no difference dominates
} else {
if (verticalDifference >= TouchEvent.SWPIE_THRESHOLD) {
return TouchEvent.SWIPE_UP
} else if (verticalDifference <= -TouchEvent.SWPIE_THRESHOLD) {
return TouchEvent.SWIPE_DOWN
}
}
return null
}
setEndEvent(endEvent) {
this.endEvent = endEvent
}
}

View file

@ -2,31 +2,12 @@
<div class="w-full h-full"> <div class="w-full h-full">
<home-bookshelf-nav-bar /> <home-bookshelf-nav-bar />
<home-bookshelf-toolbar v-show="!isHome" /> <home-bookshelf-toolbar v-show="!isHome" />
<div class="main-content overflow-y-auto overflow-x-hidden relative" :class="isHome ? 'home-page' : ''"> <div id="bookshelf-wrapper" class="main-content overflow-y-auto overflow-x-hidden relative" :class="isHome ? 'home-page' : ''">
<nuxt-child /> <nuxt-child />
<div v-if="isLoading" class="absolute top-0 left-0 w-full h-full flex items-center justify-center"> <!-- <div v-if="isLoading" class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
<ui-loading-indicator /> <ui-loading-indicator />
</div> </div> -->
<div v-else-if="!audiobooks.length" class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
<div>
<p class="mb-4 text-center text-xl">
Bookshelf empty<span v-show="isSocketConnected">
for library <strong>{{ currentLibraryName }}</strong></span
>
</p>
<div class="w-full" v-if="!isSocketConnected">
<div class="flex justify-center items-center mb-3">
<span class="material-icons text-error text-lg">cloud_off</span>
<p class="pl-2 text-error text-sm">Audiobookshelf server not connected.</p>
</div>
<p class="px-4 text-center text-error absolute bottom-12 left-0 right-0 mx-auto"><strong>Important!</strong> This app requires that you are running <u>your own server</u> and does not provide any content.</p>
</div>
<div class="flex justify-center">
<ui-btn v-if="!isSocketConnected" small @click="$router.push('/connect')" class="w-32"> Connect </ui-btn>
</div>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>
@ -40,12 +21,6 @@ export default {
isHome() { isHome() {
return this.$route.name === 'bookshelf' return this.$route.name === 'bookshelf'
}, },
isLoading() {
return this.$store.state.audiobooks.isLoading
},
audiobooks() {
return this.$store.state.audiobooks.audiobooks
},
currentLibrary() { currentLibrary() {
return this.$store.getters['libraries/getCurrentLibrary'] return this.$store.getters['libraries/getCurrentLibrary']
}, },
@ -57,35 +32,23 @@ export default {
} }
}, },
methods: { methods: {
async loadAudiobooks() {
var currentLibrary = await this.$localStore.getCurrentLibrary()
if (currentLibrary) {
this.$store.commit('libraries/setCurrentLibrary', currentLibrary.id)
}
this.$store.dispatch('audiobooks/load')
},
async loadCollections() { async loadCollections() {
this.$store.dispatch('user/loadUserCollections') this.$store.dispatch('user/loadUserCollections')
}, },
socketConnected(isConnected) { socketConnected(isConnected) {
if (isConnected) { // if (isConnected) {
console.log('Connected - Load from server') // console.log('Connected - Load from server')
this.loadAudiobooks() // this.loadAudiobooks()
if (this.$route.name === 'bookshelf-collections') this.loadCollections() // if (this.$route.name === 'bookshelf-collections') this.loadCollections()
} else { // } else {
console.log('Disconnected - Reset to local storage') // console.log('Disconnected - Reset to local storage')
this.$store.commit('audiobooks/reset') // this.$store.commit('audiobooks/reset')
this.$store.dispatch('audiobooks/useDownloaded') // this.$store.dispatch('audiobooks/useDownloaded')
} // }
} }
}, },
mounted() { mounted() {
this.$server.on('connected', this.socketConnected) this.$server.on('connected', this.socketConnected)
if (this.$server.connected) {
this.loadAudiobooks()
} else {
console.log('Bookshelf - Server not connected using downloaded')
}
}, },
beforeDestroy() { beforeDestroy() {
this.$server.off('connected', this.socketConnected) this.$server.off('connected', this.socketConnected)
@ -97,6 +60,7 @@ export default {
.main-content { .main-content {
max-height: calc(100% - 72px); max-height: calc(100% - 72px);
min-height: calc(100% - 72px); min-height: calc(100% - 72px);
max-width: 100vw;
} }
.main-content.home-page { .main-content.home-page {
max-height: calc(100% - 36px); max-height: calc(100% - 36px);

View file

@ -1,9 +1,10 @@
<template> <template>
<div class="w-full h-full"> <bookshelf-lazy-bookshelf page="collections" />
<!-- <div class="w-full h-full">
<template v-for="(shelf, index) in shelves"> <template v-for="(shelf, index) in shelves">
<bookshelf-group-shelf :key="shelf.id" group-type="collection" :groups="shelf.groups" :style="{ zIndex: shelves.length - index }" /> <bookshelf-group-shelf :key="shelf.id" group-type="collection" :groups="shelf.groups" :style="{ zIndex: shelves.length - index }" />
</template> </template>
</div> </div> -->
</template> </template>
<script> <script>

View file

@ -1,8 +1,28 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full">
<template v-for="(shelf, index) in shelves"> <template v-for="(shelf, index) in shelves">
<bookshelf-shelf :key="shelf.id" :label="shelf.label" :books="shelf.books" :style="{ zIndex: shelves.length - index }" /> <bookshelf-shelf :key="shelf.id" :label="shelf.label" :entities="shelf.entities" :type="shelf.type" :style="{ zIndex: shelves.length - index }" />
</template> </template>
<div v-if="!shelves.length" class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
<div>
<p class="mb-4 text-center text-xl">
Bookshelf empty<span v-show="isSocketConnected">
for library <strong>{{ currentLibraryName }}</strong></span
>
</p>
<div class="w-full" v-if="!isSocketConnected">
<div class="flex justify-center items-center mb-3">
<span class="material-icons text-error text-lg">cloud_off</span>
<p class="pl-2 text-error text-sm">Audiobookshelf server not connected.</p>
</div>
<p class="px-4 text-center text-error absolute bottom-12 left-0 right-0 mx-auto"><strong>Important!</strong> This app requires that you are running <u>your own server</u> and does not provide any content.</p>
</div>
<div class="flex justify-center">
<ui-btn v-if="!isSocketConnected" small @click="$router.push('/connect')" class="w-32"> Connect </ui-btn>
</div>
</div>
</div>
</div> </div>
</template> </template>
@ -10,13 +30,19 @@
export default { export default {
data() { data() {
return { return {
settings: {} shelves: [],
loading: true
} }
}, },
computed: { computed: {
books() { books() {
// return this.$store.getters['audiobooks/getFilteredAndSorted']() return this.$store.getters['downloads/getAudiobooks']
return this.$store.state.audiobooks.audiobooks },
isSocketConnected() {
return this.$store.state.socketConnected
},
currentLibraryName() {
return this.$store.getters['libraries/getCurrentLibraryName']
}, },
booksWithUserAbData() { booksWithUserAbData() {
var books = this.books.map((b) => { var books = this.books.map((b) => {
@ -50,14 +76,15 @@ export default {
}) })
return books.slice(0, 10) return books.slice(0, 10)
}, },
shelves() { downloadOnlyShelves() {
var shelves = [] var shelves = []
if (this.booksCurrentlyReading.length) { if (this.booksCurrentlyReading.length) {
shelves.push({ shelves.push({
id: 'recent', id: 'recent',
label: 'Continue Reading', label: 'Continue Reading',
books: this.booksCurrentlyReading type: 'books',
entities: this.booksCurrentlyReading
}) })
} }
@ -65,7 +92,8 @@ export default {
shelves.push({ shelves.push({
id: 'added', id: 'added',
label: 'Recently Added', label: 'Recently Added',
books: this.booksRecentlyAdded type: 'books',
entities: this.booksRecentlyAdded
}) })
} }
@ -73,22 +101,58 @@ export default {
shelves.push({ shelves.push({
id: 'read', id: 'read',
label: 'Read Again', label: 'Read Again',
books: this.booksRead type: 'books',
entities: this.booksRead
}) })
} }
return shelves return shelves
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
} }
}, },
methods: { methods: {
async init() { async fetchCategories() {
this.settings = { ...this.$store.state.user.settings } var categories = await this.$axios
.$get(`/api/libraries/${this.currentLibraryId}/categories`)
// var bookshelfView = await this.$localStore.getBookshelfView() .then((data) => {
// this.isListView = bookshelfView === 'list' return data
// this.bookshelfReady = true })
// console.log('Bookshelf view', bookshelfView) .catch((error) => {
console.error('Failed to fetch categories', error)
return []
})
this.shelves = categories
},
async socketInit(isConnected) {
if (isConnected) {
console.log('Connected - Load from server')
await this.fetchCategories()
} else {
console.log('Disconnected - Reset to local storage')
this.shelves = this.downloadOnlyShelves
}
this.loading = false
},
async libraryChanged(libid) {
console.log('Library changed', libid)
if (this.isSocketConnected) {
await this.fetchCategories()
} else {
this.shelves = this.downloadOnlyShelves
}
} }
}, },
mounted() {} mounted() {
this.$server.on('initialized', this.socketInit)
this.$eventBus.$on('library-changed', this.libraryChanged)
if (this.$server.initialized) {
this.fetchCategories()
}
},
beforeDestroy() {
this.$server.off('initialized', this.socketInit)
this.$eventBus.$off('library-changed', this.libraryChanged)
}
} }
</script> </script>

View file

@ -1,78 +1,14 @@
<template> <template>
<div class="w-full h-full"> <bookshelf-lazy-bookshelf page="books" />
<div class="w-full h-full" v-show="!isListView">
<template v-for="(shelf, index) in shelves">
<bookshelf-library-shelf :key="shelf.id" :books="shelf.books" :style="{ zIndex: shelves.length - index }" />
</template>
</div>
<div class="w-full h-full px-4 py-2" v-show="isListView">
<template v-for="book in books">
<app-bookshelf-list-row :key="book.id" :audiobook="book" :page-width="pageWidth" class="my-2" />
</template>
</div>
<div v-show="!books.length && hasFilters" class="w-full py-16 text-center text-xl">
<div class="py-4">No Books</div>
<ui-btn @click="clearFilter">Clear Filter</ui-btn>
</div>
</div>
</template> </template>
<script> <script>
export default { export default {
data() { asyncData({ store, params, query }) {
return { // Set filter by
booksPerRow: 3, if (query.filter) {
pageWidth: 0 store.dispatch('user/updateUserSettings', { mobileFilterBy: query.filter })
} }
},
computed: {
bookshelfView() {
return this.$store.state.bookshelfView
},
hasFilters() {
return this.$store.getters['user/getUserSetting']('mobileFilterBy') !== 'all'
},
isListView() {
return this.bookshelfView === 'list'
},
books() {
return this.$store.getters['audiobooks/getFilteredAndSorted']()
},
shelves() {
var shelves = []
var shelf = {
id: 0,
books: []
}
for (let i = 0; i < this.books.length; i++) {
var shelfNum = Math.floor((i + 1) / this.booksPerRow)
shelf.id = shelfNum
shelf.books.push(this.books[i])
if ((i + 1) % this.booksPerRow === 0) {
shelves.push(shelf)
shelf = {
id: 0,
books: []
}
}
}
if (shelf.books.length) {
shelf.id++
shelves.push(shelf)
}
return shelves
}
},
methods: {
clearFilter() {
this.$store.dispatch('user/updateUserSettings', {
mobileFilterBy: 'all'
})
}
},
mounted() {
this.pageWidth = window.innerWidth
} }
} }
</script> </script>

View file

@ -0,0 +1,19 @@
<template>
<bookshelf-lazy-bookshelf page="series-books" :series-id="seriesId" />
</template>
<script>
export default {
asyncData({ params }) {
return {
seriesId: params.id
}
},
data() {
return {}
},
computed: {},
methods: {},
mounted() {}
}
</script>

View file

@ -1,10 +1,11 @@
<template> <template>
<div class="w-full h-full"> <bookshelf-lazy-bookshelf page="series" />
<!-- <div class="w-full h-full">
<template v-for="(shelf, index) in shelves"> <template v-for="(shelf, index) in shelves">
<bookshelf-group-shelf v-if="!selectedSeriesName" :key="shelf.id" group-type="series" :groups="shelf.groups" :style="{ zIndex: shelves.length - index }" /> <bookshelf-group-shelf v-if="!selectedSeriesName" :key="shelf.id" group-type="series" :groups="shelf.groups" :style="{ zIndex: shelves.length - index }" />
<bookshelf-library-shelf v-else :key="shelf.id" :books="shelf.books" :style="{ zIndex: shelves.length - index }" /> <bookshelf-library-shelf v-else :key="shelf.id" :books="shelf.books" :style="{ zIndex: shelves.length - index }" />
</template> </template>
</div> </div> -->
</template> </template>
<script> <script>

View file

@ -105,6 +105,9 @@ export default {
} }
}, },
computed: { computed: {
isSocketConnected() {
return this.$store.state.socketConnected
},
hasStoragePermission() { hasStoragePermission() {
return this.$store.state.hasStoragePermission return this.$store.state.hasStoragePermission
}, },
@ -158,9 +161,10 @@ export default {
await this.searchFolder() await this.searchFolder()
var audiobooks = this.$store.state.audiobooks.audiobooks || [] // var audiobooks = this.$store.state.audiobooks.audiobooks || []
if (audiobooks.length) { // if (audiobooks.length) {
this.$store.dispatch('downloads/linkOrphanDownloads', audiobooks) if (this.isSocketConnected) {
this.$store.dispatch('downloads/linkOrphanDownloads')
} }
} }
}, },

View file

@ -1,28 +1,5 @@
<template> <template>
<div class="w-full h-full"> <div class="w-full h-full"></div>
<div class="w-full h-12 relative z-20">
<div id="toolbar" class="asolute top-0 left-0 w-full h-full bg-bg flex items-center px-2">
<span class="material-icons px-2" @click="showSearchModal = true">search</span>
<p class="font-book">{{ numAudiobooks }} Audiobooks</p>
<div class="flex-grow" />
<span class="material-icons px-2" @click="changeView">{{ viewIcon }}</span>
<div class="relative flex items-center px-2">
<span class="material-icons" @click="showFilterModal = true">filter_alt</span>
<div v-show="hasFilters" class="absolute top-0 right-2 w-2 h-2 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
</div>
<span class="material-icons px-2" @click="showSortModal = true">sort</span>
</div>
</div>
<template v-if="bookshelfReady">
<app-bookshelf v-if="!isListView" />
<app-bookshelf-list v-else />
</template>
<modals-order-modal v-model="showSortModal" :order-by.sync="settings.mobileOrderBy" :descending.sync="settings.mobileOrderDesc" @change="updateOrder" />
<modals-filter-modal v-model="showFilterModal" :filter-by.sync="settings.mobileFilterBy" @change="updateFilter" />
<modals-search-modal v-model="showSearchModal" />
</div>
</template> </template>
<script> <script>
@ -31,69 +8,9 @@ export default {
return redirect('/bookshelf') return redirect('/bookshelf')
}, },
data() { data() {
return { return {}
showSortModal: false,
showFilterModal: false,
showSearchModal: false,
settings: {},
isListView: false,
bookshelfReady: false
}
}, },
computed: { computed: {},
hasFilters() { methods: {}
return this.$store.getters['user/getUserSetting']('filterBy') !== 'all'
},
numAudiobooks() {
return this.$store.getters['audiobooks/getFiltered']().length
},
viewIcon() {
return this.isListView ? 'grid_view' : 'view_stream'
}
},
methods: {
changeView() {
this.isListView = !this.isListView
var bookshelfView = this.isListView ? 'list' : 'grid'
this.$localStore.setBookshelfView(bookshelfView)
},
updateOrder() {
this.saveSettings()
},
updateFilter() {
this.saveSettings()
},
saveSettings() {
this.$store.commit('user/setSettings', this.settings) // Immediate update
this.$store.dispatch('user/updateUserSettings', this.settings)
},
async init() {
this.settings = { ...this.$store.state.user.settings }
var bookshelfView = await this.$localStore.getBookshelfView()
this.isListView = bookshelfView === 'list'
this.bookshelfReady = true
console.log('Bookshelf view', bookshelfView)
},
settingsUpdated(settings) {
for (const key in settings) {
this.settings[key] = settings[key]
}
}
},
mounted() {
this.init()
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
},
beforeDestroy() {
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
}
} }
</script> </script>
<style>
#toolbar {
box-shadow: 0px 5px 5px #11111155;
}
</style>

View file

@ -7,18 +7,33 @@
<div v-show="isFetching" class="w-full py-8 flex justify-center"> <div v-show="isFetching" class="w-full py-8 flex justify-center">
<p class="text-lg text-gray-400">Fetching...</p> <p class="text-lg text-gray-400">Fetching...</p>
</div> </div>
<div v-if="!isFetching && lastSearch && !items.length" class="w-full py-8 flex justify-center"> <div v-if="!isFetching && lastSearch && !totalResults" class="w-full py-8 flex justify-center">
<p class="text-lg text-gray-400">Nothing found</p> <p class="text-lg text-gray-400">Nothing found</p>
</div> </div>
<template v-for="item in items"> <p v-if="bookResults.length" class="font-semibold text-sm mb-1">Books</p>
<div class="py-2 border-b border-bg flex" :key="item.id" @click="clickItem(item)"> <template v-for="bookResult in bookResults">
<cards-book-cover :audiobook="item.data" :width="50" /> <div :key="bookResult.audiobook.id" class="w-full h-16 py-1">
<div class="flex-grow px-4 h-full"> <nuxt-link :to="`/audiobook/${bookResult.audiobook.id}`">
<div class="w-full h-full"> <cards-book-search-card :audiobook="bookResult.audiobook" :search="lastSearch" :match-key="bookResult.matchKey" :match-text="bookResult.matchText" />
<p class="text-base truncate">{{ item.data.book.title }}</p> </nuxt-link>
<p class="text-sm text-gray-400 truncate">{{ item.data.book.author }}</p> </div>
</div> </template>
</div>
<p v-if="seriesResults.length" class="font-semibold text-sm mb-1 mt-2">Series</p>
<template v-for="seriesResult in seriesResults">
<div :key="seriesResult.series" class="w-full h-16 py-1">
<nuxt-link :to="`/bookshelf/series/${$encode(seriesResult.series)}`">
<cards-series-search-card :series="seriesResult.series" :book-items="seriesResult.audiobooks" />
</nuxt-link>
</div>
</template>
<p v-if="authorResults.length" class="font-semibold text-sm mb-1 mt-2">Authors</p>
<template v-for="authorResult in authorResults">
<div :key="authorResult.author" class="w-full h-14 py-1">
<nuxt-link :to="`/bookshelf/library?filter=authors.${$encode(authorResult.author)}`">
<cards-author-search-card :key="authorResult.author" :author="authorResult.author" />
</nuxt-link>
</div> </div>
</template> </template>
</div> </div>
@ -33,34 +48,43 @@ export default {
searchTimeout: null, searchTimeout: null,
lastSearch: null, lastSearch: null,
isFetching: false, isFetching: false,
items: [] bookResults: [],
seriesResults: [],
authorResults: []
} }
}, },
computed: {}, computed: {
methods: { currentLibraryId() {
clickItem(item) { return this.$store.state.libraries.currentLibraryId
this.show = false
this.$router.push(`/audiobook/${item.id}`)
}, },
bookCoverAspectRatio() {
return this.$store.getters['getBookCoverAspectRatio']
},
totalResults() {
return this.bookResults.length + this.seriesResults.length + this.authorResults.length
}
},
methods: {
async runSearch(value) { async runSearch(value) {
this.lastSearch = value this.lastSearch = value
if (!this.lastSearch) { if (!this.lastSearch) {
this.items = [] this.bookResults = []
this.seriesResults = []
this.authorResults = []
return return
} }
this.isFetching = true this.isFetching = true
var results = await this.$axios.$get(`/api/books?q=${value}`).catch((error) => { var results = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/search?q=${value}&limit=5`).catch((error) => {
console.error('Search error', error) console.error('Search error', error)
return [] return []
}) })
console.log('RESULTS', results)
this.isFetching = false this.isFetching = false
this.items = results.map((res) => {
return { this.bookResults = results ? results.audiobooks || [] : []
id: res.id, this.seriesResults = results ? results.series || [] : []
data: res, this.authorResults = results ? results.authors || [] : []
type: 'audiobook'
}
})
}, },
updateSearch(val) { updateSearch(val) {
clearTimeout(this.searchTimeout) clearTimeout(this.searchTimeout)

26
plugins/constants.js Normal file
View file

@ -0,0 +1,26 @@
const DownloadStatus = {
PENDING: 0,
READY: 1,
EXPIRED: 2,
FAILED: 3
}
const CoverDestination = {
METADATA: 0,
AUDIOBOOK: 1
}
const BookCoverAspectRatio = {
STANDARD: 0,
SQUARE: 1
}
const Constants = {
DownloadStatus,
CoverDestination,
BookCoverAspectRatio
}
export default ({ app }, inject) => {
inject('constants', Constants)
}

View file

@ -1,6 +1,8 @@
import Vue from 'vue' import Vue from 'vue'
import { formatDistance, format } from 'date-fns' import { formatDistance, format } from 'date-fns'
Vue.prototype.$eventBus = new Vue()
Vue.prototype.$isDev = process.env.NODE_ENV !== 'production' Vue.prototype.$isDev = process.env.NODE_ENV !== 'production'
Vue.prototype.$dateDistanceFromNow = (unixms) => { Vue.prototype.$dateDistanceFromNow = (unixms) => {

View file

@ -1,14 +1,8 @@
import { sort } from '@/assets/fastSort'
import { decode } from '@/plugins/init.client'
const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult'] const STANDARD_GENRES = ['adventure', 'autobiography', 'biography', 'childrens', 'comedy', 'crime', 'dystopian', 'fantasy', 'fiction', 'health', 'history', 'horror', 'mystery', 'new_adult', 'nonfiction', 'philosophy', 'politics', 'religion', 'romance', 'sci-fi', 'self-help', 'short_story', 'technology', 'thriller', 'true_crime', 'western', 'young_adult']
export const state = () => ({ export const state = () => ({
audiobooks: [], audiobooks: [],
listeners: [], listeners: [],
genres: [...STANDARD_GENRES],
tags: [],
series: [],
loadedLibraryId: 'main', loadedLibraryId: 'main',
lastLoad: 0, lastLoad: 0,
isLoading: false isLoading: false
@ -18,97 +12,6 @@ export const getters = {
getAudiobook: state => id => { getAudiobook: state => id => {
return state.audiobooks.find(ab => ab.id === id) return state.audiobooks.find(ab => ab.id === id)
}, },
getFiltered: (state, getters, rootState, rootGetters) => () => {
var filtered = state.audiobooks
var settings = rootState.user.settings || {}
var filterBy = settings.mobileFilterBy || ''
var searchGroups = ['genres', 'tags', 'series', 'authors', 'progress']
var group = searchGroups.find(_group => filterBy.startsWith(_group + '.'))
if (group) {
var filter = decode(filterBy.replace(`${group}.`, ''))
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 === 'series') filtered = filtered.filter(ab => ab.book && ab.book.series === filter)
else if (group === 'authors') filtered = filtered.filter(ab => ab.book && ab.book.author === filter)
else if (group === 'progress') {
filtered = filtered.filter(ab => {
var userAudiobook = rootGetters['user/getUserAudiobookData'](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
})
}
}
return filtered
},
getFilteredAndSorted: (state, getters, rootState, rootGetters) => () => {
var settings = rootState.user.settings
var direction = settings.mobileOrderDesc ? 'desc' : 'asc'
var filtered = getters.getFiltered()
if (settings.mobileOrderBy === 'recent') {
return sort(filtered)[direction]((ab) => {
var abprogress = rootGetters['user/getUserAudiobookData'](ab.id)
if (!abprogress) return 0
return abprogress.lastUpdate
})
} else {
var orderByNumber = settings.mobileOrderBy === 'book.volumeNumber'
return sort(filtered)[direction]((ab) => {
// Supports dot notation strings i.e. "book.title"
var value = settings.mobileOrderBy.split('.').reduce((a, b) => a[b], ab)
if (orderByNumber && !isNaN(value)) return Number(value)
return value
})
}
},
getSeriesGroups: (state, getters, rootState) => () => {
var series = {}
state.audiobooks.forEach((audiobook) => {
if (audiobook.book && audiobook.book.series) {
if (series[audiobook.book.series]) {
var bookLastUpdate = audiobook.book.lastUpdate
if (bookLastUpdate > series[audiobook.book.series].lastUpdate) series[audiobook.book.series].lastUpdate = bookLastUpdate
series[audiobook.book.series].books.push(audiobook)
} else {
series[audiobook.book.series] = {
type: 'series',
name: audiobook.book.series || '',
books: [audiobook],
lastUpdate: audiobook.book.lastUpdate
}
}
}
})
var seriesArray = Object.values(series).map((_series) => {
_series.books = sort(_series.books)['asc']((ab) => {
return ab.book && ab.book.volumeNumber && !isNaN(ab.book.volumeNumber) ? Number(ab.book.volumeNumber) : null
})
return _series
})
if (state.keywordFilter) {
const keywordFilter = state.keywordFilter.toLowerCase()
return seriesArray.filter((_series) => _series.name.toLowerCase().includes(keywordFilter))
}
return seriesArray
},
getUniqueAuthors: (state) => {
var _authors = state.audiobooks.filter(ab => !!(ab.book && ab.book.author)).map(ab => ab.book.author)
return [...new Set(_authors)]
},
getGenresUsed: (state) => {
var _genres = []
state.audiobooks.filter(ab => !!(ab.book && ab.book.genres)).forEach(ab => _genres = _genres.concat(ab.book.genres))
return [...new Set(_genres)].sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
},
getBookCoverSrc: (state, getters, rootState, rootGetters) => (bookItem, placeholder = '/book_placeholder.jpg') => { getBookCoverSrc: (state, getters, rootState, rootGetters) => (bookItem, placeholder = '/book_placeholder.jpg') => {
var book = bookItem.book var book = bookItem.book
if (!book || !book.cover || book.cover === placeholder) return placeholder if (!book || !book.cover || book.cover === placeholder) return placeholder
@ -141,43 +44,6 @@ export const getters = {
} }
export const actions = { export const actions = {
load({ state, commit, dispatch, rootState }) {
if (!rootState.user || !rootState.user.user) {
console.error('audiobooks/load - User not set')
return false
}
var currentLibraryId = rootState.libraries.currentLibraryId
if (currentLibraryId === state.loadedLibraryId) {
// Don't load again if already loaded in the last 5 minutes
var lastLoadDiff = Date.now() - state.lastLoad
if (lastLoadDiff < 5 * 60 * 1000) {
// Already up to date
return false
}
} else {
commit('reset')
commit('setLoading', true)
}
commit('setLoadedLibrary', currentLibraryId)
this.$axios
.$get(`/api/library/${currentLibraryId}/audiobooks`)
.then((data) => {
commit('set', data)
commit('setLastLoad')
commit('setLoading', false)
dispatch('downloads/linkOrphanDownloads', data, { root: true })
})
.catch((error) => {
console.error('Failed', error)
commit('set', [])
commit('setLoading', false)
})
return true
},
useDownloaded({ commit, rootGetters }) { useDownloaded({ commit, rootGetters }) {
commit('set', rootGetters['downloads/getAudiobooks']) commit('set', rootGetters['downloads/getAudiobooks'])
} }
@ -199,46 +65,6 @@ export const mutations = {
state.tags = [] state.tags = []
state.series = [] state.series = []
}, },
set(state, audiobooks) {
// GENRES
var genres = [...state.genres]
audiobooks.forEach((ab) => {
if (!ab.book) return
genres = genres.concat(ab.book.genres)
})
state.genres = [...new Set(genres)] // Remove Duplicates
state.genres.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
// TAGS
var tags = []
audiobooks.forEach((ab) => {
tags = tags.concat(ab.tags)
})
state.tags = [...new Set(tags)] // Remove Duplicates
state.tags.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
// SERIES
var series = []
audiobooks.forEach((ab) => {
if (!ab.book || !ab.book.series || series.includes(ab.book.series)) return
series.push(ab.book.series)
})
state.series = series
state.series.sort((a, b) => a.toLowerCase() < b.toLowerCase() ? -1 : 1)
audiobooks.forEach((ab) => {
var indexOf = state.audiobooks.findIndex(_ab => _ab.id === ab.id)
if (indexOf >= 0) {
state.audiobooks.splice(indexOf, 1, ab)
} else {
state.audiobooks.push(ab)
}
})
// state.audiobooks = audiobooks
state.listeners.forEach((listener) => {
listener.meth()
})
},
addUpdate(state, audiobook) { addUpdate(state, audiobook) {
var index = state.audiobooks.findIndex(a => a.id === audiobook.id) var index = state.audiobooks.findIndex(a => a.id === audiobook.id)
var origAudiobook = null var origAudiobook = null

View file

@ -20,7 +20,7 @@ export const getters = {
} }
export const actions = { export const actions = {
async loadFromStorage({ commit }) { async loadFromStorage({ commit, state }) {
var downloads = await this.$sqlStore.getAllDownloads() var downloads = await this.$sqlStore.getAllDownloads()
downloads.forEach(ab => { downloads.forEach(ab => {
@ -31,20 +31,32 @@ export const actions = {
ab.isPreparing = false ab.isPreparing = false
commit('setDownload', ab) commit('setDownload', ab)
}) })
return state.downloads
}, },
linkOrphanDownloads({ state, commit }, audiobooks) { async linkOrphanDownloads({ state, commit, rootState }) {
if (!state.mediaScanResults || !state.mediaScanResults.folders) { if (!state.mediaScanResults || !state.mediaScanResults.folders) {
return return
} }
console.log('Link orphan downloads', JSON.stringify(state.mediaScanResults.folders)) console.log('Link orphan downloads', JSON.stringify(state.mediaScanResults.folders))
state.mediaScanResults.folders.forEach((folder) => { // state.mediaScanResults.folders.forEach((folder) => {
for (let i = 0; i < state.mediaScanResults.folders.length; i++) {
var folder = state.mediaScanResults.folders[i]
if (!folder.files || !folder.files.length) return if (!folder.files || !folder.files.length) return
console.log('Link orphan downloads check folder', folder.name) console.log('Link orphan downloads check folder', folder.name)
var download = state.downloads.find(dl => dl.folderName === folder.name) var download = state.downloads.find(dl => dl.folderName === folder.name)
if (!download) { if (!download) {
var matchingAb = audiobooks.find(ab => ab.book.title === folder.name) // var matchingAb = audiobooks.find(ab => ab.book.title === folder.name)
var results = await this.$axios.$get(`/libraries/${rootState.libraries.currentLibraryId}/search?q=${folder.name}`)
var matchingAb = null
if (results && results.audiobooks) {
console.log('has ab results', JSON.stringify(results.audiobooks))
matchingAb = results.audiobooks.find(ab => ab.audiobook.book.title === folder.name)
if (matchingAb) console.log('Found matching ab for ' + folder.name, matchingAb)
else console.warn('did not find mathcing ab for ' + folder.name)
} else {
console.error('Invalid results payload', JSON.stringify(results))
}
if (matchingAb) { if (matchingAb) {
// Found matching download for ab // Found matching download for ab
var audioFile = folder.files.find(f => f.isAudio) var audioFile = folder.files.find(f => f.isAudio)
@ -72,7 +84,7 @@ export const actions = {
commit('addUpdateDownload', downloadObj) commit('addUpdateDownload', downloadObj)
} }
} }
}) }
} }
} }

View file

@ -19,7 +19,8 @@ export const state = () => ({
showSideDrawer: false, showSideDrawer: false,
bookshelfView: 'grid', bookshelfView: 'grid',
isNetworkListenerInit: false isNetworkListenerInit: false,
serverSettings: null
}) })
export const getters = { export const getters = {
@ -34,7 +35,15 @@ export const getters = {
}, },
getAudiobookIdStreaming: state => { getAudiobookIdStreaming: state => {
return state.streamAudiobook ? state.streamAudiobook.id : null return state.streamAudiobook ? state.streamAudiobook.id : null
} },
getServerSetting: state => key => {
if (!state.serverSettings) return null
return state.serverSettings[key]
},
getBookCoverAspectRatio: state => {
if (!state.serverSettings || !state.serverSettings.coverAspectRatio) return 1.6
return state.serverSettings.coverAspectRatio === 0 ? 1.6 : 1
},
} }
export const actions = { export const actions = {
@ -123,5 +132,8 @@ export const mutations = {
}, },
setBookshelfView(state, val) { setBookshelfView(state, val) {
state.bookshelfView = val state.bookshelfView = val
},
setServerSettings(state, val) {
state.serverSettings = val
} }
} }

View file

@ -5,12 +5,18 @@ export const state = () => ({
currentLibraryId: 'main', currentLibraryId: 'main',
showModal: false, showModal: false,
folders: [], folders: [],
folderLastUpdate: 0 folderLastUpdate: 0,
issues: 0,
filterData: null
}) })
export const getters = { export const getters = {
getCurrentLibrary: state => { getCurrentLibrary: state => {
return state.libraries.find(lib => lib.id === state.currentLibraryId) return state.libraries.find(lib => lib.id === state.currentLibraryId)
},
getCurrentLibraryName: (state, getters) => {
var currLib = getters.getCurrentLibrary
return currLib ? currLib.name : null
} }
} }
@ -21,16 +27,22 @@ export const actions = {
return false return false
} }
var library = state.libraries.find(lib => lib.id === libraryId) // var library = state.libraries.find(lib => lib.id === libraryId)
if (library) { // if (library) {
commit('setCurrentLibrary', libraryId) // commit('setCurrentLibrary', libraryId)
return library // return library
} // }
return this.$axios return this.$axios
.$get(`/api/libraries/${libraryId}`) .$get(`/api/libraries/${libraryId}?include=filterdata`)
.then((data) => { .then((data) => {
commit('addUpdate', data) var library = data.library
var filterData = data.filterdata
var issues = data.issues || 0
commit('addUpdate', library)
commit('setLibraryIssues', issues)
commit('setLibraryFilterData', filterData)
commit('setCurrentLibrary', libraryId) commit('setCurrentLibrary', libraryId)
return data return data
}) })
@ -117,5 +129,11 @@ export const mutations = {
}, },
removeListener(state, listenerId) { removeListener(state, listenerId) {
state.listeners = state.listeners.filter(l => l.id !== listenerId) state.listeners = state.listeners.filter(l => l.id !== listenerId)
} },
setLibraryIssues(state, val) {
state.issues = val
},
setLibraryFilterData(state, filterData) {
state.filterData = filterData
},
} }

View file

@ -2,7 +2,7 @@ export const state = () => ({
user: null, user: null,
userAudiobookData: [], userAudiobookData: [],
settings: { settings: {
mobileOrderBy: 'recent', mobileOrderBy: 'addedAt',
mobileOrderDesc: true, mobileOrderDesc: true,
mobileFilterBy: 'all', mobileFilterBy: 'all',
orderBy: 'book.title', orderBy: 'book.title',
@ -23,6 +23,9 @@ export const getters = {
return state.user ? state.user.token : null return state.user ? state.user.token : null
}, },
getUserAudiobookData: (state, getters) => (audiobookId) => { getUserAudiobookData: (state, getters) => (audiobookId) => {
return getters.getUserAudiobook(audiobookId)
},
getUserAudiobook: (state, getters) => (audiobookId) => {
return state.userAudiobookData.find(uabd => uabd.audiobookId === audiobookId) return state.userAudiobookData.find(uabd => uabd.audiobookId === audiobookId)
}, },
getUserSetting: (state) => (key) => { getUserSetting: (state) => (key) => {
@ -135,6 +138,9 @@ export const mutations = {
var hasChanges = false var hasChanges = false
for (const key in settings) { for (const key in settings) {
if (state.settings[key] !== settings[key]) { if (state.settings[key] !== settings[key]) {
if (key === 'mobileOrderBy' && settings[key] === 'recent') {
settings[key] = 'addedAt'
}
hasChanges = true hasChanges = true
state.settings[key] = settings[key] state.settings[key] = settings[key]
} }