Merge branch 'advplyr:master' into social

This commit is contained in:
Ben 2022-09-03 14:24:18 -04:00 committed by GitHub
commit 2a5ac8f094
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
65 changed files with 3170 additions and 374 deletions

View file

@ -3,7 +3,7 @@
<div id="appbar" class="absolute top-0 bottom-0 left-0 w-full h-full px-2 md:px-6 py-1 z-50">
<div class="flex h-full items-center">
<nuxt-link to="/">
<img src="/icon.svg" class="w-10 min-w-10 h-10 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
<img src="/icon.svg" class="w-8 min-w-8 h-8 mr-2 sm:w-12 sm:min-w-12 sm:h-12 sm:mr-4" />
</nuxt-link>
<nuxt-link to="/">
@ -12,10 +12,10 @@
<ui-libraries-dropdown class="mr-2" />
<controls-global-search v-if="currentLibrary" class="" />
<controls-global-search v-if="currentLibrary" class="mr-1 sm:mr-0" />
<div class="flex-grow" />
<span v-if="showExperimentalFeatures" class="material-icons text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
<span v-if="showExperimentalFeatures" class="material-icons text-2xl md:text-4xl text-warning pr-0 sm:pr-2 md:pr-4">logo_dev</span>
<ui-tooltip v-if="isChromecastInitialized && !isHttps" direction="bottom" text="Casting requires a secure connection" class="flex items-center">
<span class="material-icons-outlined text-warning text-opacity-50"> cast </span>
@ -158,7 +158,7 @@ export default {
var newIsFinished = !this.selectedIsFinished
var updateProgressPayloads = this.selectedLibraryItems.map((lid) => {
return {
id: lid,
libraryItemId: lid,
isFinished: newIsFinished
}
})

View file

@ -17,6 +17,11 @@
<cards-lazy-series-card :key="entity.name" :series-mount="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
</template>
</div>
<div v-if="shelf.type === 'tags'" class="flex items-center">
<template v-for="entity in shelf.entities">
<cards-group-card :key="entity.name" :group="entity" :height="bookCoverHeight" :width="bookCoverWidth * 2" :book-cover-aspect-ratio="bookCoverAspectRatio" class="relative mx-2" @hook:updated="updatedBookCard" />
</template>
</div>
<div v-if="shelf.type === 'authors'" class="flex items-center">
<template v-for="entity in shelf.entities">
<cards-author-card :key="entity.id" :width="bookCoverWidth / 1.25" :height="bookCoverWidth" :author="entity" :size-multiplier="sizeMultiplier" @hook:updated="updatedBookCard" class="pb-6 mx-2" @edit="editAuthor" />

View file

@ -89,7 +89,7 @@ export default {
authors: {
type: Array,
default: () => []
}
},
},
data() {
return {
@ -215,6 +215,7 @@ export default {
this.$toast.success('Removed library items with issues')
this.$router.push(`/library/${this.currentLibraryId}/bookshelf`)
this.processingIssues = false
this.$store.dispatch('libraries/fetch', this.currentLibraryId)
})
.catch((error) => {
console.error('Failed to remove library items with issues', error)
@ -228,7 +229,7 @@ export default {
this.processingSeries = true
var updateProgressPayloads = this.seriesLibraryItemIds.map((lid) => {
return {
id: lid,
libraryItemId: lid,
isFinished: newIsFinished
}
})
@ -302,4 +303,4 @@ export default {
#toolbar {
box-shadow: 0px 8px 6px #111111aa;
}
</style>
</style>

View file

@ -44,11 +44,14 @@
@close="closePlayer"
@showBookmarks="showBookmarks"
@showSleepTimer="showSleepTimerModal = true"
@showPlayerQueueItems="showPlayerQueueItemsModal = true"
/>
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="bookmarkCurrentTime" :library-item-id="libraryItemId" @select="selectBookmark" />
<modals-sleep-timer-modal v-model="showSleepTimerModal" :timer-set="sleepTimerSet" :timer-time="sleepTimerTime" :remaining="sleepTimerRemaining" @set="setSleepTimer" @cancel="cancelSleepTimer" @increment="incrementSleepTimer" @decrement="decrementSleepTimer" />
<modals-player-queue-items-modal v-model="showPlayerQueueItemsModal" :library-item-id="libraryItemId" />
</div>
</template>
@ -66,6 +69,7 @@ export default {
isPlaying: false,
currentTime: 0,
showSleepTimerModal: false,
showPlayerQueueItemsModal: false,
sleepTimerSet: false,
sleepTimerTime: 0,
sleepTimerRemaining: 0,
@ -138,9 +142,39 @@ export default {
podcastAuthor() {
if (!this.isPodcast) return null
return this.mediaMetadata.author || 'Unknown'
},
playerQueueItems() {
return this.$store.state.playerQueueItems || []
}
},
methods: {
mediaFinished(libraryItemId, episodeId) {
// Play next item in queue
if (!this.playerQueueItems.length || !this.$store.state.playerQueueAutoPlay) {
// TODO: Set media finished flag so play button will play next queue item
return
}
var currentQueueIndex = this.playerQueueItems.findIndex((i) => {
if (episodeId) return i.libraryItemId === libraryItemId && i.episodeId === episodeId
return i.libraryItemId === libraryItemId
})
if (currentQueueIndex < 0) {
console.error('Media finished not found in queue', this.playerQueueItems)
return
}
if (currentQueueIndex === this.playerQueueItems.length - 1) {
console.log('Finished last item in queue')
return
}
const nextItemInQueue = this.playerQueueItems[currentQueueIndex + 1]
if (nextItemInQueue) {
this.playLibraryItem({
libraryItemId: nextItemInQueue.libraryItemId,
episodeId: nextItemInQueue.episodeId || null,
queueItems: this.playerQueueItems
})
}
},
setPlaying(isPlaying) {
this.isPlaying = isPlaying
this.$store.commit('setIsPlaying', isPlaying)
@ -313,6 +347,7 @@ export default {
}
},
sessionOpen(session) {
// For opening session on init (temporarily unused)
this.$store.commit('setMediaPlaying', {
libraryItem: session.libraryItem,
episodeId: session.episodeId
@ -376,7 +411,8 @@ export default {
if (!libraryItem) return
this.$store.commit('setMediaPlaying', {
libraryItem,
episodeId
episodeId,
queueItems: payload.queueItems || []
})
this.$nextTick(() => {
if (this.$refs.audioPlayer) this.$refs.audioPlayer.checkUpdateChapterTrack()

View file

@ -1,26 +1,18 @@
<template>
<div class="relative">
<div class="rounded-sm h-full relative" :style="{ padding: `0px ${paddingX}px` }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
<div class="rounded-sm h-full relative" :style="{ width: width + 'px', height: height + 'px' }" @mouseover="mouseoverCard" @mouseleave="mouseleaveCard" @click="clickCard">
<nuxt-link :to="groupTo" class="cursor-pointer">
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'" :style="{ height: coverHeight + 'px', width: coverWidth + 'px' }">
<covers-group-cover ref="groupcover" :id="seriesId" :name="groupName" :group-to="groupTo" :type="groupType" :book-items="bookItems" :width="coverWidth" :height="coverHeight" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div class="w-full h-full relative" :class="isHovering ? 'bg-black-400' : 'bg-primary'">
<covers-group-cover ref="groupcover" :id="groupEncode" :name="groupName" :type="groupType" :book-items="bookItems" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
<div v-if="hasValidCovers" class="bg-black bg-opacity-60 absolute top-0 left-0 w-full h-full flex items-center justify-center text-center transition-opacity z-30" :class="isHovering ? '' : 'opacity-0'" :style="{ padding: `${sizeMultiplier}rem` }">
<p class="font-book" :style="{ fontSize: sizeMultiplier + 'rem' }">{{ groupName }}</p>
<p class="font-book" :style="{ fontSize: 1.2 * sizeMultiplier + 'rem' }">{{ groupName }}</p>
</div>
<div class="absolute top-2 right-2 w-7 h-7 rounded-lg bg-black bg-opacity-90 text-gray-300 box-shadow-book flex items-center justify-center border border-white border-opacity-25 pointer-events-none z-40">
<p class="font-book text-xl">{{ bookItems.length }}</p>
</div>
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ bookItems.length }}</div>
</div>
</nuxt-link>
</div>
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto bottom-0 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, coverWidth) + 'px' }">
<div class="w-full h-full shinyBlack flex items-center justify-center rounded-sm border" :style="{ padding: `0rem ${1 * sizeMultiplier}rem` }">
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ groupName }}</p>
</div>
</div>
</div>
</template>
@ -31,11 +23,8 @@ export default {
type: Object,
default: () => null
},
width: {
type: Number,
default: 120
},
isCategorized: Boolean,
width: Number,
height: Number,
bookCoverAspectRatio: Number
},
data() {
@ -43,23 +32,7 @@ export default {
isHovering: false
}
},
watch: {
width(newVal) {
this.$nextTick(() => {
if (this.$refs.groupcover) {
this.$refs.groupcover.init()
}
})
}
},
computed: {
seriesId() {
return this.groupEncode
},
labelFontSize() {
if (this.coverWidth < 160) return 0.75
return 0.875
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
@ -70,29 +43,11 @@ export default {
return this._group.type
},
groupTo() {
if (this.groupType === 'series') {
return `/library/${this.currentLibraryId}/series/${this._group.id}`
} else if (this.groupType === 'collection') {
return `/collection/${this._group.id}`
} else {
return `/library/${this.currentLibraryId}/bookshelf?filter=tags.${this.groupEncode}`
}
},
squareAspectRatio() {
return this.bookCoverAspectRatio === 1
},
coverWidth() {
return this.width * 2
},
coverHeight() {
return this.width * this.bookCoverAspectRatio
return `/library/${this.currentLibraryId}/bookshelf?filter=${this.filter}`
},
sizeMultiplier() {
var baseSize = this.squareAspectRatio ? 192 : 120
return this.width / baseSize
},
paddingX() {
return 16 * this.sizeMultiplier
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
return this.width / 240
},
bookItems() {
return this._group.books || []

View file

@ -39,7 +39,7 @@
<div v-if="!booksInSeries && (!isPodcast || episodeProgress)" class="absolute bottom-0 left-0 h-1 shadow-sm max-w-full z-10 rounded-b" :class="itemIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div>
<!-- Overlay is not shown if collapsing series in library -->
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen)" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
<div v-show="!booksInSeries && libraryItem && (isHovering || isSelectionMode || isMoreMenuOpen) && !processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black rounded hidden md:block" :class="overlayWrapperClasslist">
<div v-show="showPlayButton" class="h-full flex items-center justify-center pointer-events-none">
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto" @click.stop.prevent="play">
<span class="material-icons" :style="{ fontSize: playIconFontSize + 'rem' }">play_circle_filled</span>
@ -66,6 +66,11 @@
</div>
</div>
<!-- Processing/loading spinner overlay -->
<div v-if="processing" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 rounded flex items-center justify-center">
<widgets-loading-spinner size="la-lg" />
</div>
<!-- Series name overlay -->
<div v-if="booksInSeries && libraryItem && isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-60 rounded flex items-center justify-center" :style="{ padding: sizeMultiplier + 'rem' }">
<p class="text-gray-200 text-center" :style="{ fontSize: 1.1 * sizeMultiplier + 'rem' }">{{ series }}</p>
@ -88,7 +93,7 @@
</div>
<!-- Podcast Episode # -->
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<div v-if="recentEpisodeNumber && !isHovering && !isSelectionMode && !processing" class="absolute rounded-lg bg-black bg-opacity-90 box-shadow-md z-10" :style="{ top: 0.375 * sizeMultiplier + 'rem', right: 0.375 * sizeMultiplier + 'rem', padding: `${0.1 * sizeMultiplier}rem ${0.25 * sizeMultiplier}rem` }">
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }">Episode #{{ recentEpisodeNumber }}</p>
</div>
@ -129,10 +134,9 @@ export default {
return {
isHovering: false,
isMoreMenuOpen: false,
isProcessingReadUpdate: false,
processing: false,
libraryItem: null,
imageReady: false,
rescanning: false,
selected: false,
isSelectionMode: false,
showCoverBg: false
@ -382,12 +386,14 @@ export default {
{
func: 'toggleFinished',
text: `Mark as ${this.itemIsFinished ? 'Not Finished' : 'Finished'}`
},
{
func: 'openCollections',
text: 'Add to Collection'
}
]
if (this.userCanUpdate) {
items.push({
func: 'openCollections',
text: 'Add to Collection'
})
}
}
if (this.userCanUpdate) {
items.push({
@ -490,6 +496,7 @@ export default {
this.libraryItem = libraryItem
},
clickCard(e) {
if (this.processing) return
if (this.isSelectionMode) {
e.stopPropagation()
e.preventDefault()
@ -526,7 +533,7 @@ export default {
var updatePayload = {
isFinished: !this.itemIsFinished
}
this.isProcessingReadUpdate = true
this.processing = true
var apiEndpoint = `/api/me/progress/${this.libraryItemId}`
if (this.recentEpisode) apiEndpoint += `/${this.recentEpisode.id}`
@ -536,12 +543,12 @@ export default {
axios
.$patch(apiEndpoint, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
this.processing = false
toast.success(`Item marked as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.processing = false
toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
})
},
@ -549,11 +556,12 @@ export default {
this.$emit('editPodcast', this.libraryItem)
},
rescan() {
this.rescanning = true
this.$axios
if (this.processing) return
const axios = this.$axios || this.$nuxt.$axios
this.processing = true
axios
.$get(`/api/items/${this.libraryItemId}/scan`)
.then((data) => {
this.rescanning = false
var result = data.result
if (!result) {
this.$toast.error(`Re-Scan Failed for "${this.title}"`)
@ -564,11 +572,12 @@ export default {
} else if (result === 'REMOVED') {
this.$toast.error(`Re-Scan complete item was removed`)
}
this.processing = false
})
.catch((error) => {
console.error('Failed to scan library item', error)
this.$toast.error('Failed to scan library item')
this.rescanning = false
this.processing = false
})
},
showEditModalFiles() {
@ -647,11 +656,50 @@ export default {
this.selected = !this.selected
this.$emit('select', this.libraryItem)
},
play() {
async play() {
var eventBus = this.$eventBus || this.$nuxt.$eventBus
const queueItems = []
// Podcast episode load queue items
if (this.recentEpisode) {
const axios = this.$axios || this.$nuxt.$axios
this.processing = true
const fullLibraryItem = await axios.$get(`/api/items/${this.libraryItemId}`).catch((err) => {
console.error('Failed to fetch library item', err)
return null
})
this.processing = false
if (fullLibraryItem && fullLibraryItem.media.episodes) {
const episodes = fullLibraryItem.media.episodes || []
// Sort from least recent to most recent
episodes.sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
const episodeIndex = episodes.findIndex((ep) => ep.id === this.recentEpisode.id)
if (episodeIndex >= 0) {
for (let i = episodeIndex; i < episodes.length; i++) {
const episode = episodes[i]
const podcastProgress = this.store.getters['user/getUserMediaProgress'](this.libraryItemId, episode.id)
if (!podcastProgress || !podcastProgress.isFinished) {
queueItems.push({
libraryItemId: this.libraryItemId,
episodeId: episode.id,
title: episode.title,
subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null
})
}
}
}
}
}
eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId,
episodeId: this.recentEpisode ? this.recentEpisode.id : null
episodeId: this.recentEpisode ? this.recentEpisode.id : null,
queueItems
})
},
mouseover() {

View file

@ -4,7 +4,7 @@
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div v-show="isHovering" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
<div v-show="isHovering && userCanUpdate" class="w-full h-full absolute top-0 left-0 z-10 bg-black bg-opacity-40 pointer-events-none">
<div class="absolute pointer-events-auto" :style="{ top: 0.5 * sizeMultiplier + 'rem', right: 0.5 * sizeMultiplier + 'rem' }" @click.stop.prevent="clickEdit">
<span class="material-icons text-xl text-white text-opacity-75 hover:text-opacity-100">edit</span>
</div>
@ -30,7 +30,12 @@ export default {
bookshelfView: {
type: Number,
default: 0
}
},
collectionMount: {
type: Object,
default: () => null
},
isTag: Boolean
},
data() {
return {
@ -64,6 +69,9 @@ export default {
isAlternativeBookshelfView() {
const constants = this.$constants || this.$nuxt.$constants
return this.bookshelfView == constants.BookshelfView.TITLES
},
userCanUpdate() {
return this.store.getters['user/getUserCanUpdate']
}
},
methods: {
@ -99,6 +107,10 @@ export default {
}
}
},
mounted() {}
mounted() {
if (this.collectionMount) {
this.setEntity(this.collectionMount)
}
}
}
</script>

View file

@ -2,7 +2,7 @@
<div ref="card" :id="`series-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="rounded-sm z-30 cursor-pointer" @mousedown.prevent @mouseup.prevent @mousemove.prevent @mouseover="mouseover" @mouseleave="mouseleave" @click="clickCard">
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
<div class="w-full h-full bg-primary relative rounded overflow-hidden z-0">
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" :group-to="seriesBooksRoute" />
<covers-group-cover v-if="series" ref="cover" :id="seriesId" :name="displayTitle" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
</div>
<div class="absolute z-10 top-1.5 right-1.5 rounded-md leading-3 text-sm p-1 font-semibold text-white flex items-center justify-center" style="background-color: #cd9d49dd">{{ books.length }}</div>

View file

@ -17,7 +17,6 @@ export default {
},
width: Number,
height: Number,
groupTo: String,
bookCoverAspectRatio: Number
},
data() {

View file

@ -18,7 +18,7 @@
</div>
</div>
<p v-if="!imageFailed" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
<p v-if="!imageFailed && showResolution" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
</div>
</template>
@ -31,7 +31,11 @@ export default {
default: 120
},
showOpenNewTab: Boolean,
bookCoverAspectRatio: Number
bookCoverAspectRatio: Number,
showResolution: {
type: Boolean,
default: true
}
},
data() {
return {

View file

@ -20,7 +20,7 @@
</div>
</div>
<div class="absolute bottom-0 left-0 right-0 w-full py-2 px-4 flex">
<ui-btn small color="error" type="button" @click.stop="removeClick">Remove</ui-btn>
<ui-btn v-if="userCanDelete" small color="error" type="button" @click.stop="removeClick">Remove</ui-btn>
<div class="flex-grow" />
<ui-btn color="success" type="submit">Save</ui-btn>
</div>
@ -85,6 +85,9 @@ export default {
},
books() {
return this.collection.books || []
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {

View file

@ -12,11 +12,11 @@
<div class="flex-grow" />
<ui-tooltip v-if="mediaType == 'book'" :disabled="!!quickMatching" :text="`(Root User Only) Populate empty book details & cover with first book result from '${libraryProvider}'. Does not overwrite details.`" direction="bottom" class="mr-2 md:mr-4">
<ui-tooltip :disabled="!!quickMatching" :text="`Populate empty ${mediaType} details & cover with first ${mediaType} result from '${libraryProvider}'. Does not overwrite details unless 'Prefer matched metadata' server setting is enabled.`" direction="bottom" class="mr-2 md:mr-4">
<ui-btn v-if="userIsAdminOrUp" :loading="quickMatching" color="bg" type="button" class="h-full" small @click.stop.prevent="quickMatch">Quick Match</ui-btn>
</ui-tooltip>
<ui-tooltip :disabled="!!libraryScan" text="(Root User Only) Rescan audiobook including metadata" direction="bottom" class="mr-2 md:mr-4">
<ui-tooltip :disabled="!!libraryScan" text="Rescan library item including metadata" direction="bottom" class="mr-2 md:mr-4">
<ui-btn v-if="userIsAdminOrUp && !isFile" :loading="rescanning" :disabled="!!libraryScan" color="bg" type="button" class="h-full" small @click.stop.prevent="rescan">Re-Scan</ui-btn>
</ui-tooltip>

View file

@ -2,8 +2,15 @@
<div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6">
<div class="w-full mb-4">
<div v-if="userIsAdminOrUp" class="flex items-end justify-end mb-4">
<!-- <p v-if="autoDownloadEpisodes">Last new episode check {{ $formatDate(lastEpisodeCheck) }}</p> -->
<ui-text-input-with-label ref="lastCheckInput" v-model="lastEpisodeCheckInput" :disabled="checkingNewEpisodes" type="datetime-local" label="Look for new episodes after this date" class="max-w-xs mr-2" />
<ui-text-input-with-label ref="maxEpisodesInput" v-model="maxEpisodesToDownload" :disabled="checkingNewEpisodes" type="number" label="Max episodes" class="w-16 mr-2" input-class="h-10">
<div class="flex -mb-0.5">
<p class="px-1 text-sm font-semibold" :class="{ 'text-gray-400': checkingNewEpisodes }">Limit</p>
<ui-tooltip direction="top" text="Max # of episodes to download. Use 0 for unlimited.">
<span class="material-icons text-base">info_outlined</span>
</ui-tooltip>
</div>
</ui-text-input-with-label>
<ui-btn :loading="checkingNewEpisodes" @click="checkForNewEpisodes">Check & Download New Episodes</ui-btn>
</div>
@ -52,7 +59,8 @@ export default {
data() {
return {
checkingNewEpisodes: false,
lastEpisodeCheckInput: null
lastEpisodeCheckInput: null,
maxEpisodesToDownload: 3
}
},
watch: {
@ -89,6 +97,16 @@ export default {
if (this.$refs.lastCheckInput) {
this.$refs.lastCheckInput.blur()
}
if (this.$refs.maxEpisodesInput) {
this.$refs.maxEpisodesInput.blur()
}
if (this.maxEpisodesToDownload < 0) {
this.maxEpisodesToDownload = 3
this.$toast.error('Invalid max episodes to download')
return
}
this.checkingNewEpisodes = true
const lastEpisodeCheck = new Date(this.lastEpisodeCheckInput).valueOf()
@ -102,7 +120,7 @@ export default {
}
this.$axios
.$get(`/api/podcasts/${this.libraryItemId}/checknew`)
.$get(`/api/podcasts/${this.libraryItemId}/checknew?limit=${this.maxEpisodesToDownload}`)
.then((response) => {
if (response.episodes && response.episodes.length) {
console.log('New episodes', response.episodes.length)

View file

@ -1,14 +1,30 @@
<template>
<div class="w-full h-full relative">
<div id="scheduleWrapper" class="w-full overflow-y-auto px-2 py-4 md:px-4 md:py-6">
<div class="flex items-center justify-between mb-4">
<p class="text-base md:text-lg font-semibold">Schedule Automatic Episode Downloads</p>
<ui-checkbox v-model="enableAutoDownloadEpisodes" label="Enable" small checkbox-bg="bg" label-class="pl-2 text-base" />
</div>
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoDownloadEpisodes" v-model="cronExpression" @input="updatedCron" />
<div id="scheduleWrapper" class="w-full overflow-y-auto px-2 py-4 md:px-6 md:py-6">
<template v-if="!feedUrl">
<widgets-alert type="warning" class="text-base mb-4">No RSS feed URL is set for this podcast</widgets-alert>
</template>
<template v-if="feedUrl || autoDownloadEpisodes">
<div class="flex items-center justify-between mb-4">
<p class="text-base md:text-xl font-semibold">Schedule Automatic Episode Downloads</p>
<ui-checkbox v-model="enableAutoDownloadEpisodes" label="Enable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
</div>
<div v-if="enableAutoDownloadEpisodes" class="flex items-center py-2">
<ui-text-input ref="maxEpisodesInput" type="number" v-model="newMaxEpisodesToKeep" no-spinner :padding-x="1" text-center class="w-10 text-base" @change="updatedMaxEpisodesToKeep" />
<ui-tooltip text="Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes. <br>This will only delete 1 episode per new download.">
<p class="pl-4 text-base">
Max episodes to keep
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</div>
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoDownloadEpisodes" v-model="cronExpression" />
</template>
</div>
<div class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white border-opacity-5">
<div v-if="feedUrl || autoDownloadEpisodes" class="absolute bottom-0 left-0 w-full py-2 md:py-4 bg-bg border-t border-white border-opacity-5">
<div class="flex items-center px-2 md:px-4">
<div class="flex-grow" />
<ui-btn @click="save" :disabled="!isUpdated" :color="isUpdated ? 'success' : 'primary'" class="mx-2">{{ isUpdated ? 'Save' : 'No update necessary' }}</ui-btn>
@ -29,7 +45,8 @@ export default {
data() {
return {
enableAutoDownloadEpisodes: false,
cronExpression: null
cronExpression: null,
newMaxEpisodesToKeep: 0
}
},
computed: {
@ -44,29 +61,38 @@ export default {
userIsAdminOrUp() {
return this.$store.getters['user/getIsAdminOrUp']
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
mediaMetadata() {
return this.media.metadata || {}
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
},
feedUrl() {
return this.mediaMetadata.feedUrl
},
autoDownloadEpisodes() {
return !!this.media.autoDownloadEpisodes
},
autoDownloadSchedule() {
return this.media.autoDownloadSchedule
},
media() {
return this.libraryItem ? this.libraryItem.media || {} : {}
},
libraryItemId() {
return this.libraryItem ? this.libraryItem.id : null
maxEpisodesToKeep() {
return this.media.maxEpisodesToKeep
},
isUpdated() {
return this.autoDownloadSchedule !== this.cronExpression || this.autoDownloadEpisodes !== this.enableAutoDownloadEpisodes
return this.autoDownloadSchedule !== this.cronExpression || this.autoDownloadEpisodes !== this.enableAutoDownloadEpisodes || this.maxEpisodesToKeep !== Number(this.newMaxEpisodesToKeep)
}
},
methods: {
init() {
this.enableAutoDownloadEpisodes = this.autoDownloadEpisodes
this.cronExpression = this.autoDownloadSchedule
},
updatedCron() {
console.log('Updated cron', this.cronExpression)
updatedMaxEpisodesToKeep() {
if (isNaN(this.newMaxEpisodesToKeep) || this.newMaxEpisodesToKeep < 0) {
this.newMaxEpisodesToKeep = 0
} else {
this.newMaxEpisodesToKeep = Number(this.newMaxEpisodesToKeep)
}
},
save() {
// If custom expression input is focused then unfocus it instead of submitting
@ -75,6 +101,10 @@ export default {
return
}
}
if (this.$refs.maxEpisodesInput && this.$refs.maxEpisodesInput.isFocused) {
this.$refs.maxEpisodesInput.blur()
return
}
const updatePayload = {
autoDownloadEpisodes: this.enableAutoDownloadEpisodes
@ -82,6 +112,10 @@ export default {
if (this.enableAutoDownloadEpisodes) {
updatePayload.autoDownloadSchedule = this.cronExpression
}
if (this.newMaxEpisodesToKeep !== this.maxEpisodesToKeep) {
updatePayload.maxEpisodesToKeep = this.newMaxEpisodesToKeep
}
this.updateDetails(updatePayload)
},
async updateDetails(updatePayload) {
@ -100,6 +134,11 @@ export default {
}
}
return false
},
init() {
this.enableAutoDownloadEpisodes = this.autoDownloadEpisodes
this.cronExpression = this.autoDownloadSchedule
this.newMaxEpisodesToKeep = this.maxEpisodesToKeep
}
},
mounted() {

View file

@ -2,7 +2,7 @@
<modals-modal v-model="show" name="edit-library" :width="700" :height="'unset'" :processing="processing">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">{{ title }}</p>
<p class="font-book text-xl md:text-3xl text-white truncate">{{ title }}</p>
</div>
</template>
<div class="absolute -top-10 left-0 z-10 w-full flex">

View file

@ -1,8 +1,8 @@
<template>
<div class="w-full h-full px-1 md:px-4 py-1 mb-4">
<div class="flex items-center justify-between mb-4">
<p class="text-base md:text-lg font-semibold">Schedule Automatic Library Scans</p>
<ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" label="Enable" small checkbox-bg="bg" label-class="pl-2 text-base" />
<p class="text-base md:text-xl font-semibold">Schedule Automatic Library Scans</p>
<ui-checkbox v-model="enableAutoScan" @input="toggleEnableAutoScan" label="Enable" medium checkbox-bg="bg" label-class="pl-2 text-base md:text-lg" />
</div>
<widgets-cron-expression-builder ref="cronExpressionBuilder" v-if="enableAutoScan" v-model="cronExpression" @input="updatedCron" />
</div>

View file

@ -0,0 +1,102 @@
<template>
<div v-if="item" class="w-full flex items-center px-4 py-2" :class="wrapperClass" @mouseover="mouseover" @mouseleave="mouseleave">
<covers-preview-cover :src="coverUrl" :width="48" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" />
<div class="flex-grow px-2 py-1 queue-item-row-content truncate">
<p class="text-gray-200 text-sm truncate">{{ title }}</p>
<p class="text-gray-300 text-sm">{{ subtitle }}</p>
<p v-if="caption" class="text-gray-400 text-xs">{{ caption }}</p>
</div>
<div class="w-28">
<p v-if="isOpenInPlayer" class="text-sm text-right text-gray-400">Streaming</p>
<div v-else-if="isHovering" class="flex items-center justify-end -mx-1">
<button class="outline-none mx-1 flex items-center" @click.stop="playClick">
<span class="material-icons text-success">play_arrow</span>
</button>
<button class="outline-none mx-1 flex items-center" @click.stop="removeClick">
<span class="material-icons text-error">close</span>
</button>
</div>
<p v-else class="text-gray-400 text-sm text-right">{{ durationPretty }}</p>
</div>
</div>
</template>
<script>
export default {
props: {
item: {
type: Object,
default: () => {}
},
index: Number
},
data() {
return {
isHovering: false
}
},
computed: {
title() {
return this.item.title || ''
},
subtitle() {
return this.item.subtitle || ''
},
caption() {
return this.item.caption
},
libraryItemId() {
return this.item.libraryItemId
},
episodeId() {
return this.item.episodeId
},
coverPath() {
return this.item.coverPath
},
coverUrl() {
if (!this.coverPath) return '/book_placeholder.jpg'
return this.$store.getters['globals/getLibraryItemCoverSrcById'](this.libraryItemId)
},
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
duration() {
return this.item.duration
},
durationPretty() {
if (!this.duration) return 'N/A'
return this.$elapsedPretty(this.duration)
},
isOpenInPlayer() {
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episodeId)
},
wrapperClass() {
if (this.isOpenInPlayer) return 'bg-yellow-400 bg-opacity-10'
if (this.index % 2 === 0) return 'bg-gray-300 bg-opacity-5 hover:bg-opacity-10'
return 'bg-bg hover:bg-gray-300 hover:bg-opacity-10'
}
},
methods: {
mouseover() {
this.isHovering = true
},
mouseleave() {
this.isHovering = false
},
playClick() {
this.$emit('play', this.item)
},
removeClick() {
this.$emit('remove', this.item)
}
},
mounted() {}
}
</script>
<style scoped>
.queue-item-row-content {
max-width: calc(100% - 48px - 128px);
}
</style>

View file

@ -0,0 +1,70 @@
<template>
<modals-modal v-model="show" name="queue-items" :width="800" :height="'unset'">
<template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden">
<p class="font-book text-3xl text-white truncate">Player Queue</p>
</div>
</template>
<div ref="container" class="w-full rounded-lg bg-bg box-shadow-md overflow-y-auto overflow-x-hidden py-4" style="max-height: 80vh">
<div v-if="show" class="w-full h-full">
<div class="pb-4 px-4 flex items-center">
<p class="text-base text-gray-200">Player Queue</p>
<p class="text-base text-gray-400 px-4">{{ playerQueueItems.length }} Items</p>
<div class="flex-grow" />
<ui-checkbox v-model="playerQueueAutoPlay" label="Auto Play" medium checkbox-bg="primary" border-color="gray-600" label-class="pl-2 mb-px" />
</div>
<modals-player-queue-item-row v-for="(item, index) in playerQueueItems" :key="index" :item="item" :index="index" @play="playItem" @remove="removeItem" />
</div>
</div>
</modals-modal>
</template>
<script>
export default {
props: {
value: Boolean,
libraryItemId: String
},
data() {
return {}
},
computed: {
show: {
get() {
return this.value
},
set(val) {
this.$emit('input', val)
}
},
playerQueueAutoPlay: {
get() {
return this.$store.state.playerQueueAutoPlay
},
set(val) {
this.$store.commit('setPlayerQueueAutoPlay', val)
}
},
playerQueueItems() {
return this.$store.state.playerQueueItems || []
}
},
methods: {
playItem(item) {
this.$eventBus.$emit('play-item', {
libraryItemId: item.libraryItemId,
episodeId: item.episodeId || null,
queueItems: this.playerQueueItems
})
this.show = false
},
removeItem(item) {
const updatedQueue = this.playerQueueItems.filter((i) => {
if (!i.episodeId) return i.libraryItemId !== item.libraryItemId
return i.libraryItemId !== item.libraryItemId || i.episodeId !== item.episodeId
})
this.$store.commit('setPlayerQueueItems', updatedQueue)
}
}
}
</script>

View file

@ -11,11 +11,11 @@
v-for="(episode, index) in episodes"
:key="index"
class="relative"
:class="episode.enclosure && itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
@click="toggleSelectEpisode(index)"
:class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'cursor-pointer bg-success bg-opacity-10' : index % 2 == 0 ? 'cursor-pointer bg-primary bg-opacity-25 hover:bg-opacity-40' : 'cursor-pointer bg-primary bg-opacity-5 hover:bg-opacity-25'"
@click="toggleSelectEpisode(index, episode)"
>
<div class="absolute top-0 left-0 h-full flex items-center p-2">
<span v-if="episode.enclosure && itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
</div>
<div class="px-8 py-2">
@ -23,20 +23,13 @@
<p class="break-words mb-1">{{ episode.title }}</p>
<p v-if="episode.subtitle" class="break-words mb-1 text-sm text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
<p class="text-xs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
<!-- <span class="material-icons cursor-pointer text-lg hover:text-success" @click="saveEpisode(episode)">save</span> -->
</div>
</div>
</div>
<div class="flex justify-end pt-4">
<div class="relative">
<div class="absolute top-0 left-0 h-full flex items-center p-2">
<ui-checkbox v-model="selectAll" small checkbox-bg="primary" border-color="gray-600" :disabled="allDownloaded" />
</div>
<div class="px-8 py-2">
<p :class="!allDownloaded ? 'font-semibold text-gray-200' : 'text-gray-400'">Select all episodes</p>
</div>
</div>
<ui-btn :disabled="!episodesSelected.length" @click="submit">{{ buttonText }}</ui-btn>
<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-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>
</div>
</modals-modal>
@ -58,7 +51,8 @@ export default {
data() {
return {
processing: false,
selectedEpisodes: {}
selectedEpisodes: {},
selectAll: false
}
},
watch: {
@ -78,22 +72,12 @@ export default {
this.$emit('input', val)
}
},
selectAll: {
get() {
return this.episodesSelected.length == this.episodes.filter((_, index) => !(this.episodes[index].enclosure && this.itemEpisodeMap[this.episodes[index].enclosure.url])).length
},
set(val) {
for (const key in this.selectedEpisodes) {
this.selectedEpisodes[key] = val
}
}
},
title() {
if (!this.libraryItem) return ''
return this.libraryItem.media.metadata.title || 'Unknown'
},
allDownloaded() {
return Object.values(this.episodes).filter((episode) => !(episode.enclosure && this.itemEpisodeMap[episode.enclosure.url])).length === 0
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url])
},
episodesSelected() {
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
@ -115,8 +99,27 @@ export default {
}
},
methods: {
toggleSelectEpisode(index) {
toggleSelectAll(val) {
for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i]
if (this.itemEpisodeMap[episode.enclosure.url]) this.selectedEpisodes[String(i)] = false
else this.$set(this.selectedEpisodes, String(i), val)
}
},
checkSetIsSelectedAll() {
for (let i = 0; i < this.episodes.length; i++) {
const episode = this.episodes[i]
if (!this.itemEpisodeMap[episode.enclosure.url] && !this.selectedEpisodes[String(i)]) {
this.selectAll = false
return
}
}
this.selectAll = true
},
toggleSelectEpisode(index, episode) {
if (this.itemEpisodeMap[episode.enclosure.url]) return
this.$set(this.selectedEpisodes, String(index), !this.selectedEpisodes[String(index)])
this.checkSetIsSelectedAll()
},
submit() {
var episodesToDownload = []
@ -145,17 +148,15 @@ export default {
console.error('Failed to download episodes', error)
this.processing = false
this.$toast.error(errorMsg)
this.selectedEpisodes = {}
this.selectAll = false
})
},
init() {
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt) ? 1 : -1)
for (let i = 0; i < this.episodes.length; i++) {
var episode = this.episodes[i]
if (episode.enclosure && !this.itemEpisodeMap[episode.enclosure.url]) {
// Do not include episodes already downloaded
this.$set(this.selectedEpisodes, String(i), false)
}
}
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
this.selectAll = false
this.selectedEpisodes = {}
}
},
mounted() {}
@ -170,4 +171,4 @@ export default {
#episodes-scroll {
max-height: calc(80vh - 200px);
}
</style>
</style>

View file

@ -23,9 +23,16 @@
<ui-rich-text-editor label="Description" v-model="newEpisode.description" />
</div>
</div>
<div class="flex justify-end pt-4">
<div class="flex items-center justify-end pt-4">
<ui-btn @click="submit">Submit</ui-btn>
</div>
<div v-if="enclosureUrl" class="py-4">
<p class="text-xs text-gray-300 font-semibold">Episode URL from RSS feed</p>
<a :href="enclosureUrl" target="_blank" class="text-xs text-blue-400 hover:text-blue-500 hover:underline">{{ enclosureUrl }}</a>
</div>
<div v-else class="py-4">
<p class="text-xs text-gray-300 font-semibold">Episode not linked to RSS feed episode</p>
</div>
</div>
</template>
@ -76,6 +83,12 @@ export default {
},
episodeId() {
return this.episode ? this.episode.id : null
},
enclosure() {
return this.episode ? this.episode.enclosure || {} : {}
},
enclosureUrl() {
return this.enclosure.url
}
},
methods: {

View file

@ -27,6 +27,10 @@
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
</div>
</ui-tooltip>
<button v-if="playerQueueItems.length" class="outline-none text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="$emit('showPlayerQueueItems')">
<span class="material-icons text-2xl sm:text-3xl">playlist_play</span>
</button>
</div>
<player-playback-controls :loading="loading" :seek-loading="seekLoading" :playback-rate.sync="playbackRate" :paused="paused" :has-next-chapter="hasNextChapter" @prevChapter="prevChapter" @nextChapter="nextChapter" @jumpForward="jumpForward" @jumpBackward="jumpBackward" @setPlaybackRate="setPlaybackRate" @playPause="playPause" />
@ -138,6 +142,9 @@ export default {
hasNextChapter() {
if (!this.chapters.length) return false
return this.currentChapterIndex < this.chapters.length - 1
},
playerQueueItems() {
return this.$store.state.playerQueueItems || []
}
},
methods: {

View file

@ -45,7 +45,7 @@
<div v-if="selectedBackup" class="px-4 w-full text-sm py-6 rounded-lg bg-bg shadow-lg border border-black-300">
<p class="text-error text-lg font-semibold">Important Notice!</p>
<p class="text-base py-1">Applying a backup will overwrite users, user progress, book details, settings, and covers stored in metadata with the backed up data.</p>
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories. If you have enabled server settings to store cover art and metadata in your library folders then those are not backup up or overwritten.</p>
<p class="text-base py-1">Backups <strong>do not</strong> modify any files in your library folders, only data in the audiobookshelf created <span class="font-mono">/config</span> and <span class="font-mono">/metadata</span> directories. If you have enabled server settings to store cover art and metadata in your library folders then those are not backed up or overwritten.</p>
<p class="text-base py-1">All clients using your server will be automatically refreshed.</p>
<p class="text-lg text-center my-8">Are you sure you want to apply the backup created on {{ selectedBackup.datePretty }}?</p>

View file

@ -164,8 +164,8 @@ export default {
}
if (this.$root.socket) {
this.$root.socket.off('user_added', this.newUserAdded)
this.$root.socket.off('user_updated', this.userUpdated)
this.$root.socket.off('user_added', this.addUpdateUser)
this.$root.socket.off('user_updated', this.addUpdateUser)
this.$root.socket.off('user_removed', this.userRemoved)
}
}
@ -208,6 +208,6 @@ export default {
font-weight: 600;
padding-top: 5px;
padding-bottom: 5px;
background-color: #272727
background-color: #272727;
}
</style>

View file

@ -27,15 +27,15 @@
<span class="material-icons text-lg text-white text-opacity-70 hover:text-opacity-100 cursor-pointer">radio_button_unchecked</span>
</div> -->
</div>
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : '-translate-x-24'">
<div class="w-40 absolute top-0 -right-24 h-full transform transition-transform" :class="!isHovering ? 'translate-x-0' : translateDistance">
<div class="flex h-full items-center">
<ui-tooltip :text="userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished'" direction="top">
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
</ui-tooltip>
<div class="mx-1" :class="isHovering ? '' : 'ml-6'">
<div v-if="userCanUpdate" class="mx-1" :class="isHovering ? '' : 'ml-6'">
<ui-icon-btn icon="edit" borderless @click="clickEdit" />
</div>
<div class="mx-1">
<div v-if="userCanDelete" class="mx-1">
<ui-icon-btn icon="close" borderless @click="removeClick" />
</div>
</div>
@ -71,6 +71,11 @@ export default {
}
},
computed: {
translateDistance() {
if (!this.userCanUpdate && !this.userCanDelete) return 'translate-x-0'
else if (!this.userCanUpdate || !this.userCanDelete) return '-translate-x-12'
return '-translate-x-24'
},
media() {
return this.book.media || {}
},
@ -113,6 +118,12 @@ export default {
coverWidth() {
if (this.bookCoverAspectRatio === 1) return this.coverSize * 1.6
return this.coverSize
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
}
},
methods: {

View file

@ -133,10 +133,7 @@ export default {
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')
} else {
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId,
episodeId: this.episode.id
})
this.$emit('play', this.episode)
}
},
toggleFinished(confirmed = false) {

View file

@ -4,14 +4,17 @@
<p class="text-lg mb-0 font-semibold">Episodes</p>
<div class="flex-grow" />
<template v-if="isSelectionMode">
<ui-btn color="error" small @click="removeSelectedEpisodes">Remove {{ selectedEpisodes.length }} episode{{ selectedEpisodes.length > 1 ? 's' : '' }}</ui-btn>
<ui-btn small class="ml-2" @click="clearSelected">Cancel</ui-btn>
<ui-tooltip :text="`Mark as ${selectedIsFinished ? 'Not Finished' : 'Finished'}`" 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">Remove {{ selectedEpisodes.length }} episode{{ selectedEpisodes.length > 1 ? 's' : '' }}</ui-btn>
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">Cancel</ui-btn>
</template>
<controls-episode-sort-select v-else v-model="sortKey" :descending.sync="sortDesc" class="w-36 sm:w-44 md:w-48 h-9 ml-1 sm:ml-4" />
</div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">No Episodes</p>
<template v-for="episode in episodesSorted">
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" />
<tables-podcast-episode-table-row ref="episodeRow" :key="episode.id" :episode="episode" :library-item-id="libraryItem.id" :selection-mode="isSelectionMode" class="item" @play="playEpisode" @remove="removeEpisode" @edit="editEpisode" @view="viewEpisode" @selected="episodeSelected" />
</template>
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" />
@ -34,7 +37,8 @@ export default {
selectedEpisode: null,
showPodcastRemoveModal: false,
selectedEpisodes: [],
episodesToRemove: []
episodesToRemove: [],
processing: false
}
},
watch: {
@ -65,9 +69,40 @@ export default {
}
return String(a[this.sortKey]).localeCompare(String(b[this.sortKey]), undefined, { numeric: true, sensitivity: 'base' })
})
},
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
})
}
},
methods: {
toggleBatchFinished() {
this.processing = true
var newIsFinished = !this.selectedIsFinished
var updateProgressPayloads = this.selectedEpisodes.map((episode) => {
return {
libraryItemId: this.libraryItem.id,
episodeId: episode.id,
isFinished: newIsFinished
}
})
this.$axios
.patch(`/api/me/progress/batch/update`, updateProgressPayloads)
.then(() => {
this.$toast.success('Batch update success!')
this.processing = false
this.clearSelected()
})
.catch((error) => {
this.$toast.error('Batch update failed')
console.error('Failed to batch update read/not read', error)
this.processing = false
})
},
removeEpisodeModalToggled(val) {
if (!val) this.episodesToRemove = []
},
@ -91,6 +126,33 @@ export default {
this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id)
}
},
playEpisode(episode) {
const queueItems = []
const episodesInListeningOrder = this.episodesCopy.map((ep) => ({ ...ep })).sort((a, b) => String(a.publishedAt).localeCompare(String(b.publishedAt), undefined, { numeric: true, sensitivity: 'base' }))
const episodeIndex = episodesInListeningOrder.findIndex((e) => e.id === episode.id)
for (let i = episodeIndex; i < episodesInListeningOrder.length; i++) {
const episode = episodesInListeningOrder[i]
const podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItem.id, episode.id)
if (!podcastProgress || !podcastProgress.isFinished) {
queueItems.push({
libraryItemId: this.libraryItem.id,
episodeId: episode.id,
title: episode.title,
subtitle: this.mediaMetadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
duration: episode.audioFile.duration || null,
coverPath: this.media.coverPath || null
})
}
}
this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItem.id,
episodeId: episode.id,
queueItems
})
},
removeEpisode(episode) {
this.episodesToRemove = [episode]
this.showPodcastRemoveModal = true

View file

@ -14,6 +14,7 @@ export default {
value: Boolean,
label: String,
small: Boolean,
medium: Boolean,
checkboxBg: {
type: String,
default: 'white'
@ -47,19 +48,23 @@ export default {
wrapperClass() {
var classes = [`bg-${this.checkboxBg} border-${this.borderColor}`]
if (this.small) classes.push('w-4 h-4')
else if (this.medium) classes.push('w-5 h-5')
else classes.push('w-6 h-6')
return classes.join(' ')
},
labelClassname() {
if (this.labelClass) return this.labelClass
var classes = ['pl-1']
if (this.small) classes.push('text-xs md:text-sm')
var classes = []
if (this.small) classes.push('text-xs md:text-sm pl-1')
else if (this.medium) classes.push('text-base md:text-lg pl-2')
else classes.push('pl-2')
return classes.join(' ')
},
svgClass() {
var classes = [`text-${this.checkColor}`]
if (this.small) classes.push('w-3 h-3')
else if (this.medium) classes.push('w-3.5 h-3.5')
else classes.push('w-4 h-4')
return classes.join(' ')

View file

@ -1,5 +1,5 @@
<template>
<div v-if="currentLibrary" class="relative h-8 max-w-52 min-w-32" v-click-outside="clickOutsideObj">
<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 focus:outline-none cursor-pointer bg-black bg-opacity-20 text-gray-400 hover:text-gray-200" aria-haspopup="listbox" :aria-expanded="showMenu" @click.stop.prevent="clickShowMenu">
<div class="flex items-center justify-center sm:justify-start">
<widgets-library-icon :icon="currentLibraryIcon" class="sm:mr-1.5" />

View file

@ -5,7 +5,7 @@
{{ label }}<em v-if="note" class="font-normal text-xs pl-2">{{ note }}</em>
</p>
</slot>
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" @blur="inputBlurred" />
<ui-text-input ref="input" v-model="inputValue" :disabled="disabled" :readonly="readonly" :type="type" class="w-full" :class="inputClass" @blur="inputBlurred" />
</div>
</template>
@ -20,7 +20,8 @@ export default {
default: 'text'
},
readonly: Boolean,
disabled: Boolean
disabled: Boolean,
inputClass: String
},
data() {
return {}

View file

@ -147,6 +147,16 @@ export default {
margin-top: -2px;
margin-left: -2px;
}
.la-ball-spin-clockwise.la-lg {
width: 32px;
height: 32px;
}
.la-ball-spin-clockwise.la-lg > div {
width: 8px;
height: 8px;
margin-top: -4px;
margin-left: -4px;
}
.la-ball-spin-clockwise.la-2x {
width: 64px;
height: 64px;

View file

@ -39,22 +39,6 @@
</div>
</div>
</div>
<div class="flex items-center px-1 pt-6">
<div class="w-1/2 px-1 py-5">
<ui-checkbox v-model="autoDownloadEpisodes" label="Auto Download New Episodes" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-base font-semibold" />
</div>
<div v-if="autoDownloadEpisodes" class="w-1/2 px-1">
<ui-text-input-with-label ref="maxEpisodesToKeep" v-model="maxEpisodesToKeep" type="number" class="max-w-48">
<ui-tooltip direction="bottom" text="Value of 0 sets no max limit. After a new episode is auto-downloaded this will delete the oldest episode if you have more than X episodes.">
<p class="text-sm">
Max episodes to keep
<span class="material-icons icon-text text-sm">info_outlined</span>
</p>
</ui-tooltip>
</ui-text-input-with-label>
</div>
</div>
</form>
</div>
</template>
@ -83,8 +67,6 @@ export default {
explicit: false,
language: null
},
autoDownloadEpisodes: false,
maxEpisodesToKeep: 0,
newTags: []
}
},
@ -209,13 +191,6 @@ export default {
updatePayload.tags = [...this.newTags]
}
if (this.media.autoDownloadEpisodes !== this.autoDownloadEpisodes) {
updatePayload.autoDownloadEpisodes = !!this.autoDownloadEpisodes
}
if (this.autoDownloadEpisodes && !isNaN(this.maxEpisodesToKeep) && Number(this.maxEpisodesToKeep) != this.media.maxEpisodesToKeep) {
updatePayload.maxEpisodesToKeep = Number(this.maxEpisodesToKeep)
}
return {
updatePayload,
hasChanges: !!Object.keys(updatePayload).length
@ -235,8 +210,6 @@ export default {
this.details.language = this.mediaMetadata.language || ''
this.details.explicit = !!this.mediaMetadata.explicit
this.autoDownloadEpisodes = !!this.media.autoDownloadEpisodes
this.maxEpisodesToKeep = this.media.maxEpisodesToKeep || 0
this.newTags = [...(this.media.tags || [])]
},
submitForm() {