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 {
-
+ ${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();