Skip to content

Commit 08069be

Browse files
feat(library): add type-to-jump artist navigation (WIP)
Implement keyboard-driven artist navigation in library view: - Type characters to jump to matching artist at word boundary - 500ms debounce clears search buffer - Case-insensitive matching - Respects ignore words setting from Settings > Sorting - Falls back to default ignore words when input is empty Known issue: Default ignore words fallback not working correctly when sortIgnoreWordsList is empty but feature is enabled. Task: task-255 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent ab9805d commit 08069be

File tree

9 files changed

+630
-23
lines changed

9 files changed

+630
-23
lines changed

app/frontend/__tests__/ui.store.test.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
1414
import { test, fc } from '@fast-check/vitest';
15+
import { DEFAULT_SORT_IGNORE_WORDS } from '../js/constants.js';
1516

1617
// Mock window.settings
1718
const mockSettings = {
@@ -80,7 +81,7 @@ function createTestUIStore() {
8081
themePreset: 'light',
8182
settingsSection: 'general',
8283
sortIgnoreWords: true,
83-
sortIgnoreWordsList: 'the, le, la, los, a',
84+
sortIgnoreWordsList: DEFAULT_SORT_IGNORE_WORDS,
8485

8586
modal: null,
8687
contextMenu: null,
@@ -613,7 +614,7 @@ describe('UI Store - Sort Ignore Words', () => {
613614
});
614615

615616
it('should have default sort ignore words list', () => {
616-
expect(store.sortIgnoreWordsList).toBe('the, le, la, los, a');
617+
expect(store.sortIgnoreWordsList).toBe(DEFAULT_SORT_IGNORE_WORDS);
617618
});
618619
});
619620

app/frontend/js/components/library-browser.js

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { api } from '../api.js';
2+
import { DEFAULT_SORT_IGNORE_WORDS } from '../constants.js';
23

34
// Default column widths in pixels (all columns have explicit widths for grid layout)
45
const DEFAULT_COLUMN_WIDTHS = {
@@ -84,6 +85,10 @@ export function createLibraryBrowser(Alpine) {
8485
columnDragStartX: 0,
8586
wasColumnDragging: false,
8687

88+
// Type-to-jump state
89+
_typeBuffer: '', // Accumulated typed characters
90+
_typeDebounceTimer: null, // Timeout ID for clearing buffer
91+
8792
containerWidth: 0,
8893
resizeObserver: null,
8994

@@ -413,6 +418,9 @@ export function createLibraryBrowser(Alpine) {
413418
}
414419
});
415420

