@@ -139,16 +139,19 @@ export default {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
libraryItemId() {
- return this.libraryItem ? this.libraryItem.id : null
+ return this.libraryItem?.id || null
+ },
+ libraryItemUpdatedAt() {
+ return this.libraryItem?.updatedAt || null
},
mediaType() {
- return this.libraryItem ? this.libraryItem.mediaType : null
+ return this.libraryItem?.mediaType || null
},
isPodcast() {
return this.mediaType == 'podcast'
},
media() {
- return this.libraryItem ? this.libraryItem.media || {} : {}
+ return this.libraryItem?.media || {}
},
coverPath() {
return this.media.coverPath
@@ -157,7 +160,7 @@ export default {
return this.media.metadata || {}
},
libraryFiles() {
- return this.libraryItem ? this.libraryItem.libraryFiles || [] : []
+ return this.libraryItem?.libraryFiles || []
},
userCanUpload() {
return this.$store.getters['user/getUserCanUpload']
@@ -169,8 +172,8 @@ export default {
return this.libraryFiles
.filter((f) => f.fileType === 'image')
.map((file) => {
- var _file = { ...file }
- _file.localPath = `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(file.metadata.relPath).replace(/^\//, '')}`
+ const _file = { ...file }
+ _file.localPath = `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${file.ino}?token=${this.userToken}`
return _file
})
}
diff --git a/client/components/modals/item/tabs/Episodes.vue b/client/components/modals/item/tabs/Episodes.vue
index f64eea4e..661f41e0 100644
--- a/client/components/modals/item/tabs/Episodes.vue
+++ b/client/components/modals/item/tabs/Episodes.vue
@@ -20,18 +20,14 @@
- Sort # |
- {{ $strings.LabelEpisode }} |
- {{ $strings.EpisodeTitle }} |
- {{ $strings.EpisodeDuration }} |
- {{ $strings.EpisodeSize }} |
+ {{ $strings.LabelEpisode }} |
+ {{ $strings.LabelEpisodeTitle }} |
+ {{ $strings.LabelEpisodeDuration }} |
+ {{ $strings.LabelEpisodeSize }} |
-
- {{ episode.index }}
- |
-
- {{ episode.episode }}
+ |
+ {{ episode.episode }}
|
{{ episode.title }}
diff --git a/client/components/modals/item/tabs/Tools.vue b/client/components/modals/item/tabs/Tools.vue
index 4a76482a..5f2ca6b3 100644
--- a/client/components/modals/item/tabs/Tools.vue
+++ b/client/components/modals/item/tabs/Tools.vue
@@ -20,7 +20,7 @@
-
+
@@ -79,9 +79,6 @@ export default {
return {}
},
computed: {
- showExperimentalFeatures() {
- return this.$store.state.showExperimentalFeatures
- },
libraryItemId() {
return this.libraryItem?.id || null
},
diff --git a/client/components/modals/libraries/LibrarySettings.vue b/client/components/modals/libraries/LibrarySettings.vue
index 3b52588c..5d91daa6 100644
--- a/client/components/modals/libraries/LibrarySettings.vue
+++ b/client/components/modals/libraries/LibrarySettings.vue
@@ -1,6 +1,6 @@
-
+
*{{ $strings.MessageWatcherIsDisabledGlobally }}
-
+
+
+
+
+ {{ $strings.LabelSettingsAudiobooksOnly }}
+ info_outlined
+
+
+
+
{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}
-
+
{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}
+
+
+
+
+
+ {{ $strings.LabelSettingsHideSingleBookSeries }}
+ info_outlined
+
+
+
+
@@ -47,7 +67,9 @@ export default {
useSquareBookCovers: false,
disableWatcher: false,
skipMatchingMediaWithAsin: false,
- skipMatchingMediaWithIsbn: false
+ skipMatchingMediaWithIsbn: false,
+ audiobooksOnly: false,
+ hideSingleBookSeries: false
}
},
computed: {
@@ -60,6 +82,9 @@ export default {
mediaType() {
return this.library.mediaType
},
+ isBookLibrary() {
+ return this.mediaType === 'book'
+ },
providers() {
if (this.mediaType === 'podcast') return this.$store.state.scanners.podcastProviders
return this.$store.state.scanners.providers
@@ -72,7 +97,9 @@ export default {
coverAspectRatio: this.useSquareBookCovers ? this.$constants.BookCoverAspectRatio.SQUARE : this.$constants.BookCoverAspectRatio.STANDARD,
disableWatcher: !!this.disableWatcher,
skipMatchingMediaWithAsin: !!this.skipMatchingMediaWithAsin,
- skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn
+ skipMatchingMediaWithIsbn: !!this.skipMatchingMediaWithIsbn,
+ audiobooksOnly: !!this.audiobooksOnly,
+ hideSingleBookSeries: !!this.hideSingleBookSeries
}
}
},
@@ -84,6 +111,8 @@ export default {
this.disableWatcher = !!this.librarySettings.disableWatcher
this.skipMatchingMediaWithAsin = !!this.librarySettings.skipMatchingMediaWithAsin
this.skipMatchingMediaWithIsbn = !!this.librarySettings.skipMatchingMediaWithIsbn
+ this.audiobooksOnly = !!this.librarySettings.audiobooksOnly
+ this.hideSingleBookSeries = !!this.librarySettings.hideSingleBookSeries
}
},
mounted() {
diff --git a/client/components/modals/podcast/EpisodeFeed.vue b/client/components/modals/podcast/EpisodeFeed.vue
index 9c199325..0f75644b 100644
--- a/client/components/modals/podcast/EpisodeFeed.vue
+++ b/client/components/modals/podcast/EpisodeFeed.vue
@@ -39,7 +39,7 @@
-
+
{{ buttonText }}
All episodes are downloaded
@@ -99,46 +99,82 @@ export default {
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
},
buttonText() {
- if (!this.episodesSelected.length) return 'No Episodes Selected'
- return `Download ${this.episodesSelected.length} Episode${this.episodesSelected.length > 1 ? 's' : ''}`
+ if (!this.episodesSelected.length) return this.$strings.LabelNoEpisodesSelected
+ if (this.episodesSelected.length === 1) return `${this.$strings.LabelDownload} ${this.$strings.LabelEpisode.toLowerCase()}`
+ return this.$getString('LabelDownloadNEpisodes', [this.episodesSelected.length])
},
itemEpisodes() {
if (!this.libraryItem) return []
return this.libraryItem.media.episodes || []
},
itemEpisodeMap() {
- var map = {}
+ const map = {}
this.itemEpisodes.forEach((item) => {
- if (item.enclosure) map[item.enclosure.url.split('?')[0]] = true
+ if (item.enclosure) {
+ const cleanUrl = this.getCleanEpisodeUrl(item.enclosure.url)
+ map[cleanUrl] = true
+ }
})
return map
},
episodesList() {
return this.episodesCleaned.filter((episode) => {
if (!this.searchText) return true
- return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
+ return episode.title?.toLowerCase().includes(this.searchText) || episode.subtitle?.toLowerCase().includes(this.searchText)
})
+ },
+ selectAllLabel() {
+ if (this.episodesList.length === this.episodesCleaned.length) {
+ return this.$strings.LabelSelectAllEpisodes
+ }
+ const episodesNotDownloaded = this.episodesList.filter((ep) => !this.itemEpisodeMap[ep.cleanUrl]).length
+ return this.$getString('LabelSelectEpisodesShowing', [episodesNotDownloaded])
}
},
methods: {
+ /**
+ * RSS feed episode url is used for matching with existing downloaded episodes.
+ * Some RSS feeds include timestamps in the episode url (e.g. patreon) that can change on requests.
+ * These need to be removed in order to detect the same episode each time the feed is pulled.
+ *
+ * An RSS feed may include an `id` in the query string. In these cases we want to leave the `id`.
+ * @see https://github.com/advplyr/audiobookshelf/issues/1896
+ *
+ * @param {string} url - rss feed episode url
+ * @returns {string} rss feed episode url without dynamic query strings
+ */
+ getCleanEpisodeUrl(url) {
+ let queryString = url.split('?')[1]
+ if (!queryString) return url
+
+ const searchParams = new URLSearchParams(queryString)
+ for (const p of Array.from(searchParams.keys())) {
+ if (p !== 'id') searchParams.delete(p)
+ }
+
+ if (!searchParams.toString()) return url
+ return `${url}?${searchParams.toString()}`
+ },
inputUpdate() {
clearTimeout(this.searchTimeout)
this.searchTimeout = setTimeout(() => {
- if (!this.search || !this.search.trim()) {
+ if (!this.search?.trim()) {
this.searchText = ''
+ this.checkSetIsSelectedAll()
return
}
this.searchText = this.search.toLowerCase().trim()
+ this.checkSetIsSelectedAll()
}, 500)
},
toggleSelectAll(val) {
- for (const episode of this.episodesCleaned) {
+ for (const episode of this.episodesList) {
if (this.itemEpisodeMap[episode.cleanUrl]) this.selectedEpisodes[episode.cleanUrl] = false
else this.$set(this.selectedEpisodes, episode.cleanUrl, val)
}
},
checkSetIsSelectedAll() {
- for (const episode of this.episodesCleaned) {
+ for (const episode of this.episodesList) {
if (!this.itemEpisodeMap[episode.cleanUrl] && !this.selectedEpisodes[episode.cleanUrl]) {
this.selectAll = false
return
@@ -147,19 +183,19 @@ export default {
this.selectAll = true
},
toggleSelectEpisode(episode) {
- if (this.itemEpisodeMap[episode.enclosure.url?.split('?')[0]]) return
+ if (this.itemEpisodeMap[episode.cleanUrl]) return
this.$set(this.selectedEpisodes, episode.cleanUrl, !this.selectedEpisodes[episode.cleanUrl])
this.checkSetIsSelectedAll()
},
submit() {
- var episodesToDownload = []
+ let episodesToDownload = []
if (this.episodesSelected.length) {
episodesToDownload = this.episodesSelected.map((cleanUrl) => this.episodesCleaned.find((ep) => ep.cleanUrl == cleanUrl))
}
- var payloadSize = JSON.stringify(episodesToDownload).length
- var sizeInMb = payloadSize / 1024 / 1024
- var sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
+ const payloadSize = JSON.stringify(episodesToDownload).length
+ const sizeInMb = payloadSize / 1024 / 1024
+ const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
console.log('Request size', sizeInMb)
if (sizeInMb > 4.99) {
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
@@ -174,10 +210,9 @@ export default {
this.show = false
})
.catch((error) => {
- var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to download episodes'
console.error('Failed to download episodes', error)
this.processing = false
- this.$toast.error(errorMsg)
+ this.$toast.error(error.response?.data || 'Failed to download episodes')
this.selectedEpisodes = {}
this.selectAll = false
@@ -189,7 +224,7 @@ export default {
.map((_ep) => {
return {
..._ep,
- cleanUrl: _ep.enclosure.url.split('?')[0]
+ cleanUrl: this.getCleanEpisodeUrl(_ep.enclosure.url)
}
})
this.episodesCleaned.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
diff --git a/client/components/readers/ComicReader.vue b/client/components/readers/ComicReader.vue
index 166184ac..f1d9a82f 100644
--- a/client/components/readers/ComicReader.vue
+++ b/client/components/readers/ComicReader.vue
@@ -1,11 +1,11 @@
-
-
+
-
+
-
+
+ download
+
+
more
-
+
menu
-
- {{ page + 1 }} / {{ numPages }}
+
+ {{ page }} / {{ numPages }}
-
-
+
+
-
+
- ![]()
+
-
-
@@ -61,7 +60,13 @@ Archive.init({
export default {
props: {
- url: String
+ libraryItem: {
+ type: Object,
+ default: () => {}
+ },
+ playerOpen: Boolean,
+ keepProgress: Boolean,
+ fileId: String
},
data() {
return {
@@ -71,6 +76,7 @@ export default {
mainImg: null,
page: 0,
numPages: 0,
+ pageMenuWidth: 256,
showPageMenu: false,
showInfoMenu: false,
loadTimeout: null,
@@ -87,17 +93,79 @@ export default {
}
},
computed: {
+ userToken() {
+ return this.$store.getters['user/getToken']
+ },
+ libraryItemId() {
+ return this.libraryItem?.id
+ },
+ ebookUrl() {
+ if (this.fileId) {
+ return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
+ }
+ return `/api/items/${this.libraryItemId}/ebook`
+ },
comicMetadataKeys() {
return this.comicMetadata ? Object.keys(this.comicMetadata) : []
},
canGoNext() {
- return this.page < this.numPages - 1
+ return this.page < this.numPages
},
canGoPrev() {
- return this.page > 0
+ return this.page > 1
+ },
+ userMediaProgress() {
+ if (!this.libraryItemId) return
+ return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
+ },
+ savedPage() {
+ if (!this.keepProgress) return 0
+
+ // Validate ebookLocation is a number
+ if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
+ return Number(this.userMediaProgress.ebookLocation)
+ },
+ cleanedPageNames() {
+ return (
+ this.pages?.map((p) => {
+ if (p.length > 50) {
+ let firstHalf = p.slice(0, 22)
+ let lastHalf = p.slice(p.length - 23)
+ return `${firstHalf} ... ${lastHalf}`
+ }
+ return p
+ }) || []
+ )
}
},
methods: {
+ clickShowPageMenu() {
+ this.showInfoMenu = false
+ this.showPageMenu = !this.showPageMenu
+ },
+ clickShowInfoMenu() {
+ this.showPageMenu = false
+ this.showInfoMenu = !this.showInfoMenu
+ },
+ updateProgress() {
+ if (!this.keepProgress) return
+
+ if (!this.numPages) {
+ console.error('Num pages not loaded')
+ return
+ }
+ if (this.savedPage === this.page) {
+ return
+ }
+
+ const payload = {
+ ebookLocation: this.page,
+ ebookProgress: Math.max(0, Math.min(1, (Number(this.page) - 1) / Number(this.numPages)))
+ }
+ this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
+ console.error('ComicReader.updateProgress failed:', error)
+ })
+ },
clickOutside() {
if (this.showPageMenu) this.showPageMenu = false
if (this.showInfoMenu) this.showInfoMenu = false
@@ -110,12 +178,15 @@ export default {
if (!this.canGoPrev) return
this.setPage(this.page - 1)
},
- setPage(index) {
- if (index < 0 || index > this.numPages - 1) {
+ setPage(page) {
+ if (page <= 0 || page > this.numPages) {
return
}
- var filename = this.pages[index]
- this.page = index
+ this.showPageMenu = false
+ this.showInfoMenu = false
+ const filename = this.pages[page - 1]
+ this.page = page
+ this.updateProgress()
return this.extractFile(filename)
},
setLoadTimeout() {
@@ -145,10 +216,11 @@ export default {
},
async extract() {
this.loading = true
- console.log('Extracting', this.url)
-
- var buff = await this.$axios.$get(this.url, {
- responseType: 'blob'
+ var buff = await this.$axios.$get(this.ebookUrl, {
+ responseType: 'blob',
+ headers: {
+ Authorization: `Bearer ${this.userToken}`
+ }
})
const archive = await Archive.open(buff)
const originalFilesObject = await archive.getFilesObject()
@@ -164,9 +236,28 @@ export default {
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) {
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
} else {
this.$toast.error('Unable to extract pages')
@@ -249,15 +340,6 @@ export default {
\ No newline at end of file
diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue
index 20b8f203..fba30ec9 100644
--- a/client/components/readers/EpubReader.vue
+++ b/client/components/readers/EpubReader.vue
@@ -1,15 +1,15 @@
-
+
-
- chevron_left
-
+
-
- chevron_right
-
+
@@ -24,22 +24,39 @@ import ePub from 'epubjs'
*/
export default {
props: {
- url: String,
libraryItem: {
type: Object,
default: () => {}
- }
+ },
+ playerOpen: Boolean,
+ keepProgress: Boolean,
+ fileId: String
},
data() {
return {
windowWidth: 0,
+ windowHeight: 0,
/** @type {ePub.Book} */
book: null,
/** @type {ePub.Rendition} */
- rendition: null
+ rendition: null,
+ ereaderSettings: {
+ theme: 'dark',
+ fontScale: 100,
+ lineSpacing: 115,
+ spread: 'auto'
+ }
+ }
+ },
+ watch: {
+ playerOpen() {
+ this.resize()
}
},
computed: {
+ userToken() {
+ return this.$store.getters['user/getToken']
+ },
/** @returns {string} */
libraryItemId() {
return this.libraryItem?.id
@@ -52,28 +69,79 @@ export default {
},
/** @returns {Array } */
chapters() {
- return this.book ? this.book.navigation.toc : []
+ return this.book?.navigation?.toc || []
},
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
+ savedEbookLocation() {
+ if (!this.keepProgress) return null
+ if (!this.userMediaProgress?.ebookLocation) return null
+ // Validate ebookLocation is an epubcfi
+ if (!String(this.userMediaProgress.ebookLocation).startsWith('epubcfi')) return null
+ return this.userMediaProgress.ebookLocation
+ },
localStorageLocationsKey() {
return `ebookLocations-${this.libraryItemId}`
},
readerWidth() {
if (this.windowWidth < 640) return this.windowWidth
return this.windowWidth - 200
+ },
+ readerHeight() {
+ if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight
+ return this.windowHeight - 164
+ },
+ ebookUrl() {
+ if (this.fileId) {
+ return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
+ }
+ return `/api/items/${this.libraryItemId}/ebook`
+ },
+ themeRules() {
+ const isDark = this.ereaderSettings.theme === 'dark'
+ const fontColor = isDark ? '#fff' : '#000'
+ const backgroundColor = isDark ? 'rgb(35 35 35)' : 'rgb(255, 255, 255)'
+
+ const lineSpacing = this.ereaderSettings.lineSpacing / 100
+
+ const fontScale = this.ereaderSettings.fontScale / 100
+
+ return {
+ '*': {
+ color: `${fontColor}!important`,
+ 'background-color': `${backgroundColor}!important`,
+ 'line-height': lineSpacing * fontScale + 'rem!important'
+ },
+ a: {
+ color: `${fontColor}!important`
+ }
+ }
}
},
methods: {
+ updateSettings(settings) {
+ this.ereaderSettings = settings
+
+ if (!this.rendition) return
+
+ this.applyTheme()
+
+ const fontScale = settings.fontScale || 100
+ this.rendition.themes.fontSize(`${fontScale}%`)
+ this.rendition.spread(settings.spread || 'auto')
+ },
prev() {
+ if (!this.rendition?.manager) return
return this.rendition?.prev()
},
next() {
+ if (!this.rendition?.manager) return
return this.rendition?.next()
},
goToChapter(href) {
+ if (!this.rendition?.manager) return
return this.rendition?.display(href)
},
keyUp(e) {
@@ -90,6 +158,7 @@ export default {
* @param {string} payload.ebookProgress - eBook Progress Percentage
*/
updateProgress(payload) {
+ if (!this.keepProgress) return
this.$axios.$patch(`/api/me/progress/${this.libraryItemId}`, payload).catch((error) => {
console.error('EpubReader.updateProgress failed:', error)
})
@@ -181,7 +250,7 @@ export default {
},
/** @param {string} location - CFI of the new location */
relocated(location) {
- if (this.userMediaProgress?.ebookLocation === location.start.cfi) {
+ if (this.savedEbookLocation === location.start.cfi) {
return
}
@@ -201,43 +270,42 @@ export default {
const reader = this
/** @type {ePub.Book} */
- reader.book = new ePub(reader.url, {
+ reader.book = new ePub(reader.ebookUrl, {
width: this.readerWidth,
- height: window.innerHeight - 50
+ height: this.readerHeight - 50,
+ openAs: 'epub',
+ requestHeaders: {
+ Authorization: `Bearer ${this.userToken}`
+ }
})
/** @type {ePub.Rendition} */
reader.rendition = reader.book.renderTo('viewer', {
width: this.readerWidth,
- height: window.innerHeight * 0.8
+ height: this.readerHeight * 0.8,
+ spread: 'auto',
+ snap: true,
+ manager: 'continuous',
+ flow: 'paginated'
})
// load saved progress
- reader.rendition.display(this.userMediaProgress?.ebookLocation || reader.book.locations.start)
+ reader.rendition.display(this.savedEbookLocation || reader.book.locations.start)
- // load style
- reader.rendition.themes.default({ '*': { color: '#fff!important' } })
+ reader.rendition.on('rendered', () => {
+ this.applyTheme()
+ })
reader.book.ready.then(() => {
// set up event listeners
reader.rendition.on('relocated', reader.relocated)
reader.rendition.on('keydown', reader.keyUp)
- let touchStart = 0
- let touchEnd = 0
reader.rendition.on('touchstart', (event) => {
- touchStart = event.changedTouches[0].screenX
+ this.$emit('touchstart', event)
})
-
reader.rendition.on('touchend', (event) => {
- touchEnd = event.changedTouches[0].screenX
- const touchDistanceX = Math.abs(touchEnd - touchStart)
- if (touchStart < touchEnd && touchDistanceX > 120) {
- this.next()
- }
- if (touchStart > touchEnd && touchDistanceX > 120) {
- this.prev()
- }
+ this.$emit('touchend', event)
})
// load ebook cfi locations
@@ -253,17 +321,25 @@ export default {
},
resize() {
this.windowWidth = window.innerWidth
- this.rendition?.resize(this.readerWidth, window.innerHeight * 0.8)
+ this.windowHeight = window.innerHeight
+ this.rendition?.resize(this.readerWidth, this.readerHeight * 0.8)
+ },
+ applyTheme() {
+ if (!this.rendition) return
+ this.rendition.getContents().forEach((c) => {
+ c.addStylesheetRules(this.themeRules)
+ })
}
},
+ mounted() {
+ this.windowWidth = window.innerWidth
+ this.windowHeight = window.innerHeight
+ window.addEventListener('resize', this.resize)
+ this.initEpub()
+ },
beforeDestroy() {
window.removeEventListener('resize', this.resize)
this.book?.destroy()
- },
- mounted() {
- this.windowWidth = window.innerWidth
- window.addEventListener('resize', this.resize)
- this.initEpub()
}
}
diff --git a/client/components/readers/MobiReader.vue b/client/components/readers/MobiReader.vue
index e19a11f1..1d56117e 100644
--- a/client/components/readers/MobiReader.vue
+++ b/client/components/readers/MobiReader.vue
@@ -1,7 +1,7 @@
-
@@ -15,12 +15,30 @@ import defaultCss from '@/assets/ebooks/basic.js'
export default {
props: {
- url: String
+ libraryItem: {
+ type: Object,
+ default: () => {}
+ },
+ playerOpen: Boolean,
+ fileId: String
},
data() {
return {}
},
- computed: {},
+ computed: {
+ userToken() {
+ return this.$store.getters['user/getToken']
+ },
+ libraryItemId() {
+ return this.libraryItem?.id
+ },
+ ebookUrl() {
+ if (this.fileId) {
+ return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
+ }
+ return `/api/items/${this.libraryItemId}/ebook`
+ }
+ },
methods: {
addHtmlCss() {
let iframe = document.getElementsByTagName('iframe')[0]
@@ -78,8 +96,11 @@ export default {
},
async initMobi() {
// Fetch mobi file as blob
- var buff = await this.$axios.$get(this.url, {
- responseType: 'blob'
+ var buff = await this.$axios.$get(this.ebookUrl, {
+ responseType: 'blob',
+ headers: {
+ Authorization: `Bearer ${this.userToken}`
+ }
})
var reader = new FileReader()
reader.onload = async (event) => {
diff --git a/client/components/readers/PdfReader.vue b/client/components/readers/PdfReader.vue
index 89d7e45a..3100f907 100644
--- a/client/components/readers/PdfReader.vue
+++ b/client/components/readers/PdfReader.vue
@@ -11,15 +11,19 @@
-
+
{{ page }} / {{ numPages }}
+
+
+
+
-
+
{{ Math.floor(loadedRatio * 100) }}%
-
+
@@ -37,14 +41,19 @@ export default {
pdf
},
props: {
- url: String,
libraryItem: {
type: Object,
default: () => {}
- }
+ },
+ playerOpen: Boolean,
+ keepProgress: Boolean,
+ fileId: String
},
data() {
return {
+ windowWidth: 0,
+ windowHeight: 0,
+ scale: 1,
rotate: 0,
loadedRatio: 0,
page: 1,
@@ -52,14 +61,24 @@ export default {
}
},
computed: {
+ userToken() {
+ return this.$store.getters['user/getToken']
+ },
libraryItemId() {
return this.libraryItem?.id
},
+ fitToPageWidth() {
+ return this.pdfHeight * 0.6
+ },
pdfWidth() {
- return this.pdfHeight * 0.6667
+ return this.fitToPageWidth * this.scale
},
pdfHeight() {
- return window.innerHeight - 120
+ if (this.windowHeight < 400 || !this.playerOpen) return this.windowHeight - 120
+ return this.windowHeight - 284
+ },
+ maxScale() {
+ return Math.floor((this.windowWidth * 10) / this.fitToPageWidth) / 10
},
canGoNext() {
return this.page < this.numPages
@@ -67,16 +86,47 @@ export default {
canGoPrev() {
return this.page > 1
},
+ canScaleUp() {
+ return this.scale < this.maxScale
+ },
+ canScaleDown() {
+ return this.scale > 1
+ },
userMediaProgress() {
if (!this.libraryItemId) return
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
},
savedPage() {
- return Number(this.userMediaProgress?.ebookLocation || 0)
+ if (!this.keepProgress) return 0
+
+ // Validate ebookLocation is a number
+ if (!this.userMediaProgress?.ebookLocation || isNaN(this.userMediaProgress.ebookLocation)) return 0
+ return Number(this.userMediaProgress.ebookLocation)
+ },
+ ebookUrl() {
+ if (this.fileId) {
+ return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
+ }
+ return `/api/items/${this.libraryItemId}/ebook`
+ },
+ pdfDocInitParams() {
+ return {
+ url: this.ebookUrl,
+ httpHeaders: {
+ Authorization: `Bearer ${this.userToken}`
+ }
+ }
}
},
methods: {
+ zoomIn() {
+ this.scale += 0.1
+ },
+ zoomOut() {
+ this.scale -= 0.1
+ },
updateProgress() {
+ if (!this.keepProgress) return
if (!this.numPages) {
console.error('Num pages not loaded')
return
@@ -91,7 +141,7 @@ export default {
})
},
loadedEvt() {
- if (this.savedPage && this.savedPage > 0 && this.savedPage <= this.numPages) {
+ if (this.savedPage > 0 && this.savedPage <= this.numPages) {
this.page = this.savedPage
}
},
@@ -113,8 +163,19 @@ export default {
},
error(err) {
console.error(err)
+ },
+ resize() {
+ this.windowWidth = window.innerWidth
+ this.windowHeight = window.innerHeight
}
},
- mounted() {}
+ mounted() {
+ this.windowWidth = window.innerWidth
+ this.windowHeight = window.innerHeight
+ window.addEventListener('resize', this.resize)
+ },
+ beforeDestroy() {
+ window.removeEventListener('resize', this.resize)
+ }
}
\ No newline at end of file
diff --git a/client/components/readers/Reader.vue b/client/components/readers/Reader.vue
index 6532e96d..120bb400 100644
--- a/client/components/readers/Reader.vue
+++ b/client/components/readers/Reader.vue
@@ -1,36 +1,48 @@
-
-
- menu
+
+
+
+
-
+
{{ abTitle }}
- –
- {{ abAuthor }}
+ –
+ {{ abAuthor }}
- settings
- close
+
-
+
-
@@ -45,8 +92,21 @@
export default {
data() {
return {
+ touchstartX: 0,
+ touchstartY: 0,
+ touchendX: 0,
+ touchendY: 0,
+ touchstartTime: 0,
+ touchIdentifier: null,
chapters: [],
- tocOpen: false
+ tocOpen: false,
+ showSettings: false,
+ ereaderSettings: {
+ theme: 'dark',
+ fontScale: 100,
+ lineSpacing: 115,
+ spread: 'auto'
+ }
}
},
watch: {
@@ -65,6 +125,34 @@ export default {
this.$store.commit('setShowEReader', val)
}
},
+ ereaderTheme() {
+ if (this.isEpub) return this.ereaderSettings.theme
+ return 'dark'
+ },
+ spreadItems() {
+ return [
+ {
+ text: this.$strings.LabelLayoutSinglePage,
+ value: 'none'
+ },
+ {
+ text: this.$strings.LabelLayoutSplitPage,
+ value: 'auto'
+ }
+ ]
+ },
+ themeItems() {
+ return [
+ {
+ text: this.$strings.LabelThemeDark,
+ value: 'dark'
+ },
+ {
+ text: this.$strings.LabelThemeLight,
+ value: 'light'
+ }
+ ]
+ },
componentName() {
if (this.ebookType === 'epub') return 'readers-epub-reader'
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
@@ -72,11 +160,11 @@ export default {
else if (this.ebookType === 'comic') return 'readers-comic-reader'
return null
},
- hasToC() {
- return this.isEpub
+ streamLibraryItem() {
+ return this.$store.state.streamLibraryItem
},
hasSettings() {
- return false
+ return this.isEpub
},
abTitle() {
return this.mediaMetadata.title
@@ -100,10 +188,18 @@ export default {
return this.selectedLibraryItem.folderId
},
ebookFile() {
+ // 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() {
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() {
@@ -125,31 +221,36 @@ export default {
isComic() {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
},
- ebookUrl() {
- if (!this.ebookFile) return null
- let filepath = ''
- if (this.selectedLibraryItem.isFile) {
- filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
- } else {
- const itemRelPath = this.selectedLibraryItem.relPath
- if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
- const relPath = this.ebookFile.metadata.relPath
- if (relPath.startsWith('/')) relPath = relPath.slice(1)
-
- filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
- }
- return `/ebook/${this.libraryId}/${this.folderId}/${filepath}`
- },
userToken() {
return this.$store.getters['user/getToken']
+ },
+ keepProgress() {
+ return this.$store.state.ereaderKeepProgress
+ },
+ ebookFileId() {
+ return this.$store.state.ereaderFileId
+ },
+ isDarkTheme() {
+ return this.ereaderSettings.theme === 'dark'
}
},
methods: {
+ readerMounted() {
+ if (this.isEpub) {
+ this.loadEreaderSettings()
+ }
+ },
+ settingsUpdated() {
+ this.$refs.readerComponent?.updateSettings?.(this.ereaderSettings)
+ localStorage.setItem('ereaderSettings', JSON.stringify(this.ereaderSettings))
+ },
toggleToC() {
this.tocOpen = !this.tocOpen
this.chapters = this.$refs.readerComponent.chapters
},
- openSettings() {},
+ openSettings() {
+ this.showSettings = true
+ },
hotkey(action) {
if (!this.$refs.readerComponent) return
@@ -167,11 +268,72 @@ export default {
prev() {
if (this.$refs.readerComponent?.prev) this.$refs.readerComponent.prev()
},
+ handleGesture() {
+ // Touch must be less than 1s. Must be > 60px drag and X distance > Y distance
+ const touchTimeMs = Date.now() - this.touchstartTime
+ if (touchTimeMs >= 1000) {
+ console.log('Touch too long', touchTimeMs)
+ return
+ }
+
+ const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
+ const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
+ const touchDistance = Math.sqrt(Math.pow(this.touchstartX - this.touchendX, 2) + Math.pow(this.touchstartY - this.touchendY, 2))
+ if (touchDistance < 60) {
+ return
+ }
+
+ if (touchDistanceX < 60 || touchDistanceY > touchDistanceX) {
+ return
+ }
+
+ if (this.touchendX < this.touchstartX) {
+ this.next()
+ }
+ if (this.touchendX > this.touchstartX) {
+ this.prev()
+ }
+ },
+ touchstart(e) {
+ // Ignore rapid touch
+ if (this.touchstartTime && Date.now() - this.touchstartTime < 250) {
+ return
+ }
+
+ this.touchstartX = e.touches[0].screenX
+ this.touchstartY = e.touches[0].screenY
+ this.touchstartTime = Date.now()
+ this.touchIdentifier = e.touches[0].identifier
+ },
+ touchend(e) {
+ if (this.touchIdentifier !== e.changedTouches[0].identifier) {
+ return
+ }
+
+ this.touchendX = e.changedTouches[0].screenX
+ this.touchendY = e.changedTouches[0].screenY
+ this.handleGesture()
+ },
registerListeners() {
this.$eventBus.$on('reader-hotkey', this.hotkey)
+ document.body.addEventListener('touchstart', this.touchstart)
+ document.body.addEventListener('touchend', this.touchend)
},
unregisterListeners() {
this.$eventBus.$off('reader-hotkey', this.hotkey)
+ document.body.removeEventListener('touchstart', this.touchstart)
+ document.body.removeEventListener('touchend', this.touchend)
+ },
+ loadEreaderSettings() {
+ try {
+ const settings = localStorage.getItem('ereaderSettings')
+ if (settings) {
+ this.ereaderSettings = JSON.parse(settings)
+ this.settingsUpdated()
+ }
+ } catch (error) {
+ console.error('Failed to load ereader settings', error)
+ }
},
init() {
this.registerListeners()
@@ -191,11 +353,19 @@ export default {
\ No newline at end of file
diff --git a/client/components/stats/Heatmap.vue b/client/components/stats/Heatmap.vue
index 7ad71f1a..fe235bdd 100644
--- a/client/components/stats/Heatmap.vue
+++ b/client/components/stats/Heatmap.vue
@@ -235,7 +235,6 @@ export default {
style: `transform:translate(${x}px,${y}px);background-color:${bgColor};outline:1px solid ${outlineColor};outline-offset:-1px;`
})
}
- console.log('Data', this.data)
this.monthLabels = []
var lastMonth = null
diff --git a/client/components/tables/AudioTracksTableRow.vue b/client/components/tables/AudioTracksTableRow.vue
index 837aaa1a..cd3d26b9 100644
--- a/client/components/tables/AudioTracksTableRow.vue
+++ b/client/components/tables/AudioTracksTableRow.vue
@@ -17,7 +17,7 @@
{{ $secondsToTimestamp(track.duration) }}
|
-
+
|
@@ -73,11 +73,11 @@ export default {
return items
},
downloadUrl() {
- return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.track.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
+ return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.track.audioFile.ino}/download?token=${this.userToken}`
}
},
methods: {
- contextMenuAction(action) {
+ contextMenuAction({ action }) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
@@ -88,7 +88,7 @@ export default {
},
deleteLibraryFile() {
const payload = {
- message: 'This will delete the file from your file system. Are you sure?',
+ message: this.$strings.MessageConfirmDeleteFile,
callback: (confirmed) => {
if (confirmed) {
this.$axios
@@ -107,15 +107,7 @@ export default {
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
- const a = document.createElement('a')
- a.style.display = 'none'
- a.href = this.downloadUrl
- a.download = this.track.metadata.filename
- document.body.appendChild(a)
- a.click()
- setTimeout(() => {
- a.remove()
- })
+ this.$downloadFile(this.downloadUrl, this.track.metadata.filename)
}
},
mounted() {}
diff --git a/client/components/tables/BackupsTable.vue b/client/components/tables/BackupsTable.vue
index 6df009b8..a216f463 100644
--- a/client/components/tables/BackupsTable.vue
+++ b/client/components/tables/BackupsTable.vue
@@ -21,14 +21,14 @@
{{ $bytesPretty(backup.fileSize) }} |
- {{ $strings.ButtonRestore }}
-
- download
+ {{ $strings.ButtonRestore }}
error_outline
- delete
+
+
+
|
@@ -80,6 +80,9 @@ export default {
}
},
methods: {
+ downloadBackup(backup) {
+ this.$downloadFile(`${process.env.serverUrl}/api/backups/${backup.id}/download?token=${this.userToken}`)
+ },
confirm() {
this.showConfirmApply = false
@@ -91,8 +94,9 @@ export default {
})
.catch((error) => {
this.isBackingUp = false
- console.error('Failed', error)
- this.$toast.error(this.$strings.ToastBackupRestoreFailed)
+ console.error('Failed to apply backup', error)
+ const errorMsg = error.response.data || this.$strings.ToastBackupRestoreFailed
+ this.$toast.error(errorMsg)
})
},
deleteBackupClick(backup) {
diff --git a/client/components/tables/EbookFilesTable.vue b/client/components/tables/EbookFilesTable.vue
new file mode 100644
index 00000000..0c85774c
--- /dev/null
+++ b/client/components/tables/EbookFilesTable.vue
@@ -0,0 +1,87 @@
+
+
+
+
{{ $strings.HeaderEbookFiles }}
+
+ {{ ebookFiles.length }}
+
+
+
{{ $strings.ButtonFullPath }}
+
+ expand_more
+
+
+
+
+
+
+ {{ $strings.LabelPath }} |
+ {{ $strings.LabelSize }} |
+
+ {{ $strings.LabelRead }} info
+ |
+ |
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/tables/EbookFilesTableRow.vue b/client/components/tables/EbookFilesTableRow.vue
new file mode 100644
index 00000000..6362d5ab
--- /dev/null
+++ b/client/components/tables/EbookFilesTableRow.vue
@@ -0,0 +1,139 @@
+
+
+
+ {{ showFullPath ? file.metadata.path : file.metadata.relPath }} check_circle
+ |
+
+ {{ $bytesPretty(file.metadata.size) }}
+ |
+
+
+ |
+
+
+ |
+
+
+
+
\ No newline at end of file
diff --git a/client/components/tables/LibraryFilesTable.vue b/client/components/tables/LibraryFilesTable.vue
index f3a24331..c6c8c777 100644
--- a/client/components/tables/LibraryFilesTable.vue
+++ b/client/components/tables/LibraryFilesTable.vue
@@ -27,7 +27,7 @@
-
+
@@ -38,7 +38,6 @@ export default {
type: Object,
default: () => {}
},
- isMissing: Boolean,
expanded: Boolean, // start expanded
inModal: Boolean
},
diff --git a/client/components/tables/LibraryFilesTableRow.vue b/client/components/tables/LibraryFilesTableRow.vue
index 38bc885d..4378abd5 100644
--- a/client/components/tables/LibraryFilesTableRow.vue
+++ b/client/components/tables/LibraryFilesTableRow.vue
@@ -12,7 +12,7 @@
-
+
|
@@ -45,7 +45,7 @@ export default {
return this.$store.getters['user/getIsAdminOrUp']
},
downloadUrl() {
- return `${process.env.serverUrl}/s/item/${this.libraryItemId}/${this.$encodeUriPath(this.file.metadata.relPath).replace(/^\//, '')}?token=${this.userToken}`
+ return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.file.ino}/download?token=${this.userToken}`
},
contextMenuItems() {
const items = []
@@ -72,7 +72,7 @@ export default {
}
},
methods: {
- contextMenuAction(action) {
+ contextMenuAction({ action }) {
if (action === 'delete') {
this.deleteLibraryFile()
} else if (action === 'download') {
@@ -83,7 +83,7 @@ export default {
},
deleteLibraryFile() {
const payload = {
- message: 'This will delete the file from your file system. Are you sure?',
+ message: this.$strings.MessageConfirmDeleteFile,
callback: (confirmed) => {
if (confirmed) {
this.$axios
@@ -102,15 +102,7 @@ export default {
this.$store.commit('globals/setConfirmPrompt', payload)
},
downloadLibraryFile() {
- const a = document.createElement('a')
- a.style.display = 'none'
- a.href = this.downloadUrl
- a.download = this.file.metadata.filename
- document.body.appendChild(a)
- a.click()
- setTimeout(() => {
- a.remove()
- })
+ this.$downloadFile(this.downloadUrl, this.file.metadata.filename)
}
},
mounted() {}
diff --git a/client/components/tables/PlaylistItemsTable.vue b/client/components/tables/PlaylistItemsTable.vue
index 11d5c0fb..3a741bfe 100644
--- a/client/components/tables/PlaylistItemsTable.vue
+++ b/client/components/tables/PlaylistItemsTable.vue
@@ -70,7 +70,10 @@ export default {
methods: {
editItem(playlistItem) {
if (playlistItem.episode) {
- this.$store.commit('globals/setSelectedEpisode', playlist.episode)
+ const episodeIds = this.items.map((pi) => pi.episodeId)
+ this.$store.commit('setEpisodeTableEpisodeIds', episodeIds)
+ this.$store.commit('setSelectedLibraryItem', playlistItem.libraryItem)
+ this.$store.commit('globals/setSelectedEpisode', playlistItem.episode)
this.$store.commit('globals/setShowEditPodcastEpisodeModal', true)
} else {
const itemIds = this.items.map((i) => i.libraryItemId)
diff --git a/client/components/tables/TracksTable.vue b/client/components/tables/TracksTable.vue
index 22944453..2554fff1 100644
--- a/client/components/tables/TracksTable.vue
+++ b/client/components/tables/TracksTable.vue
@@ -33,7 +33,7 @@
-
+
diff --git a/client/components/tables/UsersTable.vue b/client/components/tables/UsersTable.vue
index 88323a74..cfcf3f47 100644
--- a/client/components/tables/UsersTable.vue
+++ b/client/components/tables/UsersTable.vue
@@ -19,9 +19,13 @@
{{ user.type }} |
-
- Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}
- Last: {{ usersOnline[user.id].mostRecent.media.metadata.title }}
+
+ Listening: {{ usersOnline[user.id].session.displayTitle || '' }}
+ {{ getDeviceInfoString(usersOnline[user.id].session.deviceInfo) }}
+
+
+ Last: {{ user.latestSession.displayTitle || '' }}
+ {{ getDeviceInfoString(user.latestSession.deviceInfo) }}
|
@@ -83,6 +87,12 @@ export default {
}
},
methods: {
+ getDeviceInfoString(deviceInfo) {
+ if (!deviceInfo) return ''
+ if (deviceInfo.manufacturer && deviceInfo.model) return `${deviceInfo.manufacturer} ${deviceInfo.model}`
+
+ return `${deviceInfo.osName || 'Unknown'} ${deviceInfo.osVersion || ''} ${deviceInfo.browserName || ''}`
+ },
deleteUserClick(user) {
if (this.isDeletingUser) return
if (confirm(this.$getString('MessageRemoveUserWarning', [user.username]))) {
@@ -114,11 +124,12 @@ export default {
},
loadUsers() {
this.$axios
- .$get('/api/users')
+ .$get('/api/users?include=latestSession')
.then((res) => {
this.users = res.users.sort((a, b) => {
return a.createdAt - b.createdAt
})
+ console.log('Loaded users', this.users)
})
.catch((error) => {
console.error('Failed', error)
diff --git a/client/components/tables/collection/BookTableRow.vue b/client/components/tables/collection/BookTableRow.vue
index f8cce0b4..834088d9 100644
--- a/client/components/tables/collection/BookTableRow.vue
+++ b/client/components/tables/collection/BookTableRow.vue
@@ -188,7 +188,6 @@ export default {
.$patch(`/api/me/progress/${this.book.id}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
- this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)
diff --git a/client/components/tables/library/LibraryItem.vue b/client/components/tables/library/LibraryItem.vue
index 7a99e0e6..6cec8867 100644
--- a/client/components/tables/library/LibraryItem.vue
+++ b/client/components/tables/library/LibraryItem.vue
@@ -94,7 +94,7 @@ export default {
}
},
methods: {
- contextMenuAction(action) {
+ contextMenuAction({ action }) {
this.showMobileMenu = false
if (action === 'edit') {
this.editClick()
diff --git a/client/components/tables/playlist/ItemTableRow.vue b/client/components/tables/playlist/ItemTableRow.vue
index bfb43825..ff986a33 100644
--- a/client/components/tables/playlist/ItemTableRow.vue
+++ b/client/components/tables/playlist/ItemTableRow.vue
@@ -198,7 +198,6 @@ export default {
.$patch(routepath, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
- this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)
diff --git a/client/components/tables/podcast/EpisodeTableRow.vue b/client/components/tables/podcast/EpisodeTableRow.vue
index b05d1c86..4300b8e1 100644
--- a/client/components/tables/podcast/EpisodeTableRow.vue
+++ b/client/components/tables/podcast/EpisodeTableRow.vue
@@ -183,7 +183,6 @@ export default {
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
- this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
})
.catch((error) => {
console.error('Failed', error)
diff --git a/client/components/tables/podcast/EpisodesTable.vue b/client/components/tables/podcast/EpisodesTable.vue
index feec71eb..fcfe705e 100644
--- a/client/components/tables/podcast/EpisodesTable.vue
+++ b/client/components/tables/podcast/EpisodesTable.vue
@@ -1,22 +1,29 @@
- {{ $strings.HeaderEpisodes }}
-
- {{ $strings.HeaderEpisodes }}
+
+
+ {{ $strings.HeaderEpisodes }}
+
+ {{ episodes.length }}
+ {{ episodesList.length }} / {{ episodes.length }}
+
+
-
-
-
-
- {{ $getString('MessageRemoveEpisodes', [selectedEpisodes.length]) }}
- {{ $strings.ButtonCancel }}
-
-
-
-
-
-
-
+
+
+
+
+
+ {{ $getString('MessageRemoveEpisodes', [selectedEpisodes.length]) }}
+ {{ $strings.ButtonCancel }}
+
+
+
+
+
+
+
+
{{ $strings.MessageNoEpisodes }}
@@ -51,7 +58,6 @@ export default {
selectedEpisodes: [],
episodesToRemove: [],
processing: false,
- quickMatchingEpisodes: false,
search: null,
searchTimeout: null,
searchText: null
@@ -71,6 +77,10 @@ export default {
{
text: 'Quick match all episodes',
action: 'quick-match-episodes'
+ },
+ {
+ text: this.allEpisodesFinished ? this.$strings.MessageMarkAllEpisodesNotFinished : this.$strings.MessageMarkAllEpisodesFinished,
+ action: 'batch-mark-as-finished'
}
]
},
@@ -157,14 +167,20 @@ export default {
episodesList() {
return this.episodesSorted.filter((episode) => {
if (!this.searchText) return true
- return (episode.title && episode.title.toLowerCase().includes(this.searchText)) || (episode.subtitle && episode.subtitle.toLowerCase().includes(this.searchText))
+ return episode.title?.toLowerCase().includes(this.searchText) || episode.subtitle?.toLowerCase().includes(this.searchText)
})
},
selectedIsFinished() {
// Find an item that is not finished, if none then all items finished
- return !this.selectedEpisodes.find((episode) => {
- var itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
- return !itemProgress || !itemProgress.isFinished
+ return !this.selectedEpisodes.some((episode) => {
+ const itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
+ return !itemProgress?.isFinished
+ })
+ },
+ allEpisodesFinished() {
+ return !this.episodesSorted.some((episode) => {
+ const itemProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
+ return !itemProgress?.isFinished
})
},
dateFormat() {
@@ -185,19 +201,36 @@ export default {
this.searchText = this.search.toLowerCase().trim()
}, 500)
},
- contextMenuAction(action) {
+ contextMenuAction({ action }) {
if (action === 'quick-match-episodes') {
- if (this.quickMatchingEpisodes) return
+ if (this.processing) return
this.quickMatchAllEpisodes()
+ } else if (action === 'batch-mark-as-finished') {
+ if (this.processing) return
+
+ this.markAllEpisodesFinished()
}
},
+ markAllEpisodesFinished() {
+ const newIsFinished = !this.allEpisodesFinished
+ const payload = {
+ message: newIsFinished ? this.$strings.MessageConfirmMarkAllEpisodesFinished : this.$strings.MessageConfirmMarkAllEpisodesNotFinished,
+ callback: (confirmed) => {
+ if (confirmed) {
+ this.batchUpdateEpisodesFinished(this.episodesSorted, newIsFinished)
+ }
+ },
+ type: 'yesNo'
+ }
+ this.$store.commit('globals/setConfirmPrompt', payload)
+ },
quickMatchAllEpisodes() {
if (!this.mediaMetadata.feedUrl) {
this.$toast.error(this.$strings.MessagePodcastHasNoRSSFeedForMatching)
return
}
- this.quickMatchingEpisodes = true
+ this.processing = true
const payload = {
message: 'Quick matching episodes will overwrite details if a match is found. Only unmatched episodes will be updated. Are you sure?',
@@ -217,7 +250,7 @@ export default {
this.$toast.error('Failed to match episodes')
})
}
- this.quickMatchingEpisodes = false
+ this.processing = false
},
type: 'yesNo'
}
@@ -241,17 +274,19 @@ export default {
this.$store.commit('addItemToQueue', queueItem)
},
toggleBatchFinished() {
+ this.batchUpdateEpisodesFinished(this.selectedEpisodes, !this.selectedIsFinished)
+ },
+ batchUpdateEpisodesFinished(episodes, newIsFinished) {
this.processing = true
- var newIsFinished = !this.selectedIsFinished
- var updateProgressPayloads = this.selectedEpisodes.map((episode) => {
+
+ const updateProgressPayloads = episodes.map((episode) => {
return {
libraryItemId: this.libraryItem.id,
episodeId: episode.id,
isFinished: newIsFinished
}
})
-
- this.$axios
+ return this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success(this.$strings.ToastBatchUpdateSuccess)
diff --git a/client/components/ui/Btn.vue b/client/components/ui/Btn.vue
index f40fda17..d9b75715 100644
--- a/client/components/ui/Btn.vue
+++ b/client/components/ui/Btn.vue
@@ -73,7 +73,7 @@ export default {
}
-
\ No newline at end of file
diff --git a/client/components/ui/Menu.vue b/client/components/ui/Menu.vue
deleted file mode 100644
index 4c06ac0b..00000000
--- a/client/components/ui/Menu.vue
+++ /dev/null
@@ -1,61 +0,0 @@
-
-
-
-
-
-
-
-
- -
-
- {{ item.text }}
-
-
-
- -
-
- {{ item.text }}
-
-
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/client/components/ui/RangeInput.vue b/client/components/ui/RangeInput.vue
new file mode 100644
index 00000000..4ab53a68
--- /dev/null
+++ b/client/components/ui/RangeInput.vue
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/ui/TextareaWithLabel.vue b/client/components/ui/TextareaWithLabel.vue
index 96f98c92..f2e237e0 100644
--- a/client/components/ui/TextareaWithLabel.vue
+++ b/client/components/ui/TextareaWithLabel.vue
@@ -1,7 +1,7 @@
@@ -11,6 +11,7 @@ export default {
value: [String, Number],
label: String,
disabled: Boolean,
+ readonly: Boolean,
rows: {
type: Number,
default: 2
diff --git a/client/components/ui/ToggleBtns.vue b/client/components/ui/ToggleBtns.vue
new file mode 100644
index 00000000..2312b26f
--- /dev/null
+++ b/client/components/ui/ToggleBtns.vue
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/components/ui/VueTrix.vue b/client/components/ui/VueTrix.vue
index e6f65733..ace1edd3 100644
--- a/client/components/ui/VueTrix.vue
+++ b/client/components/ui/VueTrix.vue
@@ -213,7 +213,9 @@ export default {
// Reload HTML content
this.$refs.trix.editor.loadHTML(newContent)
// Move cursor to end of new content updated
- this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition())
+ if (this.autofocus) {
+ this.$refs.trix.editor.setSelectedRange(this.getContentEndPosition())
+ }
},
getContentEndPosition() {
return this.$refs.trix.editor.getDocument().toString().length - 1
diff --git a/client/components/widgets/MoreMenu.vue b/client/components/widgets/MoreMenu.vue
index 1caa476b..f890559a 100644
--- a/client/components/widgets/MoreMenu.vue
+++ b/client/components/widgets/MoreMenu.vue
@@ -1,7 +1,17 @@
-
+
-
+
+
+
+
+
@@ -22,13 +32,43 @@ export default {
handler: this.clickedOutside,
events: ['mousedown'],
isActive: true
- }
+ },
+ submenuWidth: 144,
+ menuWidth: 144,
+ mouseoverItemIndex: null,
+ isOverSubItemMenu: false,
+ openSubMenuLeft: false
+ }
+ },
+ computed: {
+ submenuLeftPos() {
+ return this.openSubMenuLeft ? -this.submenuWidth : this.menuWidth - 1.5
}
},
- computed: {},
methods: {
- clickAction(func) {
- this.$emit('action', func)
+ mouseoverSubItemMenu(index) {
+ this.isOverSubItemMenu = true
+ },
+ mouseleaveSubItemMenu(index) {
+ setTimeout(() => {
+ if (this.isOverSubItemMenu && this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
+ }, 1)
+ },
+ mouseoverItem(index) {
+ this.isOverSubItemMenu = false
+ this.mouseoverItemIndex = index
+ },
+ mouseleaveItem(index) {
+ setTimeout(() => {
+ if (this.isOverSubItemMenu) return
+ if (this.mouseoverItemIndex === index) this.mouseoverItemIndex = null
+ }, 1)
+ },
+ clickAction(func, data) {
+ this.$emit('action', {
+ func,
+ data
+ })
this.close()
},
clickedOutside(e) {
@@ -44,7 +84,14 @@ export default {
this.$el.parentNode.removeChild(this.$el)
}
},
- mounted() {},
+ mounted() {
+ this.$nextTick(() => {
+ const boundingRect = this.$refs.wrapper?.getBoundingClientRect()
+ if (boundingRect) {
+ this.openSubMenuLeft = window.innerWidth - boundingRect.x < this.menuWidth + this.submenuWidth + 5
+ }
+ })
+ },
beforeDestroy() {}
}
\ No newline at end of file
diff --git a/client/components/widgets/NotificationWidget.vue b/client/components/widgets/NotificationWidget.vue
index a88ddbf6..891c13c3 100644
--- a/client/components/widgets/NotificationWidget.vue
+++ b/client/components/widgets/NotificationWidget.vue
@@ -1,19 +1,21 @@
- |