mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-26 13:24:39 +02:00
Offline support, downloading, syncing progress
This commit is contained in:
+11
-2
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user