Add:Player queue for audiobooks #1077

This commit is contained in:
advplyr 2022-11-12 16:48:35 -06:00
parent 3357ccfaf3
commit 78559520ab
12 changed files with 221 additions and 24 deletions

View File

@ -477,6 +477,21 @@ export default {
text: this.$strings.ButtonRemoveFromContinueListening text: this.$strings.ButtonRemoveFromContinueListening
}) })
} }
if (!this.isPodcast) {
if (this.libraryItemIdStreaming && !this.isStreamingFromDifferentLibrary) {
if (!this.isQueued) {
items.push({
func: 'addToQueue',
text: this.$strings.ButtonQueueAddItem
})
} else if (!this.isStreaming) {
items.push({
func: 'removeFromQueue',
text: this.$strings.ButtonQueueRemoveItem
})
}
}
}
return items return items
}, },
_socket() { _socket() {
@ -690,8 +705,9 @@ export default {
}) })
}, },
addToQueue() { addToQueue() {
var queueItem = {}
if (this.recentEpisode) { if (this.recentEpisode) {
const queueItem = { queueItem = {
libraryItemId: this.libraryItemId, libraryItemId: this.libraryItemId,
libraryId: this.libraryId, libraryId: this.libraryId,
episodeId: this.recentEpisode.id, episodeId: this.recentEpisode.id,
@ -701,8 +717,19 @@ export default {
duration: this.recentEpisode.audioFile.duration || null, duration: this.recentEpisode.audioFile.duration || null,
coverPath: this.media.coverPath || null coverPath: this.media.coverPath || null
} }
this.store.commit('addItemToQueue', queueItem) } else {
queueItem = {
libraryItemId: this.libraryItemId,
libraryId: this.libraryId,
episodeId: null,
title: this.title,
subtitle: this.author,
caption: '',
duration: this.media.duration || null,
coverPath: this.media.coverPath || null
} }
}
this.store.commit('addItemToQueue', queueItem)
}, },
removeFromQueue() { removeFromQueue() {
const episodeId = this.recentEpisode ? this.recentEpisode.id : null const episodeId = this.recentEpisode ? this.recentEpisode.id : null
@ -815,6 +842,18 @@ export default {
} }
} }
} }
} else {
const queueItem = {
libraryItemId: this.libraryItemId,
libraryId: this.libraryId,
episodeId: null,
title: this.title,
subtitle: this.author,
caption: '',
duration: this.media.duration || null,
coverPath: this.media.coverPath || null
}
queueItems.push(queueItem)
} }
eventBus.$emit('play-item', { eventBus.$emit('play-item', {

View File

@ -4,7 +4,7 @@
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary"> <div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
<div class="absolute cover-bg" ref="coverBg" /> <div class="absolute cover-bg" ref="coverBg" />
</div> </div>
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-cover'" /> <img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }"> <a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span> <span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
@ -63,6 +63,9 @@ export default {
}, },
resolution() { resolution() {
return `${this.naturalWidth}x${this.naturalHeight}px` return `${this.naturalWidth}x${this.naturalHeight}px`
},
placeholderUrl() {
return `${this.$config.routerBasePath}/book_placeholder.jpg`
} }
}, },
methods: { methods: {
@ -72,7 +75,7 @@ export default {
} }
}, },
imageLoaded() { imageLoaded() {
if (this.$refs.cover) { if (this.$refs.cover && this.src !== this.placeholderUrl) {
var { naturalWidth, naturalHeight } = this.$refs.cover var { naturalWidth, naturalHeight } = this.$refs.cover
this.naturalHeight = naturalHeight this.naturalHeight = naturalHeight
this.naturalWidth = naturalWidth this.naturalWidth = naturalWidth

View File

@ -7,7 +7,7 @@
<p v-if="caption" class="text-gray-400 text-xs">{{ caption }}</p> <p v-if="caption" class="text-gray-400 text-xs">{{ caption }}</p>
</div> </div>
<div class="w-28"> <div class="w-28">
<p v-if="isOpenInPlayer" class="text-sm text-right text-gray-400">Streaming</p> <p v-if="isOpenInPlayer" class="text-sm text-right text-gray-400">{{ $strings.ButtonPlaying }}</p>
<div v-else-if="isHovering" class="flex items-center justify-end -mx-1"> <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"> <button class="outline-none mx-1 flex items-center" @click.stop="playClick">
<span class="material-icons text-success">play_arrow</span> <span class="material-icons text-success">play_arrow</span>

View File

@ -2,13 +2,13 @@
<modals-modal v-model="show" name="queue-items" :width="800" :height="'unset'"> <modals-modal v-model="show" name="queue-items" :width="800" :height="'unset'">
<template #outer> <template #outer>
<div class="absolute top-0 left-0 p-5 w-2/3 overflow-hidden"> <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> <p class="font-book text-3xl text-white truncate">{{ $strings.HeaderPlayerQueue }}</p>
</div> </div>
</template> </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 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 v-if="show" class="w-full h-full">
<div class="pb-4 px-4 flex items-center"> <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-200">{{ $strings.HeaderPlayerQueue }}</p>
<p class="text-base text-gray-400 px-4">{{ playerQueueItems.length }} Items</p> <p class="text-base text-gray-400 px-4">{{ playerQueueItems.length }} Items</p>
<div class="flex-grow" /> <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" /> <ui-checkbox v-model="playerQueueAutoPlay" label="Auto Play" medium checkbox-bg="primary" border-color="gray-600" label-class="pl-2 mb-px" />

View File

@ -22,15 +22,15 @@
<span class="material-icons text-2xl sm:text-3xl">format_list_bulleted</span> <span class="material-icons text-2xl sm:text-3xl">format_list_bulleted</span>
</div> </div>
<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">queue_music</span>
</button>
<ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack"> <ui-tooltip v-if="chapters.length" direction="top" :text="useChapterTrack ? $strings.LabelUseFullTrack : $strings.LabelUseChapterTrack">
<div class="cursor-pointer text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack"> <div class="cursor-pointer text-gray-300 mx-1 lg:mx-2 hover:text-white" @mousedown.prevent @mouseup.prevent @click.stop="setUseChapterTrack">
<span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span> <span class="material-icons text-2xl sm:text-3xl transform transition-transform" :class="useChapterTrack ? 'rotate-180' : ''">timelapse</span>
</div> </div>
</ui-tooltip> </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">queue_music</span>
</button>
</div> </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" /> <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" />

View File

@ -72,11 +72,23 @@ export default {
this.expanded = !this.expanded this.expanded = !this.expanded
}, },
goToTimestamp(time) { goToTimestamp(time) {
const queueItem = {
libraryItemId: this.libraryItemId,
libraryId: this.libraryItem.libraryId,
episodeId: null,
title: this.metadata.title,
subtitle: this.metadata.authors.map((au) => au.name).join(', '),
caption: '',
duration: this.media.duration || null,
coverPath: this.media.coverPath || null
}
if (this.$store.getters['getIsMediaStreaming'](this.libraryItemId)) { if (this.$store.getters['getIsMediaStreaming'](this.libraryItemId)) {
this.$eventBus.$emit('play-item', { this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId, libraryItemId: this.libraryItemId,
episodeId: null, episodeId: null,
startTime: time startTime: time,
queueItems: [queueItem]
}) })
} else { } else {
const payload = { const payload = {
@ -86,7 +98,8 @@ export default {
this.$eventBus.$emit('play-item', { this.$eventBus.$emit('play-item', {
libraryItemId: this.libraryItemId, libraryItemId: this.libraryItemId,
episodeId: null, episodeId: null,
startTime: time startTime: time,
queueItems: [queueItem]
}) })
} }
}, },

View File

@ -137,8 +137,22 @@ export default {
this.isHovering = false this.isHovering = false
}, },
playClick() { playClick() {
const queueItems = [
{
libraryItemId: this.book.id,
libraryId: this.book.libraryId,
episodeId: null,
title: this.bookTitle,
subtitle: this.bookAuthors.map((au) => au.name).join(', '),
caption: '',
duration: this.media.duration || null,
coverPath: this.media.coverPath || null
}
]
this.$eventBus.$emit('play-item', { this.$eventBus.$emit('play-item', {
libraryItemId: this.book.id libraryItemId: this.book.id,
queueItems
}) })
}, },
clickEdit() { clickEdit() {

View File

@ -122,13 +122,42 @@ export default {
} }
}, },
clickPlay() { clickPlay() {
var nextBookNotRead = this.playableBooks.find((pb) => { const queueItems = []
var prog = this.$store.getters['user/getUserMediaProgress'](pb.id)
return !prog || !prog.isFinished // Collection queue will start at the first unfinished book
// if all books are finished then entire collection is queued
const itemsWithProgress = this.playableBooks.map((item) => {
return {
...item,
progress: this.$store.getters['user/getUserMediaProgress'](item.id)
}
}) })
if (nextBookNotRead) {
const hasUnfinishedItems = itemsWithProgress.some((i) => !i.progress || !i.progress.isFinished)
if (!hasUnfinishedItems) {
console.warn('All items in collection are finished - starting at first item')
}
for (let i = 0; i < itemsWithProgress.length; i++) {
const libraryItem = itemsWithProgress[i]
if (!hasUnfinishedItems || !libraryItem.progress || !libraryItem.progress.isFinished) {
queueItems.push({
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
episodeId: null,
title: libraryItem.media.metadata.title,
subtitle: libraryItem.media.metadata.authors.map((au) => au.name).join(', '),
caption: '',
duration: libraryItem.media.duration || null,
coverPath: libraryItem.media.coverPath || null
})
}
}
if (queueItems.length >= 0) {
this.$eventBus.$emit('play-item', { this.$eventBus.$emit('play-item', {
libraryItemId: nextBookNotRead.id libraryItemId: queueItems[0].libraryItemId,
queueItems
}) })
} }
} }

View File

@ -127,12 +127,38 @@ export default {
this.processingGoToTimestamp = false this.processingGoToTimestamp = false
return return
} }
if (session.episodeId && !libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)) { if (session.episodeId && !libraryItem.media.episodes.some((ep) => ep.id === session.episodeId)) {
this.$toast.error('Failed to get podcast episode') this.$toast.error('Failed to get podcast episode')
this.processingGoToTimestamp = false this.processingGoToTimestamp = false
return return
} }
var queueItem = {}
if (session.episodeId) {
var episode = libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)
queueItem = {
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
episodeId: episode.id,
title: episode.title,
subtitle: libraryItem.media.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
duration: episode.audioFile.duration || null,
coverPath: libraryItem.media.coverPath || null
}
} else {
queueItem = {
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
episodeId: null,
title: libraryItem.media.metadata.title,
subtitle: libraryItem.media.metadata.authors.map((au) => au.name).join(', '),
caption: '',
duration: libraryItem.media.duration || null,
coverPath: libraryItem.media.coverPath || null
}
}
const payload = { const payload = {
message: this.$getString('MessageStartPlaybackAtTime', [session.displayTitle, this.$secondsToTimestamp(session.currentTime)]), message: this.$getString('MessageStartPlaybackAtTime', [session.displayTitle, this.$secondsToTimestamp(session.currentTime)]),
callback: (confirmed) => { callback: (confirmed) => {
@ -140,7 +166,8 @@ export default {
this.$eventBus.$emit('play-item', { this.$eventBus.$emit('play-item', {
libraryItemId: libraryItem.id, libraryItemId: libraryItem.id,
episodeId: session.episodeId || null, episodeId: session.episodeId || null,
startTime: session.currentTime startTime: session.currentTime,
queueItems: [queueItem]
}) })
} }
this.processingGoToTimestamp = false this.processingGoToTimestamp = false

View File

@ -114,12 +114,38 @@ export default {
this.processingGoToTimestamp = false this.processingGoToTimestamp = false
return return
} }
if (session.episodeId && !libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)) { if (session.episodeId && !libraryItem.media.episodes.some((ep) => ep.id === session.episodeId)) {
this.$toast.error('Failed to get podcast episode') this.$toast.error('Failed to get podcast episode')
this.processingGoToTimestamp = false this.processingGoToTimestamp = false
return return
} }
var queueItem = {}
if (session.episodeId) {
var episode = libraryItem.media.episodes.find((ep) => ep.id === session.episodeId)
queueItem = {
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
episodeId: episode.id,
title: episode.title,
subtitle: libraryItem.media.metadata.title,
caption: episode.publishedAt ? `Published ${this.$formatDate(episode.publishedAt, 'MMM do, yyyy')}` : 'Unknown publish date',
duration: episode.audioFile.duration || null,
coverPath: libraryItem.media.coverPath || null
}
} else {
queueItem = {
libraryItemId: libraryItem.id,
libraryId: libraryItem.libraryId,
episodeId: null,
title: libraryItem.media.metadata.title,
subtitle: libraryItem.media.metadata.authors.map((au) => au.name).join(', '),
caption: '',
duration: libraryItem.media.duration || null,
coverPath: libraryItem.media.coverPath || null
}
}
const payload = { const payload = {
message: this.$getString('MessageStartPlaybackAtTime', [session.displayTitle, this.$secondsToTimestamp(session.currentTime)]), message: this.$getString('MessageStartPlaybackAtTime', [session.displayTitle, this.$secondsToTimestamp(session.currentTime)]),
callback: (confirmed) => { callback: (confirmed) => {
@ -127,7 +153,8 @@ export default {
this.$eventBus.$emit('play-item', { this.$eventBus.$emit('play-item', {
libraryItemId: libraryItem.id, libraryItemId: libraryItem.id,
episodeId: session.episodeId || null, episodeId: session.episodeId || null,
startTime: session.currentTime startTime: session.currentTime,
queueItems: [queueItem]
}) })
} }
this.processingGoToTimestamp = false this.processingGoToTimestamp = false

View File

@ -137,12 +137,16 @@
{{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }} {{ isMissing ? $strings.LabelMissing : $strings.LabelIncomplete }}
</ui-btn> </ui-btn>
<ui-tooltip v-if="showQueueBtn" :text="isQueued ? $strings.ButtonQueueRemoveItem : $strings.ButtonQueueAddItem" direction="top">
<ui-icon-btn :icon="isQueued ? 'playlist_add_check' : 'playlist_add'" class="mx-0.5" :class="isQueued ? 'text-success' : ''" @click="queueBtnClick" />
</ui-tooltip>
<ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook"> <ui-btn v-if="showReadButton" color="info" :padding-x="4" small class="flex items-center h-9 mr-2" @click="openEbook">
<span class="material-icons -ml-2 pr-2 text-white">auto_stories</span> <span class="material-icons -ml-2 pr-2 text-white">auto_stories</span>
{{ $strings.ButtonRead }} {{ $strings.ButtonRead }}
</ui-btn> </ui-btn>
<ui-tooltip v-if="userCanUpdate" text="Edit" direction="top"> <ui-tooltip v-if="userCanUpdate" :text="$strings.LabelEdit" direction="top">
<ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" /> <ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" />
</ui-tooltip> </ui-tooltip>
@ -398,6 +402,9 @@ export default {
isStreaming() { isStreaming() {
return this.streamLibraryItem && this.streamLibraryItem.id === this.libraryItemId return this.streamLibraryItem && this.streamLibraryItem.id === this.libraryItemId
}, },
isQueued() {
return this.$store.getters['getIsMediaQueued'](this.libraryItemId)
},
userCanUpdate() { userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate'] return this.$store.getters['user/getUserCanUpdate']
}, },
@ -412,6 +419,10 @@ export default {
// If rss feed is open then show feed url to users otherwise just show to admins // If rss feed is open then show feed url to users otherwise just show to admins
return this.userIsAdminOrUp || this.rssFeedUrl return this.userIsAdminOrUp || this.rssFeedUrl
},
showQueueBtn() {
if (this.isPodcast || this.isVideo) return false
return !this.$store.getters['getIsStreamingFromDifferentLibrary'] && this.streamLibraryItem
} }
}, },
methods: { methods: {
@ -536,6 +547,7 @@ export default {
if (!podcastProgress || !podcastProgress.isFinished) { if (!podcastProgress || !podcastProgress.isFinished) {
queueItems.push({ queueItems.push({
libraryItemId: this.libraryItemId, libraryItemId: this.libraryItemId,
libraryId: this.libraryId,
episodeId: episode.id, episodeId: episode.id,
title: episode.title, title: episode.title,
subtitle: this.title, subtitle: this.title,
@ -545,6 +557,18 @@ export default {
}) })
} }
} }
} else {
const queueItem = {
libraryItemId: this.libraryItemId,
libraryId: this.libraryId,
episodeId: null,
title: this.title,
subtitle: this.authors.map((au) => au.name).join(', '),
caption: '',
duration: this.duration || null,
coverPath: this.media.coverPath || null
}
queueItems.push(queueItem)
} }
this.$eventBus.$emit('play-item', { this.$eventBus.$emit('play-item', {
@ -615,6 +639,26 @@ export default {
console.log('RSS Feed Closed', data) console.log('RSS Feed Closed', data)
this.rssFeedUrl = null this.rssFeedUrl = null
} }
},
queueBtnClick() {
if (this.isQueued) {
// Remove from queue
this.$store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId })
} else {
// Add to queue
const queueItem = {
libraryItemId: this.libraryItemId,
libraryId: this.libraryId,
episodeId: null,
title: this.title,
subtitle: this.authors.map((au) => au.name).join(', '),
caption: '',
duration: this.duration || null,
coverPath: this.media.coverPath || null
}
this.$store.commit('addItemToQueue', queueItem)
}
} }
}, },
mounted() { mounted() {

View File

@ -107,6 +107,7 @@
"HeaderOtherFiles": "Other Files", "HeaderOtherFiles": "Other Files",
"HeaderOpenRSSFeed": "Open RSS Feed", "HeaderOpenRSSFeed": "Open RSS Feed",
"HeaderPermissions": "Permissions", "HeaderPermissions": "Permissions",
"HeaderPlayerQueue": "Player Queue",
"HeaderPodcastsToAdd": "Podcasts to Add", "HeaderPodcastsToAdd": "Podcasts to Add",
"HeaderPreviewCover": "Preview Cover", "HeaderPreviewCover": "Preview Cover",
"HeaderRemoveEpisode": "Remove Episode", "HeaderRemoveEpisode": "Remove Episode",