audiobookshelf/client/layouts/default.vue
Igor Serebryany 63f1df4015
don't save proxy user's tokens in localstorage
we still want those tokens, we will use them in the mobile app. but we
don't want them client side, otherwise proxy users remain logged in. we
centralize the logic around storing the token, and turn `setUser` into
an action vs a mutation
2023-10-06 16:31:01 -07:00

659 lines
24 KiB
Vue

<template>
<div class="text-white max-h-screen h-screen overflow-hidden bg-bg">
<app-appbar />
<app-side-rail v-if="isShowingSideRail" class="hidden md:block" />
<div id="app-content" class="h-full" :class="{ 'has-siderail': isShowingSideRail }">
<Nuxt :key="currentLang" />
</div>
<app-stream-container ref="streamContainer" />
<modals-item-edit-modal />
<modals-collections-add-create-modal />
<modals-collections-edit-modal />
<modals-playlists-add-create-modal />
<modals-playlists-edit-modal />
<modals-podcast-edit-episode />
<modals-podcast-view-episode />
<modals-authors-edit-modal />
<modals-batch-quick-match-model />
<modals-rssfeed-open-close-modal />
<prompt-confirm />
<readers-reader />
</div>
</template>
<script>
import CloseButton from '@/components/widgets/CloseButton'
export default {
middleware: 'authenticated',
data() {
return {
socket: null,
isSocketConnected: false,
isFirstSocketConnection: true,
socketConnectionToastId: null,
currentLang: null,
multiSessionOtherSessionId: null, // Used for multiple sessions open warning toast
multiSessionCurrentSessionId: null // Used for multiple sessions open warning toast
}
},
watch: {
$route(newVal) {
if (this.$store.state.showEditModal) {
this.$store.commit('setShowEditModal', false)
}
this.$store.commit('globals/resetSelectedMediaItems', [])
this.updateBodyClass()
}
},
computed: {
user() {
return this.$store.state.user.user
},
isCasting() {
return this.$store.state.globals.isCasting
},
currentLibraryId() {
return this.$store.state.libraries.currentLibraryId
},
isShowingSideRail() {
if (!this.$route.name) return false
return !this.$route.name.startsWith('config') && this.currentLibraryId
},
isShowingToolbar() {
return this.isShowingSideRail && this.$route.name !== 'upload' && this.$route.name !== 'account'
},
appContentMarginLeft() {
return this.isShowingSideRail ? 80 : 0
}
},
methods: {
updateBodyClass() {
if (this.isShowingToolbar) {
document.body.classList.remove('no-bars', 'app-bar')
document.body.classList.add('app-bar-and-toolbar')
} else {
document.body.classList.remove('no-bars', 'app-bar-and-toolbar')
document.body.classList.add('app-bar')
}
},
updateSocketConnectionToast(content, type, timeout) {
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false)
} else {
this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null })
}
},
connect() {
console.log('[SOCKET] Connected')
var token = this.$store.getters['user/getToken']
this.socket.emit('auth', token)
if (!this.isFirstSocketConnection || this.socketConnectionToastId !== null) {
this.updateSocketConnectionToast(this.$strings.ToastSocketConnected, 'success', 5000)
}
this.isFirstSocketConnection = false
this.isSocketConnected = true
},
connectError() {
console.error('[SOCKET] connect error')
this.updateSocketConnectionToast(this.$strings.ToastSocketFailedToConnect, 'error', null)
},
disconnect() {
console.log('[SOCKET] Disconnected')
this.isSocketConnected = false
this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null)
},
reconnect() {
console.error('[SOCKET] reconnected')
},
reconnectAttempt(val) {
console.log(`[SOCKET] reconnect attempt ${val}`)
},
reconnectError() {
// console.error('[SOCKET] reconnect error')
},
reconnectFailed() {
console.error('[SOCKET] reconnect failed')
},
init(payload) {
console.log('Init Payload', payload)
// Start scans currently running
if (payload.librariesScanning) {
payload.librariesScanning.forEach((libraryScan) => {
this.scanStart(libraryScan)
})
}
// Remove any current scans that are no longer running
var currentScans = [...this.$store.state.scanners.libraryScans]
currentScans.forEach((ls) => {
if (!payload.librariesScanning || !payload.librariesScanning.find((_ls) => _ls.id === ls.id)) {
this.$toast.dismiss(ls.toastId)
this.$store.commit('scanners/remove', ls)
}
})
if (payload.usersOnline) {
this.$store.commit('users/setUsersOnline', payload.usersOnline)
}
this.$eventBus.$emit('socket_init')
},
streamOpen(stream) {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamOpen(stream)
},
streamClosed(streamId) {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamClosed(streamId)
},
streamProgress(data) {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamProgress(data)
},
streamReady() {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamReady()
},
streamReset(payload) {
if (this.$refs.streamContainer) this.$refs.streamContainer.streamReset(payload)
},
streamError({ id, errorMessage }) {
this.$toast.error(`Stream Failed: ${errorMessage}`)
if (this.$refs.streamContainer) this.$refs.streamContainer.streamError(id)
},
libraryAdded(library) {
this.$store.commit('libraries/addUpdate', library)
},
libraryUpdated(library) {
this.$store.commit('libraries/addUpdate', library)
},
async libraryRemoved(library) {
console.log('Library removed', library)
this.$store.commit('libraries/remove', library)
// When removed currently selected library then set next accessible library
const currLibraryId = this.currentLibraryId
if (currLibraryId === library.id) {
var nextLibrary = this.$store.getters['libraries/getNextAccessibleLibrary']
if (nextLibrary) {
await this.$store.dispatch('libraries/fetch', nextLibrary.id)
if (this.$route.name.startsWith('config')) {
// No need to refresh
} else if (this.$route.name.startsWith('library')) {
var newRoute = this.$route.path.replace(currLibraryId, nextLibrary.id)
this.$router.push(newRoute)
} else {
this.$router.push(`/library/${nextLibrary.id}`)
}
} else {
console.error('User has no more accessible libraries')
this.$store.commit('libraries/setCurrentLibrary', null)
}
}
},
libraryItemAdded(libraryItem) {
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
},
libraryItemUpdated(libraryItem) {
if (this.$store.state.selectedLibraryItem && this.$store.state.selectedLibraryItem.id === libraryItem.id) {
this.$store.commit('setSelectedLibraryItem', libraryItem)
if (this.$store.state.globals.selectedEpisode && libraryItem.mediaType === 'podcast') {
const episode = libraryItem.media.episodes.find((ep) => ep.id === this.$store.state.globals.selectedEpisode.id)
if (episode) {
this.$store.commit('globals/setSelectedEpisode', episode)
}
}
}
this.$eventBus.$emit(`${libraryItem.id}_updated`, libraryItem)
this.$store.commit('libraries/updateFilterDataWithItem', libraryItem)
},
libraryItemRemoved(item) {
if (this.$route.name.startsWith('item')) {
if (this.$route.params.id === item.id) {
this.$router.replace(`/library/${this.currentLibraryId}`)
}
}
},
libraryItemsUpdated(libraryItems) {
libraryItems.forEach((li) => {
this.libraryItemUpdated(li)
})
},
libraryItemsAdded(libraryItems) {
libraryItems.forEach((ab) => {
this.libraryItemAdded(ab)
})
},
scanComplete(data) {
console.log('Scan complete received', data)
let message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" complete!`
let toastType = 'success'
if (data.error) {
message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" finished with error:\n${data.error}`
toastType = 'error'
} else if (data.results) {
var scanResultMsgs = []
var results = data.results
if (results.added) scanResultMsgs.push(`${results.added} added`)
if (results.updated) scanResultMsgs.push(`${results.updated} updated`)
if (results.removed) scanResultMsgs.push(`${results.removed} removed`)
if (results.missing) scanResultMsgs.push(`${results.missing} missing`)
if (!scanResultMsgs.length) message += '\nEverything was up to date'
else message += '\n' + scanResultMsgs.join('\n')
} else {
message = `${data.type === 'match' ? 'Match' : 'Scan'} "${data.name}" was canceled`
}
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
if (existingScan && !isNaN(existingScan.toastId)) {
this.$toast.update(existingScan.toastId, { content: message, options: { timeout: 5000, type: toastType, closeButton: false, onClose: () => null } }, true)
} else {
this.$toast[toastType](message, { timeout: 5000 })
}
this.$store.commit('scanners/remove', data)
},
onScanToastCancel(id) {
this.$root.socket.emit('cancel_scan', id)
},
scanStart(data) {
data.toastId = this.$toast(`${data.type === 'match' ? 'Matching' : 'Scanning'} "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) })
this.$store.commit('scanners/addUpdate', data)
},
scanProgress(data) {
var existingScan = this.$store.getters['scanners/getLibraryScan'](data.id)
if (existingScan && !isNaN(existingScan.toastId)) {
data.toastId = existingScan.toastId
this.$toast.update(existingScan.toastId, { content: `Scanning "${existingScan.name}"... ${data.progress.progress || 0}%`, options: { timeout: false } }, true)
} else {
data.toastId = this.$toast(`Scanning "${data.name}"...`, { timeout: false, type: 'info', draggable: false, closeOnClick: false, closeButton: CloseButton, closeButtonClassName: 'cancel-scan-btn', showCloseButtonOnHover: false, onClose: () => this.onScanToastCancel(data.id) })
}
this.$store.commit('scanners/addUpdate', data)
},
taskStarted(task) {
console.log('Task started', task)
this.$store.commit('tasks/addUpdateTask', task)
},
taskFinished(task) {
console.log('Task finished', task)
this.$store.commit('tasks/addUpdateTask', task)
},
metadataEmbedQueueUpdate(data) {
if (data.queued) {
this.$store.commit('tasks/addQueuedEmbedLId', data.libraryItemId)
} else {
this.$store.commit('tasks/removeQueuedEmbedLId', data.libraryItemId)
}
},
userUpdated(user) {
if (this.$store.state.user.user.id === user.id) {
this.$store.dispatch('user/setUser', user)
}
},
userOnline(user) {
this.$store.commit('users/updateUserOnline', user)
},
userOffline(user) {
this.$store.commit('users/removeUserOnline', user)
},
userStreamUpdate(user) {
this.$store.commit('users/updateUserOnline', user)
},
userSessionClosed(sessionId) {
// If this session or other session is closed then dismiss multiple sessions warning toast
if (sessionId === this.multiSessionOtherSessionId || this.multiSessionCurrentSessionId === sessionId) {
this.multiSessionOtherSessionId = null
this.multiSessionCurrentSessionId = null
this.$toast.dismiss('multiple-sessions')
}
if (this.$refs.streamContainer) this.$refs.streamContainer.sessionClosedEvent(sessionId)
},
userMediaProgressUpdate(payload) {
this.$store.commit('user/updateMediaProgress', payload)
if (payload.data) {
if (this.$store.getters['getIsMediaStreaming'](payload.data.libraryItemId, payload.data.episodeId) && this.$store.state.playbackSessionId !== payload.sessionId) {
this.multiSessionOtherSessionId = payload.sessionId
this.multiSessionCurrentSessionId = this.$store.state.playbackSessionId
console.log(`Media progress was updated from another session (${this.multiSessionOtherSessionId}) for currently open media. Device description=${payload.deviceDescription}. Current session id=${this.multiSessionCurrentSessionId}`)
if (this.$store.state.streamIsPlaying) {
this.$toast.update('multiple-sessions', { content: `Another session is open for this item on device ${payload.deviceDescription}`, options: { timeout: 20000, type: 'warning', pauseOnFocusLoss: false } }, true)
} else {
this.$eventBus.$emit('playback-time-update', payload.data.currentTime)
}
}
}
},
collectionAdded(collection) {
if (this.currentLibraryId !== collection.libraryId) return
this.$store.commit('libraries/addUpdateCollection', collection)
},
collectionUpdated(collection) {
if (this.currentLibraryId !== collection.libraryId) return
this.$store.commit('libraries/addUpdateCollection', collection)
},
collectionRemoved(collection) {
if (this.currentLibraryId !== collection.libraryId) return
if (this.$route.name.startsWith('collection')) {
if (this.$route.params.id === collection.id) {
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/collections`)
}
}
this.$store.commit('libraries/removeCollection', collection)
},
seriesRemoved({ id, libraryId }) {
if (this.currentLibraryId !== libraryId) return
this.$store.commit('libraries/removeSeriesFromFilterData', id)
},
playlistAdded(playlist) {
if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return
this.$store.commit('libraries/addUpdateUserPlaylist', playlist)
},
playlistUpdated(playlist) {
if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return
this.$store.commit('libraries/addUpdateUserPlaylist', playlist)
},
playlistRemoved(playlist) {
if (playlist.userId !== this.user.id || this.currentLibraryId !== playlist.libraryId) return
if (this.$route.name.startsWith('playlist')) {
if (this.$route.params.id === playlist.id) {
this.$router.replace(`/library/${this.currentLibraryId}/bookshelf/playlists`)
}
}
this.$store.commit('libraries/removeUserPlaylist', playlist)
},
backupApplied() {
// Force refresh
location.reload()
},
batchQuickMatchComplete(result) {
var success = result.success || false
var toast = 'Batch quick match complete!\n' + result.updates + ' Updated'
if (result.unmatched && result.unmatched > 0) {
toast += '\n' + result.unmatched + ' with no matches'
}
if (success) {
this.$toast.success(toast)
} else {
this.$toast.info(toast)
}
},
adminMessageEvt(message) {
this.$toast.info(message)
},
ereaderDevicesUpdated(data) {
if (!data?.ereaderDevices) return
this.$store.commit('libraries/setEReaderDevices', data.ereaderDevices)
},
initializeSocket() {
this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
persist: 'main',
teardown: false,
transports: ['websocket'],
upgrade: false,
reconnection: true
})
this.$root.socket = this.socket
console.log('Socket initialized')
// Pre-defined socket events
this.socket.on('connect', this.connect)
this.socket.on('connect_error', this.connectError)
this.socket.on('disconnect', this.disconnect)
this.socket.io.on('reconnect_attempt', this.reconnectAttempt)
this.socket.io.on('reconnect', this.reconnect)
this.socket.io.on('reconnect_error', this.reconnectError)
this.socket.io.on('reconnect_failed', this.reconnectFailed)
// Event received after authorizing socket
this.socket.on('init', this.init)
// Stream Listeners
this.socket.on('stream_open', this.streamOpen)
this.socket.on('stream_closed', this.streamClosed)
this.socket.on('stream_progress', this.streamProgress)
this.socket.on('stream_ready', this.streamReady)
this.socket.on('stream_reset', this.streamReset)
this.socket.on('stream_error', this.streamError)
// Library Listeners
this.socket.on('library_updated', this.libraryUpdated)
this.socket.on('library_added', this.libraryAdded)
this.socket.on('library_removed', this.libraryRemoved)
// Library Item Listeners
this.socket.on('item_added', this.libraryItemAdded)
this.socket.on('item_updated', this.libraryItemUpdated)
this.socket.on('item_removed', this.libraryItemRemoved)
this.socket.on('items_updated', this.libraryItemsUpdated)
this.socket.on('items_added', this.libraryItemsAdded)
// User Listeners
this.socket.on('user_updated', this.userUpdated)
this.socket.on('user_online', this.userOnline)
this.socket.on('user_offline', this.userOffline)
this.socket.on('user_stream_update', this.userStreamUpdate)
this.socket.on('user_session_closed', this.userSessionClosed)
this.socket.on('user_item_progress_updated', this.userMediaProgressUpdate)
// Collection Listeners
this.socket.on('collection_added', this.collectionAdded)
this.socket.on('collection_updated', this.collectionUpdated)
this.socket.on('collection_removed', this.collectionRemoved)
// Series Listeners
this.socket.on('series_removed', this.seriesRemoved)
// User Playlist Listeners
this.socket.on('playlist_added', this.playlistAdded)
this.socket.on('playlist_updated', this.playlistUpdated)
this.socket.on('playlist_removed', this.playlistRemoved)
// Scan Listeners
this.socket.on('scan_start', this.scanStart)
this.socket.on('scan_complete', this.scanComplete)
this.socket.on('scan_progress', this.scanProgress)
// Task Listeners
this.socket.on('task_started', this.taskStarted)
this.socket.on('task_finished', this.taskFinished)
this.socket.on('metadata_embed_queue_update', this.metadataEmbedQueueUpdate)
// EReader Device Listeners
this.socket.on('ereader-devices-updated', this.ereaderDevicesUpdated)
this.socket.on('backup_applied', this.backupApplied)
this.socket.on('batch_quickmatch_complete', this.batchQuickMatchComplete)
this.socket.on('admin_message', this.adminMessageEvt)
},
showUpdateToast(versionData) {
var ignoreVersion = localStorage.getItem('ignoreVersion')
var latestVersion = versionData.latestVersion
if (!ignoreVersion || ignoreVersion !== latestVersion) {
this.$toast.info(`Update is available!\nCheck release notes for v${versionData.latestVersion}`, {
position: 'top-center',
toastClassName: 'cursor-pointer',
bodyClassName: 'custom-class-1',
timeout: 20000,
closeOnClick: false,
draggable: false,
hideProgressBar: false,
onClick: () => {
window.open(versionData.githubTagUrl, '_blank')
},
onClose: () => {
localStorage.setItem('ignoreVersion', versionData.latestVersion)
}
})
} else {
console.warn(`Update is available but user chose to dismiss it! v${versionData.latestVersion}`)
}
},
checkActiveElementIsInput() {
const activeElement = document.activeElement
const inputs = ['input', 'select', 'button', 'textarea', 'trix-editor']
return activeElement && inputs.some((i) => i === activeElement.tagName.toLowerCase())
},
getHotkeyName(e) {
var keyCode = e.keyCode || e.which
if (!this.$keynames[keyCode]) {
// Unused hotkey
return null
}
var keyName = this.$keynames[keyCode]
var name = keyName
if (e.shiftKey) name = 'Shift-' + keyName
if (process.env.NODE_ENV !== 'production') {
console.log('Hotkey command', name)
}
return name
},
keyDown(e) {
var name = this.getHotkeyName(e)
if (!name) return
// Input is focused then ignore key press
if (this.checkActiveElementIsInput()) {
return
}
// Modal is open
if (this.$store.state.openModal && Object.values(this.$hotkeys.Modal).includes(name)) {
this.$eventBus.$emit('modal-hotkey', name)
e.preventDefault()
return
}
// EReader is open
if (this.$store.state.showEReader && Object.values(this.$hotkeys.EReader).includes(name)) {
this.$eventBus.$emit('reader-hotkey', name)
e.preventDefault()
return
}
// Batch selecting
if (this.$store.getters['globals/getIsBatchSelectingMediaItems'] && name === 'Escape') {
// ESCAPE key cancels batch selection
this.$store.commit('globals/resetSelectedMediaItems', [])
this.$eventBus.$emit('bookshelf_clear_selection')
e.preventDefault()
return
}
// Playing audiobook
if (this.$store.state.streamLibraryItem && Object.values(this.$hotkeys.AudioPlayer).includes(name)) {
this.$eventBus.$emit('player-hotkey', name)
e.preventDefault()
}
},
resize() {
this.$store.commit('globals/updateWindowSize', { width: window.innerWidth, height: window.innerHeight })
},
checkVersionUpdate() {
this.$store
.dispatch('checkForUpdate')
.then((res) => {
if (res && res.hasUpdate) this.showUpdateToast(res)
})
.catch((err) => console.error(err))
},
initLocalStorage() {
// Queue auto play
var playerQueueAutoPlay = localStorage.getItem('playerQueueAutoPlay')
this.$store.commit('setPlayerQueueAutoPlay', playerQueueAutoPlay !== '0')
},
loadTasks() {
this.$axios
.$get('/api/tasks?include=queue')
.then((payload) => {
console.log('Fetched tasks', payload)
if (payload.tasks) {
this.$store.commit('tasks/setTasks', payload.tasks)
}
if (payload.queuedTaskData?.embedMetadata?.length) {
this.$store.commit(
'tasks/setQueuedEmbedLIds',
payload.queuedTaskData.embedMetadata.map((td) => td.libraryItemId)
)
}
})
.catch((error) => {
console.error('Failed to load tasks', error)
})
},
changeLanguage(code) {
console.log('Changed lang', code)
this.currentLang = code
document.documentElement.lang = code
}
},
beforeMount() {
this.initializeSocket()
},
mounted() {
this.updateBodyClass()
this.resize()
this.$eventBus.$on('change-lang', this.changeLanguage)
window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown)
this.$store.dispatch('libraries/load')
this.initLocalStorage()
this.checkVersionUpdate()
this.loadTasks()
if (this.$route.query.error) {
this.$toast.error(this.$route.query.error)
this.$router.replace(this.$route.path)
}
// Set lang on HTML tag
if (this.$languageCodes?.current) {
document.documentElement.lang = this.$languageCodes.current
}
},
beforeDestroy() {
this.$eventBus.$off('change-lang', this.changeLanguage)
window.removeEventListener('resize', this.resize)
window.removeEventListener('keydown', this.keyDown)
}
}
</script>
<style>
.Vue-Toastification__toast-body.custom-class-1 {
font-size: 14px;
}
#app-content {
width: 100%;
}
#app-content.has-siderail {
width: calc(100% - 80px);
max-width: calc(100% - 80px);
margin-left: 80px;
}
@media (max-width: 768px) {
#app-content.has-siderail {
width: 100%;
max-width: 100%;
margin-left: 0px;
}
}
</style>