Add:Progress for comics #738

This commit is contained in:
advplyr 2023-06-11 11:12:52 -05:00
parent c8b5cefeb5
commit 543ac209e4
7 changed files with 236 additions and 51 deletions

View file

@ -1,5 +1,5 @@
<template> <template>
<modals-modal v-model="show" :width="300" height="100%"> <modals-modal v-model="show" :width="width" height="100%">
<template #outer> <template #outer>
<div v-if="title" class="absolute top-8 left-4 z-40" style="max-width: 80%"> <div v-if="title" class="absolute top-8 left-4 z-40" style="max-width: 80%">
<p class="text-white text-xl truncate">{{ title }}</p> <p class="text-white text-xl truncate">{{ title }}</p>
@ -9,9 +9,9 @@
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false"> <div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop> <div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20 p-2" style="max-height: 75%" @click.stop>
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label"> <ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
<template v-for="item in items"> <template v-for="item in itemsToShow">
<slot :name="item.value" :item="item" :selected="item.value === selected"> <slot :name="item.value" :item="item" :selected="item.value === selected">
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)"> <li :key="item.value" :ref="`item-${item.value}`" class="text-gray-50 select-none relative cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" :style="{ paddingTop: itemPaddingY, paddingBottom: itemPaddingY }" role="option" @click="clickedOption(item.value)">
<div class="relative flex items-center px-3"> <div class="relative flex items-center px-3">
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span> <span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p> <p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
@ -34,11 +34,27 @@ export default {
type: Array, type: Array,
default: () => [] default: () => []
}, },
selected: String // optional selected: [String, Number], // optional
itemPaddingY: {
type: String,
default: '16px'
},
width: {
type: Number,
default: 300
}
}, },
data() { data() {
return {} return {}
}, },
watch: {
show: {
immediate: true,
handler(newVal) {
if (newVal) this.$nextTick(this.init)
}
}
},
computed: { computed: {
show: { show: {
get() { get() {
@ -47,11 +63,32 @@ export default {
set(val) { set(val) {
this.$emit('input', val) this.$emit('input', val)
} }
},
itemsToShow() {
return this.items.map((i) => {
if (typeof i === 'string') {
return {
text: i,
value: i
}
}
return i
})
} }
}, },
methods: { methods: {
clickedOption(action) { clickedOption(action) {
this.$emit('action', action) this.$emit('action', action)
},
init() {
if (this.selected && this.$refs[`item-${this.selected}`]?.[0]) {
// Set scroll position so that selected item is in the center
const containerOffset = this.$refs.container.offsetTop + this.$refs.container.clientHeight / 2
const scrollAmount = this.$refs[`item-${this.selected}`][0].offsetTop - containerOffset
this.$refs.container.scrollTo({
top: scrollAmount
})
}
} }
}, },
mounted() {} mounted() {}

View file

@ -1,11 +1,6 @@
<template> <template>
<div id="comic-reader" class="w-full h-full relative"> <div id="comic-reader" class="w-full h-full relative">
<div v-show="showPageMenu" v-click-outside="clickOutsideObj" class="pagemenu absolute top-12 right-16 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52"> <div v-show="showInfoMenu" v-click-outside="clickedOutside" class="pagemenu absolute top-8 left-0 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-full" @click.stop.prevent>
<div v-for="(file, index) in pages" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click.stop="setPage(index)">
<p class="text-sm truncate">{{ file }}</p>
</div>
</div>
<div v-show="showInfoMenu" v-click-outside="clickedOutsideInfoMenu" class="pagemenu absolute top-12 right-0 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-full" @click.stop.prevent>
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1"> <div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
<p class="text-xs"> <p class="text-xs">
<strong>{{ key }}</strong> <strong>{{ key }}</strong>
@ -14,14 +9,14 @@
</div> </div>
</div> </div>
<div v-if="comicMetadata" class="absolute top-0 right-3 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu"> <div v-if="comicMetadata" id="btn-metadata" class="absolute top-0 left-14 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-8 h-7 flex items-center justify-center text-center z-20" @mousedown.prevent @click.stop.prevent="clickShowInfoMenu">
<span class="material-icons text-lg">more</span> <span class="material-icons text-base pointer-events-none">more</span>
</div> </div>
<div class="absolute top-0 right-16 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-10 h-9 flex items-center justify-center text-center z-20" @mousedown.prevent @mouseup.prevent @click.stop.prevent="clickShowPageMenu"> <div v-if="numPages" id="btn-pages" class="absolute top-0 left-3 bg-bg text-gray-100 border-b border-l border-r border-gray-400 hover:bg-black-200 cursor-pointer rounded-b-md w-8 h-7 flex items-center justify-center text-center z-20" @mousedown.prevent @mouseup.prevent @click.stop.prevent="clickShowPageMenu">
<span class="material-icons text-lg">menu</span> <span class="material-icons text-base pointer-events-none">menu</span>
</div> </div>
<div class="absolute top-0 left-3 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-9 flex items-center text-center z-20" @click.stop> <div v-if="numPages" class="absolute top-0 right-3 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-7 flex items-center text-center z-20" @click.stop>
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p> <p class="text-sm">{{ page }} / {{ numPages }}</p>
</div> </div>
<div class="overflow-hidden m-auto comicwrapper relative"> <div class="overflow-hidden m-auto comicwrapper relative">
@ -33,6 +28,8 @@
<ui-loading-indicator /> <ui-loading-indicator />
</div> </div>
</div> </div>
<modals-dialog v-model="showPageMenu" :items="pageItems" :selected="page" :width="360" item-padding-y="8px" @action="setPage" />
</div> </div>
</template> </template>
@ -51,7 +48,9 @@ export default {
libraryItem: { libraryItem: {
type: Object, type: Object,
default: () => {} default: () => {}
} },
isLocal: Boolean,
keepProgress: Boolean
}, },
data() { data() {
return { return {
@ -66,11 +65,7 @@ export default {
loadTimeout: null, loadTimeout: null,
loadedFirstPage: false, loadedFirstPage: false,
comicMetadata: null, comicMetadata: null,
clickOutsideObj: { pageMenuWidth: 256
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
}
} }
}, },
watch: { watch: {
@ -85,17 +80,112 @@ export default {
userToken() { userToken() {
return this.$store.getters['user/getToken'] return this.$store.getters['user/getToken']
}, },
libraryItemId() {
return this.libraryItem?.id
},
localLibraryItem() {
if (this.isLocal) return this.libraryItem
return this.libraryItem.localLibraryItem || null
},
localLibraryItemId() {
return this.localLibraryItem?.id
},
serverLibraryItemId() {
if (!this.isLocal) return this.libraryItem.id
// Check if local library item is connected to the current server
if (!this.libraryItem.serverAddress || !this.libraryItem.libraryItemId) return null
if (this.$store.getters['user/getServerAddress'] === this.libraryItem.serverAddress) {
return this.libraryItem.libraryItemId
}
return null
},
comicMetadataKeys() { comicMetadataKeys() {
return this.comicMetadata ? Object.keys(this.comicMetadata) : [] return this.comicMetadata ? Object.keys(this.comicMetadata) : []
}, },
canGoNext() { canGoNext() {
return this.page < this.numPages - 1 return this.page < this.numPages
}, },
canGoPrev() { canGoPrev() {
return this.page > 0 return this.page > 1
},
userItemProgress() {
if (this.isLocal) return this.localItemProgress
return this.serverItemProgress
},
localItemProgress() {
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId)
},
serverItemProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
},
savedPage() {
if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userItemProgress?.ebookLocation || isNaN(this.userItemProgress.ebookLocation)) return 0
return Number(this.userItemProgress.ebookLocation)
},
selectedCleanedPage() {
return this.cleanedPageNames[this.page - 1]
},
cleanedPageNames() {
return (
this.pages?.map((p) => {
if (p.length > 40) {
let firstHalf = p.slice(0, 18)
let lastHalf = p.slice(p.length - 17)
return `${firstHalf} ... ${lastHalf}`
}
return p
}) || []
)
},
pageItems() {
let index = 1
return this.cleanedPageNames.map((p) => {
return {
text: p,
value: index++
}
})
} }
}, },
methods: { methods: {
async updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
}
if (this.savedPage === this.page) {
return
}
const payload = {
ebookLocation: String(this.page),
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
}
// Update local item
if (this.localLibraryItemId) {
const localPayload = {
localLibraryItemId: this.localLibraryItemId,
...payload
}
const localResponse = await this.$db.updateLocalEbookProgress(localPayload)
if (localResponse.localMediaProgress) {
this.$store.commit('globals/updateLocalMediaProgress', localResponse.localMediaProgress)
}
}
// Update server item
if (this.serverLibraryItemId) {
this.$axios.$patch(`/api/me/progress/${this.serverLibraryItemId}`, payload).catch((error) => {
console.error('ComicReader.updateProgress failed:', error)
})
}
},
clickShowInfoMenu() { clickShowInfoMenu() {
this.showInfoMenu = !this.showInfoMenu this.showInfoMenu = !this.showInfoMenu
this.showPageMenu = false this.showPageMenu = false
@ -104,11 +194,10 @@ export default {
this.showPageMenu = !this.showPageMenu this.showPageMenu = !this.showPageMenu
this.showInfoMenu = false this.showInfoMenu = false
}, },
clickedOutside() { clickedOutside(e) {
this.showPageMenu = false if (e.target?.id == 'btn-metadata') return
},
clickedOutsideInfoMenu() { if (this.showInfoMenu) this.showInfoMenu = false
this.showInfoMenu = false
}, },
next() { next() {
if (!this.canGoNext) return if (!this.canGoNext) return
@ -118,13 +207,15 @@ export default {
if (!this.canGoPrev) return if (!this.canGoPrev) return
this.setPage(this.page - 1) this.setPage(this.page - 1)
}, },
setPage(index) { setPage(page) {
if (index < 0 || index > this.numPages - 1) { if (page <= 0 || page > this.numPages) {
return return
} }
this.showPageMenu = false this.showPageMenu = false
const filename = this.pages[index] const filename = this.pages[page - 1]
this.page = index this.page = page
this.updateProgress()
return this.extractFile(filename) return this.extractFile(filename)
}, },
setLoadTimeout() { setLoadTimeout() {
@ -154,7 +245,6 @@ export default {
}, },
async extract() { async extract() {
this.loading = true this.loading = true
console.log('Extracting', this.url)
var buff = await this.$axios.$get(this.url, { var buff = await this.$axios.$get(this.url, {
responseType: 'blob', responseType: 'blob',
@ -176,9 +266,28 @@ export default {
this.numPages = this.pages.length this.numPages = this.pages.length
// Calculate page menu size
const largestFilename = this.cleanedPageNames
.map((p) => p)
.sort((a, b) => a.length - b.length)
.pop()
const pEl = document.createElement('p')
pEl.innerText = largestFilename
pEl.style.fontSize = '0.875rem'
pEl.style.opacity = 0
pEl.style.position = 'absolute'
document.body.appendChild(pEl)
const textWidth = pEl.getBoundingClientRect()?.width
if (textWidth) {
this.pageMenuWidth = textWidth + (16 + 5 + 2 + 5)
}
pEl.remove()
if (this.pages.length) { if (this.pages.length) {
this.loading = false this.loading = false
await this.setPage(0)
const startPage = this.savedPage > 0 && this.savedPage <= this.numPages ? this.savedPage : 1
await this.setPage(startPage)
this.loadedFirstPage = true this.loadedFirstPage = true
} else { } else {
this.$toast.error('Unable to extract pages') this.$toast.error('Unable to extract pages')
@ -206,7 +315,6 @@ export default {
return flattenObject(filesObject) return flattenObject(filesObject)
}, },
async extractXmlFile(filename) { async extractXmlFile(filename) {
console.log('extracting xml filename', filename)
try { try {
var file = await this.filesObject[filename].extract() var file = await this.filesObject[filename].extract()
var reader = new FileReader() var reader = new FileReader()
@ -272,7 +380,7 @@ export default {
} }
.pagemenu { .pagemenu {
max-height: calc(100% - 80px); max-height: calc(100% - 20px);
} }
.comicimg { .comicimg {
height: 100%; height: 100%;

View file

@ -21,7 +21,8 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
isLocal: Boolean isLocal: Boolean,
keepProgress: Boolean
}, },
data() { data() {
return { return {
@ -83,6 +84,13 @@ export default {
}, },
localStorageLocationsKey() { localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}` return `ebookLocations-${this.libraryItemId}`
},
savedEbookLocation() {
if (!this.keepProgress) return null
if (!this.userItemProgress?.ebookLocation) return null
// Validate ebookLocation is an epubcfi
if (!String(this.userItemProgress.ebookLocation).startsWith('epubcfi')) return null
return this.userItemProgress.ebookLocation
} }
}, },
methods: { methods: {
@ -107,6 +115,8 @@ export default {
* @param {string} payload.ebookProgress - eBook Progress Percentage * @param {string} payload.ebookProgress - eBook Progress Percentage
*/ */
async updateProgress(payload) { async updateProgress(payload) {
if (!this.keepProgress) return
// Update local item // Update local item
if (this.localLibraryItemId) { if (this.localLibraryItemId) {
const localPayload = { const localPayload = {
@ -213,7 +223,7 @@ export default {
}, },
/** @param {string} location - CFI of the new location */ /** @param {string} location - CFI of the new location */
relocated(location) { relocated(location) {
if (this.userItemProgress?.ebookLocation === location.start.cfi) { if (this.savedEbookLocation === location.start.cfi) {
return return
} }
@ -255,10 +265,10 @@ export default {
}) })
// load saved progress // load saved progress
reader.rendition.display(this.userItemProgress?.ebookLocation || reader.book.locations.start) reader.rendition.display(this.savedEbookLocation || reader.book.locations.start)
// load style // load style
reader.rendition.themes.default({ '*': { color: '#fff!important', 'background-color': 'rgb(35 35 35)!important' } }) reader.rendition.themes.default({ '*': { color: '#fff!important', 'background-color': 'rgb(35 35 35)!important' }, a: { color: '#fff!important' } })
reader.book.ready.then(() => { reader.book.ready.then(() => {
// set up event listeners // set up event listeners

View file

@ -39,7 +39,8 @@ export default {
type: Object, type: Object,
default: () => {} default: () => {}
}, },
isLocal: Boolean isLocal: Boolean,
keepProgress: Boolean
}, },
data() { data() {
return { return {
@ -92,7 +93,11 @@ export default {
return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId) return this.$store.getters['user/getUserMediaProgress'](this.serverLibraryItemId)
}, },
savedPage() { savedPage() {
return Number(this.userItemProgress?.ebookLocation || 0) if (!this.keepProgress) return 0
// Validate ebookLocation is a number
if (!this.userItemProgress?.ebookLocation || isNaN(this.userItemProgress.ebookLocation)) return 0
return Number(this.userItemProgress.ebookLocation)
}, },
pdfDocInitParams() { pdfDocInitParams() {
return { return {
@ -105,10 +110,13 @@ export default {
}, },
methods: { methods: {
async updateProgress() { async updateProgress() {
if (!this.keepProgress) return
if (!this.numPages) { if (!this.numPages) {
console.error('Num pages not loaded') console.error('Num pages not loaded')
return return
} }
const payload = { const payload = {
ebookLocation: String(this.page), ebookLocation: String(this.page),
ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages))) ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))

View file

@ -5,7 +5,7 @@
<div class="flex-grow" /> <div class="flex-grow" />
<span class="material-icons text-xl text-white" @click.stop="show = false">close</span> <span class="material-icons text-xl text-white" @click.stop="show = false">close</span>
</div> </div>
<component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" /> <component v-if="readerComponentName" ref="readerComponent" :is="readerComponentName" :url="ebookUrl" :library-item="selectedLibraryItem" :is-local="isLocal" :keep-progress="keepProgress" />
</div> </div>
</template> </template>
@ -62,10 +62,19 @@ export default {
return null return null
}, },
ebookFile() { ebookFile() {
return this.media?.ebookFile || null // ebook file id is passed when reading a supplementary ebook
if (this.ebookFileId) {
return this.selectedLibraryItem.libraryFiles.find((lf) => lf.ino === this.ebookFileId)
}
return this.media.ebookFile
}, },
ebookFormat() { ebookFormat() {
return this.ebookFile?.ebookFormat || null if (!this.ebookFile) return null
// Use file extension for supplementary ebook
if (!this.ebookFile.ebookFormat) {
return this.ebookFile.metadata.ext.toLowerCase().slice(1)
}
return this.ebookFile.ebookFormat
}, },
ebookType() { ebookType() {
if (this.isMobi) return 'mobi' if (this.isMobi) return 'mobi'
@ -98,10 +107,20 @@ export default {
return Capacitor.convertFileSrc(this.localContentUrl) return Capacitor.convertFileSrc(this.localContentUrl)
} }
const serverAddress = this.$store.getters['user/getServerAddress'] const serverAddress = this.$store.getters['user/getServerAddress']
if (this.ebookFileId) {
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook/${this.ebookFileId}`
}
return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook` return `${serverAddress}/api/items/${this.selectedLibraryItem.id}/ebook`
}, },
playerLibraryItemId() { playerLibraryItemId() {
return this.$store.state.playerLibraryItemId return this.$store.state.playerLibraryItemId
},
keepProgress() {
return this.$store.state.ereaderKeepProgress
},
ebookFileId() {
return this.$store.state.ereaderFileId
} }
}, },
methods: { methods: {
@ -130,11 +149,9 @@ export default {
} }
if (this.touchendX < this.touchstartX) { if (this.touchendX < this.touchstartX) {
console.log('swiped left')
this.next() this.next()
} }
if (this.touchendX > this.touchstartX) { if (this.touchendX > this.touchstartX) {
console.log('swiped right')
this.prev() this.prev()
} }
}, },

View file

@ -483,9 +483,9 @@ export default {
readBook() { readBook() {
if (this.localLibraryItem?.media?.ebookFile) { if (this.localLibraryItem?.media?.ebookFile) {
// Has local ebook file // Has local ebook file
this.$store.commit('openReader', this.localLibraryItem) this.$store.commit('showReader', { libraryItem: this.localLibraryItem, keepProgress: true })
} else { } else {
this.$store.commit('openReader', this.libraryItem) this.$store.commit('showReader', { libraryItem: this.libraryItem, keepProgress: true })
} }
}, },
playAtTimestamp(seconds) { playAtTimestamp(seconds) {

View file

@ -20,6 +20,8 @@ export const state = () => ({
hasStoragePermission: false, hasStoragePermission: false,
selectedLibraryItem: null, selectedLibraryItem: null,
showReader: false, showReader: false,
ereaderKeepProgress: false,
ereaderFileId: null,
showSideDrawer: false, showSideDrawer: false,
isNetworkListenerInit: false, isNetworkListenerInit: false,
serverSettings: null, serverSettings: null,
@ -145,8 +147,11 @@ export const mutations = {
setIsNetworkUnmetered(state, val) { setIsNetworkUnmetered(state, val) {
state.isNetworkUnmetered = val state.isNetworkUnmetered = val
}, },
openReader(state, libraryItem) { showReader(state, { libraryItem, keepProgress, fileId }) {
state.selectedLibraryItem = libraryItem state.selectedLibraryItem = libraryItem
state.ereaderKeepProgress = keepProgress
state.ereaderFileId = fileId
state.showReader = true state.showReader = true
}, },
setShowReader(state, val) { setShowReader(state, val) {