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
-32
View File
@@ -1,38 +1,6 @@
package library
templ (d Directory) ToFormElement() {
<script>
function selectLibraryDirectory(pElement) {
try {
window.DirectoryPicker()
.then((result) => {
if (result.length != 0) {
pElement.value = result;
}
})
.catch((err) => {
console.error("error with directory picker: " + err);
});
}
catch (err) {
console.error(err);
}
}
function scanLibrary(button) {
button.disabled = true;
button.textContent = "Scanning...";
window.Scan()
.then(() => {
button.textContent = "Scan Library";
button.disabled = false;
})
.catch((err) => {
console.error("error scanning library: " + err);
button.textContent = "Scan Library";
button.disabled = false;
});
}
</script>
<button type="button" onclick="selectLibraryDirectory(this.nextElementSibling)">Select</button>
<input type="text" name="library.directory" value={ d } readonly/>
<button type="button" onclick="scanLibrary(this)">Scan Library</button>
+2 -2
View File
@@ -29,14 +29,14 @@ func (d Directory) ToFormElement() templ.Component {
templ_7745c5c3_Var1 = templ.NopComponent
}
ctx = templ.ClearChildren(ctx)
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<script>\n function selectLibraryDirectory(pElement) {\n try {\n window.DirectoryPicker()\n .then((result) => {\n if (result.length != 0) {\n pElement.value = result;\n }\n })\n .catch((err) => {\n console.error(\"error with directory picker: \" + err);\n });\n }\n catch (err) {\n console.error(err);\n }\n }\n function scanLibrary(button) {\n button.disabled = true;\n button.textContent = \"Scanning...\";\n window.Scan()\n .then(() => {\n button.textContent = \"Scan Library\";\n button.disabled = false;\n })\n .catch((err) => {\n console.error(\"error scanning library: \" + err);\n button.textContent = \"Scan Library\";\n button.disabled = false;\n });\n }\n </script><button type=\"button\" onclick=\"selectLibraryDirectory(this.nextElementSibling)\">Select</button> <input type=\"text\" name=\"library.directory\" value=\"")
templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 1, "<button type=\"button\" onclick=\"selectLibraryDirectory(this.nextElementSibling)\">Select</button> <input type=\"text\" name=\"library.directory\" value=\"")
if templ_7745c5c3_Err != nil {
return templ_7745c5c3_Err
}
var templ_7745c5c3_Var2 string
templ_7745c5c3_Var2, templ_7745c5c3_Err = templ.JoinStringErrs(d)
if templ_7745c5c3_Err != nil {
return templ.Error{Err: templ_7745c5c3_Err, FileName: `library/config.templ`, Line: 37, Col: 54}
return templ.Error{Err: templ_7745c5c3_Err, FileName: `library/config.templ`, Line: 5, Col: 54}
}
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var2))
if templ_7745c5c3_Err != nil {
+1 -1
View File
@@ -20,7 +20,7 @@ import (
const (
// thumbnailMaxSize is the maximum width/height for generated thumbnails.
thumbnailMaxSize = 256
thumbnailMaxSize = 200
// thumbnailQuality is the JPEG encoding quality for thumbnails.
thumbnailQuality = 80
// thumbnailSuffix is appended to the content hash for thumbnail filenames.
+10 -3
View File
@@ -19,6 +19,8 @@ type Track struct {
ArtistName string
TrackLength string
FilePath string
TrackNumber int64
DiscNumber int64
}
// Album represents an album for the cover grid display.
@@ -82,10 +84,15 @@ func (l *Library) GetAlbumTracks(albumID int64) ([]Track, error) {
for _, row := range rows {
tracks = append(tracks, Track{
TrackName: row.Title,
ArtistName: row.ArtistName,
TrackLength: strconv.FormatInt(row.LengthMilliseconds, 10),
TrackName: row.Title,
ArtistName: row.ArtistName,
TrackLength: strconv.FormatInt(
row.LengthMilliseconds,
10,
),
FilePath: row.FilePath,
TrackNumber: row.TrackNumber.Int64,
DiscNumber: row.DiscNumber.Int64,
})
}
+1 -1
View File
@@ -15,7 +15,7 @@
<h1 class="title">YellowJacket</h1>
<h3 class="subtitle">Music how it was meant to bee.</h3>
</hgroup>
<a href="/src/pages/config/config.html">
<a href="/src/pages/config/">
<img src="/src/assets/images/icons/ui/settings.svg" />
</a>
</header>
@@ -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;
});
};
+1 -1
View File
@@ -17,7 +17,7 @@ export default defineConfig({
rollupOptions: {
input: {
main: "index.html",
config: "src/pages/config/config.html",
config: "src/pages/config/index.html",
},
},
},
+4
View File
@@ -27,6 +27,8 @@ export namespace library {
ArtistName: string;
TrackLength: string;
FilePath: string;
TrackNumber: number;
DiscNumber: number;
static createFrom(source: any = {}) {
return new Track(source);
@@ -38,6 +40,8 @@ export namespace library {
this.ArtistName = source["ArtistName"];
this.TrackLength = source["TrackLength"];
this.FilePath = source["FilePath"];
this.TrackNumber = source["TrackNumber"];
this.DiscNumber = source["DiscNumber"];
}
}
+4
View File
@@ -9,6 +9,7 @@ import (
"github.com/golang-cz/devslog"
"github.com/wailsapp/wails/v2"
"github.com/wailsapp/wails/v2/pkg/options"
"github.com/wailsapp/wails/v2/pkg/options/linux"
"yellowjacket/backend"
"yellowjacket/backend/assets"
@@ -78,6 +79,9 @@ func main() {
MinHeight: 384,
MaxWidth: 0,
MaxHeight: 0,
Linux: &linux.Options{
WebviewGpuPolicy: linux.WebviewGpuPolicyAlways,
},
})
if err != nil {
sLogger.Error("application error", "err", err.Error())