user can configure visible columns in track-list
This commit is contained in:
@@ -16,16 +16,18 @@ import (
|
|||||||
"yellowjacket/backend/library"
|
"yellowjacket/backend/library"
|
||||||
"yellowjacket/backend/system"
|
"yellowjacket/backend/system"
|
||||||
"yellowjacket/backend/theme"
|
"yellowjacket/backend/theme"
|
||||||
|
"yellowjacket/backend/tracklist"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the application configuration.
|
// Config represents the application configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
ctx context.Context
|
ctx context.Context
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
filePath string // required
|
filePath string // required
|
||||||
Library *library.Config `toml:"Library"`
|
Library *library.Config `toml:"Library"`
|
||||||
Theme *theme.Config `toml:"Theme"`
|
Theme *theme.Config `toml:"Theme"`
|
||||||
Window *WindowConfig `toml:"Window"`
|
Window *WindowConfig `toml:"Window"`
|
||||||
|
TrackList *tracklist.Config `toml:"TrackList"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewConfig creates a new config by loading it from disk.
|
// 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 {
|
if configErrs != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"one or more config parts are invalid: %w",
|
"one or more config parts are invalid: %w",
|
||||||
@@ -160,6 +168,12 @@ func (c *Config) applyDefaults() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.Theme.ApplyDefaults()
|
c.Theme.ApplyDefaults()
|
||||||
|
|
||||||
|
if c.TrackList == nil {
|
||||||
|
c.TrackList = &tracklist.Config{}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.TrackList.ApplyDefaults()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetContext sets the Wails runtime context for event emission.
|
// 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 = ?
|
WHERE af.file_path = ?
|
||||||
LIMIT 1;
|
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
|
-- name: DeleteAllAudioFiles :exec
|
||||||
DELETE FROM audio_files;
|
DELETE FROM audio_files;
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,76 @@ func (q *Queries) GetAllAudioFilesWithArtist(ctx context.Context) ([]GetAllAudio
|
|||||||
return items, nil
|
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
|
const getAudioFile = `-- name: GetAudioFile :one
|
||||||
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
|
SELECT id, file_path, length_milliseconds, file_type_id, recording_id FROM audio_files
|
||||||
WHERE id = ? LIMIT 1
|
WHERE id = ? LIMIT 1
|
||||||
|
|||||||
@@ -54,8 +54,9 @@ const (
|
|||||||
|
|
||||||
// Config events.
|
// Config events.
|
||||||
const (
|
const (
|
||||||
LibraryConfigChanged = "LibraryConfigChanged"
|
LibraryConfigChanged = "LibraryConfigChanged"
|
||||||
ThemeConfigChanged = "ThemeConfigChanged"
|
ThemeConfigChanged = "ThemeConfigChanged"
|
||||||
|
TrackListConfigChanged = "TrackListConfigChanged"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Playlist events.
|
// Playlist events.
|
||||||
|
|||||||
+33
-13
@@ -21,6 +21,11 @@ type Track struct {
|
|||||||
FilePath string
|
FilePath string
|
||||||
TrackNumber int64
|
TrackNumber int64
|
||||||
DiscNumber int64
|
DiscNumber int64
|
||||||
|
Album string
|
||||||
|
Genre string
|
||||||
|
Year int64
|
||||||
|
Composer string
|
||||||
|
FileType string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Album represents an album for the cover grid display.
|
// 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.
|
// GetAllTracks returns an array of track structs of every file in the library.
|
||||||
func (l *Library) GetAllTracks() ([]Track, error) {
|
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 {
|
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
|
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")
|
l.logger.Error("no tracks in library")
|
||||||
|
|
||||||
return nil, errNoTracksInLibrary
|
return nil, errNoTracksInLibrary
|
||||||
}
|
}
|
||||||
|
|
||||||
var formattedTracks []Track
|
tracks := make([]Track, 0, len(rows))
|
||||||
|
|
||||||
for _, file := range audioFiles {
|
for _, row := range rows {
|
||||||
track := Track{
|
track := Track{
|
||||||
TrackName: file.Title,
|
TrackName: row.Title,
|
||||||
ArtistName: file.ArtistName,
|
ArtistName: row.ArtistName,
|
||||||
TrackLength: strconv.FormatInt(file.LengthMilliseconds, 10),
|
TrackLength: strconv.FormatInt(
|
||||||
FilePath: file.FilePath,
|
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.
|
// GetAlbumTracks returns all tracks for a given album (release group), ordered by disc and track number.
|
||||||
|
|||||||
@@ -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';
|
} from '@go/config/Config';
|
||||||
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
import { DirectoryPicker } from '@go/frontendutil/FrontendUtil';
|
||||||
import { ThemeController } from '@store/controllers/theme-controller';
|
import { ThemeController } from '@store/controllers/theme-controller';
|
||||||
|
import { TrackListController } from '@store/controllers/tracklist-controller';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
import type { ConfigFieldChangeEvent } from './config-field';
|
import type { ConfigFieldChangeEvent } from './config-field';
|
||||||
import type { BackgroundShade } from '@store/theme-store';
|
import type { BackgroundShade } from '@store/theme-store';
|
||||||
|
import {
|
||||||
|
COLUMN_DEFS,
|
||||||
|
ALL_COLUMN_IDS,
|
||||||
|
} from '@components/track-list/columns';
|
||||||
|
|
||||||
import './config-field';
|
import './config-field';
|
||||||
import './config-section';
|
import './config-section';
|
||||||
@@ -176,6 +181,9 @@ export class ConfigPage extends LitElement {
|
|||||||
// --- Theme controller for reading/writing theme state ---
|
// --- Theme controller for reading/writing theme state ---
|
||||||
private themeCtrl = new ThemeController(this);
|
private themeCtrl = new ThemeController(this);
|
||||||
|
|
||||||
|
// --- Track-list column config controller ---
|
||||||
|
private trackListCtrl = new TrackListController(this);
|
||||||
|
|
||||||
// --- Library state ---
|
// --- Library state ---
|
||||||
@state() private libraryDirectory = '';
|
@state() private libraryDirectory = '';
|
||||||
@state() private selectedDirectory = '';
|
@state() private selectedDirectory = '';
|
||||||
@@ -464,6 +472,76 @@ export class ConfigPage extends LitElement {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
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
|
// COMPUTED
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -711,6 +876,7 @@ export class ConfigPage extends LitElement {
|
|||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
|
|
||||||
${this.renderThemeSection()}
|
${this.renderThemeSection()}
|
||||||
|
${this.renderTrackListSection()}
|
||||||
${this.renderLibrarySection()}
|
${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 ---
|
// --- Library section ---
|
||||||
|
|
||||||
private renderLibrarySection() {
|
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',
|
||||||
|
];
|
||||||
@@ -2,14 +2,19 @@ import { library } from '@go/models';
|
|||||||
import { LitElement, html, css, nothing } from 'lit';
|
import { LitElement, html, css, nothing } from 'lit';
|
||||||
import { customElement, state, query } from 'lit/decorators.js';
|
import { customElement, state, query } from 'lit/decorators.js';
|
||||||
import { EventsOn } from '@runtime/runtime';
|
import { EventsOn } from '@runtime/runtime';
|
||||||
import { formatMilliseconds } from '@utils/time';
|
|
||||||
import { SelectionController } from '@utils/selection-controller';
|
import { SelectionController } from '@utils/selection-controller';
|
||||||
import type { SelectionHost } from '@utils/selection-controller';
|
import type { SelectionHost } from '@utils/selection-controller';
|
||||||
import { PlayerController } from '@store/controllers/player-controller';
|
import { PlayerController } from '@store/controllers/player-controller';
|
||||||
import { SearchController } from '@store/controllers/search-controller';
|
import { SearchController } from '@store/controllers/search-controller';
|
||||||
|
import { TrackListController } from '@store/controllers/tracklist-controller';
|
||||||
import { queueStore } from '@store/queue-store';
|
import { queueStore } from '@store/queue-store';
|
||||||
import { LibraryController } from '@store/controllers/library-controller';
|
import { LibraryController } from '@store/controllers/library-controller';
|
||||||
import { Events } from '../../events';
|
import { Events } from '../../events';
|
||||||
|
import {
|
||||||
|
COLUMN_DEFS,
|
||||||
|
DEFAULT_COLUMN_IDS,
|
||||||
|
} from './columns';
|
||||||
|
import type { ColumnDef } from './columns';
|
||||||
import {
|
import {
|
||||||
setDragPayload,
|
setDragPayload,
|
||||||
emitDragActive,
|
emitDragActive,
|
||||||
@@ -32,18 +37,42 @@ import type { PlaylistPicker } from '@components/playlist-picker/playlist-picker
|
|||||||
|
|
||||||
const COLUMN_STORAGE_KEY = 'track-list-column-widths';
|
const COLUMN_STORAGE_KEY = 'track-list-column-widths';
|
||||||
const MIN_COLUMN_WIDTH = 50;
|
const MIN_COLUMN_WIDTH = 50;
|
||||||
const DEFAULT_DURATION_WIDTH = 80;
|
const DEFAULT_FIXED_WIDTH = 80;
|
||||||
const COLUMN_COUNT = 3;
|
|
||||||
|
|
||||||
@customElement('track-list')
|
@customElement('track-list')
|
||||||
export class TrackList extends LitElement implements SelectionHost {
|
export class TrackList extends LitElement implements SelectionHost {
|
||||||
private player = new PlayerController(this);
|
private player = new PlayerController(this);
|
||||||
private libraryCtrl = new LibraryController(this);
|
private libraryCtrl = new LibraryController(this);
|
||||||
private searchCtrl = new SearchController(this);
|
private searchCtrl = new SearchController(this);
|
||||||
|
private trackListCtrl = new TrackListController(this);
|
||||||
private selection = new SelectionController(this);
|
private selection = new SelectionController(this);
|
||||||
private cancelScanComplete?: () => void;
|
private cancelScanComplete?: () => void;
|
||||||
private lastSearchTerm = '';
|
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()
|
@state()
|
||||||
private tracks: library.Track[] = [];
|
private tracks: library.Track[] = [];
|
||||||
|
|
||||||
@@ -114,14 +143,15 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
|
|
||||||
if (!term) return this.tracks;
|
if (!term) return this.tracks;
|
||||||
|
|
||||||
return this.tracks.filter(
|
const cols = this.activeColumns;
|
||||||
(t) =>
|
|
||||||
t.TrackName.toLowerCase().includes(
|
return this.tracks.filter((t) =>
|
||||||
term,
|
cols.some((col) =>
|
||||||
) ||
|
col
|
||||||
t.ArtistName.toLowerCase().includes(
|
.accessor(t)
|
||||||
term,
|
.toLowerCase()
|
||||||
),
|
.includes(term),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,8 +172,12 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private get gridTemplateColumns(): string {
|
private get gridTemplateColumns(): string {
|
||||||
|
const cols = this.activeColumns;
|
||||||
|
|
||||||
if (this.columnWidths.length === 0) {
|
if (this.columnWidths.length === 0) {
|
||||||
return '1fr 1fr 80px';
|
return cols
|
||||||
|
.map((c) => c.defaultWidth)
|
||||||
|
.join(' ');
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.columnWidths
|
return this.columnWidths
|
||||||
@@ -183,36 +217,103 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
|
|
||||||
if (totalWidth <= 0) return;
|
if (totalWidth <= 0) return;
|
||||||
|
|
||||||
const remaining = totalWidth - DEFAULT_DURATION_WIDTH;
|
const cols = this.activeColumns;
|
||||||
const half = Math.floor(remaining / 2);
|
|
||||||
|
|
||||||
this.columnWidths = [
|
if (cols.length === 0) return;
|
||||||
half,
|
|
||||||
remaining - half,
|
// Fixed-width columns use their pixel default;
|
||||||
DEFAULT_DURATION_WIDTH,
|
// 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 {
|
private loadColumnWidths(): number[] | null {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(COLUMN_STORAGE_KEY);
|
const raw = localStorage.getItem(
|
||||||
|
COLUMN_STORAGE_KEY,
|
||||||
|
);
|
||||||
|
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
|
|
||||||
const parsed: unknown = JSON.parse(raw);
|
const parsed: unknown = JSON.parse(raw);
|
||||||
|
|
||||||
|
// Support new id-keyed format: Record<string, number>.
|
||||||
if (
|
if (
|
||||||
!Array.isArray(parsed) ||
|
parsed !== null &&
|
||||||
parsed.length !== COLUMN_COUNT ||
|
typeof parsed === 'object' &&
|
||||||
!parsed.every(
|
!Array.isArray(parsed)
|
||||||
(v: unknown) =>
|
|
||||||
typeof v === 'number' && v >= MIN_COLUMN_WIDTH,
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
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 {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -220,15 +321,103 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
|
|
||||||
private saveColumnWidths() {
|
private saveColumnWidths() {
|
||||||
try {
|
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(
|
localStorage.setItem(
|
||||||
COLUMN_STORAGE_KEY,
|
COLUMN_STORAGE_KEY,
|
||||||
JSON.stringify(this.columnWidths),
|
JSON.stringify(map),
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore storage errors.
|
// 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) => {
|
private onColResizeStart = (e: MouseEvent, columnIndex: number) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.resizingColumn = columnIndex;
|
this.resizingColumn = columnIndex;
|
||||||
@@ -285,12 +474,13 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
|
|
||||||
.header-row {
|
.header-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--grid-cols, 1fr 1fr 80px);
|
grid-template-columns: var(--grid-cols);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: var(--yj-text-primary, #fff);
|
color: var(--yj-text-primary, #fff);
|
||||||
border-bottom: 1px solid var(--yj-text-tertiary, #666);
|
border-bottom: 1px solid var(--yj-text-tertiary, #666);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-cell {
|
.header-cell {
|
||||||
@@ -346,7 +536,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
|
|
||||||
.track-row {
|
.track-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--grid-cols, 1fr 1fr 80px);
|
grid-template-columns: var(--grid-cols);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-bottom: 1px solid var(--yj-border-subtle, #333);
|
border-bottom: 1px solid var(--yj-border-subtle, #333);
|
||||||
@@ -354,6 +544,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-row > * {
|
.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));
|
background-color: var(--yj-selection-bg, rgba(100, 160, 255, 0.15));
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-name {
|
.cell {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -393,11 +584,8 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-name,
|
.cell-right {
|
||||||
.track-length {
|
text-align: right;
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#context-menu {
|
#context-menu {
|
||||||
@@ -486,6 +674,16 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
override updated(changed: Map<string, unknown>) {
|
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')) {
|
if (changed.has('columnWidths')) {
|
||||||
this.style.setProperty(
|
this.style.setProperty(
|
||||||
'--grid-cols',
|
'--grid-cols',
|
||||||
@@ -511,6 +709,7 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private previousHostWidth = 0;
|
private previousHostWidth = 0;
|
||||||
|
private previousColumnIds = '';
|
||||||
|
|
||||||
private onHostResize() {
|
private onHostResize() {
|
||||||
const newWidth = this.clientWidth;
|
const newWidth = this.clientWidth;
|
||||||
@@ -529,20 +728,8 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const oldTotal = this.columnWidths.reduce(
|
this.columnWidths = this.normalizeWidths(
|
||||||
(sum, w) => sum + w,
|
this.columnWidths,
|
||||||
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.previousHostWidth = newWidth;
|
this.previousHostWidth = newWidth;
|
||||||
@@ -771,7 +958,9 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
index: number,
|
index: number,
|
||||||
): unknown => {
|
): unknown => {
|
||||||
const active = this.isActiveTrack(track);
|
const active = this.isActiveTrack(track);
|
||||||
const selected = this.selection.isSelected(track.FilePath);
|
const selected = this.selection.isSelected(
|
||||||
|
track.FilePath,
|
||||||
|
);
|
||||||
|
|
||||||
const classes = [
|
const classes = [
|
||||||
'track-row',
|
'track-row',
|
||||||
@@ -781,39 +970,49 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
|
const cols = this.activeColumns;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classes}
|
class=${classes}
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@click=${(e: MouseEvent) =>
|
@click=${(e: MouseEvent) =>
|
||||||
this.onTrackRowClick(e, track, index)}
|
this.onTrackRowClick(e, track, index)}
|
||||||
@dblclick=${() => this.onTrackRowDblClick(track)}
|
@dblclick=${() =>
|
||||||
|
this.onTrackRowDblClick(track)}
|
||||||
@contextmenu=${(e: MouseEvent) =>
|
@contextmenu=${(e: MouseEvent) =>
|
||||||
this.onTrackContextMenu(e, track)}
|
this.onTrackContextMenu(e, track)}
|
||||||
@dragstart=${(e: DragEvent) =>
|
@dragstart=${(e: DragEvent) =>
|
||||||
this.onTrackDragStart(e, track)}
|
this.onTrackDragStart(e, track)}
|
||||||
@dragend=${this.onTrackDragEnd}
|
@dragend=${this.onTrackDragEnd}
|
||||||
>
|
>
|
||||||
<div class="track-name">${track.TrackName}</div>
|
${cols.map(
|
||||||
<div class="artist-name">${track.ArtistName}</div>
|
(col) => html`
|
||||||
<div class="track-length">
|
<div class="cell ${col.align === 'right' ? 'cell-right' : ''}">
|
||||||
${formatMilliseconds(track.TrackLength)}
|
${col.accessor(track)}
|
||||||
</div>
|
</div>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
};
|
};
|
||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const visibleTracks = this.filteredTracks;
|
const visibleTracks = this.filteredTracks;
|
||||||
|
const cols = this.activeColumns;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
${this.tracks.length === 0
|
${this.tracks.length === 0
|
||||||
? html`<p>Loading tracks...</p>`
|
? html`<p>Loading tracks...</p>`
|
||||||
: html`
|
: html`
|
||||||
<div class="header-row">
|
<div class="header-row">
|
||||||
<div class="header-cell">Track Name</div>
|
${cols.map(
|
||||||
<div class="header-cell">Artist</div>
|
(col) => html`
|
||||||
<div class="header-cell">Track Length</div>
|
<div class="header-cell ${col.align === 'right' ? 'cell-right' : ''}">
|
||||||
|
${col.label}
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
${visibleTracks.length === 0
|
${visibleTracks.length === 0
|
||||||
? html`<p class="no-results">
|
? html`<p class="no-results">
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export const Events = {
|
|||||||
|
|
||||||
// Config events
|
// Config events
|
||||||
ThemeConfigChanged: "ThemeConfigChanged",
|
ThemeConfigChanged: "ThemeConfigChanged",
|
||||||
|
TrackListConfigChanged: "TrackListConfigChanged",
|
||||||
|
|
||||||
// Library events
|
// Library events
|
||||||
LibraryScanStarted: "LibraryScanStarted",
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -1,5 +1,6 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
import {tracklist} from '../models';
|
||||||
import {context} from '../models';
|
import {context} from '../models';
|
||||||
|
|
||||||
export function GetLibraryDirectory():Promise<string>;
|
export function GetLibraryDirectory():Promise<string>;
|
||||||
@@ -10,6 +11,8 @@ export function GetThemeAccentColor():Promise<string>;
|
|||||||
|
|
||||||
export function GetThemeBackgroundShade():Promise<string>;
|
export function GetThemeBackgroundShade():Promise<string>;
|
||||||
|
|
||||||
|
export function GetTrackListColumns():Promise<Array<tracklist.Column>>;
|
||||||
|
|
||||||
export function Load():Promise<void>;
|
export function Load():Promise<void>;
|
||||||
|
|
||||||
export function Save():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 SetThemeBackgroundShade(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SetTrackListColumns(arg1:Array<tracklist.Column>):Promise<void>;
|
||||||
|
|
||||||
export function Validate():Promise<void>;
|
export function Validate():Promise<void>;
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ export function GetThemeBackgroundShade() {
|
|||||||
return window['go']['config']['Config']['GetThemeBackgroundShade']();
|
return window['go']['config']['Config']['GetThemeBackgroundShade']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetTrackListColumns() {
|
||||||
|
return window['go']['config']['Config']['GetTrackListColumns']();
|
||||||
|
}
|
||||||
|
|
||||||
export function Load() {
|
export function Load() {
|
||||||
return window['go']['config']['Config']['Load']();
|
return window['go']['config']['Config']['Load']();
|
||||||
}
|
}
|
||||||
@@ -46,6 +50,10 @@ export function SetThemeBackgroundShade(arg1) {
|
|||||||
return window['go']['config']['Config']['SetThemeBackgroundShade'](arg1);
|
return window['go']['config']['Config']['SetThemeBackgroundShade'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetTrackListColumns(arg1) {
|
||||||
|
return window['go']['config']['Config']['SetTrackListColumns'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function Validate() {
|
export function Validate() {
|
||||||
return window['go']['config']['Config']['Validate']();
|
return window['go']['config']['Config']['Validate']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ export namespace library {
|
|||||||
FilePath: string;
|
FilePath: string;
|
||||||
TrackNumber: number;
|
TrackNumber: number;
|
||||||
DiscNumber: number;
|
DiscNumber: number;
|
||||||
|
Album: string;
|
||||||
|
Genre: string;
|
||||||
|
Year: number;
|
||||||
|
Composer: string;
|
||||||
|
FileType: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Track(source);
|
return new Track(source);
|
||||||
@@ -106,6 +111,11 @@ export namespace library {
|
|||||||
this.FilePath = source["FilePath"];
|
this.FilePath = source["FilePath"];
|
||||||
this.TrackNumber = source["TrackNumber"];
|
this.TrackNumber = source["TrackNumber"];
|
||||||
this.DiscNumber = source["DiscNumber"];
|
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"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user