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 { Scan, FullRescan, CancelScan, PauseScan, ResumeScan, } from '@go/library/Library'; import { GetLibraryDirectory, SetLibraryDirectory, GetScanConcurrency, SetScanConcurrency, } from '@go/config/Config'; import { DirectoryPicker } from '@go/frontendutil/FrontendUtil'; import { ThemeController } from '@store/controllers/theme-controller'; import { TrackListController } from '@store/controllers/tracklist-controller'; import { FavoritesController } from '@store/controllers/favorites-controller'; import { GetAllPlaylists } from '@go/playlist/Service'; import type { playlist } from '@go/models'; import { Events } from '../../events'; 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 './shortcut-capture'; import { shortcutsStore } from '../../store/shortcuts-store'; import { ShortcutsController } from '../../store/controllers/shortcuts-controller'; const SCROLL_STORAGE_KEY = 'yj-now-playing-scroll-mode'; const SCROLL_CHANGE_EVENT = 'yj-scroll-mode-changed'; // =================================================================== // Scan metrics types and helpers (carried over from library-manager) // =================================================================== const NS_PER_MS = 1_000_000; interface ScanProgress { phase: 'counting' | 'scanning' | 'orphans' | 'thumbnails'; total: number; processed: number; added: number; skipped: number; updated: number; } interface ScanMetrics { total: number; loadExisting: number; walkDuration: number; extractionWallClock: number; dbWritesWallClock: number; orphanCleanup: number; postScanVariants: number; formatExtraction: Record; formatCount: Record; tagExtraction: number; durationExtraction: number; batchCommits: number; coverArtSave: number; thumbnailWallClock: number; thumbnailGeneration: number; thumbnailSmall: number; thumbnailMedium: number; thumbnailLarge: number; clearQueue: number; clearDatabase: number; clearCoverFiles: number; added: number; updated: number; skipped: number; removed: number; cancelled: boolean; } function fmtNs(ns: number): string { if (ns <= 0) return '<1ms'; const ms = ns / NS_PER_MS; if (ms < 1) return '<1ms'; if (ms < 1000) return `${ms.toFixed(0)}ms`; const s = ms / 1000; if (s < 60) return `${s.toFixed(2)}s`; const m = Math.floor(s / 60); const rem = s % 60; return `${m}m ${rem.toFixed(1)}s`; } function fmtMs(ms: number): string { if (ms <= 0) return '<1ms'; if (ms < 1000) return `${ms.toFixed(0)}ms`; const s = ms / 1000; if (s < 60) return `${s.toFixed(2)}s`; const m = Math.floor(s / 60); const rem = s % 60; return `${m}m ${rem.toFixed(1)}s`; } function formatMetricsText(m: ScanMetrics): string { const line = ( label: string, value: string, indent = 0, ) => `${' '.repeat(indent)}${label}: ${value}`; const lines: string[] = [ '=== YellowJacket Scan Results ===', '', line('Total', fmtNs(m.total)), '', '-- File Counts --', line('Added', String(m.added), 1), line('Updated', String(m.updated), 1), line('Skipped', String(m.skipped), 1), line('Removed', String(m.removed), 1), ]; if ( m.clearQueue > 0 || m.clearDatabase > 0 || m.clearCoverFiles > 0 ) { lines.push( '', '-- Clear Phases --', line('Clear Queue', fmtNs(m.clearQueue), 1), line( 'Clear Database', fmtNs(m.clearDatabase), 1, ), line( 'Clear Cover Files', fmtNs(m.clearCoverFiles), 1, ), ); } lines.push( '', '-- Scan Phases --', line( 'Load Existing', fmtNs(m.loadExisting), 1, ), line( 'Directory Walk', fmtNs(m.walkDuration), 1, ), line( 'Metadata Extraction (wall)', fmtNs(m.extractionWallClock), 1, ), line( 'DB Writes (wall)', fmtNs(m.dbWritesWallClock), 1, ), line( 'Thumbnail Generation (wall)', fmtNs(m.thumbnailWallClock), 1, ), line( 'Orphan Cleanup', fmtNs(m.orphanCleanup), 1, ), line( 'Post-Scan Variants', fmtNs(m.postScanVariants), 1, ), ); return lines.join('\n'); } // =================================================================== // Config page component // =================================================================== @customElement('config-page') export class ConfigPage extends LitElement { // --- Theme controller for reading/writing theme state --- private themeCtrl = new ThemeController(this); // --- Track-list column config controller --- private trackListCtrl = new TrackListController(this); // --- Favorites controller --- private favCtrl = new FavoritesController(this); // --- Shortcuts controller --- private shortcutsCtrl = new ShortcutsController(this); // --- Shortcut metadata for UI grouping --- private static readonly SHORTCUT_META: Record< string, { label: string; category: string; scope: string; defaultKey: string; } > = { 'player.playPause': { label: 'Play / Pause', category: 'Player', scope: 'global', defaultKey: 'Space', }, 'player.next': { label: 'Next Track', category: 'Player', scope: 'global', defaultKey: 'N', }, 'player.previous': { label: 'Previous Track', category: 'Player', scope: 'global', defaultKey: 'P', }, 'player.volumeUp': { label: 'Volume Up', category: 'Player', scope: 'global', defaultKey: 'Up', }, 'player.volumeDown': { label: 'Volume Down', category: 'Player', scope: 'global', defaultKey: 'Down', }, 'player.seekForward': { label: 'Seek Forward', category: 'Player', scope: 'global', defaultKey: 'Right', }, 'player.seekBack': { label: 'Seek Back', category: 'Player', scope: 'global', defaultKey: 'Left', }, 'player.shuffle': { label: 'Toggle Shuffle', category: 'Player', scope: 'global', defaultKey: 'S', }, 'player.repeat': { label: 'Cycle Repeat', category: 'Player', scope: 'global', defaultKey: 'R', }, 'player.mute': { label: 'Toggle Mute', category: 'Player', scope: 'global', defaultKey: 'M', }, 'nav.search': { label: 'Focus Search', category: 'Navigation', scope: 'global', defaultKey: '/', }, 'nav.searchAlt': { label: 'Focus Search (Alt)', category: 'Navigation', scope: 'global', defaultKey: 'Ctrl+F', }, 'nav.queue': { label: 'Toggle Queue', category: 'Navigation', scope: 'global', defaultKey: 'Q', }, 'app.selectAll': { label: 'Select All', category: 'App', scope: 'global', defaultKey: 'Ctrl+A', }, 'tracklist.play': { label: 'Play Selected', category: 'Navigation', scope: 'panel:track-list', defaultKey: 'Enter', }, 'tracklist.delete': { label: 'Remove Selected', category: 'Navigation', scope: 'panel:track-list', defaultKey: 'Delete', }, }; // --- Now Playing state --- @state() private scrollMode = 'hover'; // --- Favorites state --- @state() private playlists: playlist.Summary[] = []; // --- Library state --- @state() private libraryDirectory = ''; @state() private selectedDirectory = ''; @state() private scanning = false; @state() private statusMessage = ''; @state() private scanProgress: ScanProgress | null = null; @state() private metrics: ScanMetrics | null = null; @state() private copied = false; @state() private errorsCopied = false; @state() private scanErrors = ''; @state() private concurrencyMode = 'auto'; @state() private scanPaused = false; @state() private showCancelDialog = false; @state() private cancelMetrics: { added: number } | null = null; @state() private shortcutConflict: { newAction: string; newKey: string; existingAction: string; } | null = null; private cancelScanStarted?: () => void; private cancelScanProgress?: () => void; private cancelScanComplete?: () => void; private cancelScanPaused?: () => void; private cancelScanResumed?: () => void; private cancelScanCancelled?: () => void; static override styles = css` :host { display: block; padding: 1.5em; 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: #fff; } .btn-warning:hover:not(:disabled) { background: var(--yj-warning-hover, #d9480f); } .btn-danger { background: var(--yj-error, #e03131); color: #fff; } .btn-danger:hover:not(:disabled) { background: var(--yj-error-hover, #c92a2a); } .btn-success { background: var(--yj-success, #2f9e44); color: #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, #2f9e44); } /* 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, #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, #e03131); } .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, #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 ); } /* Cancel confirmation dialog */ .cancel-dialog-overlay { position: fixed; inset: 0; background: rgba(0, 0, 0, 0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; } .cancel-dialog { background: var( --yj-bg-surface, #2a2a2a ); border: 1px solid var(--yj-border, #444); border-radius: 8px; padding: 24px; max-width: 420px; width: 90%; } .cancel-dialog-title { font-size: var(--yj-text-lg, 18px); font-weight: 600; margin-bottom: 12px; } .cancel-dialog-message { font-size: var(--yj-text-sm, 14px); color: var( --yj-text-secondary, #aaa ); margin-bottom: 20px; } .cancel-dialog-actions { display: flex; gap: 8px; justify-content: flex-end; } .btn-primary { background: var( --yj-accent, #ffd43b ); color: var(--yj-bg-base, #1a1b1e); font-weight: 600; } .btn-primary:hover:not(:disabled) { filter: brightness(1.1); } .status-bar.paused { color: var(--yj-accent, #ffd43b); } /* 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; } `; // =================================================================== // LIFECYCLE // =================================================================== override connectedCallback(): void { super.connectedCallback(); this.loadLibraryConfig(); void this.loadPlaylists(); this.scrollMode = localStorage.getItem(SCROLL_STORAGE_KEY) || 'hover'; this.cancelScanStarted = EventsOn( Events.LibraryScanStarted, this.handleScanStarted, ); this.cancelScanProgress = EventsOn( Events.LibraryScanProgress, this.handleScanProgress, ); this.cancelScanComplete = EventsOn( Events.LibraryScanComplete, this.handleScanComplete, ); this.cancelScanPaused = EventsOn( Events.LibraryScanPaused, this.handleScanPaused, ); this.cancelScanResumed = EventsOn( Events.LibraryScanResumed, this.handleScanResumed, ); this.cancelScanCancelled = EventsOn( Events.LibraryScanCancelled, this.handleScanCancelled, ); } override disconnectedCallback(): void { super.disconnectedCallback(); this.cancelScanStarted?.(); this.cancelScanProgress?.(); this.cancelScanComplete?.(); this.cancelScanPaused?.(); this.cancelScanResumed?.(); this.cancelScanCancelled?.(); } private async loadLibraryConfig(): Promise { try { const [dir, mode] = await Promise.all([ GetLibraryDirectory(), GetScanConcurrency(), ]); this.libraryDirectory = dir; this.selectedDirectory = dir; this.concurrencyMode = mode; } catch (err) { console.error( 'Failed to load library config:', err, ); } } // =================================================================== // LIBRARY HANDLERS // =================================================================== private handleScanStarted = (): void => { this.scanning = true; this.statusMessage = ''; this.scanProgress = null; this.metrics = null; this.copied = false; this.scanErrors = ''; this.errorsCopied = false; }; private handleScanProgress = ( progress?: ScanProgress, ): void => { if (progress) { this.scanProgress = progress; } }; private handleScanComplete = ( metrics?: ScanMetrics, ): void => { this.scanning = false; this.scanPaused = false; this.scanProgress = null; this.statusMessage = 'Scan complete.'; if (metrics) { this.metrics = metrics; } }; private handleScanPaused = (): void => { this.scanPaused = true; }; private handleScanResumed = (): void => { this.scanPaused = false; }; private handleScanCancelled = ( metrics?: ScanMetrics, ): void => { this.scanning = false; this.scanPaused = false; this.scanProgress = null; if (metrics) { this.metrics = metrics; this.statusMessage = metrics.cancelled ? 'Scan cancelled.' : 'Scan complete.'; } else { this.statusMessage = 'Scan cancelled.'; } }; private handlePauseScan = (): void => { PauseScan(); }; private handleResumeScan = (): void => { ResumeScan(); }; private handleCancelScan = (): void => { const added = this.scanProgress?.added ?? 0; this.cancelMetrics = { added }; this.showCancelDialog = true; }; private handleCancelKeep = (): void => { this.showCancelDialog = false; this.cancelMetrics = null; CancelScan(); }; private handleCancelDiscard = (): void => { this.showCancelDialog = false; this.cancelMetrics = null; CancelScan(); this.statusMessage = 'Scan cancelled. Partial results discarded \u2014 run Full Rescan for a clean library.'; }; private handleCancelDialogDismiss = (): void => { this.showCancelDialog = false; this.cancelMetrics = null; }; private handleDirectoryBrowse = async (): Promise => { try { const dir = await DirectoryPicker(); if (dir) { this.selectedDirectory = dir; } } catch (err) { console.error( 'Directory picker failed:', err, ); } }; private handleSaveDirectory = async (): Promise => { if (!this.selectedDirectory) return; try { await SetLibraryDirectory( this.selectedDirectory, ); this.libraryDirectory = this.selectedDirectory; this.statusMessage = 'Library directory saved. A scan will start automatically if the directory changed.'; } catch (err) { this.statusMessage = `Failed to save directory: ${err}`; } }; private handleConcurrencyChange = ( e: CustomEvent, ): void => { const mode = String(e.detail.value); SetScanConcurrency(mode) .then(() => { this.concurrencyMode = mode; this.statusMessage = 'Storage type saved. Takes effect on next scan.'; }) .catch((err: unknown) => { this.statusMessage = `Failed to save storage type: ${err}`; }); }; private handleSoftScan = async (): Promise => { try { await Scan(); } catch (err) { this.statusMessage = 'Scan completed with errors.'; this.scanErrors = String(err); } }; private handleFullRescan = async (): Promise => { const confirmed = confirm( 'This will delete ALL library data including cover art and re-scan from scratch. Continue?', ); if (!confirmed) return; try { await FullRescan(); } catch (err) { this.statusMessage = 'Full rescan completed with errors.'; this.scanErrors = String(err); } }; private handleCopyMetrics = async (): Promise => { if (!this.metrics) return; try { await navigator.clipboard.writeText( formatMetricsText(this.metrics), ); this.copied = true; setTimeout(() => { this.copied = false; }, 2000); } catch (err) { console.error( 'Failed to copy metrics:', err, ); } }; private handleCopyErrors = async (): Promise => { if (!this.scanErrors) return; try { await navigator.clipboard.writeText( this.scanErrors, ); this.errorsCopied = true; setTimeout(() => { this.errorsCopied = false; }, 2000); } catch (err) { console.error( 'Failed to copy errors:', 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 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 = ConfigPage.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, ); }); }; // =================================================================== // COMPUTED // =================================================================== private get directoryChanged(): boolean { return ( this.selectedDirectory !== this.libraryDirectory ); } private get hasRescanPhases(): boolean { if (!this.metrics) return false; const m = this.metrics; return ( m.clearQueue > 0 || m.clearDatabase > 0 || m.clearCoverFiles > 0 ); } // =================================================================== // RENDER // =================================================================== override render() { return html`