421+
// Type-to-jump: listen for printable characters to jump to matching artist
422+
document.addEventListener('keydown', (e) => this.handleTypeToJump(e));
423+
416424
document.addEventListener('mouseup', () => {
417425
if (this.resizingColumn) {
418426
this.finishColumnResize();
@@ -1564,6 +1572,104 @@ export function createLibraryBrowser(Alpine) {
15641572
);
15651573
},
15661574

1575+
/**
1576+
* Handle type-to-jump navigation - jump to artist matching typed characters
1577+
* @param {KeyboardEvent} event
1578+
*/
1579+
handleTypeToJump(event) {
1580+
// Only in library view
1581+
if (this.$store.ui.view !== 'library') return;
1582+
1583+
// Ignore if typing in input field
1584+
if (this.isTypingInInput(event)) return;
1585+
1586+
// Ignore modifier-only keys and non-printable characters
1587+
if (event.metaKey || event.ctrlKey || event.altKey) return;
1588+
if (event.key.length !== 1) return; // Only single printable chars
1589+
1590+
// Append to buffer
1591+
this._typeBuffer += event.key;
1592+
1593+
// Find and scroll to matching artist
1594+
this.jumpToMatchingArtist(this._typeBuffer);
1595+
1596+
// Reset debounce timer
1597+
this.resetTypeDebounce();
1598+
},
1599+
1600+
/**
1601+
* Strip leading ignore word prefix from a string (respects sortIgnoreWords setting)
1602+
* @param {string} value - String to process (lowercase)
1603+
* @returns {string} String with prefix removed if ignore words enabled
1604+
*/
1605+
stripIgnoredPrefix(value) {
1606+
const uiStore = this.$store.ui;
1607+
if (!uiStore.sortIgnoreWords) {
1608+
return value;
1609+
}
1610+
1611+
// Fall back to default list when user clears the input
1612+
const wordsList = uiStore.sortIgnoreWordsList?.trim() || DEFAULT_SORT_IGNORE_WORDS;
1613+
1614+
const ignoreWords = wordsList
1615+
.split(',')
1616+
.map((w) => w.trim().toLowerCase())
1617+
.filter(Boolean);
1618+
1619+
for (const word of ignoreWords) {
1620+
const prefix = word + ' ';
1621+
if (value.startsWith(prefix)) {
1622+
return value.slice(prefix.length);
1623+
}
1624+
}
1625+
return value;
1626+
},
1627+
1628+
/**
1629+
* Find and scroll to first track with artist matching the query at a word boundary
1630+
* @param {string} query - The search query (typed characters)
1631+
*/
1632+
jumpToMatchingArtist(query) {
1633+
const normalizedQuery = query.toLowerCase();
1634+
1635+
// Find first track with artist matching at word boundary
1636+
const matchingTrack = this.library.filteredTracks.find((track) => {
1637+
if (!track.artist) return false;
1638+
const artist = track.artist.toLowerCase();
1639+
1640+
// Check if query matches at start of artist name (with ignore words stripped)
1641+
const strippedArtist = this.stripIgnoredPrefix(artist);
1642+
if (strippedArtist.startsWith(normalizedQuery)) return true;
1643+
1644+
// Also check full artist name (for cases where user types "the")
1645+
if (artist.startsWith(normalizedQuery)) return true;
1646+
1647+
// Check if query matches at start of any word in artist name
1648+
const words = artist.split(/\s+/);
1649+
return words.some((word) => word.startsWith(normalizedQuery));
1650+
});
1651+
1652+
if (matchingTrack) {
1653+
// Select and scroll to the track
1654+
this.selectedTracks.clear();
1655+
this.selectedTracks.add(matchingTrack.id);
1656+
this.scrollToTrack(matchingTrack.id);
1657+
}
1658+
},
1659+
1660+
/**
1661+
* Reset the type-to-jump debounce timer
1662+
*/
1663+
resetTypeDebounce() {
1664+
if (this._typeDebounceTimer) {
1665+
clearTimeout(this._typeDebounceTimer);
1666+
}
1667+
this._typeDebounceTimer = setTimeout(() => {
1668+
this._typeBuffer = '';
1669+
this._typeDebounceTimer = null;
1670+
}, 500); // 500ms matches existing debounce patterns in codebase
1671+
},
1672+
15671673
/**
15681674
* Handle keyboard shortcuts
15691675
* @param {KeyboardEvent} event

app/frontend/js/constants.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
/**
2+
* Shared constants for the application
3+
*/
4+
5+
/** Default list of words to ignore when sorting (articles in various languages) */
6+
export const DEFAULT_SORT_IGNORE_WORDS = 'the, a, an, la, le, les, los, las, el, die, der, das, il, lo, gli';

app/frontend/js/stores/ui.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
11
import { api } from '../api.js';
2+
import { DEFAULT_SORT_IGNORE_WORDS } from '../constants.js';
3+
4+
// Re-export for consumers that import from ui.js
5+
export { DEFAULT_SORT_IGNORE_WORDS };
26

37
export function createUIStore(Alpine) {
48
Alpine.store('ui', {
@@ -13,7 +17,7 @@ export function createUIStore(Alpine) {
1317
themePreset: 'light',
1418
settingsSection: 'general',
1519
sortIgnoreWords: true,
16-
sortIgnoreWordsList: 'the, le, la, los, a',
20+
sortIgnoreWordsList: DEFAULT_SORT_IGNORE_WORDS,
1721

1822
modal: null,
1923
contextMenu: null,
@@ -54,7 +58,7 @@ export function createUIStore(Alpine) {
5458
this.sortIgnoreWords = window.settings.get('ui:sortIgnoreWords', true);
5559
this.sortIgnoreWordsList = window.settings.get(
5660
'ui:sortIgnoreWordsList',
57-
'the, le, la, los, a',
61+
DEFAULT_SORT_IGNORE_WORDS,
5862
);
5963

6064
console.log('[ui] Loaded settings from backend');

0 commit comments

Comments
 (0)