mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2025-01-22 00:07:52 +01:00
Add:Player queue for podcast episodes & autoplay next episode #603
This commit is contained in:
parent
91e116969a
commit
c0dd58a94e
@ -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,35 @@ 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) 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)
|
||||
@ -312,7 +342,8 @@ export default {
|
||||
console.error('No Audio Ref')
|
||||
}
|
||||
},
|
||||
sessionOpen(session) { // For opening session on init (temporarily unused)
|
||||
sessionOpen(session) {
|
||||
// For opening session on init (temporarily unused)
|
||||
this.$store.commit('setMediaPlaying', {
|
||||
libraryItem: session.libraryItem,
|
||||
episodeId: session.episodeId
|
||||
@ -376,7 +407,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()
|
||||
|
@ -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 {
|
||||
|
98
client/components/modals/player/QueueItemRow.vue
Normal file
98
client/components/modals/player/QueueItemRow.vue
Normal file
@ -0,0 +1,98 @@
|
||||
<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-400 text-sm">{{ subtitle }}</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 || ''
|
||||
},
|
||||
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>
|
56
client/components/modals/player/QueueItemsModal.vue
Normal file
56
client/components/modals/player/QueueItemsModal.vue
Normal file
@ -0,0 +1,56 @@
|
||||
<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">
|
||||
<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)
|
||||
}
|
||||
},
|
||||
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>
|
@ -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: {
|
||||
|
@ -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) {
|
||||
|
@ -11,7 +11,7 @@
|
||||
</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" />
|
||||
@ -91,6 +91,28 @@ export default {
|
||||
this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id)
|
||||
}
|
||||
},
|
||||
playEpisode(episode) {
|
||||
const queueItems = []
|
||||
const episodeIndex = this.episodes.findIndex((e) => e.id === episode.id)
|
||||
for (let i = episodeIndex; i < this.episodes.length; i++) {
|
||||
const episode = this.episodes[i]
|
||||
const audioFile = episode.audioFile
|
||||
queueItems.push({
|
||||
libraryItemId: this.libraryItem.id,
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.mediaMetadata.title,
|
||||
duration: 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
|
||||
|
@ -12,7 +12,7 @@
|
||||
<!-- Item Cover Overlay -->
|
||||
<div class="absolute top-0 left-0 w-full h-full z-10 bg-black bg-opacity-30 opacity-0 hover:opacity-100 transition-opacity" @mousedown.prevent @mouseup.prevent>
|
||||
<div v-show="showPlayButton && !isStreaming" 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 cursor-pointer" @click.stop.prevent="startStream">
|
||||
<div class="hover:text-white text-gray-200 hover:scale-110 transform duration-200 pointer-events-auto cursor-pointer" @click.stop.prevent="playItem">
|
||||
<span class="material-icons text-4xl">play_circle_filled</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -128,7 +128,7 @@
|
||||
|
||||
<!-- Icon buttons -->
|
||||
<div class="flex items-center justify-center md:justify-start pt-4">
|
||||
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream">
|
||||
<ui-btn v-if="showPlayButton" :disabled="isStreaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="playItem">
|
||||
<span v-show="!isStreaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span>
|
||||
{{ isStreaming ? 'Playing' : 'Play' }}
|
||||
</ui-btn>
|
||||
@ -429,14 +429,14 @@ export default {
|
||||
message: `Start playback for "${this.title}" at ${this.$secondsToTimestamp(bookmark.time)}?`,
|
||||
callback: (confirmed) => {
|
||||
if (confirmed) {
|
||||
this.startStream(bookmark.time)
|
||||
this.playItem(bookmark.time)
|
||||
}
|
||||
},
|
||||
type: 'yesNo'
|
||||
}
|
||||
this.$store.commit('globals/setConfirmPrompt', payload)
|
||||
} else {
|
||||
this.startStream(bookmark.time)
|
||||
this.playItem(bookmark.time)
|
||||
}
|
||||
this.showBookmarksModal = false
|
||||
},
|
||||
@ -515,21 +515,37 @@ export default {
|
||||
this.$toast.error(`Failed to mark as ${updatePayload.isFinished ? 'Finished' : 'Not Finished'}`)
|
||||
})
|
||||
},
|
||||
startStream(startTime = null) {
|
||||
playItem(startTime = null) {
|
||||
var episodeId = null
|
||||
const queueItems = []
|
||||
if (this.isPodcast) {
|
||||
var episode = this.podcastEpisodes.find((ep) => {
|
||||
var episodeIndex = this.podcastEpisodes.findIndex((ep) => {
|
||||
var podcastProgress = this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, ep.id)
|
||||
return !podcastProgress || !podcastProgress.isFinished
|
||||
})
|
||||
if (!episode) episode = this.podcastEpisodes[0]
|
||||
episodeId = episode.id
|
||||
if (episodeIndex < 0) episodeIndex = 0
|
||||
|
||||
episodeId = this.podcastEpisodes[episodeIndex].id
|
||||
|
||||
for (let i = episodeIndex; i < this.podcastEpisodes.length; i++) {
|
||||
const episode = this.podcastEpisodes[i]
|
||||
const audioFile = episode.audioFile
|
||||
queueItems.push({
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: episode.id,
|
||||
title: episode.title,
|
||||
subtitle: this.title,
|
||||
duration: audioFile.duration || null,
|
||||
coverPath: this.libraryItem.media.coverPath || null
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItem.id,
|
||||
episodeId,
|
||||
startTime
|
||||
startTime,
|
||||
queueItems
|
||||
})
|
||||
},
|
||||
editClick() {
|
||||
|
@ -133,6 +133,8 @@ export default class PlayerHandler {
|
||||
|
||||
// TODO: Add listening time between last sync and now?
|
||||
this.sendProgressSync(currentTime)
|
||||
|
||||
this.ctx.mediaFinished(this.libraryItemId, this.episodeId)
|
||||
}
|
||||
|
||||
playerStateChange(state) {
|
||||
|
@ -46,6 +46,14 @@ export const getters = {
|
||||
return `http://localhost:3333/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||
}
|
||||
return `/api/items/${libraryItem.id}/cover?token=${userToken}&ts=${lastUpdate}`
|
||||
},
|
||||
getLibraryItemCoverSrcById: (state, getters, rootState, rootGetters) => (libraryItemId, placeholder = '/book_placeholder.jpg') => {
|
||||
if (!libraryItemId) return placeholder
|
||||
var userToken = rootGetters['user/getToken']
|
||||
if (process.env.NODE_ENV !== 'production') { // Testing
|
||||
return `http://localhost:3333/api/items/${libraryItemId}/cover?token=${userToken}`
|
||||
}
|
||||
return `/api/items/${libraryItemId}/cover?token=${userToken}`
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -9,6 +9,7 @@ export const state = () => ({
|
||||
streamLibraryItem: null,
|
||||
streamEpisodeId: null,
|
||||
streamIsPlaying: false,
|
||||
playerQueueItems: [],
|
||||
playerIsFullscreen: false,
|
||||
editModalTab: 'details',
|
||||
showEditModal: false,
|
||||
@ -144,14 +145,19 @@ export const mutations = {
|
||||
state.streamLibraryItem = null
|
||||
state.streamEpisodeId = null
|
||||
state.streamIsPlaying = false
|
||||
state.playerQueueItems = []
|
||||
} else {
|
||||
state.streamLibraryItem = payload.libraryItem
|
||||
state.streamEpisodeId = payload.episodeId || null
|
||||
state.playerQueueItems = payload.queueItems || []
|
||||
}
|
||||
},
|
||||
setIsPlaying(state, isPlaying) {
|
||||
state.streamIsPlaying = isPlaying
|
||||
},
|
||||
setPlayerQueueItems(state, items) {
|
||||
state.playerQueueItems = items || []
|
||||
},
|
||||
showEditModal(state, libraryItem) {
|
||||
state.editModalTab = 'details'
|
||||
state.selectedLibraryItem = libraryItem
|
||||
|
@ -11,6 +11,7 @@ module.exports = {
|
||||
safelist: [
|
||||
'bg-success',
|
||||
'bg-red-600',
|
||||
'bg-yellow-400',
|
||||
'text-green-500',
|
||||
'py-1.5',
|
||||
'bg-info',
|
||||
|
Loading…
Reference in New Issue
Block a user