- Add BatchFailure and BatchResult types for structured batch outcomes
- Add cancelBatch channel and suppressEvents flag to TagWriter struct
- Add CancelBatchWrite method for mid-batch cancellation from frontend
- Add BatchWriteTrackTags method processing tracks sequentially
- Emit BatchWriteProgress event per-track with current/total/succeeded/failed
- Suppress per-track TrackMetadataChanged; emit single event after batch
- Wails bindings auto-generated for BatchWriteTrackTags and CancelBatchWrite
- tagwriter namespace with BatchResult/BatchFailure in models.ts
After a successful save, re-fetch albums alongside tracks and re-resolve
cover art URLs from the updated album data. Previously the dialog only
refreshed this.track but kept stale this.coverArt URLs pointing to the
old content-hash files, causing the image to revert until reopen.
Three issues fixed:
1. asBytes() helper for []interface{} → []byte conversion — same
float64 deserialization issue as numeric fields. Cover art data
from the frontend arrives as []interface{} of float64, not []byte.
2. DB sync for cover art — was a placeholder no-op. Now saves image
to covers cache dir (content-hash dedup + thumbnail generation),
upserts cover_art row, and updates release_groups.cover_art_id.
Clear sets cover_art_id to NULL on linked release groups.
3. Frontend ReadFile returns base64 string (Go []byte JSON encoding),
not number[]. Decode with atob() before creating Uint8Array for
preview blob URL.
After saveEdit() succeeds, re-fetch tracks from library store and update
this.track with the fresh data so the dialog shows updated values instead
of the stale snapshot passed via show().
- Wire saveEdit() to WriteTrackTagsByPath with diff-only TagChanges map
- Add cover art selection via ImageFilePicker with instant blob preview
- Add cover art removal (× button) with clearCoverArt state
- Show saving indicator and disable buttons during save
- Display errors inline in action bar; edit mode stays active on error
- Add ReadFile Go method on FrontendUtil to read cover art bytes
- Add Wails binding for ReadFile
- Clean up all edit state (editValues, pendingCoverArt, errorMessage) on close/cancel
- LibraryStore now listens for TrackMetadataChanged and invalidates all caches
- Track Details context menu item visible for any right-clicked track in all 4 views
- Removed selectionCount === 1 gate from track-list, queue-panel, cover-grid, playlist-details
- WriteTrackTagsByPath resolves filePath to trackID via GetAudioFileByPath
- ImageFilePicker opens native file dialog filtered to JPEG/PNG
- Added Wails TypeScript bindings for both new methods
- Add ScanHooks callback struct to library package (follows RemovalHooks pattern)
- Move phantom resolution from library to playlist service via hook
- New ResolvePhantomTracksAfterScan reads M3U8 files and resolves paths
against current audio_files using multi-root resolution
- Handles pre-existing phantoms (match by M3U8 position) and new ones
(match by phantom_file_path)
- Delete old resolvePhantomTracks method that required phantom_file_path
- Wire ScanHooks in app.go OnStartup
- Event delegation handlers were attached in firstUpdated(), but the
virtualizer is conditionally rendered (hidden when tracks empty/loading)
- On first render, tracks are [] so virtualizer doesn't exist, and
firstUpdated() never fires again — delegation was never attached
- Move delegation to a guarded helper called from both firstUpdated()
and updated(), so it attaches as soon as the virtualizer appears
- Fixes click, multi-select, context menu, double-click, and drag in
both track-list and queue-panel components
- Replace wa-icon shadow DOM with inline SVG in queue-panel (xmark)
and album-dropdown (fav icons) to eliminate per-item shadow roots
- Memoize getBeforeEntries/getAfterEntries in cover-grid to prevent
.slice() creating new array refs that trigger virtualizer relayout
- Remove transition: scale and border-radius from album cards to
avoid per-frame repaints and anti-aliased path clipping
- Add queueMicrotask batching to player-store and favorites-store
notify() to coalesce rapid-fire updates into single renders
display:none discards scrollTop in WebKitGTK, so navigating away
from album/artist/genre grids and back reset scroll to top.
Replace inline style.display toggling with a CSS class that uses
visibility:hidden + height:0 + overflow:hidden. This collapses
the element visually while keeping the DOM alive with its scroll
state intact. contain:strict on hidden views ensures zero layout
cost while collapsed.
Targeted optimizations for the DMABuf-disabled rendering path where
every frame is software-composited:
- Replace infinite CSS scroll-text animation with transition-based
cycle that only repaints during active scroll, not during pauses
- Remove CSS mask-image on scrolling text (mask + animation was the
single most expensive continuous repaint)
- Replace wa-icon in track rows with inline SVG — eliminates 30-50
shadow DOM trees (each with SVG fetch/parse) during scroll
- Remove hover transitions on album cards, artist cards, genre cards,
fav icons, queue remove buttons — each transition was causing
per-frame software repaints
- Use visibility:hidden instead of opacity:0 for queue remove button
(binary switch vs per-frame alpha blend)
- Add decoding=async to now-playing cover art images (prevents
main-thread blocking during image decode on track change)
- Add contain:strict to fixed-height track rows (33px) and queue
items (49px) — browser skips size contribution calculations
Root causes addressed:
- track-list had no _itemSize hint for flow layout — virtualizer
defaulted to 100px, measured actual ~33px rows, then called
_correctScrollError/scrollTo on every scroll causing visible jumps
- will-change:transform on virtualizer elements caused nested GPU
layers (virtualizer positions children with transforms internally)
adding compositor overhead instead of helping
- content-visibility:auto on album cards conflicted with virtualizer's
own DOM recycling, causing redundant layout recalculation
- track-list visibilityChanged handler wrote to store synchronously
on every event (per-item during scroll) without any throttling
- IIFE closure in renderTrackRow created a new function per row per render
Fixes applied:
- Add _itemSize:{height:33} + fixed height:33px on .track-row (matches
queue-panel pattern that already worked smoothly)
- Add overflow-anchor:none on track-list virtualizer
- Remove will-change:transform from all 6 scroll containers
- Remove content-visibility:auto from album cards
- RAF-throttle visibilityChanged scroll position saves
- Replace IIFE with direct cols.map() in template
contain:strict includes size containment which caused a timing issue
where the flex-based main-panel height wasn't resolved before the
virtualizer measured its container, resulting in track-list rendering
at ~20% height on first load until something triggered a relayout.
- Queue store now uses queueMicrotask batching (matching library store pattern)
- Library store adds changeGeneration counter incremented only on actual data changes
- LibraryController checks changeGeneration before requestUpdate, skipping loading-only transitions
- Reduces unnecessary component re-renders during data loading cycles
- Replace inline arrow closures in renderTrackRow with event delegation via data-index
- Replace inline arrow closures in renderTrackItem with event delegation via data-index
- Add delegated click/dblclick/contextmenu/dragstart handlers on virtualizer elements
- Remove button click in queue panel also delegated via closest('.remove-button')
- Zero new function objects created per renderItem call during scroll
- Replace 100ms debounced scroll save in cover grid with requestAnimationFrame throttling
- Position now saves continuously during scrolling (~16ms) instead of only after stop
- Cancel pending RAF in teardown() to prevent leaks
- Add overflow-anchor: none CSS to queue panel lit-virtualizer
- Keep monkey-patch for lit-virtualizer _correctScrollError with expanded comment explaining why CSS alone is insufficient
- contain: layout style on :host of all 6 scroll-heavy components
- contain: paint + will-change: transform on all scroll containers for GPU compositing
- content-visibility: auto + contain-intrinsic-size on .album-card for off-screen skip
- cover-grid, track-list, queue-panel, artists-view, genres-view, playlist-view
- Primary views (tracks, albums, artists, genres, playlists, settings) created once and kept in DOM
- Navigation toggles display:none/display:'' instead of destroying/recreating components
- viewCache Map bounded to 6 entries — no memory leaks
- Detail views (artist-details, playlist-details, genre-details) remain ephemeral
- Scroll positions naturally preserved by keeping DOM alive
- No virtualizer reinit, no data refetch, no image reload on navigation
- contain: layout style on .content-area to isolate main+queue from header/sidebar
- contain: strict on .main-panel for maximum layout isolation
- contain: layout style paint on .main-panel > * for paint containment per view
- contain: layout style paint on sidebar to isolate from main panel
- contain: layout style on .bottom-bar to isolate footer from content reflows
Sections start collapsed showing only heading, description, and a
chevron icon. Click to expand and reveal the fields. The open property
allows sections to start expanded if needed.
Progress bar now renders below the library row being scanned, with
a small status label (phase + percent) after the library name. Removed
the separate status-bar progress display.
Add Library now sits in the same row as Scan and Full Rescan, sharing
the same btn-primary style and size. Removed the standalone button
below the library list.
Scan and Full Rescan buttons now sit above the library list for easier
access. Libraries start unchecked — both buttons are disabled until at
least one library is selected.
recording_genres and release_group_recordings reference recordings.id,
so they must be deleted BEFORE the recordings table is cleaned.
Also extends toast duration to 8s for readability.
RemoveLibrary now polls until the cancelled scan goroutine finishes
before proceeding with the removal transaction. Also show removal
errors as toast messages instead of only logging to console, and
explicitly reload library list after successful removal.
- Add checkboxes to library list with select-all header
- Soft Scan operates on selected libraries (queues each individually)
- Full Rescan stays global (nukes all data, rescans all libraries)
- Remove redundant 'Scan All Libraries' button
- Fix FullRescan Go backend to scan all libraries after wipe, not just first
Scan() wrapper was deleted in Phase 11 but config-page.ts and
library-manager.ts still imported it. Use ScanAllLibraries() for
soft scan since multi-library model scans all libraries.
- Remove 'libraries' from View type union in app-sidebar.ts
- Remove Libraries nav item from sidebar navigation list
- Remove case 'libraries' view routing in index.ts
- Remove library-manager component import from index.ts
- Add libraryId, libraryName, queuedCount to ScanProgress interface
- Replace CancelScan import with CancelCurrentScan, CancelAllScans
- Add ScanAllLibraries import and Scan All Libraries button
- Show library name in progress label (Scanning: [Library Name])
- Show queue count below progress bar when libraries queued
- Cancel dialog shows scope choice (Cancel This Library / Cancel All) when queue > 0
- Subscribe to LibraryScanQueued and LibraryScanQueueDrained events
- Track scanQueuedCount state for queue-aware UI behavior
Task 1: Schema, events, and progress types
- Add library_id to CreateAudioFile SQL INSERT and regenerate sqlc code
- Add LibraryScanQueued and LibraryScanQueueDrained event constants
- Regenerate TypeScript events via genevents
- Add LibraryID, LibraryName, QueuedCount to ScanProgress
- Add LibraryID, LibraryName to ScanMetrics
- Add libraryID field to importResult for threading through pipeline
Task 2: Scan queue coordinator and per-library scanning
- Create scan_queue.go with ScanLibrary(id), ScanAllLibraries()
- Add CancelCurrentScan(), CancelAllScans() for queue-aware cancellation
- FIFO scan queue with silent dedup (same library already scanning or queued)
- Refactor Scan() -> scanInternal(libraryID, libraryName, libraryPath)
- Replace GetAllAudioFiles with GetAudioFilesByLibrary for per-library loading
- Thread libraryID through DB writer to set CreateAudioFileParams.LibraryID
- drainQueue auto-starts next queued library or emits LibraryScanQueueDrained
- Pause freezes current scan AND queue
- Add GetScanQueueLength() and QueuedLibraryNames() for UI
- Mark CancelScan() and Scan() as deprecated
- New playlist-details component with header (back button, playlist icon, title, track count)
- Full track list with all interactions: select, play, context menu, drag, phantom handling
- Drop target support for adding tracks from other views
- Search filtering for tracks within the detail view
- Navigation routing in index.ts for playlist-details view
- Added playlist-details to SEARCHABLE_VIEWS in search-store
lit-virtualizer's flow layout has a scroll error correction mechanism that
calls scrollTo() to fix sub-pixel estimation errors. On large lists (20k+),
even with fixed-height items, floating-point differences from
getBoundingClientRect() (e.g. 49.000003px vs 49px) accumulate across items
and trigger corrections that fight the native scrollbar drag gesture,
causing the thumb to desync from the mouse.
Detect scrollbar drag by checking if mousedown occurs in the scrollbar
gutter (clientX > element clientWidth), then monkey-patch the virtualizer's
_correctScrollError method to discard accumulated errors during drag
instead of calling scrollTo(). Items continue to render/recycle normally
since layout updates are not suppressed -- only the scroll position
corrections are skipped.
The previous fix (fixed CSS height on .track-item) was insufficient because
lit-virtualizer's flow layout defaults to estimating items at 100px tall.
With 20k items, the difference between 100px estimate and 49px actual creates
a ~1M px scroll height that collapses as items get measured, triggering
scroll error corrections (programmatic scrollTo calls) that fight the native
scrollbar during drag.
Setting _itemSize to { height: 49 } via the flow() config ensures the initial
scroll size estimate matches reality, eliminating the scroll error corrections
that caused the scrollbar thumb to desync from the mouse when dragging down.
- Add handleSelectAll bound handler calling selection.selectAll() in all three components
- Register/unregister event listeners in connectedCallback/disconnectedCallback
- Add selectAll() method to SelectionController that selects all items via host interface
- Change app.selectAll dispatch from document.execCommand('selectAll') to CustomEvent('shortcut:select-all')