Files
yellowjacket/frontend/src/components/config-page/config-page.ts
T
logan ac8f86eb00 fix(settings): give the library selection a home that is not the top bar
library-filter is the only control in the app that calls
setSelectedLibrary, and the phone already hid it with a comment saying
it was "reachable from the drawer's Settings". It was not: Settings adds,
removes, renames and scans libraries, and does not set the view filter,
which is a different thing -- it decides what Albums, Artists and Genres
show. A phone therefore inherited whatever a desktop session last chose
and could neither change nor see it, which is #24's sentence broken in
the band it was written for.

It is a second *placement* of the same component, not a second control,
and it is at every width rather than below 600px. A phone-only copy is
the cheaper answer and is the fault rather than the fix: "where do I
change which library I am browsing" having two answers by viewport is
exactly what one control in two places avoids.

Closes #148
2026-08-20 20:03:08 -04:00

2582 lines
89 KiB
TypeScript

import { LitElement, html, css, nothing } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import { repeat } from 'lit/directives/repeat.js';
import { EventsOn } from '@runtime/runtime';
import type * as explore from '@go/explore/models.js';
import {
AddLibrary,
RenameLibrary,
RemoveLibrary,
GetRemovalImpact,
GetAllLibrariesWithTrackCounts,
ScanLibrary,
ScanAllLibraries,
FullRescan,
} from '@go/library/library.js';
import { jobStore } from '@store/job-store';
import type { Job } from '@store/job-store';
import '@components/jobs/job-panel';
import {
GetScanConcurrency,
SetScanConcurrency,
GetDefaultPage,
SetDefaultPage,
GetQueueFallback,
SetQueueFallback,
GetAllowMeteredCatalogDownload,
SetAllowMeteredCatalogDownload,
GetPopupVolume,
SetPopupVolume,
} from '@go/config/config.js';
import { GetIndexStatus } from '@go/explore/service.js';
import { notificationStore } from '@store/notification-store';
import { describeError, explainError } from '@utils/describe-error';
import type * as library from '@go/library/models.js';
import { ThemeController } from '@store/controllers/theme-controller';
import { TrackListController } from '@store/controllers/tracklist-controller';
import { FavoritesController } from '@store/controllers/favorites-controller';
import { ViewVisibilityController } from '@store/controllers/view-visibility-controller';
import { VIEW_META } from '../../services/view-meta';
import { downloadStore } from '@store/download-store';
import { GetAllPlaylists } from '@go/playlist/service.js';
import type * as playlist from '@go/playlist/models.js';
import { Events } from '../../events';
import {
SHORTCUT_CATEGORIES,
SHORTCUT_META,
} from '../../services/shortcut-meta';
import { ViewLifecycleMixin } from '../../utils/view-lifecycle';
import type { ConfigFieldChangeEvent } from './config-field';
import type { BackgroundShade } from '@store/theme-store';
import type { IconStyle } from '@store/favorites-store';
import {
COLUMN_DEFS,
ALL_COLUMN_IDS,
} from '@components/track-list/columns';
import './config-field';
import './config-section';
// The view filter's home (#148). The same component the top bar
// carries, placed a second time rather than reimplemented -- two
// definitions of "which library am I browsing" is what this is for.
import '@components/library-filter/library-filter';
import './download-clients';
import './shortcut-capture';
import { confirmAction } from '../confirm-dialog/confirm-dialog';
import { shortcutsStore } from '../../store/shortcuts-store';
import { ShortcutsController } from '../../store/controllers/shortcuts-controller';
import { list } from '@utils/binding';
import { pickDirectory } from '../../utils/pick-directory';
const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode';
const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed';
// ===================================================================
// Config page component
// ===================================================================
@customElement('config-page')
export class ConfigPage extends ViewLifecycleMixin(LitElement) {
// --- Theme controller for reading/writing theme state ---
private themeCtrl = new ThemeController(this);
// --- Track-list column config controller ---
private trackListCtrl = new TrackListController(this);
// --- Favorites controller ---
private favCtrl = new FavoritesController(this);
/** Which destinations the navigation offers (#25). */
private viewsCtrl = new ViewVisibilityController(this);
/**
* The job snapshot, for the per-library scan status (#27).
*
* Held as state rather than read from the store in `render()` so
* Lit sees the dependency: the store notifies, and a getter read
* inside a template is not a reactive input.
*/
@state() private jobs: Job[] = [];
/**
* Set between pressing a scan button and the job snapshot that
* proves it started -- `JobsChanged` is coalesced at 250 ms, which
* is long enough for a second click to start a second scan.
*/
@state() private startingScan = false;
private unsubscribeJobs: (() => void) | null = null;
// --- Shortcuts controller ---
private shortcutsCtrl = new ShortcutsController(this);
// --- Now Playing state ---
@state() private scrollMode = 'hover';
/** Whether the ~0.6 GB catalog may be fetched on mobile data. */
@state() private allowMeteredCatalogDownload = false;
// --- Favorites state ---
@state() private playlists: playlist.Summary[] = [];
// --- Library state ---
@state() private libraries: library.Info[] = [];
@state() private editingLibraryId: number | null = null;
@state() private editingName = '';
/** The library a removal is in flight for. The impact is computed
* before the confirmation rather than held here, so this is now
* "which row is busy" and nothing else. */
@state() private removingLibraryId: number | null = null;
@state() private activeMenuId: number | null = null;
@state() private concurrencyMode = 'auto';
@state() private defaultPage = 'home';
@state() private queueFallback = 'favorites';
@state() private popupVolume = false;
@state() private indexStatus: explore.IndexStatus | null = null;
/** Three states, not one: the panel used to say "Loading status…"
* for the entire session, because the only thing that ever set
* `indexStatus` was an event that fires on *change* (errors.M3). */
@state() private indexStatusFailed = false;
@state() private shortcutConflict: {
newAction: string;
newKey: string;
existingAction: string;
} | null = null;
private cancelIndexStatus?: () => void;
private cancelLibraryAdded?: () => void;
private cancelLibraryRenamed?: () => void;
private cancelLibraryRemoved?: () => void;
static override styles = css`
:host {
display: block;
padding: 1.5em;
height: 100%;
box-sizing: border-box;
color: var(--yj-text-primary, #fff);
font-family: system-ui, -apple-system, sans-serif;
overflow-y: auto;
}
h2 {
margin: 0 0 1em;
font-size: 1.4em;
font-weight: 600;
}
/* Button styles */
button {
padding: 0.5em 1.25em;
border: none;
border-radius: 4px;
font-size: 0.85em;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s ease;
white-space: nowrap;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-warning {
background: var(--yj-warning, #e8590c);
color: var(--yj-warning-fg, #fff);
}
.btn-warning:hover:not(:disabled) {
background: var(--yj-warning-hover, #d9480f);
}
.btn-danger {
background: var(--yj-error, #e03131);
color: var(--yj-error-fg, #fff);
}
.btn-danger:hover:not(:disabled) {
background: var(--yj-error-hover, #c92a2a);
}
.btn-success {
background: var(--yj-success, #2f9e44);
color: var(--yj-success-fg, #fff);
}
.btn-success:hover:not(:disabled) {
background: var(--yj-success-hover, #2b8a3e);
}
.btn-ghost {
background: transparent;
color: var(--yj-text-tertiary, #868e96);
padding: 0.3em 0.75em;
font-size: 0.75em;
border: 1px solid var(--yj-border, #444);
}
.btn-ghost:hover:not(:disabled) {
background: var(--yj-bg-overlay, #495057);
color: var(--yj-text-primary, #fff);
}
.btn-ghost.copied {
border-color: var(--yj-success, #2f9e44);
color: var(--yj-success-text, #51cf66);
}
/* Scan actions */
.scan-actions {
display: flex;
gap: 0.75em;
flex-wrap: wrap;
}
/* #148, and the second half of #57.
library-filter is the only control in the app that calls
setSelectedLibrary, and it lived in the top bar -- which
#57 takes out of the layout on a phone, and which #143
already refused to hide as a fit step precisely because
hiding it takes away an action. So the selection gets a home
that does not depend on that bar existing.
At every width, not below 600px: a phone-only copy would be
a second place the control lives, and "where do I change
which library I am browsing" having two answers by size is
the fault, not the fix. */
.library-scope {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1em;
flex-wrap: wrap;
margin-bottom: 1em;
}
.library-scope .scope-label {
font-weight: 600;
font-size: 0.85em;
color: var(--yj-text-primary, #fff);
display: block;
}
.library-scope .scope-description {
font-size: 0.75em;
color: var(--yj-text-tertiary, #888);
margin: 0.35em 0 0;
max-width: 40em;
}
.save-row {
display: flex;
gap: 0.5em;
margin-top: 0.5em;
}
/* Status bar */
.status-bar {
margin-top: 1em;
padding: 0.75em 1em;
background: var(--yj-bg-elevated, #343a40);
border-radius: 4px;
font-size: 0.85em;
color: var(--yj-text-tertiary, #868e96);
min-height: 1.2em;
}
.status-bar.active {
color: var(--yj-accent-text, #ffd43b);
}
/* Progress bar */
.progress-info {
display: flex;
align-items: baseline;
gap: 0.5em;
margin-bottom: 0.5em;
}
.progress-label {
font-weight: 500;
}
.progress-detail {
color: var(--yj-text-tertiary, #868e96);
font-size: 0.95em;
}
.progress-percent {
margin-left: auto;
font-variant-numeric: tabular-nums;
}
.progress-phase {
font-weight: 500;
}
.progress-track {
height: 6px;
background: var(--yj-bg-base, #1a1b1e);
border-radius: 3px;
overflow: hidden;
}
.progress-fill {
height: 100%;
background: var(--yj-accent, #ffd43b);
border-radius: 3px;
transition: width 300ms ease;
}
/* Error block */
.error-block {
margin-top: 1em;
border: 1px solid var(--yj-error, #e03131);
border-radius: 4px;
overflow: hidden;
}
.error-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5em 1em;
background: color-mix(
in srgb,
var(--yj-error, #e03131) 15%,
var(--yj-bg-elevated, #343a40)
);
}
.error-title {
font-size: 0.8em;
font-weight: 600;
color: var(--yj-error-text, #ff8787);
}
.error-body {
max-height: 200px;
overflow-y: auto;
padding: 0.75em 1em;
background: var(--yj-bg-elevated, #343a40);
}
.error-body pre {
margin: 0;
font-size: 0.8em;
font-family: inherit;
white-space: pre-wrap;
word-break: break-word;
color: var(--yj-text-secondary, #adb5bd);
line-height: 1.6;
}
/* Metrics tree */
.metrics-wrapper {
margin-top: 1em;
}
.metrics-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5em;
}
.metrics-title {
margin: 0;
font-size: 0.95em;
font-weight: 600;
color: var(--yj-text-primary, #fff);
}
details {
margin-left: 1em;
}
details.root {
margin-left: 0;
}
summary {
cursor: pointer;
padding: 0.25em 0;
font-size: 0.85em;
color: var(--yj-text-secondary, #b3b3b3);
list-style: none;
}
summary::-webkit-details-marker {
display: none;
}
summary::before {
content: '\\25B6';
display: inline-block;
width: 1em;
font-size: 0.6em;
vertical-align: middle;
transition: transform 0.15s ease;
margin-right: 0.35em;
}
details[open] > summary::before {
transform: rotate(90deg);
}
.metric-row {
display: flex;
justify-content: space-between;
padding: 0.2em 0;
padding-left: 1.35em;
font-size: 0.85em;
}
.metric-label {
color: var(--yj-text-secondary, #adb5bd);
}
.metric-value {
color: var(--yj-text-primary, #e9ecef);
font-family: monospace;
font-weight: 500;
}
.metric-value.highlight {
color: var(--yj-accent-text, #ffd43b);
}
.metric-note {
color: var(--yj-text-tertiary, #868e96);
font-size: 0.75em;
font-style: italic;
padding-left: 1.35em;
}
.counts-grid {
display: grid;
grid-template-columns: repeat(4, auto);
gap: 0.25em 1.5em;
padding-left: 1.35em;
font-size: 0.85em;
}
.count-label {
color: var(--yj-text-secondary, #adb5bd);
}
.count-value {
color: var(--yj-text-primary, #e9ecef);
font-family: monospace;
}
/* Theme preview */
.color-preview {
display: flex;
gap: 0.5em;
margin-top: 0.75em;
flex-wrap: wrap;
}
.swatch {
width: 2em;
height: 2em;
border-radius: 4px;
border: 1px solid var(--yj-border, #444);
}
.swatch-label {
font-size: 0.7em;
text-align: center;
color: var(--yj-text-tertiary, #888);
margin-top: 0.2em;
}
.swatch-group {
display: flex;
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;
}
.view-note {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-font-size-sm, 0.85rem);
margin-left: auto;
}
.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
);
}
.btn-primary {
background: var(
--yj-accent,
#ffd43b
);
color: var(--yj-accent-fg, #000);
font-weight: 600;
}
.btn-primary:hover:not(:disabled) {
filter: brightness(1.1);
}
.status-bar.paused {
color: var(--yj-accent-text, #ffd43b);
}
/* Library management */
.library-list {
display: flex;
flex-direction: column;
gap: 0;
}
.library-header {
display: flex;
align-items: center;
gap: 0.75em;
padding: 0.4em 0.5em;
border-bottom: 1px solid var(--yj-border-subtle, #333);
font-size: var(--yj-text-sm, 0.8em);
color: var(--yj-text-muted, #999);
}
.library-header-label {
user-select: none;
}
.library-checkbox {
cursor: pointer;
flex-shrink: 0;
}
.library-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5em 0.75em;
padding: 0.6em 0.5em;
border-bottom: 1px solid var(--yj-border-subtle, #333);
}
.library-row:last-child {
border-bottom: none;
}
.library-row:hover {
background: var(--yj-bg-elevated, #343a40);
border-radius: 4px;
}
.library-row .inline-progress {
flex-basis: 100%;
padding-left: 1.75em;
}
.library-row .inline-progress .progress-track {
margin-top: 0.25em;
}
.library-scan-status {
font-size: 0.75em;
font-weight: 400;
color: var(--yj-accent-text, #ffd43b);
white-space: nowrap;
}
.library-name {
flex: 1;
cursor: pointer;
font-size: 0.9em;
font-weight: 500;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.library-name:hover {
color: var(--yj-accent-text, #ffd43b);
}
.library-path {
color: var(--yj-text-tertiary, #868e96);
font-size: var(--yj-text-sm, 13px);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 300px;
}
.library-count {
color: var(--yj-text-tertiary, #868e96);
font-size: var(--yj-text-sm, 13px);
white-space: nowrap;
flex-shrink: 0;
}
.edit-input {
flex: 1;
padding: 0.3em 0.5em;
background: var(--yj-bg-elevated, #343a40);
border: 1px solid var(--yj-accent, #ffd43b);
border-radius: 4px;
color: var(--yj-text-primary, #fff);
font-size: 0.9em;
font-family: inherit;
outline: none;
}
.overflow-wrapper {
position: relative;
flex-shrink: 0;
}
.overflow-btn {
cursor: pointer;
border: none;
background: transparent;
color: var(--yj-text-tertiary, #868e96);
font-size: 1.1em;
padding: 0.2em 0.4em;
letter-spacing: 2px;
border-radius: 4px;
}
.overflow-btn:hover {
background: var(--yj-bg-overlay, #495057);
color: var(--yj-text-primary, #fff);
}
.overflow-menu {
position: absolute;
top: 100%;
right: 0;
background: var(--yj-bg-surface, #2a2a2a);
border: 1px solid var(--yj-border, #444);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 100;
min-width: 120px;
padding: 4px 0;
}
.overflow-item {
padding: 0.5em 1em;
font-size: var(--yj-text-sm, 13px);
cursor: pointer;
white-space: nowrap;
}
.overflow-item:hover {
background: var(--yj-bg-elevated, #343a40);
}
.overflow-item--danger {
color: var(--yj-error-text, #ff8787);
}
.overflow-item--danger:hover {
background: color-mix(
in srgb,
var(--yj-error, #e03131) 10%,
var(--yj-bg-elevated, #343a40)
);
}
.spinner {
display: inline-block;
width: 14px;
height: 14px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 0.6s linear infinite;
vertical-align: middle;
margin-right: 4px;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* Keyboard shortcuts section */
.shortcut-category {
margin-bottom: 16px;
}
.shortcut-category-header {
font-size: var(--yj-text-sm, 13px);
font-weight: 600;
color: var(--yj-text-secondary, #aaa);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 8px;
padding-bottom: 4px;
border-bottom: 1px solid
var(--yj-border, #444);
}
.shortcut-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 0;
gap: 16px;
}
.shortcut-label {
font-size: var(--yj-text-sm, 13px);
color: var(--yj-text-primary, #eee);
}
.shortcut-scope {
font-size: var(--yj-text-xs, 11px);
color: var(
--yj-text-tertiary,
#888
);
margin-left: 4px;
}
.shortcut-actions {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
.conflict-banner {
margin-top: 12px;
padding: 12px;
background: rgba(255, 165, 0, 0.1);
border: 1px solid
rgba(255, 165, 0, 0.4);
border-radius: 6px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.conflict-text {
font-size: var(--yj-text-sm, 13px);
}
.conflict-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* Search index status */
.index-status {
padding: 0 0.25em 0.5em;
}
.index-stats {
display: flex;
align-items: center;
gap: 0.5em;
font-size: var(--yj-text-sm);
color: var(--yj-text-secondary, #aaa);
margin-bottom: 1em;
font-variant-numeric: tabular-nums;
}
.index-stat-sep {
opacity: 0.4;
}
.index-tiers {
display: flex;
flex-direction: column;
gap: 0.5em;
}
.index-tier {
display: flex;
align-items: center;
gap: 0.6em;
font-size: var(--yj-text-sm);
}
.tier-icon {
width: 1.2em;
text-align: center;
flex-shrink: 0;
}
.tier-name {
color: var(--yj-text-primary, #fff);
}
.tier-progress {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-xs, 11px);
font-variant-numeric: tabular-nums;
}
.tier-detail {
color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-xs, 11px);
font-variant-numeric: tabular-nums;
}
.tier-error {
color: var(--yj-accent-error, #f44);
font-size: var(--yj-text-xs, 11px);
}
.index-ready {
margin-top: 1em;
font-size: var(--yj-text-sm);
color: var(--yj-accent-success, #4a4);
}
.index-waiting, .index-loading {
font-size: var(--yj-text-sm);
color: var(--yj-text-tertiary, #888);
}
.index-status-failed {
align-items: center;
color: var(--yj-text-secondary, #b3b3b3);
display: flex;
font-size: var(--yj-text-sm);
gap: 0.5em;
}
.index-status-failed .link {
background: none;
border: none;
color: var(--yj-accent-text, #ffd43b);
cursor: pointer;
font: inherit;
padding: 0;
text-decoration: underline;
}
`;
// ===================================================================
// LIFECYCLE
// ===================================================================
protected override onViewActivate(): void {
void this.loadLibraries();
void this.loadPlaylists();
this.scrollMode =
localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover';
// Scanning is started and watched here (#27), so the job
// snapshot is a live input to this page.
this.unsubscribeJobs = jobStore.subscribe(() => {
this.jobs = jobStore.jobs;
});
this.jobs = jobStore.jobs;
void jobStore.init();
this.cancelLibraryAdded = EventsOn(
Events.LibraryAdded,
() => void this.loadLibraries(),
);
this.cancelLibraryRenamed = EventsOn(
Events.LibraryRenamed,
() => void this.loadLibraries(),
);
this.cancelLibraryRemoved = EventsOn(
Events.LibraryRemoved,
() => void this.loadLibraries(),
);
this.listenWhileActive(document, 'click', this.handleDocumentClick);
// Listen for index status events (pushed from Go, no binding calls).
this.cancelIndexStatus = EventsOn(
Events.IndexStatusChanged,
(status: explore.IndexStatus) => {
this.indexStatus = status;
this.indexStatusFailed = false;
},
);
// …and pull the current one, because that event only fires when
// a build *changes* state, and the steady state is no build.
void this.loadIndexStatus();
}
protected override onViewDeactivate(): void {
this.unsubscribeJobs?.();
this.unsubscribeJobs = null;
this.cancelLibraryAdded?.();
this.cancelLibraryRenamed?.();
this.cancelLibraryRemoved?.();
this.cancelIndexStatus?.();
}
private async loadLibraries(): Promise<void> {
try {
const [
libs,
mode,
defaultPage,
queueFallback,
allowMetered,
popupVolume,
] = await Promise.all([
GetAllLibrariesWithTrackCounts(),
GetScanConcurrency(),
GetDefaultPage(),
GetQueueFallback(),
GetAllowMeteredCatalogDownload(),
GetPopupVolume(),
]);
this.libraries = libs ?? [];
this.concurrencyMode = mode;
this.defaultPage = defaultPage;
this.queueFallback = queueFallback;
this.allowMeteredCatalogDownload = allowMetered;
this.popupVolume = popupVolume;
} catch (err) {
console.error(
'Failed to load libraries:',
err,
);
}
}
// ===================================================================
// LIBRARY HANDLERS
// ===================================================================
private handleAddLibrary = async (): Promise<void> => {
let dir = '';
try {
dir = (await pickDirectory()) ?? '';
if (!dir) return;
await AddLibrary(dir);
} catch (err) {
console.error('Failed to add library:', err);
notificationStore.persistent({
key: 'library-add',
title: 'Library not added',
text: explainError(
err,
'That folder could not be added as a library.',
),
detail: dir,
action: {
label: 'Try again',
run: () => void this.handleAddLibrary(),
},
});
}
};
private handleStartRename = (id: number, name: string): void => {
this.editingLibraryId = id;
this.editingName = name;
this.activeMenuId = null;
};
private handleRenameKeyDown = async (e: KeyboardEvent): Promise<void> => {
if (e.key === 'Enter') {
e.preventDefault();
if (this.editingLibraryId !== null && this.editingName.trim()) {
const name = this.editingName.trim();
try {
await RenameLibrary(this.editingLibraryId, name);
} catch (err) {
console.error('Failed to rename library:', err);
notificationStore.persistent({
key: 'library-rename',
title: 'Library not renamed',
text: explainError(
err,
`“${name}” could not be used as a library name.`,
),
detail: String(err),
});
}
}
this.editingLibraryId = null;
this.editingName = '';
} else if (e.key === 'Escape') {
this.editingLibraryId = null;
this.editingName = '';
}
};
private handleRenameInput = (e: InputEvent): void => {
this.editingName = (e.target as HTMLInputElement).value;
};
/**
* Say what will happen, then ask — through the one confirmation the
* app has, which is a `wa-dialog` and so brings the focus trap, the
* Escape handler and the focus restore the hand-rolled overlay this
* replaces had none of (a11y.16).
*/
private handleRemoveClick = async (id: number): Promise<void> => {
this.activeMenuId = null;
const lib = this.libraries.find((l) => l.id === id);
const libName = lib?.name ?? 'Library';
let impact: library.RemovalImpact | null = null;
try {
impact = await GetRemovalImpact(id);
} catch (err) {
// Asking without the impact is worse than not asking at all,
// so this is a failure the user has to see rather than a
// confirmation with a blank consequence.
console.error('Failed to get removal impact:', err);
notificationStore.persistent({
key: 'library-remove',
title: 'Library not removed',
text: `Could not work out what removing “${libName}” would delete. ${describeError(err)}`,
detail: String(err),
});
return;
}
const ok = await confirmAction({
title: 'Remove library',
message: `Remove “${libName}”?`,
impact: `This deletes ${impact?.trackCount ?? 0} tracks, affects `
+ `${impact?.playlistsAffected ?? 0} playlists and removes `
+ `${impact?.queueItemCount ?? 0} queue items.`,
confirmLabel: 'Remove',
danger: true,
});
if (!ok) return;
await this.removeLibrary(id, libName);
};
private async removeLibrary(id: number, libName: string): Promise<void> {
this.removingLibraryId = id;
try {
const summary = await RemoveLibrary(id);
this.removingLibraryId = null;
notificationStore.transient({
tone: 'success',
key: 'library-remove',
text: `Removed “${libName}” — ${summary?.tracksDeleted ?? 0} tracks deleted.`,
});
void this.loadLibraries();
} catch (err) {
this.removingLibraryId = null;
console.error('Failed to remove library:', err);
notificationStore.persistent({
key: 'library-remove',
title: 'Library not removed',
text: `Could not remove “${libName}”. ${describeError(err)}`,
detail: String(err),
});
}
}
private toggleOverflowMenu = (id: number, e: Event): void => {
e.stopPropagation();
this.activeMenuId = this.activeMenuId === id ? null : id;
};
private handleDocumentClick = (): void => {
if (this.activeMenuId !== null) {
this.activeMenuId = null;
}
if (this.editingLibraryId !== null) {
this.editingLibraryId = null;
this.editingName = '';
}
};
private async loadIndexStatus(): Promise<void> {
try {
this.indexStatus = await GetIndexStatus();
this.indexStatusFailed = false;
} catch (err) {
console.error('Failed to read index status:', err);
this.indexStatusFailed = true;
}
}
// ===================================================================
// SCANNING (#27 — back from the Jobs tab)
// ===================================================================
/** The scan job for a library, if one is registered. */
private jobForLibrary(id: number): Job | undefined {
return this.jobs.find((job) => job.id === `scan:${id}`);
}
/** The status line under a library name while it is being scanned. */
private libraryScanStatus(id: number): string | null {
const job = this.jobForLibrary(id);
if (!job) return null;
switch (job.state) {
case 'running':
return job.phase ? `Scanning · ${job.phase}` : 'Scanning';
case 'queued':
return 'Queued';
case 'paused':
return 'Paused';
case 'pausing':
return 'Pausing…';
case 'cancelling':
return 'Stopping…';
default:
return null;
}
}
private get anyScanning(): boolean {
return this.libraries.some(
(lib) => this.libraryScanStatus(lib.id) !== null,
);
}
/**
* Run something that starts a job, holding the buttons until the
* snapshot lands and saying so when it does not start at all.
*
* Persistent, not a toast: the user asked for work to happen, it
* did not, and retrying is exactly the useful response.
*/
private async startJob(
what: string,
start: () => Promise<unknown>,
retry: () => void,
): Promise<void> {
if (this.startingScan) return;
this.startingScan = true;
try {
await start();
} catch (err) {
console.error(`${what} failed:`, err);
notificationStore.persistent({
key: 'scan-start',
title: 'Scan did not start',
text: `${what} failed. ${describeError(err)}`,
detail: String(err),
action: { label: 'Try again', run: retry },
});
} finally {
this.startingScan = false;
}
}
private handleScanLibrary = (id: number): void => {
this.activeMenuId = null;
void this.startJob(
'Scanning that library',
() => ScanLibrary(id),
() => this.handleScanLibrary(id),
);
};
private handleScanAll = (): void => {
void this.startJob(
'Scanning your libraries',
() => ScanAllLibraries(),
() => this.handleScanAll(),
);
};
private handleFullRescan = async (): Promise<void> => {
const ok = await confirmAction({
title: 'Full rescan',
message:
'This deletes all library data — including downloaded '
+ 'cover art — and rebuilds it from your files.',
impact:
'It is not the same as “Scan now”, which only picks up '
+ 'what changed.',
confirmLabel: 'Rebuild everything',
danger: true,
});
if (!ok) return;
await this.startJob(
'The full rescan',
() => FullRescan(),
() => void this.handleFullRescan(),
);
};
private handleViewToggle = (
view: string,
visible: boolean,
): void => {
this.viewsCtrl
.setVisible(view, visible)
.catch((err: unknown) => {
console.error('Failed to save view visibility:', err);
notificationStore.transient({
key: 'view-visibility',
text: `Could not change which views are shown. ${describeError(err)}`,
detail: String(err),
});
// The checkbox has already flipped itself; the store is
// the truth, so redraw from it.
this.requestUpdate();
});
};
private handleDefaultPageChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const page = String(e.detail.value);
SetDefaultPage(page)
.then(() => {
this.defaultPage = page;
notificationStore.transient({
tone: 'success',
key: 'default-page',
text: 'Launch page saved.',
});
})
.catch((err: unknown) => {
console.error('Failed to save launch page:', err);
notificationStore.transient({
key: 'default-page',
text: `Could not save the launch page. ${describeError(err)}`,
detail: String(err),
});
});
};
private handleQueueFallbackChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const mode = String(e.detail.value);
SetQueueFallback(mode)
.then(() => {
this.queueFallback = mode;
notificationStore.transient({
tone: 'success',
key: 'queue-fallback',
text: 'Queue fallback saved.',
});
})
.catch((err: unknown) => {
console.error('Failed to save queue fallback:', err);
notificationStore.transient({
key: 'queue-fallback',
text: `Could not save the queue fallback. ${describeError(err)}`,
detail: String(err),
});
});
};
private handleConcurrencyChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const mode = String(e.detail.value);
SetScanConcurrency(mode)
.then(() => {
this.concurrencyMode = mode;
notificationStore.transient({
tone: 'success',
key: 'storage-type',
text: 'Storage type saved. Takes effect on next scan.',
});
})
.catch((err: unknown) => {
console.error('Failed to save storage type:', err);
notificationStore.transient({
key: 'storage-type',
text: `Could not save the storage type. ${describeError(err)}`,
detail: String(err),
});
});
};
// ===================================================================
// THEME HANDLERS
// ===================================================================
private handleAccentChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
this.themeCtrl
.setAccentColor(String(e.detail.value))
.catch((err: unknown) => {
console.error(
'Failed to set accent color:',
err,
);
});
};
private handleShadeChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
this.themeCtrl
.setBackgroundShade(
String(
e.detail.value,
) as BackgroundShade,
)
.catch((err: unknown) => {
console.error(
'Failed to set background shade:',
err,
);
});
};
// ===================================================================
// FAVORITES HANDLERS
// ===================================================================
private async loadPlaylists(): Promise<void> {
try {
this.playlists = await list(GetAllPlaylists());
} catch (err) {
console.error(
'Failed to load playlists:',
err,
);
}
}
private handleFavIconStyleChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const style = String(
e.detail.value,
) as IconStyle;
this.favCtrl
.setIconStyle(style)
.catch((err: unknown) => {
console.error(
'Failed to set icon style:',
err,
);
});
};
private handleFavPlaylistChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const id = Number(e.detail.value);
if (Number.isNaN(id)) return;
this.favCtrl
.setDefaultPlaylist(id)
.catch((err: unknown) => {
console.error(
'Failed to set default playlist:',
err,
);
});
};
private handlePinDefaultChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const pin = Boolean(e.detail.value);
this.favCtrl
.setPinDefault(pin)
.catch((err: unknown) => {
console.error(
'Failed to set pin default:',
err,
);
});
};
// ===================================================================
// KEYBOARD SHORTCUTS HANDLERS
// ===================================================================
private async handleShortcutChange(
e: CustomEvent<{ action: string; key: string }>,
) {
const { action, key } = e.detail;
// Check for conflict — find any other action with the same key in the same or overlapping scope
const meta = SHORTCUT_META[action];
const conflict = shortcutsStore.findConflict(
key,
meta?.scope ?? 'global',
action,
);
if (conflict) {
// Show conflict warning
this.shortcutConflict = {
newAction: action,
newKey: key,
existingAction: conflict.action,
};
return;
}
// No conflict — save directly
await shortcutsStore.updateBinding(action, key);
}
private async handleConflictOverwrite() {
if (!this.shortcutConflict) return;
const { newAction, newKey, existingAction } =
this.shortcutConflict;
// Unbind the existing action
await shortcutsStore.updateBinding(
existingAction,
'',
);
// Set the new binding
await shortcutsStore.updateBinding(
newAction,
newKey,
);
this.shortcutConflict = null;
}
private handleConflictCancel() {
this.shortcutConflict = null;
}
private async handleResetAllShortcuts() {
await shortcutsStore.resetAll();
}
// ===================================================================
// NOW PLAYING HANDLERS
// ===================================================================
private handleScrollModeChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const mode = String(e.detail.value);
this.scrollMode = mode;
localStorage.setItem(SCROLL_STORAGE_KEY, mode);
window.dispatchEvent(
new CustomEvent(SCROLL_CHANGE_EVENT),
);
};
// ===================================================================
// 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,
);
});
};
// ===================================================================
// RENDER
// ===================================================================
override render() {
return html`
<h2>Settings</h2>
<!--
Ordered by how often a setting is *reached*, not by how
the sections were written (H-22). Libraries was last and
below the fold while Search Index — which is configured
once, if ever — was first and the only expanded one.
-->
${this.renderLibrarySection()}
${this.renderGeneralSection()}
${this.renderNavigationSection()}
${this.renderNowPlayingSection()}
${this.renderThemeSection()}
${this.renderTrackListSection()}
${this.renderFavoritesSection()}
${this.renderShortcutsSection()}
${this.renderSearchSection()}
<download-clients></download-clients>
`;
}
// --- Search / Index section ---
private renderSearchSection() {
const s = this.indexStatus;
return html`
<config-section
heading="Search Index"
description="The explore search index is built from the MusicBrainz/ListenBrainz data dumps — popular artists, albums, and tracks with listen counts — for fast offline search."
>
<div class="index-status">
${s
? html`
<div class="index-stats">
<span class="index-stat">${this.formatCount(s.artists)} artists</span>
<span class="index-stat-sep">·</span>
<span class="index-stat">${this.formatCount(s.recordings)} recordings</span>
<span class="index-stat-sep">·</span>
<span class="index-stat">${this.formatCount(s.releaseGroups)} albums</span>
<span class="index-stat-sep">·</span>
<span class="index-stat">${this.formatCount(s.totalRows)} total</span>
${s.lastBuilt
? html`<span class="index-stat-sep">·</span>
<span class="index-stat">updated ${this.timeAgo(s.lastBuilt)}</span>`
: nothing}
</div>
${(s.tiers?.length ?? 0) > 0 && (s.tiers ?? []).some((t) => t.state === 'running' || t.state === 'pending' || t.state === 'error')
? html`
<div class="index-tiers">
${(s.tiers ?? []).map(
(t) => html`
<div class="index-tier">
<span class="tier-icon">${this.tierIcon(t.state)}</span>
<span class="tier-name">${t.name}</span>
${t.state === 'running' && t.total > 0
? html`<span class="tier-progress">${t.completed}/${t.total}</span>`
: nothing}
${t.state === 'running' && t.detail
? html`<span class="tier-detail">${t.detail}</span>`
: nothing}
${t.state === 'error'
? html`<span class="tier-error">${describeError(t.error, 'This part of the index could not be built.')}</span>`
: nothing}
</div>
`,
)}
</div>
`
: nothing}
${!s.building && s.ready
? html`<div class="index-ready">Index ready</div>`
: !s.building && !s.ready && s.totalRows === 0
? html`<div class="index-waiting">Index empty — build will start after library scan</div>`
: !s.building && !s.ready
? html`<div class="index-waiting">Waiting for index build…</div>`
: nothing}
`
: this.indexStatusFailed
? html`<div class="index-status-failed">
<span>The index status could not be read.</span>
<button
type="button"
class="link"
@click=${() => void this.loadIndexStatus()}
>
Retry
</button>
</div>`
: html`<div class="index-loading">Loading status…</div>`}
</div>
<config-field
.schema=${{
key: 'allowMeteredCatalogDownload',
label: 'Download the catalog on mobile data',
description:
'The catalog is about 0.6 GB. It is skipped on a '
+ 'cellular connection unless this is on; a '
+ 'metered Wi-Fi network cannot be detected.',
type: 'toggle' as const,
}}
.value=${this.allowMeteredCatalogDownload}
@config-change=${this.handleAllowMeteredChange}
></config-field>
<!--
The tier list above says what the build is *doing*;
this says it is a job, and gives it the pause, cancel
and log the tier list never had (#27). Cancelling one
still asks first — that confirmation is inside
applyJobControl, keyed on the kind, which is why
this embeds the shared rows rather than drawing its
own.
-->
<job-panel
kinds="index-build,catalog-enrich"
heading="Index jobs"
></job-panel>
</config-section>
`;
}
/**
* The catalog download's one permission (plan 016 B4).
*
* It is in this section rather than General because it is about
* *this* download and nothing else, and because the section already
* explains what the catalog is — the toggle would be unreadable
* beside "Default page".
*/
private handleAllowMeteredChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const allow = Boolean(e.detail.value);
const previous = this.allowMeteredCatalogDownload;
this.allowMeteredCatalogDownload = allow;
void SetAllowMeteredCatalogDownload(allow).catch((err: unknown) => {
console.error('failed to save metered download permission', err);
// The visible state reverted, so this is the Transient case:
// a small action the user can simply repeat.
this.allowMeteredCatalogDownload = previous;
notificationStore.transient({
key: 'metered-catalog-setting',
title: 'Setting not saved',
text: describeError(err, 'That setting could not be saved.'),
});
});
};
private tierIcon(state: string): string {
switch (state) {
case 'complete':
case 'skipped':
return '✅';
case 'running':
return '🔄';
case 'error':
return '❌';
case 'pending':
default:
return '⏳';
}
}
private formatCount(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return `${n}`;
}
private timeAgo(iso: string): string {
const then = new Date(iso).getTime();
if (!then) return '';
const seconds = Math.floor((Date.now() - then) / 1000);
if (seconds < 60) return 'just now';
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days === 1) return 'yesterday';
return `${days}d ago`;
}
// --- Now Playing section ---
private renderNowPlayingSection() {
return html`
<config-section
heading="Now Playing"
description="Configure the now-playing display in the bottom bar."
>
<config-field
.schema=${{
key: 'scrollMode',
label: 'Text Scroll Behaviour',
description:
'How overflowing track title and artist text is handled when it exceeds the available width.',
type: 'select' as const,
options: [
{
value: 'hover',
label: 'Scroll on Hover (Default)',
},
{
value: 'always',
label: 'Always Scroll',
},
{
value: 'never',
label: 'Never (Ellipsis)',
},
],
}}
.value=${this.scrollMode}
@config-change=${this.handleScrollModeChange}
></config-field>
</config-section>
`;
}
// --- General section ---
private renderGeneralSection() {
return html`
<config-section
heading="General"
description="General app behaviour."
>
<config-field
.schema=${{
key: 'defaultPage',
label: 'Launch Page',
description:
'The page the app opens to on launch.',
type: 'select' as const,
// Derived, not written out: this list was a
// second copy of the launchable set, and #27
// removing a destination is exactly the change
// that would have left the two disagreeing.
// Settings is excluded because it is the one
// view the backend refuses to launch into.
options: VIEW_META
.filter((v) => v.alwaysShown !== true)
.map((v) => ({ value: v.id, label: v.label })),
}}
.value=${this.defaultPage}
@config-change=${this.handleDefaultPageChange}
></config-field>
<config-field
.schema=${{
key: 'queueFallback',
label: 'When the Queue Ends',
description:
'What plays, if anything, once the queue runs out.',
type: 'select' as const,
options: [
{ value: 'favorites', label: 'Play Favorites' },
{ value: 'dynamicMix', label: 'Start a Dynamic Mix' },
{ value: 'stop', label: 'Stop' },
],
}}
.value=${this.queueFallback}
@config-change=${this.handleQueueFallbackChange}
></config-field>
<config-field
.schema=${{
key: 'popupVolume',
label: 'Volume opens in a popup',
description:
'Off, the volume slider is always visible in the '
+ 'player bar. On, it hides behind the speaker '
+ 'icon. The slider stands down on a phone either '
+ 'way, where the hardware keys own volume.',
type: 'toggle' as const,
}}
.value=${this.popupVolume}
@config-change=${this.handlePopupVolumeChange}
></config-field>
</config-section>
`;
}
/**
* The volume control's presentation (#42).
*
* In General rather than beside the theme because it is about the
* transport's behaviour rather than its colours, and next to "When
* the Queue Ends" because both are answers to "how should the
* player behave".
*/
private handlePopupVolumeChange = (
e: CustomEvent<ConfigFieldChangeEvent>,
): void => {
const popup = Boolean(e.detail.value);
const previous = this.popupVolume;
this.popupVolume = popup;
void SetPopupVolume(popup).catch((err: unknown) => {
console.error('failed to save the volume control setting', err);
this.popupVolume = previous;
notificationStore.transient({
key: 'popup-volume-setting',
title: 'Setting not saved',
text: describeError(err, 'That setting could not be saved.'),
});
});
};
// --- Navigation section ---
/**
* Which destinations the sidebar and the phone's tab bar offer.
*
* Two items are drawn but not editable, and both say why in place
* rather than being silently inert. Settings is never hideable --
* the backend refuses it too, because `config.toml` is
* hand-editable. The launch page is not hideable *while it is the
* launch page*, which is a state the user can leave by changing the
* launch page above; refusing is preferable to the alternatives,
* since resetting their launch page silently changes a second thing
* they chose and allowing it lands the app on a page nothing points
* at.
*/
private renderNavigationSection() {
return html`
<config-section
heading="Navigation"
description="Choose which destinations the sidebar and the phone's tab bar offer. Hiding one does not remove it — links and the launch page still open it."
>
<ul class="column-list">
${repeat(VIEW_META, (v) => v.id, (v) => {
const checked = this.viewsCtrl.enabled(v.id);
const isLaunchPage = this.defaultPage === v.id;
const locked = v.alwaysShown === true || isLaunchPage;
let note = '';
if (v.alwaysShown === true) {
note = 'Always shown.';
} else if (isLaunchPage) {
note = 'This is the launch page.';
} else if (
v.id === 'downloads' &&
checked &&
!downloadStore.available
) {
// The config says show it and the nav does not, which
// would otherwise read as the checkbox not working.
note = 'Hidden until a download client is configured.';
}
return html`
<li
class="column-item ${checked ? 'enabled' : 'disabled'}"
>
<input
type="checkbox"
class="column-toggle"
aria-label="Show ${v.label} in the navigation"
.checked=${checked}
?disabled=${locked}
@change=${(e: Event) =>
this.handleViewToggle(
v.id,
(e.target as HTMLInputElement).checked,
)}
/>
<span class="column-label">
${v.label}
</span>
${note
? html`<span class="view-note">${note}</span>`
: nothing}
</li>
`;
})}
</ul>
</config-section>
`;
}
// --- Theme section ---
private renderThemeSection() {
return html`
<config-section
heading="Theme"
description="Customise the app's colour scheme."
>
<config-field
.schema=${{
key: 'accentColor',
label: 'Accent Colour',
description:
'The primary highlight colour used for active items, buttons, and indicators.',
type: 'color' as const,
}}
.value=${this.themeCtrl.accentColor}
@config-change=${this.handleAccentChange}
></config-field>
<config-field
.schema=${{
key: 'backgroundShade',
label: 'Background Shade',
description:
'Controls the overall brightness of the interface.',
type: 'select' as const,
options: [
{
value: 'darker',
label: 'Darker (OLED)',
},
{
value: 'dark',
label: 'Dark (Default)',
},
{
value: 'light',
label: 'Light',
},
],
}}
.value=${this.themeCtrl
.backgroundShade}
@config-change=${this.handleShadeChange}
></config-field>
<div class="color-preview">
${this.renderSwatches()}
</div>
</config-section>
`;
}
private renderSwatches() {
const swatches = [
{ label: 'Base', var: '--yj-bg-base' },
{
label: 'Surface',
var: '--yj-bg-surface',
},
{
label: 'Elevated',
var: '--yj-bg-elevated',
},
{
label: 'Overlay',
var: '--yj-bg-overlay',
},
{ label: 'Accent', var: '--yj-accent' },
];
return swatches.map(
(s) => html`
<div class="swatch-group">
<div
class="swatch"
style="background: var(${s.var})"
></div>
<span class="swatch-label">
${s.label}
</span>
</div>
`,
);
}
// --- Favorites section ---
private renderFavoritesSection() {
const playlistOptions =
this.playlists.map((p) => ({
value: String(p.ID),
label: p.Name,
}));
return html`
<config-section
heading="Favorites"
description="Configure the default playlist used for quick-favouriting tracks."
>
<config-field
.schema=${{
key: 'favoritesPlaylist',
label: 'Default Playlist',
description:
'The playlist used when toggling the favourite icon on tracks.',
type: 'select' as const,
options: playlistOptions,
}}
.value=${String(
this.favCtrl.playlistId,
)}
@config-change=${this.handleFavPlaylistChange}
></config-field>
<config-field
.schema=${{
key: 'favoritesIcon',
label: 'Icon Style',
description:
'Choose heart or star for the favourite indicator.',
type: 'select' as const,
options: [
{
value: 'heart',
label: '\u2665 Heart',
},
{
value: 'star',
label: '\u2605 Star',
},
],
}}
.value=${this.favCtrl
.iconStyle}
@config-change=${this.handleFavIconStyleChange}
></config-field>
<config-field
.schema=${{
key: 'pinDefaultPlaylist',
label: 'Pin to Top',
description:
'Always show the default playlist first, regardless of sort order.',
type: 'toggle' as const,
}}
.value=${this.favCtrl.pinDefault}
@config-change=${this.handlePinDefaultChange}
></config-field>
</config-section>
`;
}
// --- 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">
${repeat(order, (id) => id, (id, idx) => {
const checked =
enabledIds.includes(id);
const onlyOne =
checked &&
enabledIds.length <= 1;
const isFirst = idx === 0;
const isLast =
idx === order.length - 1;
const columnLabel =
COLUMN_DEFS[id]?.label ?? id;
return html`
<li
class="column-item ${checked ? 'enabled' : 'disabled'}"
>
<input
type="checkbox"
class="column-toggle"
aria-label="Show the ${columnLabel} column"
.checked=${checked}
?disabled=${onlyOne}
@change=${() =>
this.handleColumnToggle(
id,
)}
/>
<span
class="column-label"
>
${columnLabel}
</span>
<span
class="column-arrows"
>
${isFirst
? nothing
: html`
<button
class="column-arrow-btn"
title="Move up"
aria-label="Move ${columnLabel} up"
@click=${() =>
this.handleColumnMove(
id,
'up',
)}
>
</button>
`}
${isLast
? nothing
: html`
<button
class="column-arrow-btn"
title="Move down"
aria-label="Move ${columnLabel} down"
@click=${() =>
this.handleColumnMove(
id,
'down',
)}
>
</button>
`}
</span>
</li>
`;
})}
</ul>
</config-section>
`;
}
// --- Keyboard Shortcuts section ---
private renderShortcutsSection() {
const bindings =
this.shortcutsCtrl.state.bindings;
// All four, from the shared table: the Autotag bindings are
// persisted and rebindable like any other, and listing three of
// four categories is how they came to be written down nowhere.
const categories = SHORTCUT_CATEGORIES;
return html`
<config-section
heading="Keyboard Shortcuts"
description="Customise key bindings for player controls, navigation, and app actions."
>
${categories.map((cat) => {
const actions = Object.entries(
SHORTCUT_META,
).filter(
([, meta]) =>
meta.category === cat,
);
if (actions.length === 0) return '';
return html`
<div class="shortcut-category">
<div
class="shortcut-category-header"
>
${cat}
</div>
${actions.map(
([action, meta]) => html`
<div
class="shortcut-row"
>
<span
class="shortcut-label"
>
${meta.label}
${meta.scope !==
'global'
? html`
<span
class="shortcut-scope"
>(${meta.scope.replace(
'panel:',
'',
)})</span
>
`
: ''}
</span>
<shortcut-capture
.action=${action}
.label=${meta.label}
.currentKey=${bindings.get(
action,
) ?? ''}
.defaultKey=${meta.defaultKey}
@shortcut-change=${this
.handleShortcutChange}
></shortcut-capture>
</div>
`,
)}
</div>
`;
})}
<div class="shortcut-actions">
<button
class="btn-ghost"
@click=${this
.handleResetAllShortcuts}
>
Reset All to Defaults
</button>
</div>
${this.shortcutConflict
? html`
<div class="conflict-banner">
<span
class="conflict-text"
>
<strong
>${this
.shortcutConflict
.newKey}</strong
>
is already bound to
<strong
>${SHORTCUT_META[
this
.shortcutConflict
.existingAction
]?.label ??
this
.shortcutConflict
.existingAction}</strong
>.
</span>
<div
class="conflict-actions"
>
<button
class="btn-warning"
@click=${this
.handleConflictOverwrite}
>
Overwrite
</button>
<button
class="btn-ghost"
@click=${this
.handleConflictCancel}
>
Cancel
</button>
</div>
</div>
`
: ''}
</config-section>
`;
}
// --- Library section ---
private renderLibrarySection() {
return html`
<config-section
heading="Libraries"
description="Manage your music library folders, and scan them
for new and changed files."
.open=${true}
>
<div class="library-scope">
<div>
<span class="scope-label">Showing</span>
<p class="scope-description">
Which library the Albums, Artists and Genres
views show. This is a view filter, not a
setting about the libraries themselves — the
list below is where they are added, renamed
and scanned.
</p>
</div>
<library-filter data-testid="settings-library-filter">
</library-filter>
</div>
<div class="scan-actions">
<button
class="btn-primary"
@click=${this.handleAddLibrary}
>
Add Library
</button>
<button
?disabled=${this.anyScanning
|| this.startingScan
|| this.libraries.length === 0}
@click=${this.handleScanAll}
>
Scan All
</button>
<button
class="btn-danger"
?disabled=${this.anyScanning
|| this.startingScan
|| this.libraries.length === 0}
@click=${this.handleFullRescan}
>
Full Rescan
</button>
</div>
${this.libraries.length > 0
? html`
<div class="library-list">
${this.libraries.map(
(lib) => html`
<div class="library-row">
${this.editingLibraryId === lib.id
? html`
<input
class="edit-input"
type="text"
.value=${this.editingName}
@input=${this.handleRenameInput}
@keydown=${this.handleRenameKeyDown}
@click=${(e: Event) => e.stopPropagation()}
/>
`
: html`
<span
class="library-name"
@click=${(e: Event) => {
// The document handler closes the
// editor, and without this it closes
// the one this very click opened.
e.stopPropagation();
this.handleStartRename(lib.id, lib.name);
}}
>
${lib.name}
</span>
`}
<span class="library-path">${lib.path}</span>
<span class="library-count">
${this.removingLibraryId === lib.id
? 'Removing…'
: this.libraryScanStatus(lib.id)
?? html`${lib.trackCount} tracks`}
</span>
<div class="overflow-wrapper">
<button
class="overflow-btn"
aria-label=${`Actions for ${lib.name}`}
?disabled=${this.removingLibraryId === lib.id}
@click=${(e: Event) => this.toggleOverflowMenu(lib.id, e)}
>
\u22EF
</button>
${this.activeMenuId === lib.id
? html`
<div
class="overflow-menu"
@click=${(e: Event) => e.stopPropagation()}
>
<div
class="overflow-item"
@click=${() => this.handleStartRename(lib.id, lib.name)}
>
Rename
</div>
${this.libraryScanStatus(lib.id) === null
? html`
<div
class="overflow-item"
@click=${() => this.handleScanLibrary(lib.id)}
>
Scan now
</div>
`
: nothing}
<div
class="overflow-item overflow-item--danger"
@click=${() => void this.handleRemoveClick(lib.id)}
>
Remove
</div>
</div>
`
: nothing}
</div>
</div>
`,
)}
</div>
`
: nothing}
<config-field
.schema=${{
key: 'concurrencyMode',
label: 'Storage Type',
description:
'Controls parallel workers during scanning. Auto-detect reads the disk type automatically.',
type: 'select' as const,
options: [
{
value: 'auto',
label: 'Auto-detect',
},
{
value: 'ssd',
label: 'SSD (max parallelism)',
},
{
value: 'hdd',
label: 'HDD (reduced I/O)',
},
],
}}
.value=${this.concurrencyMode}
@config-change=${this.handleConcurrencyChange}
></config-field>
<job-panel
kinds="library-scan"
heading="Scans"
></job-panel>
</config-section>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'config-page': ConfigPage;
}
}