import { LitElement, html, css, nothing } from 'lit'; import { customElement, state } from 'lit/decorators.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@components/page-header/page-header'; import { designTokens } from '../../styles/tokens.css'; import { GetAllLibrariesWithTrackCounts, ScanLibrary, ScanAllLibraries, FullRescan, } from '@go/library/library.js'; import type * as library from '@go/library/models.js'; import { EventsOn } from '@runtime/runtime'; import { Events } from '../../events'; import { jobStore } from '@store/job-store'; import type { Job } from '@store/job-store'; import { notificationStore } from '@store/notification-store'; import { describeError } from '@utils/describe-error'; import { confirmAction } from '../confirm-dialog/confirm-dialog'; import './job-row'; import './job-details-drawer'; import { applyJobControl } from './job-controls'; import { jobStateStyles } from './job-format'; import { ViewLifecycleMixin } from '../../utils/view-lifecycle'; type LibraryInfo = library.Info; /** Job states meaning the job will not progress further. */ const TERMINAL_STATES: ReadonlySet = new Set([ 'complete', 'cancelled', 'error', ]); /** * Full-page view of background work: everything running right now, the * per-library scan controls that used to live in Settings, and a short * history of what recently finished. * * This is the same job rows as the top-bar popover at a larger density — * one implementation, two placements, so the two can never disagree. */ @customElement('jobs-view') export class JobsView extends ViewLifecycleMixin(LitElement) { @state() private jobs: Job[] = []; @state() private libraries: LibraryInfo[] = []; @state() private drawerJobId = ''; @state() private drawerOpen = false; /** * Set between pressing a scan button and the job snapshot that * proves it started. `anyScanning` is derived from `JobsChanged`, * which is coalesced at 250 ms — long enough for a second click to * start a second scan (errors.M5). */ @state() private starting = false; private unsubscribe: (() => void) | null = null; private eventCleanups: Array<() => void> = []; static override styles = [ designTokens, jobStateStyles, css` :host { display: block; overflow-y: auto; height: 100%; padding: 1.5em 1.75em 3em; box-sizing: border-box; } /* The header supplies its own padding and rule, so it runs to the edge of a host that pads its own content. */ page-header { margin: -1.5em -1.75em 1em; } h1 { font-size: var(--yj-text-xl); color: var(--yj-text-primary, #e9ecef); margin: 0 0 0.2em; } .page-sub { font-size: var(--yj-text-md); color: var(--yj-text-tertiary, #868e96); margin: 0 0 1.75em; } section { margin-bottom: 2em; } .section-head { display: flex; align-items: center; justify-content: space-between; gap: 1em; margin-bottom: 0.75em; } h2 { font-size: var(--yj-text-sm); text-transform: uppercase; letter-spacing: 0.06em; color: var(--yj-text-tertiary, #868e96); margin: 0; } .card { background: rgba(255, 255, 255, 0.03); border: 1px solid rgba(255, 255, 255, 0.07); border-radius: 10px; overflow: hidden; } .card > * + * { border-top: 1px solid rgba(255, 255, 255, 0.06); } .job-entry { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 0.5em; padding-right: 0.75em; } .empty { padding: 1.1em; font-size: var(--yj-text-md); color: var(--yj-text-tertiary, #868e96); font-style: italic; } .library-row { display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 1em; padding: 0.75em 0.9em; } .library-name { font-size: var(--yj-text-md); color: var(--yj-text-primary, #e9ecef); } .library-meta { font-size: var(--yj-text-sm); color: var(--yj-text-tertiary, #868e96); margin-top: 0.15em; overflow-wrap: anywhere; } .library-state { font-size: var(--yj-text-sm); color: var(--job-tone); margin-top: 0.15em; } button.action { display: inline-flex; align-items: center; gap: 0.45em; border: 1px solid rgba(255, 255, 255, 0.14); border-radius: 7px; background: rgba(255, 255, 255, 0.05); color: var(--yj-text-primary, #e9ecef); font-size: var(--yj-text-sm); padding: 0.42em 0.85em; cursor: pointer; white-space: nowrap; transition: background-color 120ms ease, border-color 120ms ease; } button.action:hover:not(:disabled) { background: rgba(255, 255, 255, 0.11); } button.action:disabled { opacity: 0.4; cursor: default; } button.action:focus-visible { outline: 2px solid var(--yj-accent, #ffd43b); outline-offset: 2px; } button.action.danger { color: var(--yj-error-text, #ff8787); border-color: rgba(255, 107, 107, 0.35); } button.action.danger:hover:not(:disabled) { background: rgba(255, 107, 107, 0.12); } button.link { border: none; background: transparent; color: var(--yj-accent-text, #ffd43b); font-size: var(--yj-text-sm); cursor: pointer; padding: 0.2em 0.4em; border-radius: 5px; } button.link:hover { text-decoration: underline; } .details-btn { border: none; background: transparent; color: var(--yj-text-secondary, #adb5bd); font-size: var(--yj-text-sm); cursor: pointer; padding: 0.3em 0.5em; border-radius: 6px; white-space: nowrap; } .details-btn:hover { background: rgba(255, 255, 255, 0.1); color: var(--yj-text-primary, #e9ecef); } `, ]; protected override onViewActivate(): void { this.unsubscribe = jobStore.subscribe(() => { this.jobs = jobStore.jobs; }); void jobStore.init(); this.jobs = jobStore.jobs; void this.loadLibraries(); // Library CRUD happens elsewhere; keep the picker in step. for (const event of [ Events.LibraryAdded, Events.LibraryRemoved, Events.LibraryRenamed, Events.LibraryScanComplete, ]) { this.eventCleanups.push( EventsOn(event, () => void this.loadLibraries()), ); } } protected override onViewDeactivate(): void { this.unsubscribe?.(); this.unsubscribe = null; this.eventCleanups.forEach((off) => off()); this.eventCleanups = []; } private async loadLibraries(): Promise { try { this.libraries = (await GetAllLibrariesWithTrackCounts()) ?? []; } catch (err) { console.error('Failed to load libraries:', err); } } /** The scan job for a library, if one is registered. */ private jobForLibrary(id: number): Job | undefined { return jobStore.getJob(`scan:${id}`); } private openDetails(id: string) { this.drawerJobId = id; this.drawerOpen = true; } private onDrawerClosed = () => { this.drawerOpen = false; }; /** * 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, retry: () => void, ): Promise { if (this.starting) return; this.starting = 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.starting = false; } } private async startScan(id: number) { await this.startJob( 'Scanning that library', () => ScanLibrary(id), () => void this.startScan(id), ); } private async startAllScans() { await this.startJob( 'Scanning your libraries', () => ScanAllLibraries(), () => void this.startAllScans(), ); } private async clearFinished() { await jobStore.clearFinished(); } private async fullRescan() { 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.fullRescan(), ); } private renderJobList(list: Job[], emptyText: string) { if (list.length === 0) { return html`
${emptyText}
`; } return html`
${list.map( (job) => html`
`, )}
`; } /** The status line under a library name in the scan-control list. */ private libraryStatus(job: Job | undefined): string | null { 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 renderLibraryRow(lib: LibraryInfo) { const job = this.jobForLibrary(lib.id); const status = this.libraryStatus(job); const busy = status !== null; return html`
${lib.name}
${lib.trackCount.toLocaleString()} tracks · ${lib.path}
${status ? html`
${status}
` : nothing}
${busy ? html` ` : html` `}
`; } override render() { // Derived from `this.jobs` rather than the store getters so Lit // sees the reactive dependency and re-renders on every snapshot. const active = this.jobs.filter((j) => !TERMINAL_STATES.has(j.state)); const finished = this.jobs.filter((j) => TERMINAL_STATES.has(j.state)); const anyScanning = this.libraries.some((lib) => Boolean(this.libraryStatus(this.jobForLibrary(lib.id))), ); return html`

Library scans and search index builds, with their progress and output.

Running now

${this.renderJobList(active, 'Nothing is running.')}

Libraries

${this.libraries.length === 0 ? html`
No libraries yet — add one in Settings.
` : this.libraries.map((lib) => this.renderLibraryRow(lib), )}

Maintenance

Full rescan
Wipes all library data and cover art, then rebuilds from your files. Only needed when the library is corrupt — a normal scan already picks up changes.
${finished.length > 0 ? html`

Recently finished

${this.renderJobList(finished, '')}
` : nothing} `; } } declare global { interface HTMLElementTagNameMap { 'jobs-view': JobsView; } }