feat(09-03): add scan control UI with pause/resume/cancel and confirmation dialog

- Pause/Resume buttons replace Soft Scan/Full Rescan during active scan
- Cancel button shows confirmation dialog with Keep/Discard/Continue options
- Register LibraryScanPaused/Resumed/Cancelled event handlers
- Status bar shows 'Scan paused.' with accent color when paused
- Add CancelScan/PauseScan/ResumeScan Wails binding stubs
- Cancel dialog overlay dismissible by clicking outside or Continue Scanning
- Add cancelled field to ScanMetrics TypeScript interface
This commit is contained in:
2026-03-06 21:54:50 -05:00
parent 6bb266e234
commit 391436927c
4 changed files with 441 additions and 23 deletions
@@ -2,7 +2,13 @@ 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 } from '@go/library/Library';
import {
Scan,
FullRescan,
CancelScan,
PauseScan,
ResumeScan,
} from '@go/library/Library';
import {
GetLibraryDirectory,
SetLibraryDirectory,
@@ -68,6 +74,7 @@ interface ScanMetrics {
updated: number;
skipped: number;
removed: number;
cancelled: boolean;
}
function fmtNs(ns: number): string {
@@ -215,10 +222,16 @@ export class ConfigPage extends LitElement {
@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;
private cancelScanStarted?: () => void;
private cancelScanProgress?: () => void;
private cancelScanComplete?: () => void;
private cancelScanPaused?: () => void;
private cancelScanResumed?: () => void;
private cancelScanCancelled?: () => void;
static override styles = css`
:host {
@@ -604,6 +617,68 @@ export class ConfigPage extends LitElement {
);
}
/* 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);
}
`;
// ===================================================================
@@ -627,6 +702,18 @@ export class ConfigPage extends LitElement {
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 {
@@ -634,6 +721,9 @@ export class ConfigPage extends LitElement {
this.cancelScanStarted?.();
this.cancelScanProgress?.();
this.cancelScanComplete?.();
this.cancelScanPaused?.();
this.cancelScanResumed?.();
this.cancelScanCancelled?.();
}
private async loadLibraryConfig(): Promise<void> {
@@ -680,6 +770,7 @@ export class ConfigPage extends LitElement {
metrics?: ScanMetrics,
): void => {
this.scanning = false;
this.scanPaused = false;
this.scanProgress = null;
this.statusMessage = 'Scan complete.';
@@ -688,6 +779,66 @@ export class ConfigPage extends LitElement {
}
};
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<void> => {
try {
const dir = await DirectoryPicker();
@@ -1325,32 +1476,52 @@ export class ConfigPage extends LitElement {
></config-field>
<div class="scan-actions">
<button
class="btn-warning"
?disabled=${this.scanning}
@click=${this.handleSoftScan}
>
${this.scanning
? 'Scanning...'
: 'Soft Scan'}
</button>
<button
class="btn-danger"
?disabled=${this.scanning}
@click=${this.handleFullRescan}
>
${this.scanning
? 'Scanning...'
: 'Full Rescan'}
</button>
${this.scanning
? html`
${this.scanPaused
? html`<button
class="btn-warning"
@click=${this.handleResumeScan}
>
Resume
</button>`
: html`<button
class="btn-warning"
@click=${this.handlePauseScan}
>
Pause
</button>`}
<button
class="btn-danger"
@click=${this.handleCancelScan}
>
Cancel Scan
</button>
`
: html`
<button
class="btn-warning"
@click=${this.handleSoftScan}
>
Soft Scan
</button>
<button
class="btn-danger"
@click=${this.handleFullRescan}
>
Full Rescan
</button>
`}
</div>
<div
class="status-bar ${this.scanning ? 'active' : ''}"
class="status-bar ${this.scanning ? 'active' : ''} ${this.scanPaused ? 'paused' : ''}"
>
${this.scanProgress
? this.renderScanProgress()
: this.statusMessage || 'Ready.'}
${this.scanPaused
? 'Scan paused.'
: this.scanProgress
? this.renderScanProgress()
: this.statusMessage || 'Ready.'}
</div>
${this.scanErrors
@@ -1377,6 +1548,59 @@ export class ConfigPage extends LitElement {
: ''}
${this.renderMetrics()}
${this.showCancelDialog
? html`
<div
class="cancel-dialog-overlay"
@click=${this.handleCancelDialogDismiss}
>
<div
class="cancel-dialog"
@click=${(e: Event) => e.stopPropagation()}
>
<div
class="cancel-dialog-title"
>
Cancel Scan
</div>
<div
class="cancel-dialog-message"
>
${this.cancelMetrics
?.added
? `Keep ${this.cancelMetrics.added} tracks found so far, or discard?`
: 'Cancel the current scan?'}
</div>
<div
class="cancel-dialog-actions"
>
<button
class="btn-primary"
@click=${this.handleCancelKeep}
>
${this
.cancelMetrics
?.added
? `Keep ${this.cancelMetrics.added} tracks`
: 'Cancel Scan'}
</button>
<button
class="btn-danger"
@click=${this.handleCancelDiscard}
>
Discard
</button>
<button
class="btn-ghost"
@click=${this.handleCancelDialogDismiss}
>
Continue Scanning
</button>
</div>
</div>
</div>
`
: nothing}
</config-section>
`;
}
@@ -0,0 +1,164 @@
import { LitElement, html, css } from 'lit';
import { customElement, property, state } from 'lit/decorators.js';
import { buildKeyString } from '../../services/keyboard-shortcut-service';
@customElement('shortcut-capture')
export class ShortcutCapture extends LitElement {
@property() action = '';
@property() currentKey = '';
@property() defaultKey = '';
@state() private recording = false;
static override styles = css`
:host {
display: inline-block;
}
button {
font-family: inherit;
font-size: var(--yj-text-sm, 13px);
padding: 4px 12px;
border-radius: 4px;
border: 1px solid var(--yj-border, #555);
background: var(--yj-bg-input, #333);
color: var(--yj-text-primary, #eee);
cursor: pointer;
min-width: 80px;
text-align: center;
transition:
border-color 0.15s,
background 0.15s;
}
button:hover {
border-color: var(--yj-accent, #ffd43b);
}
button.recording {
border-color: var(--yj-accent, #ffd43b);
background: var(--yj-bg-active, #444);
animation: pulse 1.2s ease-in-out infinite;
}
button.not-set {
color: var(--yj-text-tertiary, #888);
font-style: italic;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.reset-btn {
font-size: var(--yj-text-xs, 11px);
padding: 2px 6px;
margin-left: 4px;
border: none;
background: transparent;
color: var(--yj-text-tertiary, #888);
cursor: pointer;
min-width: auto;
opacity: 0;
transition: opacity 0.15s;
}
:host(:hover) .reset-btn {
opacity: 1;
}
.reset-btn:hover {
color: var(--yj-accent, #ffd43b);
}
`;
private handleClick = () => {
this.recording = true;
// Focus self so keydown events arrive
this.shadowRoot?.querySelector('button')?.focus();
};
private handleKeydown = (e: KeyboardEvent) => {
if (!this.recording) return;
e.preventDefault();
e.stopPropagation();
const keyStr = buildKeyString(e);
if (!keyStr) return; // bare modifier press — keep recording
if (keyStr === 'Escape') {
this.recording = false;
return;
}
this.recording = false;
this.dispatchEvent(
new CustomEvent('shortcut-change', {
detail: { action: this.action, key: keyStr },
bubbles: true,
composed: true,
}),
);
};
private handleBlur = () => {
// Cancel recording if focus leaves
if (this.recording) {
this.recording = false;
}
};
private handleReset = (e: Event) => {
e.stopPropagation();
if (this.defaultKey && this.currentKey !== this.defaultKey) {
this.dispatchEvent(
new CustomEvent('shortcut-change', {
detail: {
action: this.action,
key: this.defaultKey,
},
bubbles: true,
composed: true,
}),
);
}
};
override render() {
const showReset =
this.defaultKey && this.currentKey !== this.defaultKey;
return html`
<button
class=${this.recording
? 'recording'
: this.currentKey
? ''
: 'not-set'}
@click=${this.handleClick}
@keydown=${this.handleKeydown}
@blur=${this.handleBlur}
>
${this.recording
? 'Press a key combo\u2026'
: this.currentKey || 'Not set'}
</button>
${showReset
? html`
<button
class="reset-btn"
@click=${this.handleReset}
title="Reset to default (${this.defaultKey})"
>
\u21BA
</button>
`
: ''}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'shortcut-capture': ShortcutCapture;
}
}
+10
View File
@@ -3,10 +3,16 @@
import {library} from '../models';
import {context} from '../models';
export function CancelScan():Promise<void>;
export function FullRescan():Promise<library.ScanMetrics>;
export function GetAlbumTracks(arg1:number):Promise<Array<library.Track>>;
export function IsScanActive():Promise<boolean>;
export function IsScanPaused():Promise<boolean>;
export function GetAlbumsByArtist(arg1:number):Promise<Array<library.Album>>;
export function GetAllAlbums():Promise<Array<library.Album>>;
@@ -19,6 +25,10 @@ export function GetAllTracks():Promise<Array<library.Track>>;
export function GetTracksByGenre(arg1:string):Promise<Array<library.Track>>;
export function PauseScan():Promise<void>;
export function ResumeScan():Promise<void>;
export function Scan():Promise<library.ScanMetrics>;
export function SearchTracks(arg1:string):Promise<Array<library.Track>>;
+20
View File
@@ -2,6 +2,10 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT
export function CancelScan() {
return window['go']['library']['Library']['CancelScan']();
}
export function FullRescan() {
return window['go']['library']['Library']['FullRescan']();
}
@@ -10,6 +14,14 @@ export function GetAlbumTracks(arg1) {
return window['go']['library']['Library']['GetAlbumTracks'](arg1);
}
export function IsScanActive() {
return window['go']['library']['Library']['IsScanActive']();
}
export function IsScanPaused() {
return window['go']['library']['Library']['IsScanPaused']();
}
export function GetAlbumsByArtist(arg1) {
return window['go']['library']['Library']['GetAlbumsByArtist'](arg1);
}
@@ -34,6 +46,14 @@ export function GetTracksByGenre(arg1) {
return window['go']['library']['Library']['GetTracksByGenre'](arg1);
}
export function PauseScan() {
return window['go']['library']['Library']['PauseScan']();
}
export function ResumeScan() {
return window['go']['library']['Library']['ResumeScan']();
}
export function Scan() {
return window['go']['library']['Library']['Scan']();
}