search bar

This commit is contained in:
2026-02-20 17:38:29 -05:00
parent 089af5e299
commit 402789e763
10 changed files with 566 additions and 29 deletions
+5
View File
@@ -33,6 +33,11 @@ p {
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
background-color: var(--yj-bg-elevated, #343a40); background-color: var(--yj-bg-elevated, #343a40);
gap: 1em;
}
.top-bar search-bar {
flex: 0 1 320px;
} }
ul { ul {
+1
View File
@@ -15,6 +15,7 @@
<h1 class="title">YellowJacket</h1> <h1 class="title">YellowJacket</h1>
<h3 class="subtitle">Music how it was meant to bee.</h3> <h3 class="subtitle">Music how it was meant to bee.</h3>
</hgroup> </hgroup>
<search-bar></search-bar>
</header> </header>
<div class="sidebar"> <div class="sidebar">
<app-sidebar></app-sidebar> <app-sidebar></app-sidebar>
+22
View File
@@ -7,12 +7,15 @@ import '@components/queue-panel/queue-panel.ts';
import '@components/playlist-view/playlist-view.ts'; import '@components/playlist-view/playlist-view.ts';
import '@components/library-manager/library-manager.ts'; import '@components/library-manager/library-manager.ts';
import '@components/config-page/config-page.ts'; import '@components/config-page/config-page.ts';
import '@components/search-bar/search-bar.ts';
import type { SearchBar } from '@components/search-bar/search-bar.ts';
import '@awesome.me/webawesome/dist/styles/themes/default.css'; import '@awesome.me/webawesome/dist/styles/themes/default.css';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js'; import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js';
import { libraryStore } from '@store/library-store'; import { libraryStore } from '@store/library-store';
import { playlistStore } from '@store/playlist-store'; import { playlistStore } from '@store/playlist-store';
import { queueStore } from '@store/queue-store'; import { queueStore } from '@store/queue-store';
import { searchStore } from '@store/search-store';
// Importing the theme store triggers initialization: it fetches the saved // Importing the theme store triggers initialization: it fetches the saved
// theme from the backend and applies CSS custom properties to :root. // theme from the backend and applies CSS custom properties to :root.
import '@store/theme-store'; import '@store/theme-store';
@@ -37,6 +40,8 @@ document.addEventListener('navigate', (e: Event) => {
if (!mainContent) return; if (!mainContent) return;
searchStore.setCurrentView(view);
switch (view) { switch (view) {
case 'albums': case 'albums':
mainContent.innerHTML = '<cover-grid></cover-grid>'; mainContent.innerHTML = '<cover-grid></cover-grid>';
@@ -118,3 +123,20 @@ if (queueButton && queuePanel) {
}) as EventListener, }) as EventListener,
); );
} }
// ---------------------------------------------------------------
// Ctrl+F to focus the search bar
// ---------------------------------------------------------------
document.addEventListener('keydown', (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'f') {
const bar = document.querySelector(
'search-bar',
) as SearchBar | null;
if (bar && !bar.hasAttribute('hidden')) {
e.preventDefault();
bar.focusInput();
}
}
});
@@ -10,6 +10,7 @@ import { grid } from '@lit-labs/virtualizer/layouts/grid.js';
import { GetAlbumTracks } from '@go/library/Library'; import { GetAlbumTracks } from '@go/library/Library';
import { library } from '@go/models'; import { library } from '@go/models';
import { LibraryController } from '@store/controllers/library-controller'; import { LibraryController } from '@store/controllers/library-controller';
import { SearchController } from '@store/controllers/search-controller';
import { queueStore } from '@store/queue-store'; import { queueStore } from '@store/queue-store';
import { Events } from '../../events'; import { Events } from '../../events';
import '@awesome.me/webawesome/dist/components/popup/popup.js'; import '@awesome.me/webawesome/dist/components/popup/popup.js';
@@ -63,7 +64,9 @@ const ZOOM_STEP = 16;
@customElement('cover-grid') @customElement('cover-grid')
export class CoverGrid extends LitElement { export class CoverGrid extends LitElement {
private libraryCtrl = new LibraryController(this); private libraryCtrl = new LibraryController(this);
private searchCtrl = new SearchController(this);
private cancelScanComplete?: () => void; private cancelScanComplete?: () => void;
private lastSearchTerm = '';
// Fixed grid spacing constants. // Fixed grid spacing constants.
private static readonly GRID_GAP = 8; private static readonly GRID_GAP = 8;
@@ -163,6 +166,25 @@ export class CoverGrid extends LitElement {
string[] string[]
>(); >();
// =================================================================
// Filtered albums (search)
// =================================================================
private get filteredAlbums(): library.Album[] {
const term =
this.searchCtrl.term.toLowerCase();
if (!term) return this.albums;
return this.albums.filter(
(a) =>
a.Name.toLowerCase().includes(term) ||
a.ArtistName.toLowerCase().includes(
term,
),
);
}
/** Wheel event handler ref for manual add/remove. */ /** Wheel event handler ref for manual add/remove. */
private wheelHandler = (e: WheelEvent) => { private wheelHandler = (e: WheelEvent) => {
this.onWheel(e); this.onWheel(e);
@@ -634,7 +656,9 @@ export class CoverGrid extends LitElement {
// restore can place it at the same // restore can place it at the same
// visual position. // visual position.
if (this.expandedAlbumId !== null) { if (this.expandedAlbumId !== null) {
const idx = this.albums.findIndex( const filtered =
this.filteredAlbums;
const idx = filtered.findIndex(
(a) => (a) =>
a.ID === a.ID ===
this.expandedAlbumId, this.expandedAlbumId,
@@ -921,7 +945,7 @@ export class CoverGrid extends LitElement {
this.expandedAlbumId !== null this.expandedAlbumId !== null
) { ) {
const idx = const idx =
this.albums.findIndex( this.filteredAlbums.findIndex(
(a) => (a) =>
a.ID === a.ID ===
this this
@@ -1003,6 +1027,17 @@ export class CoverGrid extends LitElement {
})(); })();
} }
// Close dropdown and clear selection when
// the search term changes.
const currentTerm = this.searchCtrl.term;
if (currentTerm !== this.lastSearchTerm) {
this.lastSearchTerm = currentTerm;
this.closeDropdown();
this.selectedAlbums = new Set();
this.lastSelectedAlbumIndex = null;
}
// On zoom while dropdown is open, recompute // On zoom while dropdown is open, recompute
// the split (column count may change) and // the split (column count may change) and
// re-scroll after layout settles. // re-scroll after layout settles.
@@ -1133,7 +1168,7 @@ export class CoverGrid extends LitElement {
const safeIndex = Math.min( const safeIndex = Math.min(
saved, saved,
this.albums.length - 1, this.filteredAlbums.length - 1,
); );
if (safeIndex <= 0) return; if (safeIndex <= 0) return;
@@ -1352,10 +1387,11 @@ export class CoverGrid extends LitElement {
) { ) {
const pad = CoverGrid.GRID_PADDING; const pad = CoverGrid.GRID_PADDING;
const cols = this.currentColumnCount; const cols = this.currentColumnCount;
const filtered = this.filteredAlbums;
// Prefer the expanded album as focus. // Prefer the expanded album as focus.
if (this.expandedAlbumId !== null) { if (this.expandedAlbumId !== null) {
const idx = this.albums.findIndex( const idx = filtered.findIndex(
(a) => a.ID === this.expandedAlbumId, (a) => a.ID === this.expandedAlbumId,
); );
@@ -1387,7 +1423,7 @@ export class CoverGrid extends LitElement {
); );
const albumIndex = Math.min( const albumIndex = Math.min(
centerRow * cols, centerRow * cols,
Math.max(0, this.albums.length - 1), Math.max(0, filtered.length - 1),
); );
// Pixel offset from that album's top edge // Pixel offset from that album's top edge
@@ -1461,7 +1497,7 @@ export class CoverGrid extends LitElement {
private getCaratOffset(): number { private getCaratOffset(): number {
if (this.expandedAlbumId === null) return 0; if (this.expandedAlbumId === null) return 0;
const idx = this.albums.findIndex( const idx = this.filteredAlbums.findIndex(
(a) => a.ID === this.expandedAlbumId, (a) => a.ID === this.expandedAlbumId,
); );
@@ -1492,19 +1528,21 @@ export class CoverGrid extends LitElement {
* "before" virtualizer; the rest go into "after". * "before" virtualizer; the rest go into "after".
*/ */
private computeSplitIndex() { private computeSplitIndex() {
const filtered = this.filteredAlbums;
if (this.expandedAlbumId === null) { if (this.expandedAlbumId === null) {
this.splitIndex = this.albums.length; this.splitIndex = filtered.length;
return; return;
} }
const columns = this.getColumnCount(); const columns = this.getColumnCount();
const expandedIndex = this.albums.findIndex( const expandedIndex = filtered.findIndex(
(a) => a.ID === this.expandedAlbumId, (a) => a.ID === this.expandedAlbumId,
); );
if (expandedIndex < 0) { if (expandedIndex < 0) {
this.splitIndex = this.albums.length; this.splitIndex = filtered.length;
return; return;
} }
@@ -1513,7 +1551,7 @@ export class CoverGrid extends LitElement {
(Math.floor(expandedIndex / columns) + (Math.floor(expandedIndex / columns) +
1) * 1) *
columns, columns,
this.albums.length, filtered.length,
); );
} }
@@ -1528,11 +1566,12 @@ export class CoverGrid extends LitElement {
* component state (e.g. selectedAlbums) changes. * component state (e.g. selectedAlbums) changes.
*/ */
private buildGridEntries(): GridEntry[] { private buildGridEntries(): GridEntry[] {
const filtered = this.filteredAlbums;
const entries: GridEntry[] = []; const entries: GridEntry[] = [];
for (let i = 0; i < this.albums.length; i++) { for (let i = 0; i < filtered.length; i++) {
entries.push({ entries.push({
album: this.albums[i]!, album: filtered[i]!,
albumIndex: i, albumIndex: i,
}); });
} }
@@ -1814,7 +1853,8 @@ export class CoverGrid extends LitElement {
return; return;
} }
const expandedIndex = this.albums.findIndex( const filtered = this.filteredAlbums;
const expandedIndex = filtered.findIndex(
(a) => a.ID === this.expandedAlbumId, (a) => a.ID === this.expandedAlbumId,
); );
@@ -1920,12 +1960,13 @@ export class CoverGrid extends LitElement {
from: number, from: number,
to: number, to: number,
): Set<number> { ): Set<number> {
const filtered = this.filteredAlbums;
const start = Math.min(from, to); const start = Math.min(from, to);
const end = Math.max(from, to); const end = Math.max(from, to);
const ids = new Set<number>(); const ids = new Set<number>();
for (let i = start; i <= end; i++) { for (let i = start; i <= end; i++) {
const album = this.albums[i]; const album = filtered[i];
if (album) { if (album) {
ids.add(album.ID); ids.add(album.ID);
@@ -2119,6 +2160,7 @@ export class CoverGrid extends LitElement {
e: Event, e: Event,
): { album: library.Album; index: number } | null { ): { album: library.Album; index: number } | null {
const path = e.composedPath(); const path = e.composedPath();
const filtered = this.filteredAlbums;
for (const el of path) { for (const el of path) {
if ( if (
@@ -2130,7 +2172,7 @@ export class CoverGrid extends LitElement {
if (raw === undefined) return null; if (raw === undefined) return null;
const index = parseInt(raw, 10); const index = parseInt(raw, 10);
const album = this.albums[index]; const album = filtered[index];
if (!album) return null; if (!album) return null;
@@ -2309,7 +2351,7 @@ export class CoverGrid extends LitElement {
if (raw === undefined) return; if (raw === undefined) return;
const index = parseInt(raw, 10); const index = parseInt(raw, 10);
const album = this.albums[index]; const album = this.filteredAlbums[index];
if (album && img.src !== album.CoverArtPath) { if (album && img.src !== album.CoverArtPath) {
img.src = album.CoverArtPath; img.src = album.CoverArtPath;
@@ -2886,6 +2928,14 @@ export class CoverGrid extends LitElement {
`; `;
} }
if (this.filteredAlbums.length === 0) {
return html`
<div class="empty-state">
<p>No albums match your search.</p>
</div>
`;
}
const gridContent = this.splitMode const gridContent = this.splitMode
? this.renderSplitGrid() ? this.renderSplitGrid()
: this.renderSingleGrid(); : this.renderSingleGrid();
@@ -20,6 +20,7 @@ import type { playlist } from '@go/models';
import { queueStore } from '@store/queue-store'; import { queueStore } from '@store/queue-store';
import { PlayerController } from '@store/controllers/player-controller'; import { PlayerController } from '@store/controllers/player-controller';
import { PlaylistController } from '@store/controllers/playlist-controller'; import { PlaylistController } from '@store/controllers/playlist-controller';
import { SearchController } from '@store/controllers/search-controller';
import '@components/track-info/track-info'; import '@components/track-info/track-info';
import '@components/playlist-picker/playlist-picker.js'; import '@components/playlist-picker/playlist-picker.js';
import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js'; import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker.js';
@@ -53,11 +54,13 @@ export class PlaylistView
{ {
private player = new PlayerController(this); private player = new PlayerController(this);
private playlistCtrl = new PlaylistController(this); private playlistCtrl = new PlaylistController(this);
private searchCtrl = new SearchController(this);
private selection = new SelectionController(this); private selection = new SelectionController(this);
private cancelScanComplete?: () => void; private cancelScanComplete?: () => void;
private scrollDebounceTimer: ReturnType< private scrollDebounceTimer: ReturnType<
typeof setTimeout typeof setTimeout
> | null = null; > | null = null;
private lastSearchTerm = '';
/** /**
* Index of the playlist whose tracks are currently * Index of the playlist whose tracks are currently
@@ -65,6 +68,89 @@ export class PlaylistView
*/ */
private activePlaylistIndex = -1; private activePlaylistIndex = -1;
// =================================================================
// Filtered entries (search)
// =================================================================
private get filteredEntries(): PlaylistEntry[] {
const term =
this.searchCtrl.term.toLowerCase();
if (!term) return this.entries;
return this.entries.filter(
(e) =>
e.summary.Name.toLowerCase().includes(
term,
) ||
e.tracks.some(
(t) =>
t.Title.toLowerCase().includes(
term,
) ||
t.Artist.toLowerCase().includes(
term,
),
),
);
}
/**
* Return the tracks to display for a playlist entry,
* preserving original indices for event handlers.
* When a search term is active, only tracks matching
* the term are shown. When there is no search term
* (or the playlist matched by name), all tracks are
* returned.
*/
private getVisibleTracks(
entry: PlaylistEntry,
): { track: playlist.Track; trackIndex: number }[] {
const term =
this.searchCtrl.term.toLowerCase();
if (!term) {
return entry.tracks.map(
(track, trackIndex) => ({
track,
trackIndex,
}),
);
}
// If the playlist name itself matches, show
// all tracks — the whole playlist is relevant.
if (
entry.summary.Name.toLowerCase().includes(
term,
)
) {
return entry.tracks.map(
(track, trackIndex) => ({
track,
trackIndex,
}),
);
}
// Otherwise only show tracks whose metadata
// matches.
return entry.tracks
.map((track, trackIndex) => ({
track,
trackIndex,
}))
.filter(
({ track }) =>
track.Title.toLowerCase().includes(
term,
) ||
track.Artist.toLowerCase().includes(
term,
),
);
}
@state() private entries: PlaylistEntry[] = []; @state() private entries: PlaylistEntry[] = [];
@state() private loading = true; @state() private loading = true;
@state() private refreshing = false; @state() private refreshing = false;
@@ -607,6 +693,39 @@ export class PlaylistView
); );
} }
override updated() {
const currentTerm = this.searchCtrl.term;
if (currentTerm !== this.lastSearchTerm) {
this.lastSearchTerm = currentTerm;
this.selection.clear();
this.activePlaylistIndex = -1;
const term = currentTerm.toLowerCase();
this.entries = this.entries.map((e) => {
if (!term) {
return { ...e, expanded: false };
}
const hasTrackMatch = e.tracks.some(
(t) =>
t.Title.toLowerCase().includes(
term,
) ||
t.Artist.toLowerCase().includes(
term,
),
);
return {
...e,
expanded: hasTrackMatch,
};
});
}
}
private get scrollContainer(): HTMLElement | null { private get scrollContainer(): HTMLElement | null {
return ( return (
this.shadowRoot?.querySelector( this.shadowRoot?.querySelector(
@@ -1559,14 +1678,30 @@ export class PlaylistView
`; `;
} }
const visible = this.filteredEntries;
if (visible.length === 0) {
return html`
<div class="empty-state">
<p>No playlists match your search.</p>
</div>
`;
}
return html` return html`
<ul <ul
class="playlist-list" class="playlist-list"
@scroll=${this.onScroll} @scroll=${this.onScroll}
> >
${this.entries.map((entry, i) => ${visible.map((entry) => {
this.renderPlaylistItem(entry, i), const originalIndex =
)} this.entries.indexOf(entry);
return this.renderPlaylistItem(
entry,
originalIndex,
);
})}
</ul> </ul>
`; `;
} }
@@ -1686,8 +1821,8 @@ export class PlaylistView
Play All Play All
</button> </button>
</div> </div>
${entry.tracks.map( ${this.getVisibleTracks(entry).map(
(track, trackIndex) => { ({ track, trackIndex }) => {
const isPhantom = const isPhantom =
track.Phantom; track.Phantom;
const active = const active =
@@ -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`
<div class="search-container">
<wa-icon
class="search-icon"
name="magnifying-glass"
></wa-icon>
<input
type="text"
placeholder="Search..."
.value=${term}
@input=${this.handleInput}
@keydown=${this.handleKeydown}
/>
${term
? html`
<button
class="clear-button"
@click=${this.handleClear}
>
<wa-icon
name="xmark"
></wa-icon>
</button>
`
: nothing}
</div>
`;
}
}
@@ -6,6 +6,7 @@ import { formatMilliseconds } from '@utils/time';
import { SelectionController } from '@utils/selection-controller'; import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller'; import type { SelectionHost } from '@utils/selection-controller';
import { PlayerController } from '@store/controllers/player-controller'; import { PlayerController } from '@store/controllers/player-controller';
import { SearchController } from '@store/controllers/search-controller';
import { queueStore } from '@store/queue-store'; import { queueStore } from '@store/queue-store';
import { LibraryController } from '@store/controllers/library-controller'; import { LibraryController } from '@store/controllers/library-controller';
import { Events } from '../../events'; import { Events } from '../../events';
@@ -38,8 +39,10 @@ const COLUMN_COUNT = 3;
export class TrackList extends LitElement implements SelectionHost { export class TrackList extends LitElement implements SelectionHost {
private player = new PlayerController(this); private player = new PlayerController(this);
private libraryCtrl = new LibraryController(this); private libraryCtrl = new LibraryController(this);
private searchCtrl = new SearchController(this);
private selection = new SelectionController(this); private selection = new SelectionController(this);
private cancelScanComplete?: () => void; private cancelScanComplete?: () => void;
private lastSearchTerm = '';
@state() @state()
private tracks: library.Track[] = []; private tracks: library.Track[] = [];
@@ -89,16 +92,36 @@ export class TrackList extends LitElement implements SelectionHost {
private flowLayout = flow(); private flowLayout = flow();
private hasRestoredScroll = false; 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 // SelectionHost interface
// ================================================================= // =================================================================
getItemKey(index: number): string | undefined { getItemKey(index: number): string | undefined {
return this.tracks[index]?.FilePath; return this.filteredTracks[index]?.FilePath;
} }
getItemCount(): number { getItemCount(): number {
return this.tracks.length; return this.filteredTracks.length;
} }
onSelectionChanged(): void { onSelectionChanged(): void {
@@ -295,6 +318,12 @@ export class TrackList extends LitElement implements SelectionHost {
background-color: var(--yj-text-tertiary, #6c757d); background-color: var(--yj-text-tertiary, #6c757d);
} }
.no-results {
padding: 24px 16px;
color: var(--yj-text-secondary, #b3b3b3);
font-size: 13px;
}
lit-virtualizer { lit-virtualizer {
flex: 1; flex: 1;
overflow-x: hidden; overflow-x: hidden;
@@ -456,6 +485,14 @@ export class TrackList extends LitElement implements SelectionHost {
this.lastActiveTrackPath = currentPath; this.lastActiveTrackPath = currentPath;
this.virtualizer?.requestUpdate(); 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; private previousHostWidth = 0;
@@ -752,6 +789,8 @@ export class TrackList extends LitElement implements SelectionHost {
}; };
override render() { override render() {
const visibleTracks = this.filteredTracks;
return html` return html`
${this.tracks.length === 0 ${this.tracks.length === 0
? html`<p>Loading tracks...</p>` ? html`<p>Loading tracks...</p>`
@@ -761,12 +800,18 @@ export class TrackList extends LitElement implements SelectionHost {
<div class="header-cell">Artist</div> <div class="header-cell">Artist</div>
<div class="header-cell">Track Length</div> <div class="header-cell">Track Length</div>
</div> </div>
<lit-virtualizer ${visibleTracks.length === 0
scroller ? html`<p class="no-results">
.items=${this.tracks} No tracks match your search.
.renderItem=${this.renderTrackRow} </p>`
.layout=${this.flowLayout} : html`
></lit-virtualizer> <lit-virtualizer
scroller
.items=${visibleTracks}
.renderItem=${this.renderTrackRow}
.layout=${this.flowLayout}
></lit-virtualizer>
`}
`} `}
<div class="resize-overlay"> <div class="resize-overlay">
@@ -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();
}
}
+2
View File
@@ -7,3 +7,5 @@ export { QueueController } from './controllers/queue-controller';
export { themeStore } from './theme-store'; export { themeStore } from './theme-store';
export type { ThemeState, BackgroundShade } from './theme-store'; export type { ThemeState, BackgroundShade } from './theme-store';
export { ThemeController } from './controllers/theme-controller'; export { ThemeController } from './controllers/theme-controller';
export { searchStore } from './search-store';
export { SearchController } from './controllers/search-controller';
+61
View File
@@ -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<Subscriber>();
// ===================================================================
// 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();