import { LitElement, html, css, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import { EventsOn } from '@runtime/runtime'; import { Scan, FullRescan } 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 { Events } from '../../events'; import type { ConfigFieldChangeEvent } from './config-field'; import type { BackgroundShade } from '@store/theme-store'; import './config-field'; import './config-section'; // =================================================================== // Scan metrics types and helpers (carried over from library-manager) // =================================================================== const NS_PER_MS = 1_000_000; 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; } 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); // --- Library state --- @state() private libraryDirectory = ''; @state() private selectedDirectory = ''; @state() private scanning = false; @state() private statusMessage = ''; @state() private metrics: ScanMetrics | null = null; @state() private copied = false; @state() private concurrencyMode = 'auto'; private cancelScanStarted?: () => void; private cancelScanComplete?: () => 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); } /* 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; } `; // =================================================================== // LIFECYCLE // =================================================================== override connectedCallback(): void { super.connectedCallback(); this.loadLibraryConfig(); this.cancelScanStarted = EventsOn( Events.LibraryScanStarted, this.handleScanStarted, ); this.cancelScanComplete = EventsOn( Events.LibraryScanComplete, this.handleScanComplete, ); } override disconnectedCallback(): void { super.disconnectedCallback(); this.cancelScanStarted?.(); this.cancelScanComplete?.(); } 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 = 'Scanning...'; this.metrics = null; this.copied = false; }; private handleScanComplete = ( metrics?: ScanMetrics, ): void => { this.scanning = false; this.statusMessage = 'Scan complete.'; if (metrics) { this.metrics = metrics; } }; 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 failed: ${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 failed: ${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, ); } }; // =================================================================== // 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, ); }); }; // =================================================================== // 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.renderThemeSection()} ${this.renderLibrarySection()} `; } // --- 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}
`, ); } // --- Library section --- private renderLibrarySection() { return html` ${this.directoryChanged ? html`
` : nothing}
${this.statusMessage || 'Ready.'}
${this.renderMetrics()}
`; } // --- Metrics --- private renderMetricRow( label: string, value: string, highlight = false, ) { return html`
${label} ${value}
`; } 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; } }