mirror of
				https://github.com/advplyr/audiobookshelf.git
				synced 2025-10-27 11:18:14 +01:00 
			
		
		
		
	Update user audiobook progress model, add mark as read/not read, download individual tracks
This commit is contained in:
		
							parent
							
								
									1f2afe4d92
								
							
						
					
					
						commit
						41c391e87b
					
				| @ -83,6 +83,10 @@ | |||||||
|   box-shadow: 2px 8px 6px #111111aa; |   box-shadow: 2px 8px 6px #111111aa; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  | .box-shadow-md-up { | ||||||
|  |   box-shadow: 0px -8px 8px #11111144; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| .box-shadow-lg-up { | .box-shadow-lg-up { | ||||||
|   box-shadow: 0px -12px 8px #111111ee; |   box-shadow: 0px -12px 8px #111111ee; | ||||||
| } | } | ||||||
| @ -93,4 +97,4 @@ | |||||||
| 
 | 
 | ||||||
| .box-shadow-book { | .box-shadow-book { | ||||||
|   box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166; |   box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166; | ||||||
| } | } | ||||||
|  | |||||||
| @ -26,7 +26,7 @@ | |||||||
|               <span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span> |               <span class="material-icons" :class="selected ? 'text-yellow-400' : ''" :style="{ fontSize: 1.25 * sizeMultiplier + 'rem' }">{{ selected ? 'radio_button_checked' : 'radio_button_unchecked' }}</span> | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|           <div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 bg-yellow-400 shadow-sm" :style="{ width: width * userProgressPercent + 'px' }"></div> |           <div v-show="!isSelectionMode" class="absolute bottom-0 left-0 h-1 shadow-sm" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: width * userProgressPercent + 'px' }"></div> | ||||||
| 
 | 
 | ||||||
