search bar
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
Reference in New Issue
Block a user