user can configure visible columns in track-list

This commit is contained in:
2026-02-22 11:24:19 -05:00
parent 9d84a11543
commit b5e70a8ce6
15 changed files with 1137 additions and 82 deletions
+79
View File
@@ -16,6 +16,7 @@ import (
"yellowjacket/backend/library"
"yellowjacket/backend/system"
"yellowjacket/backend/theme"
"yellowjacket/backend/tracklist"
)
// Config represents the application configuration.
@@ -26,6 +27,7 @@ type Config struct {
Library *library.Config `toml:"Library"`
Theme *theme.Config `toml:"Theme"`
Window *WindowConfig `toml:"Window"`
TrackList *tracklist.Config `toml:"TrackList"`
}
// NewConfig creates a new config by loading it from disk.
@@ -70,6 +72,12 @@ func (c *Config) Validate() error {
}
}
if c.TrackList != nil {
if err := c.TrackList.Validate(); err != nil {
configErrs = errors.Join(configErrs, err)
}
}
if configErrs != nil {
return fmt.Errorf(
"one or more config parts are invalid: %w",
@@ -160,6 +168,12 @@ func (c *Config) applyDefaults() {
}
c.Theme.ApplyDefaults()
if c.TrackList == nil {
c.TrackList = &tracklist.Config{}
}
c.TrackList.ApplyDefaults()
}
// SetContext sets the Wails runtime context for event emission.
@@ -357,3 +371,68 @@ func (c *Config) emitThemeChanged() {
},
)
}
// GetTrackListColumns returns the configured track-list columns.
func (c *Config) GetTrackListColumns() []tracklist.Column {
if c.TrackList == nil {
return tracklist.DefaultColumns
}
return c.TrackList.Columns
}
// SetTrackListColumns validates and saves a new column layout.
func (c *Config) SetTrackListColumns(
columns []tracklist.Column,
) error {
if c.TrackList == nil {
c.TrackList = &tracklist.Config{}
}
c.TrackList.Columns = columns
if err := c.TrackList.Validate(); err != nil {
return fmt.Errorf(
"invalid track-list columns: %w", err,
)
}
if err := c.Save(); err != nil {
return fmt.Errorf(
"could not save config: %w", err,
)
}
c.emitTrackListChanged()
c.logger.Info(
"track-list columns updated",
"count", len(columns),
)
return nil
}
// emitTrackListChanged sends the TrackListConfigChanged event
// to the frontend.
func (c *Config) emitTrackListChanged() {
if c.ctx == nil || c.TrackList == nil {
return
}
cols := make([]map[string]any, 0, len(c.TrackList.Columns))
for _, col := range c.TrackList.Columns {
cols = append(cols, map[string]any{
"id": string(col.ID),
})
}
runtime.EventsEmit(
c.ctx,
events.TrackListConfigChanged,
map[string]any{
"columns": cols,
},
)
}
@@ -71,6 +71,26 @@ LEFT JOIN cover_art ca ON rg.cover_art_id = ca.id
WHERE af.file_path = ?
LIMIT 1;
-- name: GetAllTracksWithFullMetadata :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
COALESCE(r.genre, '') AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id;
-- name: DeleteAllAudioFiles :exec
DELETE FROM audio_files;
@@ -190,6 +190,76 @@ func (q *Queries) GetAllAudioFilesWithArtist(ctx context.Context) ([]GetAllAudio
return items, nil
}
const getAllTracksWithFullMetadata = `-- name: GetAllTracksWithFullMetadata :many
SELECT
af.file_path,
af.length_milliseconds,
COALESCE(r.name, '') AS title,
COALESCE(ac.text, '') AS artist_name,
r.track_number,
r.disc_number,
COALESCE(rg.name, '') AS album,
COALESCE(r.genre, '') AS genre,
COALESCE(r.year, 0) AS year,
COALESCE(r.composer, '') AS composer,
COALESCE(ft.extension, '') AS file_type
FROM audio_files af
JOIN recordings r ON af.recording_id = r.id
JOIN artist_credit ac ON r.artist_credit_id = ac.id
LEFT JOIN release_group_recordings rgr ON r.id = rgr.recording_id
LEFT JOIN release_groups rg ON rgr.release_group_id = rg.id
LEFT JOIN file_types ft ON af.file_type_id = ft.id
`
type GetAllTracksWithFullMetadataRow struct {
FilePath string
LengthMilliseconds int64
Title string
ArtistName string
TrackNumber sql.NullInt64
DiscNumber sql.NullInt64
Album string
Genre string
Year int64
Composer string
FileType string
}
func (q *Queries) GetAllTracksWithFullMetadata(ctx context.Context) ([]GetAllTracksWithFullMetadataRow, error) {
rows, err := q.db.QueryContext(ctx, getAllTracksWithFullMetadata)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetAllTracksWithFullMetadataRow
for rows.Next() {
var i GetAllTracksWithFullMetadataRow
if err := rows.Scan(
&i.FilePath,
&i.LengthMilliseconds,
&i.Title,
&i.ArtistName,
&i.TrackNumber,
&i.DiscNumber,
&i.Album,
&i.Genre,
&i.Year,
&i.Composer,
&i.FileType,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getAudioFile = `-- name: GetAudioFile :one
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
WHERE id = ? LIMIT 1
+1
View File
@@ -56,6 +56,7 @@ const (
const (
LibraryConfigChanged = "LibraryConfigChanged"
ThemeConfigChanged = "ThemeConfigChanged"
TrackListConfigChanged = "TrackListConfigChanged"
)
// Playlist events.
+34 -14
View File
@@ -21,6 +21,11 @@ type Track struct {
FilePath string
TrackNumber int64
DiscNumber int64
Album string
Genre string
Year int64
Composer string
FileType string
}
// Album represents an album for the cover grid display.
@@ -37,36 +42,51 @@ type Album struct {
// GetAllTracks returns an array of track structs of every file in the library.
func (l *Library) GetAllTracks() ([]Track, error) {
audioFiles, err := l.db.Queries.GetAllAudioFilesWithArtist(l.ctx)
rows, err := l.db.Queries.GetAllTracksWithFullMetadata(
l.ctx,
)
if err != nil {
l.logger.Error("could not retrieve audio files", "error", err)
l.logger.Error(
"could not retrieve audio files",
"error", err,
)
return nil, err
}
l.logger.Info("audio file list", "count", len(audioFiles))
l.logger.Info("audio file list", "count", len(rows))
if len(audioFiles) == 0 {
if len(rows) == 0 {
l.logger.Error("no tracks in library")
return nil, errNoTracksInLibrary
}
var formattedTracks []Track
tracks := make([]Track, 0, len(rows))
for _, file := range audioFiles {
for _, row := range rows {
track := Track{
TrackName: file.Title,
ArtistName: file.ArtistName,
TrackLength: strconv.FormatInt(file.LengthMilliseconds, 10),
FilePath: file.FilePath,
}
formattedTracks = append(formattedTracks, track)
TrackName: row.Title,
ArtistName: row.ArtistName,
TrackLength: strconv.FormatInt(
row.LengthMilliseconds, 10,
),
FilePath: row.FilePath,
TrackNumber: row.TrackNumber.Int64,
DiscNumber: row.DiscNumber.Int64,
Album: row.Album,
Genre: row.Genre,
Year: row.Year,
Composer: row.Composer,
FileType: row.FileType,
}
l.logger.Info("formatted tracks", "count", len(formattedTracks))
tracks = append(tracks, track)
}
return formattedTracks, nil
l.logger.Info("formatted tracks", "count", len(tracks))
return tracks, nil
}
// GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number.
+104
View File
@@ -0,0 +1,104 @@
// Package tracklist manages track-list display configuration.
package tracklist
import (
"errors"
"fmt"
"slices"
)
var (
errUnknownColumnID = errors.New("unknown track-list column ID")
errDuplicateColumn = errors.New("duplicate column ID")
)
// ColumnID identifies a displayable column in the track list.
type ColumnID string
// Valid column identifiers.
const (
ColTrackName ColumnID = "trackName"
ColArtistName ColumnID = "artistName"
ColTrackLength ColumnID = "trackLength"
ColAlbum ColumnID = "album"
ColGenre ColumnID = "genre"
ColYear ColumnID = "year"
ColComposer ColumnID = "composer"
ColTrackNumber ColumnID = "trackNumber"
ColDiscNumber ColumnID = "discNumber"
ColFilePath ColumnID = "filePath"
ColFileType ColumnID = "fileType"
)
// AllColumnIDs lists every recognised column in default display
// order.
var AllColumnIDs = []ColumnID{
ColTrackName,
ColArtistName,
ColTrackLength,
ColAlbum,
ColGenre,
ColYear,
ColComposer,
ColTrackNumber,
ColDiscNumber,
ColFilePath,
ColFileType,
}
// DefaultColumns is the initial column configuration matching the
// original hardcoded layout.
var DefaultColumns = []Column{
{ID: ColTrackName},
{ID: ColArtistName},
{ID: ColTrackLength},
}
// Column represents a visible column in the track list.
type Column struct {
ID ColumnID `json:"id" toml:"ID"`
}
// Config holds track-list display preferences.
type Config struct {
Columns []Column `json:"columns" toml:"Columns"`
}
// ApplyDefaults fills zero-value fields with sensible defaults.
func (c *Config) ApplyDefaults() {
if len(c.Columns) == 0 {
c.Columns = make([]Column, len(DefaultColumns))
copy(c.Columns, DefaultColumns)
}
}
// Validate checks that every column ID is recognised and that
// there are no duplicates.
func (c *Config) Validate() error {
c.ApplyDefaults()
seen := make(map[ColumnID]bool, len(c.Columns))
for _, col := range c.Columns {
if !isValidColumnID(col.ID) {
return fmt.Errorf(
"%w: %q", errUnknownColumnID, col.ID,
)
}
if seen[col.ID] {
return fmt.Errorf(
"%w: %q", errDuplicateColumn, col.ID,
)
}
seen[col.ID] = true
}
return nil
}
// isValidColumnID returns true when id matches a known column.
func isValidColumnID(id ColumnID) bool {
return slices.Contains(AllColumnIDs, id)
}
@@ -10,9 +10,14 @@ import {
} from '@go/config/Config';
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
import { ThemeController } from '@store/controllers/theme-controller';
import { TrackListController } from '@store/controllers/tracklist-controller';
import { Events } from '../../events';
import type { ConfigFieldChangeEvent } from './config-field';
import type { BackgroundShade } from '@store/theme-store';
import {
COLUMN_DEFS,
ALL_COLUMN_IDS,
} from '@components/track-list/columns';
import './config-field';
import './config-section';
@@ -176,6 +181,9 @@ export class ConfigPage extends LitElement {
// --- Theme controller for reading/writing theme state ---
private themeCtrl = new ThemeController(this);
// --- Track-list column config controller ---
private trackListCtrl = new TrackListController(this);
// --- Library state ---
@state() private libraryDirectory = '';
@state() private selectedDirectory = '';
@@ -464,6 +472,76 @@ export class ConfigPage extends LitElement {
flex-direction: column;
align-items: center;
}
/* Track list column configurator */
.column-list {
list-style: none;
padding: 0;
margin: 0;
}
.column-item {
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.5em 0.75em;
border-bottom: 1px solid
var(--yj-border-subtle, #333);
font-size: 0.85em;
}
.column-item:last-child {
border-bottom: none;
}
.column-item.enabled {
color: var(--yj-text-primary, #fff);
}
.column-item.disabled {
color: var(--yj-text-tertiary, #888);
}
.column-toggle {
cursor: pointer;
accent-color: var(
--yj-accent,
#ffd43b
);
}
.column-label {
flex: 1;
}
.column-arrows {
display: flex;
gap: 0.15em;
margin-left: auto;
}
.column-arrow-btn {
background: none;
border: 1px solid transparent;
border-radius: 3px;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
font-size: 0.65em;
line-height: 1;
padding: 0.2em 0.35em;
transition:
color 0.15s,
border-color 0.15s;
}
.column-arrow-btn:hover {
color: var(--yj-text-primary, #fff);
border-color: var(
--yj-border-subtle,
#333
);
}
`;
// ===================================================================
@@ -679,6 +757,93 @@ export class ConfigPage extends LitElement {
});
};
// ===================================================================
// TRACK LIST COLUMN HANDLERS
// ===================================================================
private handleColumnToggle = (
columnId: string,
): void => {
const current = [
...this.trackListCtrl.columnIds,
];
const idx = current.indexOf(columnId);
if (idx >= 0) {
// Don't allow removing the last column.
if (current.length <= 1) return;
current.splice(idx, 1);
} else {
current.push(columnId);
}
this.trackListCtrl
.setColumns(current)
.catch((err: unknown) => {
console.error(
'Failed to update columns:',
err,
);
});
};
/**
* Builds the merged column order: enabled IDs first
* (in their configured display order), then disabled
* IDs (in default static order).
*/
private getMergedColumnOrder(): string[] {
const enabledIds = [
...this.trackListCtrl.columnIds,
];
const disabledIds = ALL_COLUMN_IDS.filter(
(id) => !enabledIds.includes(id),
);
return [...enabledIds, ...disabledIds];
}
private handleColumnMove = (
columnId: string,
direction: 'up' | 'down',
): void => {
const order = this.getMergedColumnOrder();
const idx = order.indexOf(columnId);
if (idx < 0) return;
const targetIdx =
direction === 'up' ? idx - 1 : idx + 1;
if (targetIdx < 0 || targetIdx >= order.length)
return;
// Swap adjacent items in the full list.
const tmp = order[targetIdx]!;
order[targetIdx] = order[idx]!;
order[idx] = tmp;
// Keep only the enabled columns, preserving
// the new order.
const enabledSet = new Set(
this.trackListCtrl.columnIds,
);
const newEnabled = order.filter((id) =>
enabledSet.has(id),
);
this.trackListCtrl
.setColumns(newEnabled)
.catch((err: unknown) => {
console.error(
'Failed to reorder columns:',
err,
);
});
};
// ===================================================================
// COMPUTED
// ===================================================================
@@ -711,6 +876,7 @@ export class ConfigPage extends LitElement {
<h2>Settings</h2>
${this.renderThemeSection()}
${this.renderTrackListSection()}
${this.renderLibrarySection()}
`;
}
@@ -802,6 +968,91 @@ export class ConfigPage extends LitElement {
);
}
// --- Track list section ---
private renderTrackListSection() {
const enabledIds = this.trackListCtrl.columnIds;
const order = this.getMergedColumnOrder();
return html`
<config-section
heading="Track List Columns"
description="Choose which columns are visible and set their display order."
>
<ul class="column-list">
${order.map((id, idx) => {
const checked =
enabledIds.includes(id);
const onlyOne =
checked &&
enabledIds.length <= 1;
const isFirst = idx === 0;
const isLast =
idx === order.length - 1;
return html`
<li
class="column-item ${checked ? 'enabled' : 'disabled'}"
>
<input
type="checkbox"
class="column-toggle"
.checked=${checked}
?disabled=${onlyOne}
@change=${() =>
this.handleColumnToggle(
id,
)}
/>
<span
class="column-label"
>
${COLUMN_DEFS[id]
?.label ??
id}
</span>
<span
class="column-arrows"
>
${isFirst
? nothing
: html`
<button
class="column-arrow-btn"
title="Move up"
@click=${() =>
this.handleColumnMove(
id,
'up',
)}
>
</button>
`}
${isLast
? nothing
: html`
<button
class="column-arrow-btn"
title="Move down"
@click=${() =>
this.handleColumnMove(
id,
'down',
)}
>
</button>
`}
</span>
</li>
`;
})}
</ul>
</config-section>
`;
}
// --- Library section ---
private renderLibrarySection() {
@@ -0,0 +1,102 @@
import type { library } from '@go/models';
import { formatMilliseconds } from '@utils/time';
/** Definition for a single displayable column. */
export interface ColumnDef {
/** Unique identifier matching the backend ColumnID. */
id: string;
/** Human-readable header label. */
label: string;
/** Extracts the display value from a track. */
accessor: (track: library.Track) => string;
/** Default CSS width (used when no saved width exists). */
defaultWidth: string;
/** Text alignment. Defaults to left. */
align?: 'left' | 'right';
}
/** Registry of every available column keyed by ID. */
export const COLUMN_DEFS: Record<string, ColumnDef> = {
trackName: {
id: 'trackName',
label: 'Track Name',
accessor: (t) => t.TrackName,
defaultWidth: '1fr',
},
artistName: {
id: 'artistName',
label: 'Artist',
accessor: (t) => t.ArtistName,
defaultWidth: '1fr',
},
trackLength: {
id: 'trackLength',
label: 'Duration',
accessor: (t) => formatMilliseconds(t.TrackLength),
defaultWidth: '80px',
},
album: {
id: 'album',
label: 'Album',
accessor: (t) => t.Album,
defaultWidth: '1fr',
},
genre: {
id: 'genre',
label: 'Genre',
accessor: (t) => t.Genre,
defaultWidth: '120px',
},
year: {
id: 'year',
label: 'Year',
accessor: (t) =>
t.Year ? String(t.Year) : '',
defaultWidth: '60px',
},
composer: {
id: 'composer',
label: 'Composer',
accessor: (t) => t.Composer,
defaultWidth: '1fr',
},
trackNumber: {
id: 'trackNumber',
label: 'Track #',
accessor: (t) =>
t.TrackNumber ? String(t.TrackNumber) : '',
defaultWidth: '60px',
},
discNumber: {
id: 'discNumber',
label: 'Disc #',
accessor: (t) =>
t.DiscNumber ? String(t.DiscNumber) : '',
defaultWidth: '60px',
},
filePath: {
id: 'filePath',
label: 'File Path',
accessor: (t) => t.FilePath,
defaultWidth: '1fr',
},
fileType: {
id: 'fileType',
label: 'File Type',
accessor: (t) => t.FileType,
defaultWidth: '80px',
},
};
/**
* All column IDs in default display order.
* Used by the settings UI to list available columns.
*/
export const ALL_COLUMN_IDS: string[] = Object.keys(COLUMN_DEFS);
/** Default column IDs matching the original hardcoded layout. */
export const DEFAULT_COLUMN_IDS: string[] = [
'trackName',
'artistName',
'trackLength',
];
+258 -59
View File
@@ -2,14 +2,19 @@ import { library } from '@go/models';
import { LitElement, html, css, nothing } from 'lit';
import { customElement, state, query } from 'lit/decorators.js';
import { EventsOn } from '@runtime/runtime';
import { formatMilliseconds } from '@utils/time';
import { SelectionController } from '@utils/selection-controller';
import type { SelectionHost } from '@utils/selection-controller';
import { PlayerController } from '@store/controllers/player-controller';
import { SearchController } from '@store/controllers/search-controller';
import { TrackListController } from '@store/controllers/tracklist-controller';
import { queueStore } from '@store/queue-store';
import { LibraryController } from '@store/controllers/library-controller';
import { Events } from '../../events';
import {
COLUMN_DEFS,
DEFAULT_COLUMN_IDS,
} from './columns';
import type { ColumnDef } from './columns';
import {
setDragPayload,
emitDragActive,
@@ -32,18 +37,42 @@ import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker
const COLUMN_STORAGE_KEY = 'track-list-column-widths';
const MIN_COLUMN_WIDTH = 50;
const DEFAULT_DURATION_WIDTH = 80;
const COLUMN_COUNT = 3;
const DEFAULT_FIXED_WIDTH = 80;
@customElement('track-list')
export class TrackList extends LitElement implements SelectionHost {
private player = new PlayerController(this);
private libraryCtrl = new LibraryController(this);
private searchCtrl = new SearchController(this);
private trackListCtrl = new TrackListController(this);
private selection = new SelectionController(this);
private cancelScanComplete?: () => void;
private lastSearchTerm = '';
/**
* Resolved column definitions for the currently configured
* column IDs. Falls back to defaults for any unknown ID.
*/
private get activeColumns(): ColumnDef[] {
const ids = this.trackListCtrl.columnIds;
if (!ids || ids.length === 0) {
return DEFAULT_COLUMN_IDS
.map((id) => COLUMN_DEFS[id])
.filter(
(d): d is ColumnDef =>
d !== undefined,
);
}
return ids
.map((id) => COLUMN_DEFS[id])
.filter(
(d): d is ColumnDef =>
d !== undefined,
);
}
@state()
private tracks: library.Track[] = [];
@@ -114,13 +143,14 @@ export class TrackList extends LitElement implements SelectionHost {
if (!term) return this.tracks;
return this.tracks.filter(
(t) =>
t.TrackName.toLowerCase().includes(
term,
) ||
t.ArtistName.toLowerCase().includes(
term,
const cols = this.activeColumns;
return this.tracks.filter((t) =>
cols.some((col) =>
col
.accessor(t)
.toLowerCase()
.includes(term),
),
);
}
@@ -142,8 +172,12 @@ export class TrackList extends LitElement implements SelectionHost {
}
private get gridTemplateColumns(): string {
const cols = this.activeColumns;
if (this.columnWidths.length === 0) {
return '1fr 1fr 80px';
return cols
.map((c) => c.defaultWidth)
.join(' ');
}
return this.columnWidths
@@ -183,36 +217,103 @@ export class TrackList extends LitElement implements SelectionHost {
if (totalWidth <= 0) return;
const remaining = totalWidth - DEFAULT_DURATION_WIDTH;
const half = Math.floor(remaining / 2);
const cols = this.activeColumns;
this.columnWidths = [
half,
remaining - half,
DEFAULT_DURATION_WIDTH,
];
if (cols.length === 0) return;
// Fixed-width columns use their pixel default;
// flex columns share the remainder equally.
const fixedTotal = cols.reduce((sum, c) => {
if (c.defaultWidth.endsWith('px')) {
return (
sum +
parseInt(c.defaultWidth, 10)
);
}
return sum;
}, 0);
const flexCols = cols.filter(
(c) => !c.defaultWidth.endsWith('px'),
);
const remaining = Math.max(
0,
totalWidth - fixedTotal,
);
const perFlex =
flexCols.length > 0
? Math.floor(
remaining / flexCols.length,
)
: DEFAULT_FIXED_WIDTH;
const raw = cols.map((c) => {
if (c.defaultWidth.endsWith('px')) {
return parseInt(c.defaultWidth, 10);
}
return Math.max(
MIN_COLUMN_WIDTH,
perFlex,
);
});
this.columnWidths = this.normalizeWidths(raw);
}
private loadColumnWidths(): number[] | null {
try {
const raw = localStorage.getItem(COLUMN_STORAGE_KEY);
const raw = localStorage.getItem(
COLUMN_STORAGE_KEY,
);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
// Support new id-keyed format: Record<string, number>.
if (
!Array.isArray(parsed) ||
parsed.length !== COLUMN_COUNT ||
!parsed.every(
(v: unknown) =>
typeof v === 'number' && v >= MIN_COLUMN_WIDTH,
)
parsed !== null &&
typeof parsed === 'object' &&
!Array.isArray(parsed)
) {
return null;
const map = parsed as Record<
string,
unknown
>;
const cols = this.activeColumns;
const widths = cols.map((c) => {
const w = map[c.id];
if (
typeof w === 'number' &&
w >= MIN_COLUMN_WIDTH
) {
return w;
}
return parsed as number[];
// Fallback for columns without saved width.
if (
c.defaultWidth.endsWith('px')
) {
return parseInt(
c.defaultWidth,
10,
);
}
return MIN_COLUMN_WIDTH;
});
return this.normalizeWidths(widths);
}
// Legacy array format — discard on column count mismatch.
return null;
} catch {
return null;
}
@@ -220,15 +321,103 @@ export class TrackList extends LitElement implements SelectionHost {
private saveColumnWidths() {
try {
const cols = this.activeColumns;
const map: Record<string, number> = {};
cols.forEach((c, i) => {
map[c.id] =
this.columnWidths[i] ??
MIN_COLUMN_WIDTH;
});
localStorage.setItem(
COLUMN_STORAGE_KEY,
JSON.stringify(this.columnWidths),
JSON.stringify(map),
);
} catch {
// Ignore storage errors.
}
}
/**
* Scale widths so they sum to exactly the container width.
* Every column is guaranteed at least MIN_COLUMN_WIDTH.
*/
private normalizeWidths(
widths: number[],
): number[] {
const container = this.clientWidth;
if (container <= 0 || widths.length === 0) {
return widths;
}
const minTotal =
widths.length * MIN_COLUMN_WIDTH;
// If the container can't even fit minimums,
// give every column the minimum.
if (container <= minTotal) {
return widths.map(
() => MIN_COLUMN_WIDTH,
);
}
const sum = widths.reduce(
(a, b) => a + b,
0,
);
if (sum <= 0) {
const even = Math.floor(
container / widths.length,
);
return widths.map(() =>
Math.max(MIN_COLUMN_WIDTH, even),
);
}
// Scale proportionally.
const scale = container / sum;
const scaled = widths.map((w) =>
Math.max(
MIN_COLUMN_WIDTH,
Math.round(w * scale),
),
);
// Fix rounding remainder so the total is
// exactly containerWidth.
const scaledSum = scaled.reduce(
(a, b) => a + b,
0,
);
const diff = container - scaledSum;
if (diff !== 0) {
// Apply remainder to the widest column.
let maxIdx = 0;
for (let i = 1; i < scaled.length; i++) {
if (
(scaled[i] ?? 0) >
(scaled[maxIdx] ?? 0)
) {
maxIdx = i;
}
}
scaled[maxIdx] =
(scaled[maxIdx] ?? 0) + diff;
}
return scaled;
}
private onColResizeStart = (e: MouseEvent, columnIndex: number) => {
e.preventDefault();
this.resizingColumn = columnIndex;
@@ -285,12 +474,13 @@ export class TrackList extends LitElement implements SelectionHost {
.header-row {
display: grid;
grid-template-columns: var(--grid-cols, 1fr 1fr 80px);
grid-template-columns: var(--grid-cols);
padding: 8px;
font-weight: bold;
color: var(--yj-text-primary, #fff);
border-bottom: 1px solid var(--yj-text-tertiary, #666);
flex-shrink: 0;
overflow: hidden;
}
.header-cell {
@@ -346,7 +536,7 @@ export class TrackList extends LitElement implements SelectionHost {
.track-row {
display: grid;
grid-template-columns: var(--grid-cols, 1fr 1fr 80px);
grid-template-columns: var(--grid-cols);
font-size: 12px;
padding: 8px;
border-bottom: 1px solid var(--yj-border-subtle, #333);
@@ -354,6 +544,7 @@ export class TrackList extends LitElement implements SelectionHost {
width: 100%;
cursor: default;
user-select: none;
overflow: hidden;
}
.track-row > * {
@@ -385,7 +576,7 @@ export class TrackList extends LitElement implements SelectionHost {
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
}
.track-name {
.cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
@@ -393,11 +584,8 @@ export class TrackList extends LitElement implements SelectionHost {
user-select: none;
}
.artist-name,
.track-length {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
.cell-right {
text-align: right;
}
#context-menu {
@@ -486,6 +674,16 @@ export class TrackList extends LitElement implements SelectionHost {
}
override updated(changed: Map<string, unknown>) {
// Recompute widths when the column config changes.
const colKey = this.trackListCtrl.columnIds.join(
',',
);
if (colKey !== this.previousColumnIds) {
this.previousColumnIds = colKey;
this.initColumnWidths();
}
if (changed.has('columnWidths')) {
this.style.setProperty(
'--grid-cols',
@@ -511,6 +709,7 @@ export class TrackList extends LitElement implements SelectionHost {
}
private previousHostWidth = 0;
private previousColumnIds = '';
private onHostResize() {
const newWidth = this.clientWidth;
@@ -529,20 +728,8 @@ export class TrackList extends LitElement implements SelectionHost {
return;
}
const oldTotal = this.columnWidths.reduce(
(sum, w) => sum + w,
0,
);
if (oldTotal <= 0) return;
const scale = newWidth / oldTotal;
this.columnWidths = this.columnWidths.map((w) =>
Math.max(
MIN_COLUMN_WIDTH,
Math.round(w * scale),
),
this.columnWidths = this.normalizeWidths(
this.columnWidths,
);
this.previousHostWidth = newWidth;
@@ -771,7 +958,9 @@ export class TrackList extends LitElement implements SelectionHost {
index: number,
): unknown => {
const active = this.isActiveTrack(track);
const selected = this.selection.isSelected(track.FilePath);
const selected = this.selection.isSelected(
track.FilePath,
);
const classes = [
'track-row',
@@ -781,39 +970,49 @@ export class TrackList extends LitElement implements SelectionHost {
.filter(Boolean)
.join(' ');
const cols = this.activeColumns;
return html`
<div
class=${classes}
draggable="true"
@click=${(e: MouseEvent) =>
this.onTrackRowClick(e, track, index)}
@dblclick=${() => this.onTrackRowDblClick(track)}
@dblclick=${() =>
this.onTrackRowDblClick(track)}
@contextmenu=${(e: MouseEvent) =>
this.onTrackContextMenu(e, track)}
@dragstart=${(e: DragEvent) =>
this.onTrackDragStart(e, track)}
@dragend=${this.onTrackDragEnd}
>
<div class="track-name">${track.TrackName}</div>
<div class="artist-name">${track.ArtistName}</div>
<div class="track-length">
${formatMilliseconds(track.TrackLength)}
${cols.map(
(col) => html`
<div class="cell ${col.align === 'right' ? 'cell-right' : ''}">
${col.accessor(track)}
</div>
`,
)}
</div>
`;
};
override render() {
const visibleTracks = this.filteredTracks;
const cols = this.activeColumns;
return html`
${this.tracks.length === 0
? html`<p>Loading tracks...</p>`
: html`
<div class="header-row">
<div class="header-cell">Track Name</div>
<div class="header-cell">Artist</div>
<div class="header-cell">Track Length</div>
${cols.map(
(col) => html`
<div class="header-cell ${col.align === 'right' ? 'cell-right' : ''}">
${col.label}
</div>
`,
)}
</div>
${visibleTracks.length === 0
? html`<p class="no-results">
+1
View File
@@ -50,6 +50,7 @@ export const Events = {
// Config events
ThemeConfigChanged: "ThemeConfigChanged",
TrackListConfigChanged: "TrackListConfigChanged",
// Library events
LibraryScanStarted: "LibraryScanStarted",
@@ -0,0 +1,60 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from 'lit';
import type { TrackListState } from '../tracklist-store';
import { trackListStore } from '../tracklist-store';
/**
* TrackListController connects a Lit component to the
* TrackListStore so it re-renders when the column layout changes.
*/
export class TrackListController
implements ReactiveController
{
private host: ReactiveControllerHost;
private unsubscribe?: () => void;
constructor(host: ReactiveControllerHost) {
this.host = host;
host.addController(this);
}
// ===============================================================
// LIFECYCLE HOOKS
// ===============================================================
hostConnected(): void {
this.unsubscribe = trackListStore.subscribe(
() => {
this.host.requestUpdate();
},
);
}
hostDisconnected(): void {
this.unsubscribe?.();
}
// ===============================================================
// STATE ACCESSORS
// ===============================================================
get state(): Readonly<TrackListState> {
return trackListStore.getState();
}
get columnIds(): readonly string[] {
return this.state.columnIds;
}
// ===============================================================
// ACTIONS
// ===============================================================
async setColumns(
columnIds: string[],
): Promise<void> {
await trackListStore.setColumns(columnIds);
}
}
+108
View File
@@ -0,0 +1,108 @@
import { EventsOn } from '@runtime/runtime';
import {
GetTrackListColumns,
SetTrackListColumns,
} from '@go/config/Config';
import { tracklist } from '@go/models';
import { Events } from '../events';
import { DEFAULT_COLUMN_IDS } from '@components/track-list/columns';
export interface TrackListState {
/** Ordered list of visible column IDs. */
columnIds: string[];
}
type Subscriber = () => void;
class TrackListStore {
private state: TrackListState = {
columnIds: [...DEFAULT_COLUMN_IDS],
};
private subscribers = new Set<Subscriber>();
constructor() {
this.initializeEventListeners();
this.loadFromBackend();
}
// ===============================================================
// WAILS EVENT BRIDGE
// ===============================================================
private initializeEventListeners(): void {
EventsOn(
Events.TrackListConfigChanged,
(data: {
columns: Array<{ id: string }>;
}) => {
this.update({
columnIds: data.columns.map(
(c) => c.id,
),
});
},
);
}
private async loadFromBackend(): Promise<void> {
try {
const columns = await GetTrackListColumns();
this.update({
columnIds: columns.map(
(c: tracklist.Column) => c.id,
),
});
} catch {
// Use defaults on failure.
}
}
// ===============================================================
// STATE ACCESS
// ===============================================================
getState(): Readonly<TrackListState> {
return this.state;
}
// ===============================================================
// ACTIONS
// ===============================================================
async setColumns(columnIds: string[]): Promise<void> {
const columns = columnIds.map((id) => {
const col = new tracklist.Column();
col.id = id;
return col;
});
await SetTrackListColumns(columns);
}
// ===============================================================
// SUBSCRIPTION SYSTEM
// ===============================================================
subscribe(callback: Subscriber): () => void {
this.subscribers.add(callback);
return () => this.subscribers.delete(callback);
}
private update(
partial: Partial<TrackListState>,
): void {
this.state = { ...this.state, ...partial };
this.notify();
}
private notify(): void {
this.subscribers.forEach((cb) => cb());
}
}
// Singleton instance.
export const trackListStore = new TrackListStore();
+5
View File
@@ -1,5 +1,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
import {tracklist} from '../models';
import {context} from '../models';
export function GetLibraryDirectory():Promise<string>;
@@ -10,6 +11,8 @@ export function GetThemeAccentColor():Promise<string>;
export function GetThemeBackgroundShade():Promise<string>;
export function GetTrackListColumns():Promise<Array<tracklist.Column>>;
export function Load():Promise<void>;
export function Save():Promise<void>;
@@ -24,4 +27,6 @@ export function SetThemeAccentColor(arg1:string):Promise<void>;
export function SetThemeBackgroundShade(arg1:string):Promise<void>;
export function SetTrackListColumns(arg1:Array<tracklist.Column>):Promise<void>;
export function Validate():Promise<void>;
+8
View File
@@ -18,6 +18,10 @@ export function GetThemeBackgroundShade() {
return window['go']['config']['Config']['GetThemeBackgroundShade']();
}
export function GetTrackListColumns() {
return window['go']['config']['Config']['GetTrackListColumns']();
}
export function Load() {
return window['go']['config']['Config']['Load']();
}
@@ -46,6 +50,10 @@ export function SetThemeBackgroundShade(arg1) {
return window['go']['config']['Config']['SetThemeBackgroundShade'](arg1);
}
export function SetTrackListColumns(arg1) {
return window['go']['config']['Config']['SetTrackListColumns'](arg1);
}
export function Validate() {
return window['go']['config']['Config']['Validate']();
}
+27
View File
@@ -93,6 +93,11 @@ export namespace library {
FilePath: string;
TrackNumber: number;
DiscNumber: number;
Album: string;
Genre: string;
Year: number;
Composer: string;
FileType: string;
static createFrom(source: any = {}) {
return new Track(source);
@@ -106,6 +111,11 @@ export namespace library {
this.FilePath = source["FilePath"];
this.TrackNumber = source["TrackNumber"];
this.DiscNumber = source["DiscNumber"];
this.Album = source["Album"];
this.Genre = source["Genre"];
this.Year = source["Year"];
this.Composer = source["Composer"];
this.FileType = source["FileType"];
}
}
@@ -196,3 +206,20 @@ export namespace playlist {
}
export namespace tracklist {
export class Column {
id: string;
static createFrom(source: any = {}) {
return new Column(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.id = source["id"];
}
}
}