frontend changes

-smaller thumbnails
-GPU acceleration
-lazy loading/ rendering only visible elements
-cover grid shows album tracks on click
This commit is contained in:
2026-02-16 22:42:13 -05:00
parent 176b32fb0d
commit 481beca00a
13 changed files with 1353 additions and 312 deletions
@@ -0,0 +1,353 @@
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { library } from '@go/models';
import { PlayerController } from '@store/controllers/player-controller';
import { formatMilliseconds } from '@utils/time';
/** Detail payload for the track-click custom event. */
export interface TrackClickDetail {
track: library.Track;
index: number;
ctrlKey: boolean;
shiftKey: boolean;
metaKey: boolean;
}
/** Detail payload for the track-dblclick custom event. */
export interface TrackDblClickDetail {
track: library.Track;
index: number;
}
/** Detail payload for the track-contextmenu custom event. */
export interface TrackContextMenuDetail {
track: library.Track;
clientX: number;
clientY: number;
}
/**
* Self-contained dropdown that renders an album's track list.
*
* Owns a PlayerController so that active-track highlighting
* only re-renders this component, not the parent grid.
*/
@customElement('album-dropdown')
export class AlbumDropdown extends LitElement {
private player = new PlayerController(this);
@property({ attribute: false })
tracks: library.Track[] = [];
@property({ type: Boolean, attribute: 'loading-tracks' })
loadingTracks = false;
@property({ attribute: false })
selectedTracks: Set<string> = new Set();
static override styles = css`
:host {
display: block;
grid-column: 1 / -1;
}
.album-dropdown {
background-color: #1a1a2e;
border-top: 2px solid #ffd43b;
border-bottom: 2px solid #ffd43b;
border-radius: 4px;
padding: 12px 16px;
box-sizing: border-box;
min-height: 230px;
}
.dropdown-loading {
display: flex;
align-items: center;
justify-content: center;
height: 206px;
color: #b3b3b3;
font-size: 13px;
}
.dropdown-tracks {
column-count: 3;
column-fill: auto;
column-gap: 24px;
height: 206px;
}
.dropdown-tracks.overflow {
height: auto;
min-height: 206px;
}
.track-row {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 8px;
border-radius: 4px;
cursor: default;
user-select: none;
font-size: 12px;
break-inside: avoid;
}
.track-row:hover {
background-color: rgba(
255,
255,
255,
0.05
);
}
.track-row.selected {
background-color: rgba(
100,
160,
255,
0.15
);
}
.track-row.active {
background-color: rgba(
255,
212,
59,
0.1
);
color: #ffd43b;
}
.track-row.selected.active {
background-color: rgba(
100,
160,
255,
0.15
);
}
.track-number {
color: #888;
min-width: 22px;
text-align: right;
flex-shrink: 0;
}
.track-title {
color: #fff;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
}
.track-duration {
color: #888;
flex-shrink: 0;
margin-left: auto;
}
`;
/* ================================================================
* Rendering helpers
* ================================================================ */
private isActiveTrack(
track: library.Track,
): boolean {
const currentTrack = this.player.currentTrack;
if (!currentTrack) return false;
return currentTrack.filePath === track.FilePath;
}
/**
* Determine whether the multi-column track layout
* overflows 3 columns at the base height.
*
* Heuristic: each track row is ~28px tall, the base
* dropdown content height is 206px, each column fits
* ~7 tracks, and with 3 columns that is ~21 tracks.
*/
private tracksOverflow(): boolean {
const rowHeight = 28;
const containerHeight = 206;
const perColumn = Math.floor(
containerHeight / rowHeight,
);
const maxTracks = perColumn * 3;
return this.tracks.length > maxTracks;
}
/* ================================================================
* Event dispatching
* ================================================================ */
private onTrackClick(
e: MouseEvent,
track: library.Track,
index: number,
) {
e.stopPropagation();
this.dispatchEvent(
new CustomEvent<TrackClickDetail>(
'track-click',
{
bubbles: true,
composed: true,
detail: {
track,
index,
ctrlKey: e.ctrlKey,
shiftKey: e.shiftKey,
metaKey: e.metaKey,
},
},
),
);
}
private onTrackDblClick(
e: MouseEvent,
track: library.Track,
index: number,
) {
e.stopPropagation();
this.dispatchEvent(
new CustomEvent<TrackDblClickDetail>(
'track-dblclick',
{
bubbles: true,
composed: true,
detail: { track, index },
},
),
);
}
private onTrackContextMenu(
e: MouseEvent,
track: library.Track,
) {
e.preventDefault();
e.stopPropagation();
this.dispatchEvent(
new CustomEvent<TrackContextMenuDetail>(
'track-contextmenu',
{
bubbles: true,
composed: true,
detail: {
track,
clientX: e.clientX,
clientY: e.clientY,
},
},
),
);
}
/* ================================================================
* Render
* ================================================================ */
private renderTrackRow(
track: library.Track,
index: number,
) {
const active = this.isActiveTrack(track);
const selected = this.selectedTracks.has(
track.FilePath,
);
const classes = [
'track-row',
active ? 'active' : '',
selected ? 'selected' : '',
]
.filter(Boolean)
.join(' ');
const displayNumber =
track.TrackNumber > 0
? track.TrackNumber
: index + 1;
return html`
<div
class=${classes}
@click=${(e: MouseEvent) =>
this.onTrackClick(e, track, index)}
@dblclick=${(e: MouseEvent) =>
this.onTrackDblClick(
e,
track,
index,
)}
@contextmenu=${(e: MouseEvent) =>
this.onTrackContextMenu(e, track)}
>
<span class="track-number">
${displayNumber}
</span>
<span
class="track-title"
title="${track.TrackName}"
>
${track.TrackName}
</span>
<span class="track-duration">
${formatMilliseconds(
track.TrackLength,
)}
</span>
</div>
`;
}
override render() {
if (this.loadingTracks) {
return html`
<div class="album-dropdown">
<div class="dropdown-loading">
Loading tracks...
</div>
</div>
`;
}
const overflow = this.tracksOverflow();
const tracksClass = overflow
? 'dropdown-tracks overflow'
: 'dropdown-tracks';
return html`
<div class="album-dropdown">
<div class=${tracksClass}>
${this.tracks.map(
(track, i) =>
this.renderTrackRow(track, i),
)}
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'album-dropdown': AlbumDropdown;
}
}
File diff suppressed because it is too large Load Diff
@@ -50,6 +50,7 @@ export class TrackList extends LitElement {
private virtualizer!: LitVirtualizer;
private lastSelectedIndex: number | null = null;
private lastActiveTrackPath: string | null = null;
private closeHandler = () => this.closeContextMenu();
@@ -289,7 +290,7 @@ export class TrackList extends LitElement {
background-color: rgba(255, 212, 59, 0.1);
}
.track-row.active .track-name {
.track-row.active {
color: #ffd43b;
}
@@ -392,6 +393,14 @@ export class TrackList extends LitElement {
if (changed.has('columnWidths')) {
this.virtualizer?.requestUpdate();
}
const currentPath =
this.player.currentTrack?.filePath ?? null;
if (currentPath !== this.lastActiveTrackPath) {
this.lastActiveTrackPath = currentPath;
this.virtualizer?.requestUpdate();
}
}
private previousHostWidth = 0;
+36 -2
View File
@@ -1,8 +1,42 @@
import 'htmx.org/dist/htmx.js'
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
import { Scan } from '@go/library/Library';
declare global {
interface Window { DirectoryPicker: any; }
interface Window {
DirectoryPicker: typeof DirectoryPicker;
Scan: typeof Scan;
selectLibraryDirectory: (pElement: HTMLInputElement) => void;
scanLibrary: (button: HTMLButtonElement) => void;
}
}
window.DirectoryPicker = DirectoryPicker
window.DirectoryPicker = DirectoryPicker;
window.Scan = Scan;
window.selectLibraryDirectory = (pElement: HTMLInputElement) => {
window.DirectoryPicker()
.then((result) => {
if (result.length !== 0) {
pElement.value = result;
}
})
.catch((err: unknown) => {
console.error('error with directory picker: ' + err);
});
};
window.scanLibrary = (button: HTMLButtonElement) => {
button.disabled = true;
button.textContent = 'Scanning...';
window.Scan()
.then(() => {
button.textContent = 'Scan Library';
button.disabled = false;
})
.catch((err: unknown) => {
console.error('error scanning library: ' + err);
button.textContent = 'Scan Library';
button.disabled = false;
});
};