|           <ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0"> |           <ui-tooltip v-if="showError" :text="errorText" class="absolute bottom-4 left-0"> | ||||||
|             <div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300"> |             <div :style="{ height: 1.5 * sizeMultiplier + 'rem', width: 2.5 * sizeMultiplier + 'rem' }" class="bg-error rounded-r-full shadow-md flex items-center justify-end border-r border-b border-red-300"> | ||||||
| @ -125,6 +125,9 @@ export default { | |||||||
|     userProgressPercent() { |     userProgressPercent() { | ||||||
|       return this.userProgress ? this.userProgress.progress || 0 : 0 |       return this.userProgress ? this.userProgress.progress || 0 : 0 | ||||||
|     }, |     }, | ||||||
|  |     userIsRead() { | ||||||
|  |       return this.userProgress ? !!this.userProgress.isRead : false | ||||||
|  |     }, | ||||||
|     showError() { |     showError() { | ||||||
|       return this.hasMissingParts || this.hasInvalidParts |       return this.hasMissingParts || this.hasInvalidParts | ||||||
|     }, |     }, | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ | |||||||
|         <div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div> |         <div :key="tab.id" class="w-28 rounded-t-lg flex items-center justify-center mr-1 cursor-pointer hover:bg-bg font-book border-t border-l border-r border-black-300 tab" :class="selectedTab === tab.id ? 'tab-selected bg-bg pb-px' : 'bg-primary text-gray-400'" @click="selectTab(tab.id)">{{ tab.title }}</div> | ||||||
|       </template> |       </template> | ||||||
|     </div> |     </div> | ||||||
|     <div class="px-4 w-full h-full text-sm py-6 rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300"> |     <div class="w-full h-full text-sm rounded-b-lg rounded-tr-lg bg-bg shadow-lg border border-black-300"> | ||||||
|       <keep-alive> |       <keep-alive> | ||||||
|         <component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" /> |         <component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" /> | ||||||
|       </keep-alive> |       </keep-alive> | ||||||
| @ -22,7 +22,6 @@ | |||||||
| export default { | export default { | ||||||
|   data() { |   data() { | ||||||
|     return { |     return { | ||||||
|       selectedTab: 'details', |  | ||||||
|       processing: false, |       processing: false, | ||||||
|       audiobook: null, |       audiobook: null, | ||||||
|       fetchOnShow: false, |       fetchOnShow: false, | ||||||
| @ -79,6 +78,14 @@ export default { | |||||||
|         this.$store.commit('setShowEditModal', val) |         this.$store.commit('setShowEditModal', val) | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
|  |     selectedTab: { | ||||||
|  |       get() { | ||||||
|  |         return this.$store.state.editModalTab | ||||||
|  |       }, | ||||||
|  |       set(val) { | ||||||
|  |         this.$store.commit('setEditModalTab', val) | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     height() { |     height() { | ||||||
|       var maxHeightAllowed = window.innerHeight - 150 |       var maxHeightAllowed = window.innerHeight - 150 | ||||||
|       return Math.min(maxHeightAllowed, 650) |       return Math.min(maxHeightAllowed, 650) | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="w-full h-full overflow-hidden overflow-y-auto px-1"> |   <div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6"> | ||||||
|     <div class="flex"> |     <div class="flex"> | ||||||
|       <div class="relative"> |       <div class="relative"> | ||||||
|         <cards-book-cover :audiobook="audiobook" /> |         <cards-book-cover :audiobook="audiobook" /> | ||||||
|  | |||||||
| @ -1,60 +1,64 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="w-full h-full overflow-hidden overflow-y-auto px-1"> |   <div class="w-full h-full relative"> | ||||||
|     <div v-if="userProgress" class="bg-success bg-opacity-40 rounded-md w-full px-4 py-1 mb-4 border border-success border-opacity-50"> |     <div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto"> | ||||||
|       <div class="w-full flex items-center"> |       <!-- <div v-if="userProgress" class="bg-success bg-opacity-40 rounded-md w-full px-4 py-1 mb-4 border border-success border-opacity-50"> | ||||||
|         <p> |         <div class="w-full flex items-center"> | ||||||
|           Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span> |           <p> | ||||||
|         </p> |             Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span> | ||||||
|         <div class="flex-grow" /> |           </p> | ||||||
|         <ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn> |           <div class="flex-grow" /> | ||||||
|       </div> |           <ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn> | ||||||
|  |         </div> | ||||||
|  |       </div> --> | ||||||
|  |       <form @submit.prevent="submitForm"> | ||||||
|  |         <ui-text-input-with-label v-model="details.title" label="Title" /> | ||||||
|  | 
 | ||||||
|  |         <ui-text-input-with-label v-model="details.subtitle" label="Subtitle" class="mt-2" /> | ||||||
|  | 
 | ||||||
|  |         <div class="flex mt-2 -mx-1"> | ||||||
|  |           <div class="w-3/4 px-1"> | ||||||
|  |             <ui-text-input-with-label v-model="details.author" label="Author" /> | ||||||
|  |           </div> | ||||||
|  |           <div class="flex-grow px-1"> | ||||||
|  |             <ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" /> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  | 
 | ||||||
|  |         <div class="flex mt-2 -mx-1"> | ||||||
|  |           <div class="w-3/4 px-1"> | ||||||
|  |             <ui-input-dropdown v-model="details.series" label="Series" :items="series" /> | ||||||
|  |           </div> | ||||||
|  |           <div class="flex-grow px-1"> | ||||||
|  |             <ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" /> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  | 
 | ||||||
|  |         <ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" /> | ||||||
|  | 
 | ||||||
|  |         <div class="flex mt-2 -mx-1"> | ||||||
|  |           <div class="w-1/2 px-1"> | ||||||
|  |             <ui-multi-select v-model="details.genres" label="Genres" :items="genres" /> | ||||||
|  |           </div> | ||||||
|  |           <div class="flex-grow px-1"> | ||||||
|  |             <ui-multi-select v-model="newTags" label="Tags" :items="tags" /> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  | 
 | ||||||
|  |         <div class="flex mt-2 -mx-1"> | ||||||
|  |           <div class="w-1/2 px-1"> | ||||||
|  |             <ui-text-input-with-label v-model="details.narrarator" label="Narrarator" /> | ||||||
|  |           </div> | ||||||
|  |         </div> | ||||||
|  |       </form> | ||||||
|     </div> |     </div> | ||||||
|     <form @submit.prevent="submitForm"> |  | ||||||
|       <ui-text-input-with-label v-model="details.title" label="Title" /> |  | ||||||
| 
 | 
 | ||||||
|       <ui-text-input-with-label v-model="details.subtitle" label="Subtitle" class="mt-2" /> |     <div class="absolute bottom-0 left-0 w-full py-4 bg-bg" :class="isScrollable ? 'box-shadow-md-up' : 'border-t border-primary border-opacity-50'"> | ||||||
| 
 |       <div class="flex px-4"> | ||||||
|       <div class="flex mt-2 -mx-1"> |  | ||||||
|         <div class="w-3/4 px-1"> |  | ||||||
|           <ui-text-input-with-label v-model="details.author" label="Author" /> |  | ||||||
|         </div> |  | ||||||
|         <div class="flex-grow px-1"> |  | ||||||
|           <ui-text-input-with-label v-model="details.publishYear" type="number" label="Publish Year" /> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
| 
 |  | ||||||
|       <div class="flex mt-2 -mx-1"> |  | ||||||
|         <div class="w-3/4 px-1"> |  | ||||||
|           <ui-input-dropdown v-model="details.series" label="Series" :items="series" /> |  | ||||||
|         </div> |  | ||||||
|         <div class="flex-grow px-1"> |  | ||||||
|           <ui-text-input-with-label v-model="details.volumeNumber" label="Volume #" /> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
| 
 |  | ||||||
|       <ui-textarea-with-label v-model="details.description" :rows="3" label="Description" class="mt-2" /> |  | ||||||
| 
 |  | ||||||
|       <div class="flex mt-2 -mx-1"> |  | ||||||
|         <div class="w-1/2 px-1"> |  | ||||||
|           <ui-multi-select v-model="details.genres" label="Genres" :items="genres" /> |  | ||||||
|         </div> |  | ||||||
|         <div class="flex-grow px-1"> |  | ||||||
|           <ui-multi-select v-model="newTags" label="Tags" :items="tags" /> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
| 
 |  | ||||||
|       <div class="flex mt-2 -mx-1"> |  | ||||||
|         <div class="w-1/2 px-1"> |  | ||||||
|           <ui-text-input-with-label v-model="details.narrarator" label="Narrarator" /> |  | ||||||
|         </div> |  | ||||||
|       </div> |  | ||||||
| 
 |  | ||||||
|       <div class="flex py-4 mt-2"> |  | ||||||
|         <ui-btn color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn> |         <ui-btn color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn> | ||||||
|         <div class="flex-grow" /> |         <div class="flex-grow" /> | ||||||
|         <ui-btn type="submit">Submit</ui-btn> |         <ui-btn type="submit">Submit</ui-btn> | ||||||
|       </div> |       </div> | ||||||
|     </form> |     </div> | ||||||
|   </div> |   </div> | ||||||
| </template> | </template> | ||||||
| 
 | 
 | ||||||
| @ -81,7 +85,8 @@ export default { | |||||||
|         genres: [] |         genres: [] | ||||||
|       }, |       }, | ||||||
|       newTags: [], |       newTags: [], | ||||||
|       resettingProgress: false |       resettingProgress: false, | ||||||
|  |       isScrollable: false | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   watch: { |   watch: { | ||||||
| @ -107,12 +112,12 @@ export default { | |||||||
|     book() { |     book() { | ||||||
|       return this.audiobook ? this.audiobook.book || {} : {} |       return this.audiobook ? this.audiobook.book || {} : {} | ||||||
|     }, |     }, | ||||||
|     userAudiobook() { |     // userAudiobook() { | ||||||
|       return this.$store.getters['user/getUserAudiobook'](this.audiobookId) |     //   return this.$store.getters['user/getUserAudiobook'](this.audiobookId) | ||||||
|     }, |     // }, | ||||||
|     userProgress() { |     // userProgress() { | ||||||
|       return this.userAudiobook ? this.userAudiobook.progress : 0 |     //   return this.userAudiobook ? this.userAudiobook.progress : 0 | ||||||
|     }, |     // }, | ||||||
|     genres() { |     genres() { | ||||||
|       return this.$store.state.audiobooks.genres |       return this.$store.state.audiobooks.genres | ||||||
|     }, |     }, | ||||||
| @ -189,7 +194,40 @@ export default { | |||||||
|             this.isProcessing = false |             this.isProcessing = false | ||||||
|           }) |           }) | ||||||
|       } |       } | ||||||
|  |     }, | ||||||
|  |     checkIsScrollable() { | ||||||
|  |       this.$nextTick(() => { | ||||||
|  |         if (this.$refs.formWrapper) { | ||||||
|  |           if (this.$refs.formWrapper.scrollHeight > this.$refs.formWrapper.clientHeight) { | ||||||
|  |             this.isScrollable = true | ||||||
|  |           } else { | ||||||
|  |             this.isScrollable = false | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  |       }) | ||||||
|  |     }, | ||||||
|  |     setResizeObserver() { | ||||||
|  |       try { | ||||||
|  |         this.$nextTick(() => { | ||||||
|  |           const resizeObserver = new ResizeObserver(() => { | ||||||
|  |             this.checkIsScrollable() | ||||||
|  |           }) | ||||||
|  |           resizeObserver.observe(this.$refs.formWrapper) | ||||||
|  |         }) | ||||||
|  |       } catch (error) { | ||||||
|  |         console.error('Failed to set resize observer') | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|  |   }, | ||||||
|  |   mounted() { | ||||||
|  |     // this.init() | ||||||
|  |     this.setResizeObserver() | ||||||
|   } |   } | ||||||
| } | } | ||||||
| </script> | </script> | ||||||
|  | 
 | ||||||
|  | <style scoped> | ||||||
|  | .details-form-wrapper { | ||||||
|  |   height: calc(100% - 70px); | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @ -1,5 +1,5 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="w-full h-full overflow-hidden overflow-y-auto px-1"> |   <div class="w-full h-full overflow-hidden overflow-y-auto px-4 py-6"> | ||||||
|     <div class="w-full border border-black-200 p-4 my-4"> |     <div class="w-full border border-black-200 p-4 my-4"> | ||||||
|       <p class="text-center text-lg mb-4 pb-8 border-b border-black-200"> |       <p class="text-center text-lg mb-4 pb-8 border-b border-black-200"> | ||||||
|         <span class="text-error">Experimental Feature!</span> If your audiobook is made up of multiple audio files, this will concatenate them into a single file. The file type will be the same as the first track. Preparing downloads can take anywhere from a few seconds to several minutes and will be stored in |         <span class="text-error">Experimental Feature!</span> If your audiobook is made up of multiple audio files, this will concatenate them into a single file. The file type will be the same as the first track. Preparing downloads can take anywhere from a few seconds to several minutes and will be stored in | ||||||
| @ -13,8 +13,8 @@ | |||||||
|           <p v-if="singleAudioDownloadFailed" class="text-error mb-2">Download Failed</p> |           <p v-if="singleAudioDownloadFailed" class="text-error mb-2">Download Failed</p> | ||||||
|           <p v-if="singleAudioDownloadReady" class="text-success mb-2">Download Ready!</p> |           <p v-if="singleAudioDownloadReady" class="text-success mb-2">Download Ready!</p> | ||||||
|           <p v-if="singleAudioDownloadExpired" class="text-error mb-2">Download Expired</p> |           <p v-if="singleAudioDownloadExpired" class="text-error mb-2">Download Expired</p> | ||||||
| 
 |           <a v-if="isSingleTrack" :href="`/local/${singleTrackPath}`" class="btn outline-none rounded-md shadow-md relative border border-gray-600 px-4 py-2 bg-primary">Download Track</a> | ||||||
|           <ui-btn v-if="!singleAudioDownloadReady" :loading="singleAudioDownloadPending" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn> |           <ui-btn v-else-if="!singleAudioDownloadReady" :loading="singleAudioDownloadPending" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn> | ||||||
|           <ui-btn v-else @click="downloadWithProgress">Download</ui-btn> |           <ui-btn v-else @click="downloadWithProgress">Download</ui-btn> | ||||||
|         </div> |         </div> | ||||||
|       </div> |       </div> | ||||||
| @ -81,6 +81,14 @@ export default { | |||||||
|     }, |     }, | ||||||
|     zipBundleDownload() { |     zipBundleDownload() { | ||||||
|       return this.downloads.find((d) => d.type === 'zipBundle') |       return this.downloads.find((d) => d.type === 'zipBundle') | ||||||
|  |     }, | ||||||
|  |     isSingleTrack() { | ||||||
|  |       if (!this.audiobook.tracks) return false | ||||||
|  |       return this.audiobook.tracks.length === 1 | ||||||
|  |     }, | ||||||
|  |     singleTrackPath() { | ||||||
|  |       if (!this.isSingleTrack) return null | ||||||
|  |       return this.audiobook.tracks[0].path | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="w-full h-full overflow-hidden"> |   <div class="w-full h-full overflow-hidden px-4 py-6"> | ||||||
|     <form @submit.prevent="submitSearch"> |     <form @submit.prevent="submitSearch"> | ||||||
|       <div class="flex items-center justify-start -mx-1 h-20"> |       <div class="flex items-center justify-start -mx-1 h-20"> | ||||||
|         <div class="w-72 px-1"> |         <div class="w-72 px-1"> | ||||||
|  | |||||||
| @ -1,5 +1,5 @@ | |||||||
| <template> | <template> | ||||||
|   <div class="w-full h-full overflow-y-auto overflow-x-hidden"> |   <div class="w-full h-full overflow-y-auto overflow-x-hidden px-4 py-6"> | ||||||
|     <div class="flex mb-4"> |     <div class="flex mb-4"> | ||||||
|       <nuxt-link :to="`/audiobook/${audiobook.id}/edit`"> |       <nuxt-link :to="`/audiobook/${audiobook.id}/edit`"> | ||||||
|         <ui-btn color="primary">Edit Track Order</ui-btn> |         <ui-btn color="primary">Edit Track Order</ui-btn> | ||||||
| @ -11,6 +11,7 @@ | |||||||
|         <th class="text-left">Filename</th> |         <th class="text-left">Filename</th> | ||||||
|         <th class="text-left">Size</th> |         <th class="text-left">Size</th> | ||||||
|         <th class="text-left">Duration</th> |         <th class="text-left">Duration</th> | ||||||
|  |         <th class="text-center">Download</th> | ||||||
|       </tr> |       </tr> | ||||||
|       <template v-for="track in tracks"> |       <template v-for="track in tracks"> | ||||||
|         <tr :key="track.index"> |         <tr :key="track.index"> | ||||||
| @ -26,6 +27,9 @@ | |||||||
|           <td class="font-mono"> |           <td class="font-mono"> | ||||||
|             {{ $secondsToTimestamp(track.duration) }} |             {{ $secondsToTimestamp(track.duration) }} | ||||||
|           </td> |           </td> | ||||||
|  |           <td class="font-mono text-center"> | ||||||
|  |             <a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a> | ||||||
|  |           </td> | ||||||
|         </tr> |         </tr> | ||||||
|       </template> |       </template> | ||||||
|     </table> |     </table> | ||||||
|  | |||||||
| @ -19,6 +19,7 @@ | |||||||
|             <th class="text-left">Filename</th> |             <th class="text-left">Filename</th> | ||||||
|             <th class="text-left">Size</th> |             <th class="text-left">Size</th> | ||||||
|             <th class="text-left">Duration</th> |             <th class="text-left">Duration</th> | ||||||
|  |             <th class="text-center">Download</th> | ||||||
|           </tr> |           </tr> | ||||||
|           <template v-for="track in tracks"> |           <template v-for="track in tracks"> | ||||||
|             <tr :key="track.index"> |             <tr :key="track.index"> | ||||||
| @ -34,6 +35,9 @@ | |||||||
|               <td class="font-mono"> |               <td class="font-mono"> | ||||||
|                 {{ $secondsToTimestamp(track.duration) }} |                 {{ $secondsToTimestamp(track.duration) }} | ||||||
|               </td> |               </td> | ||||||
|  |               <td class="text-center"> | ||||||
|  |                 <a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a> | ||||||
|  |               </td> | ||||||
|             </tr> |             </tr> | ||||||
|           </template> |           </template> | ||||||
|         </table> |         </table> | ||||||
|  | |||||||
							
								
								
									
										49
									
								
								client/components/ui/IconBtn.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								client/components/ui/IconBtn.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,49 @@ | |||||||
|  | <template> | ||||||
|  |   <button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn"> | ||||||
|  |     <span class="material-icons icon-text">{{ icon }}</span> | ||||||
|  |   </button> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script> | ||||||
|  | export default { | ||||||
|  |   props: { | ||||||
|  |     icon: String, | ||||||
|  |     disabled: Boolean | ||||||
|  |   }, | ||||||
|  |   data() { | ||||||
|  |     return {} | ||||||
|  |   }, | ||||||
|  |   computed: {}, | ||||||
|  |   methods: { | ||||||
|  |     clickBtn(e) { | ||||||
|  |       if (this.disabled) { | ||||||
|  |         e.preventDefault() | ||||||
|  |         return | ||||||
|  |       } | ||||||
|  |       this.$emit('click') | ||||||
|  |       e.stopPropagation() | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   mounted() {} | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <style> | ||||||
|  | button.icon-btn::before { | ||||||
|  |   content: ''; | ||||||
|  |   position: absolute; | ||||||
|  |   border-radius: 6px; | ||||||
|  |   top: 0; | ||||||
|  |   left: 0; | ||||||
|  |   width: 100%; | ||||||
|  |   height: 100%; | ||||||
|  |   background-color: rgba(255, 255, 255, 0); | ||||||
|  |   transition: all 0.1s ease-in-out; | ||||||
|  | } | ||||||
|  | button.icon-btn:hover:not(:disabled)::before { | ||||||
|  |   background-color: rgba(255, 255, 255, 0.1); | ||||||
|  | } | ||||||
|  | button.icon-btn:disabled::before { | ||||||
|  |   background-color: rgba(0, 0, 0, 0.2); | ||||||
|  | } | ||||||
|  | </style> | ||||||
							
								
								
									
										82
									
								
								client/components/ui/ReadIconBtn.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								client/components/ui/ReadIconBtn.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,82 @@ | |||||||
|  | <template> | ||||||
|  |   <button class="icon-btn rounded-md bg-primary border border-gray-600 flex items-center justify-center h-9 w-9 relative" @click="clickBtn"> | ||||||
|  |     <div class="w-5 h-5 text-white relative"> | ||||||
|  |       <svg v-if="isRead" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> | ||||||
|  |         <path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-9 15l-5-5 1.41-1.41L10 13.17l7.59-7.59L19 7l-9 9z" /> | ||||||
|  |       </svg> | ||||||
|  |       <svg v-else xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor"> | ||||||
|  |         <path d="M19 1H5c-1.1 0-1.99.9-1.99 2L3 15.93c0 .69.35 1.3.88 1.66L12 23l8.11-5.41c.53-.36.88-.97.88-1.66L21 3c0-1.1-.9-2-2-2zm-7 19.6l-7-4.66V3h14v12.93l-7 4.67zm-2.01-7.42l-2.58-2.59L6 12l4 4 8-8-1.42-1.42z" /> | ||||||
|  |       </svg> | ||||||
|  |       <!-- <svg v-if="!isRead" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 482.204 482.204" xml:space="preserve" fill="currentColor"> | ||||||
|  |         <path | ||||||
|  |           d="M83.127,344.477c54.602,1.063,101.919,9.228,136.837,23.613c0.596,0.244,1.227,0.366,1.852,0.366 | ||||||
|  | 			c0.95,0,1.895-0.279,2.706-0.822c1.349-0.902,2.158-2.418,2.158-4.041l0.019-261.017c0-1.992-1.215-3.783-3.066-4.519 | ||||||
|  | 			L85.019,42.899c-1.496-0.596-3.193-0.411-4.527,0.494c-1.334,0.906-2.133,2.413-2.133,4.025v292.197 | ||||||
|  | 			C78.359,342.264,80.479,344.425,83.127,344.477z" | ||||||
|  |         /> | ||||||
|  |         <path | ||||||
|  |           d="M480.244,89.256c-1.231-0.917-2.824-1.198-4.297-0.759l-49.025,14.657 | ||||||
|  | 			c-2.06,0.616-3.471,2.51-3.471,4.659v252.151c0,0,0.218,3.978-3.97,3.978c-4.796,0-7.946,0-7.946,0 | ||||||
|  | 			c-39.549,0-113.045,4.105-160.93,31.6l-9.504,5.442l-9.503-5.442c-47.886-27.494-121.381-31.6-160.93-31.6c0,0-8.099,0-10.142,0 | ||||||
|  | 			c-1.891,0-1.775-2.272-1.775-2.271V107.813c0-2.149-1.411-4.043-3.47-4.659L6.256,88.497c-1.473-0.439-3.066-0.158-4.298,0.759 | ||||||
|  | 			S0,91.619,0,93.155v305.069c0,1.372,0.581,2.681,1.597,3.604c1.017,0.921,2.375,1.372,3.741,1.236 | ||||||
|  | 			c14.571-1.429,37.351-3.131,63.124-3.131c56.606,0,102.097,8.266,131.576,23.913c4.331,2.272,29.441,15.803,41.065,15.803 | ||||||
|  | 			c11.624,0,36.733-13.53,41.063-15.803c29.48-15.647,74.971-23.913,131.577-23.913c25.771,0,48.553,1.702,63.123,3.131 | ||||||
|  | 			c1.367,0.136,2.725-0.315,3.742-1.236c1.016-0.923,1.596-2.231,1.596-3.604V93.155C482.203,91.619,481.476,90.173,480.244,89.256z | ||||||
|  | 			" | ||||||
|  |         /> | ||||||
|  |         <path | ||||||
|  |           d="M257.679,367.634c0.812,0.543,1.757,0.822,2.706,0.822c0.626,0,1.256-0.122,1.853-0.366 | ||||||
|  | 			c34.917-14.386,82.235-22.551,136.837-23.613c2.648-0.052,4.769-2.213,4.769-4.861V47.418c0-1.613-0.799-3.12-2.133-4.025 | ||||||
|  | 			c-1.334-0.904-3.031-1.09-4.528-0.494L258.569,98.057c-1.851,0.736-3.065,2.527-3.065,4.519l0.019,261.017 | ||||||
|  | 			C255.521,365.216,256.331,366.732,257.679,367.634z" | ||||||
|  |         /> | ||||||
|  |       </svg> | ||||||
|  |       <svg v-else viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg"><path d="M19 2H6c-1.206 0-3 .799-3 3v14c0 2.201 1.794 3 3 3h15v-2H6.012C5.55 19.988 5 19.806 5 19c0-.101.009-.191.024-.273.112-.576.584-.717.988-.727H21V4a2 2 0 0 0-2-2zm0 9-2-1-2 1V4h4v7z" /></svg> --> | ||||||
|  |     </div> | ||||||
|  |   </button> | ||||||
|  | </template> | ||||||
|  | 
 | ||||||
|  | <script> | ||||||
|  | export default { | ||||||
|  |   props: { | ||||||
|  |     isRead: Boolean, | ||||||
|  |     disabled: Boolean | ||||||
|  |   }, | ||||||
|  |   data() { | ||||||
|  |     return {} | ||||||
|  |   }, | ||||||
|  |   computed: {}, | ||||||
|  |   methods: { | ||||||
|  |     clickBtn(e) { | ||||||
|  |       if (this.disabled) { | ||||||
|  |         e.preventDefault() | ||||||
|  |         return | ||||||
|  |       } | ||||||
|  |       this.$emit('click') | ||||||
|  |       e.stopPropagation() | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   mounted() {} | ||||||
|  | } | ||||||
|  | </script> | ||||||
|  | 
 | ||||||
|  | <style> | ||||||
|  | button.icon-btn::before { | ||||||
|  |   content: ''; | ||||||
|  |   position: absolute; | ||||||
|  |   border-radius: 6px; | ||||||
|  |   top: 0; | ||||||
|  |   left: 0; | ||||||
|  |   width: 100%; | ||||||
|  |   height: 100%; | ||||||
|  |   background-color: rgba(255, 255, 255, 0); | ||||||
|  |   transition: all 0.1s ease-in-out; | ||||||
|  | } | ||||||
|  | button.icon-btn:hover:not(:disabled)::before { | ||||||
|  |   background-color: rgba(255, 255, 255, 0.1); | ||||||
|  | } | ||||||
|  | button.icon-btn:disabled::before { | ||||||
|  |   background-color: rgba(0, 0, 0, 0.2); | ||||||
|  | } | ||||||
|  | </style> | ||||||
| @ -22,8 +22,19 @@ export default { | |||||||
|       isShowing: false |       isShowing: false | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|  |   watch: { | ||||||
|  |     text() { | ||||||
|  |       this.updateText() | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  |     updateText() { | ||||||
|  |       if (this.tooltip) { | ||||||
|  |         this.tooltip.innerHTML = this.text | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|     createTooltip() { |     createTooltip() { | ||||||
|  |       if (!this.$refs.box) return | ||||||
|       var boxChow = this.$refs.box.getBoundingClientRect() |       var boxChow = this.$refs.box.getBoundingClientRect() | ||||||
|       var top = 0 |       var top = 0 | ||||||
|       var left = 0 |       var left = 0 | ||||||
| @ -33,6 +44,9 @@ export default { | |||||||
|       } else if (this.direction === 'bottom') { |       } else if (this.direction === 'bottom') { | ||||||
|         top = boxChow.top + boxChow.height + 4 |         top = boxChow.top + boxChow.height + 4 | ||||||
|         left = boxChow.left |         left = boxChow.left | ||||||
|  |       } else if (this.direction === 'top') { | ||||||
|  |         top = boxChow.top - 24 | ||||||
|  |         left = boxChow.left | ||||||
|       } |       } | ||||||
|       var tooltip = document.createElement('div') |       var tooltip = document.createElement('div') | ||||||
|       tooltip.className = 'absolute px-2 bg-black bg-opacity-90 py-1 text-white pointer-events-none text-xs rounded shadow-lg' |       tooltip.className = 'absolute px-2 bg-black bg-opacity-90 py-1 text-white pointer-events-none text-xs rounded shadow-lg' | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
|   "name": "audiobookshelf-client", |   "name": "audiobookshelf-client", | ||||||
|   "version": "1.0.5", |   "version": "1.0.6", | ||||||
|   "description": "Audiobook manager and player", |   "description": "Audiobook manager and player", | ||||||
|   "main": "index.js", |   "main": "index.js", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|  | |||||||
| @ -5,7 +5,7 @@ | |||||||
|         <div class="w-52" style="min-width: 208px"> |         <div class="w-52" style="min-width: 208px"> | ||||||
|           <div class="relative"> |           <div class="relative"> | ||||||
|             <cards-book-cover :audiobook="audiobook" :width="208" /> |             <cards-book-cover :audiobook="audiobook" :width="208" /> | ||||||
|             <div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: 240 * progressPercent + 'px' }"></div> |             <div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :class="userIsRead ? 'bg-success' : 'bg-yellow-400'" :style="{ width: 208 * progressPercent + 'px' }"></div> | ||||||
|           </div> |           </div> | ||||||
|         </div> |         </div> | ||||||
|         <div class="flex-grow px-10"> |         <div class="flex-grow px-10"> | ||||||
| @ -22,24 +22,40 @@ | |||||||
|           <p class="text-gray-300 text-sm my-1"> |           <p class="text-gray-300 text-sm my-1"> | ||||||
|             {{ durationPretty }}<span class="px-4">{{ sizePretty }}</span> |             {{ durationPretty }}<span class="px-4">{{ sizePretty }}</span> | ||||||
|           </p> |           </p> | ||||||
|  |           <div v-if="progressPercent > 0 && progressPercent < 1" class="px-4 py-2 mt-4 bg-primary text-sm font-semibold rounded-md text-gray-200 relative max-w-max" :class="resettingProgress ? 'opacity-25' : ''"> | ||||||
|  |             <p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p> | ||||||
|  |             <p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p> | ||||||
|  |             <div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick"> | ||||||
|  |               <span class="material-icons text-sm">close</span> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  | 
 | ||||||
|           <div class="flex items-center pt-4"> |           <div class="flex items-center pt-4"> | ||||||
|             <ui-btn :disabled="streaming" color="success" :padding-x="4" class="flex items-center" @click="startStream"> |             <ui-btn :disabled="streaming" color="success" :padding-x="4" small class="flex items-center h-9 mr-2" @click="startStream"> | ||||||
|               <span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span> |               <span v-show="!streaming" class="material-icons -ml-2 pr-1 text-white">play_arrow</span> | ||||||
|               {{ streaming ? 'Streaming' : 'Play' }} |               {{ streaming ? 'Streaming' : 'Play' }} | ||||||
|             </ui-btn> |             </ui-btn> | ||||||
|             <ui-btn :padding-x="4" class="flex items-center ml-4" @click="editClick"><span class="material-icons text-white pr-2" style="font-size: 18px">edit</span>Edit</ui-btn> | 
 | ||||||
|  |             <ui-tooltip text="Edit" direction="top"> | ||||||
|  |               <ui-icon-btn icon="edit" class="mx-0.5" @click="editClick" /> | ||||||
|  |             </ui-tooltip> | ||||||
|  | 
 | ||||||
|  |             <ui-tooltip text="Download" direction="top"> | ||||||
|  |               <ui-icon-btn icon="download" class="mx-0.5" @click="downloadClick" /> | ||||||
|  |             </ui-tooltip> | ||||||
|  | 
 | ||||||
|  |             <ui-tooltip :text="isRead ? 'Mark as Not Read' : 'Mark as Read'" direction="top"> | ||||||
|  |               <ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="isRead" class="mx-0.5" @click="toggleRead" /> | ||||||
|  |             </ui-tooltip> | ||||||
| 
 | 
 | ||||||
|             <ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn> |             <ui-btn v-if="isDeveloperMode" class="mx-2" @click="openRssFeed">Open RSS Feed</ui-btn> | ||||||
| 
 | 
 | ||||||
|             <div v-if="progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 ml-4 relative" :class="resettingProgress ? 'opacity-25' : ''"> |             <div class="flex-grow" /> | ||||||
|               <p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p> |           </div> | ||||||
|               <p class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p> | 
 | ||||||
|               <div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick"> |           <div class="my-4"> | ||||||
|                 <span class="material-icons text-sm">close</span> |             <p class="text-sm text-gray-100">{{ description }}</p> | ||||||
|               </div> |  | ||||||
|             </div> |  | ||||||
|           </div> |           </div> | ||||||
|           <p class="text-sm my-4 text-gray-100">{{ description }}</p> |  | ||||||
| 
 | 
 | ||||||
|           <div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4"> |           <div v-if="missingParts.length" class="bg-error border-red-800 shadow-md p-4"> | ||||||
|             <p class="text-sm mb-2"> |             <p class="text-sm mb-2"> | ||||||
| @ -88,7 +104,17 @@ export default { | |||||||
|   }, |   }, | ||||||
|   data() { |   data() { | ||||||
|     return { |     return { | ||||||
|       resettingProgress: false |       isRead: false, | ||||||
|  |       resettingProgress: false, | ||||||
|  |       isProcessingReadUpdate: false | ||||||
|  |     } | ||||||
|  |   }, | ||||||
|  |   watch: { | ||||||
|  |     userIsRead: { | ||||||
|  |       immediate: true, | ||||||
|  |       handler(newVal) { | ||||||
|  |         this.isRead = newVal | ||||||
|  |       } | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   computed: { |   computed: { | ||||||
| @ -149,7 +175,7 @@ export default { | |||||||
|     }, |     }, | ||||||
|     authorTooltipText() { |     authorTooltipText() { | ||||||
|       var txt = ['FL: ' + this.authorFL || 'Not Set', 'LF: ' + this.authorLF || 'Not Set'] |       var txt = ['FL: ' + this.authorFL || 'Not Set', 'LF: ' + this.authorLF || 'Not Set'] | ||||||
|       return txt.join('\n') |       return txt.join('<br>') | ||||||
|     }, |     }, | ||||||
|     series() { |     series() { | ||||||
|       return this.book.series || null |       return this.book.series || null | ||||||
| @ -189,7 +215,7 @@ export default { | |||||||
|       return this.audiobook.audioFiles || [] |       return this.audiobook.audioFiles || [] | ||||||
|     }, |     }, | ||||||
|     description() { |     description() { | ||||||
|       return this.book.description || 'No Description' |       return this.book.description || '' | ||||||
|     }, |     }, | ||||||
|     userAudiobooks() { |     userAudiobooks() { | ||||||
|       return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {} |       return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {} | ||||||
| @ -200,6 +226,9 @@ export default { | |||||||
|     userCurrentTime() { |     userCurrentTime() { | ||||||
|       return this.userAudiobook ? this.userAudiobook.currentTime : 0 |       return this.userAudiobook ? this.userAudiobook.currentTime : 0 | ||||||
|     }, |     }, | ||||||
|  |     userIsRead() { | ||||||
|  |       return this.userAudiobook ? !!this.userAudiobook.isRead : false | ||||||
|  |     }, | ||||||
|     userTimeRemaining() { |     userTimeRemaining() { | ||||||
|       return this.duration - this.userCurrentTime |       return this.duration - this.userCurrentTime | ||||||
|     }, |     }, | ||||||
| @ -214,6 +243,23 @@ export default { | |||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   methods: { |   methods: { | ||||||
|  |     toggleRead() { | ||||||
|  |       var updatePayload = { | ||||||
|  |         isRead: !this.isRead | ||||||
|  |       } | ||||||
|  |       this.isProcessingReadUpdate = true | ||||||
|  |       this.$axios | ||||||
|  |         .$patch(`/api/user/audiobook/${this.audiobookId}`, updatePayload) | ||||||
|  |         .then(() => { | ||||||
|  |           this.isProcessingReadUpdate = false | ||||||
|  |           this.$toast.success(`"${this.title}" Marked as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) | ||||||
|  |         }) | ||||||
|  |         .catch((error) => { | ||||||
|  |           console.error('Failed', error) | ||||||
|  |           this.isProcessingReadUpdate = false | ||||||
|  |           this.$toast.error(`Failed to mark as ${updatePayload.isRead ? 'Read' : 'Not Read'}`) | ||||||
|  |         }) | ||||||
|  |     }, | ||||||
|     openRssFeed() { |     openRssFeed() { | ||||||
|       this.$axios |       this.$axios | ||||||
|         .$post('/api/feed', { audiobookId: this.audiobook.id }) |         .$post('/api/feed', { audiobookId: this.audiobook.id }) | ||||||
| @ -269,6 +315,9 @@ export default { | |||||||
|             this.resettingProgress = false |             this.resettingProgress = false | ||||||
|           }) |           }) | ||||||
|       } |       } | ||||||
|  |     }, | ||||||
|  |     downloadClick() { | ||||||
|  |       this.$store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'download' }) | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   mounted() { |   mounted() { | ||||||
|  | |||||||
| @ -3,6 +3,7 @@ import Vue from 'vue' | |||||||
| export const state = () => ({ | export const state = () => ({ | ||||||
|   serverSettings: null, |   serverSettings: null, | ||||||
|   streamAudiobook: null, |   streamAudiobook: null, | ||||||
|  |   editModalTab: 'details', | ||||||
|   showEditModal: false, |   showEditModal: false, | ||||||
|   selectedAudiobook: null, |   selectedAudiobook: null, | ||||||
|   playOnLoad: false, |   playOnLoad: false, | ||||||
| @ -63,9 +64,18 @@ export const mutations = { | |||||||
|     state.playOnLoad = val |     state.playOnLoad = val | ||||||
|   }, |   }, | ||||||
|   showEditModal(state, audiobook) { |   showEditModal(state, audiobook) { | ||||||
|  |     state.editModalTab = 'details' | ||||||
|     state.selectedAudiobook = audiobook |     state.selectedAudiobook = audiobook | ||||||
|     state.showEditModal = true |     state.showEditModal = true | ||||||
|   }, |   }, | ||||||
|  |   showEditModalOnTab(state, { audiobook, tab }) { | ||||||
|  |     state.editModalTab = tab | ||||||
|  |     state.selectedAudiobook = audiobook | ||||||
|  |     state.showEditModal = true | ||||||
|  |   }, | ||||||
|  |   setEditModalTab(state, tab) { | ||||||
|  |     state.editModalTab = tab | ||||||
|  |   }, | ||||||
|   setShowEditModal(state, val) { |   setShowEditModal(state, val) { | ||||||
|     state.showEditModal = val |     state.showEditModal = val | ||||||
|   }, |   }, | ||||||
|  | |||||||
| @ -10,7 +10,7 @@ | |||||||
|   <Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support> |   <Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support> | ||||||
|   <Project>https://github.com/advplyr/audiobookshelf</Project> |   <Project>https://github.com/advplyr/audiobookshelf</Project> | ||||||
|   <Overview>**(Android app in beta, try it out)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free & open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview> |   <Overview>**(Android app in beta, try it out)** Audiobook manager and player. Saves your progress, supports multiple accounts, stream all audio formats on the fly. No more switching between dozens of audio files for a single audiobook, Audiobookshelf shows you one audio track with skipping, seeking and adjustable playback speed. Free & open source mobile apps under construction, consider contributing by posting feedback, suggestions, feature requests on github or the forums.</Overview> | ||||||
|   <Category>MediaApp:Books MediaServer:Books Status:Beta</Category> |   <Category>MediaApp:Books MediaServer:Books</Category> | ||||||
|   <WebUI>http://[IP]:[PORT:80]</WebUI> |   <WebUI>http://[IP]:[PORT:80]</WebUI> | ||||||
|   <TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL> |   <TemplateURL>https://raw.githubusercontent.com/advplyr/docker-templates/master/audiobookshelf.xml</TemplateURL> | ||||||
|   <Icon>https://github.com/advplyr/audiobookshelf/raw/master/client/static/Logo.png</Icon> |   <Icon>https://github.com/advplyr/audiobookshelf/raw/master/client/static/Logo.png</Icon> | ||||||
|  | |||||||
| @ -1,6 +1,6 @@ | |||||||
| { | { | ||||||
|   "name": "audiobookshelf", |   "name": "audiobookshelf", | ||||||
|   "version": "1.0.5", |   "version": "1.0.6", | ||||||
|   "description": "Self-hosted audiobook server for managing and playing audiobooks.", |   "description": "Self-hosted audiobook server for managing and playing audiobooks.", | ||||||
|   "main": "index.js", |   "main": "index.js", | ||||||
|   "scripts": { |   "scripts": { | ||||||
|  | |||||||
| @ -36,6 +36,7 @@ class ApiController { | |||||||
|     this.router.patch('/match/:id', this.match.bind(this)) |     this.router.patch('/match/:id', this.match.bind(this)) | ||||||
| 
 | 
 | ||||||
|     this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this)) |     this.router.delete('/user/audiobook/:id', this.resetUserAudiobookProgress.bind(this)) | ||||||
|  |     this.router.patch('/user/audiobook/:id', this.updateUserAudiobookProgress.bind(this)) | ||||||
|     this.router.patch('/user/password', this.userChangePassword.bind(this)) |     this.router.patch('/user/password', this.userChangePassword.bind(this)) | ||||||
|     this.router.patch('/user/settings', this.userUpdateSettings.bind(this)) |     this.router.patch('/user/settings', this.userUpdateSettings.bind(this)) | ||||||
|     this.router.get('/users', this.getUsers.bind(this)) |     this.router.get('/users', this.getUsers.bind(this)) | ||||||
| @ -233,7 +234,16 @@ class ApiController { | |||||||
|   async resetUserAudiobookProgress(req, res) { |   async resetUserAudiobookProgress(req, res) { | ||||||
|     req.user.resetAudiobookProgress(req.params.id) |     req.user.resetAudiobookProgress(req.params.id) | ||||||
|     await this.db.updateEntity('user', req.user) |     await this.db.updateEntity('user', req.user) | ||||||
|     this.emitter('user_updated', req.user.toJSONForBrowser()) |     this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) | ||||||
|  |     res.sendStatus(200) | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   async updateUserAudiobookProgress(req, res) { | ||||||
|  |     var wasUpdated = req.user.updateAudiobookProgress(req.params.id, req.body) | ||||||
|  |     if (wasUpdated) { | ||||||
|  |       await this.db.updateEntity('user', req.user) | ||||||
|  |       this.clientEmitter(req.user.id, 'user_updated', req.user.toJSONForBrowser()) | ||||||
|  |     } | ||||||
|     res.sendStatus(200) |     res.sendStatus(200) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  | |||||||
| @ -134,11 +134,11 @@ class StreamManager { | |||||||
|       Logger.error('No User for client', client) |       Logger.error('No User for client', client) | ||||||
|       return |       return | ||||||
|     } |     } | ||||||
|     if (!client.user.updateAudiobookProgress) { |     if (!client.user.updateAudiobookProgressFromStream) { | ||||||
|       Logger.error('Invalid User for client', client) |       Logger.error('Invalid User for client', client) | ||||||
|       return |       return | ||||||
|     } |     } | ||||||
|     client.user.updateAudiobookProgress(client.stream) |     client.user.updateAudiobookProgressFromStream(client.stream) | ||||||
|     this.db.updateEntity('user', client.user) |     this.db.updateEntity('user', client.user) | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  | |||||||
							
								
								
									
										91
									
								
								server/objects/AudiobookProgress.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										91
									
								
								server/objects/AudiobookProgress.js
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,91 @@ | |||||||
|  | class AudiobookProgress { | ||||||
|  |   constructor(progress) { | ||||||
|  |     this.audiobookId = null | ||||||
|  |     this.totalDuration = null // seconds
 | ||||||
|  |     this.progress = null // 0 to 1
 | ||||||
|  |     this.currentTime = null // seconds
 | ||||||
|  |     this.isRead = false | ||||||
|  |     this.lastUpdate = null | ||||||
|  |     this.startedAt = null | ||||||
|  |     this.finishedAt = null | ||||||
|  | 
 | ||||||
|  |     if (progress) { | ||||||
|  |       this.construct(progress) | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   toJSON() { | ||||||
|  |     return { | ||||||
|  |       audiobookId: this.audiobookId, | ||||||
|  |       totalDuration: this.totalDuration, | ||||||
|  |       progress: this.progress, | ||||||
|  |       currentTime: this.currentTime, | ||||||
|  |       isRead: this.isRead, | ||||||
|  |       lastUpdate: this.lastUpdate, | ||||||
|  |       startedAt: this.startedAt, | ||||||
|  |       finishedAt: this.finishedAt | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   construct(progress) { | ||||||
|  |     this.audiobookId = progress.audiobookId | ||||||
|  |     this.totalDuration = progress.totalDuration | ||||||
|  |     this.progress = progress.progress | ||||||
|  |     this.currentTime = progress.currentTime | ||||||
|  |     this.isRead = !!progress.isRead | ||||||
|  |     this.lastUpdate = progress.lastUpdate | ||||||
|  |     this.startedAt = progress.startedAt | ||||||
|  |     this.finishedAt = progress.finishedAt || null | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   updateFromStream(stream) { | ||||||
|  |     this.audiobookId = stream.audiobookId | ||||||
|  |     this.totalDuration = stream.totalDuration | ||||||
|  |     this.progress = stream.clientProgress | ||||||
|  |     this.currentTime = stream.clientCurrentTime | ||||||
|  |     this.lastUpdate = Date.now() | ||||||
|  | 
 | ||||||
|  |     if (!this.startedAt) { | ||||||
|  |       this.startedAt = Date.now() | ||||||
|  |     } | ||||||
|  | 
 | ||||||
|  |     // If has < 10 seconds remaining mark as read
 | ||||||
|  |     var timeRemaining = this.totalDuration - this.currentTime | ||||||
|  |     if (timeRemaining < 10) { | ||||||
|  |       if (!this.isRead) { | ||||||
|  |         this.isRead = true | ||||||
|  |         this.progress = 1 | ||||||
|  |         this.finishedAt = Date.now() | ||||||
|  |       } | ||||||
|  |     } else { | ||||||
|  |       this.isRead = false | ||||||
|  |       this.finishedAt = null | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|  |   update(payload) { | ||||||
|  |     var hasUpdates = false | ||||||
|  |     for (const key in payload) { | ||||||
|  |       if (payload[key] !== this[key]) { | ||||||
|  |         if (key === 'isRead') { | ||||||
|  |           if (!payload[key]) { // Updating to Not Read - Reset progress and current time
 | ||||||
|  |             this.finishedAt = null | ||||||
|  |             this.progress = 0 | ||||||
|  |             this.currentTime = 0 | ||||||
|  |           } else { // Updating to Read
 | ||||||
|  |             if (!this.finishedAt) this.finishedAt = Date.now() | ||||||
|  |             this.progress = 1 | ||||||
|  |           } | ||||||
|  |         } | ||||||
|  | 
 | ||||||
|  |         this[key] = payload[key] | ||||||
|  |         hasUpdates = true | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     if (!this.startedAt) { | ||||||
|  |       this.startedAt = Date.now() | ||||||
|  |     } | ||||||
|  |     return hasUpdates | ||||||
|  |   } | ||||||
|  | } | ||||||
|  | module.exports = AudiobookProgress | ||||||
| @ -1,3 +1,5 @@ | |||||||
|  | const AudiobookProgress = require('./AudiobookProgress') | ||||||
|  | 
 | ||||||
| class User { | class User { | ||||||
|   constructor(user) { |   constructor(user) { | ||||||
|     this.id = null |     this.id = null | ||||||
| @ -26,6 +28,17 @@ class User { | |||||||
|     } |     } | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|  |   audiobooksToJSON() { | ||||||
|  |     if (!this.audiobooks) return null | ||||||
|  |     var _map = {} | ||||||
|  |     for (const key in this.audiobooks) { | ||||||
|  |       if (this.audiobooks[key]) { | ||||||
|  |         _map[key] = this.audiobooks[key].toJSON() | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |     return _map | ||||||
|  |   } | ||||||
|  | 
 | ||||||
|   toJSON() { |   toJSON() { | ||||||
|     return { |     return { | ||||||
|       id: this.id, |       id: this.id, | ||||||
| @ -34,7 +47,7 @@ class User { | |||||||
|       type: this.type, |       type: this.type, | ||||||
|       stream: this.stream, |       stream: this.stream, | ||||||
|       token: this.token, |       token: this.token, | ||||||
|       audiobooks: this.audiobooks, |       audiobooks: this.audiobooksToJSON(), | ||||||
|       isActive: this.isActive, |       isActive: this.isActive, | ||||||
|       createdAt: this.createdAt, |       createdAt: this.createdAt, | ||||||
|       settings: this.settings |       settings: this.settings | ||||||
| @ -48,7 +61,7 @@ class User { | |||||||
|       type: this.type, |       type: this.type, | ||||||
|       stream: this.stream, |       stream: this.stream, | ||||||
|       token: this.token, |       token: this.token, | ||||||
|       audiobooks: this.audiobooks, |       audiobooks: this.audiobooksToJSON(), | ||||||
|       isActive: this.isActive, |       isActive: this.isActive, | ||||||
|       createdAt: this.createdAt, |       createdAt: this.createdAt, | ||||||
|       settings: this.settings |       settings: this.settings | ||||||
| @ -62,7 +75,14 @@ class User { | |||||||
|     this.type = user.type |     this.type = user.type | ||||||
|     this.stream = user.stream || null |     this.stream = user.stream || null | ||||||
|     this.token = user.token |     this.token = user.token | ||||||
|     this.audiobooks = user.audiobooks || null |     if (user.audiobooks) { | ||||||
|  |       this.audiobooks = {} | ||||||
|  |       for (const key in user.audiobooks) { | ||||||
|  |         if (user.audiobooks[key]) { | ||||||
|  |           this.audiobooks[key] = new AudiobookProgress(user.audiobooks[key]) | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|     this.isActive = (user.isActive === undefined || user.id === 'root') ? true : !!user.isActive |     this.isActive = (user.isActive === undefined || user.id === 'root') ? true : !!user.isActive | ||||||
|     this.createdAt = user.createdAt || Date.now() |     this.createdAt = user.createdAt || Date.now() | ||||||
|     this.settings = user.settings || this.getDefaultUserSettings() |     this.settings = user.settings || this.getDefaultUserSettings() | ||||||
| @ -84,18 +104,21 @@ class User { | |||||||
|     return hasUpdates |     return hasUpdates | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   updateAudiobookProgress(stream) { |   updateAudiobookProgressFromStream(stream) { | ||||||
|     if (!this.audiobooks) this.audiobooks = {} |     if (!this.audiobooks) this.audiobooks = {} | ||||||
|     if (!this.audiobooks[stream.audiobookId]) { |     if (!this.audiobooks[stream.audiobookId]) { | ||||||
|       this.audiobooks[stream.audiobookId] = { |       this.audiobooks[stream.audiobookId] = new AudiobookProgress() | ||||||
|         audiobookId: stream.audiobookId, |  | ||||||
|         totalDuration: stream.totalDuration, |  | ||||||
|         startedAt: Date.now() |  | ||||||
|       } |  | ||||||
|     } |     } | ||||||
|     this.audiobooks[stream.audiobookId].lastUpdate = Date.now() |     this.audiobooks[stream.audiobookId].updateFromStream(stream) | ||||||
|     this.audiobooks[stream.audiobookId].progress = stream.clientProgress |   } | ||||||
|     this.audiobooks[stream.audiobookId].currentTime = stream.clientCurrentTime | 
 | ||||||
|  |   updateAudiobookProgress(audiobookId, updatePayload) { | ||||||
|  |     if (!this.audiobooks) this.audiobooks = {} | ||||||
|  |     if (!this.audiobooks[audiobookId]) { | ||||||
|  |       this.audiobooks[audiobookId] = new AudiobookProgress() | ||||||
|  |       this.audiobooks[audiobookId].audiobookId = audiobookId | ||||||
|  |     } | ||||||
|  |     return this.audiobooks[audiobookId].update(updatePayload) | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   // Returns Boolean If update was made
 |   // Returns Boolean If update was made
 | ||||||
|  | |||||||
		Loading…
	
		Reference in New Issue
	
	Block a user