mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Update:Podcast episode table is lazy loaded #1549
This commit is contained in:
		
							parent
							
								
									160c83df4a
								
							
						
					
					
						commit
						021adf3104
					
				| @ -12,7 +12,7 @@ | |||||||
|       </div> |       </div> | ||||||
|     </div> |     </div> | ||||||
|     <transition name="slide"> |     <transition name="slide"> | ||||||
|       <div class="w-full" v-show="showFiles"> |       <div class="w-full" v-if="showFiles"> | ||||||
|         <table class="text-sm tracksTable"> |         <table class="text-sm tracksTable"> | ||||||
|           <tr> |           <tr> | ||||||
|             <th class="text-left px-4">{{ $strings.LabelPath }}</th> |             <th class="text-left px-4">{{ $strings.LabelPath }}</th> | ||||||
| @ -70,7 +70,7 @@ export default { | |||||||
|     }, |     }, | ||||||
|     audioFiles() { |     audioFiles() { | ||||||
|       if (this.libraryItem.mediaType === 'podcast') { |       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 || [] |       return this.libraryItem.media?.audioFiles || [] | ||||||
|     }, |     }, | ||||||
|  | |||||||
| @ -1,19 +1,23 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="w-full px-2 py-3 overflow-hidden relative border-b border-white border-opacity-10" @mouseover="mouseover" @mouseleave="mouseleave"> |   <div :id="`lazy-episode-${index}`" class="w-full h-full cursor-pointer" @mouseover="mouseover" @mouseleave="mouseleave"> | ||||||
|     <div v-if="episode" class="flex items-center cursor-pointer" :class="{ 'opacity-70': isSelected || selectionMode }" @click="clickedEpisode"> |     <div class="flex" @click="clickedEpisode"> | ||||||
|       <div class="flex-grow px-2"> |       <div class="flex-grow"> | ||||||
|         <div class="flex items-center"> |         <div class="flex items-center"> | ||||||
|           <span class="text-sm font-semibold">{{ title }}</span> |           <span class="text-sm font-semibold">{{ episodeTitle }}</span> | ||||||
|           <widgets-podcast-type-indicator :type="episode.episodeType" /> |           <widgets-podcast-type-indicator :type="episodeType" /> | ||||||
|         </div> |         </div> | ||||||
| 
 | 
 | ||||||
|         <p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5" v-html="subtitle"></p> |         <div class="h-10 flex items-center mt-1.5 mb-0.5"> | ||||||
|         <div class="flex justify-between pt-2 max-w-xl"> |           <p class="text-sm text-gray-200 episode-subtitle" v-html="episodeSubtitle"></p> | ||||||
|           <p v-if="episode.season" class="text-sm text-gray-300">Season #{{ episode.season }}</p> |         </div> | ||||||
|           <p v-if="episode.episode" class="text-sm text-gray-300">Episode #{{ episode.episode }}</p> |         <div class="h-8 flex items-center"> | ||||||
|           <p v-if="episode.chapters?.length" class="text-sm text-gray-300">{{ episode.chapters.length }} Chapters</p> |           <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> |             <p v-if="publishedAt" class="text-sm text-gray-300">Published {{ $formatDate(publishedAt, dateFormat) }}</p> | ||||||
|           </div> |           </div> | ||||||
|  |         </div> | ||||||
| 
 | 
 | ||||||
|         <div class="flex items-center pt-2"> |         <div class="flex items-center pt-2"> | ||||||
|           <button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick"> |           <button class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer focus:outline-none" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click.stop="playClick"> | ||||||
| @ -37,10 +41,11 @@ | |||||||
|           <ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" /> |           <ui-icon-btn v-if="userCanDelete" icon="close" borderless @click="removeClick" /> | ||||||
|         </div> |         </div> | ||||||
|       </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> | ||||||
|     <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="flex h-full items-center"> | ||||||
|         <div class="mx-1"> |         <div class="mx-1"> | ||||||
|           <ui-checkbox v-model="isSelected" @input="selectedUpdated" checkbox-bg="bg" /> |           <ui-checkbox v-model="isSelected" @input="selectedUpdated" checkbox-bg="bg" /> | ||||||
| @ -55,84 +60,89 @@ | |||||||
| <script> | <script> | ||||||
| export default { | export default { | ||||||
|   props: { |   props: { | ||||||
|  |     index: Number, | ||||||
|     libraryItemId: String, |     libraryItemId: String, | ||||||
|     episode: { |     episode: { | ||||||
|       type: Object, |       type: Object, | ||||||
|       default: () => {} |       default: () => null | ||||||
|     }, |     } | ||||||
|     selectionMode: Boolean |  | ||||||
|   }, |   }, | ||||||
|   data() { |   data() { | ||||||
|     return { |     return { | ||||||
|       isProcessingReadUpdate: false, |       isProcessingReadUpdate: false, | ||||||
|       processingRemove: false, |       processingRemove: false, | ||||||
|       isHovering: false, |       isHovering: false, | ||||||
|       isSelected: false |       isSelected: false, | ||||||
|  |       isSelectionMode: false | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
|  |     store() { | ||||||
|  |       return this.$store || this.$nuxt.$store | ||||||
|  |     }, | ||||||
|  |     axios() { | ||||||
|  |       return this.$axios || this.$nuxt.$axios | ||||||
|  |     }, | ||||||
|     userCanUpdate() { |     userCanUpdate() { | ||||||
|       return this.$store.getters['user/getUserCanUpdate'] |       return this.store.getters['user/getUserCanUpdate'] | ||||||
|     }, |     }, | ||||||
|     userCanDelete() { |     userCanDelete() { | ||||||
|       return this.$store.getters['user/getUserCanDelete'] |       return this.store.getters['user/getUserCanDelete'] | ||||||
|     }, |     }, | ||||||
|     audioFile() { |     episodeId() { | ||||||
|       return this.episode.audioFile |       return this.episode?.id || '' | ||||||
|     }, |     }, | ||||||
|     title() { |     episodeTitle() { | ||||||
|       return this.episode.title || '' |       return this.episode?.title || '' | ||||||
|     }, |     }, | ||||||
|     subtitle() { |     episodeSubtitle() { | ||||||
|       return this.episode.subtitle || this.description |       return this.episode?.subtitle || '' | ||||||
|     }, |     }, | ||||||
|     description() { |     episodeType() { | ||||||
|       return this.episode.description || '' |       return this.episode?.episodeType || '' | ||||||
|     }, |     }, | ||||||
|     duration() { |     publishedAt() { | ||||||
|       return this.$secondsToTimestamp(this.episode.duration) |       return this.episode?.publishedAt | ||||||
|     }, |     }, | ||||||
|     libraryItemIdStreaming() { |     dateFormat() { | ||||||
|       return this.$store.getters['getLibraryItemIdStreaming'] |       return this.store.state.serverSettings.dateFormat | ||||||
|     }, |  | ||||||
|     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 |  | ||||||
|     }, |     }, | ||||||
|     itemProgress() { |     itemProgress() { | ||||||
|       return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id) |       return this.store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episodeId) | ||||||
|     }, |     }, | ||||||
|     itemProgressPercent() { |     itemProgressPercent() { | ||||||
|       return this.itemProgress ? this.itemProgress.progress : 0 |       return this.itemProgress?.progress || 0 | ||||||
|     }, |     }, | ||||||
|     userIsFinished() { |     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() { |     timeRemaining() { | ||||||
|       if (this.streamIsPlaying) return 'Playing' |       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' |       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` |       return `${this.$elapsedPretty(remaining)} left` | ||||||
|     }, |  | ||||||
|     publishedAt() { |  | ||||||
|       return this.episode.publishedAt |  | ||||||
|     }, |  | ||||||
|     dateFormat() { |  | ||||||
|       return this.$store.state.serverSettings.dateFormat |  | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|     clickAddToPlaylist() { |     setSelectionMode(isSelectionMode) { | ||||||
|       this.$emit('addToPlaylist', this.episode) |       this.isSelectionMode = isSelectionMode | ||||||
|  |       if (!this.isSelectionMode) this.isSelected = false | ||||||
|     }, |     }, | ||||||
|     clickedEpisode() { |     clickedEpisode() { | ||||||
|       this.$emit('view', this.episode) |       this.$emit('view', this.episode) | ||||||
| @ -150,16 +160,23 @@ export default { | |||||||
|     mouseleave() { |     mouseleave() { | ||||||
|       this.isHovering = false |       this.isHovering = false | ||||||
|     }, |     }, | ||||||
|     clickEdit() { |  | ||||||
|       this.$emit('edit', this.episode) |  | ||||||
|     }, |  | ||||||
|     playClick() { |     playClick() { | ||||||
|       if (this.streamIsPlaying) { |       if (this.streamIsPlaying) { | ||||||
|         this.$eventBus.$emit('pause-item') |         const eventBus = this.$eventBus || this.$nuxt.$eventBus | ||||||
|  |         eventBus.$emit('pause-item') | ||||||
|       } else { |       } else { | ||||||
|         this.$emit('play', this.episode) |         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) { |     toggleFinished(confirmed = false) { | ||||||
|       if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) { |       if (!this.userIsFinished && this.itemProgressPercent > 0 && !confirmed) { | ||||||
|         const payload = { |         const payload = { | ||||||
| @ -171,37 +188,47 @@ export default { | |||||||
|           }, |           }, | ||||||
|           type: 'yesNo' |           type: 'yesNo' | ||||||
|         } |         } | ||||||
|         this.$store.commit('globals/setConfirmPrompt', payload) |         this.store.commit('globals/setConfirmPrompt', payload) | ||||||
|         return |         return | ||||||
|       } |       } | ||||||
| 
 | 
 | ||||||
|       var updatePayload = { |       const updatePayload = { | ||||||
|         isFinished: !this.userIsFinished |         isFinished: !this.userIsFinished | ||||||
|       } |       } | ||||||
|       this.isProcessingReadUpdate = true |       this.isProcessingReadUpdate = true | ||||||
|       this.$axios |       this.axios | ||||||
|         .$patch(`/api/me/progress/${this.libraryItemId}/${this.episode.id}`, updatePayload) |         .$patch(`/api/me/progress/${this.libraryItemId}/${this.episodeId}`, updatePayload) | ||||||
|         .then(() => { |         .then(() => { | ||||||
|           this.isProcessingReadUpdate = false |           this.isProcessingReadUpdate = false | ||||||
|         }) |         }) | ||||||
|         .catch((error) => { |         .catch((error) => { | ||||||
|           console.error('Failed', error) |           console.error('Failed', error) | ||||||
|           this.isProcessingReadUpdate = false |           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() { |     removeClick() { | ||||||
|       this.$emit('remove', this.episode) |       this.$emit('remove', this.episode) | ||||||
|     }, |     }, | ||||||
|     queueBtnClick() { |     destroy() { | ||||||
|       if (this.isQueued) { |       // destroy the vue listeners, etc | ||||||
|         // Remove from queue |       this.$destroy() | ||||||
|         this.$store.commit('removeItemFromQueue', { libraryItemId: this.libraryItemId, episodeId: this.episode.id }) | 
 | ||||||
|       } else { |       // remove the element from the DOM | ||||||
|         // Add to queue |       if (this.$el && this.$el.parentNode) { | ||||||
|         this.$emit('addToQueue', this.episode) |         this.$el.parentNode.removeChild(this.$el) | ||||||
|       } |       } else if (this.$el && this.$el.remove) { | ||||||
|  |         this.$el.remove() | ||||||
|       } |       } | ||||||
|     } |     } | ||||||
|  |   }, | ||||||
|  |   mounted() {} | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
| @ -1,5 +1,5 @@ | |||||||
| <template> | <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 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"> |       <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> |         <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> |           <ui-btn :disabled="processing" small class="ml-2 h-9" @click="clearSelected">{{ $strings.ButtonCancel }}</ui-btn> | ||||||
|         </template> |         </template> | ||||||
|         <template v-else> |         <template v-else> | ||||||
|           <controls-filter-select v-model="filterKey" :items="filterItems" class="w-36 h-9 md: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" /> |           <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" /> |           <div class="flex-grow md:hidden" /> | ||||||
|           <ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" /> |           <ui-context-menu-dropdown v-if="contextMenuItems.length" :items="contextMenuItems" class="ml-1" @action="contextMenuAction" /> | ||||||
|         </template> |         </template> | ||||||
|       </div> |       </div> | ||||||
|     </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"> |     <div v-if="episodes.length" class="w-full py-3 mx-auto flex"> | ||||||
|       <form @submit.prevent="submit" class="flex flex-grow"> |       <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" /> |         <ui-text-input v-model="search" @input="inputUpdate" type="search" :placeholder="$strings.PlaceholderSearchEpisode" class="flex-grow mr-2 text-sm md:text-base" /> | ||||||
|       </form> |       </form> | ||||||
|     </div> |     </div> | ||||||
|     <template v-for="episode in episodesList"> |     <div class="relative min-h-[176px]"> | ||||||
|       <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 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> |       </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" /> |     <modals-podcast-remove-episode v-model="showPodcastRemoveModal" @input="removeEpisodeModalToggled" :library-item="libraryItem" :episodes="episodesToRemove" @clearSelected="clearSelected" /> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| <script> | <script> | ||||||
|  | import Vue from 'vue' | ||||||
|  | import LazyEpisodeRow from './LazyEpisodeRow.vue' | ||||||
|  | 
 | ||||||
| export default { | export default { | ||||||
|   props: { |   props: { | ||||||
|     libraryItem: { |     libraryItem: { | ||||||
| @ -60,7 +73,15 @@ export default { | |||||||
|       processing: false, |       processing: false, | ||||||
|       search: null, |       search: null, | ||||||
|       searchTimeout: null, |       searchTimeout: null, | ||||||
|       searchText: null |       searchText: null, | ||||||
|  |       isSearching: false, | ||||||
|  |       totalEpisodes: 0, | ||||||
|  |       episodesPerPage: null, | ||||||
|  |       episodeIndexesMounted: [], | ||||||
|  |       episodeComponentRefs: {}, | ||||||
|  |       windowHeight: 0, | ||||||
|  |       episodesTableOffsetTop: 0, | ||||||
|  |       episodeRowHeight: 176 | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   watch: { |   watch: { | ||||||
| @ -194,13 +215,19 @@ export default { | |||||||
|     submit() {}, |     submit() {}, | ||||||
|     inputUpdate() { |     inputUpdate() { | ||||||
|       clearTimeout(this.searchTimeout) |       clearTimeout(this.searchTimeout) | ||||||
|  |       this.isSearching = true | ||||||
|  |       let searchStart = this.searchText | ||||||
|       this.searchTimeout = setTimeout(() => { |       this.searchTimeout = setTimeout(() => { | ||||||
|         if (!this.search || !this.search.trim()) { |         this.isSearching = false | ||||||
|  |         if (!this.search?.trim()) { | ||||||
|           this.searchText = '' |           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 }) { |     contextMenuAction({ action }) { | ||||||
|       if (action === 'quick-match-episodes') { |       if (action === 'quick-match-episodes') { | ||||||
| @ -304,24 +331,30 @@ export default { | |||||||
|       if (!val) this.episodesToRemove = [] |       if (!val) this.episodesToRemove = [] | ||||||
|     }, |     }, | ||||||
|     clearSelected() { |     clearSelected() { | ||||||
|       const episodeRows = this.$refs.episodeRow |  | ||||||
|       if (episodeRows && episodeRows.length) { |  | ||||||
|         for (const epRow of episodeRows) { |  | ||||||
|           if (epRow) epRow.isSelected = false |  | ||||||
|         } |  | ||||||
|       } |  | ||||||
|       this.selectedEpisodes = [] |       this.selectedEpisodes = [] | ||||||
|  |       this.setSelectionModeForEpisodes() | ||||||
|     }, |     }, | ||||||
|     removeSelectedEpisodes() { |     removeSelectedEpisodes() { | ||||||
|       this.episodesToRemove = this.selectedEpisodes |       this.episodesToRemove = this.selectedEpisodes | ||||||
|       this.showPodcastRemoveModal = true |       this.showPodcastRemoveModal = true | ||||||
|     }, |     }, | ||||||
|     episodeSelected({ isSelected, episode }) { |     episodeSelected({ isSelected, episode }) { | ||||||
|  |       let isSelectionModeBefore = this.isSelectionMode | ||||||
|       if (isSelected) { |       if (isSelected) { | ||||||
|         this.selectedEpisodes.push(episode) |         this.selectedEpisodes.push(episode) | ||||||
|       } else { |       } else { | ||||||
|         this.selectedEpisodes = this.selectedEpisodes.filter((ep) => ep.id !== episode.id) |         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) { |     playEpisode(episode) { | ||||||
|       const queueItems = [] |       const queueItems = [] | ||||||
| @ -367,12 +400,143 @@ export default { | |||||||
|       this.$store.commit('globals/setSelectedEpisode', episode) |       this.$store.commit('globals/setSelectedEpisode', episode) | ||||||
|       this.$store.commit('globals/setShowViewPodcastEpisodeModal', true) |       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() { |     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() { |   mounted() { | ||||||
|  |     this.episodesCopy = this.episodes.map((ep) => ({ ...ep })) | ||||||
|  |     this.initListeners() | ||||||
|     this.init() |     this.init() | ||||||
|  |   }, | ||||||
|  |   beforeDestroy() { | ||||||
|  |     this.removeListeners() | ||||||
|   } |   } | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
| @ -1,6 +1,6 @@ | |||||||
| <template> | <template> | ||||||
|   <div id="page-wrapper" class="bg-bg page overflow-hidden" :class="streamLibraryItem ? 'streaming' : ''"> |   <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="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="w-full flex justify-center lg:block lg:w-52" style="min-width: 208px"> | ||||||
|           <div class="relative group" style="height: fit-content"> |           <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" /> |           <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" /> |           <tables-chapters-table v-if="chapters.length" :library-item="libraryItem" class="mt-6" /> | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -49,6 +49,7 @@ class LibraryItemController { | |||||||
|           item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()] |           item.episodesDownloading = [this.podcastManager.currentDownload.toJSONForClient()] | ||||||
|         } |         } | ||||||
|       } |       } | ||||||
|  | 
 | ||||||
|       return res.json(item) |       return res.json(item) | ||||||
|     } |     } | ||||||
|     res.json(req.libraryItem) |     res.json(req.libraryItem) | ||||||
|  | |||||||
| @ -48,13 +48,15 @@ class PodcastEpisode { | |||||||
|     this.guid = episode.guid || null |     this.guid = episode.guid || null | ||||||
|     this.pubDate = episode.pubDate |     this.pubDate = episode.pubDate | ||||||
|     this.chapters = episode.chapters?.map(ch => ({ ...ch })) || [] |     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.publishedAt = episode.publishedAt | ||||||
|     this.addedAt = episode.addedAt |     this.addedAt = episode.addedAt | ||||||
|     this.updatedAt = episode.updatedAt |     this.updatedAt = episode.updatedAt | ||||||
| 
 | 
 | ||||||
|  |     if (this.audioFile) { | ||||||
|       this.audioFile.index = 1 // Only 1 audio file per episode
 |       this.audioFile.index = 1 // Only 1 audio file per episode
 | ||||||
|     } |     } | ||||||
|  |   } | ||||||
| 
 | 
 | ||||||
|   toJSON() { |   toJSON() { | ||||||
|     return { |     return { | ||||||
| @ -73,7 +75,7 @@ class PodcastEpisode { | |||||||
|       guid: this.guid, |       guid: this.guid, | ||||||
|       pubDate: this.pubDate, |       pubDate: this.pubDate, | ||||||
|       chapters: this.chapters.map(ch => ({ ...ch })), |       chapters: this.chapters.map(ch => ({ ...ch })), | ||||||
|       audioFile: this.audioFile.toJSON(), |       audioFile: this.audioFile?.toJSON() || null, | ||||||
|       publishedAt: this.publishedAt, |       publishedAt: this.publishedAt, | ||||||
|       addedAt: this.addedAt, |       addedAt: this.addedAt, | ||||||
|       updatedAt: this.updatedAt |       updatedAt: this.updatedAt | ||||||
| @ -97,8 +99,8 @@ class PodcastEpisode { | |||||||
|       guid: this.guid, |       guid: this.guid, | ||||||
|       pubDate: this.pubDate, |       pubDate: this.pubDate, | ||||||
|       chapters: this.chapters.map(ch => ({ ...ch })), |       chapters: this.chapters.map(ch => ({ ...ch })), | ||||||
|       audioFile: this.audioFile.toJSON(), |       audioFile: this.audioFile?.toJSON() || null, | ||||||
|       audioTrack: this.audioTrack.toJSON(), |       audioTrack: this.audioTrack?.toJSON() || null, | ||||||
|       publishedAt: this.publishedAt, |       publishedAt: this.publishedAt, | ||||||
|       addedAt: this.addedAt, |       addedAt: this.addedAt, | ||||||
|       updatedAt: this.updatedAt, |       updatedAt: this.updatedAt, | ||||||
| @ -108,6 +110,7 @@ class PodcastEpisode { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   get audioTrack() { |   get audioTrack() { | ||||||
|  |     if (!this.audioFile) return null | ||||||
|     const audioTrack = new AudioTrack() |     const audioTrack = new AudioTrack() | ||||||
|     audioTrack.setData(this.libraryItemId, this.audioFile, 0) |     audioTrack.setData(this.libraryItemId, this.audioFile, 0) | ||||||
|     return audioTrack |     return audioTrack | ||||||
| @ -116,9 +119,9 @@ class PodcastEpisode { | |||||||
|     return [this.audioTrack] |     return [this.audioTrack] | ||||||
|   } |   } | ||||||
|   get duration() { |   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() { |   get enclosureUrl() { | ||||||
|     return this.enclosure?.url || null |     return this.enclosure?.url || null | ||||||
|   } |   } | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user