Settings

${this.renderNowPlayingSection()} ${this.renderThemeSection()} ${this.renderFavoritesSection()} ${this.renderTrackListSection()} ${this.renderShortcutsSection()} ${this.renderLibrarySection()} `; } // --- Now Playing section --- private renderNowPlayingSection() { 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; return html`
  • this.handleColumnToggle( id, )} /> ${COLUMN_DEFS[id] ?.label ?? id} ${isFirst ? nothing : html` `} ${isLast ? nothing : html` `}
  • `; })}
`; } // --- Keyboard Shortcuts section --- private renderShortcutsSection() { const bindings = this.shortcutsCtrl.state.bindings; const categories = [ 'Player', 'Navigation', 'App', ]; return html` ${categories.map((cat) => { const actions = Object.entries( ConfigPage.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 ${ConfigPage .SHORTCUT_META[ this .shortcutConflict .existingAction ]?.label ?? this .shortcutConflict .existingAction}.
` : ''}
`; } // --- Library section --- private renderLibrarySection() { return html` ${this.directoryChanged ? html`
` : nothing}
${this.scanning ? html` ${this.scanPaused ? html`` : html``} ` : html` `}
${this.scanPaused ? 'Scan paused.' : this.scanProgress ? this.renderScanProgress() : this.statusMessage || 'Ready.'}
${this.scanErrors ? html`
Scan Errors
${this.scanErrors}
` : ''} ${this.renderMetrics()} ${this.showCancelDialog ? html`
e.stopPropagation()} >
Cancel Scan
${this.cancelMetrics ?.added ? `Keep ${this.cancelMetrics.added} tracks found so far, or discard?` : 'Cancel the current scan?'}
` : nothing}
`; } // --- Metrics --- private renderMetricRow( label: string, value: string, highlight = false, ) { return html`
${label} ${value}
`; } private renderScanProgress() { const p = this.scanProgress; if (!p) return nothing; if (p.phase === 'counting') { return html`
Counting files\u2026
`; } const percent = p.total > 0 ? Math.min( 100, Math.round( (p.processed / p.total) * 100, ), ) : 0; const phaseLabel: Record = { scanning: 'Scanning', orphans: 'Cleaning up', thumbnails: 'Generating thumbnails', }; const label = phaseLabel[p.phase] ?? 'Scanning'; // Build detail string: "1,247 / 2,013 files (891 new, 23 updated, 356 skipped)" const parts: string[] = []; if (p.added > 0) parts.push(`${p.added.toLocaleString()} new`); if (p.updated > 0) parts.push( `${p.updated.toLocaleString()} updated`, ); if (p.skipped > 0) parts.push( `${p.skipped.toLocaleString()} skipped`, ); const detail = p.phase === 'scanning' && p.total > 0 ? html` ${p.processed.toLocaleString()} / ${p.total.toLocaleString()} files${parts.length ? ` (${parts.join(', ')})` : ''} ` : nothing; return html`
${label}\u2026 ${detail} ${percent}%
`; } private renderMetrics() { const m = this.metrics; if (!m) return nothing; const formatEntries = Object.entries( m.formatExtraction ?? {}, ).sort(([, a], [, b]) => b - a); const pureDb = Math.max( 0, m.batchCommits - m.coverArtSave, ); return html`

