mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-12-31 11:38:47 -05:00
Compare commits
173 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2793fe29b | ||
|
|
24b9ac6a68 | ||
|
|
9a5ed64fae | ||
|
|
c2af96e7cd | ||
|
|
104cadb0b3 | ||
|
|
6814adffcc | ||
|
|
20c11e381e | ||
|
|
b5952f16eb | ||
|
|
5b6878e5de | ||
|
|
89a25bcf39 | ||
|
|
d0cd512be8 | ||
|
|
3543dea0fb | ||
|
|
1949e25ccb | ||
|
|
b715ef3bfc | ||
|
|
954050df81 | ||
|
|
e4aa7f10fa | ||
|
|
2afd0e2acd | ||
|
|
0829237166 | ||
|
|
541975f038 | ||
|
|
01bf58ab97 | ||
|
|
d99b2c25e8 | ||
|
|
a31df5ff81 | ||
|
|
63e5cf2e60 | ||
|
|
7beca048e7 | ||
|
|
ec998dc1ac | ||
|
|
ddc54c8811 | ||
|
|
72e306935f | ||
|
|
96a7c7f4d1 | ||
|
|
9c65d655b8 | ||
|
|
b108f2241b | ||
|
|
9439acf300 | ||
|
|
d181e66d83 | ||
|
|
a87c3f2c77 | ||
|
|
2834f6077e | ||
|
|
918013ccb3 | ||
|
|
4c4672c6c1 | ||
|
|
b3991574c7 | ||
|
|
c881bcbe59 | ||
|
|
89aa4a8bdc | ||
|
|
c5a4f63670 | ||
|
|
1b97582975 | ||
|
|
9b7aacf3ea | ||
|
|
47b9ee557e | ||
|
|
e40e0bfa25 | ||
|
|
d56e3a3617 | ||
|
|
78fe6d47ba | ||
|
|
995cf51ae3 | ||
|
|
d838ff2f2e | ||
|
|
f2f07ff534 | ||
|
|
8cff68ca64 | ||
|
|
eb5331d34a | ||
|
|
f425185575 | ||
|
|
9fc352a5a4 | ||
|
|
e85ddc1aa1 | ||
|
|
b9be7510f8 | ||
|
|
f4497acd48 | ||
|
|
f73a0cce72 | ||
|
|
254ba1f089 | ||
|
|
0a179e4eed | ||
|
|
0ac63b2678 | ||
|
|
1d13d0a553 | ||
|
|
fc6ff016a7 | ||
|
|
e378b79fbc | ||
|
|
7e377297d7 | ||
|
|
00a02921dd | ||
|
|
b5d4c11f6f | ||
|
|
a0bc959850 | ||
|
|
a4b0f6c202 | ||
|
|
65cf928afe | ||
|
|
cf7fd315b6 | ||
|
|
d86a3b3dc2 | ||
|
|
e07e2cd359 | ||
|
|
8140d7021a | ||
|
|
bdbc5e3161 | ||
|
|
bb9013541b | ||
|
|
1668153acd | ||
|
|
aeba7674f8 | ||
|
|
5b0d105e21 | ||
|
|
feb54d0629 | ||
|
|
3284fe8f31 | ||
|
|
18cb394884 | ||
|
|
d0bce2949e | ||
|
|
a0e80772cd | ||
|
|
e44595521d | ||
|
|
fdf647eb32 | ||
|
|
71369bd2a0 | ||
|
|
36b1f43f4c | ||
|
|
a8bc1df3e7 | ||
|
|
a96869f547 | ||
|
|
77b030199e | ||
|
|
0e1c6c0ba7 | ||
|
|
c397422d3b | ||
|
|
15313826bf | ||
|
|
c6405b9013 | ||
|
|
d748d43efc | ||
|
|
d54edb93d6 | ||
|
|
b8ca6671fc | ||
|
|
cb7fb646ba | ||
|
|
aa82c8a253 | ||
|
|
aae92649b1 | ||
|
|
a9f5c64204 | ||
|
|
1392baf1eb | ||
|
|
0ec50bb570 | ||
|
|
b60473d7ae | ||
|
|
014fc45c15 | ||
|
|
4b4fb33d8f | ||
|
|
35e3458fb4 | ||
|
|
8f42153bee | ||
|
|
2f04d34bce | ||
|
|
09566c02ea | ||
|
|
d714ef37d9 | ||
|
|
fde07d26e5 | ||
|
|
9547824aaa | ||
|
|
5a01be1ee3 | ||
|
|
5dc4606657 | ||
|
|
2fd3238576 | ||
|
|
c1bcfe8304 | ||
|
|
a3642b204d | ||
|
|
8243da69f6 | ||
|
|
6d5987b2e0 | ||
|
|
a2fdc3e876 | ||
|
|
f92b66a469 | ||
|
|
c3d256c42b | ||
|
|
fdc792cb82 | ||
|
|
a16fb31e6e | ||
|
|
4d8a1b5b6d | ||
|
|
c382f07b05 | ||
|
|
9f6a7d065c | ||
|
|
11aa75ecbe | ||
|
|
05ce9c6eda | ||
|
|
15aaf2863c | ||
|
|
019063e6f4 | ||
|
|
ea79948122 | ||
|
|
7a0f27e3cc | ||
|
|
4f75a89633 | ||
|
|
b3f19ef628 | ||
|
|
f16e312319 | ||
|
|
056da0ef70 | ||
|
|
ca5f781531 | ||
|
|
53c96b2540 | ||
|
|
9712bdf5f0 | ||
|
|
0678c26627 | ||
|
|
b52e240025 | ||
|
|
2fa73f7a8d | ||
|
|
2cc23b6d6b | ||
|
|
9a617226b3 | ||
|
|
fbfc015d92 | ||
|
|
3e4c94e2b4 | ||
|
|
1da471e136 | ||
|
|
4dba95c000 | ||
|
|
36477a832c | ||
|
|
b4aa8f0c9a | ||
|
|
6a974d5ef0 | ||
|
|
304eda9f8c | ||
|
|
581f2e3d15 | ||
|
|
be2d317325 | ||
|
|
9f6bfeb839 | ||
|
|
f4f5f79af7 | ||
|
|
92bb2fb23d | ||
|
|
3c406c12b4 | ||
|
|
81d4ac3ed2 | ||
|
|
32bdae31a8 | ||
|
|
84c16c4a39 | ||
|
|
b8b3d05f5e | ||
|
|
bac09de23d | ||
|
|
b0bf9604bb | ||
|
|
688531f0a7 | ||
|
|
dfc7877f69 | ||
|
|
e00116a0e3 | ||
|
|
2ab287e2a9 | ||
|
|
1e0da09b2f | ||
|
|
0e7a5649cc | ||
|
|
30009e45da |
10
Dockerfile
10
Dockerfile
@@ -10,11 +10,15 @@ FROM sandreas/tone:v0.1.5 AS tone
|
||||
FROM node:16-alpine
|
||||
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apk update && \
|
||||
apk add --no-cache --update \
|
||||
curl \
|
||||
tzdata \
|
||||
ffmpeg
|
||||
ffmpeg \
|
||||
make \
|
||||
python3 \
|
||||
g++
|
||||
|
||||
COPY --from=tone /usr/local/bin/tone /usr/local/bin/
|
||||
COPY --from=build /client/dist /client/dist
|
||||
@@ -23,6 +27,10 @@ COPY server server
|
||||
|
||||
RUN npm ci --only=production
|
||||
|
||||
RUN apk del make python3 g++
|
||||
|
||||
ENV NODE_OPTIONS=--max-old-space-size=4096
|
||||
|
||||
EXPOSE 80
|
||||
HEALTHCHECK \
|
||||
--interval=30s \
|
||||
|
||||
@@ -232,6 +232,20 @@ Bookshelf Label
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
.episode-subtitle-long {
|
||||
word-break: break-word;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
line-height: 16px;
|
||||
/* fallback */
|
||||
max-height: 72px;
|
||||
/* fallback */
|
||||
-webkit-line-clamp: 6;
|
||||
/* number of lines to show */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
|
||||
|
||||
/* Padding for toastification toasts in the top right to not cover appbar/toolbar */
|
||||
.app-bar-and-toolbar .Vue-Toastification__container.top-right {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</nuxt-link>
|
||||
|
||||
<nuxt-link to="/">
|
||||
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf <span v-if="showExperimentalFeatures" class="material-icons text-lg text-warning pr-1">logo_dev</span></h1>
|
||||
<h1 class="text-xl mr-6 hidden lg:block hover:underline">audiobookshelf</h1>
|
||||
</nuxt-link>
|
||||
|
||||
<ui-libraries-dropdown class="mr-2" />
|
||||
@@ -15,8 +15,6 @@
|
||||
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
|
||||
<div class="flex-grow" />
|
||||
|
||||
<widgets-notification-widget class="hidden md:block" />
|
||||
|
||||
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
|
||||
<span class="material-icons-outlined text-2xl text-warning text-opacity-50"> cast </span>
|
||||
</ui-tooltip>
|
||||
@@ -24,6 +22,8 @@
|
||||
<google-cast-launcher></google-cast-launcher>
|
||||
</div>
|
||||
|
||||
<widgets-notification-widget class="hidden md:block" />
|
||||
|
||||
<nuxt-link v-if="currentLibrary" to="/config/stats" class="hover:text-gray-200 cursor-pointer w-8 h-8 hidden sm:flex items-center justify-center mx-1">
|
||||
<ui-tooltip :text="$strings.HeaderYourStats" direction="bottom" class="flex items-center">
|
||||
<span class="material-icons text-2xl" aria-label="User Stats" role="button">equalizer</span>
|
||||
@@ -149,9 +149,6 @@ export default {
|
||||
processingBatch() {
|
||||
return this.$store.state.processingBatch
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
isChromecastEnabled() {
|
||||
return this.$store.getters['getServerSetting']('chromecastEnabled')
|
||||
},
|
||||
@@ -178,6 +175,11 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
options.push({
|
||||
text: 'Re-Scan',
|
||||
action: 'rescan'
|
||||
})
|
||||
|
||||
return options
|
||||
}
|
||||
},
|
||||
@@ -206,13 +208,39 @@ export default {
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
contextMenuAction(action) {
|
||||
contextMenuAction({ action }) {
|
||||
if (action === 'quick-embed') {
|
||||
this.requestBatchQuickEmbed()
|
||||
} else if (action === 'quick-match') {
|
||||
this.batchAutoMatchClick()
|
||||
} else if (action === 'rescan') {
|
||||
this.batchRescan()
|
||||
}
|
||||
},
|
||||
async batchRescan() {
|
||||
const payload = {
|
||||
message: `Are you sure you want to re-scan ${this.selectedMediaItems.length} items?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.$axios
|
||||
.$post(`/api/items/batch/scan`, {
|
||||
libraryItemIds: this.selectedMediaItems.map((i) => i.id)
|
||||
})
|
||||
.then(() => {
|
||||
console.log('Batch Re-Scan started')
|
||||
this.cancelSelectionMode()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Batch Re-Scan failed', error)
|
||||
const errorMsg = error.response.data || 'Failed to batch re-scan'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
async playSelectedItems() {
|
||||
this.$store.commit('setProcessingBatch', true)
|
||||
|
||||
@@ -275,13 +303,13 @@ export default {
|
||||
this.$axios
|
||||
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
|
||||
.then(() => {
|
||||
this.$toast.success('Batch update success!')
|
||||
this.$toast.success(this.$strings.ToastBatchUpdateSuccess)
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
this.$store.commit('globals/resetSelectedMediaItems', [])
|
||||
this.$eventBus.$emit('bookshelf_clear_selection')
|
||||
})
|
||||
.catch((error) => {
|
||||
this.$toast.error('Batch update failed')
|
||||
this.$toast.error(this.$strings.ToastBatchUpdateFailed)
|
||||
console.error('Failed to batch update read/not read', error)
|
||||
this.$store.commit('setProcessingBatch', false)
|
||||
})
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<!-- Alternate plain view -->
|
||||
<div v-else-if="isAlternativeBookshelfView" class="w-full mb-24">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||
<widgets-item-slider v-if="shelf.type === 'book' || shelf.type === 'podcast'" :shelf-id="shelf.id" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||
<p class="font-semibold text-gray-100" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ $strings[shelf.labelStringKey] }}</p>
|
||||
</widgets-item-slider>
|
||||
<widgets-episode-slider v-else-if="shelf.type === 'episode'" :key="index + '.'" :items="shelf.entities" :continue-listening-shelf="shelf.id === 'continue-listening'" :height="232 * sizeMultiplier" class="bookshelf-row pl-8 my-6" @selectEntity="(payload) => selectEntity(payload, index)">
|
||||
@@ -36,7 +36,7 @@
|
||||
<!-- Regular bookshelf view -->
|
||||
<div v-else class="w-full">
|
||||
<template v-for="(shelf, index) in shelves">
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening'" @selectEntity="(payload) => selectEntity(payload, index)" />
|
||||
<app-book-shelf-row :key="index" :index="index" :shelf="shelf" :size-multiplier="sizeMultiplier" :book-cover-width="bookCoverWidth" :book-cover-aspect-ratio="coverAspectRatio" :continue-listening-shelf="shelf.id === 'continue-listening' || shelf.id === 'continue-reading'" @selectEntity="(payload) => selectEntity(payload, index)" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -65,9 +65,6 @@ export default {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
@@ -171,7 +168,7 @@ export default {
|
||||
},
|
||||
async fetchCategories() {
|
||||
const categories = await this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed`)
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/personalized?include=rssfeed,numEpisodesIncomplete`)
|
||||
.then((data) => {
|
||||
return data
|
||||
})
|
||||
@@ -286,7 +283,8 @@ export default {
|
||||
}
|
||||
if (user.mediaProgress.length) {
|
||||
const mediaProgressToHide = user.mediaProgress.filter((mp) => mp.hideFromContinueListening)
|
||||
this.removeItemsFromContinueListening(mediaProgressToHide)
|
||||
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-listening')
|
||||
this.removeItemsFromContinueListeningReading(mediaProgressToHide, 'continue-reading')
|
||||
}
|
||||
},
|
||||
libraryItemAdded(libraryItem) {
|
||||
@@ -336,8 +334,9 @@ export default {
|
||||
},
|
||||
libraryItemsAdded(libraryItems) {
|
||||
console.log('libraryItems added', libraryItems)
|
||||
// TODO: Check if audiobook would be on this shelf
|
||||
if (!this.search) {
|
||||
|
||||
const isThisLibrary = !libraryItems.some((li) => li.libraryId !== this.currentLibraryId)
|
||||
if (!this.search && isThisLibrary) {
|
||||
this.fetchCategories()
|
||||
}
|
||||
},
|
||||
@@ -346,6 +345,14 @@ export default {
|
||||
this.libraryItemUpdated(li)
|
||||
})
|
||||
},
|
||||
episodeAdded(episodeWithLibraryItem) {
|
||||
console.log('Podcast episode added', episodeWithLibraryItem)
|
||||
|
||||
const isThisLibrary = episodeWithLibraryItem.libraryItem?.libraryId === this.currentLibraryId
|
||||
if (!this.search && isThisLibrary) {
|
||||
this.fetchCategories()
|
||||
}
|
||||
},
|
||||
removeAllSeriesFromContinueSeries(seriesIds) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
if (shelf.type == 'book' && shelf.id == 'continue-series') {
|
||||
@@ -357,8 +364,8 @@ export default {
|
||||
}
|
||||
})
|
||||
},
|
||||
removeItemsFromContinueListening(mediaProgressItems) {
|
||||
const continueListeningShelf = this.shelves.find((s) => s.id === 'continue-listening')
|
||||
removeItemsFromContinueListeningReading(mediaProgressItems, categoryId) {
|
||||
const continueListeningShelf = this.shelves.find((s) => s.id === categoryId)
|
||||
if (continueListeningShelf) {
|
||||
if (continueListeningShelf.type === 'book') {
|
||||
continueListeningShelf.entities = continueListeningShelf.entities.filter((ent) => {
|
||||
@@ -373,17 +380,6 @@ export default {
|
||||
})
|
||||
}
|
||||
}
|
||||
// this.shelves.forEach((shelf) => {
|
||||
// if (shelf.id == 'continue-listening') {
|
||||
// if (shelf.type == 'book') {
|
||||
// // Filter out books from continue listening shelf
|
||||
// shelf.entities = shelf.entities.filter((ent) => {
|
||||
// if (mediaProgressItems.some(mp => mp.libraryItemId === ent.id)) return false
|
||||
// return true
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// })
|
||||
},
|
||||
authorUpdated(author) {
|
||||
this.shelves.forEach((shelf) => {
|
||||
@@ -417,6 +413,7 @@ export default {
|
||||
this.$root.socket.on('item_removed', this.libraryItemRemoved)
|
||||
this.$root.socket.on('items_updated', this.libraryItemsUpdated)
|
||||
this.$root.socket.on('items_added', this.libraryItemsAdded)
|
||||
this.$root.socket.on('episode_added', this.episodeAdded)
|
||||
} else {
|
||||
console.error('Error socket not initialized')
|
||||
}
|
||||
@@ -431,6 +428,7 @@ export default {
|
||||
this.$root.socket.off('item_removed', this.libraryItemRemoved)
|
||||
this.$root.socket.off('items_updated', this.libraryItemsUpdated)
|
||||
this.$root.socket.off('items_added', this.libraryItemsAdded)
|
||||
this.$root.socket.off('episode_added', this.episodeAdded)
|
||||
} else {
|
||||
console.error('Error socket not initialized')
|
||||
}
|
||||
|
||||
@@ -81,6 +81,8 @@
|
||||
|
||||
<!-- issues page remove all button -->
|
||||
<ui-btn v-if="isIssuesFilter && userCanDelete && !isBatchSelecting" :loading="processingIssues" color="error" small class="ml-4" @click="removeAllIssues">{{ $strings.ButtonRemoveAll }} {{ numShowing }} {{ entityName }}</ui-btn>
|
||||
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="110" class="ml-2" @action="contextMenuAction" />
|
||||
</template>
|
||||
<!-- search page -->
|
||||
<template v-else-if="page === 'search'">
|
||||
@@ -186,6 +188,9 @@ export default {
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
@@ -276,10 +281,30 @@ export default {
|
||||
},
|
||||
isIssuesFilter() {
|
||||
return this.filterBy === 'issues' && this.$route.query.filter === 'issues'
|
||||
},
|
||||
contextMenuItems() {
|
||||
const items = []
|
||||
|
||||
if (this.isPodcastLibrary && this.isLibraryPage && this.userCanDownload) {
|
||||
items.push({
|
||||
text: 'Export OPML',
|
||||
action: 'export-opml'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
seriesContextMenuAction(action) {
|
||||
contextMenuAction({ action }) {
|
||||
if (action === 'export-opml') {
|
||||
this.exportOPML()
|
||||
}
|
||||
},
|
||||
exportOPML() {
|
||||
this.$downloadFile(`/api/libraries/${this.currentLibraryId}/opml?token=${this.$store.getters['user/getToken']}`, null, true)
|
||||
},
|
||||
seriesContextMenuAction({ action }) {
|
||||
if (action === 'open-rss-feed') {
|
||||
this.showOpenSeriesRSSFeed()
|
||||
} else if (action === 're-add-to-continue-listening') {
|
||||
|
||||
@@ -90,6 +90,11 @@ export default {
|
||||
title: this.$strings.HeaderNotifications,
|
||||
path: '/config/notifications'
|
||||
},
|
||||
{
|
||||
id: 'config-email',
|
||||
title: this.$strings.HeaderEmail,
|
||||
path: '/config/email'
|
||||
},
|
||||
{
|
||||
id: 'config-item-metadata-utils',
|
||||
title: this.$strings.HeaderItemMetadataUtils,
|
||||
|
||||
@@ -78,9 +78,6 @@ export default {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
libraryMediaType() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
},
|
||||
@@ -318,10 +315,10 @@ export default {
|
||||
|
||||
const entityPath = this.entityName === 'series-books' ? 'items' : this.entityName
|
||||
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed`
|
||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1&include=rssfeed,numEpisodesIncomplete`
|
||||
|
||||
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||
console.error('failed to fetch books', error)
|
||||
console.error('failed to fetch items', error)
|
||||
return null
|
||||
})
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div v-if="podcastAuthor" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ podcastAuthor }}</div>
|
||||
<div v-else-if="musicArtists" class="pl-1 sm:pl-1.5 text-xs sm:text-base">{{ musicArtists }}</div>
|
||||
<div v-else-if="authors.length" class="pl-1 sm:pl-1.5 text-xs sm:text-base">
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
<nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</div>
|
||||
<div v-else class="text-xs sm:text-base cursor-pointer pl-1 sm:pl-1.5">{{ $strings.LabelUnknown }}</div>
|
||||
<widgets-explicit-indicator :explicit="isExplicit"></widgets-explicit-indicator>
|
||||
@@ -268,6 +268,10 @@ export default {
|
||||
seek(time) {
|
||||
this.playerHandler.seek(time)
|
||||
},
|
||||
playbackTimeUpdate(time) {
|
||||
// When updating progress from another session
|
||||
this.playerHandler.seek(time, false)
|
||||
},
|
||||
setCurrentTime(time) {
|
||||
this.currentTime = time
|
||||
if (this.$refs.audioPlayer) {
|
||||
@@ -366,9 +370,8 @@ export default {
|
||||
navigator.mediaSession.setActionHandler('seekbackward', this.mediaSessionSeekBackward)
|
||||
navigator.mediaSession.setActionHandler('seekforward', this.mediaSessionSeekForward)
|
||||
navigator.mediaSession.setActionHandler('seekto', this.mediaSessionSeekTo)
|
||||
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionPreviousTrack)
|
||||
const hasNextChapter = this.$refs.audioPlayer && this.$refs.audioPlayer.hasNextChapter
|
||||
navigator.mediaSession.setActionHandler('nexttrack', hasNextChapter ? this.mediaSessionNextTrack : null)
|
||||
navigator.mediaSession.setActionHandler('previoustrack', this.mediaSessionSeekBackward)
|
||||
navigator.mediaSession.setActionHandler('nexttrack', this.mediaSessionSeekForward)
|
||||
} else {
|
||||
console.warn('Media session not available')
|
||||
}
|
||||
@@ -478,12 +481,14 @@ export default {
|
||||
mounted() {
|
||||
this.$eventBus.$on('cast-session-active', this.castSessionActive)
|
||||
this.$eventBus.$on('playback-seek', this.seek)
|
||||
this.$eventBus.$on('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('cast-session-active', this.castSessionActive)
|
||||
this.$eventBus.$off('playback-seek', this.seek)
|
||||
this.$eventBus.$off('playback-time-update', this.playbackTimeUpdate)
|
||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<nuxt-link :to="`/author/${author.id}`">
|
||||
<nuxt-link :to="`/author/${author.id}?library=${currentLibraryId}`">
|
||||
<div @mouseover="mouseover" @mouseleave="mouseleave">
|
||||
<div :style="{ width: width + 'px', height: height + 'px' }" class="bg-primary box-shadow-book rounded-md relative overflow-hidden">
|
||||
<!-- Image or placeholder -->
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<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 === 'subtitle'" class="truncate text-xs text-gray-300" v-html="matchHtml" />
|
||||
|
||||
<p v-if="matchKey !== 'authors'" class="text-xs text-gray-200 truncate">by {{ authorName }}</p>
|
||||
<p v-else class="truncate text-xs text-gray-200" v-html="matchHtml" />
|
||||
@@ -61,7 +61,6 @@ export default {
|
||||
},
|
||||
matchHtml() {
|
||||
if (!this.matchText || !this.search) return ''
|
||||
if (this.matchKey === 'subtitle') return ''
|
||||
|
||||
// This used to highlight the part of the search found
|
||||
// but with removing commas periods etc this is no longer plausible
|
||||
@@ -69,6 +68,7 @@ export default {
|
||||
|
||||
if (this.matchKey === 'episode') return `<p class="truncate">${this.$strings.LabelEpisode}: ${html}</p>`
|
||||
if (this.matchKey === 'tags') return `<p class="truncate">${this.$strings.LabelTags}: ${html}</p>`
|
||||
if (this.matchKey === 'subtitle') return `<p class="truncate">${html}</p>`
|
||||
if (this.matchKey === 'authors') return `by ${html}`
|
||||
if (this.matchKey === 'isbn') return `<p class="truncate">ISBN: ${html}</p>`
|
||||
if (this.matchKey === 'asin') return `<p class="truncate">ASIN: ${html}</p>`
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<div class="flex items-center h-full px-1 overflow-hidden">
|
||||
<div class="h-5 w-5 min-w-5 text-lg mr-1.5 flex items-center justify-center">
|
||||
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{actionIcon}}</span>
|
||||
<div class="flex items-center px-1 overflow-hidden">
|
||||
<div class="w-8 flex items-center justify-center">
|
||||
<!-- <div class="text-lg"> -->
|
||||
<span v-if="isFinished" :class="taskIconStatus" class="material-icons text-base">{{ actionIcon }}</span>
|
||||
<widgets-loading-spinner v-else />
|
||||
<!-- </div> -->
|
||||
</div>
|
||||
<div class="flex-grow px-2 taskRunningCardContent">
|
||||
<p class="truncate text-sm">{{ title }}</p>
|
||||
@@ -36,10 +38,13 @@ export default {
|
||||
return this.task.details || 'Unknown'
|
||||
},
|
||||
isFinished() {
|
||||
return this.task.isFinished || false
|
||||
return !!this.task.isFinished
|
||||
},
|
||||
isFailed() {
|
||||
return this.task.isFailed || false
|
||||
return !!this.task.isFailed
|
||||
},
|
||||
isSuccess() {
|
||||
return this.isFinished && !this.isFailed
|
||||
},
|
||||
failedMessage() {
|
||||
return this.task.error || ''
|
||||
@@ -48,6 +53,11 @@ export default {
|
||||
return this.task.action || ''
|
||||
},
|
||||
actionIcon() {
|
||||
if (this.isFailed) {
|
||||
return 'error'
|
||||
} else if (this.isSuccess) {
|
||||
return 'done'
|
||||
}
|
||||
switch (this.action) {
|
||||
case 'download-podcast-episode':
|
||||
return 'cloud_download'
|
||||
@@ -68,16 +78,15 @@ export default {
|
||||
return ''
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.taskRunningCardContent {
|
||||
width: calc(100% - 80px);
|
||||
height: 75px;
|
||||
width: calc(100% - 84px);
|
||||
height: 60px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
|
||||
@@ -114,6 +114,7 @@ export default {
|
||||
var files = this.item.itemFiles.concat(this.item.otherFiles)
|
||||
return {
|
||||
index: this.item.index,
|
||||
directory: this.directory,
|
||||
...this.itemData,
|
||||
files
|
||||
}
|
||||
|
||||
@@ -76,6 +76,10 @@
|
||||
<div ref="moreIcon" v-show="!isSelectionMode && moreMenuItems.length" class="hidden md:block absolute cursor-pointer hover:text-yellow-300 300 hover:scale-125 transform duration-150" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickShowMore">
|
||||
<span class="material-icons" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">more_vert</span>
|
||||
</div>
|
||||
|
||||
<div v-if="ebookFormat" class="absolute" :style="{ bottom: 0.375 * sizeMultiplier + 'rem', left: 0.375 * sizeMultiplier + 'rem' }">
|
||||
<span class="text-white/80" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">{{ ebookFormat }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Processing/loading spinner overlay -->
|
||||
@@ -112,9 +116,14 @@
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div v-else-if="numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<div v-else-if="!numEpisodesIncomplete && numEpisodes && !isHovering && !isSelectionMode" class="absolute rounded-full bg-black bg-opacity-90 box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodes }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast Num Episodes -->
|
||||
<div v-else-if="numEpisodesIncomplete && !isHovering && !isSelectionMode" class="absolute rounded-full bg-yellow-400 text-black font-semibold box-shadow-md z-10 flex items-center justify-center" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', width: 1.25 * sizeMultiplier + 'rem', height: 1.25 * sizeMultiplier + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">{{ numEpisodesIncomplete }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -170,12 +179,6 @@ export default {
|
||||
dateFormat() {
|
||||
return this.store.state.serverSettings.dateFormat
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.store.state.showExperimentalFeatures
|
||||
},
|
||||
enableEReader() {
|
||||
return this.store.getters['getServerSetting']('enableEReader')
|
||||
},
|
||||
_libraryItem() {
|
||||
return this.libraryItem || {}
|
||||
},
|
||||
@@ -221,7 +224,7 @@ export default {
|
||||
libraryId() {
|
||||
return this._libraryItem.libraryId
|
||||
},
|
||||
hasEbook() {
|
||||
ebookFormat() {
|
||||
return this.media.ebookFormat
|
||||
},
|
||||
numTracks() {
|
||||
@@ -229,9 +232,11 @@ export default {
|
||||
return this.media.numTracks || 0 // toJSONMinified
|
||||
},
|
||||
numEpisodes() {
|
||||
if (!this.isPodcast) return 0
|
||||
return this.media.numEpisodes || 0
|
||||
},
|
||||
numEpisodesIncomplete() {
|
||||
return this._libraryItem.numEpisodesIncomplete || 0
|
||||
},
|
||||
processingBatch() {
|
||||
return this.store.state.processingBatch
|
||||
},
|
||||
@@ -252,14 +257,14 @@ export default {
|
||||
},
|
||||
booksInSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.numBooks : 0
|
||||
return this.collapsedSeries?.numBooks || 0
|
||||
},
|
||||
seriesSequenceList() {
|
||||
return this.collapsedSeries ? this.collapsedSeries.seriesSequenceList : null
|
||||
return this.collapsedSeries?.seriesSequenceList || null
|
||||
},
|
||||
libraryItemIdsInSeries() {
|
||||
// Only added to item object when collapseSeries is enabled
|
||||
return this.collapsedSeries ? this.collapsedSeries.libraryItemIds || [] : []
|
||||
return this.collapsedSeries?.libraryItemIds || []
|
||||
},
|
||||
hasCover() {
|
||||
return !!this.media.coverPath
|
||||
@@ -325,6 +330,9 @@ export default {
|
||||
if (this.episodeProgress) return this.episodeProgress
|
||||
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
isEBookOnly() {
|
||||
return !this.numTracks && this.ebookFormat
|
||||
},
|
||||
useEBookProgress() {
|
||||
if (!this.userProgress || this.userProgress.progress) return false
|
||||
return this.userProgress.ebookProgress > 0
|
||||
@@ -360,13 +368,13 @@ export default {
|
||||
return this.store.getters['getIsStreamingFromDifferentLibrary']
|
||||
},
|
||||
showReadButton() {
|
||||
return !this.isSelectionMode && !this.showPlayButton && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
||||
return !this.isSelectionMode && !this.showPlayButton && this.ebookFormat
|
||||
},
|
||||
showPlayButton() {
|
||||
return !this.isSelectionMode && !this.isMissing && !this.isInvalid && !this.isStreaming && (this.numTracks || this.recentEpisode || this.isMusic)
|
||||
},
|
||||
showSmallEBookIcon() {
|
||||
return !this.isSelectionMode && this.hasEbook && (this.showExperimentalFeatures || this.enableEReader)
|
||||
return !this.isSelectionMode && this.ebookFormat
|
||||
},
|
||||
isMissing() {
|
||||
return this._libraryItem.isMissing
|
||||
@@ -482,6 +490,18 @@ export default {
|
||||
text: this.$strings.LabelAddToPlaylist
|
||||
})
|
||||
}
|
||||
if (this.ebookFormat && this.store.state.libraries.ereaderDevices?.length) {
|
||||
items.push({
|
||||
text: this.$strings.LabelSendEbookToDevice,
|
||||
subitems: this.store.state.libraries.ereaderDevices.map((d) => {
|
||||
return {
|
||||
text: d.name,
|
||||
func: 'sendToDevice',
|
||||
data: d.name
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
if (this.userCanUpdate) {
|
||||
items.push({
|
||||
@@ -508,7 +528,7 @@ export default {
|
||||
if (this.continueListeningShelf) {
|
||||
items.push({
|
||||
func: 'removeFromContinueListening',
|
||||
text: this.$strings.ButtonRemoveFromContinueListening
|
||||
text: this.isEBookOnly ? this.$strings.ButtonRemoveFromContinueReading : this.$strings.ButtonRemoveFromContinueListening
|
||||
})
|
||||
}
|
||||
if (!this.isPodcast) {
|
||||
@@ -667,7 +687,6 @@ export default {
|
||||
.$patch(apiEndpoint, updatePayload)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
@@ -712,7 +731,40 @@ export default {
|
||||
// More menu func
|
||||
this.store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'match' })
|
||||
},
|
||||
sendToDevice(deviceName) {
|
||||
// More menu func
|
||||
const payload = {
|
||||
// message: `Are you sure you want to send ${this.ebookFormat} ebook "${this.title}" to device "${deviceName}"?`,
|
||||
message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFormat, this.title, deviceName]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
const payload = {
|
||||
libraryItemId: this.libraryItemId,
|
||||
deviceName
|
||||
}
|
||||
this.processing = true
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
axios
|
||||
.$post(`/api/emails/send-ebook-to-device`, payload)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$getString('ToastSendEbookToDeviceSuccess', [deviceName]))
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to send ebook to device', error)
|
||||
this.$toast.error(this.$strings.ToastSendEbookToDeviceFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
removeSeriesFromContinueListening() {
|
||||
if (!this.series) return
|
||||
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
this.processing = true
|
||||
axios
|
||||
@@ -825,8 +877,8 @@ export default {
|
||||
items: this.moreMenuItems
|
||||
},
|
||||
created() {
|
||||
this.$on('action', (func) => {
|
||||
if (_this[func]) _this[func]()
|
||||
this.$on('action', (action) => {
|
||||
if (action.func && _this[action.func]) _this[action.func](action.data)
|
||||
})
|
||||
this.$on('close', () => {
|
||||
_this.isMoreMenuOpen = false
|
||||
@@ -838,7 +890,7 @@ export default {
|
||||
var wrapperBox = this.$refs.moreIcon.getBoundingClientRect()
|
||||
var el = instance.$el
|
||||
|
||||
var elHeight = this.moreMenuItems.length * 28 + 2
|
||||
var elHeight = this.moreMenuItems.length * 28 + 10
|
||||
var elWidth = 130
|
||||
|
||||
var bottomOfIcon = wrapperBox.top + wrapperBox.height
|
||||
@@ -865,12 +917,13 @@ export default {
|
||||
this.createMoreMenu()
|
||||
},
|
||||
async clickReadEBook() {
|
||||
var libraryItem = await this.$axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
|
||||
const axios = this.$axios || this.$nuxt.$axios
|
||||
var libraryItem = await axios.$get(`/api/items/${this.libraryItemId}?expanded=1`).catch((error) => {
|
||||
console.error('Failed to get lirbary item', this.libraryItemId)
|
||||
return null
|
||||
})
|
||||
if (!libraryItem) return
|
||||
this.store.commit('showEReader', libraryItem)
|
||||
this.store.commit('showEReader', { libraryItem, keepProgress: true })
|
||||
},
|
||||
selectBtnClick(evt) {
|
||||
if (this.processingBatch) return
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="narrators?.length" class="flex py-0.5 mt-4">
|
||||
<div class="w-32">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNarrators }}</span>
|
||||
</div>
|
||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="publishedYear" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublishYear }}</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -20,15 +20,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="publisher" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPublisher }}</span>
|
||||
</div>
|
||||
<div>
|
||||
{{ publisher }}
|
||||
<nuxt-link :to="`/library/${libraryId}/bookshelf?filter=publishers.${$encode(publisher)}`" class="hover:underline">{{ publisher }}</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicAlbum" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Album</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -36,7 +36,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicAlbumArtist" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Album Artist</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -44,7 +44,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicTrackPretty" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Track</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -52,7 +52,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="musicDiscPretty" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">Disc</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -60,7 +60,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="podcastType" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelPodcastType }}</span>
|
||||
</div>
|
||||
<div class="capitalize">
|
||||
@@ -68,7 +68,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5" v-if="genres.length">
|
||||
<div class="w-32">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelGenres }}</span>
|
||||
</div>
|
||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||
@@ -79,7 +79,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5" v-if="tags.length">
|
||||
<div class="w-32">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelTags }}</span>
|
||||
</div>
|
||||
<div class="max-w-[calc(100vw-10rem)] overflow-hidden overflow-ellipsis">
|
||||
@@ -90,7 +90,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="tracks.length || audioFile || (isPodcast && totalPodcastDuration)" class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelDuration }}</span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -98,7 +98,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex py-0.5">
|
||||
<div class="w-32">
|
||||
<div class="w-24 min-w-24 sm:w-32 sm:min-w-32">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelSize }}</span>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs">{{ selectedText }}</span>
|
||||
</span>
|
||||
@@ -14,12 +14,17 @@
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-sm ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
</div>
|
||||
|
||||
<!-- selected checkmark icon -->
|
||||
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||
<span class="material-icons text-base text-yellow-400">check</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
|
||||
@@ -197,8 +197,8 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
.globalSearchMenu {
|
||||
max-height: 80vh;
|
||||
max-height: calc(100vh - 75px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full bg-fg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<button type="button" class="relative w-full h-full bg-bg border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
</span>
|
||||
@@ -9,31 +9,35 @@
|
||||
<path fill-rule="evenodd" d="M10 3a1 1 0 01.707.293l3 3a1 1 0 01-1.414 1.414L10 5.414 7.707 7.707a1 1 0 01-1.414-1.414l3-3A1 1 0 0110 3zm-3.707 9.293a1 1 0 011.414 0L10 14.586l2.293-2.293a1 1 0 011.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</span>
|
||||
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-300" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||
<div v-else class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 cursor-pointer text-gray-400 hover:text-gray-200" @mousedown.stop @mouseup.stop @click.stop.prevent="clearSelected">
|
||||
<span class="material-icons" style="font-size: 1.1rem">close</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm">
|
||||
<div v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm libraryFilterMenu">
|
||||
<ul v-show="!sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in selectItems">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item)">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item)">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal ml-3 block truncate text-sm md:text-base">{{ item.text }}</span>
|
||||
<span class="font-normal ml-3 block truncate text-sm">{{ item.text }}</span>
|
||||
</div>
|
||||
<div v-if="item.sublist" class="absolute right-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-icons text-2xl">arrow_right</span>
|
||||
</div>
|
||||
<!-- selected checkmark icon -->
|
||||
<div v-if="item.value === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||
<span class="material-icons text-base text-yellow-400">check</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
<ul v-show="sublist" class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-black-400" role="option" @click="sublist = null">
|
||||
<li class="text-gray-50 select-none relative py-2 pl-9 cursor-pointer hover:bg-white/5" role="option" @click="sublist = null">
|
||||
<div class="absolute left-1 top-0 bottom-0 h-full flex items-center">
|
||||
<span class="material-icons text-2xl">arrow_left</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="font-normal ml-3 block truncate">Back</span>
|
||||
<span class="font-normal block truncate">Back</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-if="!sublistItems.length" class="text-gray-400 select-none relative px-2" role="option">
|
||||
@@ -41,16 +45,15 @@
|
||||
<span class="font-normal block truncate py-2">No {{ sublist }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<li v-else-if="sublist === 'series'" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedSublistOption($encode('no-series'))">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal block truncate py-2 text-xs text-white text-opacity-80">{{ $strings.MessageNoSeries }}</span>
|
||||
</div>
|
||||
</li>
|
||||
<template v-for="item in sublistItems">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative px-2 cursor-pointer hover:bg-black-400" :class="`${sublist}.${item.value}` === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedSublistOption(item.value)">
|
||||
<li :key="item.value" class="select-none relative px-2 cursor-pointer hover:bg-white/5" :class="`${sublist}.${item.value}` === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedSublistOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal truncate py-2 text-xs">{{ item.text }}</span>
|
||||
</div>
|
||||
<!-- selected checkmark icon -->
|
||||
<div v-if="`${sublist}.${item.value}` === selected" class="absolute inset-y-0 right-2 h-full flex items-center pointer-events-none">
|
||||
<span class="material-icons text-base text-yellow-400">check</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
@@ -72,9 +75,8 @@ export default {
|
||||
},
|
||||
watch: {
|
||||
showMenu(newVal) {
|
||||
if (!newVal) {
|
||||
if (this.sublist && !this.selectedItemSublist) this.sublist = null
|
||||
if (!this.sublist && this.selectedItemSublist) this.sublist = this.selectedItemSublist
|
||||
if (newVal) {
|
||||
this.sublist = this.selectedItemSublist
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -122,6 +124,11 @@ export default {
|
||||
value: 'narrators',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelPublisher,
|
||||
value: 'publishers',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLanguage,
|
||||
value: 'languages',
|
||||
@@ -165,6 +172,11 @@ export default {
|
||||
value: 'narrators',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelPublisher,
|
||||
value: 'publishers',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelLanguage,
|
||||
value: 'languages',
|
||||
@@ -185,6 +197,11 @@ export default {
|
||||
value: 'tracks',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelEbooks,
|
||||
value: 'ebooks',
|
||||
sublist: true
|
||||
},
|
||||
{
|
||||
text: this.$strings.LabelAbridged,
|
||||
value: 'abridged',
|
||||
@@ -255,21 +272,25 @@ export default {
|
||||
return this.bookItems
|
||||
},
|
||||
selectedItemSublist() {
|
||||
return this.selected && this.selected.includes('.') ? this.selected.split('.')[0] : false
|
||||
return this.selected?.includes('.') ? this.selected.split('.')[0] : null
|
||||
},
|
||||
selectedText() {
|
||||
if (!this.selected) return ''
|
||||
var parts = this.selected.split('.')
|
||||
var filterName = this.selectItems.find((i) => i.value === parts[0])
|
||||
var filterValue = null
|
||||
const parts = this.selected.split('.')
|
||||
const filterName = this.selectItems.find((i) => i.value === parts[0])
|
||||
let filterValue = null
|
||||
if (parts.length > 1) {
|
||||
var decoded = this.$decode(parts[1])
|
||||
if (decoded.startsWith('aut_')) {
|
||||
var author = this.authors.find((au) => au.id == decoded)
|
||||
const decoded = this.$decode(parts[1])
|
||||
if (parts[0] === 'authors') {
|
||||
const author = this.authors.find((au) => au.id == decoded)
|
||||
if (author) filterValue = author.name
|
||||
} else if (decoded.startsWith('ser_')) {
|
||||
var series = this.series.find((se) => se.id == decoded)
|
||||
if (series) filterValue = series.name
|
||||
} else if (parts[0] === 'series') {
|
||||
if (decoded === 'no-series') {
|
||||
filterValue = this.$strings.MessageNoSeries
|
||||
} else {
|
||||
const series = this.series.find((se) => se.id == decoded)
|
||||
if (series) filterValue = series.name
|
||||
}
|
||||
} else {
|
||||
filterValue = decoded
|
||||
}
|
||||
@@ -302,6 +323,9 @@ export default {
|
||||
languages() {
|
||||
return this.filterData.languages || []
|
||||
},
|
||||
publishers() {
|
||||
return this.filterData.publishers || []
|
||||
},
|
||||
progress() {
|
||||
return [
|
||||
{
|
||||
@@ -334,6 +358,18 @@ export default {
|
||||
}
|
||||
]
|
||||
},
|
||||
ebooks() {
|
||||
return [
|
||||
{
|
||||
id: 'ebook',
|
||||
name: this.$strings.LabelHasEbook
|
||||
},
|
||||
{
|
||||
id: 'supplementary',
|
||||
name: this.$strings.LabelHasSupplementaryEbook
|
||||
}
|
||||
]
|
||||
},
|
||||
missing() {
|
||||
return [
|
||||
{
|
||||
@@ -391,7 +427,7 @@ export default {
|
||||
]
|
||||
},
|
||||
sublistItems() {
|
||||
return (this[this.sublist] || []).map((item) => {
|
||||
const sublistItems = (this[this.sublist] || []).map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return {
|
||||
text: item,
|
||||
@@ -404,6 +440,13 @@ export default {
|
||||
}
|
||||
}
|
||||
})
|
||||
if (this.sublist === 'series') {
|
||||
sublistItems.unshift({
|
||||
text: this.$strings.MessageNoSeries,
|
||||
value: this.$encode('no-series')
|
||||
})
|
||||
}
|
||||
return sublistItems
|
||||
},
|
||||
filterData() {
|
||||
return this.$store.state.libraries.filterData || {}
|
||||
@@ -428,7 +471,7 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
var val = option.value
|
||||
const val = option.value
|
||||
if (this.selected === val) {
|
||||
this.showMenu = false
|
||||
return
|
||||
@@ -439,4 +482,10 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.libraryFilterMenu {
|
||||
max-height: calc(100vh - 125px);
|
||||
}
|
||||
</style>
|
||||
@@ -7,11 +7,11 @@
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-96 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in selectItems">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
</div>
|
||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
<template>
|
||||
<div ref="wrapper" class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<button type="button" class="relative w-full h-full border border-gray-500 hover:border-gray-400 rounded shadow-sm pl-3 pr-3 py-0 text-left focus:outline-none cursor-pointer" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" @click.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center justify-between">
|
||||
<span class="block truncate text-xs" :class="!selectedText ? 'text-gray-300' : ''">{{ selectedText }}</span>
|
||||
<span class="material-icons text-lg text-yellow-400">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<ul v-show="showMenu" class="absolute z-10 mt-1 w-full bg-bg border border-black-200 shadow-lg max-h-80 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer hover:bg-black-400" :class="item.value === selected ? 'bg-primary bg-opacity-50' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<li :key="item.value" class="select-none relative py-2 pr-9 cursor-pointer hover:bg-white/5" :class="item.value === selected ? 'bg-white/5 text-yellow-400' : 'text-gray-200 hover:text-white'" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate text-xs">{{ item.text }}</span>
|
||||
<span class="font-normal ml-3 block truncate">{{ item.text }}</span>
|
||||
</div>
|
||||
<span v-if="item.value === selected" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">{{ descending ? 'expand_more' : 'expand_less' }}</span>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<div class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 overflow-y-auto overflow-x-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<div class="w-full p-8">
|
||||
<div class="flex py-2">
|
||||
<div class="w-1/2 px-2">
|
||||
@@ -103,7 +103,6 @@
|
||||
<ui-toggle-switch labeledBy="selected-tags-not-accessible--permissions-toggle" v-model="newUser.permissions.selectedTagsNotAccessible" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -353,7 +352,8 @@ export default {
|
||||
accessAllTags: true,
|
||||
selectedTagsNotAccessible: false
|
||||
},
|
||||
librariesAccessible: []
|
||||
librariesAccessible: [],
|
||||
itemTagsSelected: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,84 +1,99 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" name="audiofile-data-modal" :width="700" :height="'unset'">
|
||||
<div v-if="audioFile" ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||
<p class="text-base text-gray-200">{{ metadata.filename }}</p>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
|
||||
|
||||
<div class="flex flex-col sm:flex-row text-sm">
|
||||
<div class="w-full sm:w-1/2">
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelSize }}
|
||||
</p>
|
||||
<p>{{ $bytesPretty(metadata.size) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelDuration }}
|
||||
</p>
|
||||
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
|
||||
<p>{{ audioFile.format }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelChapters }}
|
||||
</p>
|
||||
<p>{{ audioFile.chapters?.length || 0 }}</p>
|
||||
</div>
|
||||
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelEmbeddedCover }}
|
||||
</p>
|
||||
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-1/2">
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelCodec }}
|
||||
</p>
|
||||
<p>{{ audioFile.codec }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelChannels }}
|
||||
</p>
|
||||
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelBitrate }}
|
||||
</p>
|
||||
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
|
||||
<p>{{ audioFile.timeBase }}</p>
|
||||
</div>
|
||||
<div v-if="audioFile.language" class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelLanguage }}
|
||||
</p>
|
||||
<p>{{ audioFile.language || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<p class="text-base text-gray-200 truncate">{{ metadata.filename }}</p>
|
||||
<ui-btn v-if="ffprobeData" small class="ml-2" @click="ffprobeData = null">{{ $strings.ButtonReset }}</ui-btn>
|
||||
<ui-btn v-else-if="userIsAdminOrUp" small :loading="probingFile" class="ml-2" @click="getFFProbeData">Probe Audio File</ui-btn>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
|
||||
<template v-if="!ffprobeData">
|
||||
<ui-text-input-with-label :value="metadata.path" readonly :label="$strings.LabelPath" class="mb-4 text-sm" />
|
||||
|
||||
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
|
||||
<p class="w-32 min-w-32 text-black-50 mb-1">
|
||||
{{ key.replace('tag', '') }}
|
||||
</p>
|
||||
<p>{{ value }}</p>
|
||||
<div class="flex flex-col sm:flex-row text-sm">
|
||||
<div class="w-full sm:w-1/2">
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelSize }}
|
||||
</p>
|
||||
<p>{{ $bytesPretty(metadata.size) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelDuration }}
|
||||
</p>
|
||||
<p>{{ $secondsToTimestamp(audioFile.duration) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">{{ $strings.LabelFormat }}</p>
|
||||
<p>{{ audioFile.format }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelChapters }}
|
||||
</p>
|
||||
<p>{{ audioFile.chapters?.length || 0 }}</p>
|
||||
</div>
|
||||
<div v-if="audioFile.embeddedCoverArt" class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelEmbeddedCover }}
|
||||
</p>
|
||||
<p>{{ audioFile.embeddedCoverArt || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full sm:w-1/2">
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelCodec }}
|
||||
</p>
|
||||
<p>{{ audioFile.codec }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelChannels }}
|
||||
</p>
|
||||
<p>{{ audioFile.channels }} ({{ audioFile.channelLayout }})</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelBitrate }}
|
||||
</p>
|
||||
<p>{{ $bytesPretty(audioFile.bitRate || 0, 0) }}</p>
|
||||
</div>
|
||||
<div class="flex mb-1">
|
||||
<p class="w-32 text-black-50">{{ $strings.LabelTimeBase }}</p>
|
||||
<p>{{ audioFile.timeBase }}</p>
|
||||
</div>
|
||||
<div v-if="audioFile.language" class="flex mb-1">
|
||||
<p class="w-32 text-black-50">
|
||||
{{ $strings.LabelLanguage }}
|
||||
</p>
|
||||
<p>{{ audioFile.language || '' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-full h-px bg-white bg-opacity-10 my-4" />
|
||||
|
||||
<p class="font-bold mb-2">{{ $strings.LabelMetaTags }}</p>
|
||||
|
||||
<div v-for="(value, key) in metaTags" :key="key" class="flex mb-1 text-sm">
|
||||
<p class="w-32 min-w-32 text-black-50 mb-1">
|
||||
{{ key.replace('tag', '') }}
|
||||
</p>
|
||||
<p>{{ value }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else class="w-full">
|
||||
<div class="relative">
|
||||
<ui-textarea-with-label :value="prettyFfprobeData" readonly :rows="30" class="text-xs" />
|
||||
|
||||
<button class="absolute top-4 right-4" :class="copiedToClipboard ? 'text-success' : 'text-white/50 hover:text-white/80'" @click.stop="copyFfprobeData">
|
||||
<span class="material-icons">{{ copiedToClipboard ? 'check' : 'content_copy' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
@@ -91,10 +106,24 @@ export default {
|
||||
audioFile: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
libraryItemId: String
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
probingFile: false,
|
||||
ffprobeData: null,
|
||||
copiedToClipboard: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.ffprobeData = null
|
||||
this.copiedToClipboard = false
|
||||
this.probingFile = false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
@@ -110,9 +139,36 @@ export default {
|
||||
},
|
||||
metaTags() {
|
||||
return this.audioFile?.metaTags || {}
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
prettyFfprobeData() {
|
||||
if (!this.ffprobeData) return ''
|
||||
return JSON.stringify(this.ffprobeData, null, 2)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getFFProbeData() {
|
||||
this.probingFile = true
|
||||
this.$axios
|
||||
.$get(`/api/items/${this.libraryItemId}/ffprobe/${this.audioFile.ino}`)
|
||||
.then((data) => {
|
||||
console.log('Got ffprobe data', data)
|
||||
this.ffprobeData = data
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to get ffprobe data', error)
|
||||
this.$toast.error('FFProbe failed')
|
||||
})
|
||||
.finally(() => {
|
||||
this.probingFile = false
|
||||
})
|
||||
},
|
||||
async copyFfprobeData() {
|
||||
this.copiedToClipboard = await this.$copyToClipboard(this.prettyFfprobeData)
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -48,7 +48,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
clickedOption(action) {
|
||||
this.$emit('action', action)
|
||||
this.$emit('action', { action })
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<modals-modal v-model="show" name="listening-session-modal" :processing="processing" :width="700" :height="'unset'">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
|
||||
<p class="text-lg md:text-2xl text-white truncate">{{ $strings.HeaderSession }} {{ _session.id }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden p-6" style="max-height: 80vh">
|
||||
@@ -50,19 +50,19 @@
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelItem }}</p>
|
||||
<div v-if="_session.libraryId" class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibrary }} Id</div>
|
||||
<div class="px-1">
|
||||
<div class="px-1 text-xs">
|
||||
{{ _session.libraryId }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelLibraryItem }} Id</div>
|
||||
<div class="px-1">
|
||||
<div class="px-1 text-xs">
|
||||
{{ _session.libraryItemId }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="_session.episodeId" class="flex items-center -mx-1 mb-1">
|
||||
<div class="w-40 px-1 text-gray-200">{{ $strings.LabelEpisode }} Id</div>
|
||||
<div class="px-1">
|
||||
<div class="px-1 text-xs">
|
||||
{{ _session.episodeId }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -81,7 +81,7 @@
|
||||
</div>
|
||||
<div class="w-full md:w-1/3">
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mb-2 mt-6 md:mt-0">{{ $strings.LabelUser }}</p>
|
||||
<p class="mb-1">{{ _session.userId }}</p>
|
||||
<p class="mb-1 text-xs">{{ _session.userId }}</p>
|
||||
|
||||
<p class="font-semibold uppercase text-xs text-gray-400 tracking-wide mt-6 mb-2">{{ $strings.LabelMediaPlayer }}</p>
|
||||
<p class="mb-1">{{ playMethodName }}</p>
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
<div ref="wrapper" class="modal modal-bg w-full h-full fixed top-0 left-0 bg-primary items-center justify-center opacity-0 hidden" :class="`z-${zIndex} bg-opacity-${bgOpacity}`">
|
||||
<div class="absolute top-0 left-0 right-0 w-full h-36 bg-gradient-to-t from-transparent via-black-500 to-black-700 opacity-90 pointer-events-none" />
|
||||
|
||||
<div class="absolute top-3 right-3 landscape:top-2 landscape:right-2 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 h-8 w-8 landscape:h-8 landscape:w-8 md:portrait:h-12 md:portrait:w-12 lg:w-12 lg:h-12 flex items-center justify-center cursor-pointer text-white hover:text-gray-300" @click="clickClose">
|
||||
<button class="absolute top-4 right-4 landscape:top-4 landscape:right-4 md:portrait:top-5 md:portrait:right-5 lg:top-5 lg:right-5 inline-flex text-gray-200 hover:text-white" aria-label="Close modal" @click="clickClose">
|
||||
<span class="material-icons text-2xl landscape:text-2xl md:portrait:text-4xl lg:text-4xl">close</span>
|
||||
</div>
|
||||
</button>
|
||||
<slot name="outer" />
|
||||
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||
<div ref="content" style="min-width: 380px; min-height: 200px; max-width: 100vw" class="relative text-white" aria-modal="true" :style="{ height: modalHeight, width: modalWidth, marginTop: contentMarginTop + 'px' }" @mousedown="mousedownModal" @mouseup="mouseupModal" v-click-outside="clickBg">
|
||||
<slot />
|
||||
<div v-if="processing" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-black bg-opacity-60 rounded-lg flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
</template>
|
||||
<form class="flex items-center justify-center px-6 py-3" @submit.prevent="submitCustomTime">
|
||||
<ui-text-input v-model="customTime" type="number" step="any" min="0.1" placeholder="Time in minutes" class="w-48" />
|
||||
<ui-btn color="success" type="submit" padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
|
||||
<ui-btn color="success" type="submit" :padding-x="0" class="h-9 w-12 flex items-center justify-center ml-1">Set</ui-btn>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else class="w-full p-4">
|
||||
|
||||
@@ -8,10 +8,9 @@
|
||||
<div class="p-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-hidden" style="min-height: 400px; max-height: 80vh">
|
||||
<template v-if="!showImageUploader">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="flex">
|
||||
<div>
|
||||
<div class="flex flex-wrap">
|
||||
<div class="w-full flex justify-center mb-2 md:w-auto md:mb-0 md:block">
|
||||
<covers-collection-cover :book-items="books" :width="200" :height="100 * bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<!-- <ui-btn type="button" @click="showImageUploader = true">Upload</ui-btn> -->
|
||||
</div>
|
||||
<div class="flex-grow px-4">
|
||||
<ui-text-input-with-label v-model="newCollectionName" :label="$strings.LabelName" class="mb-2" />
|
||||
@@ -41,7 +40,6 @@
|
||||
<ui-btn color="success">Upload</ui-btn>
|
||||
</div>
|
||||
</template>
|
||||
<!-- <modals-upload-image-modal v-model="showUploadImageModal" entity="collection" :entity-id="collection.id" /> -->
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
171
client/components/modals/emails/EReaderDeviceModal.vue
Normal file
171
client/components/modals/emails/EReaderDeviceModal.vue
Normal file
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<modals-modal ref="modal" v-model="show" name="ereader-device-edit" :width="800" :height="'unset'" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
|
||||
<p class="text-3xl text-white truncate">{{ title }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="w-full text-sm rounded-lg bg-bg shadow-lg border border-black-300">
|
||||
<div class="w-full px-3 py-5 md:p-12">
|
||||
<div class="flex items-center -mx-1 mb-2">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="ereaderNameInput" v-model="newDevice.name" :disabled="processing" :label="$strings.LabelName" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="ereaderEmailInput" v-model="newDevice.email" :disabled="processing" :label="$strings.LabelEmail" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center pt-4">
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" type="submit">{{ $strings.ButtonSubmit }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
existingDevices: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
ereaderDevice: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
newDevice: {
|
||||
name: '',
|
||||
email: ''
|
||||
}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
handler(newVal) {
|
||||
if (newVal) {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this.ereaderDevice ? 'Create Device' : 'Update Device'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submitForm() {
|
||||
this.$refs.ereaderNameInput.blur()
|
||||
this.$refs.ereaderEmailInput.blur()
|
||||
|
||||
if (!this.newDevice.name?.trim() || !this.newDevice.email?.trim()) {
|
||||
this.$toast.error('Name and email required')
|
||||
return
|
||||
}
|
||||
|
||||
this.newDevice.name = this.newDevice.name.trim()
|
||||
this.newDevice.email = this.newDevice.email.trim()
|
||||
|
||||
if (!this.ereaderDevice) {
|
||||
if (this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
||||
this.$toast.error('EReader device with that name already exists')
|
||||
return
|
||||
}
|
||||
|
||||
this.submitCreate()
|
||||
} else {
|
||||
if (this.ereaderDevice.name !== this.newDevice.name && this.existingDevices.some((d) => d.name === this.newDevice.name)) {
|
||||
this.$toast.error('EReader device with that name already exists')
|
||||
return
|
||||
}
|
||||
|
||||
this.submitUpdate()
|
||||
}
|
||||
},
|
||||
submitUpdate() {
|
||||
this.processing = true
|
||||
|
||||
const existingDevicesWithoutThisOne = this.existingDevices.filter((d) => d.name !== this.ereaderDevice.name)
|
||||
|
||||
const payload = {
|
||||
ereaderDevices: [
|
||||
...existingDevicesWithoutThisOne,
|
||||
{
|
||||
...this.newDevice
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/emails/ereader-devices`, payload)
|
||||
.then((data) => {
|
||||
this.$emit('update', data.ereaderDevices)
|
||||
this.$toast.success('Device updated')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update device', error)
|
||||
this.$toast.error('Failed to update device')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
submitCreate() {
|
||||
this.processing = true
|
||||
|
||||
const payload = {
|
||||
ereaderDevices: [
|
||||
...this.existingDevices,
|
||||
{
|
||||
...this.newDevice
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$post('/api/emails/ereader-devices', payload)
|
||||
.then((data) => {
|
||||
this.$emit('update', data.ereaderDevices || [])
|
||||
this.$toast.success('Device added')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to add device', error)
|
||||
this.$toast.error('Failed to add device')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
if (this.ereaderDevice) {
|
||||
this.newDevice.name = this.ereaderDevice.name
|
||||
this.newDevice.email = this.ereaderDevice.email
|
||||
} else {
|
||||
this.newDevice.name = ''
|
||||
this.newDevice.email = ''
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -74,6 +74,9 @@ export default {
|
||||
this.$store.commit('setEditModalTab', val)
|
||||
}
|
||||
},
|
||||
height() {
|
||||
return Math.min(this.availableHeight, 650)
|
||||
},
|
||||
tabs() {
|
||||
return [
|
||||
{
|
||||
@@ -124,9 +127,6 @@ export default {
|
||||
}
|
||||
]
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
@@ -136,14 +136,26 @@ export default {
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
selectedLibraryItem() {
|
||||
return this.$store.state.selectedLibraryItem || {}
|
||||
},
|
||||
selectedLibraryItemId() {
|
||||
return this.selectedLibraryItem.id
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem?.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
availableTabs() {
|
||||
if (!this.userCanUpdate && !this.userCanDownload) return []
|
||||
return this.tabs.filter((tab) => {
|
||||
if (tab.experimental && !this.showExperimentalFeatures) return false
|
||||
if (tab.mediaType && this.mediaType !== tab.mediaType) return false
|
||||
if (tab.admin && !this.userIsAdminOrUp) return false
|
||||
|
||||
if (tab.id === 'tools' && this.isMissing) return false
|
||||
if (tab.id === 'chapters' && this.isEBookOnly) return false
|
||||
|
||||
if ((tab.id === 'tools' || tab.id === 'files') && this.userCanDownload) return true
|
||||
if (tab.id !== 'tools' && tab.id !== 'files' && this.userCanUpdate) return true
|
||||
@@ -151,9 +163,6 @@ export default {
|
||||
return false
|
||||
})
|
||||
},
|
||||
height() {
|
||||
return Math.min(this.availableHeight, 650)
|
||||
},
|
||||
tabName() {
|
||||
var _tab = this.tabs.find((t) => t.id === this.selectedTab)
|
||||
return _tab ? _tab.component : ''
|
||||
@@ -161,20 +170,11 @@ export default {
|
||||
isMissing() {
|
||||
return this.selectedLibraryItem.isMissing
|
||||
},
|
||||
selectedLibraryItem() {
|
||||
return this.$store.state.selectedLibraryItem || {}
|
||||
},
|
||||
selectedLibraryItemId() {
|
||||
return this.selectedLibraryItem.id
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
isEBookOnly() {
|
||||
return this.media.ebookFile && !this.media.tracks?.length
|
||||
},
|
||||
mediaType() {
|
||||
return this.libraryItem ? this.libraryItem.mediaType : null
|
||||
return this.libraryItem?.mediaType || null
|
||||
},
|
||||
title() {
|
||||
return this.mediaMetadata.title || 'No Title'
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full h-full overflow-hidden overflow-y-auto px-2 sm:px-4 py-6 relative">
|
||||
<div class="flex flex-wrap">
|
||||
<div class="flex flex-wrap mb-4">
|
||||
<div class="relative">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, null, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId, libraryItemUpdatedAt, true)" :width="120" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
|
||||
<!-- book cover overlay -->
|
||||
<div v-if="media.coverPath" class="absolute top-0 left-0 w-full h-full z-10 opacity-0 hover:opacity-100 transition-opacity duration-100">
|
||||
@@ -17,10 +17,10 @@
|
||||
<div class="flex-grow sm:pl-2 md:pl-6 sm:pr-2 mt-2 md:mt-0">
|
||||
<div class="flex items-center">
|
||||
<div v-if="userCanUpload" class="w-10 md:w-40 pr-2 pt-4 md:min-w-32">
|
||||
<ui-file-input ref="fileInput" @change="fileUploadSelected"
|
||||
><span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span
|
||||
><span class="material-icons text-2xl inline-block md:!hidden">upload</span></ui-file-input
|
||||
>
|
||||
<ui-file-input ref="fileInput" @change="fileUploadSelected">
|
||||
<span class="hidden md:inline-block">{{ $strings.ButtonUploadCover }}</span>
|
||||
<span class="material-icons text-2xl inline-block md:!hidden">upload</span>
|
||||
</ui-file-input>
|
||||
</div>
|
||||
<form @submit.prevent="submitForm" class="flex flex-grow">
|
||||
<ui-text-input-with-label v-model="imageUrl" :label="$strings.LabelCoverImageURL" />
|
||||
@@ -36,10 +36,10 @@
|
||||
</div>
|
||||
|
||||
<div v-if="showLocalCovers" class="flex items-center justify-center pb-2">
|
||||
<template v-for="cover in localCovers">
|
||||
<div :key="cover.path" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="cover.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(cover)">
|
||||
<template v-for="localCoverFile in localCovers">
|
||||
<div :key="localCoverFile.ino" class="m-0.5 mb-5 border-2 border-transparent hover:border-yellow-300 cursor-pointer" :class="localCoverFile.metadata.path === coverPath ? 'border-yellow-300' : ''" @click="setCover(localCoverFile)">
|
||||
<div class="h-24 bg-primary" :style="{ width: 96 / bookCoverAspectRatio + 'px' }">
|
||||
<covers-preview-cover :src="`${cover.localPath}?token=${userToken}`" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-preview-cover :src="localCoverFile.localPath" :width="96 / bookCoverAspectRatio" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -128,7 +128,7 @@ export default {
|
||||
},
|
||||
providers() {
|
||||
if (this.isPodcast) return this.$store.state.scanners.podcastProviders
|
||||
return [...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
|
||||
return [{ text: 'All', value: 'all' }, ...this.$store.state.scanners.providers, ...this.$store.state.scanners.coverOnlyProviders]
|
||||
},
|
||||
searchTitleLabel() {
|
||||
if (this.provider.startsWith('audible')) return this.$strings.LabelSearchTitleOrASIN
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -223,7 +226,7 @@ export default {
|
||||
this.searchTitle = this.mediaMetadata.title || ''
|
||||
this.searchAuthor = this.mediaMetadata.authorName || ''
|
||||
if (this.isPodcast) this.provider = 'itunes'
|
||||
else this.provider = localStorage.getItem('book-provider') || 'google'
|
||||
else this.provider = localStorage.getItem('book-cover-provider') || localStorage.getItem('book-provider') || 'google'
|
||||
},
|
||||
removeCover() {
|
||||
if (!this.media.coverPath) {
|
||||
@@ -288,13 +291,13 @@ export default {
|
||||
},
|
||||
getSearchQuery() {
|
||||
var searchQuery = `provider=${this.provider}&title=${this.searchTitle}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor}`
|
||||
if (this.searchAuthor) searchQuery += `&author=${this.searchAuthor || ''}`
|
||||
if (this.isPodcast) searchQuery += '&podcast=1'
|
||||
return searchQuery
|
||||
},
|
||||
persistProvider() {
|
||||
try {
|
||||
localStorage.setItem('book-provider', this.provider)
|
||||
localStorage.setItem('book-cover-provider', this.provider)
|
||||
} catch (error) {
|
||||
console.error('PersistProvider', error)
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Split to mp3 -->
|
||||
<div v-if="showMp3Split && showExperimentalFeatures" class="w-full border border-black-200 p-4 my-8">
|
||||
<!-- <div v-if="showMp3Split" class="w-full border border-black-200 p-4 my-8">
|
||||
<div class="flex items-center">
|
||||
<div>
|
||||
<p class="text-lg">{{ $strings.LabelToolsSplitM4b }}</p>
|
||||
@@ -31,7 +31,7 @@
|
||||
<ui-btn :disabled="true">{{ $strings.MessageNotYetImplemented }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<!-- Embed Metadata -->
|
||||
<div v-if="mediaTracks.length" class="w-full border border-black-200 p-4 my-8">
|
||||
@@ -79,9 +79,6 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem?.id || null
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
|
||||
<div class="flex items-center py-2">
|
||||
<div class="flex items-center py-3">
|
||||
<ui-toggle-switch v-model="useSquareBookCovers" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsSquareBookCoversHelp">
|
||||
<p class="pl-4 text-base">
|
||||
@@ -17,18 +17,38 @@
|
||||
</div>
|
||||
<p v-if="globalWatcherDisabled" class="text-xs text-warning">*{{ $strings.MessageWatcherIsDisabledGlobally }}</p>
|
||||
</div>
|
||||
<div v-if="mediaType == 'book'" class="py-3">
|
||||
<div v-if="isBookLibrary" class="flex items-center py-3">
|
||||
<ui-toggle-switch v-model="audiobooksOnly" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsAudiobooksOnlyHelp">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelSettingsAudiobooksOnly }}
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithAsin" @input="formUpdated" />
|
||||
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithASIN }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="mediaType == 'book'" class="py-3">
|
||||
<div v-if="isBookLibrary" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="skipMatchingMediaWithIsbn" @input="formUpdated" />
|
||||
<p class="pl-4 text-base">{{ $strings.LabelSettingsSkipMatchingBooksWithISBN }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isBookLibrary" class="py-3">
|
||||
<div class="flex items-center">
|
||||
<ui-toggle-switch v-model="hideSingleBookSeries" @input="formUpdated" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsHideSingleBookSeriesHelp">
|
||||
<p class="pl-4 text-base">
|
||||
{{ $strings.LabelSettingsHideSingleBookSeries }}
|
||||
<span class="material-icons icon-text text-sm">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end pt-4">
|
||||
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" label="Select all episodes" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
|
||||
<ui-checkbox v-if="!allDownloaded" v-model="selectAll" @input="toggleSelectAll" :label="selectAllLabel" small checkbox-bg="primary" border-color="gray-600" class="mx-8" />
|
||||
<ui-btn v-if="!allDownloaded" :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
|
||||
<p v-else class="text-success text-base px-2 py-4">All episodes are downloaded</p>
|
||||
</div>
|
||||
@@ -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))
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-52">
|
||||
<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="setPage(index)">
|
||||
<div v-show="showPageMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-8 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400" :style="{ width: pageMenuWidth + 'px' }">
|
||||
<div v-for="(file, index) in cleanedPageNames" :key="file" class="w-full cursor-pointer hover:bg-black-200 px-2 py-1" :class="page === index ? 'bg-black-200' : ''" @click="setPage(index + 1)">
|
||||
<p class="text-sm truncate">{{ file }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 right-10 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
|
||||
<div v-show="showInfoMenu" v-click-outside="clickOutside" class="pagemenu absolute top-9 left-20 rounded-md overflow-y-auto bg-bg shadow-lg z-20 border border-gray-400 w-96">
|
||||
<div v-for="key in comicMetadataKeys" :key="key" class="w-full px-2 py-1">
|
||||
<p class="text-xs">
|
||||
<strong>{{ key }}</strong
|
||||
@@ -14,39 +14,38 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="comicMetadata" class="absolute top-0 right-52 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="showInfoMenu = !showInfoMenu">
|
||||
<a v-if="pages && numPages" :href="mainImg" :download="pages[page - 1]" class="absolute top-0 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" :class="comicMetadata ? 'left-32' : 'left-20'">
|
||||
<span class="material-icons text-xl">download</span>
|
||||
</a>
|
||||
<div v-if="comicMetadata" class="absolute top-0 left-20 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">
|
||||
<span class="material-icons text-xl">more</span>
|
||||
</div>
|
||||
<div class="absolute top-0 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" style="right: 156px" @mousedown.prevent @click.stop.prevent="showPageMenu = !showPageMenu">
|
||||
<div v-if="numPages" class="absolute top-0 left-8 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="clickShowPageMenu">
|
||||
<span class="material-icons text-xl">menu</span>
|
||||
</div>
|
||||
<div class="absolute top-0 right-20 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">
|
||||
<p class="font-mono">{{ page + 1 }} / {{ numPages }}</p>
|
||||
<div v-if="numPages" class="absolute top-0 right-16 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">
|
||||
<p class="font-mono">{{ page }} / {{ numPages }}</p>
|
||||
</div>
|
||||
|
||||
<div class="overflow-hidden m-auto comicwrapper relative">
|
||||
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
||||
<div class="overflow-hidden w-full h-full relative">
|
||||
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
||||
<div class="flex items-center justify-center h-full w-1/2">
|
||||
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
|
||||
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 lg:w-1/3 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
|
||||
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
|
||||
<span v-show="loadedFirstPage" class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-full flex justify-center">
|
||||
<img v-if="mainImg" :src="mainImg" class="object-contain comicimg" />
|
||||
<img v-if="mainImg" :src="mainImg" class="object-contain h-full m-auto" />
|
||||
</div>
|
||||
|
||||
<div v-show="loading" class="w-full h-full absolute top-0 left-0 flex items-center justify-center z-10">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- <div v-show="loading" class="w-screen h-screen absolute top-0 left-0 bg-black bg-opacity-20 flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
</div> -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
<style scoped>
|
||||
.pagemenu {
|
||||
max-height: calc(100vh - 60px);
|
||||
}
|
||||
.comicimg {
|
||||
height: calc(100vh - 40px);
|
||||
margin: auto;
|
||||
}
|
||||
.comicwrapper {
|
||||
width: 100vw;
|
||||
height: calc(100vh - 40px);
|
||||
margin-top: 20px;
|
||||
max-height: calc(100% - 48px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,15 +1,15 @@
|
||||
<template>
|
||||
<div class="h-full w-full">
|
||||
<div id="epub-reader" class="h-full w-full">
|
||||
<div class="h-full flex items-center justify-center">
|
||||
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center overflow-x-hidden justify-center">
|
||||
<span v-if="hasPrev" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
|
||||
</div>
|
||||
<button type="button" aria-label="Previous page" class="w-24 max-w-24 h-full hidden sm:flex items-center overflow-x-hidden justify-center opacity-50 hover:opacity-100">
|
||||
<span v-if="hasPrev" class="material-icons text-6xl" @mousedown.prevent @click="prev">chevron_left</span>
|
||||
</button>
|
||||
<div id="frame" class="w-full" style="height: 80%">
|
||||
<div id="viewer"></div>
|
||||
</div>
|
||||
<div style="width: 100px; max-width: 100px" class="h-full hidden sm:flex items-center justify-center overflow-x-hidden">
|
||||
<span v-if="hasNext" class="material-icons text-white text-opacity-50 hover:text-opacity-80 cursor-pointer text-6xl" @mousedown.prevent @click="next">chevron_right</span>
|
||||
</div>
|
||||
<button type="button" aria-label="Next page" class="w-24 max-w-24 h-full hidden sm:flex items-center justify-center overflow-x-hidden opacity-50 hover:opacity-100">
|
||||
<span v-if="hasNext" class="material-icons text-6xl" @mousedown.prevent @click="next">chevron_right</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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,21 +69,69 @@ export default {
|
||||
},
|
||||
/** @returns {Array<ePub.NavItem>} */
|
||||
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() {
|
||||
return this.rendition?.prev()
|
||||
},
|
||||
@@ -90,6 +155,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 +247,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 +267,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 +318,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()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div class="h-full max-h-full w-full">
|
||||
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-12 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
||||
<div class="ebook-viewer absolute overflow-y-scroll left-0 right-0 top-16 w-full max-w-4xl m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
||||
<iframe title="html-viewer" width="100%"> Loading </iframe>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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) => {
|
||||
|
||||
@@ -11,15 +11,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-0 right-20 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">
|
||||
<div class="absolute top-0 right-20 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center">
|
||||
<p class="font-mono">{{ page }} / {{ numPages }}</p>
|
||||
</div>
|
||||
<div class="absolute top-0 right-40 bg-bg text-gray-100 border-b border-l border-r border-gray-400 z-20 rounded-b-md px-2 h-9 hidden md:flex items-center text-center">
|
||||
<ui-icon-btn icon="zoom_out" :size="8" :disabled="!canScaleDown" borderless class="mr-px" @click="zoomOut" />
|
||||
<ui-icon-btn icon="zoom_in" :size="8" :disabled="!canScaleUp" borderless class="ml-px" @click="zoomIn" />
|
||||
</div>
|
||||
|
||||
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
|
||||
<div class="flex items-center justify-center">
|
||||
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
|
||||
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="overflow-auto">
|
||||
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
||||
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
|
||||
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md" :src="pdfDocInitParams" :page="page" :rotate="rotate" @progress="progressEvt" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event" @loaded="loadedEvt"></pdf>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,17 +34,26 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import pdf from 'vue-pdf'
|
||||
import pdf from '@teckel/vue-pdf'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
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,
|
||||
@@ -48,35 +61,121 @@ 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
|
||||
},
|
||||
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() {
|
||||
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
|
||||
}
|
||||
|
||||
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('EpubReader.updateProgress failed:', error)
|
||||
})
|
||||
},
|
||||
loadedEvt() {
|
||||
if (this.savedPage > 0 && this.savedPage <= this.numPages) {
|
||||
this.page = this.savedPage
|
||||
}
|
||||
},
|
||||
progressEvt(progress) {
|
||||
this.loadedRatio = progress
|
||||
},
|
||||
numPagesLoaded(e) {
|
||||
this.numPages = e
|
||||
},
|
||||
prev() {
|
||||
if (this.page <= 1) return
|
||||
this.page--
|
||||
this.updateProgress()
|
||||
},
|
||||
next() {
|
||||
if (this.page >= this.numPages) return
|
||||
this.page++
|
||||
this.updateProgress()
|
||||
},
|
||||
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)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,38 +1,90 @@
|
||||
<template>
|
||||
<div v-if="show" class="w-screen h-screen fixed top-0 left-0 z-60 bg-primary text-white">
|
||||
<div class="absolute top-4 left-4 z-20">
|
||||
<span v-if="hasToC && !tocOpen" ref="tocButton" class="material-icons cursor-pointer text-2xl" @click="toggleToC">menu</span>
|
||||
<div v-if="show" id="reader" :data-theme="ereaderTheme" class="group absolute top-0 left-0 w-full z-60 data-[theme=dark]:bg-primary data-[theme=dark]:text-white data-[theme=light]:bg-white data-[theme=light]:text-black" :class="{ 'reader-player-open': !!streamLibraryItem }">
|
||||
<div class="absolute top-4 left-4 z-20 flex items-center">
|
||||
<button v-if="isEpub" @click="toggleToC" type="button" aria-label="Table of contents menu" class="inline-flex opacity-80 hover:opacity-100">
|
||||
<span class="material-icons text-2xl">menu</span>
|
||||
</button>
|
||||
<button v-if="hasSettings" @click="openSettings" type="button" aria-label="Ereader settings" class="mx-4 inline-flex opacity-80 hover:opacity-100">
|
||||
<span class="material-icons text-1.5xl">settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-4 left-1/2 transform -translate-x-1/2">
|
||||
<h1 class="text-lg sm:text-xl md:text-2xl mb-1" style="line-height: 1.15; font-weight: 100">
|
||||
<h1 :data-type="ebookType" class="text-lg sm:text-xl md:text-2xl mb-1 data-[type=comic]:hidden" style="line-height: 1.15; font-weight: 100">
|
||||
<span style="font-weight: 600">{{ abTitle }}</span>
|
||||
<span v-if="abAuthor" style="display: inline"> – </span>
|
||||
<span v-if="abAuthor">{{ abAuthor }}</span>
|
||||
<span v-if="abAuthor" class="hidden md:inline"> – </span>
|
||||
<span v-if="abAuthor" class="hidden md:inline">{{ abAuthor }}</span>
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-4 right-4 z-20">
|
||||
<span v-if="hasSettings" class="material-icons cursor-pointer text-2xl" @click="openSettings">settings</span>
|
||||
<span class="material-icons cursor-pointer text-2xl" @click="close">close</span>
|
||||
<button @click="close" type="button" aria-label="Close ereader" class="inline-flex opacity-80 hover:opacity-100">
|
||||
<span class="material-icons text-2xl">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<component v-if="componentName" ref="readerComponent" :is="componentName" :url="ebookUrl" :library-item="selectedLibraryItem" />
|
||||
<component v-if="componentName" ref="readerComponent" :is="componentName" :library-item="selectedLibraryItem" :player-open="!!streamLibraryItem" :keep-progress="keepProgress" :file-id="ebookFileId" @touchstart="touchstart" @touchend="touchend" @hook:mounted="readerMounted" />
|
||||
|
||||
<!-- TOC side nav -->
|
||||
<div v-if="tocOpen" class="w-full h-full fixed inset-0 bg-black/20 z-20" @click.stop.prevent="toggleToC"></div>
|
||||
<div v-if="hasToC" class="w-72 h-full max-h-full absolute top-0 left-0 bg-bg shadow-xl transition-transform z-30" :class="tocOpen ? 'translate-x-0' : '-translate-x-72'" @click.stop.prevent="toggleToC">
|
||||
<div class="p-4 h-full overflow-hidden">
|
||||
<p class="text-lg font-semibold mb-2">Table of Contents</p>
|
||||
<div v-if="isEpub" class="w-96 h-full max-h-full absolute top-0 left-0 shadow-xl transition-transform z-30 group-data-[theme=dark]:bg-primary group-data-[theme=dark]:text-white group-data-[theme=light]:bg-white group-data-[theme=light]:text-black" :class="tocOpen ? 'translate-x-0' : '-translate-x-96'" @click.stop.prevent="toggleToC">
|
||||
<div class="p-4 h-full">
|
||||
<div class="flex items-center mb-2">
|
||||
<button @click.stop.prevent="toggleToC" type="button" aria-label="Close table of contents" class="inline-flex opacity-80 hover:opacity-100">
|
||||
<span class="material-icons text-2xl">arrow_back</span>
|
||||
</button>
|
||||
|
||||
<p class="text-lg font-semibold ml-2">{{ $strings.HeaderTableOfContents }}</p>
|
||||
</div>
|
||||
<div class="tocContent">
|
||||
<ul>
|
||||
<li v-for="chapter in chapters" :key="chapter.id" class="py-1">
|
||||
<a :href="chapter.href" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
|
||||
<a :href="chapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(chapter.href)">{{ chapter.label }}</a>
|
||||
<ul v-if="chapter.subitems.length">
|
||||
<li v-for="subchapter in chapter.subitems" :key="subchapter.id" class="py-1 pl-4">
|
||||
<a :href="subchapter.href" class="opacity-80 hover:opacity-100" @click.prevent="$refs.readerComponent.goToChapter(subchapter.href)">{{ subchapter.label }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ereader settings modal -->
|
||||
<modals-modal v-model="showSettings" name="ereader-settings-modal" :width="500" :height="'unset'" :processing="false">
|
||||
<template #outer>
|
||||
<div class="absolute top-0 left-0 p-5 w-3/4 overflow-hidden">
|
||||
<p class="text-xl md:text-3xl text-white truncate">{{ $strings.HeaderEreaderSettings }}</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="px-2 py-4 md:p-8 w-full text-base rounded-lg bg-bg shadow-lg border border-black-300 relative overflow-x-hidden overflow-y-auto" style="max-height: 80vh">
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-40">
|
||||
<p class="text-lg">{{ $strings.LabelTheme }}:</p>
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.theme" :items="themeItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-40">
|
||||
<p class="text-lg">{{ $strings.LabelFontScale }}:</p>
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.fontScale" :min="5" :max="300" :step="5" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center mb-4">
|
||||
<div class="w-40">
|
||||
<p class="text-lg">{{ $strings.LabelLineSpacing }}:</p>
|
||||
</div>
|
||||
<ui-range-input v-model="ereaderSettings.lineSpacing" :min="100" :max="300" :step="5" @input="settingsUpdated" />
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
<div class="w-40">
|
||||
<p class="text-lg">{{ $strings.LabelLayout }}:</p>
|
||||
</div>
|
||||
<ui-toggle-btns v-model="ereaderSettings.spread" :items="spreadItems" @input="settingsUpdated" />
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -40,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: {
|
||||
@@ -60,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'
|
||||
@@ -67,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
|
||||
@@ -95,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() {
|
||||
@@ -120,33 +221,37 @@ 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) {
|
||||
console.log('Reader hotkey', action)
|
||||
if (!this.$refs.readerComponent) return
|
||||
|
||||
if (action === this.$hotkeys.EReader.NEXT_PAGE) {
|
||||
@@ -163,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()
|
||||
@@ -187,12 +353,19 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* @import url(@/assets/calibre/basic.css); */
|
||||
.ebook-viewer {
|
||||
height: calc(100% - 96px);
|
||||
}
|
||||
.tocContent {
|
||||
height: calc(100% - 36px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
#reader {
|
||||
height: 100%;
|
||||
}
|
||||
#reader.reader-player-open {
|
||||
height: calc(100% - 164px);
|
||||
}
|
||||
@media (max-height: 400px) {
|
||||
#reader.reader-player-open {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
{{ $secondsToTimestamp(track.duration) }}
|
||||
</td>
|
||||
<td v-if="contextMenuItems.length" class="text-center">
|
||||
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
|
||||
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="110" @action="contextMenuAction" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -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() {}
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
<td class="hidden sm:table-cell font-mono md:text-sm text-xs">{{ $bytesPretty(backup.fileSize) }}</td>
|
||||
<td>
|
||||
<div class="w-full flex flex-row items-center justify-center">
|
||||
<ui-btn v-if="backup.serverVersion" small color="primary" @click="applyBackup(backup)">{{ $strings.ButtonRestore }}</ui-btn>
|
||||
|
||||
<a v-if="backup.serverVersion" :href="`/metadata/${$encodeUriPath(backup.path)}?token=${userToken}`" class="mx-1 pt-1 hover:text-opacity-100 text-opacity-70 text-white" download><span class="material-icons text-xl">download</span></a>
|
||||
<ui-btn v-if="backup.serverVersion && backup.key" small color="primary" @click="applyBackup(backup)">{{ $strings.ButtonRestore }}</ui-btn>
|
||||
<ui-tooltip v-else text="This backup was created with an old version of audiobookshelf no longer supported" direction="bottom" class="mx-2 flex items-center">
|
||||
<span class="material-icons-outlined text-2xl text-error">error_outline</span>
|
||||
</ui-tooltip>
|
||||
|
||||
<span class="material-icons text-xl hover:text-error hover:text-opacity-100 text-opacity-70 text-white cursor-pointer mx-1" @click="deleteBackupClick(backup)">delete</span>
|
||||
<button aria-label="Download Backup" class="inline-flex material-icons text-xl mx-1 mt-1 text-white/70 hover:text-white/100" @click.stop="downloadBackup(backup)">download</button>
|
||||
|
||||
<button aria-label="Delete Backup" class="inline-flex material-icons text-xl mx-1 text-white/70 hover:text-error" @click="deleteBackupClick(backup)">delete</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -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) {
|
||||
|
||||
87
client/components/tables/EbookFilesTable.vue
Normal file
87
client/components/tables/EbookFilesTable.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<template>
|
||||
<div class="w-full my-2">
|
||||
<div class="w-full bg-primary px-4 md:px-6 py-2 flex items-center cursor-pointer" @click.stop="clickBar">
|
||||
<p class="pr-2 md:pr-4">{{ $strings.HeaderEbookFiles }}</p>
|
||||
<div class="h-5 md:h-7 w-5 md:w-7 rounded-full bg-white bg-opacity-10 flex items-center justify-center">
|
||||
<span class="text-sm font-mono">{{ ebookFiles.length }}</span>
|
||||
</div>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn v-if="userIsAdmin" small :color="showFullPath ? 'gray-600' : 'primary'" class="mr-2 hidden md:block" @click.stop="showFullPath = !showFullPath">{{ $strings.ButtonFullPath }}</ui-btn>
|
||||
<div class="cursor-pointer h-10 w-10 rounded-full hover:bg-black-400 flex justify-center items-center duration-500" :class="showFiles ? 'transform rotate-180' : ''">
|
||||
<span class="material-icons text-4xl">expand_more</span>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="slide">
|
||||
<div class="w-full" v-show="showFiles">
|
||||
<table class="text-sm tracksTable">
|
||||
<tr>
|
||||
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
|
||||
<th class="text-left w-24 min-w-24">{{ $strings.LabelSize }}</th>
|
||||
<th class="text-left px-4 w-24">
|
||||
{{ $strings.LabelRead }} <ui-tooltip :text="$strings.LabelReadEbookWithoutProgress" direction="top" class="inline-block"><span class="material-icons-outlined text-sm align-middle">info</span></ui-tooltip>
|
||||
</th>
|
||||
<th v-if="showMoreColumn" class="text-center w-16"></th>
|
||||
</tr>
|
||||
<template v-for="file in ebookFiles">
|
||||
<tables-ebook-files-table-row :key="file.path" :libraryItemId="libraryItemId" :showFullPath="showFullPath" :file="file" @read="readEbook" />
|
||||
</template>
|
||||
</table>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showFiles: false,
|
||||
showFullPath: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItemId() {
|
||||
return this.libraryItem.id
|
||||
},
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userIsAdmin() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
libraryIsAudiobooksOnly() {
|
||||
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
|
||||
},
|
||||
showMoreColumn() {
|
||||
return this.userCanDelete || this.userCanDownload || (this.userCanUpdate && !this.libraryIsAudiobooksOnly)
|
||||
},
|
||||
ebookFiles() {
|
||||
return (this.libraryItem.libraryFiles || []).filter((lf) => lf.fileType === 'ebook')
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
readEbook(fileIno) {
|
||||
this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: false, fileId: fileIno })
|
||||
},
|
||||
clickBar() {
|
||||
this.showFiles = !this.showFiles
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
139
client/components/tables/EbookFilesTableRow.vue
Normal file
139
client/components/tables/EbookFilesTableRow.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<template>
|
||||
<tr>
|
||||
<td class="px-4">
|
||||
{{ showFullPath ? file.metadata.path : file.metadata.relPath }} <ui-tooltip :text="$strings.LabelPrimaryEbook" class="inline-block"><span v-if="isPrimary" class="material-icons-outlined text-success align-text-bottom">check_circle</span></ui-tooltip>
|
||||
</td>
|
||||
<td>
|
||||
{{ $bytesPretty(file.metadata.size) }}
|
||||
</td>
|
||||
<td class="text-xs">
|
||||
<ui-icon-btn icon="auto_stories" outlined borderless icon-font-size="1.125rem" :size="8" @click="readEbook" />
|
||||
</td>
|
||||
<td v-if="contextMenuItems.length" class="text-center">
|
||||
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="130" :processing="processing" @action="contextMenuAction" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String,
|
||||
showFullPath: Boolean,
|
||||
file: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
userToken() {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
userCanDelete() {
|
||||
return this.$store.getters['user/getUserCanDelete']
|
||||
},
|
||||
userCanUpdate() {
|
||||
return this.$store.getters['user/getUserCanUpdate']
|
||||
},
|
||||
userIsAdmin() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
downloadUrl() {
|
||||
return `${process.env.serverUrl}/api/items/${this.libraryItemId}/file/${this.file.ino}/download?token=${this.userToken}`
|
||||
},
|
||||
isPrimary() {
|
||||
return !this.file.isSupplementary
|
||||
},
|
||||
libraryIsAudiobooksOnly() {
|
||||
return this.$store.getters['libraries/getLibraryIsAudiobooksOnly']
|
||||
},
|
||||
contextMenuItems() {
|
||||
const items = []
|
||||
if (this.userCanUpdate && !this.libraryIsAudiobooksOnly) {
|
||||
items.push({
|
||||
text: this.isPrimary ? this.$strings.LabelSetEbookAsSupplementary : this.$strings.LabelSetEbookAsPrimary,
|
||||
action: 'updateStatus'
|
||||
})
|
||||
}
|
||||
if (this.userCanDownload) {
|
||||
items.push({
|
||||
text: this.$strings.LabelDownload,
|
||||
action: 'download'
|
||||
})
|
||||
}
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDelete,
|
||||
action: 'delete'
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
readEbook() {
|
||||
this.$emit('read', this.file.ino)
|
||||
},
|
||||
contextMenuAction({ action }) {
|
||||
if (action === 'delete') {
|
||||
this.deleteLibraryFile()
|
||||
} else if (action === 'download') {
|
||||
this.downloadLibraryFile()
|
||||
} else if (action === 'updateStatus') {
|
||||
this.updateEbookStatus()
|
||||
}
|
||||
},
|
||||
updateEbookStatus() {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$patch(`/api/items/${this.libraryItemId}/ebook/${this.file.ino}/status`)
|
||||
.then(() => {
|
||||
this.$toast.success('Ebook updated')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update ebook', error)
|
||||
this.$toast.error('Failed to update ebook')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
deleteLibraryFile() {
|
||||
const payload = {
|
||||
message: this.$strings.MessageConfirmDeleteFile,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/items/${this.libraryItemId}/file/${this.file.ino}`)
|
||||
.then(() => {
|
||||
this.$toast.success('File deleted')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete file', error)
|
||||
this.$toast.error('Failed to delete file')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
downloadLibraryFile() {
|
||||
this.$downloadFile(this.downloadUrl, this.file.metadata.filename)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -27,7 +27,7 @@
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
|
||||
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :library-item-id="libraryItemId" :audio-file="selectedAudioFile" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -38,7 +38,6 @@ export default {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
isMissing: Boolean,
|
||||
expanded: Boolean, // start expanded
|
||||
inModal: Boolean
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td v-if="contextMenuItems.length" class="text-center">
|
||||
<ui-context-menu-dropdown :items="contextMenuItems" menu-width="110px" @action="contextMenuAction" />
|
||||
<ui-context-menu-dropdown :items="contextMenuItems" :menu-width="110" @action="contextMenuAction" />
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -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() {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :audio-file="selectedAudioFile" />
|
||||
<modals-audio-file-data-modal v-model="showAudioFileDataModal" :library-item-id="libraryItemId" :audio-file="selectedAudioFile" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -19,9 +19,13 @@
|
||||
</td>
|
||||
<td class="text-sm">{{ user.type }}</td>
|
||||
<td class="hidden lg:table-cell">
|
||||
<div v-if="usersOnline[user.id]">
|
||||
<p v-if="usersOnline[user.id].session && usersOnline[user.id].session.libraryItem" class="truncate text-xs">Listening: {{ usersOnline[user.id].session.libraryItem.media.metadata.title || '' }}</p>
|
||||
<p v-else-if="usersOnline[user.id].mostRecent && usersOnline[user.id].mostRecent.media" class="truncate text-xs">Last: {{ usersOnline[user.id].mostRecent.media.metadata.title }}</p>
|
||||
<div v-if="usersOnline[user.id]?.session?.displayTitle">
|
||||
<p class="truncate text-xs">Listening: {{ usersOnline[user.id].session.displayTitle || '' }}</p>
|
||||
<p class="truncate text-xs text-gray-300">{{ getDeviceInfoString(usersOnline[user.id].session.deviceInfo) }}</p>
|
||||
</div>
|
||||
<div v-else-if="user.latestSession?.displayTitle">
|
||||
<p class="truncate text-xs">Last: {{ user.latestSession.displayTitle || '' }}</p>
|
||||
<p class="truncate text-xs text-gray-300">{{ getDeviceInfoString(user.latestSession.deviceInfo) }}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td class="text-xs font-mono hidden sm:table-cell">
|
||||
@@ -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)
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
<template>
|
||||
<div class="w-full px-1 md:px-2 py-2 overflow-hidden relative" @mouseover="mouseover" @mouseleave="mouseleave" :class="isHovering ? 'bg-white bg-opacity-5' : ''">
|
||||
<div v-if="book" class="flex h-16 md:h-20">
|
||||
<div v-if="book" class="flex h-18 md:h-[5.5rem]">
|
||||
<div class="w-10 min-w-10 md:w-16 md:max-w-16 h-full">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<span class="material-icons drag-handle text-lg md:text-xl">menu</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-full relative flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
|
||||
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="absolute top-0 left-0 bg-black bg-opacity-50 flex items-center justify-center h-full w-full z-10" v-show="isHovering && showPlayBtn">
|
||||
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
|
||||
<span class="material-icons text-2xl">play_arrow</span>
|
||||
<div class="h-full flex items-center" :style="{ width: coverWidth + 'px', minWidth: coverWidth + 'px', maxWidth: coverWidth + 'px' }">
|
||||
<div class="relative" :style="{ height: coverHeight + 'px', minHeight: coverHeight + 'px', maxHeight: coverHeight + 'px' }">
|
||||
<covers-book-cover :library-item="book" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="absolute top-0 left-0 flex items-center justify-center bg-black bg-opacity-50 h-full w-full z-10" v-show="isHovering && showPlayBtn">
|
||||
<div class="w-8 h-8 bg-white bg-opacity-20 rounded-full flex items-center justify-center hover:bg-opacity-40 cursor-pointer" @click="playClick">
|
||||
<span class="material-icons text-2xl">play_arrow</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,9 +21,12 @@
|
||||
<div class="truncate max-w-48 md:max-w-md">
|
||||
<nuxt-link :to="`/item/${book.id}`" class="truncate hover:underline text-sm md:text-base">{{ bookTitle }}</nuxt-link>
|
||||
</div>
|
||||
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
|
||||
<nuxt-link v-for="_series in seriesList" :key="_series.id" :to="`/library/${book.libraryId}/series/${_series.id}`" class="hover:underline font-sans text-gray-300"> {{ _series.text }}</nuxt-link>
|
||||
</div>
|
||||
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
|
||||
<template v-for="(author, index) in bookAuthors">
|
||||
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
|
||||
<nuxt-link :key="author.id" :to="`/author/${author.id}?library=${book.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
|
||||
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -96,6 +101,19 @@ export default {
|
||||
bookDuration() {
|
||||
return this.$elapsedPretty(this.media.duration)
|
||||
},
|
||||
series() {
|
||||
return this.mediaMetadata.series || []
|
||||
},
|
||||
seriesList() {
|
||||
return this.series.map((se) => {
|
||||
let text = se.name
|
||||
if (se.sequence) text += ` #${se.sequence}`
|
||||
return {
|
||||
...se,
|
||||
text
|
||||
}
|
||||
})
|
||||
},
|
||||
isMissing() {
|
||||
return this.book.isMissing
|
||||
},
|
||||
@@ -117,6 +135,9 @@ export default {
|
||||
coverSize() {
|
||||
return this.$store.state.globals.isMobile ? 30 : 50
|
||||
},
|
||||
coverHeight() {
|
||||
return this.coverSize * 1.6
|
||||
},
|
||||
coverWidth() {
|
||||
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
|
||||
return this.coverSize
|
||||
@@ -167,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)
|
||||
|
||||
@@ -94,7 +94,7 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
contextMenuAction(action) {
|
||||
contextMenuAction({ action }) {
|
||||
this.showMobileMenu = false
|
||||
if (action === 'edit') {
|
||||
this.editClick()
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</div>
|
||||
<div class="truncate max-w-48 md:max-w-md text-xs md:text-sm text-gray-300">
|
||||
<template v-for="(author, index) in bookAuthors">
|
||||
<nuxt-link :key="author.id" :to="`/author/${author.id}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
|
||||
<nuxt-link :key="author.id" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="truncate hover:underline">{{ author.name }}</nuxt-link
|
||||
><span :key="author.id + '-comma'" v-if="index < bookAuthors.length - 1">, </span>
|
||||
</template>
|
||||
<nuxt-link v-if="episode" :to="`/item/${libraryItem.id}`" class="truncate hover:underline">{{ mediaMetadata.title }}</nuxt-link>
|
||||
@@ -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)
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5">{{ subtitle }}</p>
|
||||
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle"></p>
|
||||
<div class="flex justify-between pt-2 max-w-xl">
|
||||
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
|
||||
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
|
||||
@@ -22,10 +21,6 @@
|
||||
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
||||
</button>
|
||||
|
||||
<!-- <button v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" class="h-8 w-8 flex justify-center items-center mx-2" :class="isQueued ? 'text-success' : ''" @click.stop="queueBtnClick">
|
||||
<span class="material-icons-outlined">{{ isQueued ? 'playlist_add_check' : 'queue' }}</span>
|
||||
</button> -->
|
||||
|
||||
<ui-tooltip v-if="libraryItemIdStreaming && !isStreamingFromDifferentLibrary" :text="isQueued ? $strings.MessageRemoveFromPlayerQueue : $strings.MessageAddToPlayerQueue" :class="isQueued ? 'text-success' : ''" direction="top">
|
||||
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_play'" borderless @click="queueBtnClick" />
|
||||
</ui-tooltip>
|
||||
@@ -89,7 +84,7 @@ export default {
|
||||
return this.episode.title || ''
|
||||
},
|
||||
subtitle() {
|
||||
return this.episode.subtitle || ''
|
||||
return this.episode.subtitle || this.description
|
||||
},
|
||||
description() {
|
||||
return this.episode.description || ''
|
||||
@@ -188,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)
|
||||
|
||||
@@ -1,22 +1,29 @@
|
||||
<template>
|
||||
<div class="w-full py-6">
|
||||
<p class="text-lg mb-2 font-semibold md:hidden">{{ $strings.HeaderEpisodes }}</p>
|
||||
<div class="flex items-center mb-4">
|
||||
<p class="text-lg mb-0 font-semibold hidden md:block">{{ $strings.HeaderEpisodes }}</p>
|
||||
<div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4">
|
||||
<div class="flex items-center flex-nowrap whitespace-nowrap mb-2 md:mb-0">
|
||||
<p class="text-lg mb-0 font-semibold">{{ $strings.HeaderEpisodes }}</p>
|
||||
<div class="inline-flex bg-white/5 px-1 mx-2 rounded-md text-sm text-gray-100">
|
||||
<p v-if="episodesList.length === episodes.length">{{ episodes.length }}</p>
|
||||
<p v-else>{{ episodesList.length }} / {{ episodes.length }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow hidden md:block" />
|
||||
<template v-if="isSelectionMode">
|
||||
<ui-tooltip :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
||||
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<ui-btn color="error" :disabled="processing" small class="h-9" @click="removeSelectedEpisodes">{{ $getString('MessageRemoveEpisodes', [selectedEpisodes.length]) }}</ui-btn>
|
||||
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 sm:ml-4" />
|
||||
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||
<div class="flex-grow md:hidden" />
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
||||
</template>
|
||||
<div class="flex items-center">
|
||||
<template v-if="isSelectionMode">
|
||||
<ui-tooltip :text="selectedIsFinished ? $strings.MessageMarkAsNotFinished : $strings.MessageMarkAsFinished" direction="bottom">
|
||||
<ui-read-icon-btn :disabled="processing" :is-read="selectedIsFinished" @click="toggleBatchFinished" class="mx-1.5" />
|
||||
</ui-tooltip>
|
||||
<ui-btn color="error" :disabled="processing" small class="h-9" @click="removeSelectedEpisodes">{{ $getString('MessageRemoveEpisodes', [selectedEpisodes.length]) }}</ui-btn>
|
||||
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
|
||||
</template>
|
||||
<template v-else>
|
||||
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" />
|
||||
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" />
|
||||
<div class="flex-grow md:hidden" />
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
|
||||
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
|
||||
@@ -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)
|
||||
|
||||
@@ -73,7 +73,7 @@ export default {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
<style scoped>
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
|
||||
@@ -1,16 +1,37 @@
|
||||
<template>
|
||||
<div class="relative h-9 w-9" v-click-outside="clickOutsideObj">
|
||||
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu">
|
||||
<button type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<slot :disabled="disabled" :showMenu="showMenu" :clickShowMenu="clickShowMenu" :processing="processing">
|
||||
<button v-if="!processing" type="button" :disabled="disabled" class="relative h-full w-full flex items-center justify-center shadow-sm pl-3 pr-3 text-left focus:outline-none cursor-pointer text-gray-100 hover:text-gray-200 rounded-full hover:bg-white/5" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-icons" :class="iconClass">more_vert</span>
|
||||
</button>
|
||||
<div v-else class="h-full w-full flex items-center justify-center">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
</slot>
|
||||
|
||||
<transition name="menu">
|
||||
<div v-show="showMenu" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg max-h-56 rounded-md py-1 overflow-auto focus:outline-none sm:text-sm" :style="{ width: menuWidth }">
|
||||
<div v-show="showMenu" ref="menuWrapper" class="absolute right-0 mt-1 z-10 bg-bg border border-black-200 shadow-lg rounded-md py-1 focus:outline-none sm:text-sm" :style="{ width: menuWidth + 'px' }">
|
||||
<template v-for="(item, index) in items">
|
||||
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
|
||||
<p>{{ item.text }}</p>
|
||||
<template v-if="item.subitems">
|
||||
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
|
||||
<p>{{ item.text }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="mouseoverItemIndex === index"
|
||||
:key="`subitems-${index}`"
|
||||
@mouseover="mouseoverSubItemMenu(index)"
|
||||
@mouseleave="mouseleaveSubItemMenu(index)"
|
||||
class="absolute bg-bg border rounded-b-md border-black-200 shadow-lg z-50 -ml-px py-1"
|
||||
:class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'"
|
||||
:style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }"
|
||||
>
|
||||
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.action, subitem.data)">
|
||||
<p>{{ subitem.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.action)">
|
||||
<p class="text-left">{{ item.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
@@ -31,9 +52,10 @@ export default {
|
||||
default: ''
|
||||
},
|
||||
menuWidth: {
|
||||
type: String,
|
||||
default: '192px'
|
||||
}
|
||||
type: Number,
|
||||
default: 192
|
||||
},
|
||||
processing: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -42,22 +64,54 @@ export default {
|
||||
events: ['mousedown'],
|
||||
isActive: true
|
||||
},
|
||||
showMenu: false
|
||||
submenuWidth: 144,
|
||||
showMenu: false,
|
||||
mouseoverItemIndex: null,
|
||||
isOverSubItemMenu: false,
|
||||
openSubMenuLeft: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
submenuLeftPos() {
|
||||
return this.openSubMenuLeft ? -(this.submenuWidth - 1) : this.menuWidth - 0.5
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
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)
|
||||
},
|
||||
clickShowMenu() {
|
||||
if (this.disabled) return
|
||||
this.showMenu = !this.showMenu
|
||||
this.$nextTick(() => {
|
||||
const boundingRect = this.$refs.menuWrapper?.getBoundingClientRect()
|
||||
if (boundingRect) {
|
||||
this.openSubMenuLeft = window.innerWidth - boundingRect.x < this.menuWidth + this.submenuWidth + 5
|
||||
}
|
||||
})
|
||||
},
|
||||
clickedOutside() {
|
||||
this.showMenu = false
|
||||
},
|
||||
clickAction(action) {
|
||||
clickAction(action, data) {
|
||||
if (this.disabled) return
|
||||
this.showMenu = false
|
||||
this.$emit('action', action)
|
||||
this.$emit('action', { action, data })
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
<template>
|
||||
<div v-if="currentLibrary" class="relative h-8 max-w-52 md:min-w-32" v-click-outside="clickOutsideObj">
|
||||
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" :aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name" @click.stop.prevent="clickShowMenu">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="disabled"
|
||||
class="w-10 sm:w-full relative h-full border border-white border-opacity-10 hover:border-opacity-20 rounded shadow-sm px-2 text-left text-sm cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200"
|
||||
aria-haspopup="listbox"
|
||||
:aria-expanded="showMenu"
|
||||
:aria-label="$strings.ButtonLibrary + ': ' + currentLibrary.name"
|
||||
@click.stop.prevent="clickShowMenu"
|
||||
>
|
||||
<div class="flex items-center justify-center sm:justify-start">
|
||||
<ui-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />
|
||||
<span class="hidden sm:block truncate">{{ currentLibrary.name }}</span>
|
||||
@@ -8,7 +16,7 @@
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px min-w-48 w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full min-w-48 bg-primary border border-black-200 shadow-lg rounded-b-md py-1 overflow-auto focus:outline-none sm:text-sm librariesDropdownMenu" tabindex="-1" role="listbox">
|
||||
<template v-for="library in librariesFiltered">
|
||||
<li :key="library.id" class="text-gray-400 hover:text-white relative py-2 cursor-pointer hover:bg-black-400" role="option" tabindex="0" @keydown.enter="selectLibrary(library)" @click="selectLibrary(library)">
|
||||
<div class="flex items-center px-2">
|
||||
@@ -93,4 +101,10 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.librariesDropdownMenu {
|
||||
max-height: calc(100vh - 75px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,61 +0,0 @@
|
||||
<template>
|
||||
<div class="relative" v-click-outside="clickOutside">
|
||||
<button type="button" class="relative w-full bg-fg border border-gray-500 rounded shadow-sm pl-3 pr-10 py-2 text-left focus:outline-none sm:text-sm cursor-pointer" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="showMenu = !showMenu">
|
||||
<span class="flex items-center">
|
||||
<span class="block truncate">{{ label }}</span>
|
||||
</span>
|
||||
<span class="ml-3 absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<span class="material-icons text-2xl text-gray-100" aria-label="User Account" role="button">person</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-black-200 shadow-lg max-h-56 rounded-b-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3">
|
||||
<template v-for="item in items">
|
||||
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</nuxt-link>
|
||||
<li v-else :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate font-sans">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: 'Menu'
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMenu: false
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickOutside() {
|
||||
this.showMenu = false
|
||||
},
|
||||
clickedOption(itemValue) {
|
||||
this.$emit('action', itemValue)
|
||||
this.showMenu = false
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
86
client/components/ui/RangeInput.vue
Normal file
86
client/components/ui/RangeInput.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div class="inline-flex">
|
||||
<input v-model="input" type="range" :min="min" :max="max" :step="step" />
|
||||
|
||||
<p class="text-sm ml-2">{{ input }}%</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
min: Number,
|
||||
max: Number,
|
||||
step: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
input: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input[type='range'] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
input[type='range']:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* chromium */
|
||||
input[type='range']::-webkit-slider-runnable-track {
|
||||
background-color: rgb(0 0 0 / 0.25);
|
||||
border-radius: 9999px;
|
||||
height: 0.75rem;
|
||||
}
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
margin-top: -0.25rem;
|
||||
border-radius: 9999px;
|
||||
background-color: rgb(255 255 255 / 0.7);
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
input[type='range']:focus::-webkit-slider-thumb {
|
||||
border: 1px solid #6b6b6b;
|
||||
outline: 3px solid #6b6b6b;
|
||||
outline-offset: 0.125rem;
|
||||
}
|
||||
|
||||
/* firefox */
|
||||
input[type='range']::-moz-range-track {
|
||||
background-color: rgb(0 0 0 / 0.25);
|
||||
border-radius: 9999px;
|
||||
height: 0.75rem;
|
||||
}
|
||||
input[type='range']::-moz-range-thumb {
|
||||
border: none;
|
||||
border-radius: 9999px;
|
||||
margin-top: -0.25rem;
|
||||
background-color: rgb(255 255 255 / 0.7);
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
input[type='range']:focus::-moz-range-thumb {
|
||||
border: 1px solid #6b6b6b;
|
||||
outline: 3px solid #6b6b6b;
|
||||
outline-offset: 0.125rem;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
|
||||
<ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :rows="rows" class="w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -11,6 +11,7 @@ export default {
|
||||
value: [String, Number],
|
||||
label: String,
|
||||
disabled: Boolean,
|
||||
readonly: Boolean,
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 2
|
||||
|
||||
85
client/components/ui/ToggleBtns.vue
Normal file
85
client/components/ui/ToggleBtns.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<div class="inline-flex toggle-btn-wrapper shadow-md">
|
||||
<button v-for="item in items" :key="item.value" type="button" class="toggle-btn outline-none relative border border-gray-600 px-4 py-1" :class="{ selected: item.value === value }" @click.stop="clickBtn(item.value)">
|
||||
{{ item.text }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: String,
|
||||
/**
|
||||
* [{ "text", "", "value": "" }]
|
||||
*/
|
||||
items: {
|
||||
type: Array,
|
||||
default: Object
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
clickBtn(value) {
|
||||
this.$emit('input', value)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toggle-btn-wrapper .toggle-btn:first-child {
|
||||
border-top-left-radius: 0.375rem /* 6px */;
|
||||
border-bottom-left-radius: 0.375rem /* 6px */;
|
||||
}
|
||||
.toggle-btn-wrapper .toggle-btn:last-child {
|
||||
border-top-right-radius: 0.375rem /* 6px */;
|
||||
border-bottom-right-radius: 0.375rem /* 6px */;
|
||||
}
|
||||
.toggle-btn-wrapper .toggle-btn:first-child::before {
|
||||
border-top-left-radius: 0.375rem /* 6px */;
|
||||
border-bottom-left-radius: 0.375rem /* 6px */;
|
||||
}
|
||||
.toggle-btn-wrapper .toggle-btn:last-child::before {
|
||||
border-top-right-radius: 0.375rem /* 6px */;
|
||||
border-bottom-right-radius: 0.375rem /* 6px */;
|
||||
}
|
||||
|
||||
.toggle-btn-wrapper .toggle-btn:not(:first-child) {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.toggle-btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0);
|
||||
transition: all 0.1s ease-in-out;
|
||||
}
|
||||
.toggle-btn:hover:not(:disabled)::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.toggle-btn:hover:not(:disabled) {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
.toggle-btn.selected {
|
||||
color: white;
|
||||
}
|
||||
.toggle-btn.selected::before {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
button.toggle-btn:disabled::before {
|
||||
background-color: rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
<template>
|
||||
<div class="absolute w-36 bg-bg rounded-md border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" style="top: 0; left: 0">
|
||||
<div ref="wrapper" class="absolute bg-bg rounded-md py-1 border border-black-200 shadow-lg z-50" v-click-outside="clickOutsideObj" :style="{ width: menuWidth + 'px' }" style="top: 0; left: 0">
|
||||
<template v-for="(item, index) in items">
|
||||
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white hover:bg-opacity-5 text-white text-xs cursor-pointer" @click.stop="clickAction(item.func)">
|
||||
<template v-if="item.subitems">
|
||||
<div :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-default" :class="{ 'bg-white/5': mouseoverItemIndex == index }" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop>
|
||||
<p>{{ item.text }}</p>
|
||||
</div>
|
||||
<div v-if="mouseoverItemIndex === index" :key="`subitems-${index}`" @mouseover="mouseoverSubItemMenu(index)" @mouseleave="mouseleaveSubItemMenu(index)" class="absolute bg-bg rounded-b-md border border-black-200 py-1 shadow-lg z-50" :class="openSubMenuLeft ? 'rounded-l-md' : 'rounded-r-md'" :style="{ left: submenuLeftPos + 'px', top: index * 28 + 'px', width: submenuWidth + 'px' }">
|
||||
<div v-for="(subitem, subitemindex) in item.subitems" :key="`subitem-${subitemindex}`" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @click.stop="clickAction(subitem.func, subitem.data)">
|
||||
<p>{{ subitem.text }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div v-else :key="index" class="flex items-center px-2 py-1.5 hover:bg-white/5 text-white text-xs cursor-pointer" @mouseover="mouseoverItem(index)" @mouseleave="mouseleaveItem(index)" @click.stop="clickAction(item.func)">
|
||||
<p>{{ item.text }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,19 +1,21 @@
|
||||
<template>
|
||||
<div v-if="tasksRunning" class="w-4 h-4 mx-3 relative" v-click-outside="clickOutsideObj">
|
||||
<div v-if="tasksToShow.length" class="w-4 h-4 mx-3 relative" v-click-outside="clickOutsideObj">
|
||||
<button type="button" :disabled="disabled" class="w-10 sm:w-full relative h-full cursor-pointer" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<ui-tooltip text="Tasks running" direction="bottom" class="flex items-center">
|
||||
<ui-tooltip v-if="tasksRunning" :text="$strings.LabelTasks" direction="bottom" class="flex items-center">
|
||||
<widgets-loading-spinner />
|
||||
</ui-tooltip>
|
||||
<ui-tooltip v-else text="Activities" direction="bottom" class="flex items-center">
|
||||
<span class="material-icons text-1.5xl" aria-label="Activities" role="button">notifications</span>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
</button>
|
||||
<transition name="menu">
|
||||
<div class="sm:w-80 w-full relative">
|
||||
<div v-show="showMenu" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md py-1 px-2 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalTaskRunningMenu">
|
||||
<div v-show="showMenu" class="absolute z-40 -mt-px w-40 sm:w-full bg-bg border border-black-200 shadow-lg rounded-md text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm globalTaskRunningMenu">
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-if="tasksRunningOrFailed.length">
|
||||
<p class="uppercase text-xs text-gray-400 my-1 px-1 font-semibold">{{ $strings.LabelTasks }}</p>
|
||||
<template v-for="task in tasksRunningOrFailed">
|
||||
<template v-if="tasksToShow.length">
|
||||
<template v-for="task in tasksToShow">
|
||||
<nuxt-link :key="task.id" v-if="actionLink(task)" :to="actionLink(task)">
|
||||
<li class="text-gray-50 select-none relative hover:bg-black-400 py-1 cursor-pointer">
|
||||
<cards-item-task-running-card :task="task" />
|
||||
@@ -54,9 +56,10 @@ export default {
|
||||
tasksRunning() {
|
||||
return this.tasks.some((t) => !t.isFinished)
|
||||
},
|
||||
tasksRunningOrFailed() {
|
||||
// return just the tasks that are running or failed in the last 1 minute
|
||||
return this.tasks.filter((t) => !t.isFinished || (t.isFailed && t.finishedAt > new Date().getTime() - 1000 * 60)) || []
|
||||
tasksToShow() {
|
||||
// return just the tasks that are running or failed (or show success) in the last 1 minute
|
||||
const tasks = this.tasks.filter((t) => !t.isFinished || ((t.isFailed || t.showSuccess) && t.finishedAt > new Date().getTime() - 1000 * 60)) || []
|
||||
return tasks.sort((a, b) => b.startedAt - a.startedAt)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -75,6 +78,8 @@ export default {
|
||||
return `/audiobook/${task.data.libraryItemId}/manage?tool=m4b`
|
||||
case 'embed-metadata':
|
||||
return `/audiobook/${task.data.libraryItemId}/manage?tool=embed`
|
||||
case 'scan-item':
|
||||
return `/item/${task.data.libraryItemId}`
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -35,7 +35,9 @@ export default {
|
||||
isSocketConnected: false,
|
||||
isFirstSocketConnection: true,
|
||||
socketConnectionToastId: null,
|
||||
currentLang: null
|
||||
currentLang: null,
|
||||
multiSessionOtherSessionId: null, // Used for multiple sessions open warning toast
|
||||
multiSessionCurrentSessionId: null // Used for multiple sessions open warning toast
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -300,14 +302,27 @@ export default {
|
||||
this.$store.commit('users/updateUserOnline', user)
|
||||
},
|
||||
userSessionClosed(sessionId) {
|
||||
// If this session or other session is closed then dismiss multiple sessions warning toast
|
||||
if (sessionId === this.multiSessionOtherSessionId || this.multiSessionCurrentSessionId === sessionId) {
|
||||
this.multiSessionOtherSessionId = null
|
||||
this.multiSessionCurrentSessionId = null
|
||||
this.$toast.dismiss('multiple-sessions')
|
||||
}
|
||||
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
|
||||
},
|
||||
userMediaProgressUpdate(payload) {
|
||||
this.$store.commit('user/updateMediaProgress', payload)
|
||||
|
||||
if (payload.data) {
|
||||
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId)) {
|
||||
// TODO: Update currently open session if being played from another device
|
||||
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId) && this.$store.state.playbackSessionId !== payload.sessionId) {
|
||||
this.multiSessionOtherSessionId = payload.sessionId
|
||||
this.multiSessionCurrentSessionId = this.$store.state.playbackSessionId
|
||||
console.log(`Media progress was updated from another session (${this.multiSessionOtherSessionId}) for currently open media. Device description=${payload.deviceDescription}. Current session id=${this.multiSessionCurrentSessionId}`)
|
||||
if (this.$store.state.streamIsPlaying) {
|
||||
this.$toast.update('multiple-sessions', { content: `Another session is open for this item on device ${payload.deviceDescription}`, options: { timeout: 20000, type: 'warning', pauseOnFocusLoss: false } }, true)
|
||||
} else {
|
||||
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -365,6 +380,11 @@ export default {
|
||||
adminMessageEvt(message) {
|
||||
this.$toast.info(message)
|
||||
},
|
||||
ereaderDevicesUpdated(data) {
|
||||
if (!data?.ereaderDevices) return
|
||||
|
||||
this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices)
|
||||
},
|
||||
initializeSocket() {
|
||||
this.socket = this.$nuxtSocket({
|
||||
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
|
||||
@@ -437,6 +457,9 @@ export default {
|
||||
this.socket.on('task_finished', this.taskFinished)
|
||||
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
|
||||
|
||||
// EReader Device Listeners
|
||||
this.socket.on('ereader-devices-updated', this.ereaderDevicesUpdated)
|
||||
|
||||
this.socket.on('backup_applied', this.backupApplied)
|
||||
|
||||
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)
|
||||
@@ -468,9 +491,9 @@ export default {
|
||||
}
|
||||
},
|
||||
checkActiveElementIsInput() {
|
||||
var activeElement = document.activeElement
|
||||
var inputs = ['input', 'select', 'button', 'textarea']
|
||||
return activeElement && inputs.indexOf(activeElement.tagName.toLowerCase()) !== -1
|
||||
const activeElement = document.activeElement
|
||||
const inputs = ['input', 'select', 'button', 'textarea', 'trix-editor']
|
||||
return activeElement && inputs.some((i) => i === activeElement.tagName.toLowerCase())
|
||||
},
|
||||
getHotkeyName(e) {
|
||||
var keyCode = e.keyCode || e.which
|
||||
@@ -537,12 +560,6 @@ export default {
|
||||
.catch((err) => console.error(err))
|
||||
},
|
||||
initLocalStorage() {
|
||||
// If experimental features set in local storage
|
||||
var experimentalFeaturesSaved = localStorage.getItem('experimental')
|
||||
if (experimentalFeaturesSaved === '1') {
|
||||
this.$store.commit('setExperimentalFeatures', true)
|
||||
}
|
||||
|
||||
// Queue auto play
|
||||
var playerQueueAutoPlay = localStorage.getItem('playerQueueAutoPlay')
|
||||
this.$store.commit('setPlayerQueueAutoPlay', playerQueueAutoPlay !== '0')
|
||||
|
||||
@@ -71,9 +71,8 @@ module.exports = {
|
||||
],
|
||||
|
||||
proxy: {
|
||||
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } },
|
||||
'/ebook/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||
'/s/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' + process.env : '/' },
|
||||
'/api/': { target: process.env.NODE_ENV !== 'production' ? 'http://localhost:3333' : '/' },
|
||||
'/dev/': { target: 'http://localhost:3333', pathRewrite: { '^/dev/': '' } }
|
||||
},
|
||||
|
||||
io: {
|
||||
|
||||
146
client/package-lock.json
generated
146
client/package-lock.json
generated
@@ -1,16 +1,17 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.2.20",
|
||||
"version": "2.3.3",
|
||||
"lockfileVersion": 2,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.2.20",
|
||||
"version": "2.3.3",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"@nuxtjs/proxy": "^2.1.0",
|
||||
"@teckel/vue-pdf": "^4.3.5",
|
||||
"core-js": "^3.16.0",
|
||||
"cron-parser": "^4.7.1",
|
||||
"date-fns": "^2.25.0",
|
||||
@@ -21,7 +22,6 @@
|
||||
"nuxt-socket-io": "^1.1.18",
|
||||
"trix": "^1.3.1",
|
||||
"v-click-outside": "^3.1.2",
|
||||
"vue-pdf": "^4.2.0",
|
||||
"vue-toastification": "^1.7.11",
|
||||
"vuedraggable": "^2.24.3"
|
||||
},
|
||||
@@ -2983,6 +2983,43 @@
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
|
||||
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
|
||||
},
|
||||
"node_modules/@teckel/vue-pdf": {
|
||||
"version": "4.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@teckel/vue-pdf/-/vue-pdf-4.3.5.tgz",
|
||||
"integrity": "sha512-g2DAbZMPbPc7NPFImOsU/e7rt7wfdmBkmFa2kPsB4x+k+Bs8yC5Icmq/VnTSEq/Y8bNvEY7i6+JoicGnlfQL7Q==",
|
||||
"dependencies": {
|
||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||
"loader-utils": "^1.4.0",
|
||||
"pdfjs-dist": "^2.5.207 <2.8.0",
|
||||
"raw-loader": "^4.0.1",
|
||||
"vue-resize-sensor": "^2.0.0",
|
||||
"worker-loader": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@teckel/vue-pdf/node_modules/json5": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@teckel/vue-pdf/node_modules/loader-utils": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
|
||||
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
|
||||
"dependencies": {
|
||||
"big.js": "^5.2.2",
|
||||
"emojis-list": "^3.0.0",
|
||||
"json5": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/anymatch": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz",
|
||||
@@ -15921,43 +15958,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
|
||||
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
|
||||
},
|
||||
"node_modules/vue-pdf": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz",
|
||||
"integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==",
|
||||
"dependencies": {
|
||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||
"loader-utils": "^1.4.0",
|
||||
"pdfjs-dist": "2.6.347",
|
||||
"raw-loader": "^4.0.2",
|
||||
"vue-resize-sensor": "^2.0.0",
|
||||
"worker-loader": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-pdf/node_modules/json5": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"json5": "lib/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-pdf/node_modules/loader-utils": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
|
||||
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
|
||||
"dependencies": {
|
||||
"big.js": "^5.2.2",
|
||||
"emojis-list": "^3.0.0",
|
||||
"json5": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue-resize-sensor": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
|
||||
@@ -19591,6 +19591,39 @@
|
||||
"resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
|
||||
"integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
|
||||
},
|
||||
"@teckel/vue-pdf": {
|
||||
"version": "4.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@teckel/vue-pdf/-/vue-pdf-4.3.5.tgz",
|
||||
"integrity": "sha512-g2DAbZMPbPc7NPFImOsU/e7rt7wfdmBkmFa2kPsB4x+k+Bs8yC5Icmq/VnTSEq/Y8bNvEY7i6+JoicGnlfQL7Q==",
|
||||
"requires": {
|
||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||
"loader-utils": "^1.4.0",
|
||||
"pdfjs-dist": "^2.5.207 <2.8.0",
|
||||
"raw-loader": "^4.0.1",
|
||||
"vue-resize-sensor": "^2.0.0",
|
||||
"worker-loader": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"json5": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz",
|
||||
"integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==",
|
||||
"requires": {
|
||||
"minimist": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"loader-utils": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
|
||||
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
|
||||
"requires": {
|
||||
"big.js": "^5.2.2",
|
||||
"emojis-list": "^3.0.0",
|
||||
"json5": "^1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"@types/anymatch": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-3.0.0.tgz",
|
||||
@@ -29618,39 +29651,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vue-no-ssr/-/vue-no-ssr-1.1.1.tgz",
|
||||
"integrity": "sha512-ZMjqRpWabMPqPc7gIrG0Nw6vRf1+itwf0Itft7LbMXs2g3Zs/NFmevjZGN1x7K3Q95GmIjWbQZTVerxiBxI+0g=="
|
||||
},
|
||||
"vue-pdf": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-pdf/-/vue-pdf-4.3.0.tgz",
|
||||
"integrity": "sha512-zd3lJj6CbtrawgaaDDciTDjkJMUKiLWtbEmBg5CvFn9Noe9oAO/GNy/fc5c59qGuFCJ14ibIV1baw4S07e5bSQ==",
|
||||
"requires": {
|
||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||
"loader-utils": "^1.4.0",
|
||||
"pdfjs-dist": "2.6.347",
|
||||
"raw-loader": "^4.0.2",
|
||||
"vue-resize-sensor": "^2.0.0",
|
||||
"worker-loader": "^2.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"json5": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz",
|
||||
"integrity": "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow==",
|
||||
"requires": {
|
||||
"minimist": "^1.2.0"
|
||||
}
|
||||
},
|
||||
"loader-utils": {
|
||||
"version": "1.4.2",
|
||||
"resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.4.2.tgz",
|
||||
"integrity": "sha512-I5d00Pd/jwMD2QCduo657+YM/6L3KZu++pmX9VFncxaxvHcru9jx1lBaFft+r4Mt2jK0Yhp41XlRAihzPxHNCg==",
|
||||
"requires": {
|
||||
"big.js": "^5.2.2",
|
||||
"emojis-list": "^3.0.0",
|
||||
"json5": "^1.0.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"vue-resize-sensor": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-resize-sensor/-/vue-resize-sensor-2.0.0.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-client",
|
||||
"version": "2.2.20",
|
||||
"version": "2.3.3",
|
||||
"description": "Self-hosted audiobook and podcast client",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@@ -15,6 +15,7 @@
|
||||
"dependencies": {
|
||||
"@nuxtjs/axios": "^5.13.6",
|
||||
"@nuxtjs/proxy": "^2.1.0",
|
||||
"@teckel/vue-pdf": "^4.3.5",
|
||||
"core-js": "^3.16.0",
|
||||
"cron-parser": "^4.7.1",
|
||||
"date-fns": "^2.25.0",
|
||||
@@ -25,7 +26,6 @@
|
||||
"nuxt-socket-io": "^1.1.18",
|
||||
"trix": "^1.3.1",
|
||||
"v-click-outside": "^3.1.2",
|
||||
"vue-pdf": "^4.2.0",
|
||||
"vue-toastification": "^1.7.11",
|
||||
"vuedraggable": "^2.24.3"
|
||||
},
|
||||
|
||||
@@ -112,17 +112,17 @@
|
||||
<div class="flex text-xs uppercase text-gray-300 font-semibold mb-2">
|
||||
<div class="flex-grow">{{ $strings.LabelFilename }}</div>
|
||||
<div class="w-20">{{ $strings.LabelDuration }}</div>
|
||||
<div class="w-20 text-center">{{ $strings.HeaderChapters }}</div>
|
||||
<div class="w-20 hidden md:block text-center">{{ $strings.HeaderChapters }}</div>
|
||||
</div>
|
||||
<template v-for="track in audioTracks">
|
||||
<div :key="track.ino" class="flex items-center py-2" :class="currentTrackIndex === track.index && isPlayingChapter ? 'bg-success bg-opacity-10' : ''">
|
||||
<div class="flex-grow">
|
||||
<div class="flex-grow max-w-[calc(100%-80px)] pr-2">
|
||||
<p class="text-xs truncate max-w-sm">{{ track.metadata.filename }}</p>
|
||||
</div>
|
||||
<div class="w-20" style="min-width: 80px">
|
||||
<p class="text-xs font-mono text-gray-200">{{ $secondsToTimestamp(Math.round(track.duration), false, true) }}</p>
|
||||
</div>
|
||||
<div class="w-20 flex justify-center" style="min-width: 80px">
|
||||
<div class="w-20 hidden md:flex justify-center" style="min-width: 80px">
|
||||
<span v-if="(track.chapters || []).length" class="material-icons text-success text-sm">check</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
<transition name="slide">
|
||||
<div v-if="showEncodeOptions" class="mb-4 pb-4 border-b border-white/10">
|
||||
<div class="flex flex-wrap -mx-2">
|
||||
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 64k)'" class="m-2 max-w-40" />
|
||||
<ui-text-input-with-label ref="bitrateInput" v-model="encodingOptions.bitrate" :disabled="processing || isTaskFinished" :label="'Audio Bitrate (e.g. 128k)'" class="m-2 max-w-40" />
|
||||
<ui-text-input-with-label ref="channelsInput" v-model="encodingOptions.channels" :disabled="processing || isTaskFinished" :label="'Audio Channels (1 or 2)'" class="m-2 max-w-40" />
|
||||
<ui-text-input-with-label ref="codecInput" v-model="encodingOptions.codec" :disabled="processing || isTaskFinished" :label="'Audio Codec'" class="m-2 max-w-40" />
|
||||
</div>
|
||||
@@ -214,7 +214,7 @@ export default {
|
||||
showEncodeOptions: false,
|
||||
shouldBackupAudioFiles: true,
|
||||
encodingOptions: {
|
||||
bitrate: '64k',
|
||||
bitrate: '128k',
|
||||
channels: '2',
|
||||
codec: 'aac'
|
||||
}
|
||||
|
||||
@@ -43,8 +43,8 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ store, app, params, redirect }) {
|
||||
const author = await app.$axios.$get(`/api/authors/${params.id}?library=${store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => {
|
||||
async asyncData({ store, app, params, redirect, query }) {
|
||||
const author = await app.$axios.$get(`/api/authors/${params.id}?library=${query.library || store.state.libraries.currentLibraryId}&include=items,series`).catch((error) => {
|
||||
console.error('Failed to get author', error)
|
||||
return null
|
||||
})
|
||||
@@ -53,6 +53,10 @@ export default {
|
||||
return redirect(`/library/${store.state.libraries.currentLibraryId}/authors`)
|
||||
}
|
||||
|
||||
if (query.library) {
|
||||
store.commit('libraries/setCurrentLibrary', query.library)
|
||||
}
|
||||
|
||||
return {
|
||||
author
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ export default {
|
||||
feed: this.rssFeed
|
||||
})
|
||||
},
|
||||
contextMenuAction(action) {
|
||||
contextMenuAction({ action }) {
|
||||
if (action === 'delete') {
|
||||
this.removeClick()
|
||||
} else if (action === 'create-playlist') {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="configContent" :class="`page-${currentPage}`">
|
||||
<div v-show="isMobilePortrait" class="w-full pb-4 px-2 flex border-b border-white border-opacity-10 mb-2 cursor-pointer" @click.stop.prevent="toggleShowMore">
|
||||
<span class="material-icons text-2xl cursor-pointer">arrow_forward</span>
|
||||
<p class="pl-3 capitalize">{{ $strings.HeaderSettings }}</p>
|
||||
<p class="pl-3 capitalize">{{ currentPage }}</p>
|
||||
</div>
|
||||
<nuxt-child />
|
||||
</div>
|
||||
@@ -55,6 +55,7 @@ export default {
|
||||
else if (pageName === 'library-stats') return this.$strings.HeaderLibraryStats
|
||||
else if (pageName === 'users') return this.$strings.HeaderUsers
|
||||
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
|
||||
else if (pageName === 'email') return this.$strings.HeaderEmail
|
||||
}
|
||||
return this.$strings.HeaderSettings
|
||||
}
|
||||
@@ -79,14 +80,6 @@ export default {
|
||||
width: 900px;
|
||||
max-width: calc(100% - 176px);
|
||||
}
|
||||
.configContent.page-library-stats {
|
||||
width: 1200px;
|
||||
}
|
||||
@media (max-width: 1550px) {
|
||||
.configContent.page-library-stats {
|
||||
margin-left: 176px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 1240px) {
|
||||
.configContent {
|
||||
margin-left: 176px;
|
||||
@@ -98,8 +91,5 @@ export default {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
}
|
||||
.configContent.page-library-stats {
|
||||
margin-left: 0px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -11,14 +11,18 @@
|
||||
<div v-if="enableBackups" class="mb-6">
|
||||
<div class="flex items-center pl-6 mb-2">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">schedule</span>
|
||||
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span></div>
|
||||
<div class="w-48">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.HeaderSchedule }}:</span>
|
||||
</div>
|
||||
<div class="text-gray-100">{{ scheduleDescription }}</div>
|
||||
<span class="material-icons text-lg text-black-50 hover:text-yellow-500 cursor-pointer ml-2" @click="showCronBuilder = !showCronBuilder">edit</span>
|
||||
</div>
|
||||
|
||||
<div v-if="nextBackupDate" class="flex items-center pl-6 py-0.5 px-2">
|
||||
<span class="material-icons-outlined text-2xl text-black-50 mr-2">event</span>
|
||||
<div class="w-48"><span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span></div>
|
||||
<div class="w-48">
|
||||
<span class="text-white text-opacity-60 uppercase text-sm">{{ $strings.LabelNextBackupDate }}:</span>
|
||||
</div>
|
||||
<div class="text-gray-100">{{ nextBackupDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -48,6 +52,11 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
updatingServerSettings: false,
|
||||
@@ -98,7 +107,7 @@ export default {
|
||||
this.$toast.error('Invalid number of backups to keep')
|
||||
return
|
||||
}
|
||||
var updatePayload = {
|
||||
const updatePayload = {
|
||||
backupSchedule: this.enableBackups ? this.cronExpression : false,
|
||||
backupsToKeep: Number(this.backupsToKeep),
|
||||
maxBackupSize: Number(this.maxBackupSize)
|
||||
@@ -108,15 +117,15 @@ export default {
|
||||
updateServerSettings(payload) {
|
||||
this.updatingServerSettings = true
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update server settings', error)
|
||||
this.updatingServerSettings = false
|
||||
})
|
||||
},
|
||||
initServerSettings() {
|
||||
this.newServerSettings = this.serverSettings ? { ...this.serverSettings } : {}
|
||||
|
||||
260
client/pages/config/email.vue
Normal file
260
client/pages/config/email.vue
Normal file
@@ -0,0 +1,260 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-settings-content :header-text="$strings.HeaderEmailSettings" :description="''">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div class="flex items-center -mx-1 mb-2">
|
||||
<div class="w-full md:w-3/4 px-1">
|
||||
<ui-text-input-with-label ref="hostInput" v-model="newSettings.host" :disabled="savingSettings" :label="$strings.LabelHost" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/4 px-1">
|
||||
<ui-text-input-with-label ref="portInput" v-model="newSettings.port" type="number" :disabled="savingSettings" :label="$strings.LabelPort" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center mb-2 py-3">
|
||||
<ui-toggle-switch labeledBy="email-settings-secure" v-model="newSettings.secure" :disabled="savingSettings" />
|
||||
<ui-tooltip :text="$strings.LabelEmailSettingsSecureHelp">
|
||||
<div class="pl-4 flex items-center">
|
||||
<span id="email-settings-secure">{{ $strings.LabelEmailSettingsSecure }}</span>
|
||||
<span class="material-icons text-lg pl-1">info_outlined</span>
|
||||
</div>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center -mx-1 mb-2">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="userInput" v-model="newSettings.user" :disabled="savingSettings" :label="$strings.LabelUsername" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="passInput" v-model="newSettings.pass" type="password" :disabled="savingSettings" :label="$strings.LabelPassword" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center -mx-1 mb-2">
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="fromInput" v-model="newSettings.fromAddress" :disabled="savingSettings" :label="$strings.LabelEmailSettingsFromAddress" />
|
||||
</div>
|
||||
<div class="w-full md:w-1/2 px-1">
|
||||
<ui-text-input-with-label ref="testInput" v-model="newSettings.testAddress" :disabled="savingSettings" :label="$strings.LabelEmailSettingsTestAddress" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pt-4">
|
||||
<ui-btn v-if="hasUpdates" :disabled="savingSettings" type="button" @click="resetChanges">{{ $strings.ButtonReset }}</ui-btn>
|
||||
<ui-btn v-else :loading="sendingTest" :disabled="savingSettings || !newSettings.host" type="button" @click="sendTestClick">{{ $strings.ButtonTest }}</ui-btn>
|
||||
<ui-btn :loading="savingSettings" :disabled="!hasUpdates" type="submit">{{ $strings.ButtonSave }}</ui-btn>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-show="loading" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-25 flex items-center justify-center">
|
||||
<ui-loading-indicator />
|
||||
</div>
|
||||
</app-settings-content>
|
||||
|
||||
<app-settings-content :header-text="$strings.HeaderEreaderDevices" showAddButton :description="''" @clicked="addNewDeviceClick">
|
||||
<table v-if="existingEReaderDevices.length" class="tracksTable my-4">
|
||||
<tr>
|
||||
<th class="text-left">{{ $strings.LabelName }}</th>
|
||||
<th class="text-left">{{ $strings.LabelEmail }}</th>
|
||||
<th class="w-40"></th>
|
||||
</tr>
|
||||
<tr v-for="device in existingEReaderDevices" :key="device.name">
|
||||
<td>
|
||||
<p class="text-sm md:text-base text-gray-100">{{ device.name }}</p>
|
||||
</td>
|
||||
<td class="text-left">
|
||||
<p class="text-sm md:text-base text-gray-100">{{ device.email }}</p>
|
||||
</td>
|
||||
<td class="w-40">
|
||||
<div class="flex justify-end items-center h-10">
|
||||
<ui-icon-btn icon="edit" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name" class="mx-1" @click="editDeviceClick(device)" />
|
||||
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingDeviceName === device.name" @click="deleteDeviceClick(device)" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<div v-else class="text-center py-4">
|
||||
<p class="text-lg text-gray-100">No Devices</p>
|
||||
</div>
|
||||
</app-settings-content>
|
||||
|
||||
<modals-emails-e-reader-device-modal v-model="showEReaderDeviceModal" :existing-devices="existingEReaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
savingSettings: false,
|
||||
sendingTest: false,
|
||||
deletingDeviceName: null,
|
||||
settings: null,
|
||||
newSettings: {
|
||||
host: null,
|
||||
port: 465,
|
||||
secure: true,
|
||||
user: null,
|
||||
pass: null,
|
||||
testAddress: null,
|
||||
fromAddress: null
|
||||
},
|
||||
newEReaderDevice: {
|
||||
name: '',
|
||||
email: ''
|
||||
},
|
||||
selectedEReaderDevice: null,
|
||||
showEReaderDeviceModal: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasUpdates() {
|
||||
if (!this.settings) return true
|
||||
for (const key in this.newSettings) {
|
||||
if (key === 'ereaderDevices') continue
|
||||
if (this.newSettings[key] !== this.settings[key]) return true
|
||||
}
|
||||
return false
|
||||
},
|
||||
existingEReaderDevices() {
|
||||
return this.settings?.ereaderDevices || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
resetChanges() {
|
||||
this.newSettings = {
|
||||
...this.settings
|
||||
}
|
||||
},
|
||||
editDeviceClick(device) {
|
||||
this.selectedEReaderDevice = device
|
||||
this.showEReaderDeviceModal = true
|
||||
},
|
||||
deleteDeviceClick(device) {
|
||||
const payload = {
|
||||
message: `Are you sure you want to delete e-reader device "${device.name}"?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.deleteDevice(device)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
deleteDevice(device) {
|
||||
const payload = {
|
||||
ereaderDevices: this.existingEReaderDevices.filter((d) => d.name !== device.name)
|
||||
}
|
||||
this.deletingDeviceName = device.name
|
||||
this.$axios
|
||||
.$post(`/api/emails/ereader-devices`, payload)
|
||||
.then((data) => {
|
||||
this.ereaderDevicesUpdated(data.ereaderDevices)
|
||||
this.$toast.success('Device deleted')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to delete device', error)
|
||||
this.$toast.error('Failed to delete device')
|
||||
})
|
||||
.finally(() => {
|
||||
this.deletingDeviceName = null
|
||||
})
|
||||
},
|
||||
ereaderDevicesUpdated(ereaderDevices) {
|
||||
this.settings.ereaderDevices = ereaderDevices
|
||||
this.newSettings.ereaderDevices = ereaderDevices.map((d) => ({ ...d }))
|
||||
},
|
||||
addNewDeviceClick() {
|
||||
this.selectedEReaderDevice = null
|
||||
this.showEReaderDeviceModal = true
|
||||
},
|
||||
sendTestClick() {
|
||||
this.sendingTest = true
|
||||
this.$axios
|
||||
.$post('/api/emails/test')
|
||||
.then(() => {
|
||||
this.$toast.success('Test Email Sent')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to send test email', error)
|
||||
const errorMsg = error.response.data || 'Failed to send test email'
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.sendingTest = false
|
||||
})
|
||||
},
|
||||
validateForm() {
|
||||
for (const ref of [this.$refs.hostInput, this.$refs.portInput, this.$refs.userInput, this.$refs.passInput, this.$refs.fromInput]) {
|
||||
if (ref?.blur) ref.blur()
|
||||
}
|
||||
|
||||
if (this.newSettings.port) {
|
||||
this.newSettings.port = Number(this.newSettings.port)
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
submitForm() {
|
||||
if (!this.validateForm()) return
|
||||
|
||||
const updatePayload = {
|
||||
host: this.newSettings.host,
|
||||
port: this.newSettings.port,
|
||||
secure: this.newSettings.secure,
|
||||
user: this.newSettings.user,
|
||||
pass: this.newSettings.pass,
|
||||
testAddress: this.newSettings.testAddress,
|
||||
fromAddress: this.newSettings.fromAddress
|
||||
}
|
||||
this.savingSettings = true
|
||||
this.$axios
|
||||
.$patch('/api/emails/settings', updatePayload)
|
||||
.then((data) => {
|
||||
this.settings = data.settings
|
||||
this.newSettings = {
|
||||
...data.settings
|
||||
}
|
||||
this.$toast.success('Email settings updated')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to update email settings', error)
|
||||
this.$toast.error('Failed to update email settings')
|
||||
})
|
||||
.finally(() => {
|
||||
this.savingSettings = false
|
||||
})
|
||||
},
|
||||
init() {
|
||||
this.loading = true
|
||||
|
||||
this.$axios
|
||||
.$get(`/api/emails/settings`)
|
||||
.then((data) => {
|
||||
this.settings = data.settings
|
||||
this.newSettings = {
|
||||
...this.settings
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to get email settings', error)
|
||||
this.$toast.error('Failed to load email settings')
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
},
|
||||
beforeDestroy() {}
|
||||
}
|
||||
</script>
|
||||
@@ -39,11 +39,15 @@
|
||||
<ui-multi-select v-model="newServerSettings.sortingPrefixes" small :items="newServerSettings.sortingPrefixes" :label="$strings.LabelPrefixesToIgnore" @input="updateSortingPrefixes" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<div class="flex items-center py-2 mb-2">
|
||||
<ui-toggle-switch labeledBy="settings-chromecast-support" v-model="newServerSettings.chromecastEnabled" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('chromecastEnabled', val)" />
|
||||
<p class="pl-4" id="settings-chromecast-support">{{ $strings.LabelSettingsChromecastSupport }}</p>
|
||||
</div>
|
||||
|
||||
<div class="w-44 mb-2">
|
||||
<ui-dropdown v-model="newServerSettings.metadataFileFormat" small :items="metadataFileFormats" label="Metadata File Format" @input="updateMetadataFileFormat" :disabled="updatingServerSettings" />
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsDisplay }}</h2>
|
||||
</div>
|
||||
@@ -162,7 +166,8 @@
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="pt-4">
|
||||
<!-- old experimental features -->
|
||||
<!-- <div class="pt-4">
|
||||
<h2 class="font-semibold">{{ $strings.HeaderSettingsExperimental }}</h2>
|
||||
</div>
|
||||
|
||||
@@ -176,26 +181,6 @@
|
||||
</a>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-2">
|
||||
<ui-toggle-switch labeledBy="settings-enable-e-reader" v-model="newServerSettings.enableEReader" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('enableEReader', val)" />
|
||||
<ui-tooltip :text="$strings.LabelSettingsEnableEReaderHelp">
|
||||
<p class="pl-4">
|
||||
<span id="settings-enable-e-reader">{{ $strings.LabelSettingsEnableEReader }}</span>
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div>
|
||||
|
||||
<!-- <div class="flex items-center py-2">
|
||||
<ui-toggle-switch v-model="newServerSettings.scannerUseTone" :disabled="updatingServerSettings" @input="(val) => updateSettingsKey('scannerUseTone', val)" />
|
||||
<ui-tooltip text="Tone library for metadata">
|
||||
<p class="pl-4">
|
||||
Use Tone library for metadata
|
||||
<span class="material-icons icon-text">info_outlined</span>
|
||||
</p>
|
||||
</ui-tooltip>
|
||||
</div> -->
|
||||
</div>
|
||||
</div>
|
||||
@@ -207,7 +192,6 @@
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeCache">{{ $strings.ButtonPurgeAllCache }}</ui-btn>
|
||||
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isPurgingCache" @click.stop="purgeItemsCache">{{ $strings.ButtonPurgeItemsCache }}</ui-btn>
|
||||
<ui-btn color="bg" small :padding-x="4" class="mr-2 text-xs md:text-sm" :loading="isResettingLibraryItems" @click="resetLibraryItems">{{ $strings.ButtonRemoveAllLibraryItems }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center py-4">
|
||||
@@ -264,6 +248,11 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isResettingLibraryItems: false,
|
||||
@@ -272,7 +261,17 @@ export default {
|
||||
useBookshelfView: false,
|
||||
isPurgingCache: false,
|
||||
newServerSettings: {},
|
||||
showConfirmPurgeCache: false
|
||||
showConfirmPurgeCache: false,
|
||||
metadataFileFormats: [
|
||||
{
|
||||
text: '.json',
|
||||
value: 'json'
|
||||
},
|
||||
{
|
||||
text: '.abs',
|
||||
value: 'abs'
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -289,14 +288,6 @@ export default {
|
||||
providers() {
|
||||
return this.$store.state.scanners.providers
|
||||
},
|
||||
showExperimentalFeatures: {
|
||||
get() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('setExperimentalFeatures', val)
|
||||
}
|
||||
},
|
||||
dateFormats() {
|
||||
return this.$store.state.globals.dateFormats
|
||||
},
|
||||
@@ -341,6 +332,10 @@ export default {
|
||||
updateServerLanguage(val) {
|
||||
this.updateSettingsKey('language', val)
|
||||
},
|
||||
updateMetadataFileFormat(val) {
|
||||
if (this.serverSettings.metadataFileFormat === val) return
|
||||
this.updateSettingsKey('metadataFileFormat', val)
|
||||
},
|
||||
updateSettingsKey(key, val) {
|
||||
this.updateServerSettings({
|
||||
[key]: val
|
||||
@@ -350,8 +345,7 @@ export default {
|
||||
this.updatingServerSettings = true
|
||||
this.$store
|
||||
.dispatch('updateServerSettings', payload)
|
||||
.then((success) => {
|
||||
console.log('Updated Server Settings', success)
|
||||
.then(() => {
|
||||
this.updatingServerSettings = false
|
||||
this.$toast.success('Server settings updated')
|
||||
|
||||
@@ -373,23 +367,6 @@ export default {
|
||||
this.homepageUseBookshelfView = this.newServerSettings.homeBookshelfView != this.$constants.BookshelfView.DETAIL
|
||||
this.useBookshelfView = this.newServerSettings.bookshelfView != this.$constants.BookshelfView.DETAIL
|
||||
},
|
||||
resetLibraryItems() {
|
||||
if (confirm(this.$strings.MessageRemoveAllItemsWarning)) {
|
||||
this.isResettingLibraryItems = true
|
||||
this.$axios
|
||||
.$delete('/api/items/all')
|
||||
.then(() => {
|
||||
this.isResettingLibraryItems = false
|
||||
this.$toast.success('Successfully reset items')
|
||||
location.reload()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('failed to reset items', error)
|
||||
this.isResettingLibraryItems = false
|
||||
this.$toast.error('Failed to reset items - manually remove the /config/libraryItems folder')
|
||||
})
|
||||
}
|
||||
},
|
||||
purgeCache() {
|
||||
this.showConfirmPurgeCache = true
|
||||
},
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
|
||||
@@ -19,6 +19,11 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
|
||||
@@ -38,6 +38,11 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showLibraryModal: false,
|
||||
|
||||
@@ -87,6 +87,11 @@
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ redirect, store }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
return
|
||||
}
|
||||
|
||||
if (!store.state.libraries.currentLibraryId) {
|
||||
return redirect('/config')
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
search: null,
|
||||
|
||||
@@ -46,6 +46,11 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
loading: false,
|
||||
|
||||
@@ -104,7 +104,12 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
async asyncData({ params, redirect, app }) {
|
||||
async asyncData({ store, redirect, app }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
return
|
||||
}
|
||||
|
||||
const users = await app.$axios
|
||||
.$get('/api/users')
|
||||
.then((res) => {
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
<div class="flex mb-4 items-center">
|
||||
<h1 class="text-2xl">{{ $strings.HeaderStatsRecentSessions }}</h1>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn :to="`/config/users/${user.id}/sessions`" class="text-xs" :padding-x="1.5" :padding-y="1">{{ $strings.ButtonViewAll }}</ui-btn>
|
||||
<ui-btn v-if="isAdminOrUp" :to="`/config/users/${user.id}/sessions`" class="text-xs" :padding-x="1.5" :padding-y="1">{{ $strings.ButtonViewAll }}</ui-btn>
|
||||
</div>
|
||||
<p v-if="!mostRecentListeningSessions.length">{{ $strings.MessageNoListeningSessions }}</p>
|
||||
<template v-for="(item, index) in mostRecentListeningSessions">
|
||||
@@ -82,6 +82,9 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
user() {
|
||||
return this.$store.state.user.user
|
||||
},
|
||||
@@ -116,7 +119,6 @@ export default {
|
||||
console.error('Failed to load listening sesions', err)
|
||||
return []
|
||||
})
|
||||
console.log('Loaded user listening data', this.listeningStats)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -47,12 +47,6 @@
|
||||
<div class="py-2">
|
||||
<h1 class="text-lg mb-2 text-white text-opacity-90 px-2 sm:px-0">{{ $strings.HeaderSavedMediaProgress }}</h1>
|
||||
|
||||
<div v-if="mediaProgressWithoutMedia.length" class="flex items-center py-2 mb-2">
|
||||
<p class="text-error">User has media progress for {{ mediaProgressWithoutMedia.length }} items that no longer exist.</p>
|
||||
<div class="flex-grow" />
|
||||
<ui-btn small :loading="purgingMediaProgress" @click.stop="purgeMediaProgress">{{ $strings.ButtonPurgeMediaProgress }}</ui-btn>
|
||||
</div>
|
||||
|
||||
<table v-if="mediaProgressWithMedia.length" class="userAudiobooksTable">
|
||||
<tr class="bg-primary bg-opacity-40">
|
||||
<th class="w-16 text-left">{{ $strings.LabelItem }}</th>
|
||||
@@ -111,8 +105,7 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
listeningSessions: {},
|
||||
listeningStats: {},
|
||||
purgingMediaProgress: false
|
||||
listeningStats: {}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -134,9 +127,6 @@ export default {
|
||||
mediaProgressWithMedia() {
|
||||
return this.mediaProgress.filter((mp) => mp.media)
|
||||
},
|
||||
mediaProgressWithoutMedia() {
|
||||
return this.mediaProgress.filter((mp) => !mp.media)
|
||||
},
|
||||
totalListeningTime() {
|
||||
return this.listeningStats.totalTime || 0
|
||||
},
|
||||
@@ -176,24 +166,6 @@ export default {
|
||||
return []
|
||||
})
|
||||
console.log('Loaded user listening data', this.listeningSessions, this.listeningStats)
|
||||
},
|
||||
purgeMediaProgress() {
|
||||
this.purgingMediaProgress = true
|
||||
|
||||
this.$axios
|
||||
.$post(`/api/users/${this.user.id}/purge-media-progress`)
|
||||
.then((updatedUser) => {
|
||||
console.log('Updated user', updatedUser)
|
||||
this.$toast.success('Media progress purged')
|
||||
this.user = updatedUser
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to purge media progress', error)
|
||||
this.$toast.error('Failed to purge media progress')
|
||||
})
|
||||
.finally(() => {
|
||||
this.purgingMediaProgress = false
|
||||
})
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
|
||||
@@ -9,6 +9,11 @@
|
||||
|
||||
<script>
|
||||
export default {
|
||||
asyncData({ store, redirect }) {
|
||||
if (!store.getters['user/getIsAdminOrUp']) {
|
||||
redirect('/')
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedAccount: null,
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
<nuxt-link v-for="(artist, index) in musicArtists" :key="index" :to="`/artist/${$encode(artist)}`" class="hover:underline">{{ artist }}<span v-if="index < musicArtists.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else-if="authors.length" class="mb-2 mt-0.5 text-gray-200 text-lg md:text-xl max-w-[calc(100vw-2rem)] overflow-hidden overflow-ellipsis">
|
||||
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
by <nuxt-link v-for="(author, index) in authors" :key="index" :to="`/author/${author.id}?library=${libraryItem.libraryId}`" class="hover:underline">{{ author.name }}<span v-if="index < authors.length - 1">, </span></nuxt-link>
|
||||
</p>
|
||||
<p v-else class="mb-2 mt-0.5 text-gray-200 text-xl">by Unknown</p>
|
||||
</template>
|
||||
@@ -52,13 +52,6 @@
|
||||
<div class="hidden md:block flex-grow" />
|
||||
</div>
|
||||
|
||||
<!-- Alerts -->
|
||||
<div v-show="showExperimentalReadAlert" class="bg-error p-4 rounded-xl flex items-center">
|
||||
<span class="material-icons text-2xl">warning_amber</span>
|
||||
<p v-if="userIsAdminOrUp" class="ml-4">Book has no audio tracks but has an ebook. The experimental e-reader can be enabled in config.</p>
|
||||
<p v-else class="ml-4">Book has no audio tracks but has an ebook. The experimental e-reader must be enabled by a server admin.</p>
|
||||
</div>
|
||||
|
||||
<!-- Podcast episode downloads queue -->
|
||||
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 mt-4 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative max-w-max mx-auto md:mx-0">
|
||||
<div class="flex items-center">
|
||||
@@ -122,7 +115,7 @@
|
||||
<ui-icon-btn icon="search" class="mx-0.5" :loading="fetchingRSSFeed" outlined @click="findEpisodesClick" />
|
||||
</ui-tooltip>
|
||||
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" menu-width="148px" @action="contextMenuAction">
|
||||
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" :menu-width="148" @action="contextMenuAction">
|
||||
<template #default="{ showMenu, clickShowMenu, disabled }">
|
||||
<button type="button" :disabled="disabled" class="mx-0.5 icon-btn bg-primary border border-gray-600 w-9 h-9 rounded-md flex items-center justify-center relative" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
|
||||
<span class="material-icons">more_horiz</span>
|
||||
@@ -147,7 +140,9 @@
|
||||
|
||||
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />
|
||||
|
||||
<tables-library-files-table v-if="libraryFiles.length" :is-missing="isMissing" :library-item="libraryItem" class="mt-6" />
|
||||
<tables-ebook-files-table v-if="ebookFiles.length" :library-item="libraryItem" class="mt-6" />
|
||||
|
||||
<tables-library-files-table v-if="libraryFiles.length" :library-item="libraryItem" class="mt-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -200,12 +195,6 @@ export default {
|
||||
dateFormat() {
|
||||
return this.$store.state.serverSettings.dateFormat
|
||||
},
|
||||
showExperimentalFeatures() {
|
||||
return this.$store.state.showExperimentalFeatures
|
||||
},
|
||||
enableEReader() {
|
||||
return this.$store.getters['getServerSetting']('enableEReader')
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
@@ -257,7 +246,7 @@ export default {
|
||||
return this.tracks.length
|
||||
},
|
||||
showReadButton() {
|
||||
return this.ebookFile && (this.showExperimentalFeatures || this.enableEReader)
|
||||
return this.ebookFile
|
||||
},
|
||||
libraryId() {
|
||||
return this.libraryItem.libraryId
|
||||
@@ -320,6 +309,9 @@ export default {
|
||||
libraryFiles() {
|
||||
return this.libraryItem.libraryFiles || []
|
||||
},
|
||||
ebookFiles() {
|
||||
return this.libraryFiles.filter((lf) => lf.fileType === 'ebook')
|
||||
},
|
||||
ebookFile() {
|
||||
return this.media.ebookFile
|
||||
},
|
||||
@@ -330,9 +322,6 @@ export default {
|
||||
// Music track
|
||||
return this.media.audioFile
|
||||
},
|
||||
showExperimentalReadAlert() {
|
||||
return !this.tracks.length && this.ebookFile && !this.showExperimentalFeatures && !this.enableEReader
|
||||
},
|
||||
description() {
|
||||
return this.mediaMetadata.description || ''
|
||||
},
|
||||
@@ -431,6 +420,19 @@ export default {
|
||||
})
|
||||
}
|
||||
|
||||
if (this.ebookFile && this.$store.state.libraries.ereaderDevices?.length) {
|
||||
items.push({
|
||||
text: this.$strings.LabelSendEbookToDevice,
|
||||
subitems: this.$store.state.libraries.ereaderDevices.map((d) => {
|
||||
return {
|
||||
text: d.name,
|
||||
action: 'sendToDevice',
|
||||
data: d.name
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (this.userCanDelete) {
|
||||
items.push({
|
||||
text: this.$strings.ButtonDelete,
|
||||
@@ -506,7 +508,7 @@ export default {
|
||||
this.$store.commit('showEditModalOnTab', { libraryItem: this.libraryItem, tab: 'cover' })
|
||||
},
|
||||
openEbook() {
|
||||
this.$store.commit('showEReader', this.libraryItem)
|
||||
this.$store.commit('showEReader', { libraryItem: this.libraryItem, keepProgress: true })
|
||||
},
|
||||
toggleFinished(confirmed = false) {
|
||||
if (!this.userIsFinished && this.progressPercent > 0 && !confirmed) {
|
||||
@@ -531,7 +533,6 @@ export default {
|
||||
.$patch(`/api/me/progress/${this.libraryItemId}`, updatePayload)
|
||||
.then(() => {
|
||||
this.isProcessingReadUpdate = false
|
||||
this.$toast.success(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedSuccess : this.$strings.ToastItemMarkedAsNotFinishedSuccess)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
@@ -602,10 +603,11 @@ export default {
|
||||
}
|
||||
},
|
||||
clearProgressClick() {
|
||||
if (!this.userMediaProgress) return
|
||||
if (confirm(`Are you sure you want to reset your progress?`)) {
|
||||
this.resettingProgress = true
|
||||
this.$axios
|
||||
.$delete(`/api/me/progress/${this.libraryItemId}`)
|
||||
.$delete(`/api/me/progress/${this.userMediaProgress.id}`)
|
||||
.then(() => {
|
||||
console.log('Progress reset complete')
|
||||
this.$toast.success(`Your progress was reset`)
|
||||
@@ -676,14 +678,7 @@ export default {
|
||||
}
|
||||
},
|
||||
downloadLibraryItem() {
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = this.downloadUrl
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => {
|
||||
a.remove()
|
||||
})
|
||||
this.$downloadFile(this.downloadUrl)
|
||||
},
|
||||
deleteLibraryItem() {
|
||||
const payload = {
|
||||
@@ -710,7 +705,35 @@ export default {
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
contextMenuAction(action) {
|
||||
sendToDevice(deviceName) {
|
||||
const payload = {
|
||||
message: this.$getString('MessageConfirmSendEbookToDevice', [this.ebookFile.ebookFormat, this.title, deviceName]),
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
const payload = {
|
||||
libraryItemId: this.libraryItemId,
|
||||
deviceName
|
||||
}
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/emails/send-ebook-to-device`, payload)
|
||||
.then(() => {
|
||||
this.$toast.success(this.$getString('ToastSendEbookToDeviceSuccess', [deviceName]))
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to send ebook to device', error)
|
||||
this.$toast.error(this.$strings.ToastSendEbookToDeviceFailed)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
},
|
||||
contextMenuAction({ action, data }) {
|
||||
if (action === 'collections') {
|
||||
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
|
||||
this.$store.commit('globals/setShowCollectionsModal', true)
|
||||
@@ -725,6 +748,8 @@ export default {
|
||||
this.downloadLibraryItem()
|
||||
} else if (action === 'delete') {
|
||||
this.deleteLibraryItem()
|
||||
} else if (action === 'sendToDevice') {
|
||||
this.sendToDevice(data)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
<widgets-podcast-type-indicator :type="episode.episodeType" />
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-200 mb-4">{{ episode.subtitle }}</p>
|
||||
<p class="text-sm text-gray-200 mb-4 episode-subtitle-long" v-html="episode.subtitle || episode.description" />
|
||||
|
||||
<div class="flex items-center">
|
||||
<button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="episode.progress && episode.progress.isFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick(episode)">
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<div class="flex items-center">
|
||||
<a :href="podcast.pageUrl" class="text-base md:text-lg text-gray-200 hover:underline" target="_blank" @click.stop>{{ podcast.title }}</a>
|
||||
<widgets-explicit-indicator :explicit="podcast.explicit" />
|
||||
<widgets-already-in-library-indicator :already-in-library="podcast.alreadyInLibrary"/>
|
||||
<widgets-already-in-library-indicator :already-in-library="podcast.alreadyInLibrary" />
|
||||
</div>
|
||||
<p class="text-sm md:text-base text-gray-300 whitespace-nowrap truncate">by {{ podcast.artistName }}</p>
|
||||
<p class="text-xs text-gray-400 leading-5">{{ podcast.genres.join(', ') }}</p>
|
||||
@@ -146,11 +146,15 @@ export default {
|
||||
async submitSearch(term) {
|
||||
this.processing = true
|
||||
this.termSearched = ''
|
||||
var results = await this.$axios.$get(`/api/search/podcast?term=${encodeURIComponent(term)}`).catch((error) => {
|
||||
let results = await this.$axios.$get(`/api/search/podcast?term=${encodeURIComponent(term)}`).catch((error) => {
|
||||
console.error('Search request failed', error)
|
||||
return []
|
||||
})
|
||||
console.log('Got results', results)
|
||||
|
||||
// Filter out podcasts without an RSS feed
|
||||
results = results.filter((r) => r.feedUrl)
|
||||
|
||||
for (let result of results) {
|
||||
let podcast = this.existentPodcasts.find((p) => p.itunesId === result.id || p.title === result.title.toLowerCase())
|
||||
if (podcast) {
|
||||
@@ -164,7 +168,7 @@ export default {
|
||||
},
|
||||
async selectPodcast(podcast) {
|
||||
console.log('Selected podcast', podcast)
|
||||
if(podcast.existentId){
|
||||
if (podcast.existentId) {
|
||||
this.$router.push(`/item/${podcast.existentId}`)
|
||||
return
|
||||
}
|
||||
@@ -173,7 +177,7 @@ export default {
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
var payload = await this.$axios.$post(`/api/podcasts/feed`, {rssFeed: podcast.feedUrl}).catch((error) => {
|
||||
const payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: podcast.feedUrl }).catch((error) => {
|
||||
console.error('Failed to get feed', error)
|
||||
this.$toast.error('Failed to get podcast feed')
|
||||
return null
|
||||
|
||||
@@ -19,7 +19,7 @@ export default {
|
||||
return redirect(`/library/${libraryId}`)
|
||||
}
|
||||
|
||||
const series = await app.$axios.$get(`/api/series/${params.id}?include=progress,rssfeed`).catch((error) => {
|
||||
const series = await app.$axios.$get(`/api/libraries/${library.id}/series/${params.id}?include=progress,rssfeed`).catch((error) => {
|
||||
console.error('Failed', error)
|
||||
return false
|
||||
})
|
||||
|
||||
@@ -74,9 +74,17 @@ export default {
|
||||
} else {
|
||||
this.$router.replace('/oops?message=No libraries available')
|
||||
}
|
||||
} else if (this.$route.query.redirect) {
|
||||
this.$router.replace(this.$route.query.redirect)
|
||||
} else {
|
||||
if (this.$route.query.redirect) {
|
||||
const isAdminUser = this.$store.getters['user/getIsAdminOrUp']
|
||||
const redirect = this.$route.query.redirect
|
||||
// If not admin user then do not redirect to config pages other than your stats
|
||||
if (isAdminUser || !redirect.startsWith('/config/') || redirect === '/config/stats') {
|
||||
this.$router.replace(redirect)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
this.$router.replace(`/library/${this.$store.state.libraries.currentLibraryId}`)
|
||||
}
|
||||
}
|
||||
@@ -107,7 +115,7 @@ export default {
|
||||
const payload = {
|
||||
newRoot: { ...this.newRoot }
|
||||
}
|
||||
var success = await this.$axios
|
||||
const success = await this.$axios
|
||||
.$post('/init', payload)
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
@@ -124,9 +132,10 @@ export default {
|
||||
|
||||
location.reload()
|
||||
},
|
||||
setUser({ user, userDefaultLibraryId, serverSettings, Source, feeds }) {
|
||||
setUser({ user, userDefaultLibraryId, serverSettings, Source, ereaderDevices }) {
|
||||
this.$store.commit('setServerSettings', serverSettings)
|
||||
this.$store.commit('setSource', Source)
|
||||
this.$store.commit('libraries/setEReaderDevices', ereaderDevices)
|
||||
this.$setServerLanguageCode(serverSettings.language)
|
||||
|
||||
if (serverSettings.chromecastEnabled) {
|
||||
@@ -143,17 +152,17 @@ export default {
|
||||
this.error = null
|
||||
this.processing = true
|
||||
|
||||
var payload = {
|
||||
const payload = {
|
||||
username: this.username,
|
||||
password: this.password || ''
|
||||
}
|
||||
var authRes = await this.$axios.$post('/login', payload).catch((error) => {
|
||||
const authRes = await this.$axios.$post('/login', payload).catch((error) => {
|
||||
console.error('Failed', error.response)
|
||||
if (error.response) this.error = error.response.data
|
||||
else this.error = 'Unknown Error'
|
||||
return false
|
||||
})
|
||||
if (authRes && authRes.error) {
|
||||
if (authRes?.error) {
|
||||
this.error = authRes.error
|
||||
} else if (authRes) {
|
||||
this.setUser(authRes)
|
||||
@@ -161,7 +170,7 @@ export default {
|
||||
this.processing = false
|
||||
},
|
||||
checkAuth() {
|
||||
var token = localStorage.getItem('token')
|
||||
const token = localStorage.getItem('token')
|
||||
if (!token) return false
|
||||
|
||||
this.processing = true
|
||||
|
||||
@@ -78,6 +78,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
import uploadHelpers from '@/mixins/uploadHelpers'
|
||||
|
||||
export default {
|
||||
@@ -243,7 +244,7 @@ export default {
|
||||
ref.setUploadStatus(status)
|
||||
}
|
||||
},
|
||||
uploadItem(item) {
|
||||
async uploadItem(item) {
|
||||
var form = new FormData()
|
||||
form.set('title', item.title)
|
||||
if (!this.selectedLibraryIsPodcast) {
|
||||
@@ -294,18 +295,41 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
var items = this.validateItems()
|
||||
const items = this.validateItems()
|
||||
if (!items) {
|
||||
this.$toast.error('Some invalid items')
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
var itemsUploaded = 0
|
||||
var itemsFailed = 0
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
var item = items[i]
|
||||
|
||||
const itemsToUpload = []
|
||||
|
||||
// Check if path already exists before starting upload
|
||||
// uploading fails if path already exists
|
||||
for (const item of items) {
|
||||
const filepath = Path.join(this.selectedFolder.fullPath, item.directory)
|
||||
const exists = await this.$axios
|
||||
.$post(`/api/filesystem/pathexists`, { filepath })
|
||||
.then((data) => {
|
||||
if (data.exists) {
|
||||
this.$toast.error(`Filepath "${filepath}" already exists on server`)
|
||||
}
|
||||
return data.exists
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to check if filepath exists', error)
|
||||
return false
|
||||
})
|
||||
if (!exists) {
|
||||
itemsToUpload.push(item)
|
||||
}
|
||||
}
|
||||
|
||||
let itemsUploaded = 0
|
||||
let itemsFailed = 0
|
||||
for (const item of itemsToUpload) {
|
||||
this.updateItemCardStatus(item.index, 'uploading')
|
||||
var result = await this.uploadItem(item)
|
||||
const result = await this.uploadItem(item)
|
||||
if (result) itemsUploaded++
|
||||
else itemsFailed++
|
||||
this.updateItemCardStatus(item.index, result ? 'success' : 'failed')
|
||||
|
||||
@@ -24,7 +24,6 @@ export default class PlayerHandler {
|
||||
|
||||
this.failedProgressSyncs = 0
|
||||
this.lastSyncTime = 0
|
||||
this.lastSyncedAt = 0
|
||||
this.listeningTimeSinceSync = 0
|
||||
|
||||
this.playInterval = null
|
||||
@@ -53,6 +52,11 @@ export default class PlayerHandler {
|
||||
return this.libraryItem.media.episodes.find(ep => ep.id === this.episodeId)
|
||||
}
|
||||
|
||||
setSessionId(sessionId) {
|
||||
this.currentSessionId = sessionId
|
||||
this.ctx.$store.commit('setPlaybackSessionId', sessionId)
|
||||
}
|
||||
|
||||
load(libraryItem, episodeId, playWhenReady, playbackRate, startTimeOverride = undefined) {
|
||||
this.libraryItem = libraryItem
|
||||
this.isVideo = libraryItem.mediaType === 'video'
|
||||
@@ -183,10 +187,11 @@ export default class PlayerHandler {
|
||||
}
|
||||
|
||||
async prepare(forceTranscode = false) {
|
||||
this.currentSessionId = null // Reset session
|
||||
this.setSessionId(null) // Reset session
|
||||
|
||||
const payload = {
|
||||
deviceInfo: {
|
||||
clientName: 'Abs Web',
|
||||
deviceId: this.getDeviceId()
|
||||
},
|
||||
supportedMimeTypes: this.player.playableMimeTypes,
|
||||
@@ -210,6 +215,8 @@ export default class PlayerHandler {
|
||||
this.playWhenReady = false
|
||||
this.initialPlaybackRate = playbackRate
|
||||
this.startTimeOverride = undefined
|
||||
this.lastSyncTime = 0
|
||||
this.listeningTimeSinceSync = 0
|
||||
|
||||
this.prepareSession(session)
|
||||
}
|
||||
@@ -217,7 +224,7 @@ export default class PlayerHandler {
|
||||
prepareSession(session) {
|
||||
this.failedProgressSyncs = 0
|
||||
this.startTime = this.startTimeOverride !== undefined ? this.startTimeOverride : session.currentTime
|
||||
this.currentSessionId = session.id
|
||||
this.setSessionId(session.id)
|
||||
this.displayTitle = session.displayTitle
|
||||
this.displayAuthor = session.displayAuthor
|
||||
|
||||
@@ -262,7 +269,7 @@ export default class PlayerHandler {
|
||||
this.player = null
|
||||
this.playerState = 'IDLE'
|
||||
this.libraryItem = null
|
||||
this.currentSessionId = null
|
||||
this.setSessionId(null)
|
||||
this.startTime = 0
|
||||
this.stopPlayInterval()
|
||||
}
|
||||
@@ -275,6 +282,10 @@ export default class PlayerHandler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* First sync happens after 20 seconds
|
||||
* subsequent syncs happen every 10 seconds
|
||||
*/
|
||||
startPlayInterval() {
|
||||
clearInterval(this.playInterval)
|
||||
let lastTick = Date.now()
|
||||
@@ -287,7 +298,8 @@ export default class PlayerHandler {
|
||||
const exactTimeElapsed = ((Date.now() - lastTick) / 1000)
|
||||
lastTick = Date.now()
|
||||
this.listeningTimeSinceSync += exactTimeElapsed
|
||||
if (this.listeningTimeSinceSync >= 5) {
|
||||
const TimeToWaitBeforeSync = this.lastSyncTime > 0 ? 10 : 20
|
||||
if (this.listeningTimeSinceSync >= TimeToWaitBeforeSync) {
|
||||
this.sendProgressSync(currentTime)
|
||||
}
|
||||
}, 1000)
|
||||
@@ -297,14 +309,18 @@ export default class PlayerHandler {
|
||||
let syncData = null
|
||||
if (this.player) {
|
||||
const listeningTimeToAdd = Math.max(0, Math.floor(this.listeningTimeSinceSync))
|
||||
syncData = {
|
||||
timeListened: listeningTimeToAdd,
|
||||
duration: this.getDuration(),
|
||||
currentTime: this.getCurrentTime()
|
||||
// When opening player and quickly closing dont save progress
|
||||
if (listeningTimeToAdd > 20) {
|
||||
syncData = {
|
||||
timeListened: listeningTimeToAdd,
|
||||
duration: this.getDuration(),
|
||||
currentTime: this.getCurrentTime()
|
||||
}
|
||||
}
|
||||
}
|
||||
this.listeningTimeSinceSync = 0
|
||||
return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 1000 }).catch((error) => {
|
||||
this.lastSyncTime = 0
|
||||
return this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/close`, syncData, { timeout: 6000 }).catch((error) => {
|
||||
console.error('Failed to close session', error)
|
||||
})
|
||||
}
|
||||
@@ -322,13 +338,15 @@ export default class PlayerHandler {
|
||||
duration: this.getDuration(),
|
||||
currentTime
|
||||
}
|
||||
|
||||
this.listeningTimeSinceSync = 0
|
||||
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 3000 }).then(() => {
|
||||
this.ctx.$axios.$post(`/api/session/${this.currentSessionId}/sync`, syncData, { timeout: 9000 }).then(() => {
|
||||
this.failedProgressSyncs = 0
|
||||
}).catch((error) => {
|
||||
console.error('Failed to update session progress', error)
|
||||
// After 4 failed sync attempts show an alert toast
|
||||
this.failedProgressSyncs++
|
||||
if (this.failedProgressSyncs >= 2) {
|
||||
if (this.failedProgressSyncs >= 4) {
|
||||
this.ctx.showFailedProgressSyncs()
|
||||
this.failedProgressSyncs = 0
|
||||
}
|
||||
@@ -383,13 +401,13 @@ export default class PlayerHandler {
|
||||
this.player.setPlaybackRate(playbackRate)
|
||||
}
|
||||
|
||||
seek(time) {
|
||||
seek(time, shouldSync = true) {
|
||||
if (!this.player) return
|
||||
this.player.seek(time, this.playerPlaying)
|
||||
this.ctx.setCurrentTime(time)
|
||||
|
||||
// Update progress if paused
|
||||
if (!this.playerPlaying) {
|
||||
if (!this.playerPlaying && shouldSync) {
|
||||
this.sendProgressSync(time)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ export default function ({ $axios, store, $config }) {
|
||||
if (config.url.startsWith('http:') || config.url.startsWith('https:')) {
|
||||
return
|
||||
}
|
||||
var bearerToken = store.state.user.user ? store.state.user.user.token : null
|
||||
const bearerToken = store.state.user.user?.token || null
|
||||
if (bearerToken) {
|
||||
config.headers.common['Authorization'] = `Bearer ${bearerToken}`
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
const SupportedFileTypes = {
|
||||
image: ['png', 'jpg', 'jpeg', 'webp'],
|
||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb'],
|
||||
audio: ['m4b', 'mp3', 'm4a', 'flac', 'opus', 'ogg', 'oga', 'mp4', 'aac', 'wma', 'aiff', 'wav', 'webm', 'webma', 'mka', 'awb', 'caf'],
|
||||
ebook: ['epub', 'pdf', 'mobi', 'azw3', 'cbr', 'cbz'],
|
||||
info: ['nfo'],
|
||||
text: ['txt'],
|
||||
metadata: ['opf', 'abs']
|
||||
metadata: ['opf', 'abs', 'xml', 'json']
|
||||
}
|
||||
|
||||
const DownloadStatus = {
|
||||
|
||||
@@ -34,7 +34,7 @@ Vue.prototype.$strings = { ...enUsStrings }
|
||||
|
||||
Vue.prototype.$getString = (key, subs) => {
|
||||
if (!Vue.prototype.$strings[key]) return ''
|
||||
if (subs && Array.isArray(subs) && subs.length) {
|
||||
if (subs?.length && Array.isArray(subs)) {
|
||||
return supplant(Vue.prototype.$strings[key], subs)
|
||||
}
|
||||
return Vue.prototype.$strings[key]
|
||||
|
||||
@@ -24,20 +24,20 @@ Vue.prototype.$formatJsDate = (jsdate, fnsFormat = 'MM/dd/yyyy HH:mm') => {
|
||||
return format(jsdate, fnsFormat)
|
||||
}
|
||||
Vue.prototype.$formatTime = (unixms, fnsFormat = 'HH:mm') => {
|
||||
if (!unixms) return ''
|
||||
return format(unixms, fnsFormat)
|
||||
if (!unixms) return ''
|
||||
return format(unixms, fnsFormat)
|
||||
}
|
||||
Vue.prototype.$formatJsTime = (jsdate, fnsFormat = 'HH:mm') => {
|
||||
if (!jsdate || !isDate(jsdate)) return ''
|
||||
return format(jsdate, fnsFormat)
|
||||
if (!jsdate || !isDate(jsdate)) return ''
|
||||
return format(jsdate, fnsFormat)
|
||||
}
|
||||
Vue.prototype.$formatDatetime = (unixms, fnsDateFormart = 'MM/dd/yyyy', fnsTimeFormat = 'HH:mm') => {
|
||||
if (!unixms) return ''
|
||||
return format(unixms, `${fnsDateFormart} ${fnsTimeFormat}`)
|
||||
if (!unixms) return ''
|
||||
return format(unixms, `${fnsDateFormart} ${fnsTimeFormat}`)
|
||||
}
|
||||
Vue.prototype.$formatJsDatetime = (jsdate, fnsDateFormart = 'MM/dd/yyyy', fnsTimeFormat = 'HH:mm') => {
|
||||
if (!jsdate || !isDate(jsdate)) return ''
|
||||
return format(jsdate, `${fnsDateFormart} ${fnsTimeFormat}`)
|
||||
if (!jsdate || !isDate(jsdate)) return ''
|
||||
return format(jsdate, `${fnsDateFormart} ${fnsTimeFormat}`)
|
||||
}
|
||||
Vue.prototype.$addDaysToToday = (daysToAdd) => {
|
||||
var date = addDays(new Date(), daysToAdd)
|
||||
@@ -132,8 +132,10 @@ Vue.prototype.$copyToClipboard = (str, ctx) => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(str).then(() => {
|
||||
if (ctx) ctx.$toast.success('Copied to clipboard')
|
||||
resolve(true)
|
||||
}, (err) => {
|
||||
console.error('Clipboard copy failed', str, err)
|
||||
resolve(false)
|
||||
})
|
||||
} else {
|
||||
const el = document.createElement('textarea')
|
||||
@@ -147,6 +149,7 @@ Vue.prototype.$copyToClipboard = (str, ctx) => {
|
||||
document.body.removeChild(el)
|
||||
|
||||
if (ctx) ctx.$toast.success('Copied to clipboard')
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -145,6 +145,25 @@ Vue.prototype.$getNextScheduledDate = (expression) => {
|
||||
return interval.next().toDate()
|
||||
}
|
||||
|
||||
Vue.prototype.$downloadFile = (url, filename = null, openInNewTab = false) => {
|
||||
const a = document.createElement('a')
|
||||
a.style.display = 'none'
|
||||
a.href = url
|
||||
|
||||
if (filename) {
|
||||
a.download = filename
|
||||
}
|
||||
if (openInNewTab) {
|
||||
a.target = '_blank'
|
||||
}
|
||||
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
setTimeout(() => {
|
||||
a.remove()
|
||||
})
|
||||
}
|
||||
|
||||
export function supplant(str, subs) {
|
||||
// source: http://crockford.com/javascript/remedial.html
|
||||
return str.replace(/{([^{}]*)}/g,
|
||||
|
||||
@@ -83,8 +83,8 @@ export const getters = {
|
||||
getLibraryItemCoverSrc: (state, getters, rootState, rootGetters) => (libraryItem, placeholder = null) => {
|
||||
if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
|
||||
if (!libraryItem) return placeholder
|
||||
var media = libraryItem.media
|
||||
if (!media || !media.coverPath || media.coverPath === placeholder) return placeholder
|
||||
const media = libraryItem.media
|
||||
if (!media?.coverPath || media.coverPath === placeholder) return placeholder
|
||||
|
||||
// Absolute URL covers (should no longer be used)
|
||||
if (media.coverPath.startsWith('http:') || media.coverPath.startsWith('https:')) return media.coverPath
|
||||
@@ -99,14 +99,14 @@ export const getters = {
|
||||
|
||||
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||
},
|
||||
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = null, raw = false) => {
|
||||
if (!placeholder) placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
|
||||
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, timestamp = null, raw = false) => {
|
||||
const placeholder = `${rootState.routerBasePath}/book_placeholder.jpg`
|
||||
if (!libraryItemId) return placeholder
|
||||
var userToken = rootGetters['user/getToken']
|
||||
const userToken = rootGetters['user/getToken']
|
||||
if (process.env.NODE_ENV !== 'production') { // Testing
|
||||
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}`
|
||||
return `http://localhost:3333${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}`
|
||||
}
|
||||
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}`
|
||||
return `${rootState.routerBasePath}/api/items/${libraryItemId}/cover?token=${userToken}${raw ? '&raw=1' : ''}${timestamp ? `&ts=${timestamp}` : ''}`
|
||||
},
|
||||
getIsBatchSelectingMediaItems: (state) => {
|
||||
return state.selectedMediaItems.length
|
||||
|
||||
@@ -6,6 +6,7 @@ export const state = () => ({
|
||||
Source: null,
|
||||
versionData: null,
|
||||
serverSettings: null,
|
||||
playbackSessionId: null,
|
||||
streamLibraryItem: null,
|
||||
streamEpisodeId: null,
|
||||
streamIsPlaying: false,
|
||||
@@ -16,11 +17,12 @@ export const state = () => ({
|
||||
editPodcastModalTab: 'details',
|
||||
showEditModal: false,
|
||||
showEReader: false,
|
||||
ereaderKeepProgress: false,
|
||||
ereaderFileId: null,
|
||||
selectedLibraryItem: null,
|
||||
developerMode: false,
|
||||
processingBatch: false,
|
||||
previousPath: '/',
|
||||
showExperimentalFeatures: false,
|
||||
bookshelfBookIds: [],
|
||||
episodeTableEpisodeIds: [],
|
||||
openModal: null,
|
||||
@@ -35,7 +37,7 @@ export const getters = {
|
||||
return state.serverSettings[key]
|
||||
},
|
||||
getLibraryItemIdStreaming: state => {
|
||||
return state.streamLibraryItem ? state.streamLibraryItem.id : null
|
||||
return state.streamLibraryItem?.id || null
|
||||
},
|
||||
getIsStreamingFromDifferentLibrary: (state, getters, rootState) => {
|
||||
if (!state.streamLibraryItem) return false
|
||||
@@ -150,6 +152,9 @@ export const mutations = {
|
||||
if (!settings) return
|
||||
state.serverSettings = settings
|
||||
},
|
||||
setPlaybackSessionId(state, playbackSessionId) {
|
||||
state.playbackSessionId = playbackSessionId
|
||||
},
|
||||
setMediaPlaying(state, payload) {
|
||||
if (!payload) {
|
||||
state.streamLibraryItem = null
|
||||
@@ -206,8 +211,10 @@ export const mutations = {
|
||||
setEditPodcastModalTab(state, tab) {
|
||||
state.editPodcastModalTab = tab
|
||||
},
|
||||
showEReader(state, libraryItem) {
|
||||
showEReader(state, { libraryItem, keepProgress, fileId }) {
|
||||
state.selectedLibraryItem = libraryItem
|
||||
state.ereaderKeepProgress = keepProgress
|
||||
state.ereaderFileId = fileId
|
||||
|
||||
state.showEReader = true
|
||||
},
|
||||
@@ -223,10 +230,6 @@ export const mutations = {
|
||||
setProcessingBatch(state, val) {
|
||||
state.processingBatch = val
|
||||
},
|
||||
setExperimentalFeatures(state, val) {
|
||||
state.showExperimentalFeatures = val
|
||||
localStorage.setItem('experimental', val ? 1 : 0)
|
||||
},
|
||||
setOpenModal(state, val) {
|
||||
state.openModal = val
|
||||
},
|
||||
|
||||
@@ -11,7 +11,8 @@ export const state = () => ({
|
||||
filterData: null,
|
||||
numUserPlaylists: 0,
|
||||
collections: [],
|
||||
userPlaylists: []
|
||||
userPlaylists: [],
|
||||
ereaderDevices: []
|
||||
})
|
||||
|
||||
export const getters = {
|
||||
@@ -56,6 +57,9 @@ export const getters = {
|
||||
if (!getters.getCurrentLibrarySettings || isNaN(getters.getCurrentLibrarySettings.coverAspectRatio)) return 1
|
||||
return getters.getCurrentLibrarySettings.coverAspectRatio === Constants.BookCoverAspectRatio.STANDARD ? 1.6 : 1
|
||||
},
|
||||
getLibraryIsAudiobooksOnly: (state, getters) => {
|
||||
return !!getters.getCurrentLibrarySettings?.audiobooksOnly
|
||||
},
|
||||
getCollection: state => id => {
|
||||
return state.collections.find(c => c.id === id)
|
||||
},
|
||||
@@ -234,21 +238,23 @@ export const mutations = {
|
||||
if (!libraryItem || !state.filterData) return
|
||||
if (state.currentLibraryId !== libraryItem.libraryId) return
|
||||
/*
|
||||
var data = {
|
||||
structure of filterData:
|
||||
{
|
||||
authors: [],
|
||||
genres: [],
|
||||
tags: [],
|
||||
series: [],
|
||||
narrators: [],
|
||||
languages: []
|
||||
languages: [],
|
||||
publishers: []
|
||||
}
|
||||
*/
|
||||
var mediaMetadata = libraryItem.media.metadata
|
||||
const mediaMetadata = libraryItem.media.metadata
|
||||
|
||||
// Add/update book authors
|
||||
if (mediaMetadata.authors && mediaMetadata.authors.length) {
|
||||
if (mediaMetadata.authors?.length) {
|
||||
mediaMetadata.authors.forEach((author) => {
|
||||
var indexOf = state.filterData.authors.findIndex(au => au.id === author.id)
|
||||
const indexOf = state.filterData.authors.findIndex(au => au.id === author.id)
|
||||
if (indexOf >= 0) {
|
||||
state.filterData.authors.splice(indexOf, 1, author)
|
||||
} else {
|
||||
@@ -259,9 +265,9 @@ export const mutations = {
|
||||
}
|
||||
|
||||
// Add/update series
|
||||
if (mediaMetadata.series && mediaMetadata.series.length) {
|
||||
if (mediaMetadata.series?.length) {
|
||||
mediaMetadata.series.forEach((series) => {
|
||||
var indexOf = state.filterData.series.findIndex(se => se.id === series.id)
|
||||
const indexOf = state.filterData.series.findIndex(se => se.id === series.id)
|
||||
if (indexOf >= 0) {
|
||||
state.filterData.series.splice(indexOf, 1, { id: series.id, name: series.name })
|
||||
} else {
|
||||
@@ -272,7 +278,7 @@ export const mutations = {
|
||||
}
|
||||
|
||||
// Add genres
|
||||
if (mediaMetadata.genres && mediaMetadata.genres.length) {
|
||||
if (mediaMetadata.genres?.length) {
|
||||
mediaMetadata.genres.forEach((genre) => {
|
||||
if (!state.filterData.genres.includes(genre)) {
|
||||
state.filterData.genres.push(genre)
|
||||
@@ -282,7 +288,7 @@ export const mutations = {
|
||||
}
|
||||
|
||||
// Add tags
|
||||
if (libraryItem.media.tags && libraryItem.media.tags.length) {
|
||||
if (libraryItem.media.tags?.length) {
|
||||
libraryItem.media.tags.forEach((tag) => {
|
||||
if (!state.filterData.tags.includes(tag)) {
|
||||
state.filterData.tags.push(tag)
|
||||
@@ -292,7 +298,7 @@ export const mutations = {
|
||||
}
|
||||
|
||||
// Add narrators
|
||||
if (mediaMetadata.narrators && mediaMetadata.narrators.length) {
|
||||
if (mediaMetadata.narrators?.length) {
|
||||
mediaMetadata.narrators.forEach((narrator) => {
|
||||
if (!state.filterData.narrators.includes(narrator)) {
|
||||
state.filterData.narrators.push(narrator)
|
||||
@@ -301,12 +307,16 @@ export const mutations = {
|
||||
})
|
||||
}
|
||||
|
||||
// Add publishers
|
||||
if (mediaMetadata.publisher && !state.filterData.publishers.includes(mediaMetadata.publisher)) {
|
||||
state.filterData.publishers.push(mediaMetadata.publisher)
|
||||
state.filterData.publishers.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
|
||||
// Add language
|
||||
if (mediaMetadata.language) {
|
||||
if (!state.filterData.languages.includes(mediaMetadata.language)) {
|
||||
state.filterData.languages.push(mediaMetadata.language)
|
||||
state.filterData.languages.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
if (mediaMetadata.language && !state.filterData.languages.includes(mediaMetadata.language)) {
|
||||
state.filterData.languages.push(mediaMetadata.language)
|
||||
state.filterData.languages.sort((a, b) => a.localeCompare(b))
|
||||
}
|
||||
},
|
||||
setCollections(state, collections) {
|
||||
@@ -339,5 +349,8 @@ export const mutations = {
|
||||
removeUserPlaylist(state, playlist) {
|
||||
state.userPlaylists = state.userPlaylists.filter(p => p.id !== playlist.id)
|
||||
state.numUserPlaylists = state.userPlaylists.length
|
||||
},
|
||||
setEReaderDevices(state, ereaderDevices) {
|
||||
state.ereaderDevices = ereaderDevices
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user