Offline support, downloading, syncing progress

This commit is contained in:
Mark Cooper
2021-09-12 18:37:08 -05:00
parent e33a054288
commit 7fe37ff983
37 changed files with 2836 additions and 201 deletions
+11 -2
View File
@@ -22,7 +22,9 @@
<p class="font-mono pt-1 pb-4">{{ $config.version }}</p>
<ui-btn v-if="isUpdateAvailable" class="w-full my-4" color="success" @click="clickUpdate"> Version {{ availableVersion }} is available! Open App Store</ui-btn>
<ui-btn v-if="isUpdateAvailable" class="w-full my-4" color="success" @click="clickUpdate">Update is available</ui-btn>
<ui-btn v-if="!isUpdateAvailable || immediateUpdateAllowed" class="w-full my-4" color="primary" @click="openAppStore">Open app store</ui-btn>
</div>
</template>
@@ -67,8 +69,15 @@ export default {
this.$server.logout()
this.$router.push('/connect')
},
openAppStore() {
AppUpdate.openAppStore()
},
async clickUpdate() {
await AppUpdate.openAppStore()
if (this.immediateUpdateAllowed) {
AppUpdate.performImmediateUpdate()
} else {
AppUpdate.openAppStore()
}
}
},
mounted() {}
+248 -37
View File
@@ -3,17 +3,19 @@
<div class="flex">
<div class="w-32">
<div class="relative">
<cards-book-cover :audiobook="audiobook" :width="128" />
<cards-book-cover :audiobook="audiobook" :download-cover="downlaodedCover" :width="128" />
<div class="absolute bottom-0 left-0 h-1.5 bg-yellow-400 shadow-sm" :style="{ width: 128 * progressPercent + 'px' }"></div>
</div>
<!-- <cards-book-cover :audiobook="audiobook" :width="128" /> -->
<div class="flex my-4">
<p class="text-sm">{{ numTracks }} Tracks</p>
</div>
</div>
<div class="flex-grow px-3">
<h1 class="text-lg">{{ title }}</h1>
<h3 v-if="series" class="font-book text-gray-300 text-lg leading-7">{{ seriesText }}</h3>
<p class="text-sm text-gray-400">by {{ author }}</p>
<p class="text-gray-300 text-sm my-1">
{{ durationPretty }}<span class="px-4">{{ sizePretty }}</span>
{{ $elapsedPretty(duration) }}<span class="px-4">{{ $bytesPretty(size) }}</span>
</p>
<div v-if="progressPercent > 0" class="px-4 py-2 bg-primary text-sm font-semibold rounded-md text-gray-200 mt-4 relative" :class="resettingProgress ? 'opacity-25' : ''">
@@ -24,11 +26,15 @@
</div>
</div>
<ui-btn color="success" :disabled="streaming" class="flex items-center justify-center w-full mt-4" :padding-x="4" @click="playClick">
<span v-show="!streaming" class="material-icons">play_arrow</span>
<span class="px-1">{{ streaming ? 'Streaming' : 'Play' }}</span>
</ui-btn>
<div class="flex my-4"></div>
<div v-if="isConnected || isDownloadPlayable" class="flex mt-4">
<ui-btn color="success" :disabled="isPlaying" class="flex items-center justify-center w-full mr-2" :padding-x="4" @click="playClick">
<span v-show="!isPlaying" class="material-icons">play_arrow</span>
<span class="px-1">{{ isPlaying ? (isStreaming ? 'Streaming' : 'Playing') : isDownloadPlayable ? 'Play local' : 'Play stream' }}</span>
</ui-btn>
<ui-btn v-if="isConnected" color="primary" :disabled="isPlaying" class="flex items-center justify-center" :padding-x="2" @click="downloadClick">
<span class="material-icons" :class="isDownloaded ? 'animate-pulse' : ''">{{ downloadObj ? (isDownloading || isDownloadPreparing ? 'downloading' : 'download_done') : 'download' }}</span>
</ui-btn>
</div>
</div>
</div>
<div class="w-full py-4">
@@ -38,15 +44,25 @@
</template>
<script>
import Path from 'path'
import { Dialog } from '@capacitor/dialog'
import { Capacitor } from '@capacitor/core'
import AudioDownloader from '@/plugins/audio-downloader'
export default {
async asyncData({ params, redirect, app }) {
async asyncData({ store, params, redirect, app }) {
var audiobookId = params.id
var audiobook = await app.$axios.$get(`/api/audiobook/${audiobookId}`).catch((error) => {
console.error('Failed', error)
return false
})
var audiobook = null
if (app.$server.connected) {
audiobook = await app.$axios.$get(`/api/audiobook/${audiobookId}`).catch((error) => {
console.error('Failed', error)
return false
})
} else {
audiobook = store.getters['audiobooks/getAudiobook'](audiobookId)
}
if (!audiobook) {
console.error('No audiobook...', params.id)
return redirect('/')
@@ -61,6 +77,9 @@ export default {
}
},
computed: {
isConnected() {
return this.$store.state.socketConnected
},
audiobookId() {
return this.audiobook.id
},
@@ -87,14 +106,11 @@ export default {
if (!this.volumeNumber) return this.series
return `${this.series} #${this.volumeNumber}`
},
durationPretty() {
return this.audiobook.durationPretty
},
duration() {
return this.audiobook.duration
},
sizePretty() {
return this.audiobook.sizePretty
size() {
return this.audiobook.size
},
userAudiobooks() {
return this.$store.state.user.user ? this.$store.state.user.user.audiobooks || {} : {}
@@ -102,27 +118,65 @@ export default {
userAudiobook() {
return this.userAudiobooks[this.audiobookId] || null
},
localUserAudiobooks() {
return this.$store.state.user.localUserAudiobooks || {}
},
localUserAudiobook() {
return this.localUserAudiobooks[this.audiobookId] || null
},
mostRecentUserAudiobook() {
if (!this.localUserAudiobook) return this.userAudiobook
if (!this.userAudiobook) return this.localUserAudiobook
return this.localUserAudiobook.lastUpdate > this.userAudiobook.lastUpdate ? this.localUserAudiobook : this.userAudiobook
},
userCurrentTime() {
return this.userAudiobook ? this.userAudiobook.currentTime : 0
return this.mostRecentUserAudiobook ? this.mostRecentUserAudiobook.currentTime : 0
},
userTimeRemaining() {
return this.duration - this.userCurrentTime
},
progressPercent() {
return this.userAudiobook ? this.userAudiobook.progress : 0
},
streamAudiobook() {
return this.$store.state.streamAudiobook
return this.mostRecentUserAudiobook ? this.mostRecentUserAudiobook.progress : 0
},
isStreaming() {
return this.streamAudiobook && this.streamAudiobook.id === this.audiobookId
return this.$store.getters['isAudiobookStreaming'](this.audiobookId)
},
isPlaying() {
return this.$store.getters['isAudiobookPlaying'](this.audiobookId)
},
numTracks() {
if (this.audiobook.tracks) return this.audiobook.tracks.length
return this.audiobook.numTracks || 0
},
isDownloading() {
return this.downloadObj ? this.downloadObj.isDownloading : false
},
isDownloadPreparing() {
return this.downloadObj ? this.downloadObj.isPreparing : false
},
isDownloadPlayable() {
return this.downloadObj && !this.isDownloading && !this.isDownloadPreparing
},
downlaodedCover() {
return this.downloadObj ? this.downloadObj.cover : null
},
downloadObj() {
return this.$store.getters['downloads/getDownload'](this.audiobookId)
}
},
methods: {
playClick() {
this.$store.commit('setPlayOnLoad', true)
this.$store.commit('setStreamAudiobook', this.audiobook)
this.$server.socket.emit('open_stream', this.audiobook.id)
if (!this.isDownloadPlayable) {
// Stream
console.log('[PLAYCLICK] Set Playing STREAM ' + this.title)
this.$store.commit('setStreamAudiobook', this.audiobook)
this.$server.socket.emit('open_stream', this.audiobook.id)
} else {
// Local
console.log('[PLAYCLICK] Set Playing Local Download ' + this.title)
this.$store.commit('setPlayingDownload', this.downloadObj)
}
},
async clearProgressClick() {
const { value } = await Dialog.confirm({
@@ -132,17 +186,26 @@ export default {
if (value) {
this.resettingProgress = true
this.$axios
.$delete(`/api/user/audiobook/${this.audiobookId}`)
.then(() => {
console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`)
this.resettingProgress = false
})
.catch((error) => {
console.error('Progress reset failed', error)
this.resettingProgress = false
})
if (this.$server.connected) {
await this.$axios
.$delete(`/api/user/audiobook/${this.audiobookId}`)
.then(() => {
console.log('Progress reset complete')
this.$toast.success(`Your progress was reset`)
})
.catch((error) => {
console.error('Progress reset failed', error)
})
}
this.$localStore.updateUserAudiobookProgress({
audiobookId: this.audiobookId,
currentTime: 0,
totalDuration: this.duration,
progress: 0,
lastUpdate: Date.now(),
isRead: false
})
this.resettingProgress = false
}
},
audiobookUpdated() {
@@ -155,12 +218,160 @@ export default {
.catch((error) => {
console.error('Failed', error)
})
},
downloadClick() {
if (!this.$server.connected) return
if (this.downloadObj) {
console.log('Already downloaded', this.downloadObj)
} else {
this.prepareDownload()
}
},
async prepareDownload() {
// var audiobook = await this.$axios.$get(`/api/audiobook/${this.audiobookId}`).catch((error) => {
// console.error('Failed', error)
// return false
// })
var audiobook = this.audiobook
if (!audiobook) {
return
}
var downloadObject = {
id: this.audiobookId,
audiobook: {
...audiobook
},
isPreparing: true,
isDownloading: false,
toastId: this.$toast(`Preparing download for "${this.title}"`, { timeout: false })
}
if (audiobook.tracks.length === 1) {
// Single track should not need preparation
console.log('Single track, start download no prep needed')
var track = audiobook.tracks[0]
var fileext = track.ext
var url = `${this.$store.state.serverUrl}/local/${track.path}`
this.startDownload(url, fileext, downloadObject)
} else {
// Multi-track merge
this.$store.commit('downloads/addUpdateDownload', downloadObject)
var prepareDownloadPayload = {
audiobookId: this.audiobookId,
audioFileType: 'same',
type: 'singleAudio'
}
this.$server.socket.emit('download', prepareDownloadPayload)
}
},
getCoverUrlForDownload() {
if (!this.book || !this.book.cover) return null
var cover = this.book.cover
if (cover.startsWith('http')) return cover
var _clean = cover.replace(/\\/g, '/')
if (_clean.startsWith('/local')) {
var _cover = process.env.NODE_ENV !== 'production' && process.env.PROD !== '1' ? _clean.replace('/local', '') : _clean
return `${this.$store.state.serverUrl}${_cover}`
}
return _clean
},
async downloadCover(download) {
var coverUrl = this.getCoverUrlForDownload()
if (!coverUrl) {
return null
}
var extname = Path.extname(coverUrl) || '.jpg'
var downloadRequestPayload = {
downloadUrl: coverUrl,
filename: `${download.id}${extname}`,
title: download.audiobook.book.title
}
console.log('Starting cover download', coverUrl, downloadRequestPayload.filename)
var downloadRes = await AudioDownloader.downloadCover(downloadRequestPayload)
if (downloadRes && downloadRes.url) {
console.log('Cover art downloaded', downloadRes.url)
return downloadRes.url
} else {
console.error('Cover art failed to download')
return null
}
},
async startDownload(url, fileext, download) {
this.$toast.update(download.toastId, { content: `Downloading "${download.audiobook.book.title}"...` })
download.isDownloading = true
download.isPreparing = false
download.filename = `${download.id}${fileext}`
this.$store.commit('downloads/addUpdateDownload', download)
console.log('Starting Download URL', url)
var downloadRequestPayload = {
filename: download.filename,
downloadUrl: url,
title: download.audiobook.book.title
}
var downloadRes = await AudioDownloader.download(downloadRequestPayload)
var downloadId = downloadRes.value
if (!downloadId) {
console.error('Invalid download, removing')
this.$toast.update(download.toastId, { content: `Failed download "${download.audiobook.book.title}"`, options: { timeout: 5000, type: 'error' } })
this.$store.commit('downloads/removeDownload', download)
} else {
console.log('Download cover now')
var coverUrl = await this.downloadCover(download)
if (coverUrl) {
console.log('Got cover url', coverUrl)
download.coverUrl = coverUrl
download.cover = Capacitor.convertFileSrc(coverUrl)
this.$store.commit('downloads/addUpdateDownload', download)
}
}
},
downloadReady(prepareDownload) {
var download = this.$store.getters['downloads/getDownload'](prepareDownload.audiobookId)
if (download) {
var fileext = prepareDownload.ext
var url = `${this.$store.state.serverUrl}/downloads/${prepareDownload.id}/${prepareDownload.filename}`
this.startDownload(url, fileext, download)
} else {
console.error('Prepare download killed but download not found', prepareDownload)
}
},
downloadKilled(prepareDownload) {
var download = this.$store.getters['downloads/getDownload'](prepareDownload.audiobookId)
if (download) {
this.$toast.update(download.toastId, { content: `Prepare download killed for "${download.audiobook.book.title}"`, options: { timeout: 5000, type: 'error' } })
this.$store.commit('downloads/removeDownload', download)
} else {
console.error('Prepare download killed but download not found', prepareDownload)
}
},
downloadFailed(prepareDownload) {
var download = this.$store.getters['downloads/getDownload'](prepareDownload.audiobookId)
if (download) {
this.$toast.update(download.toastId, { content: `Prepare download failed for "${download.audiobook.book.title}"`, options: { timeout: 5000, type: 'error' } })
this.$store.commit('downloads/removeDownload', download)
} else {
console.error('Prepare download failed but download not found', prepareDownload)
}
}
},
mounted() {
this.$server.socket.on('download_ready', this.downloadReady)
this.$server.socket.on('download_killed', this.downloadKilled)
this.$server.socket.on('download_failed', this.downloadFailed)
this.$store.commit('audiobooks/addListener', { id: 'audiobook', audiobookId: this.audiobookId, meth: this.audiobookUpdated })
},
beforeDestroy() {
this.$server.socket.off('download_ready', this.downloadReady)
this.$server.socket.off('download_killed', this.downloadKilled)
this.$server.socket.off('download_failed', this.downloadFailed)
this.$store.commit('audiobooks/removeListener', 'audiobook')
}
}
+23 -6
View File
@@ -1,11 +1,14 @@
<template>
<div class="w-full h-full">
<div class="relative flex items-center justify-center min-h-screen sm:pt-0">
<nuxt-link to="/" class="absolute top-2 left-2 z-20">
<span class="material-icons text-4xl">arrow_back</span>
</nuxt-link>
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
<img src="/Logo.png" class="h-20 w-20 mb-2" />
<h1 class="text-2xl font-book">AudioBookshelf</h1>
</div>
<p class="hidden absolute short:block top-0 left-0 p-2 font-book text-xl">AudioBookshelf</p>
<p class="hidden absolute short:block top-1.5 left-12 p-2 font-book text-xl">AudioBookshelf</p>
<div class="max-w-sm mx-auto sm:px-6 lg:px-8 z-10">
<div v-show="loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6 text-center">
@@ -15,8 +18,8 @@
<div v-show="!loggedIn" class="mt-8 bg-primary overflow-hidden shadow rounded-lg p-6">
<h2 class="text-xl leading-7 mb-4">Enter an <span class="font-book font-normal">AudioBookshelf</span><br />server address:</h2>
<form v-show="!showAuth" @submit.prevent="submit" novalidate>
<ui-text-input v-model="serverUrl" :disabled="processing" placeholder="http://55.55.55.55:13378" type="url" class="w-60 sm:w-72 h-10" />
<ui-btn :disabled="processing" type="submit" :padding-x="3" class="h-10">Submit</ui-btn>
<ui-text-input v-model="serverUrl" :disabled="processing || !networkConnected" placeholder="http://55.55.55.55:13378" type="url" class="w-60 sm:w-72 h-10" />
<ui-btn :disabled="processing || !networkConnected" type="submit" :padding-x="3" class="h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</form>
<template v-if="showAuth">
<div class="flex items-center">
@@ -29,7 +32,7 @@
<ui-text-input v-model="username" :disabled="processing" placeholder="username" class="w-full my-1 text-lg" />
<ui-text-input v-model="password" type="password" :disabled="processing" placeholder="password" class="w-full my-1 text-lg" />
<ui-btn :disabled="processing" type="submit" class="mt-1 h-10">Submit</ui-btn>
<ui-btn :disabled="processing || !networkConnected" type="submit" class="mt-1 h-10">{{ networkConnected ? 'Submit' : 'No Internet' }}</ui-btn>
</form>
</template>
@@ -77,8 +80,16 @@ export default {
loggedIn: false
}
},
computed: {
networkConnected() {
return this.$store.state.networkConnected
}
},
methods: {
async submit() {
if (!this.networkConnected) {
return
}
if (!this.serverUrl.startsWith('http')) {
this.serverUrl = 'http://' + this.serverUrl
}
@@ -94,6 +105,9 @@ export default {
}
},
async submitAuth() {
if (!this.networkConnected) {
return
}
if (!this.username) {
this.error = 'Invalid username'
return
@@ -137,8 +151,11 @@ export default {
}
this.$server.on('connected', this.socketConnected)
var localServerUrl = localStorage.getItem('serverUrl')
var localUserToken = localStorage.getItem('userToken')
var localServerUrl = await this.$localStore.getServerUrl()
var localUserToken = await this.$localStore.getToken()
if (!this.networkConnected) return
if (localServerUrl) {
this.serverUrl = localServerUrl
if (localUserToken) {