feat: implement global keyboard shortcuts for power users (Consolidate, Merge, Move, Reset)

This commit is contained in:
Tiberiu Ichim 2026-02-20 18:09:50 +02:00
parent f171755d43
commit 1e5e0d926b
4 changed files with 136 additions and 14 deletions

View file

@ -0,0 +1,44 @@
# UX and Power User Shortcuts
## Overview
Audiobookshelf includes several workflow improvements designed to speed up common tasks and ensure a smooth navigation experience.
## 1. Keyboard Shortcuts
To improve the efficiency of batch operations, global keyboard listeners have been added to the library views.
- **Select All**: `Ctrl + A` (Windows/Linux) or `Cmd + A` (macOS).
- **Behavior**: Selects all items currently loaded on the screen.
- **Persistent Selection**: If you scroll down and new items load while in "Select All" mode, the new items will be automatically selected.
- **Exit**: Clicking outside the items or manually deselecting an item will toggle off the "Select All" persistent state.
- **Action Shortcuts** (Context-aware: Applied to selection in Library, or current item on Item Page):
- **Consolidate**: `Ctrl + K`.
- **Merge**: `Ctrl + M` (Requires 2+ selected items).
- **Move to Library**: `Ctrl + Shift + M`.
- **Reset Metadata**: `Alt + R`. (Note: `Alt` is used specifically to avoid conflict with standard "Reload" `Ctrl + R`).
## 2. Navigation and Filter Persistence
The interface manages filter states dynamically to prevent confusion when switching contexts.
### Library Switch Reset
When you switch from one library to another (e.g., from "Audiobooks" to "Podcasts"):
- **Search Reset**: The search query is cleared.
- **Filter Reset**: All filters (Genre, Series, Collections, Progress) are reset to "All".
- **Sort Reset**: The view returns to the library's default sort order.
- **Rationale**: This prevents the "No results found" scenario that occurs when filters from one library are unknowingly applied to another.
### Home View "View All" Shortcuts
The Home view includes shortcuts next to shelf headlines to jump directly to a filtered/sorted library view.
| Shortcut | Action |
| :--- | :--- |
| **Recently Added** | Navigates to Library -> Bookshelf sorted by `Added At` (DESC). |
| **Recent Series** | Navigates to Library -> Series sorted by `Recent`. |
| **Newest Authors** | Navigates to Library -> Authors sorted by `Added At` (DESC). |
## 3. Batch Selection Mode
When multiple items are selected:
- A specialized **Selection App Bar** appears at the top.
- **Counters**: Displays exactly how many items are selected.
- **Contextual Menu**: The three-dot menu in this bar dynamically updates to show only actions relevant to the selected items (e.g., "Merge" only appears if books are selected).
- **Escape**: Pressing `Esc` or clicking the "X" in the selection bar clears all selections.

View file

@ -128,7 +128,14 @@ export default {
return this.selectedMediaItems.length
},
selectedMediaItems() {
return this.$store.state.globals.selectedMediaItems
return this.$store.state.globals.selectedMediaItems || []
},
isItemPage() {
return this.$route.name === 'item-id'
},
isBookshelfPage() {
const bookshelfRoutes = ['library-library-bookshelf', 'library-library-series', 'library-library-collections', 'library-library-playlists', 'library-library-authors', 'library-library']
return bookshelfRoutes.includes(this.$route.name)
},
selectedMediaItemsArePlayable() {
return !this.selectedMediaItems.some((i) => !i.hasTracks)
@ -526,13 +533,57 @@ export default {
},
batchAutoMatchClick() {
this.$store.commit('globals/setShowBatchQuickMatchModal', true)
},
handleKeyDown(e) {
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) {
return
}
const ctrlOrMeta = e.ctrlKey || e.metaKey
const shift = e.shiftKey
const alt = e.altKey
if (ctrlOrMeta && e.key === 'a') {
if (this.isBookshelfPage) {
e.preventDefault()
this.$eventBus.$emit('bookshelf_select_all')
}
} else if (ctrlOrMeta && e.key.toLowerCase() === 'k') {
e.preventDefault()
if (this.numMediaItemsSelected > 0) {
this.batchConsolidate()
} else if (this.isItemPage) {
this.$eventBus.$emit('item_shortcut_consolidate')
}
} else if (ctrlOrMeta && !shift && e.key.toLowerCase() === 'm') {
if (this.numMediaItemsSelected > 1) {
e.preventDefault()
this.batchMerge()
}
} else if (ctrlOrMeta && shift && e.key.toLowerCase() === 'm') {
e.preventDefault()
if (this.numMediaItemsSelected > 0) {
this.batchMoveToLibrary()
} else if (this.isItemPage) {
this.$eventBus.$emit('item_shortcut_move')
}
} else if (alt && e.key.toLowerCase() === 'r') {
e.preventDefault()
if (this.numMediaItemsSelected > 0) {
this.batchResetMetadata()
} else if (this.isItemPage) {
this.$eventBus.$emit('item_shortcut_reset')
}
}
}
},
mounted() {
this.$eventBus.$on('bookshelf-total-entities', this.setBookshelfTotalEntities)
window.addEventListener('keydown', this.handleKeyDown)
},
beforeDestroy() {
this.$eventBus.$off('bookshelf-total-entities', this.setBookshelfTotalEntities)
window.removeEventListener('keydown', this.handleKeyDown)
}
}
</script>

