From 1e5e0d926bfb29a44311ae1869c0f70432f3059b Mon Sep 17 00:00:00 2001 From: Tiberiu Ichim Date: Fri, 20 Feb 2026 18:09:50 +0200 Subject: [PATCH] feat: implement global keyboard shortcuts for power users (Consolidate, Merge, Move, Reset) --- artifacts/docs/ux_power_user_shortcuts.md | 44 +++++++++++++++++++ client/components/app/Appbar.vue | 53 ++++++++++++++++++++++- client/components/app/LazyBookshelf.vue | 16 ++----- client/pages/item/_id/index.vue | 37 ++++++++++++++++ 4 files changed, 136 insertions(+), 14 deletions(-) create mode 100644 artifacts/docs/ux_power_user_shortcuts.md diff --git a/artifacts/docs/ux_power_user_shortcuts.md b/artifacts/docs/ux_power_user_shortcuts.md new file mode 100644 index 000000000..8dc68864d --- /dev/null +++ b/artifacts/docs/ux_power_user_shortcuts.md @@ -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. diff --git a/client/components/app/Appbar.vue b/client/components/app/Appbar.vue index f82d48eba..78072d698 100644 --- a/client/components/app/Appbar.vue +++ b/client/components/app/Appbar.vue @@ -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) } } diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue index 3301b5d45..300c82096 100644 --- a/client/components/app/LazyBookshelf.vue +++ b/client/components/app/LazyBookshelf.vue @@ -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) { diff --git a/client/pages/item/_id/index.vue b/client/pages/item/_id/index.vue index f7fcd03a3..72e97a29a 100644 --- a/client/pages/item/_id/index.vue +++ b/client/pages/item/_id/index.vue @@ -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) } }