added basic sorting in track-list and cover-grid
This commit is contained in:
@@ -61,6 +61,67 @@ const SCROLL_DEBOUNCE_MS = 100;
|
|||||||
/** Pixels to change card width per scroll tick. */
|
/** Pixels to change card width per scroll tick. */
|
||||||
const ZOOM_STEP = 16;
|
const ZOOM_STEP = 16;
|
||||||
|
|
||||||
|
/** localStorage keys for sort preferences. */
|
||||||
|
const SORT_FIELD_KEY = 'cover-grid-sort-field';
|
||||||
|
const SORT_DIR_KEY = 'cover-grid-sort-direction';
|
||||||
|
|
||||||
|
/** Available sort fields for the album grid. */
|
||||||
|
type AlbumSortField = 'name' | 'artist' | 'year';
|
||||||
|
|
||||||
|
/** Sort option definition for the dropdown. */
|
||||||
|
interface AlbumSortOption {
|
||||||
|
id: AlbumSortField;
|
||||||
|
label: string;
|
||||||
|
comparator: (
|
||||||
|
a: library.Album,
|
||||||
|
b: library.Album,
|
||||||
|
) => number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** All available sort options for albums. */
|
||||||
|
const ALBUM_SORT_OPTIONS: AlbumSortOption[] = [
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
label: 'Name',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
a.Name.localeCompare(b.Name),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'artist',
|
||||||
|
label: 'Artist',
|
||||||
|
comparator: (a, b) => {
|
||||||
|
const cmp = a.ArtistName.localeCompare(
|
||||||
|
b.ArtistName,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cmp !== 0) return cmp;
|
||||||
|
|
||||||
|
return a.Name.localeCompare(b.Name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'year',
|
||||||
|
label: 'Year',
|
||||||
|
comparator: (a, b) => {
|
||||||
|
// Albums without a year sort last.
|
||||||
|
if (!a.Year && !b.Year) {
|
||||||
|
return a.Name.localeCompare(b.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!a.Year) return 1;
|
||||||
|
if (!b.Year) return -1;
|
||||||
|
|
||||||
|
const cmp = a.Year - b.Year;
|
||||||
|
|
||||||
|
if (cmp !== 0) return cmp;
|
||||||
|
|
||||||
|
return a.Name.localeCompare(b.Name);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
@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);
|
||||||
@@ -186,14 +247,34 @@ export class CoverGrid extends LitElement {
|
|||||||
const term =
|
const term =
|
||||||
this.searchCtrl.term.toLowerCase();
|
this.searchCtrl.term.toLowerCase();
|
||||||
|
|
||||||
if (!term) return this.albums;
|
let albums: library.Album[];
|
||||||
|
|
||||||
return this.albums.filter(
|
if (!term) {
|
||||||
(a) =>
|
albums = this.albums;
|
||||||
a.Name.toLowerCase().includes(term) ||
|
} else {
|
||||||
a.ArtistName.toLowerCase().includes(
|
albums = this.albums.filter(
|
||||||
term,
|
(a) =>
|
||||||
),
|
a.Name.toLowerCase().includes(
|
||||||
|
term,
|
||||||
|
) ||
|
||||||
|
a.ArtistName.toLowerCase().includes(
|
||||||
|
term,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply sort.
|
||||||
|
const opt = ALBUM_SORT_OPTIONS.find(
|
||||||
|
(o) => o.id === this.sortField,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!opt) return albums;
|
||||||
|
|
||||||
|
const dir =
|
||||||
|
this.sortDirection === 'asc' ? 1 : -1;
|
||||||
|
|
||||||
|
return [...albums].sort(
|
||||||
|
(a, b) => dir * opt.comparator(a, b),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +296,120 @@ export class CoverGrid extends LitElement {
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ========================================
|
||||||
|
* Sort toolbar
|
||||||
|
* ======================================== */
|
||||||
|
|
||||||
|
.sort-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(
|
||||||
|
--yj-text-secondary,
|
||||||
|
#b3b3b3
|
||||||
|
);
|
||||||
|
border-bottom: 1px solid
|
||||||
|
var(--yj-border-subtle, #333);
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-anchor {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-anchor:hover {
|
||||||
|
background: var(
|
||||||
|
--yj-hover-overlay,
|
||||||
|
rgba(255, 255, 255, 0.05)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-anchor .sort-label {
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dir-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(
|
||||||
|
--yj-text-secondary,
|
||||||
|
#b3b3b3
|
||||||
|
);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dir-btn:hover {
|
||||||
|
background: var(
|
||||||
|
--yj-hover-overlay,
|
||||||
|
rgba(255, 255, 255, 0.05)
|
||||||
|
);
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-panel {
|
||||||
|
background-color: var(
|
||||||
|
--yj-bg-elevated,
|
||||||
|
#343a40
|
||||||
|
);
|
||||||
|
border: 1px solid
|
||||||
|
var(--yj-border, #444);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 0;
|
||||||
|
box-shadow: 0 8px 24px
|
||||||
|
rgba(0, 0, 0, 0.5);
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-panel wa-dropdown-item {
|
||||||
|
cursor: pointer;
|
||||||
|
--wa-color-text-normal: var(
|
||||||
|
--yj-text-primary,
|
||||||
|
#fff
|
||||||
|
);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-panel
|
||||||
|
wa-dropdown-item:hover {
|
||||||
|
background-color: var(
|
||||||
|
--yj-hover-overlay,
|
||||||
|
rgba(255, 255, 255, 0.1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-panel
|
||||||
|
wa-dropdown-item.active-sort {
|
||||||
|
color: var(--yj-accent, #ffd43b);
|
||||||
|
--wa-color-text-normal: var(
|
||||||
|
--yj-accent,
|
||||||
|
#ffd43b
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#sort-dropdown {
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
.grid-scroll-container {
|
.grid-scroll-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -438,6 +633,21 @@ export class CoverGrid extends LitElement {
|
|||||||
@state()
|
@state()
|
||||||
private playlistFilePaths: string[] = [];
|
private playlistFilePaths: string[] = [];
|
||||||
|
|
||||||
|
/** Current album sort field. */
|
||||||
|
@state()
|
||||||
|
private sortField: AlbumSortField = 'name';
|
||||||
|
|
||||||
|
/** Current sort direction. */
|
||||||
|
@state()
|
||||||
|
private sortDirection: SortDirection = 'asc';
|
||||||
|
|
||||||
|
/** Whether the sort dropdown popup is open. */
|
||||||
|
@state()
|
||||||
|
private sortDropdownOpen = false;
|
||||||
|
|
||||||
|
@query('#sort-dropdown')
|
||||||
|
private sortDropdownPopup!: HTMLElement;
|
||||||
|
|
||||||
/** ID of the album whose dropdown is currently open, or null. */
|
/** ID of the album whose dropdown is currently open, or null. */
|
||||||
@state()
|
@state()
|
||||||
private expandedAlbumId: number | null = null;
|
private expandedAlbumId: number | null = null;
|
||||||
@@ -531,12 +741,135 @@ export class CoverGrid extends LitElement {
|
|||||||
private transitionOverlay: HTMLDivElement | null =
|
private transitionOverlay: HTMLDivElement | null =
|
||||||
null;
|
null;
|
||||||
|
|
||||||
|
/* ====================================================================
|
||||||
|
* Sort controls
|
||||||
|
* ==================================================================== */
|
||||||
|
|
||||||
|
/** Restore sort preferences from localStorage. */
|
||||||
|
private restoreSortPreferences() {
|
||||||
|
try {
|
||||||
|
const field = localStorage.getItem(
|
||||||
|
SORT_FIELD_KEY,
|
||||||
|
);
|
||||||
|
const dir =
|
||||||
|
localStorage.getItem(SORT_DIR_KEY);
|
||||||
|
|
||||||
|
if (
|
||||||
|
field &&
|
||||||
|
ALBUM_SORT_OPTIONS.some(
|
||||||
|
(o) => o.id === field,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.sortField =
|
||||||
|
field as AlbumSortField;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dir === 'asc' || dir === 'desc') {
|
||||||
|
this.sortDirection = dir;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist sort preferences to localStorage. */
|
||||||
|
private saveSortPreferences() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(
|
||||||
|
SORT_FIELD_KEY,
|
||||||
|
this.sortField,
|
||||||
|
);
|
||||||
|
localStorage.setItem(
|
||||||
|
SORT_DIR_KEY,
|
||||||
|
this.sortDirection,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the sort field from the dropdown. */
|
||||||
|
private onSortDropdownSelect(
|
||||||
|
field: AlbumSortField,
|
||||||
|
) {
|
||||||
|
this.sortField = field;
|
||||||
|
this.saveSortPreferences();
|
||||||
|
this.closeSortDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle sort direction. */
|
||||||
|
private toggleSortDirection() {
|
||||||
|
this.sortDirection =
|
||||||
|
this.sortDirection === 'asc'
|
||||||
|
? 'desc'
|
||||||
|
: 'asc';
|
||||||
|
this.saveSortPreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleSortDropdown() {
|
||||||
|
if (this.sortDropdownOpen) {
|
||||||
|
this.closeSortDropdown();
|
||||||
|
} else {
|
||||||
|
this.openSortDropdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openSortDropdown() {
|
||||||
|
this.sortDropdownOpen = true;
|
||||||
|
|
||||||
|
await this.updateComplete;
|
||||||
|
|
||||||
|
const popup = this.sortDropdownPopup;
|
||||||
|
const anchor =
|
||||||
|
this.shadowRoot?.querySelector(
|
||||||
|
'.sort-anchor',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (popup && anchor) {
|
||||||
|
(popup as any).anchor = anchor;
|
||||||
|
(popup as any).active = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeSortDropdown() {
|
||||||
|
if (!this.sortDropdownOpen) return;
|
||||||
|
|
||||||
|
this.sortDropdownOpen = false;
|
||||||
|
|
||||||
|
const popup = this.sortDropdownPopup;
|
||||||
|
|
||||||
|
if (popup) {
|
||||||
|
(popup as any).active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortDropdownCloseHandler = (
|
||||||
|
e: MouseEvent,
|
||||||
|
) => {
|
||||||
|
if (!this.sortDropdownOpen) return;
|
||||||
|
|
||||||
|
const path = e.composedPath();
|
||||||
|
const popup = this.sortDropdownPopup;
|
||||||
|
|
||||||
|
if (popup && path.includes(popup)) return;
|
||||||
|
|
||||||
|
const anchor =
|
||||||
|
this.shadowRoot?.querySelector(
|
||||||
|
'.sort-anchor',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (anchor && path.includes(anchor)) return;
|
||||||
|
|
||||||
|
this.closeSortDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
/* ====================================================================
|
/* ====================================================================
|
||||||
* Lifecycle
|
* Lifecycle
|
||||||
* ==================================================================== */
|
* ==================================================================== */
|
||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
this.restoreSortPreferences();
|
||||||
this.loadAlbums();
|
this.loadAlbums();
|
||||||
this.cancelScanComplete = EventsOn(
|
this.cancelScanComplete = EventsOn(
|
||||||
Events.LibraryScanComplete,
|
Events.LibraryScanComplete,
|
||||||
@@ -554,6 +887,10 @@ export class CoverGrid extends LitElement {
|
|||||||
'mousedown',
|
'mousedown',
|
||||||
this.mousedownCloseHandler,
|
this.mousedownCloseHandler,
|
||||||
);
|
);
|
||||||
|
document.addEventListener(
|
||||||
|
'mousedown',
|
||||||
|
this.sortDropdownCloseHandler,
|
||||||
|
);
|
||||||
|
|
||||||
// error events do not bubble — use capture
|
// error events do not bubble — use capture
|
||||||
// phase to catch <img> load failures.
|
// phase to catch <img> load failures.
|
||||||
@@ -579,6 +916,10 @@ export class CoverGrid extends LitElement {
|
|||||||
'mousedown',
|
'mousedown',
|
||||||
this.mousedownCloseHandler,
|
this.mousedownCloseHandler,
|
||||||
);
|
);
|
||||||
|
document.removeEventListener(
|
||||||
|
'mousedown',
|
||||||
|
this.sortDropdownCloseHandler,
|
||||||
|
);
|
||||||
this.removeEventListener(
|
this.removeEventListener(
|
||||||
'error',
|
'error',
|
||||||
this.onGridImageError,
|
this.onGridImageError,
|
||||||
@@ -2764,6 +3105,90 @@ export class CoverGrid extends LitElement {
|
|||||||
this.closeContextMenu();
|
this.closeContextMenu();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* ====================================================================
|
||||||
|
* Render: sort toolbar
|
||||||
|
* ==================================================================== */
|
||||||
|
|
||||||
|
/** Render the sort toolbar above the grid. */
|
||||||
|
private renderSortToolbar() {
|
||||||
|
const activeOpt = ALBUM_SORT_OPTIONS.find(
|
||||||
|
(o) => o.id === this.sortField,
|
||||||
|
);
|
||||||
|
|
||||||
|
const label = activeOpt
|
||||||
|
? activeOpt.label
|
||||||
|
: 'Name';
|
||||||
|
|
||||||
|
const dirIcon =
|
||||||
|
this.sortDirection === 'asc'
|
||||||
|
? 'arrow-up-short-wide'
|
||||||
|
: 'arrow-down-wide-short';
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="sort-toolbar">
|
||||||
|
<span>Sort:</span>
|
||||||
|
<button
|
||||||
|
class="sort-anchor"
|
||||||
|
@click=${() =>
|
||||||
|
this.toggleSortDropdown()}
|
||||||
|
>
|
||||||
|
<span class="sort-label">
|
||||||
|
${label}
|
||||||
|
</span>
|
||||||
|
<wa-icon
|
||||||
|
name="chevron-down"
|
||||||
|
></wa-icon>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="sort-dir-btn"
|
||||||
|
title="${this.sortDirection === 'asc' ? 'Ascending' : 'Descending'}"
|
||||||
|
@click=${() =>
|
||||||
|
this.toggleSortDirection()}
|
||||||
|
>
|
||||||
|
<wa-icon
|
||||||
|
name=${dirIcon}
|
||||||
|
></wa-icon>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
${this.renderSortDropdownPopup()}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the sort dropdown popup. */
|
||||||
|
private renderSortDropdownPopup() {
|
||||||
|
return html`
|
||||||
|
<wa-popup
|
||||||
|
id="sort-dropdown"
|
||||||
|
placement="bottom-start"
|
||||||
|
flip
|
||||||
|
shift
|
||||||
|
.active=${this.sortDropdownOpen}
|
||||||
|
>
|
||||||
|
${this.sortDropdownOpen
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
class="sort-dropdown-panel"
|
||||||
|
>
|
||||||
|
${ALBUM_SORT_OPTIONS.map(
|
||||||
|
(opt) => html`
|
||||||
|
<wa-dropdown-item
|
||||||
|
class=${this.sortField === opt.id ? 'active-sort' : ''}
|
||||||
|
@click=${() =>
|
||||||
|
this.onSortDropdownSelect(
|
||||||
|
opt.id,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
${opt.label}
|
||||||
|
</wa-dropdown-item>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</wa-popup>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
/* ====================================================================
|
/* ====================================================================
|
||||||
* Rendering helpers
|
* Rendering helpers
|
||||||
* ==================================================================== */
|
* ==================================================================== */
|
||||||
@@ -2934,6 +3359,7 @@ export class CoverGrid extends LitElement {
|
|||||||
: this.renderSingleGrid();
|
: this.renderSingleGrid();
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
|
${this.renderSortToolbar()}
|
||||||
${this.searchCtrl.term
|
${this.searchCtrl.term
|
||||||
? html`<div class="search-indicator">
|
? html`<div class="search-indicator">
|
||||||
Showing results for
|
Showing results for
|
||||||
|
|||||||
@@ -1,6 +1,21 @@
|
|||||||
import type { library } from '@go/models';
|
import type { library } from '@go/models';
|
||||||
import { formatMilliseconds } from '@utils/time';
|
import { formatMilliseconds } from '@utils/time';
|
||||||
|
|
||||||
|
/** Compares two strings using locale-aware ordering. */
|
||||||
|
const compareStr = (
|
||||||
|
a: string,
|
||||||
|
b: string,
|
||||||
|
): number => a.localeCompare(b);
|
||||||
|
|
||||||
|
/** Compares two numbers, treating 0 as "empty" (sorted last). */
|
||||||
|
const compareNum = (a: number, b: number): number => {
|
||||||
|
if (!a && !b) return 0;
|
||||||
|
if (!a) return 1;
|
||||||
|
if (!b) return -1;
|
||||||
|
|
||||||
|
return a - b;
|
||||||
|
};
|
||||||
|
|
||||||
/** Definition for a single displayable column. */
|
/** Definition for a single displayable column. */
|
||||||
export interface ColumnDef {
|
export interface ColumnDef {
|
||||||
/** Unique identifier matching the backend ColumnID. */
|
/** Unique identifier matching the backend ColumnID. */
|
||||||
@@ -13,6 +28,15 @@ export interface ColumnDef {
|
|||||||
defaultWidth: string;
|
defaultWidth: string;
|
||||||
/** Text alignment. Defaults to left. */
|
/** Text alignment. Defaults to left. */
|
||||||
align?: 'left' | 'right';
|
align?: 'left' | 'right';
|
||||||
|
/**
|
||||||
|
* Comparison function for sorting two tracks by this column.
|
||||||
|
* Returns negative if a < b, positive if a > b, zero if equal.
|
||||||
|
* If omitted the column is not sortable.
|
||||||
|
*/
|
||||||
|
comparator?: (
|
||||||
|
a: library.Track,
|
||||||
|
b: library.Track,
|
||||||
|
) => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Registry of every available column keyed by ID. */
|
/** Registry of every available column keyed by ID. */
|
||||||
@@ -22,30 +46,43 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
|
|||||||
label: 'Track Name',
|
label: 'Track Name',
|
||||||
accessor: (t) => t.TrackName,
|
accessor: (t) => t.TrackName,
|
||||||
defaultWidth: '1fr',
|
defaultWidth: '1fr',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareStr(a.TrackName, b.TrackName),
|
||||||
},
|
},
|
||||||
artistName: {
|
artistName: {
|
||||||
id: 'artistName',
|
id: 'artistName',
|
||||||
label: 'Artist',
|
label: 'Artist',
|
||||||
accessor: (t) => t.ArtistName,
|
accessor: (t) => t.ArtistName,
|
||||||
defaultWidth: '1fr',
|
defaultWidth: '1fr',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareStr(a.ArtistName, b.ArtistName),
|
||||||
},
|
},
|
||||||
trackLength: {
|
trackLength: {
|
||||||
id: 'trackLength',
|
id: 'trackLength',
|
||||||
label: 'Duration',
|
label: 'Duration',
|
||||||
accessor: (t) => formatMilliseconds(t.TrackLength),
|
accessor: (t) => formatMilliseconds(t.TrackLength),
|
||||||
defaultWidth: '80px',
|
defaultWidth: '80px',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
Number(a.TrackLength) - Number(b.TrackLength),
|
||||||
},
|
},
|
||||||
album: {
|
album: {
|
||||||
id: 'album',
|
id: 'album',
|
||||||
label: 'Album',
|
label: 'Album',
|
||||||
accessor: (t) => t.Album,
|
accessor: (t) => t.Album,
|
||||||
defaultWidth: '1fr',
|
defaultWidth: '1fr',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareStr(a.Album, b.Album),
|
||||||
},
|
},
|
||||||
genre: {
|
genre: {
|
||||||
id: 'genre',
|
id: 'genre',
|
||||||
label: 'Genre',
|
label: 'Genre',
|
||||||
accessor: (t) => (t.Genre ?? []).join(', '),
|
accessor: (t) => (t.Genre ?? []).join(', '),
|
||||||
defaultWidth: '120px',
|
defaultWidth: '120px',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareStr(
|
||||||
|
(a.Genre ?? []).join(', '),
|
||||||
|
(b.Genre ?? []).join(', '),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
year: {
|
year: {
|
||||||
id: 'year',
|
id: 'year',
|
||||||
@@ -53,12 +90,16 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
|
|||||||
accessor: (t) =>
|
accessor: (t) =>
|
||||||
t.Year ? String(t.Year) : '',
|
t.Year ? String(t.Year) : '',
|
||||||
defaultWidth: '60px',
|
defaultWidth: '60px',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareNum(a.Year, b.Year),
|
||||||
},
|
},
|
||||||
composer: {
|
composer: {
|
||||||
id: 'composer',
|
id: 'composer',
|
||||||
label: 'Composer',
|
label: 'Composer',
|
||||||
accessor: (t) => t.Composer,
|
accessor: (t) => t.Composer,
|
||||||
defaultWidth: '1fr',
|
defaultWidth: '1fr',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareStr(a.Composer, b.Composer),
|
||||||
},
|
},
|
||||||
trackNumber: {
|
trackNumber: {
|
||||||
id: 'trackNumber',
|
id: 'trackNumber',
|
||||||
@@ -66,6 +107,8 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
|
|||||||
accessor: (t) =>
|
accessor: (t) =>
|
||||||
t.TrackNumber ? String(t.TrackNumber) : '',
|
t.TrackNumber ? String(t.TrackNumber) : '',
|
||||||
defaultWidth: '60px',
|
defaultWidth: '60px',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareNum(a.TrackNumber, b.TrackNumber),
|
||||||
},
|
},
|
||||||
discNumber: {
|
discNumber: {
|
||||||
id: 'discNumber',
|
id: 'discNumber',
|
||||||
@@ -73,18 +116,24 @@ export const COLUMN_DEFS: Record<string, ColumnDef> = {
|
|||||||
accessor: (t) =>
|
accessor: (t) =>
|
||||||
t.DiscNumber ? String(t.DiscNumber) : '',
|
t.DiscNumber ? String(t.DiscNumber) : '',
|
||||||
defaultWidth: '60px',
|
defaultWidth: '60px',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareNum(a.DiscNumber, b.DiscNumber),
|
||||||
},
|
},
|
||||||
filePath: {
|
filePath: {
|
||||||
id: 'filePath',
|
id: 'filePath',
|
||||||
label: 'File Path',
|
label: 'File Path',
|
||||||
accessor: (t) => t.FilePath,
|
accessor: (t) => t.FilePath,
|
||||||
defaultWidth: '1fr',
|
defaultWidth: '1fr',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareStr(a.FilePath, b.FilePath),
|
||||||
},
|
},
|
||||||
fileType: {
|
fileType: {
|
||||||
id: 'fileType',
|
id: 'fileType',
|
||||||
label: 'File Type',
|
label: 'File Type',
|
||||||
accessor: (t) => t.FileType,
|
accessor: (t) => t.FileType,
|
||||||
defaultWidth: '80px',
|
defaultWidth: '80px',
|
||||||
|
comparator: (a, b) =>
|
||||||
|
compareStr(a.FileType, b.FileType),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -36,9 +36,13 @@ 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';
|
||||||
|
|
||||||
const COLUMN_STORAGE_KEY = 'track-list-column-widths';
|
const COLUMN_STORAGE_KEY = 'track-list-column-widths';
|
||||||
|
const SORT_FIELD_KEY = 'track-list-sort-field';
|
||||||
|
const SORT_DIR_KEY = 'track-list-sort-direction';
|
||||||
const MIN_COLUMN_WIDTH = 50;
|
const MIN_COLUMN_WIDTH = 50;
|
||||||
const DEFAULT_FIXED_WIDTH = 80;
|
const DEFAULT_FIXED_WIDTH = 80;
|
||||||
|
|
||||||
|
type SortDirection = 'asc' | 'desc';
|
||||||
|
|
||||||
@customElement('track-list')
|
@customElement('track-list')
|
||||||
export class TrackList extends LitElement implements SelectionHost {
|
export class TrackList extends LitElement implements SelectionHost {
|
||||||
private player = new PlayerController(this);
|
private player = new PlayerController(this);
|
||||||
@@ -127,6 +131,21 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
@state()
|
@state()
|
||||||
private columnWidths: number[] = [];
|
private columnWidths: number[] = [];
|
||||||
|
|
||||||
|
/** Column ID to sort by, or null for default order. */
|
||||||
|
@state()
|
||||||
|
private sortField: string | null = null;
|
||||||
|
|
||||||
|
/** Current sort direction. */
|
||||||
|
@state()
|
||||||
|
private sortDirection: SortDirection = 'asc';
|
||||||
|
|
||||||
|
/** Whether the sort dropdown popup is open. */
|
||||||
|
@state()
|
||||||
|
private sortDropdownOpen = false;
|
||||||
|
|
||||||
|
@query('#sort-dropdown')
|
||||||
|
private sortDropdownPopup!: HTMLElement;
|
||||||
|
|
||||||
private resizingColumn: number | null = null;
|
private resizingColumn: number | null = null;
|
||||||
private resizeStartX = 0;
|
private resizeStartX = 0;
|
||||||
private resizeStartWidths: number[] = [];
|
private resizeStartWidths: number[] = [];
|
||||||
@@ -157,16 +176,37 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Sorted tracks
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
private get sortedTracks(): library.Track[] {
|
||||||
|
const tracks = this.filteredTracks;
|
||||||
|
|
||||||
|
if (!this.sortField) return tracks;
|
||||||
|
|
||||||
|
const col = COLUMN_DEFS[this.sortField];
|
||||||
|
|
||||||
|
if (!col?.comparator) return tracks;
|
||||||
|
|
||||||
|
const dir =
|
||||||
|
this.sortDirection === 'asc' ? 1 : -1;
|
||||||
|
|
||||||
|
return [...tracks].sort(
|
||||||
|
(a, b) => dir * col.comparator!(a, b),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// =================================================================
|
// =================================================================
|
||||||
// SelectionHost interface
|
// SelectionHost interface
|
||||||
// =================================================================
|
// =================================================================
|
||||||
|
|
||||||
getItemKey(index: number): string | undefined {
|
getItemKey(index: number): string | undefined {
|
||||||
return this.filteredTracks[index]?.FilePath;
|
return this.sortedTracks[index]?.FilePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
getItemCount(): number {
|
getItemCount(): number {
|
||||||
return this.filteredTracks.length;
|
return this.sortedTracks.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
onSelectionChanged(): void {
|
onSelectionChanged(): void {
|
||||||
@@ -564,19 +604,131 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
|
|
||||||
static override styles = css`
|
static override styles = css`
|
||||||
:host {
|
:host {
|
||||||
position: relative;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Sort toolbar ---- */
|
||||||
|
|
||||||
|
.sort-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
border-bottom: 1px solid
|
||||||
|
var(--yj-border-subtle, #333);
|
||||||
|
flex-shrink: 0;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-anchor {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-anchor:hover {
|
||||||
|
background: var(
|
||||||
|
--yj-hover-overlay,
|
||||||
|
rgba(255, 255, 255, 0.05)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-anchor .sort-label {
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dir-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--yj-text-secondary, #b3b3b3);
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dir-btn:hover {
|
||||||
|
background: var(
|
||||||
|
--yj-hover-overlay,
|
||||||
|
rgba(255, 255, 255, 0.05)
|
||||||
|
);
|
||||||
|
color: var(--yj-text-primary, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-panel {
|
||||||
|
background-color: var(
|
||||||
|
--yj-bg-elevated,
|
||||||
|
#343a40
|
||||||
|
);
|
||||||
|
border: 1px solid var(--yj-border, #444);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 4px 0;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
|
||||||
|
min-width: 140px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-panel wa-dropdown-item {
|
||||||
|
cursor: pointer;
|
||||||
|
--wa-color-text-normal: var(
|
||||||
|
--yj-text-primary,
|
||||||
|
#fff
|
||||||
|
);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-panel wa-dropdown-item:hover {
|
||||||
|
background-color: var(
|
||||||
|
--yj-hover-overlay,
|
||||||
|
rgba(255, 255, 255, 0.1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-dropdown-panel wa-dropdown-item.active-sort {
|
||||||
|
color: var(--yj-accent, #ffd43b);
|
||||||
|
--wa-color-text-normal: var(
|
||||||
|
--yj-accent,
|
||||||
|
#ffd43b
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#sort-dropdown {
|
||||||
|
z-index: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Header row ---- */
|
||||||
|
|
||||||
.header-row {
|
.header-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--grid-cols);
|
grid-template-columns: var(--grid-cols);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--yj-text-primary, #fff);
|
color: var(--yj-text-primary, #fff);
|
||||||
border-bottom: 1px solid var(--yj-text-tertiary, #666);
|
border-bottom: 1px solid
|
||||||
|
var(--yj-text-tertiary, #666);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -585,6 +737,20 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-cell:hover {
|
||||||
|
color: var(--yj-accent, #ffd43b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sort-arrow {
|
||||||
|
font-size: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--yj-accent, #ffd43b);
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-overlay {
|
.resize-overlay {
|
||||||
@@ -746,6 +912,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
|
|
||||||
override connectedCallback() {
|
override connectedCallback() {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
|
this.restoreSortPreferences();
|
||||||
this.loadTracks();
|
this.loadTracks();
|
||||||
this.cancelScanComplete = EventsOn(
|
this.cancelScanComplete = EventsOn(
|
||||||
Events.LibraryScanComplete,
|
Events.LibraryScanComplete,
|
||||||
@@ -754,6 +921,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
document.addEventListener('click', this.closeHandler);
|
document.addEventListener('click', this.closeHandler);
|
||||||
document.addEventListener('contextmenu', this.closeHandler);
|
document.addEventListener('contextmenu', this.closeHandler);
|
||||||
document.addEventListener('mousedown', this.mousedownCloseHandler);
|
document.addEventListener('mousedown', this.mousedownCloseHandler);
|
||||||
|
document.addEventListener('mousedown', this.sortDropdownCloseHandler);
|
||||||
document.addEventListener('click', this.clearSelectionHandler);
|
document.addEventListener('click', this.clearSelectionHandler);
|
||||||
document.addEventListener('mousemove', this.onColResizeMove);
|
document.addEventListener('mousemove', this.onColResizeMove);
|
||||||
document.addEventListener('mouseup', this.onColResizeEnd);
|
document.addEventListener('mouseup', this.onColResizeEnd);
|
||||||
@@ -778,6 +946,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
document.removeEventListener('click', this.closeHandler);
|
document.removeEventListener('click', this.closeHandler);
|
||||||
document.removeEventListener('contextmenu', this.closeHandler);
|
document.removeEventListener('contextmenu', this.closeHandler);
|
||||||
document.removeEventListener('mousedown', this.mousedownCloseHandler);
|
document.removeEventListener('mousedown', this.mousedownCloseHandler);
|
||||||
|
document.removeEventListener('mousedown', this.sortDropdownCloseHandler);
|
||||||
document.removeEventListener('click', this.clearSelectionHandler);
|
document.removeEventListener('click', this.clearSelectionHandler);
|
||||||
document.removeEventListener('mousemove', this.onColResizeMove);
|
document.removeEventListener('mousemove', this.onColResizeMove);
|
||||||
document.removeEventListener('mouseup', this.onColResizeEnd);
|
document.removeEventListener('mouseup', this.onColResizeEnd);
|
||||||
@@ -1062,6 +1231,161 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
this.closeContextMenu(true);
|
this.closeContextMenu(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// =================================================================
|
||||||
|
// Sort controls
|
||||||
|
// =================================================================
|
||||||
|
|
||||||
|
/** Restore sort preferences from localStorage. */
|
||||||
|
private restoreSortPreferences() {
|
||||||
|
try {
|
||||||
|
const field =
|
||||||
|
localStorage.getItem(SORT_FIELD_KEY);
|
||||||
|
const dir =
|
||||||
|
localStorage.getItem(SORT_DIR_KEY);
|
||||||
|
|
||||||
|
if (
|
||||||
|
field &&
|
||||||
|
COLUMN_DEFS[field]?.comparator
|
||||||
|
) {
|
||||||
|
this.sortField = field;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dir === 'asc' || dir === 'desc') {
|
||||||
|
this.sortDirection = dir;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Persist sort preferences to localStorage. */
|
||||||
|
private saveSortPreferences() {
|
||||||
|
try {
|
||||||
|
if (this.sortField) {
|
||||||
|
localStorage.setItem(
|
||||||
|
SORT_FIELD_KEY,
|
||||||
|
this.sortField,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(
|
||||||
|
SORT_FIELD_KEY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(
|
||||||
|
SORT_DIR_KEY,
|
||||||
|
this.sortDirection,
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Ignore storage errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle a click on a column header to toggle sorting.
|
||||||
|
* First click: sort ascending. Second: descending.
|
||||||
|
* Third: clear sort (back to default order).
|
||||||
|
*/
|
||||||
|
private onHeaderCellClick(colId: string) {
|
||||||
|
const col = COLUMN_DEFS[colId];
|
||||||
|
|
||||||
|
if (!col?.comparator) return;
|
||||||
|
|
||||||
|
if (this.sortField === colId) {
|
||||||
|
if (this.sortDirection === 'asc') {
|
||||||
|
this.sortDirection = 'desc';
|
||||||
|
} else {
|
||||||
|
this.sortField = null;
|
||||||
|
this.sortDirection = 'asc';
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.sortField = colId;
|
||||||
|
this.sortDirection = 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveSortPreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set sort from the dropdown and close it. */
|
||||||
|
private onSortDropdownSelect(
|
||||||
|
colId: string | null,
|
||||||
|
) {
|
||||||
|
if (colId === null) {
|
||||||
|
this.sortField = null;
|
||||||
|
this.sortDirection = 'asc';
|
||||||
|
} else {
|
||||||
|
this.sortField = colId;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.saveSortPreferences();
|
||||||
|
this.closeSortDropdown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Toggle sort direction via the toolbar button. */
|
||||||
|
private toggleSortDirection() {
|
||||||
|
this.sortDirection =
|
||||||
|
this.sortDirection === 'asc'
|
||||||
|
? 'desc'
|
||||||
|
: 'asc';
|
||||||
|
this.saveSortPreferences();
|
||||||
|
}
|
||||||
|
|
||||||
|
private toggleSortDropdown() {
|
||||||
|
if (this.sortDropdownOpen) {
|
||||||
|
this.closeSortDropdown();
|
||||||
|
} else {
|
||||||
|
this.openSortDropdown();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async openSortDropdown() {
|
||||||
|
this.sortDropdownOpen = true;
|
||||||
|
|
||||||
|
await this.updateComplete;
|
||||||
|
|
||||||
|
const popup = this.sortDropdownPopup;
|
||||||
|
const anchor = this.shadowRoot?.querySelector(
|
||||||
|
'.sort-anchor',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (popup && anchor) {
|
||||||
|
(popup as any).anchor = anchor;
|
||||||
|
(popup as any).active = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private closeSortDropdown() {
|
||||||
|
if (!this.sortDropdownOpen) return;
|
||||||
|
|
||||||
|
this.sortDropdownOpen = false;
|
||||||
|
|
||||||
|
const popup = this.sortDropdownPopup;
|
||||||
|
|
||||||
|
if (popup) {
|
||||||
|
(popup as any).active = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sortDropdownCloseHandler = (
|
||||||
|
e: MouseEvent,
|
||||||
|
) => {
|
||||||
|
if (!this.sortDropdownOpen) return;
|
||||||
|
|
||||||
|
const path = e.composedPath();
|
||||||
|
const popup = this.sortDropdownPopup;
|
||||||
|
|
||||||
|
if (popup && path.includes(popup)) return;
|
||||||
|
|
||||||
|
const anchor =
|
||||||
|
this.shadowRoot?.querySelector(
|
||||||
|
'.sort-anchor',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (anchor && path.includes(anchor)) return;
|
||||||
|
|
||||||
|
this.closeSortDropdown();
|
||||||
|
};
|
||||||
|
|
||||||
private isActiveTrack(track: library.Track): boolean {
|
private isActiveTrack(track: library.Track): boolean {
|
||||||
const currentTrack = this.player.currentTrack;
|
const currentTrack = this.player.currentTrack;
|
||||||
|
|
||||||
@@ -1114,19 +1438,134 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Render the sort toolbar above the header row. */
|
||||||
|
private renderSortToolbar() {
|
||||||
|
const activeCol = this.sortField
|
||||||
|
? COLUMN_DEFS[this.sortField]
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const label = activeCol
|
||||||
|
? activeCol.label
|
||||||
|
: 'Default';
|
||||||
|
|
||||||
|
const dirIcon =
|
||||||
|
this.sortDirection === 'asc'
|
||||||
|
? 'arrow-up-short-wide'
|
||||||
|
: 'arrow-down-wide-short';
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<div class="sort-toolbar">
|
||||||
|
<span>Sort:</span>
|
||||||
|
<button
|
||||||
|
class="sort-anchor"
|
||||||
|
@click=${() =>
|
||||||
|
this.toggleSortDropdown()}
|
||||||
|
>
|
||||||
|
<span class="sort-label">
|
||||||
|
${label}
|
||||||
|
</span>
|
||||||
|
<wa-icon
|
||||||
|
name="chevron-down"
|
||||||
|
></wa-icon>
|
||||||
|
</button>
|
||||||
|
${this.sortField
|
||||||
|
? html`
|
||||||
|
<button
|
||||||
|
class="sort-dir-btn"
|
||||||
|
title="${this.sortDirection === 'asc' ? 'Ascending' : 'Descending'}"
|
||||||
|
@click=${() =>
|
||||||
|
this.toggleSortDirection()}
|
||||||
|
>
|
||||||
|
<wa-icon
|
||||||
|
name=${dirIcon}
|
||||||
|
></wa-icon>
|
||||||
|
</button>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</div>
|
||||||
|
${this.renderSortDropdownPopup()}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render the sort dropdown popup. */
|
||||||
|
private renderSortDropdownPopup() {
|
||||||
|
const cols = this.activeColumns;
|
||||||
|
|
||||||
|
return html`
|
||||||
|
<wa-popup
|
||||||
|
id="sort-dropdown"
|
||||||
|
placement="bottom-start"
|
||||||
|
flip
|
||||||
|
shift
|
||||||
|
.active=${this.sortDropdownOpen}
|
||||||
|
>
|
||||||
|
${this.sortDropdownOpen
|
||||||
|
? html`
|
||||||
|
<div
|
||||||
|
class="sort-dropdown-panel"
|
||||||
|
>
|
||||||
|
<wa-dropdown-item
|
||||||
|
class=${!this.sortField ? 'active-sort' : ''}
|
||||||
|
@click=${() =>
|
||||||
|
this.onSortDropdownSelect(
|
||||||
|
null,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Default
|
||||||
|
</wa-dropdown-item>
|
||||||
|
${cols
|
||||||
|
.filter(
|
||||||
|
(c) =>
|
||||||
|
c.comparator,
|
||||||
|
)
|
||||||
|
.map(
|
||||||
|
(col) => html`
|
||||||
|
<wa-dropdown-item
|
||||||
|
class=${this.sortField === col.id ? 'active-sort' : ''}
|
||||||
|
@click=${() =>
|
||||||
|
this.onSortDropdownSelect(
|
||||||
|
col.id,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
${col.label}
|
||||||
|
</wa-dropdown-item>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: nothing}
|
||||||
|
</wa-popup>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const visibleTracks = this.filteredTracks;
|
const visibleTracks = this.sortedTracks;
|
||||||
const cols = this.activeColumns;
|
const cols = this.activeColumns;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
${this.tracks.length === 0
|
${this.tracks.length === 0
|
||||||
? html`<p>Loading tracks...</p>`
|
? html`<p>Loading tracks...</p>`
|
||||||
: html`
|
: html`
|
||||||
|
${this.renderSortToolbar()}
|
||||||
|
<div class="table-container">
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
${cols.map(
|
${cols.map(
|
||||||
(col) => html`
|
(col) => html`
|
||||||
<div class="header-cell ${col.align === 'right' ? 'cell-right' : ''}">
|
<div
|
||||||
${col.label}
|
class="header-cell ${col.align === 'right' ? 'cell-right' : ''}"
|
||||||
|
@click=${() =>
|
||||||
|
this.onHeaderCellClick(
|
||||||
|
col.id,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>${col.label}</span>
|
||||||
|
${this.sortField === col.id
|
||||||
|
? html`<span
|
||||||
|
class="sort-arrow"
|
||||||
|
>
|
||||||
|
${this.sortDirection === 'asc' ? '\u25B2' : '\u25BC'}
|
||||||
|
</span>`
|
||||||
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
@@ -1143,27 +1582,28 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
.layout=${this.flowLayout}
|
.layout=${this.flowLayout}
|
||||||
></lit-virtualizer>
|
></lit-virtualizer>
|
||||||
`}
|
`}
|
||||||
`}
|
|
||||||
|
|
||||||
${this.searchCtrl.term && visibleTracks.length > 0
|
${this.searchCtrl.term && visibleTracks.length > 0
|
||||||
? html`<div class="search-indicator">
|
? html`<div class="search-indicator">
|
||||||
Showing results for
|
Showing results for
|
||||||
“${this.searchCtrl.term}”
|
“${this.searchCtrl.term}”
|
||||||
</div>`
|
</div>`
|
||||||
: nothing}
|
: nothing}
|
||||||
|
|
||||||
<div class="resize-overlay">
|
<div class="resize-overlay">
|
||||||
${this.colBoundaryPositions.map(
|
${this.colBoundaryPositions.map(
|
||||||
(pos, i) => html`
|
(pos, i) => html`
|
||||||
<div
|
<div
|
||||||
class="col-resize-handle ${this.resizingColumn === i ? 'active' : ''}"
|
class="col-resize-handle ${this.resizingColumn === i ? 'active' : ''}"
|
||||||
style="left: ${pos}px"
|
style="left: ${pos}px"
|
||||||
@mousedown=${(e: MouseEvent) =>
|
@mousedown=${(e: MouseEvent) =>
|
||||||
this.onColResizeStart(e, i)}
|
this.onColResizeStart(e, i)}
|
||||||
></div>
|
></div>
|
||||||
`,
|
`,
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
|
||||||
<wa-popup
|
<wa-popup
|
||||||
id="context-menu"
|
id="context-menu"
|
||||||
|
|||||||
Reference in New Issue
Block a user