diff --git a/backend/config/config.go b/backend/config/config.go
index d2739f8..0e4c176 100644
--- a/backend/config/config.go
+++ b/backend/config/config.go
@@ -16,16 +16,18 @@ import (
"yellowjacket/backend/library"
"yellowjacket/backend/system"
"yellowjacket/backend/theme"
+ "yellowjacket/backend/tracklist"
)
// Config represents the application configuration.
type Config struct {
- ctx context.Context
- logger *slog.Logger
- filePath string // required
- Library *library.Config `toml:"Library"`
- Theme *theme.Config `toml:"Theme"`
- Window *WindowConfig `toml:"Window"`
+ ctx context.Context
+ logger *slog.Logger
+ filePath string // required
+ 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,
+ },
+ )
+}
diff --git a/backend/database/sql/queries/audio_files.sql b/backend/database/sql/queries/audio_files.sql
index 13ce89a..bdff729 100644
--- a/backend/database/sql/queries/audio_files.sql
+++ b/backend/database/sql/queries/audio_files.sql
@@ -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;
diff --git a/backend/database/sql/sqlcgen/audio_files.sql.go b/backend/database/sql/sqlcgen/audio_files.sql.go
index 4a09103..be04202 100644
--- a/backend/database/sql/sqlcgen/audio_files.sql.go
+++ b/backend/database/sql/sqlcgen/audio_files.sql.go
@@ -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
diff --git a/backend/events/events.go b/backend/events/events.go
index 6635320..ce53b05 100644
--- a/backend/events/events.go
+++ b/backend/events/events.go
@@ -54,8 +54,9 @@ const (
// Config events.
const (
- LibraryConfigChanged = "LibraryConfigChanged"
- ThemeConfigChanged = "ThemeConfigChanged"
+ LibraryConfigChanged = "LibraryConfigChanged"
+ ThemeConfigChanged = "ThemeConfigChanged"
+ TrackListConfigChanged = "TrackListConfigChanged"
)
// Playlist events.
diff --git a/backend/library/query.go b/backend/library/query.go
index 267fdce..371a487 100644
--- a/backend/library/query.go
+++ b/backend/library/query.go
@@ -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,
+ 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,
}
- formattedTracks = append(formattedTracks, track)
+
+ tracks = append(tracks, track)
}
- l.logger.Info("formatted tracks", "count", len(formattedTracks))
+ l.logger.Info("formatted tracks", "count", len(tracks))
- return formattedTracks, nil
+ return tracks, nil
}
// GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number.
diff --git a/backend/tracklist/config.go b/backend/tracklist/config.go
new file mode 100644
index 0000000..cee0d09
--- /dev/null
+++ b/backend/tracklist/config.go
@@ -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)
+}
diff --git a/frontend/src/components/config-page/config-page.ts b/frontend/src/components/config-page/config-page.ts
index dc4fca6..1c4d68d 100644
--- a/frontend/src/components/config-page/config-page.ts
+++ b/frontend/src/components/config-page/config-page.ts
@@ -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 {
Settings
${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`
+
+
+
+ `;
+ }
+
// --- Library section ---
private renderLibrarySection() {
diff --git a/frontend/src/components/track-list/columns.ts b/frontend/src/components/track-list/columns.ts
new file mode 100644
index 0000000..87d1f0e
--- /dev/null
+++ b/frontend/src/components/track-list/columns.ts
@@ -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 = {
+ 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',
+];
diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts
index abe15ba..808ea38 100644
--- a/frontend/src/components/track-list/track-list.ts
+++ b/frontend/src/components/track-list/track-list.ts
@@ -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,14 +143,15 @@ 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.
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;
+ }
+
+ // Fallback for columns without saved width.
+ if (
+ c.defaultWidth.endsWith('px')
+ ) {
+ return parseInt(
+ c.defaultWidth,
+ 10,
+ );
+ }
+
+ return MIN_COLUMN_WIDTH;
+ });
+
+ return this.normalizeWidths(widths);
}
- return parsed as number[];
+ // 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 = {};
+
+ 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) {
+ // 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`
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}
>
-
${track.TrackName}
-
${track.ArtistName}
-
- ${formatMilliseconds(track.TrackLength)}
-
+ ${cols.map(
+ (col) => html`
+
+ ${col.accessor(track)}
+
+ `,
+ )}
`;
};
override render() {
const visibleTracks = this.filteredTracks;
+ const cols = this.activeColumns;
return html`
${this.tracks.length === 0
? html`Loading tracks...
`
: html`
${visibleTracks.length === 0
? html`
diff --git a/frontend/src/events.ts b/frontend/src/events.ts
index 5c3e274..a22b531 100644
--- a/frontend/src/events.ts
+++ b/frontend/src/events.ts
@@ -50,6 +50,7 @@ export const Events = {
// Config events
ThemeConfigChanged: "ThemeConfigChanged",
+ TrackListConfigChanged: "TrackListConfigChanged",
// Library events
LibraryScanStarted: "LibraryScanStarted",
diff --git a/frontend/src/store/controllers/tracklist-controller.ts b/frontend/src/store/controllers/tracklist-controller.ts
new file mode 100644
index 0000000..b7e52e2
--- /dev/null
+++ b/frontend/src/store/controllers/tracklist-controller.ts
@@ -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 {
+ return trackListStore.getState();
+ }
+
+ get columnIds(): readonly string[] {
+ return this.state.columnIds;
+ }
+
+ // ===============================================================
+ // ACTIONS
+ // ===============================================================
+
+ async setColumns(
+ columnIds: string[],
+ ): Promise {
+ await trackListStore.setColumns(columnIds);
+ }
+}
diff --git a/frontend/src/store/tracklist-store.ts b/frontend/src/store/tracklist-store.ts
new file mode 100644
index 0000000..8d2773a
--- /dev/null
+++ b/frontend/src/store/tracklist-store.ts
@@ -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();
+
+ 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 {
+ try {
+ const columns = await GetTrackListColumns();
+
+ this.update({
+ columnIds: columns.map(
+ (c: tracklist.Column) => c.id,
+ ),
+ });
+ } catch {
+ // Use defaults on failure.
+ }
+ }
+
+ // ===============================================================
+ // STATE ACCESS
+ // ===============================================================
+
+ getState(): Readonly {
+ return this.state;
+ }
+
+ // ===============================================================
+ // ACTIONS
+ // ===============================================================
+
+ async setColumns(columnIds: string[]): Promise {
+ 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,
+ ): void {
+ this.state = { ...this.state, ...partial };
+ this.notify();
+ }
+
+ private notify(): void {
+ this.subscribers.forEach((cb) => cb());
+ }
+}
+
+// Singleton instance.
+export const trackListStore = new TrackListStore();
diff --git a/frontend/wailsjs/go/config/Config.d.ts b/frontend/wailsjs/go/config/Config.d.ts
index f34187a..7e8ce6b 100755
--- a/frontend/wailsjs/go/config/Config.d.ts
+++ b/frontend/wailsjs/go/config/Config.d.ts
@@ -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;
@@ -10,6 +11,8 @@ export function GetThemeAccentColor():Promise;
export function GetThemeBackgroundShade():Promise;
+export function GetTrackListColumns():Promise>;
+
export function Load():Promise;
export function Save():Promise;
@@ -24,4 +27,6 @@ export function SetThemeAccentColor(arg1:string):Promise;
export function SetThemeBackgroundShade(arg1:string):Promise;
+export function SetTrackListColumns(arg1:Array):Promise;
+
export function Validate():Promise;
diff --git a/frontend/wailsjs/go/config/Config.js b/frontend/wailsjs/go/config/Config.js
index 9c0375a..dfd2283 100755
--- a/frontend/wailsjs/go/config/Config.js
+++ b/frontend/wailsjs/go/config/Config.js
@@ -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']();
}
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts
index a35d05a..85d9d91 100755
--- a/frontend/wailsjs/go/models.ts
+++ b/frontend/wailsjs/go/models.ts
@@ -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"];
+ }
+ }
+
+}
+