diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index e43a136..23d205a 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -46,7 +46,13 @@ 3. Default keyboard shortcuts work immediately after install — play/pause, next/prev, volume up/down, search focus, queue toggle, shuffle, repeat all respond to keys 4. User can open a settings UI, rebind any shortcut to a different key, and the new binding takes effect immediately — conflicts are warned about before saving 5. Keyboard shortcuts are context-aware — typing in a search box doesn't trigger player shortcuts (except Escape to blur) -**Plans:** TBD +**Plans:** 5 plans +Plans: +- [ ] 09-01-PLAN.md — Backend scan control (cancel/pause/resume methods, events, metrics) +- [ ] 09-02-PLAN.md — Backend shortcuts config + frontend keyboard shortcut service +- [ ] 09-03-PLAN.md — Frontend scan control UI (buttons, cancel dialog) +- [ ] 09-04-PLAN.md — Frontend shortcut settings UI (record-style capture, conflict detection) +- [ ] 09-05-PLAN.md — Integration verification checkpoint ### Phase 10: Tag Editing **Goal:** Users can edit track metadata from within the app and changes are written to the actual audio files @@ -118,7 +124,7 @@ | 6. SQL Consolidation & Code Quality | v1.0 | 3/3 | Complete | 2026-03-04 | | 7. Backend Performance | v1.0 | 2/2 | Complete | 2026-03-05 | | 8. Frontend Performance & UX | v1.0 | 4/4 | Complete | 2026-03-05 | -| 9. Scan Cancellation & Keyboard Shortcuts | v1.1 | 0/? | Not started | - | +| 9. Scan Cancellation & Keyboard Shortcuts | v1.1 | 0/5 | Planning complete | - | | 10. Tag Editing | v1.1 | 0/? | Not started | - | | 11. Smart Playlists | v1.1 | 0/? | Not started | - | | 12. Gapless Playback & Crossfade | v1.1 | 0/? | Not started | - | diff --git a/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-PLAN.md b/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-PLAN.md new file mode 100644 index 0000000..74b1aa1 --- /dev/null +++ b/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-PLAN.md @@ -0,0 +1,337 @@ +--- +phase: 09-scan-cancellation-keyboard-shortcuts +plan: 01 +type: execute +wave: 1 +depends_on: [] +files_modified: + - backend/events/events.go + - frontend/src/events.ts + - backend/library/library.go + - backend/library/scan_control.go + - backend/library/metrics.go +autonomous: true +requirements: + - SCAN-01 + - SCAN-02 + - SCAN-03 + +must_haves: + truths: + - "CancelScan() cancels the scan context and workers stop at their next checkpoint" + - "PauseScan() blocks workers via a channel; ResumeScan() unblocks them" + - "Cancelled scans skip orphan cleanup to avoid deleting unvisited files" + - "Batch commits use l.ctx (app context), not the cancellable scanCtx, so in-flight transactions complete" + - "ScanMetrics.Cancelled is true when a scan was cancelled" + artifacts: + - path: "backend/library/scan_control.go" + provides: "CancelScan, PauseScan, ResumeScan, IsScanActive, IsScanPaused methods" + exports: ["CancelScan", "PauseScan", "ResumeScan", "IsScanActive", "IsScanPaused"] + - path: "backend/events/events.go" + provides: "New scan control events" + contains: "LibraryScanCancelled" + - path: "backend/library/metrics.go" + provides: "Cancelled field on ScanMetrics" + contains: "Cancelled" + key_links: + - from: "backend/library/scan_control.go" + to: "backend/library/library.go" + via: "scanCancel context.CancelFunc and scanPauseCh channel on Library struct" + pattern: "l\\.scanCancel|l\\.scanPauseCh" + - from: "backend/library/library.go" + to: "backend/events/events.go" + via: "EventsEmit for scan lifecycle events" + pattern: "events\\.LibraryScan" +--- + + +Add scan cancellation and pause/resume to the Go backend. Thread a per-scan cancellable context through the existing scan pipeline, add pause/resume via a blocking channel, and expose Wails-bound methods for frontend control. + +Purpose: Backend foundation for SCAN-01/02/03 — frontend buttons wire to these methods in Plan 03. +Output: scan_control.go with CancelScan/PauseScan/ResumeScan, modified Scan() method, new events, updated metrics. + + + +@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md +@/home/caleb/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-RESEARCH.md + +@backend/library/library.go +@backend/library/metrics.go +@backend/events/events.go + + + +type Library struct { + mu sync.Mutex + ctx context.Context + logger *slog.Logger + conf *Config + db *database.DB + rescanHooks RescanHooks +} + + +type ScanMetrics struct { + mu sync.Mutex + // ... existing timing and count fields ... + Added int64 `json:"added"` + Updated int64 `json:"updated"` + Skipped int64 `json:"skipped"` + Removed int64 `json:"removed"` + Warnings []ScanWarning `json:"warnings"` +} + + +const ( + LibraryScanStarted = "LibraryScanStarted" + LibraryScanProgress = "LibraryScanProgress" + LibraryScanComplete = "LibraryScanComplete" +) + + +func (l *Library) Scan() (*ScanMetrics, error) + + + + + + + + + + + + + + Task 1: Add scan control events and metrics fields + backend/events/events.go, frontend/src/events.ts, backend/library/metrics.go + +1. In `backend/events/events.go`, add a new const block for scan control events: + ```go + // Scan control events. + const ( + LibraryScanCancelled = "LibraryScanCancelled" + LibraryScanPaused = "LibraryScanPaused" + LibraryScanResumed = "LibraryScanResumed" + ) + ``` + Place it after the existing Library events block (line 48). + +2. Run `go generate ./backend/events/...` to regenerate `frontend/src/events.ts`. + +3. In `backend/library/metrics.go`, add a `Cancelled` field to `ScanMetrics`: + ```go + Cancelled bool `json:"cancelled"` + ``` + Place it after the `Removed int64` field (line 51), before the `Warnings` field. + + + cd backend && go build ./... && go generate ./events/... && grep -q "LibraryScanCancelled" events/events.go && grep -q "LibraryScanCancelled" ../frontend/src/events.ts && grep -q "Cancelled" library/metrics.go + + Three new scan control events exist in events.go and are synced to frontend/src/events.ts. ScanMetrics has a Cancelled bool field. + + + + Task 2: Add scan control fields to Library struct and create scan_control.go + backend/library/library.go, backend/library/scan_control.go + +1. In `backend/library/library.go`, add scan control fields to the `Library` struct (after `rescanHooks` at line 86): + ```go + // Scan control fields — protected by mu. + scanActive bool + scanCancel context.CancelFunc + scanPaused bool + scanPauseCh chan struct{} + ``` + +2. Create `backend/library/scan_control.go` with these Wails-bound methods: + + ```go + package library + + import ( + "github.com/wailsapp/wails/v2/pkg/runtime" + "yellowjacket/backend/events" + ) + + // CancelScan cancels an in-progress scan. Returns immediately; + // scan goroutines stop at their next checkpoint. + func (l *Library) CancelScan() { + l.mu.Lock() + cancel := l.scanCancel + l.mu.Unlock() + + if cancel != nil { + cancel() + } + } + + // PauseScan pauses an in-progress scan. Workers block at their + // next pause checkpoint until ResumeScan is called. + func (l *Library) PauseScan() { + l.mu.Lock() + defer l.mu.Unlock() + + if !l.scanActive || l.scanPaused { + return + } + + l.scanPaused = true + l.scanPauseCh = make(chan struct{}) + + runtime.EventsEmit(l.ctx, events.LibraryScanPaused) + } + + // ResumeScan unblocks a paused scan. + func (l *Library) ResumeScan() { + l.mu.Lock() + defer l.mu.Unlock() + + if !l.scanPaused { + return + } + + l.scanPaused = false + close(l.scanPauseCh) // unblocks all waiting workers + + runtime.EventsEmit(l.ctx, events.LibraryScanResumed) + } + + // IsScanActive returns whether a scan is currently running. + func (l *Library) IsScanActive() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.scanActive + } + + // IsScanPaused returns whether the scan is currently paused. + func (l *Library) IsScanPaused() bool { + l.mu.Lock() + defer l.mu.Unlock() + return l.scanPaused + } + + // waitIfPaused blocks the calling goroutine if the scan is paused. + // Returns ctx.Err() if the context is cancelled while waiting. + func (l *Library) waitIfPaused(ctx context.Context) error { + l.mu.Lock() + ch := l.scanPauseCh + paused := l.scanPaused + l.mu.Unlock() + + if !paused || ch == nil { + return nil + } + + select { + case <-ch: // closed = unpaused + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + ``` + + Note: `waitIfPaused` takes a `context.Context` parameter (the scan-specific context), not `l.ctx`. Add `"context"` to the import block. + +3. Modify `Scan()` in `backend/library/library.go`: + + a. At the top of Scan() (after `metrics := newScanMetrics()`, line 176), create a cancellable scan context: + ```go + scanCtx, scanCancel := context.WithCancel(l.ctx) + defer scanCancel() + + l.mu.Lock() + l.scanCancel = scanCancel + l.scanActive = true + l.scanPaused = false + l.scanPauseCh = nil + l.mu.Unlock() + + defer func() { + l.mu.Lock() + l.scanCancel = nil + l.scanActive = false + // If still paused, unpause so no dangling channel + if l.scanPaused { + l.scanPaused = false + if l.scanPauseCh != nil { + close(l.scanPauseCh) + } + } + l.scanPauseCh = nil + l.mu.Unlock() + }() + ``` + + b. Replace ALL occurrences of `<-l.ctx.Done()` inside Scan() with `<-scanCtx.Done()`, and `l.ctx.Err()` with `scanCtx.Err()` (the walk goroutine send-to-workChan selects and the walk error return, and the worker pool send-to-resultChan select). There are 3 occurrences: line ~297, ~324, ~496. + + c. In the worker pool loop (Phase 3, around line 474), add a pause checkpoint before processing each file. Add at the start of the `g.Go(func() error {` closure body: + ```go + if err := l.waitIfPaused(scanCtx); err != nil { + return err + } + ``` + + d. **CRITICAL — Batch commits use l.ctx, NOT scanCtx:** The `commitBatch` method and all DB operations within it should continue to use `l.ctx` (the app context), NOT the scan-specific `scanCtx`. This is already the case since `commitBatch` accesses `l.ctx` internally. DO NOT change `commitBatch` to use `scanCtx`. This ensures in-flight transactions always complete even when the scan is cancelled. + + e. **CRITICAL — Skip orphan cleanup on cancelled scan:** Before the orphan cleanup phase (Phase 5, around line 549), add a check: + ```go + // Skip orphan cleanup if the scan was cancelled — existingPaths + // still contains unvisited files that would be incorrectly deleted. + cancelled := scanCtx.Err() != nil + if cancelled { + metrics.Cancelled = true + l.logger.Info("scan cancelled, skipping orphan cleanup") + } else { + // ... existing orphan cleanup code ... + } + ``` + Wrap the existing orphan cleanup code (existingPaths.Range through metrics.OrphanCleanup = ...) inside the `else` block. + + f. Also skip the "Phase 6: post-scan variant generation" if cancelled (wrap in same `if !cancelled` check or separate check). + + g. When the scan was cancelled, emit `LibraryScanCancelled` instead of (or in addition to) `LibraryScanComplete`. Update the finalize section: + ```go + if cancelled { + runtime.EventsEmit(l.ctx, events.LibraryScanCancelled, metrics) + } else { + runtime.EventsEmit(l.ctx, events.LibraryScanComplete, metrics) + } + ``` + + + cd backend && go build ./... && go vet ./library/... + + Library struct has scan control fields. scan_control.go provides CancelScan/PauseScan/ResumeScan/IsScanActive/IsScanPaused. Scan() uses per-scan context, workers check for pause, orphan cleanup is skipped on cancel, and appropriate events are emitted. + + + + + +```bash +cd backend && go build ./... && go vet ./library/... && go vet ./events/... +``` +All backend code compiles. No vet errors. New scan control methods are exported and Wails-bindable. + + + +- `go build ./...` passes with no errors +- `CancelScan`, `PauseScan`, `ResumeScan`, `IsScanActive`, `IsScanPaused` are exported methods on `*Library` +- `waitIfPaused` is an unexported helper that blocks on pause channel +- Scan() creates a per-scan context and uses it for worker cancellation +- Orphan cleanup and variant generation are skipped when scan is cancelled +- `LibraryScanCancelled`, `LibraryScanPaused`, `LibraryScanResumed` events exist and are synced to TypeScript +- `ScanMetrics.Cancelled` bool field exists + + + +After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-SUMMARY.md` + diff --git a/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-PLAN.md b/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-PLAN.md new file mode 100644 index 0000000..13860c1 --- /dev/null +++ b/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-PLAN.md @@ -0,0 +1,460 @@ +--- +phase: 09-scan-cancellation-keyboard-shortcuts +plan: 02 +type: execute +wave: 1 +depends_on: [] +files_modified: + - backend/shortcuts/config.go + - backend/config/config.go + - frontend/src/services/keyboard-shortcut-service.ts + - frontend/src/store/shortcuts-store.ts + - frontend/src/store/controllers/shortcuts-controller.ts + - frontend/src/store/index.ts +autonomous: true +requirements: + - KEY-01 + - KEY-04 + - KEY-05 + +must_haves: + truths: + - "Default keyboard shortcuts work immediately — Space toggles play/pause, arrows adjust volume/seek, S/R/Q/M/N/P trigger actions" + - "Shortcuts are suppressed when a text input is focused (except Escape which blurs)" + - "Shortcuts are context-aware — panel-specific bindings (Enter/Delete in track list) only fire when that panel has focus" + - "Shortcut config persists to TOML via Wails bindings and survives app restart" + artifacts: + - path: "backend/shortcuts/config.go" + provides: "Shortcuts config package with defaults and validation" + exports: ["Config", "ApplyDefaults", "Validate", "DefaultBindings"] + - path: "frontend/src/services/keyboard-shortcut-service.ts" + provides: "Singleton keyboard shortcut service with scope resolution" + exports: ["keyboardShortcutService", "KeyboardShortcutService"] + - path: "frontend/src/store/shortcuts-store.ts" + provides: "Shortcuts store persisting bindings via Wails config" + exports: ["shortcutsStore", "ShortcutsStore"] + key_links: + - from: "frontend/src/services/keyboard-shortcut-service.ts" + to: "frontend/src/store/shortcuts-store.ts" + via: "Service reads bindings from store to resolve key combos to actions" + pattern: "shortcutsStore" + - from: "frontend/src/store/shortcuts-store.ts" + to: "backend/config/config.go" + via: "Wails bindings GetShortcuts/SetShortcuts for persistence" + pattern: "GetShortcuts|SetShortcuts" + - from: "frontend/src/services/keyboard-shortcut-service.ts" + to: "frontend/src/store/player-store.ts" + via: "Action dispatch calls store methods for player controls" + pattern: "playerStore|queueStore" +--- + + +Create the keyboard shortcuts backend config package and the frontend keyboard shortcut service with default bindings, scope resolution, and action dispatch. + +Purpose: Foundation for KEY-01/04/05 — shortcuts work out of the box. Settings UI (KEY-02/03) wires to this in Plan 04. +Output: Go shortcuts config, frontend service singleton, shortcuts store with Wails persistence. + + + +@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md +@/home/caleb/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-RESEARCH.md +@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-CONTEXT.md + +@backend/config/config.go +@backend/theme/config.go +@frontend/src/store/index.ts +@frontend/src/store/theme-store.ts +@frontend/src/store/player-store.ts +@frontend/src/store/queue-store.ts + + + +type Config struct { + ctx context.Context + logger *slog.Logger + filePath string + Library *library.Config `toml:"Library"` + Theme *theme.Config `toml:"Theme"` + Window *WindowConfig `toml:"Window"` + TrackList *tracklist.Config `toml:"TrackList"` + Favorites *favorites.Config `toml:"Favorites"` +} + + +type Config struct { + AccentColor string `toml:"AccentColor"` + BackgroundShade BackgroundShade `toml:"BackgroundShade"` +} +func (c *Config) ApplyDefaults() { ... } +func (c *Config) Validate() error { ... } + + +class ThemeStore { + private state: ThemeState; + private subscribers = new Set<(state: ThemeState) => void>(); + subscribe(cb: (state: ThemeState) => void): () => void { ... } + private notify() { queueMicrotask(() => { ... }) } +} +export const themeStore = new ThemeStore(); + + +// From player-store.ts: +export const playerStore: { togglePlayback(), setVolume(v: number), seek(pos: number) } +// From queue-store.ts: +export const queueStore: { next(), previous(), toggleShuffle(), cycleRepeat() } + + +export { playerStore } from './player-store'; +export { queueStore } from './queue-store'; +export { themeStore } from './theme-store'; +export { searchStore } from './search-store'; + + +const ShortcutsConfigChanged = "ShortcutsConfigChanged" // will be added in Plan 01 events or here + + + + + + + Task 1: Create backend shortcuts config package and wire into main config + backend/shortcuts/config.go, backend/config/config.go, backend/events/events.go, frontend/src/events.ts + +1. Create `backend/shortcuts/config.go`: + + ```go + package shortcuts + + // Config holds user-customized keyboard shortcut bindings. + // Keys are action IDs (e.g. "player.playPause"), values are + // key combo strings in canonical format (e.g. "Ctrl+F", "Space"). + type Config struct { + Bindings map[string]string `toml:"Bindings"` + } + + // DefaultBindings returns the default keyboard shortcut bindings. + // Follows hybrid style: Space/arrows for player, Ctrl+key for app actions. + func DefaultBindings() map[string]string { + return map[string]string{ + // Player controls (Global scope, no modifier) + "player.playPause": "Space", + "player.next": "N", + "player.previous": "P", + "player.volumeUp": "Up", + "player.volumeDown": "Down", + "player.seekForward": "Right", + "player.seekBack": "Left", + "player.shuffle": "S", + "player.repeat": "R", + "player.mute": "M", + + // Navigation (Global scope) + "nav.search": "/", + "nav.searchAlt": "Ctrl+F", + "nav.queue": "Q", + + // App actions (Global scope, Ctrl modifier) + "app.selectAll": "Ctrl+A", + + // Panel-specific (track list) + "tracklist.play": "Enter", + "tracklist.delete": "Delete", + } + } + + // ApplyDefaults fills any missing bindings with defaults. + // Existing user customizations are preserved. + func (c *Config) ApplyDefaults() { + if c.Bindings == nil { + c.Bindings = DefaultBindings() + return + } + + defaults := DefaultBindings() + for action, key := range defaults { + if _, exists := c.Bindings[action]; !exists { + c.Bindings[action] = key + } + } + } + + // Validate checks that the config is well-formed. + func (c *Config) Validate() error { + c.ApplyDefaults() + // No validation errors possible — any string is a valid binding. + // Conflict detection is a frontend UX concern, not a config error. + return nil + } + ``` + +2. In `backend/config/config.go`: + - Add import: `"yellowjacket/backend/shortcuts"` + - Add field to Config struct: `Shortcuts *shortcuts.Config \`toml:"Shortcuts"\`` + - In `applyDefaults()`, add: + ```go + if c.Shortcuts == nil { + c.Shortcuts = &shortcuts.Config{} + } + c.Shortcuts.ApplyDefaults() + ``` + - In `Validate()`, add validation for Shortcuts (after the Favorites block): + ```go + if c.Shortcuts != nil { + if err := c.Shortcuts.Validate(); err != nil { + configErrs = errors.Join(configErrs, err) + } + } + ``` + - Add Wails binding methods: + ```go + // GetShortcuts returns the current shortcut bindings map. + func (c *Config) GetShortcuts() map[string]string { + if c.Shortcuts == nil { + c.Shortcuts = &shortcuts.Config{} + c.Shortcuts.ApplyDefaults() + } + return c.Shortcuts.Bindings + } + + // SetShortcuts saves the entire shortcut bindings map. + func (c *Config) SetShortcuts(bindings map[string]string) error { + if c.Shortcuts == nil { + c.Shortcuts = &shortcuts.Config{} + } + c.Shortcuts.Bindings = bindings + + if err := c.Save(); err != nil { + return fmt.Errorf("could not save shortcuts config: %w", err) + } + + if c.ctx != nil { + runtime.EventsEmit(c.ctx, events.ShortcutsConfigChanged, bindings) + } + + c.logger.Info("shortcuts config updated") + return nil + } + + // SetShortcut saves a single shortcut binding. + func (c *Config) SetShortcut(action string, key string) error { + if c.Shortcuts == nil { + c.Shortcuts = &shortcuts.Config{} + c.Shortcuts.ApplyDefaults() + } + c.Shortcuts.Bindings[action] = key + + if err := c.Save(); err != nil { + return fmt.Errorf("could not save shortcut: %w", err) + } + + if c.ctx != nil { + runtime.EventsEmit(c.ctx, events.ShortcutsConfigChanged, c.Shortcuts.Bindings) + } + + c.logger.Info("shortcut updated", "action", action, "key", key) + return nil + } + + // ResetShortcuts resets all shortcuts to defaults. + func (c *Config) ResetShortcuts() error { + c.Shortcuts = &shortcuts.Config{ + Bindings: shortcuts.DefaultBindings(), + } + + if err := c.Save(); err != nil { + return fmt.Errorf("could not save shortcuts reset: %w", err) + } + + if c.ctx != nil { + runtime.EventsEmit(c.ctx, events.ShortcutsConfigChanged, c.Shortcuts.Bindings) + } + + c.logger.Info("shortcuts reset to defaults") + return nil + } + ``` + +3. Add `ShortcutsConfigChanged` event to `backend/events/events.go` in the Config events block: + ```go + ShortcutsConfigChanged = "ShortcutsConfigChanged" + ``` + +4. Run `go generate ./backend/events/...` to sync to TypeScript. + + + cd backend && go build ./... && go vet ./shortcuts/... && go vet ./config/... && go generate ./events/... && grep -q "ShortcutsConfigChanged" ../frontend/src/events.ts + + Shortcuts config package exists with defaults matching user decisions. Config.go has Shortcuts field, getter/setter Wails bindings, and emits ShortcutsConfigChanged. Event synced to TypeScript. + + + + Task 2: Create frontend keyboard shortcut service, store, and controller + frontend/src/services/keyboard-shortcut-service.ts, frontend/src/store/shortcuts-store.ts, frontend/src/store/controllers/shortcuts-controller.ts, frontend/src/store/index.ts + +1. Create `frontend/src/services/keyboard-shortcut-service.ts`: + + This is the FIRST file in the `services/` directory — create the directory. + + The service is a singleton that: + - Listens on `document.addEventListener('keydown', ...)` in constructor + - Resolves the active scope by walking the shadow DOM active element chain + - Looks up the key combo in the shortcuts store + - Dispatches the action by calling the appropriate store method + + Key implementation details: + - **Key string builder:** `buildKeyString(e: KeyboardEvent): string` + - Modifiers in fixed order: Ctrl (includes Meta on Mac) + Alt + Shift + - Skip bare modifier presses (return '' for Control, Alt, Shift, Meta) + - Normalize: ArrowUp→Up, ArrowDown→Down, ArrowLeft→Left, ArrowRight→Right, ' '→Space + - Single-char keys: uppercase (e.g., 's' → 'S') + + - **Shadow DOM active element:** `getDeepActiveElement(): Element | null` + - Walk `el.shadowRoot.activeElement` chain recursively + + - **isTextInputFocused():** Check deep active element — if tagName is INPUT (type text/search/url/email/password/number/tel), TEXTAREA, or isContentEditable → true + + - **resolveScope():** Returns 'text-input' | 'panel:track-list' | 'panel:queue' | 'global' + - First check isTextInputFocused → 'text-input' + - Walk up from deep active element checking closest('[data-shortcut-scope]') attribute + - If found, return `panel:${value}` + - Default: 'global' + + - **handleKeydown logic:** + 1. If scope is 'text-input': only allow Escape (blur the active element), suppress everything else — return early + 2. Build key string + 3. Get bindings from shortcutsStore + 4. First try panel-specific match: find binding where action starts with panel prefix AND key matches + 5. Then try global match: find binding where action does NOT start with any panel prefix AND key matches + 6. If match found: preventDefault, dispatch action + + - **dispatch(action: string):** Switch on action ID to call store methods: + - `player.playPause` → `playerStore.togglePlayback()` + - `player.next` → `queueStore.next()` + - `player.previous` → `queueStore.previous()` + - `player.volumeUp` → `playerStore.adjustVolume(5)` (add adjustVolume method if not exists, or use setVolume with current + 5) + - `player.volumeDown` → `playerStore.adjustVolume(-5)` + - `player.seekForward` → `playerStore.seekRelative(5)` (add seekRelative if needed, or use seek with current + 5) + - `player.seekBack` → `playerStore.seekRelative(-5)` + - `player.shuffle` → `queueStore.toggleShuffle()` + - `player.repeat` → `queueStore.cycleRepeat()` + - `player.mute` → `playerStore.toggleMute()` + - `nav.search`, `nav.searchAlt` → Focus search box: `document.querySelector('search-bar')?.shadowRoot?.querySelector('input')?.focus()` (walk shadow DOM to find the input) + - `nav.queue` → Toggle queue visibility (dispatch a custom event or call a store method) + - `app.selectAll` → `document.execCommand('selectAll')` or dispatch to active panel + - `tracklist.play` → Dispatch custom event `shortcut:tracklist-play` on document + - `tracklist.delete` → Dispatch custom event `shortcut:tracklist-delete` on document + + Export `buildKeyString` as a named export (needed by shortcut-capture widget in Plan 04). + Export the singleton: `export const keyboardShortcutService = new KeyboardShortcutService();` + + Note on volume/seek: Check the actual player-store API. If `adjustVolume(delta)` doesn't exist, the service should read current volume from playerStore state, add the delta, clamp to 0-100, and call `SetVolume()` via Wails binding. Same for seek: read current position, add delta seconds, call `Seek()`. Use the Wails-generated bindings directly (e.g., `import { SetVolume, Seek } from '../../wailsjs/go/player/Player'` — check the actual import path). + +2. Create `frontend/src/store/shortcuts-store.ts`: + + Follow existing store pattern (class-based singleton with subscribe/notify): + ```typescript + interface ShortcutBinding { + action: string; + key: string; + scope: 'global' | string; // 'global' or 'panel:track-list' etc. + category: 'Player' | 'Navigation' | 'App'; + } + + interface ShortcutsState { + bindings: Map; // action → key combo + loaded: boolean; + } + ``` + + - Constructor: call `GetShortcuts()` Wails binding to load initial state. Listen for `ShortcutsConfigChanged` event to update. + - `getBindings(): Map` — returns current bindings + - `getKeyForAction(action: string): string` — lookup + - `getActionForKey(key: string, scope?: string): string | undefined` — reverse lookup (for the service). Check panel-specific scope first, then global. + - `updateBinding(action: string, key: string): Promise` — calls `SetShortcut()` Wails binding + - `resetAll(): Promise` — calls `ResetShortcuts()` Wails binding + - `findConflict(key: string, scope: string, excludeAction: string): { action: string, key: string } | null` — for conflict detection + + Use `queueMicrotask` coalescing for notify (match existing pattern). + +3. Create `frontend/src/store/controllers/shortcuts-controller.ts`: + + Follow existing controller pattern (ReactiveController bridging store to LitElement): + ```typescript + import { ReactiveController, ReactiveControllerHost } from 'lit'; + import { shortcutsStore, ShortcutsState } from '../shortcuts-store'; + + export class ShortcutsController implements ReactiveController { + host: ReactiveControllerHost; + state: ShortcutsState; + private unsubscribe?: () => void; + + constructor(host: ReactiveControllerHost) { + this.host = host; + this.state = shortcutsStore.getState(); + host.addController(this); + } + + hostConnected() { + this.unsubscribe = shortcutsStore.subscribe((state) => { + this.state = state; + this.host.requestUpdate(); + }); + } + + hostDisconnected() { + this.unsubscribe?.(); + } + } + ``` + +4. Update `frontend/src/store/index.ts` — add exports: + ```typescript + export { shortcutsStore } from './shortcuts-store'; + export { ShortcutsController } from './controllers/shortcuts-controller'; + ``` + +5. Initialize the keyboard shortcut service. The service must be created once at app startup. Find where other singletons are initialized (likely in `frontend/src/index.ts` or the main app component). Import and reference the singleton to ensure it's instantiated: + ```typescript + import { keyboardShortcutService } from './services/keyboard-shortcut-service'; + ``` + The import alone triggers instantiation since the module exports a `new KeyboardShortcutService()` at module scope. + + + cd frontend && npx tsc --noEmit 2>&1 | head -30 + + Keyboard shortcut service listens for keydown events and dispatches actions based on scope. Shortcuts store loads bindings from Go config. Default shortcuts work: Space=play/pause, arrows=volume/seek, S/R/Q/M/N/P=player actions, /+Ctrl+F=search, Enter/Delete=tracklist panel. Text input suppression works (Escape only). Controller available for Lit components. + + + + + +```bash +cd backend && go build ./... && go vet ./... +cd ../frontend && npx tsc --noEmit +``` +Both backend and frontend compile. Shortcuts config persists through TOML. Service initializes at startup. + + + +- Go `shortcuts` package exists with `Config`, `ApplyDefaults`, `Validate`, `DefaultBindings` +- Config.go has `Shortcuts` field, `GetShortcuts`, `SetShortcuts`, `SetShortcut`, `ResetShortcuts` methods +- `ShortcutsConfigChanged` event exists and is synced to TypeScript +- Frontend `KeyboardShortcutService` singleton listens on `document.keydown` +- Shadow DOM active element resolution works (recursive walk) +- Text input suppression: only Escape passes through +- Scope resolution: text-input > panel-specific > global +- Default bindings match user decisions: Space, arrows, S, R, Q, M, N, P, /, Ctrl+F, Ctrl+A, Enter, Delete +- ShortcutsStore loads from Wails binding and subscribes to change events +- ShortcutsController bridges store to Lit components + + + +After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-SUMMARY.md` + diff --git a/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-03-PLAN.md b/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-03-PLAN.md new file mode 100644 index 0000000..5db43ee --- /dev/null +++ b/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-03-PLAN.md @@ -0,0 +1,319 @@ +--- +phase: 09-scan-cancellation-keyboard-shortcuts +plan: 03 +type: execute +wave: 2 +depends_on: + - 09-01 +files_modified: + - frontend/src/components/config-page/config-page.ts +autonomous: true +requirements: + - SCAN-01 + - SCAN-02 + - SCAN-03 + +must_haves: + truths: + - "Cancel button appears during an active scan and calls CancelScan() Wails binding" + - "Pause button appears during an active scan and calls PauseScan() Wails binding" + - "Resume button replaces Pause when paused and calls ResumeScan() Wails binding" + - "On cancel, a confirmation dialog asks 'Keep X tracks found so far, or discard?'" + - "Keep option: scan stops, partial results remain in library" + - "Discard option: scan stops, added tracks from this scan are removed" + - "LibraryScanCancelled, LibraryScanPaused, LibraryScanResumed events update UI state" + artifacts: + - path: "frontend/src/components/config-page/config-page.ts" + provides: "Pause/Cancel/Resume buttons, cancel confirmation dialog, event handling for scan control" + contains: "handleCancelScan" + key_links: + - from: "frontend/src/components/config-page/config-page.ts" + to: "backend/library/scan_control.go" + via: "Wails bindings CancelScan/PauseScan/ResumeScan" + pattern: "CancelScan|PauseScan|ResumeScan" + - from: "frontend/src/components/config-page/config-page.ts" + to: "backend/events/events.go" + via: "EventsOn for LibraryScanCancelled/Paused/Resumed" + pattern: "LibraryScanCancelled|LibraryScanPaused|LibraryScanResumed" +--- + + +Add scan control buttons (Pause, Resume, Cancel) and a cancel confirmation dialog to the config page's library scan section. + +Purpose: Frontend UX for SCAN-01/02/03. Wires to backend scan control methods from Plan 01. +Output: Modified config-page.ts with scan control UI, event handling, and cancel confirmation. + + + +@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md +@/home/caleb/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-RESEARCH.md +@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-CONTEXT.md +@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-SUMMARY.md + +@frontend/src/components/config-page/config-page.ts +@frontend/src/events.ts + + + +// From wailsjs/go/library/Library: +export function CancelScan(): Promise; +export function PauseScan(): Promise; +export function ResumeScan(): Promise; +export function IsScanActive(): Promise; +export function IsScanPaused(): Promise; + + +export const LibraryScanCancelled = "LibraryScanCancelled"; +export const LibraryScanPaused = "LibraryScanPaused"; +export const LibraryScanResumed = "LibraryScanResumed"; + + +interface ScanMetrics { + // ... existing fields ... + cancelled: boolean; + added: number; + // ... +} + + +@state() scanning = false; +@state() statusMessage = ''; +@state() scanProgress: ScanProgress | null = null; +@state() metrics: any = null; +@state() scanErrors = ''; + + +
+ + +
+ + +
+ ${this.scanProgress ? this.renderScanProgress() : this.statusMessage || 'Ready.'} +
+
+
+ + + + + Task 1: Add scan control state, event handlers, and UI buttons + frontend/src/components/config-page/config-page.ts + +1. **Add new state properties** to the config-page component class: + ```typescript + @state() private scanPaused = false; + @state() private showCancelDialog = false; + @state() private cancelMetrics: { added: number } | null = null; + ``` + +2. **Register event listeners** in `connectedCallback()` (find where existing scan events are registered and add alongside them): + ```typescript + EventsOn(events.LibraryScanPaused, () => { + this.scanPaused = true; + }); + EventsOn(events.LibraryScanResumed, () => { + this.scanPaused = false; + }); + EventsOn(events.LibraryScanCancelled, (metrics: any) => { + this.scanning = false; + this.scanPaused = false; + this.scanProgress = null; + this.metrics = metrics; + this.statusMessage = metrics?.cancelled ? 'Scan cancelled.' : 'Scan complete.'; + }); + ``` + +3. **Add scan control handler methods:** + + ```typescript + private handlePauseScan() { + PauseScan(); + } + + private handleResumeScan() { + ResumeScan(); + } + + private handleCancelScan() { + // Show confirmation dialog with current progress + const added = this.scanProgress?.added ?? 0; + this.cancelMetrics = { added }; + this.showCancelDialog = true; + } + + private async handleCancelKeep() { + this.showCancelDialog = false; + this.cancelMetrics = null; + CancelScan(); + } + + private async handleCancelDiscard() { + this.showCancelDialog = false; + this.cancelMetrics = null; + CancelScan(); + // After cancel completes, trigger a full rescan to clear partial data. + // The simpler approach: use the library's FullRescan which clears tables first. + // Wait briefly for cancel to take effect, then initiate full rescan. + // Alternatively, just cancel — the user can manually rescan if they want clean state. + // Per research: "discard" clears the entire library since partial state is unreliable. + // Call the existing clearLibraryTables equivalent via FullRescan. + // For simplicity and safety: cancel + emit a status message saying "Partial results discarded. Run Full Rescan to start fresh." + this.statusMessage = 'Scan cancelled. Partial results discarded — run Full Rescan for a clean library.'; + // Note: A more sophisticated approach would track added IDs and delete them. + // For v1.1, the simple discard = cancel + inform user approach is safer. + } + + private handleCancelDialogDismiss() { + this.showCancelDialog = false; + this.cancelMetrics = null; + } + ``` + +4. **Modify the scan buttons area** (around line 1327). Add Pause/Resume and Cancel buttons that appear ONLY during scanning. Place them between the existing scan buttons and the status bar: + + Per user decision: "Pause and Cancel buttons placed next to the existing status label, above the existing progress bar." + + Replace the `.scan-actions` div content when scanning is active: + ```typescript +
+ ${this.scanning + ? html` + ${this.scanPaused + ? html`` + : html`` + } + + ` + : html` + + + ` + } +
+ ``` + +5. **Add cancel confirmation dialog** — render it conditionally when `showCancelDialog` is true. Place the dialog render at the end of the library section's render method (after the metrics tree, before the closing `` tag): + + ```typescript + ${this.showCancelDialog ? html` +
+
e.stopPropagation()}> +
Cancel Scan
+
+ ${this.cancelMetrics?.added + ? `Keep ${this.cancelMetrics.added} tracks found so far, or discard?` + : 'Cancel the current scan?'} +
+
+ + + +
+
+
+ ` : ''} + ``` + +6. **Update the status bar** to show paused state: + In the existing status bar rendering, update to show "Paused" when paused: + ```typescript +
+ ${this.scanPaused + ? 'Scan paused.' + : this.scanProgress + ? this.renderScanProgress() + : this.statusMessage || 'Ready.'} +
+ ``` + +7. **Add CSS styles** for the cancel dialog and paused state. Add to the component's static styles: + ```css + .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; + } + .status-bar.paused { + color: var(--yj-accent, #ffd43b); + } + ``` + +8. **Import Wails bindings** — add imports for `CancelScan`, `PauseScan`, `ResumeScan` from the Wails generated bindings path. Check the actual import path by looking at how existing Library bindings are imported (e.g., `Scan` and `FullRescan`). + +9. **Reset scanPaused** in the existing `LibraryScanComplete` handler (the scan finished normally): + Add `this.scanPaused = false;` to the existing handler. +
+ + cd frontend && npx tsc --noEmit 2>&1 | head -30 + + Config page shows Pause/Cancel buttons during active scan. Pause toggles to Resume when paused. Cancel shows confirmation dialog with "Keep X tracks / Discard / Continue Scanning" options. All scan control events update UI state correctly. CSS styles render the dialog overlay properly. +
+ +
+ + +```bash +cd frontend && npx tsc --noEmit +``` +TypeScript compiles with no errors. Scan control UI renders correctly. + + + +- Pause button visible during scan, calls PauseScan() +- Resume button replaces Pause when paused, calls ResumeScan() +- Cancel button visible during scan, shows confirmation dialog +- Confirmation dialog shows track count and offers Keep/Discard/Continue +- LibraryScanPaused/Resumed/Cancelled events update component state +- Status bar shows "Scan paused." when paused +- Dialog overlay dismissible by clicking outside or "Continue Scanning" + + + +After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-03-SUMMARY.md` + diff --git a/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-04-PLAN.md b/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-04-PLAN.md new file mode 100644 index 0000000..7250d78 --- /dev/null +++ b/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-04-PLAN.md @@ -0,0 +1,505 @@ +--- +phase: 09-scan-cancellation-keyboard-shortcuts +plan: 04 +type: execute +wave: 2 +depends_on: + - 09-02 +files_modified: + - frontend/src/components/config-page/shortcut-capture.ts + - frontend/src/components/config-page/config-page.ts +autonomous: true +requirements: + - KEY-02 + - KEY-03 + +must_haves: + truths: + - "User can see all keyboard shortcuts grouped by category (Player, Navigation, App) in a Keyboard Shortcuts tab" + - "User can click a shortcut row and press a new key combo to rebind it (record-style capture)" + - "Conflicts are detected and shown — user can overwrite (old becomes unbound) or cancel" + - "Reset to defaults button resets all shortcuts" + - "Individual per-shortcut reset is available" + artifacts: + - path: "frontend/src/components/config-page/shortcut-capture.ts" + provides: "Record-style key capture web component" + exports: ["ShortcutCapture"] + - path: "frontend/src/components/config-page/config-page.ts" + provides: "Keyboard Shortcuts tab in settings" + contains: "renderShortcutsSection" + key_links: + - from: "frontend/src/components/config-page/shortcut-capture.ts" + to: "frontend/src/services/keyboard-shortcut-service.ts" + via: "Uses buildKeyString for consistent key combo normalization" + pattern: "buildKeyString" + - from: "frontend/src/components/config-page/config-page.ts" + to: "frontend/src/store/shortcuts-store.ts" + via: "ShortcutsController for reactive state, store methods for persistence" + pattern: "shortcutsStore|ShortcutsController" +--- + + +Create the Keyboard Shortcuts settings UI with record-style key capture, conflict detection, and category grouping. + +Purpose: Frontend UX for KEY-02/03 — visual shortcut customization with conflict warnings. +Output: shortcut-capture.ts component, Keyboard Shortcuts tab added to config-page.ts. + + + +@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md +@/home/caleb/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-RESEARCH.md +@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-CONTEXT.md +@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-SUMMARY.md + +@frontend/src/components/config-page/config-page.ts +@frontend/src/store/shortcuts-store.ts +@frontend/src/services/keyboard-shortcut-service.ts + + + +class ShortcutsStore { + getBindings(): Map; // action → key combo + getKeyForAction(action: string): string; + updateBinding(action: string, key: string): Promise; + resetAll(): Promise; + findConflict(key: string, scope: string, excludeAction: string): { action: string; key: string } | null; + subscribe(cb: (state: ShortcutsState) => void): () => void; + getState(): ShortcutsState; +} +export const shortcutsStore: ShortcutsStore; +export class ShortcutsController implements ReactiveController { state: ShortcutsState; } + + +export function buildKeyString(e: KeyboardEvent): string; + + +// Action scopes (derived from action prefix): +// - "player.*", "nav.*", "app.*" → global scope +// - "tracklist.*" → panel:track-list scope + +// Action categories (for UI grouping): +// - Player: player.playPause, player.next, player.previous, player.volumeUp, player.volumeDown, +// player.seekForward, player.seekBack, player.shuffle, player.repeat, player.mute +// - Navigation: nav.search, nav.searchAlt, nav.queue, tracklist.play, tracklist.delete +// - App: app.selectAll + + +// Currently renders 4 sections vertically: Theme, Favorites, Track List Columns, Library +// Each section uses component +// Per user decision: Shortcuts lives as a "Keyboard Shortcuts" tab within the settings dialog +// Since the current layout is vertical sections (NOT tabbed), add "Keyboard Shortcuts" as +// a new alongside the existing ones. +// If/when tabs are needed, that's a layout change beyond this phase. + + + + + + + Task 1: Create shortcut-capture web component + frontend/src/components/config-page/shortcut-capture.ts + +Create `frontend/src/components/config-page/shortcut-capture.ts` — a record-style key capture widget inspired by VS Code's keybinding editor. + +The component: +- Displays the current key binding as a styled button/badge +- When clicked, enters "recording" mode — displays "Press a key combo..." prompt +- Captures the next keydown event and normalizes it via `buildKeyString` +- On Escape during recording: cancels, returns to display mode +- On valid key: exits recording, dispatches `shortcut-change` CustomEvent with `{ action, key }` detail +- On bare modifier press (Ctrl alone, etc.): stays in recording mode (buildKeyString returns '') + +```typescript +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 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, + })); + } + }; + + render() { + const showReset = this.defaultKey && this.currentKey !== this.defaultKey; + return html` + + ${showReset ? html` + + ` : ''} + `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'shortcut-capture': ShortcutCapture; + } +} +``` + + + cd frontend && npx tsc --noEmit 2>&1 | head -20 + + shortcut-capture component renders a key badge, enters recording mode on click, captures keydown via buildKeyString, dispatches shortcut-change event, supports Escape cancel, and shows per-shortcut reset button when binding differs from default. + + + + Task 2: Add Keyboard Shortcuts section to config page with conflict detection + frontend/src/components/config-page/config-page.ts + +1. **Import required modules** at the top of config-page.ts: + ```typescript + import './shortcut-capture'; + import { shortcutsStore } from '../../store/shortcuts-store'; + import { ShortcutsController } from '../../store/controllers/shortcuts-controller'; + ``` + +2. **Add ShortcutsController** to the component class: + ```typescript + private shortcutsCtrl = new ShortcutsController(this); + ``` + +3. **Define shortcut metadata** — a static map of action IDs to human-readable labels and categories. Add as a class property or module-level const: + ```typescript + private static readonly SHORTCUT_META: Record = { + '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' }, + }; + ``` + +4. **Add conflict detection state:** + ```typescript + @state() private shortcutConflict: { newAction: string; newKey: string; existingAction: string } | null = null; + ``` + +5. **Add shortcut change handler:** + ```typescript + 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(); + } + ``` + +6. **Render the Keyboard Shortcuts section.** Add a new method `renderShortcutsSection()` and call it from the main render method. Place it as a new `` after the existing sections (before or after Library section — find the natural insertion point): + + ```typescript + 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}. + +
+ + +
+
+ ` : ''} +
+ `; + } + ``` + +7. **Call `renderShortcutsSection()`** from the main render method. Insert `${this.renderShortcutsSection()}` in the template — place it between "Track List Columns" and "Library" sections, or after Library. Look at the current render layout to find the best spot. + +8. **Add CSS styles** for the shortcuts section: + ```css + .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; + } + ``` +
+ + cd frontend && npx tsc --noEmit 2>&1 | head -20 + + Keyboard Shortcuts section renders in the config page with shortcuts grouped by category (Player, Navigation, App). Each row shows label + shortcut-capture widget. Conflict detection warns before overwriting. "Reset All to Defaults" and per-shortcut reset work. Panel-specific shortcuts show their scope label. +
+ +
+ + +```bash +cd frontend && npx tsc --noEmit +``` +TypeScript compiles. shortcut-capture component and shortcuts section are properly wired. + + + +- `shortcut-capture` component exists and handles recording, Escape cancel, blur cancel, reset +- Config page has a "Keyboard Shortcuts" section with category headers +- All 16 default shortcuts are listed with their labels +- Clicking a capture widget enters recording mode, pressing a key updates the binding +- Conflicts are detected and shown in a warning banner with Overwrite/Cancel options +- "Reset All to Defaults" button calls store.resetAll() +- Per-shortcut reset icon appears on hover when binding differs from default +- Panel-specific shortcuts show their scope (e.g., "track-list") next to the label + + + +After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-04-SUMMARY.md` + diff --git a/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-05-PLAN.md b/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-05-PLAN.md new file mode 100644 index 0000000..e8238cf --- /dev/null +++ b/.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-05-PLAN.md @@ -0,0 +1,164 @@ +--- +phase: 09-scan-cancellation-keyboard-shortcuts +plan: 05 +type: execute +wave: 3 +depends_on: + - 09-01 + - 09-02 + - 09-03 + - 09-04 +files_modified: [] +autonomous: false +requirements: + - SCAN-01 + - SCAN-02 + - SCAN-03 + - KEY-01 + - KEY-02 + - KEY-03 + - KEY-04 + - KEY-05 + +must_haves: + truths: + - "User can start a scan, pause it, resume it, and cancel it — all via buttons in the settings page" + - "Cancelled scan does not corrupt the database or delete unvisited files" + - "Default keyboard shortcuts work immediately — Space, arrows, S, R, Q, M, N, P, /, Ctrl+F" + - "Shortcuts are suppressed when typing in search box (except Escape)" + - "User can rebind any shortcut via record-style capture in settings" + - "Shortcut conflicts are detected and warned about" + - "Shortcut bindings persist across app restart" + artifacts: [] + key_links: [] +--- + + +Verify all Phase 9 features work together end-to-end — scan control and keyboard shortcuts. + +Purpose: Catch integration issues before marking the phase complete. +Output: Verification results and any integration fixes needed. + + + +@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md +@/home/caleb/.config/opencode/get-shit-done/templates/summary.md + + + +@.planning/PROJECT.md +@.planning/ROADMAP.md +@.planning/STATE.md +@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-01-SUMMARY.md +@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-02-SUMMARY.md +@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-03-SUMMARY.md +@.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-04-SUMMARY.md + + + + + + Task 1: Build verification and automated checks + + +1. Run the full build to verify everything compiles: + ```bash + cd backend && go build ./... + cd ../frontend && npx tsc --noEmit + ``` + +2. Run existing tests to verify no regressions: + ```bash + cd backend && go test ./... -count=1 -timeout 120s + ``` + +3. Run go vet on all packages: + ```bash + cd backend && go vet ./... + ``` + +4. Verify event sync is up to date: + ```bash + cd backend && go generate ./events/... + git diff --exit-code frontend/src/events.ts + ``` + +5. Verify the new scan control methods are Wails-bindable (exported, on a bound struct): + ```bash + grep -n "func (l \*Library) CancelScan\|func (l \*Library) PauseScan\|func (l \*Library) ResumeScan\|func (l \*Library) IsScanActive\|func (l \*Library) IsScanPaused" backend/library/scan_control.go + ``` + +6. Verify shortcuts config is accessible: + ```bash + grep -n "func (c \*Config) GetShortcuts\|func (c \*Config) SetShortcut" backend/config/config.go + ``` + +7. Fix any issues found. + + + cd backend && go build ./... && go vet ./... && go test ./... -count=1 -timeout 120s 2>&1 | tail -20 + + Full backend + frontend build passes, all existing tests pass, no regressions. + + + + Task 2: Human verification of all Phase 9 features + Verify all scan control and keyboard shortcut features work end-to-end. + Human confirms all 23 verification steps pass. + All Phase 9 requirements verified: SCAN-01/02/03 and KEY-01/02/03/04/05. + +Complete scan cancellation and keyboard shortcuts features: +1. Backend: CancelScan/PauseScan/ResumeScan methods with per-scan context and channel-based pause +2. Frontend scan UI: Pause/Resume/Cancel buttons during scan, cancel confirmation dialog +3. Keyboard shortcuts: 16 default bindings (Space, arrows, S/R/Q/M/N/P, /, Ctrl+F, Ctrl+A, Enter, Delete) +4. Keyboard shortcut settings: Record-style key capture, conflict detection, grouped by category, reset to defaults +5. Config persistence: Shortcuts saved to TOML config file + + +**Scan Control (Settings > Library):** +1. Open Settings, configure a library directory with many audio files +2. Click "Soft Scan" — verify Pause and Cancel buttons appear, progress shows +3. Click "Pause" — verify status says "Scan paused.", button changes to "Resume" +4. Click "Resume" — verify scan continues from where it left off +5. Start another scan, click "Cancel Scan" — verify confirmation dialog appears showing track count +6. Click "Keep X tracks" — verify scan stops, tracks remain in library +7. Start another scan, cancel, click "Discard" — verify scan stops with discard message + +**Keyboard Shortcuts:** +8. Without any text input focused, press Space — verify play/pause toggles +9. Press Up/Down arrows — verify volume changes +10. Press Left/Right arrows — verify seeking (if a track is playing) +11. Press S — verify shuffle toggles +12. Press R — verify repeat mode cycles +13. Press Q — verify queue panel toggles +14. Press / or Ctrl+F — verify search box gets focus +15. Click inside the search box, type — verify shortcuts do NOT fire while typing +16. Press Escape while in search box — verify search box blurs and shortcuts resume + +**Shortcut Settings (Settings > Keyboard Shortcuts):** +17. Scroll to Keyboard Shortcuts section — verify shortcuts grouped by Player, Navigation, App +18. Click on a shortcut's key badge (e.g., Space for Play/Pause) — verify it enters "Press a key combo..." mode +19. Press a new key — verify the binding updates +20. Try binding a key that's already used — verify conflict warning appears +21. Click "Overwrite" — verify old binding is cleared and new one is set +22. Click "Reset All to Defaults" — verify all shortcuts return to defaults +23. Restart the app — verify custom bindings persist + + Type "approved" or describe any issues found + + + + + +Full build passes. All existing tests pass. Human verification covers all 8 requirement IDs. + + + +- `go build ./...` and `npx tsc --noEmit` pass +- `go test ./...` passes with no regressions +- All 23 manual verification steps confirmed by user + + + +After completion, create `.planning/phases/09-scan-cancellation-keyboard-shortcuts/09-05-SUMMARY.md` +