Update:Podcast episode table is lazy loaded #1549

This commit is contained in:
advplyr 2023-12-31 14:51:01 -06:00
parent 160c83df4a
commit 021adf3104
6 changed files with 300 additions and 105 deletions

View File

@ -12,7 +12,7 @@
</div>
</div>
<transition name="slide">
<div class="w-full" v-show="showFiles">
<div class="w-full" v-if="showFiles">
<table class="text-sm tracksTable">
<tr>
<th class="text-left px-4">{{ $strings.LabelPath }}</th>
@ -70,7 +70,7 @@ export default {
},
audioFiles() {
if (this.libraryItem.mediaType === 'podcast') {
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile) || []
return this.libraryItem.media?.episodes.map((ep) => ep.audioFile).filter((af) => af) || []
}
return this.libraryItem.media?.audioFiles || []
},

View File

@ -1,18 +1,22 @@
<template>
<div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave">
<div v-if="episode" class="flex items-center cursor-pointer" :class="{ 'opacity-70': isSelected || selectionMode }" @click="clickedEpisode">
<div class="flex-grow px-2">
<div :id="`lazy-episode-${index}`" class="w-full h-full cursor-pointer" @mouseover="mouseover" @mouseleave="mouseleave">
<div class="flex" @click="clickedEpisode">
<div class="flex-grow">
<div class="flex items-center">
<span class="text-sm font-semibold">{{ title }}</span>
<widgets-podcast-type-indicator :type="episode.episodeType" />
<span class="text-sm font-semibold">{{ episodeTitle }}</span>
<widgets-podcast-type-indicator :type="episodeType" />
</div>
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle"></p>
<div class="flex justify-between pt-2 max-w-xl">
<p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
<p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
<p v-if="episode.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p>
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
<div class="h-10 flex items-center mt-1.5 mb-0.5">
<p class="text-sm text-gray-200 episode-subtitle" v-html="episodeSubtitle"></p>
</div>
<div class="h-8 flex items-center">
<div class="w-full inline-flex justify-between max-w-xl">
<p v-if="episode?.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p>
<p v-if="episode?.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p>
<p v-if="episode?.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p>
<p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p>
</div>
</div>
<div class="flex items-center pt-2">
@ -37,10 +41,11 @@
<ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" />
</div>
</div>
<div v-if="isHovering || isSelected || selectionMode" class="hidden md:block w-12 min-w-12" />
<div v-if="isHovering || isSelected || isSelectionMode" class="hidden md:block w-12 min-w-12" />
</div>
<div v-if="isSelected || selectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10 z-10 cursor-pointer" @click.stop="clickedSelectionBg" />
<div class="hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20" :class="!isHovering && !isSelected && !selectionMode ? 'translate-x-24' : 'translate-x-0'">
<div v-if="isSelected || isSelectionMode" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-10 z-10 cursor-pointer" @click.stop="clickedSelectionBg" />
<div class="hidden md:block md:w-12 md:min-w-12 md:-right-0 md:absolute md:top-0 h-full transform transition-transform z-20" :class="!isHovering && !isSelected && !isSelectionMode ? 'translate-x-24' : 'translate-x-0'">
<div class="flex h-full items-center">
<div class="mx-1">
<ui-checkbox v-model="isSelected" @input="selectedUpdated" checkbox-bg="bg" />
@ -55,84 +60,89 @@
<script>
export default {
props: {
index: Number,
libraryItemId: String,
episode: {
type: Object,
default: () => {}
},
selectionMode: Boolean
default: () => null
}
},
data() {
return {
isProcessingReadUpdate: false,
processingRemove: false,
isHovering: false,
isSelected: false
isSelected: false,
isSelectionMode: false
}
},
computed: {
store() {
return this.$store || this.$nuxt.$store
},
axios() {
return this.$axios || this.$nuxt.$axios
},
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
return this.store.getters['user/getUserCanUpdate']
},
userCanDelete() {
return this.$store.getters['user/getUserCanDelete']
return this.store.getters['user/getUserCanDelete']
},
audioFile() {
return this.episode.audioFile
episodeId() {
return this.episode?.id || ''
},
title() {
return this.episode.title || ''
episodeTitle() {
return this.episode?.title || ''
},
subtitle() {
return this.episode.subtitle || this.description
episodeSubtitle() {
return this.episode?.subtitle || ''
},
description() {
return this.episode.description || ''
episodeType() {
return this.episode?.episodeType || ''
},
duration() {
return this.$secondsToTimestamp(this.episode.duration)
publishedAt() {
return this.episode?.publishedAt
},
libraryItemIdStreaming() {
return this.$store.getters['getLibraryItemIdStreaming']
},
isStreamingFromDifferentLibrary() {
return this.$store.getters['getIsStreamingFromDifferentLibrary']
},
isStreaming() {
return this.$store.getters['getIsMediaStreaming'](this.libraryItemId, this.episode.id)
},
isQueued() {
return this.$store.getters['getIsMediaQueued'](this.libraryItemId, this.episode.id)
},
streamIsPlaying() {
return this.$store.state.streamIsPlaying && this.isStreaming
dateFormat() {
return this.store.state.serverSettings.dateFormat
},
itemProgress() {
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId)
},
itemProgressPercent() {
return this.itemProgress ? this.itemProgress.progress : 0
return this.itemProgress?.progress || 0
},
userIsFinished() {
return this.itemProgress ? !!this.itemProgress.isFinished : false
return !!this.itemProgress?.isFinished
},
libraryItemIdStreaming() {
return this.store.getters['getLibraryItemIdStreaming']
},
isStreamingFromDifferentLibrary() {
return this.store.getters['getIsStreamingFromDifferentLibrary']
},
isStreaming() {
return this.store.getters['getIsMediaStreaming'](this.libraryItemId, this.episodeId)
},
isQueued() {
return this.store.getters['getIsMediaQueued'](this.libraryItemId, this.episodeId)
},
streamIsPlaying() {
return this.store.state.streamIsPlaying && this.isStreaming
},
timeRemaining() {
if (this.streamIsPlaying) return 'Playing'
if (!this.itemProgress) return this.$elapsedPretty(this.episode.duration)
if (!this.itemProgress) return this.$elapsedPretty(this.episode?.duration || 0)
if (this.userIsFinished) return 'Finished'
var remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime)
const remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime)
return `${this.$elapsedPretty(remaining)} left`
},
publishedAt() {
return this.episode.publishedAt
},
dateFormat() {
return this.$store.state.serverSettings.dateFormat
}
},
methods: {
clickAddToPlaylist() {
this.$emit('addToPlaylist', this.episode)
setSelectionMode(isSelectionMode) {
this.isSelectionMode = isSelectionMode
if (!this.isSelectionMode) this.isSelected = false
},
clickedEpisode() {
this.$emit('view', this.episode)
@ -150,16 +160,23 @@ export default {
mouseleave() {
this.isHovering = false
},
clickEdit() {
this.$emit('edit', this.episode)
},
playClick() {
if (this.streamIsPlaying) {
this.$eventBus.$emit('pause-item')
const eventBus = this.$eventBus || this.$nuxt.$eventBus
eventBus.$emit('pause-item')
} else {
this.$emit('play', this.episode)
}
},
queueBtnClick() {
if (this.isQueued) {
// Remove from queue
this.store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId, episodeId: this.episodeId })
} else {
// Add to queue
this.$emit('addToQueue', this.episode)
}
},
toggleFinished(confirmed = false) {
if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) {
const payload = {
@ -171,37 +188,47 @@ export default {
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
this.store.commit('globals/setConfirmPrompt', payload)
return
}
var updatePayload = {
const updatePayload = {
isFinished: !this.userIsFinished
}
this.isProcessingReadUpdate = true
this.$axios
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload)
this.axios
.$patch(`/api/me/progress/${this.libraryItemId}/${this.episodeId}`, updatePayload)
.then(() => {
this.isProcessingReadUpdate = false
})
.catch((error) => {
console.error('Failed', error)
this.isProcessingReadUpdate = false
this.$toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
const toast = this.$toast || this.$nuxt.$toast
toast.error(updatePayload.isFinished ? this.$strings.ToastItemMarkedAsFinishedFailed : this.$strings.ToastItemMarkedAsNotFinishedFailed)
})
},
clickAddToPlaylist() {
this.$emit('addToPlaylist', this.episode)
},
clickEdit() {
this.$emit('edit', this.episode)
},
removeClick() {
this.$emit('remove', this.episode)
},
queueBtnClick() {
if (this.isQueued) {
// Remove from queue
this.$store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId, episodeId: this.episode.id })
} else {
// Add to queue
this.$emit('addToQueue', this.episode)
destroy() {
// destroy the vue listeners, etc
this.$destroy()
// remove the element from the DOM
if (this.$el && this.$el.parentNode) {
this.$el.parentNode.removeChild(this.$el)
} else if (this.$el && this.$el.remove) {
this.$el.remove()
}
}
}
},
mounted() {}
}
</script>
</script>

View File

@ -1,5 +1,5 @@
<template>
<div class="w-full py-6">
<div id="lazy-episodes-table" class="w-full py-6">
<div class="flex flex-wrap flex-col md:flex-row md:items-center mb-4">
<div class="flex items-center flex-nowrap whitespace-nowrap mb-2 md:mb-0">
<p class="text-lg mb-0 font-semibold">{{ $strings.HeaderEpisodes }}</p>
@ -18,28 +18,41 @@
<ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn>
</template>
<template v-else>
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" />
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" />
<controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md:ml-4" @change="filterSortChanged" />
<controls-sort-select v-model="sortKey" :descending.sync="sortDesc" :items="sortItems" class="w-44 md:w-48 h-9 ml-1 sm:ml-4" @change="filterSortChanged" />
<div class="flex-grow md:hidden" />
<ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" />
</template>
</div>
</div>
<p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p>
<!-- <p v-if="!episodes.length" class="py-4 text-center text-lg">{{ $strings.MessageNoEpisodes }}</p> -->
<div v-if="episodes.length" class="w-full py-3 mx-auto flex">
<form @submit.prevent="submit" class="flex flex-grow">
<ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" />
</form>
</div>
<template v-for="episode in episodesList">
<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" @addToQueue="addEpisodeToQueue" @addToPlaylist="addToPlaylist" />
</template>
<div class="relative min-h-[176px]">
<template v-for="episode in totalEpisodes">
<div :key="episode" :id="`episode-${episode - 1}`" class="w-full h-44 px-2 py-3 overflow-hidden relative border-b border-white/10">
<!-- episode is mounted here -->
</div>
</template>
<div v-if="isSearching" class="w-full h-full absolute inset-0 flex justify-center py-12" :class="{ 'bg-black/50': totalEpisodes }">
<ui-loading-indicator />
</div>
<div v-else-if="!totalEpisodes" class="h-44 flex items-center justify-center">
<p class="text-lg">{{ $strings.MessageNoEpisodes }}</p>
</div>
</div>
<modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" />
</div>
</template>
<script>
import Vue from 'vue'
import LazyEpisodeRow from './LazyEpisodeRow.vue'
export default {
props: {
libraryItem: {
@ -60,7 +73,15 @@ export default {
processing: false,
search: null,
searchTimeout: null,
searchText: null
searchText: null,
isSearching: false,
totalEpisodes: 0,
episodesPerPage: null,
episodeIndexesMounted: [],
episodeComponentRefs: {},
windowHeight: 0,
episodesTableOffsetTop: 0,
episodeRowHeight: 176
}
},
watch: {
@ -194,13 +215,19 @@ export default {
submit() {},
inputUpdate() {
clearTimeout(this.searchTimeout)
this.isSearching = true
let searchStart = this.searchText
this.searchTimeout = setTimeout(() => {
if (!this.search || !this.search.trim()) {
this.isSearching = false
if (!this.search?.trim()) {
this.searchText = ''
return
} else {
this.searchText = this.search.toLowerCase().trim()
}
this.searchText = this.search.toLowerCase().trim()
}, 500)
if (searchStart !== this.searchText) {
this.init()
}
}, 750)
},
contextMenuAction({ action }) {
if (action === 'quick-match-episodes') {
@ -304,24 +331,30 @@ export default {
if (!val) this.episodesToRemove = []
},
clearSelected() {
const episodeRows = this.$refs.episodeRow
if (episodeRows && episodeRows.length) {
for (const epRow of episodeRows) {
if (epRow) epRow.isSelected = false
}
}
this.selectedEpisodes = []
this.setSelectionModeForEpisodes()
},
removeSelectedEpisodes() {
this.episodesToRemove = this.selectedEpisodes
this.showPodcastRemoveModal = true
},
episodeSelected({ isSelected, episode }) {
let isSelectionModeBefore = this.isSelectionMode
if (isSelected) {
this.selectedEpisodes.push(episode)
} else {
this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id)
}
if (this.isSelectionMode !== isSelectionModeBefore) {
this.setSelectionModeForEpisodes()
}
},
setSelectionModeForEpisodes() {
for (const key in this.episodeComponentRefs) {
if (this.episodeComponentRefs[key]?.setSelectionMode) {
this.episodeComponentRefs[key].setSelectionMode(this.isSelectionMode)
}
}
},
playEpisode(episode) {
const queueItems = []
@ -367,12 +400,143 @@ export default {
this.$store.commit('globals/setSelectedEpisode', episode)
this.$store.commit('globals/setShowViewPodcastEpisodeModal', true)
},
destroyEpisodeComponents() {
for (const key in this.episodeComponentRefs) {
if (this.episodeComponentRefs[key]?.destroy) {
this.episodeComponentRefs[key].destroy()
}
}
this.episodeComponentRefs = {}
this.episodeIndexesMounted = []
},
mountEpisode(index) {
const episodeEl = document.getElementById(`episode-${index}`)
if (!episodeEl) {
console.warn('Episode row el not found at ' + index)
return
}
this.episodeIndexesMounted.push(index)
if (this.episodeComponentRefs[index]) {
const episodeComponent = this.episodeComponentRefs[index]
episodeEl.appendChild(episodeComponent.$el)
if (this.isSelectionMode) {
episodeComponent.setSelectionMode(true)
if (this.selectedEpisodes.some((i) => i.id === episodeComponent.episodeId)) {
episodeComponent.isSelected = true
} else {
episodeComponent.isSelected = false
}
} else {
episodeComponent.setSelectionMode(false)
}
} else {
const _this = this
const ComponentClass = Vue.extend(LazyEpisodeRow)
const instance = new ComponentClass({
propsData: {
index,
libraryItemId: this.libraryItem.id,
episode: this.episodesList[index]
},
created() {
this.$on('selected', (payload) => {
_this.episodeSelected(payload)
})
this.$on('view', (payload) => {
_this.viewEpisode(payload)
})
this.$on('play', (payload) => {
_this.playEpisode(payload)
})
this.$on('addToQueue', (payload) => {
_this.addEpisodeToQueue(payload)
})
this.$on('remove', (payload) => {
_this.removeEpisode(payload)
})
this.$on('edit', (payload) => {
_this.editEpisode(payload)
})
this.$on('addToPlaylist', (payload) => {
_this.addToPlaylist(payload)
})
}
})
this.episodeComponentRefs[index] = instance
instance.$mount()
episodeEl.appendChild(instance.$el)
if (this.isSelectionMode) {
instance.setSelectionMode(true)
if (this.selectedEpisodes.some((i) => i.id === this.episodesList[index].id)) {
instance.isSelected = true
}
}
}
},
mountEpisodes(startIndex, endIndex) {
for (let i = startIndex; i < endIndex; i++) {
if (!this.episodeIndexesMounted.includes(i)) {
this.mountEpisode(i)
}
}
},
scroll(evt) {
if (!evt?.target?.scrollTop) return
const scrollTop = Math.max(evt.target.scrollTop - this.episodesTableOffsetTop, 0)
let firstEpisodeIndex = Math.floor(scrollTop / this.episodeRowHeight)
let lastEpisodeIndex = Math.ceil((scrollTop + this.windowHeight) / this.episodeRowHeight)
lastEpisodeIndex = Math.min(this.totalEpisodes - 1, lastEpisodeIndex)
this.episodeIndexesMounted = this.episodeIndexesMounted.filter((_index) => {
if (_index < firstEpisodeIndex || _index >= lastEpisodeIndex) {
const el = document.getElementById(`lazy-episode-${_index}`)
if (el) el.remove()
return false
}
return true
})
this.mountEpisodes(firstEpisodeIndex, lastEpisodeIndex + 1)
},
initListeners() {
const itemPageWrapper = document.getElementById('item-page-wrapper')
if (itemPageWrapper) {
itemPageWrapper.addEventListener('scroll', this.scroll)
}
},
removeListeners() {
const itemPageWrapper = document.getElementById('item-page-wrapper')
if (itemPageWrapper) {
itemPageWrapper.removeEventListener('scroll', this.scroll)
}
},
filterSortChanged() {
this.init()
},
init() {
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
this.destroyEpisodeComponents()
this.totalEpisodes = this.episodesList.length
const lazyEpisodesTableEl = document.getElementById('lazy-episodes-table')
this.episodesTableOffsetTop = (lazyEpisodesTableEl?.offsetTop || 0) + 64
this.windowHeight = window.innerHeight
this.episodesPerPage = Math.ceil(this.windowHeight / this.episodeRowHeight)
this.$nextTick(() => {
this.mountEpisodes(0, Math.min(this.episodesPerPage, this.totalEpisodes))
})
}
},
mounted() {
this.episodesCopy = this.episodes.map((ep) => ({ ...ep }))
this.initListeners()
this.init()
},
beforeDestroy() {
this.removeListeners()
}
}
</script>

View File

@ -1,6 +1,6 @@
<template>
<div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamLibraryItem ? 'streaming' : ''">
<div class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8">
<div id="item-page-wrapper" class="w-full h-full overflow-y-auto px-2 py-6 lg:p-8">
<div class="flex flex-col lg:flex-row max-w-6xl mx-auto">
<div class="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px">
<div class="relative group" style="height: fit-content">
@ -136,7 +136,7 @@
<widgets-audiobook-data v-if="tracks.length" :library-item-id="libraryItemId" :is-file="isFile" :media="media" />
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" />
<tables-podcast-lazy-episodes-table v-if="isPodcast" :library-item="libraryItem" />
<tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" />

View File

@ -49,6 +49,7 @@ class LibraryItemController {
item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()]
}
}
return res.json(item)
}
res.json(req.libraryItem)

View File

@ -48,12 +48,14 @@ class PodcastEpisode {
this.guid = episode.guid || null
this.pubDate = episode.pubDate
this.chapters = episode.chapters?.map(ch => ({ ...ch })) || []
this.audioFile = new AudioFile(episode.audioFile)
this.audioFile = episode.audioFile ? new AudioFile(episode.audioFile) : null
this.publishedAt = episode.publishedAt
this.addedAt = episode.addedAt
this.updatedAt = episode.updatedAt
this.audioFile.index = 1 // Only 1 audio file per episode
if (this.audioFile) {
this.audioFile.index = 1 // Only 1 audio file per episode
}
}
toJSON() {
@ -73,7 +75,7 @@ class PodcastEpisode {
guid: this.guid,
pubDate: this.pubDate,
chapters: this.chapters.map(ch => ({ ...ch })),
audioFile: this.audioFile.toJSON(),
audioFile: this.audioFile?.toJSON() || null,
publishedAt: this.publishedAt,
addedAt: this.addedAt,
updatedAt: this.updatedAt
@ -97,8 +99,8 @@ class PodcastEpisode {
guid: this.guid,
pubDate: this.pubDate,
chapters: this.chapters.map(ch => ({ ...ch })),
audioFile: this.audioFile.toJSON(),
audioTrack: this.audioTrack.toJSON(),
audioFile: this.audioFile?.toJSON() || null,
audioTrack: this.audioTrack?.toJSON() || null,
publishedAt: this.publishedAt,
addedAt: this.addedAt,
updatedAt: this.updatedAt,
@ -108,6 +110,7 @@ class PodcastEpisode {
}
get audioTrack() {
if (!this.audioFile) return null
const audioTrack = new AudioTrack()
audioTrack.setData(this.libraryItemId, this.audioFile, 0)
return audioTrack
@ -116,9 +119,9 @@ class PodcastEpisode {
return [this.audioTrack]
}
get duration() {
return this.audioFile.duration
return this.audioFile?.duration || 0
}
get size() { return this.audioFile.metadata.size }
get size() { return this.audioFile?.metadata.size || 0 }
get enclosureUrl() {
return this.enclosure?.url || null
}