frontend changes
-smaller thumbnails -GPU acceleration -lazy loading/ rendering only visible elements -cover grid shows album tracks on click
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@ export default defineConfig({
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: "index.html",
|
||||
config: "src/pages/config/config.html",
|
||||
config: "src/pages/config/index.html",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user