mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-03-03 06:29:42 +00:00
feat: implement global keyboard shortcuts for power users (Consolidate, Merge, Move, Reset)
This commit is contained in:
parent
f171755d43
commit
1e5e0d926b
4 changed files with 136 additions and 14 deletions
44
artifacts/docs/ux_power_user_shortcuts.md
Normal file
44
artifacts/docs/ux_power_user_shortcuts.md
Normal 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.
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue