diff --git a/frontend/src/components/config-page/config-page.ts b/frontend/src/components/config-page/config-page.ts index cc2b328..da93c57 100644 --- a/frontend/src/components/config-page/config-page.ts +++ b/frontend/src/components/config-page/config-page.ts @@ -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 { @@ -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 => { try { const dir = await DirectoryPicker(); @@ -1325,32 +1476,52 @@ export class ConfigPage extends LitElement { >
- - + ${this.scanning + ? html` + ${this.scanPaused + ? html`` + : html``} + + ` + : html` + + + `}
- ${this.scanProgress - ? this.renderScanProgress() - : this.statusMessage || 'Ready.'} + ${this.scanPaused + ? 'Scan paused.' + : this.scanProgress + ? this.renderScanProgress() + : this.statusMessage || 'Ready.'}
${this.scanErrors @@ -1377,6 +1548,59 @@ export class ConfigPage extends LitElement { : ''} ${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} `; } diff --git a/frontend/src/components/config-page/shortcut-capture.ts b/frontend/src/components/config-page/shortcut-capture.ts new file mode 100644 index 0000000..f2df09b --- /dev/null +++ b/frontend/src/components/config-page/shortcut-capture.ts @@ -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` + + ${showReset + ? html` + + ` + : ''} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'shortcut-capture': ShortcutCapture; + } +} diff --git a/frontend/wailsjs/go/library/Library.d.ts b/frontend/wailsjs/go/library/Library.d.ts index 4f32e4d..2089259 100755 --- a/frontend/wailsjs/go/library/Library.d.ts +++ b/frontend/wailsjs/go/library/Library.d.ts @@ -3,10 +3,16 @@ import {library} from '../models'; import {context} from '../models'; +export function CancelScan():Promise; + export function FullRescan():Promise; export function GetAlbumTracks(arg1:number):Promise>; +export function IsScanActive():Promise; + +export function IsScanPaused():Promise; + export function GetAlbumsByArtist(arg1:number):Promise>; export function GetAllAlbums():Promise>; @@ -19,6 +25,10 @@ export function GetAllTracks():Promise>; export function GetTracksByGenre(arg1:string):Promise>; +export function PauseScan():Promise; + +export function ResumeScan():Promise; + export function Scan():Promise; export function SearchTracks(arg1:string):Promise>; diff --git a/frontend/wailsjs/go/library/Library.js b/frontend/wailsjs/go/library/Library.js index be22bf5..69c39c2 100755 --- a/frontend/wailsjs/go/library/Library.js +++ b/frontend/wailsjs/go/library/Library.js @@ -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'](); }