View file

@ -816,19 +816,8 @@ export default {
windowResize() {
this.executeRebuild()
},
handleKeyDown(e) {
if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
// Only trigger if no input/textarea is focused
if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) {
return
}
e.preventDefault()
this.selectAll()
}
},
initListeners() {
window.addEventListener('resize', this.windowResize)
window.addEventListener('keydown', this.handleKeyDown)
this.$nextTick(() => {
var bookshelf = document.getElementById('bookshelf')
@ -839,6 +828,7 @@ export default {
})
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$on('bookshelf_select_all', this.selectAll)
this.$eventBus.$on('user-settings', this.settingsUpdated)
if (this.$root.socket) {
@ -864,13 +854,13 @@ export default {
},
removeListeners() {
window.removeEventListener('resize', this.windowResize)
window.removeEventListener('keydown', this.handleKeyDown)
var bookshelf = document.getElementById('bookshelf')
if (bookshelf) {
bookshelf.removeEventListener('scroll', this.scroll)
}
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
this.$eventBus.$off('bookshelf_select_all', this.selectAll)
this.$eventBus.$off('user-settings', this.settingsUpdated)
if (this.$root.socket) {

View file

@ -835,6 +835,33 @@ export default {
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
resetMetadata() {
const payload = {
message: `Are you sure you want to reset metadata for "${this.title}"? This will remove metadata files and re-scan the item from files.`,
callback: (confirmed) => {
if (confirmed) {
this.processing = true
this.$axios
.$post('/api/items/batch/reset-metadata', {
libraryItemIds: [this.libraryItemId]
})
.then(() => {
this.$toast.success('Reset metadata successful')
})
.catch((error) => {
console.error('Reset metadata failed', error)
const errorMsg = error.response?.data || 'Reset metadata failed'
this.$toast.error(errorMsg)
})
.finally(() => {
this.processing = false
})
}
},
type: 'yesNo'
}
this.$store.commit('globals/setConfirmPrompt', payload)
},
contextMenuAction({ action, data }) {
if (action === 'collections') {
this.$store.commit('setSelectedLibraryItem', this.libraryItem)
@ -879,6 +906,12 @@ export default {
this.$root.socket.on('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.on('episode_download_finished', this.episodeDownloadFinished)
this.$root.socket.on('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
this.$eventBus.$on('item_shortcut_consolidate', this.consolidate)
this.$eventBus.$on('item_shortcut_move', () => {
this.contextMenuAction({ action: 'move' })
})
this.$eventBus.$on('item_shortcut_reset', this.resetMetadata)
},
beforeDestroy() {
this.$eventBus.$off(`${this.libraryItem.id}_updated`, this.libraryItemUpdated)
@ -891,6 +924,10 @@ export default {
this.$root.socket.off('episode_download_started', this.episodeDownloadStarted)
this.$root.socket.off('episode_download_finished', this.episodeDownloadFinished)
this.$root.socket.off('episode_download_queue_cleared', this.episodeDownloadQueueCleared)
this.$eventBus.$off('item_shortcut_consolidate', this.consolidate)
this.$eventBus.$off('item_shortcut_move')
this.$eventBus.$off('item_shortcut_reset', this.resetMetadata)
}
}
</script>