From b5e70a8ce6e917ec081460ed850efb1e5a257988 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Sun, 22 Feb 2026 11:24:19 -0500 Subject: [PATCH] user can configure visible columns in track-list --- backend/config/config.go | 91 ++++- backend/database/sql/queries/audio_files.sql | 20 ++ .../database/sql/sqlcgen/audio_files.sql.go | 70 ++++ backend/events/events.go | 5 +- backend/library/query.go | 46 ++- backend/tracklist/config.go | 104 ++++++ .../src/components/config-page/config-page.ts | 251 ++++++++++++++ frontend/src/components/track-list/columns.ts | 102 ++++++ .../src/components/track-list/track-list.ts | 321 ++++++++++++++---- frontend/src/events.ts | 1 + .../store/controllers/tracklist-controller.ts | 60 ++++ frontend/src/store/tracklist-store.ts | 108 ++++++ frontend/wailsjs/go/config/Config.d.ts | 5 + frontend/wailsjs/go/config/Config.js | 8 + frontend/wailsjs/go/models.ts | 27 ++ 15 files changed, 1137 insertions(+), 82 deletions(-) create mode 100644 backend/tracklist/config.go create mode 100644 frontend/src/components/track-list/columns.ts create mode 100644 frontend/src/store/controllers/tracklist-controller.ts create mode 100644 frontend/src/store/tracklist-store.ts 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`
-
Track Name
-
Artist
-
Track Length
+ ${cols.map( + (col) => html` +
+ ${col.label} +
+ `, + )}
${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"]; + } + } + +} +