Update user audiobook progress model, add mark as read/not read, download individual tracks

This commit is contained in:
advplyr 2021-09-06 14:13:01 -05:00
parent 4f094df0b2
commit 11d7330c5d
21 changed files with 496 additions and 100 deletions

View File

@ -83,6 +83,10 @@
box-shadow: 2px 8px 6px #111111aa;
}
.box-shadow-md-up {
box-shadow: 0px -8px 8px #11111144;
}
.box-shadow-lg-up {
box-shadow: 0px -12px 8px #111111ee;
}
@ -93,4 +97,4 @@
.box-shadow-book {
box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166;
}
}

View File

@ -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>
</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">
<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() {
return this.userProgress ? this.userProgress.progress || 0 : 0
},
userIsRead() {
return this.userProgress ? !!this.userProgress.isRead : false
},
showError() {
return this.hasMissingParts || this.hasInvalidParts
},

View File

@ -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>
</template>
</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>
<component v-if="audiobook" :is="tabName" :audiobook="audiobook" :processing.sync="processing" @close="show = false" />
</keep-alive>
@ -22,7 +22,6 @@
export default {
data() {
return {
selectedTab: 'details',
processing: false,
audiobook: null,
fetchOnShow: false,
@ -79,6 +78,14 @@ export default {
this.$store.commit('setShowEditModal', val)
}
},
selectedTab: {
get() {
return this.$store.state.editModalTab
},
set(val) {
this.$store.commit('setEditModalTab', val)
}
},
height() {
var maxHeightAllowed = window.innerHeight - 150
return Math.min(maxHeightAllowed, 650)

View File

@ -1,5 +1,5 @@
<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="relative">
<cards-book-cover :audiobook="audiobook" />

View File

@ -1,60 +1,64 @@
<template>
<div class="w-full h-full overflow-hidden overflow-y-auto px-1">
<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 class="w-full flex items-center">
<p>
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
</p>
<div class="flex-grow" />
<ui-btn v-if="!resettingProgress" small :padding-x="2" class="-mr-3" @click="resetProgress">Reset</ui-btn>
</div>
<div class="w-full h-full relative">
<div ref="formWrapper" class="px-4 py-6 details-form-wrapper w-full overflow-hidden overflow-y-auto">
<!-- <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 class="w-full flex items-center">
<p>
Your progress: <span class="font-mono text-lg">{{ (userProgress * 100).toFixed(0) }}%</span>
</p>
<div class="flex-grow" />
<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>
<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>
<div class="flex py-4 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">
<ui-btn color="error" type="button" small @click.stop.prevent="deleteAudiobook">Remove</ui-btn>
<div class="flex-grow" />
<ui-btn type="submit">Submit</ui-btn>
</div>
</form>
</div>
</div>
</template>
@ -81,7 +85,8 @@ export default {
genres: []
},
newTags: [],
resettingProgress: false
resettingProgress: false,
isScrollable: false
}
},
watch: {
@ -107,12 +112,12 @@ export default {
book() {
return this.audiobook ? this.audiobook.book || {} : {}
},
userAudiobook() {
return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
},
userProgress() {
return this.userAudiobook ? this.userAudiobook.progress : 0
},
// userAudiobook() {
// return this.$store.getters['user/getUserAudiobook'](this.audiobookId)
// },
// userProgress() {
// return this.userAudiobook ? this.userAudiobook.progress : 0
// },
genres() {
return this.$store.state.audiobooks.genres
},
@ -189,7 +194,40 @@ export default {
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>

View File

@ -1,5 +1,5 @@
<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">
<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
@ -13,8 +13,8 @@
<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="singleAudioDownloadExpired" class="text-error mb-2">Download Expired</p>
<ui-btn v-if="!singleAudioDownloadReady" :loading="singleAudioDownloadPending" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
<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-else-if="!singleAudioDownloadReady" :loading="singleAudioDownloadPending" :disabled="tempDisable" @click="startSingleAudioDownload">Start Download</ui-btn>
<ui-btn v-else @click="downloadWithProgress">Download</ui-btn>
</div>
</div>
@ -81,6 +81,14 @@ export default {
},
zipBundleDownload() {
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: {

View File

@ -1,5 +1,5 @@
<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">
<div class="flex items-center justify-start -mx-1 h-20">
<div class="w-72 px-1">

View File

@ -1,5 +1,5 @@
<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">
<nuxt-link :to="`/audiobook/${audiobook.id}/edit`">
<ui-btn color="primary">Edit Track Order</ui-btn>
@ -11,6 +11,7 @@
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th class="text-center">Download</th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
@ -26,6 +27,9 @@
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td class="font-mono text-center">
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>

View File

@ -19,6 +19,7 @@
<th class="text-left">Filename</th>
<th class="text-left">Size</th>
<th class="text-left">Duration</th>
<th class="text-center">Download</th>
</tr>
<template v-for="track in tracks">
<tr :key="track.index">
@ -34,6 +35,9 @@
<td class="font-mono">
{{ $secondsToTimestamp(track.duration) }}
</td>
<td class="text-center">
<a :href="`/local/${track.path}`" download><span class="material-icons icon-text">download</span></a>
</td>
</tr>
</template>
</table>

View 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>

View 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>

View File

@ -22,8 +22,19 @@ export default {
isShowing: false
}
},
watch: {
text() {
this.updateText()
}
},
methods: {
updateText() {
if (this.tooltip) {
this.tooltip.innerHTML = this.text
}
},
createTooltip() {
if (!this.$refs.box) return
var boxChow = this.$refs.box.getBoundingClientRect()
var top = 0
var left = 0
@ -33,6 +44,9 @@ export default {
} else if (this.direction === 'bottom') {
top = boxChow.top + boxChow.height + 4
left = boxChow.left
} else if (this.direction === 'top') {
top = boxChow.top - 24
left = boxChow.left
}
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'

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf-client",
"version": "1.0.5",
"version": "1.0.6",
"description": "Audiobook manager and player",
"main": "index.js",
"scripts": {

View File

@ -5,7 +5,7 @@
<div class="w-52" style="min-width: 208px">
<div class="relative">
<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 class="flex-grow px-10">
@ -22,24 +22,40 @@
<p class="text-gray-300 text-sm my-1">
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
</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">
<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>
{{ streaming ? 'Streaming' : 'Play' }}
</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>
<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' : ''">
<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-grow" />
</div>
<div class="my-4">
<p class="text-sm text-gray-100">{{ description }}</p>
</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">
<p class="text-sm mb-2">
@ -88,7 +104,17 @@ export default {
},
data() {
return {
resettingProgress: false
isRead: false,
resettingProgress: false,
isProcessingReadUpdate: false
}
},
watch: {
userIsRead: {
immediate: true,
handler(newVal) {
this.isRead = newVal
}
}
},
computed: {
@ -149,7 +175,7 @@ export default {
},
authorTooltipText() {
var txt = ['FL: ' + this.authorFL || 'Not Set', 'LF: ' + this.authorLF || 'Not Set']
return txt.join('\n')
return txt.join('<br>')
},
series() {
return this.book.series || null
@ -189,7 +215,7 @@ export default {
return this.audiobook.audioFiles || []
},
description() {
return this.book.description || 'No Description'
return this.book.description || ''
},
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
@ -200,6 +226,9 @@ export default {
userCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime : 0
},
userIsRead() {
return this.userAudiobook ? !!this.userAudiobook.isRead : false
},
userTimeRemaining() {
return this.duration - this.userCurrentTime
},
@ -214,6 +243,23 @@ export default {
}
},
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() {
this.$axios
.$post('/api/feed', { audiobookId: this.audiobook.id })
@ -269,6 +315,9 @@ export default {
this.resettingProgress = false
})
}
},
downloadClick() {
this.$store.commit('showEditModalOnTab', { audiobook: this.audiobook, tab: 'download' })
}
},
mounted() {

View File

@ -3,6 +3,7 @@ import Vue from 'vue'
export const state = () => ({
serverSettings: null,
streamAudiobook: null,
editModalTab: 'details',
showEditModal: false,
selectedAudiobook: null,
playOnLoad: false,
@ -63,9 +64,18 @@ export const mutations = {
state.playOnLoad = val
},
showEditModal(state, audiobook) {
state.editModalTab = 'details'
state.selectedAudiobook = audiobook
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) {
state.showEditModal = val
},

View File

@ -10,7 +10,7 @@
<Support>https://forums.unraid.net/topic/112698-support-audiobookshelf/</Support>
<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 &amp; 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>
<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>

View File

@ -1,6 +1,6 @@
{
"name": "audiobookshelf",
"version": "1.0.5",
"version": "1.0.6",
"description": "Self-hosted audiobook server for managing and playing audiobooks.",
"main": "index.js",
"scripts": {

View File

@ -36,6 +36,7 @@ class ApiController {
this.router.patch('/match/:id', this.match.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/settings', this.userUpdateSettings.bind(this))
this.router.get('/users', this.getUsers.bind(this))
@ -233,7 +234,16 @@ class ApiController {
async resetUserAudiobookProgress(req, res) {
req.user.resetAudiobookProgress(req.params.id)
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)
}

View File

@ -134,11 +134,11 @@ class StreamManager {
Logger.error('No User for client', client)
return
}
if (!client.user.updateAudiobookProgress) {
if (!client.user.updateAudiobookProgressFromStream) {
Logger.error('Invalid User for client', client)
return
}
client.user.updateAudiobookProgress(client.stream)
client.user.updateAudiobookProgressFromStream(client.stream)
this.db.updateEntity('user', client.user)
}
}

View 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

View File

@ -1,3 +1,5 @@
const AudiobookProgress = require('./AudiobookProgress')
class User {
constructor(user) {
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() {
return {
id: this.id,
@ -34,7 +47,7 @@ class User {
type: this.type,
stream: this.stream,
token: this.token,
audiobooks: this.audiobooks,
audiobooks: this.audiobooksToJSON(),
isActive: this.isActive,
createdAt: this.createdAt,
settings: this.settings
@ -48,7 +61,7 @@ class User {
type: this.type,
stream: this.stream,
token: this.token,
audiobooks: this.audiobooks,
audiobooks: this.audiobooksToJSON(),
isActive: this.isActive,
createdAt: this.createdAt,
settings: this.settings
@ -62,7 +75,14 @@ class User {
this.type = user.type
this.stream = user.stream || null
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.createdAt = user.createdAt || Date.now()
this.settings = user.settings || this.getDefaultUserSettings()
@ -84,18 +104,21 @@ class User {
return hasUpdates
}
updateAudiobookProgress(stream) {
updateAudiobookProgressFromStream(stream) {
if (!this.audiobooks) this.audiobooks = {}
if (!this.audiobooks[stream.audiobookId]) {
this.audiobooks[stream.audiobookId] = {
audiobookId: stream.audiobookId,
totalDuration: stream.totalDuration,
startedAt: Date.now()
}
this.audiobooks[stream.audiobookId] = new AudiobookProgress()
}
this.audiobooks[stream.audiobookId].lastUpdate = Date.now()
this.audiobooks[stream.audiobookId].progress = stream.clientProgress
this.audiobooks[stream.audiobookId].currentTime = stream.clientCurrentTime
this.audiobooks[stream.audiobookId].updateFromStream(stream)
}
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