From 402789e763d49b9dd74bbe67ef689876b57afcc6 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Fri, 20 Feb 2026 17:38:29 -0500 Subject: [PATCH] search bar --- frontend/index.css | 5 + frontend/index.html | 1 + frontend/index.ts | 22 +++ .../src/components/cover-grid/cover-grid.ts | 82 +++++++-- .../components/playlist-view/playlist-view.ts | 145 +++++++++++++++- .../src/components/search-bar/search-bar.ts | 162 ++++++++++++++++++ .../src/components/track-list/track-list.ts | 61 ++++++- .../store/controllers/search-controller.ts | 54 ++++++ frontend/src/store/index.ts | 2 + frontend/src/store/search-store.ts | 61 +++++++ 10 files changed, 566 insertions(+), 29 deletions(-) create mode 100644 frontend/src/components/search-bar/search-bar.ts create mode 100644 frontend/src/store/controllers/search-controller.ts create mode 100644 frontend/src/store/search-store.ts diff --git a/frontend/index.css b/frontend/index.css index 9edc5f8..17fd694 100644 --- a/frontend/index.css +++ b/frontend/index.css @@ -33,6 +33,11 @@ p { justify-content: space-between; align-items: center; background-color: var(--yj-bg-elevated, #343a40); + gap: 1em; +} + +.top-bar search-bar { + flex: 0 1 320px; } ul { diff --git a/frontend/index.html b/frontend/index.html index 06c1258..b727352 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -15,6 +15,7 @@

YellowJacket

Music how it was meant to bee.

+ - ${entry.tracks.map( - (track, trackIndex) => { + ${this.getVisibleTracks(entry).map( + ({ track, trackIndex }) => { const isPhantom = track.Phantom; const active = diff --git a/frontend/src/components/search-bar/search-bar.ts b/frontend/src/components/search-bar/search-bar.ts new file mode 100644 index 0000000..769491c --- /dev/null +++ b/frontend/src/components/search-bar/search-bar.ts @@ -0,0 +1,162 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, query } from 'lit/decorators.js'; +import { SearchController } from '@store/controllers/search-controller'; +import '@awesome.me/webawesome/dist/components/icon/icon.js'; + +/** + * Global search bar displayed in the top bar. + * Hides itself when the active view is not searchable. + */ +@customElement('search-bar') +export class SearchBar extends LitElement { + private searchCtrl = new SearchController(this); + + @query('input') + private inputEl!: HTMLInputElement; + + static override styles = css` + :host { + display: flex; + align-items: center; + } + + :host([hidden]) { + display: none; + } + + .search-container { + display: flex; + align-items: center; + background: var(--yj-bg-surface, #212529); + border: 1px solid var(--yj-border-subtle, #555); + border-radius: 6px; + padding: 0 10px; + gap: 8px; + height: 32px; + min-width: 200px; + max-width: 360px; + width: 100%; + transition: border-color 0.15s ease; + } + + .search-container:focus-within { + border-color: var(--yj-accent, #ffd43b); + } + + .search-icon { + color: var(--yj-text-tertiary, #888); + font-size: 14px; + flex-shrink: 0; + } + + input { + flex: 1; + background: none; + border: none; + outline: none; + color: var(--yj-text-primary, #fff); + font-size: 13px; + font-family: inherit; + min-width: 0; + } + + input::placeholder { + color: var(--yj-text-tertiary, #888); + } + + .clear-button { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + color: var(--yj-text-tertiary, #888); + cursor: pointer; + padding: 0; + font-size: 12px; + flex-shrink: 0; + } + + .clear-button:hover { + color: var(--yj-text-primary, #fff); + } + `; + + override updated() { + // Toggle the hidden attribute based on whether the + // current view supports searching. + if (this.searchCtrl.isSearchableView) { + this.removeAttribute('hidden'); + } else { + this.setAttribute('hidden', ''); + } + } + + /** + * Focus the search input and select all text. + * Called by the global Ctrl+F handler. + */ + focusInput(): void { + if (!this.inputEl) return; + + this.inputEl.focus(); + this.inputEl.select(); + } + + private handleInput = (e: Event) => { + const input = e.target as HTMLInputElement; + this.searchCtrl.term = input.value; + }; + + private handleClear = () => { + this.searchCtrl.term = ''; + + if (this.inputEl) { + this.inputEl.value = ''; + this.inputEl.focus(); + } + }; + + private handleKeydown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + this.searchCtrl.term = ''; + + if (this.inputEl) { + this.inputEl.value = ''; + this.inputEl.blur(); + } + } + }; + + override render() { + const term = this.searchCtrl.term; + + return html` +
+ + + ${term + ? html` + + ` + : nothing} +
+ `; + } +} diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index 8ffdb33..b9e1c24 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -6,6 +6,7 @@ import { formatMilliseconds } from '@utils/time'; import { SelectionController } from '@utils/selection-controller'; import type { SelectionHost } from '@utils/selection-controller'; import { PlayerController } from '@store/controllers/player-controller'; +import { SearchController } from '@store/controllers/search-controller'; import { queueStore } from '@store/queue-store'; import { LibraryController } from '@store/controllers/library-controller'; import { Events } from '../../events'; @@ -38,8 +39,10 @@ const COLUMN_COUNT = 3; export class TrackList extends LitElement implements SelectionHost { private player = new PlayerController(this); private libraryCtrl = new LibraryController(this); + private searchCtrl = new SearchController(this); private selection = new SelectionController(this); private cancelScanComplete?: () => void; + private lastSearchTerm = ''; @state() private tracks: library.Track[] = []; @@ -89,16 +92,36 @@ export class TrackList extends LitElement implements SelectionHost { private flowLayout = flow(); private hasRestoredScroll = false; + // ================================================================= + // Filtered tracks (search) + // ================================================================= + + private get filteredTracks(): library.Track[] { + const term = this.searchCtrl.term.toLowerCase(); + + if (!term) return this.tracks; + + return this.tracks.filter( + (t) => + t.TrackName.toLowerCase().includes( + term, + ) || + t.ArtistName.toLowerCase().includes( + term, + ), + ); + } + // ================================================================= // SelectionHost interface // ================================================================= getItemKey(index: number): string | undefined { - return this.tracks[index]?.FilePath; + return this.filteredTracks[index]?.FilePath; } getItemCount(): number { - return this.tracks.length; + return this.filteredTracks.length; } onSelectionChanged(): void { @@ -295,6 +318,12 @@ export class TrackList extends LitElement implements SelectionHost { background-color: var(--yj-text-tertiary, #6c757d); } + .no-results { + padding: 24px 16px; + color: var(--yj-text-secondary, #b3b3b3); + font-size: 13px; + } + lit-virtualizer { flex: 1; overflow-x: hidden; @@ -456,6 +485,14 @@ export class TrackList extends LitElement implements SelectionHost { this.lastActiveTrackPath = currentPath; this.virtualizer?.requestUpdate(); } + + // Clear selection when search term changes. + const currentTerm = this.searchCtrl.term; + + if (currentTerm !== this.lastSearchTerm) { + this.lastSearchTerm = currentTerm; + this.selection.clear(); + } } private previousHostWidth = 0; @@ -752,6 +789,8 @@ export class TrackList extends LitElement implements SelectionHost { }; override render() { + const visibleTracks = this.filteredTracks; + return html` ${this.tracks.length === 0 ? html`

Loading tracks...

` @@ -761,12 +800,18 @@ export class TrackList extends LitElement implements SelectionHost {
Artist
Track Length
- + ${visibleTracks.length === 0 + ? html`

+ No tracks match your search. +

` + : html` + + `} `}
diff --git a/frontend/src/store/controllers/search-controller.ts b/frontend/src/store/controllers/search-controller.ts new file mode 100644 index 0000000..18cfcc4 --- /dev/null +++ b/frontend/src/store/controllers/search-controller.ts @@ -0,0 +1,54 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit'; +import { searchStore } from '../search-store'; + +/** + * SearchController connects a Lit component to the SearchStore. + * + * Usage in a component: + * + * private search = new SearchController(this); + * + * render() { + * const term = this.search.term; + * ... + * } + */ +export class SearchController implements ReactiveController { + private host: ReactiveControllerHost; + private unsubscribe?: () => void; + + constructor(host: ReactiveControllerHost) { + this.host = host; + host.addController(this); + } + + // =================================================================== + // LIFECYCLE HOOKS + // =================================================================== + + hostConnected(): void { + this.unsubscribe = searchStore.subscribe(() => { + this.host.requestUpdate(); + }); + } + + hostDisconnected(): void { + this.unsubscribe?.(); + } + + // =================================================================== + // DATA ACCESS + // =================================================================== + + get term(): string { + return searchStore.getTerm(); + } + + set term(value: string) { + searchStore.setTerm(value); + } + + get isSearchableView(): boolean { + return searchStore.isSearchableView(); + } +} diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts index 4e1695e..92deb27 100644 --- a/frontend/src/store/index.ts +++ b/frontend/src/store/index.ts @@ -7,3 +7,5 @@ export { QueueController } from './controllers/queue-controller'; export { themeStore } from './theme-store'; export type { ThemeState, BackgroundShade } from './theme-store'; export { ThemeController } from './controllers/theme-controller'; +export { searchStore } from './search-store'; +export { SearchController } from './controllers/search-controller'; diff --git a/frontend/src/store/search-store.ts b/frontend/src/store/search-store.ts new file mode 100644 index 0000000..13cd43d --- /dev/null +++ b/frontend/src/store/search-store.ts @@ -0,0 +1,61 @@ +/** Searchable views that respond to the global search term. */ +const SEARCHABLE_VIEWS = new Set(['tracks', 'albums', 'playlists']); + +type Subscriber = () => void; + +class SearchStore { + private term = ''; + private currentView = 'tracks'; + private subscribers = new Set(); + + // =================================================================== + // SEARCH TERM + // =================================================================== + + getTerm(): string { + return this.term; + } + + setTerm(term: string): void { + if (term === this.term) return; + + this.term = term; + this.notify(); + } + + // =================================================================== + // CURRENT VIEW + // =================================================================== + + getCurrentView(): string { + return this.currentView; + } + + setCurrentView(view: string): void { + if (view === this.currentView) return; + + this.currentView = view; + this.notify(); + } + + isSearchableView(): boolean { + return SEARCHABLE_VIEWS.has(this.currentView); + } + + // =================================================================== + // SUBSCRIPTION SYSTEM + // =================================================================== + + subscribe(callback: Subscriber): () => void { + this.subscribers.add(callback); + + return () => this.subscribers.delete(callback); + } + + private notify(): void { + this.subscribers.forEach((callback) => callback()); + } +} + +// Singleton instance. +export const searchStore = new SearchStore();