Scan Results

${this.renderMetricRow('Total', fmtNs(m.total), true)}
File Counts
Added ${m.added} Updated ${m.updated} Skipped ${m.skipped} Removed ${m.removed}
${this.hasRescanPhases ? html`
Clear Phases ${this.renderMetricRow('Clear Queue', fmtNs(m.clearQueue))} ${this.renderMetricRow('Clear Database', fmtNs(m.clearDatabase))} ${this.renderMetricRow('Clear Cover Files', fmtNs(m.clearCoverFiles))}
` : nothing} ${this.renderMetricRow('Load Existing Files', fmtNs(m.loadExisting))} ${this.renderMetricRow('Directory Walk', fmtNs(m.walkDuration))}
Metadata Extraction — ${fmtNs(m.extractionWallClock)} wall-clock

Per-format and per-operation times are cumulative across ${Object.values( m.formatCount ?? {}, ).reduce((a, b) => a + b, 0)} files

${formatEntries.length > 0 ? html`
By Format ${formatEntries.map( ([ext, ms]) => this.renderMetricRow( `${ext} (${m.formatCount?.[ext] ?? 0} files)`, fmtMs( ms, ), ), )}
` : nothing}
By Operation ${this.renderMetricRow('Tag Extraction', fmtNs(m.tagExtraction))} ${this.renderMetricRow('Duration Extraction', fmtNs(m.durationExtraction))}
Database Writes — ${fmtNs(m.dbWritesWallClock)} wall-clock ${this.renderMetricRow('Batch Commits', fmtNs(m.batchCommits))} ${this.renderMetricRow('Pure DB Operations', fmtNs(pureDb))} ${this.renderMetricRow('Save Cover Originals', fmtNs(m.coverArtSave))}
Thumbnail Generation — ${fmtNs(m.thumbnailWallClock)} wall-clock

Generated concurrently; cumulative CPU time may exceed wall-clock

${this.renderMetricRow('Cumulative CPU Time', fmtNs(m.thumbnailGeneration))} ${this.renderMetricRow('Small (_sm)', fmtNs(m.thumbnailSmall))} ${this.renderMetricRow('Medium (_md)', fmtNs(m.thumbnailMedium))} ${this.renderMetricRow('Large (_lg)', fmtNs(m.thumbnailLarge))}
${this.renderMetricRow('Orphan Cleanup', fmtNs(m.orphanCleanup))} ${this.renderMetricRow('Post-Scan Variants', fmtNs(m.postScanVariants))}
`; } } declare global { interface HTMLElementTagNameMap { 'config-page': ConfigPage; } }