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, } from '@go/library/library.js'; import { GetScanConcurrency, SetScanConcurrency, GetDefaultPage, SetDefaultPage, GetQueueFallback, SetQueueFallback, GetAllowMeteredCatalogDownload, SetAllowMeteredCatalogDownload, } 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 { 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'; 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); // --- 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 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; } .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; } .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'; // Scan progress lives in the jobs panel now — this page only // needs to know when the library list itself changes. 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.cancelLibraryAdded?.(); this.cancelLibraryRenamed?.(); this.cancelLibraryRemoved?.(); this.cancelIndexStatus?.(); } private async loadLibraries(): Promise { try { const [libs, mode, defaultPage, queueFallback, allowMetered] = await Promise.all([ GetAllLibrariesWithTrackCounts(), GetScanConcurrency(), GetDefaultPage(), GetQueueFallback(), GetAllowMeteredCatalogDownload(), ]); this.libraries = libs ?? []; this.concurrencyMode = mode; this.defaultPage = defaultPage; this.queueFallback = queueFallback; this.allowMeteredCatalogDownload = allowMetered; } catch (err) { console.error( 'Failed to load libraries:', err, ); } } // =================================================================== // LIBRARY HANDLERS // =================================================================== private handleAddLibrary = async (): Promise => { 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 => { 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 => { 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 { 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 { try { this.indexStatus = await GetIndexStatus(); this.indexStatusFailed = false; } catch (err) { console.error('Failed to read index status:', err); this.indexStatusFailed = true; } } private handleDefaultPageChange = ( e: CustomEvent, ): 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, ): 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, ): 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, ): void => { this.themeCtrl .setAccentColor(String(e.detail.value)) .catch((err: unknown) => { console.error( 'Failed to set accent color:', err, ); }); }; private handleShadeChange = ( e: CustomEvent, ): 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 { try { this.playlists = await list(GetAllPlaylists()); } catch (err) { console.error( 'Failed to load playlists:', err, ); } } private handleFavIconStyleChange = ( e: CustomEvent, ): 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, ): 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, ): 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, ): 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`

Settings

${this.renderLibrarySection()} ${this.renderGeneralSection()} ${this.renderNowPlayingSection()} ${this.renderThemeSection()} ${this.renderTrackListSection()} ${this.renderFavoritesSection()} ${this.renderShortcutsSection()} ${this.renderSearchSection()} `; } // --- Search / Index section --- private renderSearchSection() { const s = this.indexStatus; return html`
${s ? html`
${this.formatCount(s.artists)} artists · ${this.formatCount(s.recordings)} recordings · ${this.formatCount(s.releaseGroups)} albums · ${this.formatCount(s.totalRows)} total ${s.lastBuilt ? html`· updated ${this.timeAgo(s.lastBuilt)}` : nothing}
${(s.tiers?.length ?? 0) > 0 && (s.tiers ?? []).some((t) => t.state === 'running' || t.state === 'pending' || t.state === 'error') ? html`
${(s.tiers ?? []).map( (t) => html`
${this.tierIcon(t.state)} ${t.name} ${t.state === 'running' && t.total > 0 ? html`${t.completed}/${t.total}` : nothing} ${t.state === 'running' && t.detail ? html`${t.detail}` : nothing} ${t.state === 'error' ? html`${describeError(t.error, 'This part of the index could not be built.')}` : nothing}
`, )}
` : nothing} ${!s.building && s.ready ? html`
Index ready
` : !s.building && !s.ready && s.totalRows === 0 ? html`
Index empty — build will start after library scan
` : !s.building && !s.ready ? html`
Waiting for index build…
` : nothing} ` : this.indexStatusFailed ? html`
The index status could not be read.
` : html`
Loading status…
`}
`; } /** * 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, ): 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` `; } // --- General section --- private renderGeneralSection() { return html` `; } // --- Theme section --- private renderThemeSection() { return html`
${this.renderSwatches()}
`; } 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`
${s.label}
`, ); } // --- Favorites section --- private renderFavoritesSection() { const playlistOptions = this.playlists.map((p) => ({ value: String(p.ID), label: p.Name, })); return html` `; } // --- Track list section --- private renderTrackListSection() { const enabledIds = this.trackListCtrl.columnIds; const order = this.getMergedColumnOrder(); return html`
    ${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`
  • this.handleColumnToggle( id, )} /> ${columnLabel} ${isFirst ? nothing : html` `} ${isLast ? nothing : html` `}
  • `; })}
`; } // --- 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` ${categories.map((cat) => { const actions = Object.entries( SHORTCUT_META, ).filter( ([, meta]) => meta.category === cat, ); if (actions.length === 0) return ''; return html`
${cat}
${actions.map( ([action, meta]) => html`
${meta.label} ${meta.scope !== 'global' ? html` (${meta.scope.replace( 'panel:', '', )}) ` : ''}
`, )}
`; })}
${this.shortcutConflict ? html`
${this .shortcutConflict .newKey} is already bound to ${SHORTCUT_META[ this .shortcutConflict .existingAction ]?.label ?? this .shortcutConflict .existingAction}.
` : ''}
`; } // --- Library section --- private renderLibrarySection() { return html`
${this.libraries.length > 0 ? html`
${this.libraries.map( (lib) => html`
${this.editingLibraryId === lib.id ? html` e.stopPropagation()} /> ` : html` { // 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} `} ${lib.path} ${this.removingLibraryId === lib.id ? 'Removing…' : html`${lib.trackCount} tracks`}
${this.activeMenuId === lib.id ? html`
e.stopPropagation()} >
this.handleStartRename(lib.id, lib.name)} > Rename
void this.handleRemoveClick(lib.id)} > Remove
` : nothing}
`, )}
` : nothing}
`; } } declare global { interface HTMLElementTagNameMap { 'config-page': ConfigPage; } }