From c9e78c491ce4d1b60efb9ff809e98e5f01cbeb4f Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Wed, 18 Feb 2026 11:34:10 -0500 Subject: [PATCH] cover-grid album dropdown behavior fixes --- SCROLL_RESTORE_FINDINGS.md | 150 ----- backend/library/coverart.go | 370 +++++++++--- backend/library/library.go | 10 +- backend/library/query.go | 23 +- backend/player/player.go | 36 +- backend/playlist/playlist.go | 28 +- .../components/cover-grid/album-dropdown.ts | 65 +- .../src/components/cover-grid/cover-grid.ts | 554 ++++++++++++++---- .../src/components/now-playing/now-playing.ts | 2 +- .../components/playlist-view/playlist-view.ts | 4 + .../src/components/queue-panel/queue-panel.ts | 4 + .../src/components/track-info/track-info.ts | 8 +- .../src/components/track-list/track-list.ts | 4 + .../store/controllers/library-controller.ts | 12 + frontend/src/store/library-store.ts | 66 +++ frontend/src/store/player-store.ts | 6 +- frontend/wailsjs/go/models.ts | 16 +- 17 files changed, 934 insertions(+), 424 deletions(-) delete mode 100644 SCROLL_RESTORE_FINDINGS.md diff --git a/SCROLL_RESTORE_FINDINGS.md b/SCROLL_RESTORE_FINDINGS.md deleted file mode 100644 index 277f624..0000000 --- a/SCROLL_RESTORE_FINDINGS.md +++ /dev/null @@ -1,150 +0,0 @@ -# Scroll Position Restore: Findings & Status - -## Goal - -When switching between views (track-list, cover-grid), restore the scroll position so the user doesn't lose their place. Data is already cached via `LibraryStore` so re-queries aren't needed. - -## Architecture - -- **LibraryStore** (`frontend/src/store/library-store.ts`): Singleton that caches track/album data and stores a per-view scroll position (stored as the first visible item index, not pixel offset). -- **LibraryController** (`frontend/src/store/controllers/library-controller.ts`): ReactiveController bridging LibraryStore to Lit components. -- **Save mechanism**: Both components listen for `visibilityChanged` events on ``. The event carries `{ first, last }` (indices of first/last visible items). We store `first` in the LibraryStore on every event. -- **Restore mechanism**: On first `visibilityChanged` after mount, call `scrollToIndex(savedIndex, 'start')` to jump to the saved item. -- **Backend**: `LibraryScanComplete` event emitted from Go after library scan; frontend store listens and invalidates caches. - -## Key Technical Details - -### lit-virtualizer internals (v2.1.1) - -- `LitVirtualizer` extends `LitElement` but uses `createRenderRoot() { return this }` (no shadow DOM). -- The actual work is done by a `Virtualizer` class, created by the `virtualize()` directive during `LitVirtualizer.render()`. -- The `Virtualizer` instance is stored on the host element via `hostElement[virtualizerRef]`. -- `LitVirtualizer.layoutComplete` delegates to `this[virtualizerRef]?.layoutComplete` — returns `undefined` if the Virtualizer hasn't been created yet. - -### Virtualizer layout cycle - -1. `connected()` → `_schedule(_updateLayout)` (deferred via microtask) -2. `_updateLayout()` → `_updateView()` (reads viewport bounds via `getBoundingClientRect`) → `layout.reflowIfNeeded()` -3. Layout `_reflow()` → `_getActiveItems()` → `_updateVisibleIndices()` → `_sendStateChangedMessage()` -4. `_handleLayoutMessage('stateChanged')` → `_updateDOM()`: - - **`_notifyVisibility()`** → dispatches `visibilityChanged` event - - **`_notifyRange()`** → dispatches `rangeChanged` event - - **`_finishDOMUpdate()`**: - - `_positionChildren()` — positions child elements - - `_sizeHostElement()` — updates the sizer element (creates scrollable area) - - `_correctScrollError()` — calls native `scrollTo` if there's a scroll error - -**Critical**: `visibilityChanged` fires BEFORE `_finishDOMUpdate`. This means when the event handler runs, the sizer hasn't been updated yet and children haven't been positioned yet. - -### Sizer element - -The virtualizer creates scrollable area using an absolutely positioned hidden div with `style.transform = translate(Wpx, Hpx)`. This transform creates overflow that establishes `scrollHeight`. For scroller mode (`scroller=true`), this is the mechanism for scroll area. - -### `scrollToIndex` internals - -`scrollToIndex(index, 'start')` → `element(index).scrollIntoView({ block: 'start' })` → `_scrollElementIntoView`: -- If item is in rendered range: calls native `scrollIntoView()` on the DOM element (works immediately) -- If item is NOT in range: sets `this._layout.pin = options` → triggers async reflow via `_triggerReflow()` → `Promise.resolve().then(() => this.reflowIfNeeded())` - -The pin-triggered reflow: `_setPositionFromPin()` → calculates target scroll position → `_scrollError` → `_sendStateChangedMessage()` → `_updateDOM()` → `_finishDOMUpdate()` → `_sizeHostElement()` + `_correctScrollError()` → `_nativeScrollTo()`. - -**The sizer update and scrollTo happen in the same synchronous chain.** If the browser hasn't laid out the sizer yet, `scrollTo` may be clamped to the (incorrect) current `scrollHeight`. - -### `layoutComplete` internals - -- **Lazily created**: accessing `layoutComplete` creates a promise if one doesn't exist -- **Resolved by `_scheduleLayoutComplete()`** which is called from `_childrenSizeChanged` (ResizeObserver callback) -- **Uses internal double-rAF**: `requestAnimationFrame(() => requestAnimationFrame(() => resolve()))` -- **After resolution**: `_resetLayoutCompleteState()` nulls out the promise (next access creates a fresh one) -- `_scheduleLayoutComplete` only resolves if `_layoutCompletePromise` is non-null AND `_pendingLayoutComplete` is null - -### Flow layout vs Grid layout - -**Flow layout** (`track-list`): -- Computes item positions as `index * delta` — doesn't need cross-axis viewport width -- Works immediately on first layout cycle -- First `visibilityChanged` has real item indices - -**Grid layout** (`cover-grid`): -- Needs viewport width to compute number of columns (`rolumns`) -- Viewport width comes from `_updateView()` → `getBoundingClientRect()`, but on first cycle the element may have zero width -- When `_viewDim2 <= 0` (no width), `rolumns = 0`, `_first = -1`, `_last = -1` -- `_getItemPosition` divides by `rolumns` — division by 0 when columns=0 produces `Infinity` -- First `visibilityChanged` is premature: `first: 0, last: 0` (defaults from BaseLayout constructor, since `_updateVisibleIndices` returns early when `_first === -1`) -- Real layout happens after ResizeObserver reports viewport width → second reflow → second `visibilityChanged` with real indices - -### Browser frame order - -1. JavaScript execution (microtasks, macrotasks) -2. ResizeObserver callbacks -3. `requestAnimationFrame` callbacks -4. Style/Layout calculation -5. Paint - -## What Has Been Tried - -### Approach 1: `scrollTop` pixel offset with `await updateComplete` + double-rAF -**Result**: Failed for both components. -**Why**: `await this.updateComplete` only waits for the parent Lit component's render. The `LitVirtualizer` child element exists in the DOM but hasn't completed its own Lit render cycle — the `Virtualizer` instance doesn't exist yet. The double-rAF fires too early. - -### Approach 2: `scrollTop` pixel offset with `await updateComplete` + `await layoutComplete` -**Result**: Failed for both components. -**Why**: After `await this.updateComplete`, `this.virtualizer.layoutComplete` returns `undefined` because `virtualizerRef` hasn't been set yet (LitVirtualizer hasn't rendered). `await undefined` resolves immediately. - -### Approach 3: Index-based save/restore via `visibilityChanged` event + immediate `scrollToIndex` -**Result**: Track-list worked. Cover-grid did not. -**Why track-list worked**: Flow layout has real items on first `visibilityChanged`. `scrollToIndex` sets a pin, the reflow works correctly. -**Why cover-grid failed**: First `visibilityChanged` is premature (0 columns). `scrollToIndex` sets pin, but reflow with 0 columns produces garbage positions (division by 0). - -### Approach 4: `visibilityChanged` + `scrollHeight > clientHeight` guard + `layoutComplete?.then` + `scrollToIndex` -**Result**: Cover-grid partially worked (scrolled to correct position after one manual scroll, not on initial load). Track-list worked but with a flash. -**Why cover-grid failed**: `visibilityChanged` fires BEFORE `_finishDOMUpdate` updates the sizer. So `scrollHeight` reflects the PREVIOUS state (premature layout with scrollSize=1). The guard `scrollHeight <= clientHeight` was always true during the visibilityChanged handler, causing every event to be skipped. Only after a manual scroll (which triggers a fresh `visibilityChanged` with updated DOM state) did it work. -**Why track-list flashed**: `layoutComplete` uses internal double-rAF, so the scroll happens 2 frames after the initial render at position 0. - -### Approach 5: `visibilityChanged` + `scrollHeight > clientHeight` guard removed + `layoutComplete?.then` + `scrollToIndex` -**Result**: Cover-grid worked but with flash. Track-list worked but with flash. -**Why it flashed**: The double-rAF delay in `layoutComplete` means 2 frames render at position 0 before scrolling. - -### Approach 6: `visibilityChanged` + `requestAnimationFrame` + `scrollToIndex` (no guard for cover-grid) -**Result**: Track-list worked without flash. Cover-grid did not work at all. -**Why track-list worked**: Flow layout has real items immediately. rAF fires after sizer is set. `scrollToIndex` works. -**Why cover-grid failed**: First `visibilityChanged` is premature (0 columns). `hasRestoredScroll` set to true. rAF fires, but grid still has 0 columns → pin fails. Restore opportunity consumed. - -### Approach 7: `visibilityChanged` + `last <= 0` guard for cover-grid + `requestAnimationFrame` + `scrollToIndex` -**Result**: Track-list worked without flash. Cover-grid did not work. -**Why cover-grid failed**: The `last <= 0` guard correctly skips the premature event. The second `visibilityChanged` (real layout, `last > 0`) triggers the handler. `hasRestoredScroll = true`, schedules rAF. But in the rAF callback, `scrollToIndex` → pin → reflow → `_sizeHostElement` + `_correctScrollError` → `scrollTo`. The sizer was updated in `_finishDOMUpdate` (same JS execution context as the `visibilityChanged`), but the browser hasn't processed it into `scrollHeight` yet when `scrollTo` is called inside the pin-triggered reflow. The `scrollTo` is clamped to the old (small) scrollHeight. - -**Key insight**: For cover-grid, even after waiting for the real `visibilityChanged`, the pin mechanism's synchronous reflow chain does `_sizeHostElement` + `scrollTo` atomically. The browser never gets a chance to process the sizer into `scrollHeight` between these two operations. This is why `requestAnimationFrame` alone isn't enough for cover-grid — the problem isn't WHEN we call `scrollToIndex`, it's that `scrollToIndex`'s internal reflow always does sizer+scroll atomically. - -## Current State of Code - -The current code has: -- `track-list.ts`: `visibilityChanged` handler with `requestAnimationFrame` + `scrollToIndex` (works without flash) -- `cover-grid.ts`: `visibilityChanged` handler with `last <= 0` guard + `requestAnimationFrame` + `scrollToIndex` (does NOT work) - -## Untried Ideas - -1. **Bypass `scrollToIndex` entirely for cover-grid**: After `layoutComplete` resolves (sizer is painted), directly set `el.scrollTop` instead of `scrollToIndex`. This avoids the pin mechanism's atomic sizer+scroll problem. The virtualizer will react to the scroll event and re-render items at the new position. - -2. **Use `scrollToIndex` on the SECOND `visibilityChanged` after the real one**: The first real `visibilityChanged` triggers `_finishDOMUpdate` which sets the sizer. After the browser paints (next frame), `scrollHeight` is correct. If we could delay to the next `visibilityChanged`... but there might not be one without user interaction. - -3. **Pre-set the pin before the virtualizer initializes**: If we could inject the pin into the layout before the first `_updateLayout` runs, the virtualizer would start at the correct position. But the layout's pin setter is internal. - -4. **Use `element(index).scrollIntoView()` when the item IS in range**: After `layoutComplete`, if the target item happens to be in the rendered range, native `scrollIntoView` works. But for distant items it won't be in range. - -5. **Two-phase for cover-grid**: Use `layoutComplete?.then` to wait for sizer to be painted, then set `scrollTop` directly (not `scrollToIndex`). Calculate pixel offset: `offset = padding + Math.floor(index / columns) * (itemHeight + gap)`. Grid config is known: `itemSize: 230px height, gap: 16px, padding: 16px`. Columns can be derived from viewport width: `columns = Math.floor((viewportWidth - padding*2 + gap) / (itemWidth + gap))`. This is fragile but would work. - -6. **For cover-grid, after `layoutComplete` resolves, use `requestAnimationFrame` + direct `scrollTop`**: `layoutComplete` ensures sizer is painted → `scrollHeight` is correct. Then rAF + `el.scrollTop = computedOffset` avoids the pin mechanism entirely. The virtualizer reacts to the scroll event. - -7. **Hybrid approach**: Track-list uses `requestAnimationFrame` + `scrollToIndex` (works). Cover-grid uses `layoutComplete?.then` + direct `scrollTop` (avoids pin, avoids flash since we set scrollTop before paint in the rAF following layoutComplete... actually layoutComplete already used double-rAF so there would still be a flash). - -## File Locations - -- `frontend/src/store/library-store.ts` — LibraryStore singleton -- `frontend/src/store/controllers/library-controller.ts` — LibraryController -- `frontend/src/components/track-list/track-list.ts` — Track list component -- `frontend/src/components/cover-grid/cover-grid.ts` — Cover grid component -- `frontend/src/events.ts` — Frontend event constants -- `backend/events/events.go` — Backend event constants -- `backend/library/library.go` — Emits LibraryScanComplete -- `frontend/node_modules/@lit-labs/virtualizer/` — Virtualizer source (v2.1.1) diff --git a/backend/library/coverart.go b/backend/library/coverart.go index 1e1cbfc..e39cdca 100644 --- a/backend/library/coverart.go +++ b/backend/library/coverart.go @@ -18,42 +18,75 @@ import ( "yellowjacket/backend/system" ) -const ( - // thumbnailMaxSize is the maximum width/height for generated thumbnails. - thumbnailMaxSize = 200 - // thumbnailQuality is the JPEG encoding quality for thumbnails. - thumbnailQuality = 80 - // thumbnailSuffix is appended to the content hash for thumbnail filenames. - thumbnailSuffix = "_thumb" -) +// thumbnailTier defines a single size tier for generated cover art thumbnails. +type thumbnailTier struct { + // Suffix appended to the content hash (e.g. "_sm", "_md", "_lg"). + Suffix string + // MaxSize is the maximum width or height in pixels. + MaxSize int + // Quality is the JPEG encoding quality (1-100). + Quality int +} + +// thumbnailTiers lists all generated size variants, ordered smallest to largest. +var thumbnailTiers = []thumbnailTier{ + {Suffix: "_sm", MaxSize: 100, Quality: 75}, + {Suffix: "_md", MaxSize: 200, Quality: 80}, + {Suffix: "_lg", MaxSize: 400, Quality: 85}, +} + +// legacyThumbSuffix is the old single-thumbnail suffix used before the +// multi-tier system. Kept for migration purposes only. +const legacyThumbSuffix = "_thumb" + +// isSizedVariant reports whether a filename contains any known size suffix +// (current tiers or legacy). +func isSizedVariant(name string) bool { + if strings.Contains(name, legacyThumbSuffix) { + return true + } + + for _, tier := range thumbnailTiers { + if strings.Contains(name, tier.Suffix) { + return true + } + } + + return false +} // saveCoverArt saves embedded cover art to the cache directory. // Returns the file path where the art was saved, or empty string if no picture data. -func (l *Library) saveCoverArt(pic *metadata.PictureData) (string, error) { +func (l *Library) saveCoverArt( + pic *metadata.PictureData, +) (string, error) { if pic == nil || len(pic.Data) == 0 { return "", nil } - // Get the data directory for storing cover art + // Get the data directory for storing cover art. dataDir, err := system.GetUserDataDirPath() if err != nil { - return "", fmt.Errorf("could not get user data directory: %w", err) + return "", fmt.Errorf( + "could not get user data directory: %w", err, + ) } coverDir := filepath.Join(dataDir, "covers") - // Ensure directory exists + // Ensure directory exists. if err := os.MkdirAll(coverDir, 0o755); err != nil { - return "", fmt.Errorf("could not create covers directory: %w", err) + return "", fmt.Errorf( + "could not create covers directory: %w", err, + ) } - // Generate filename from content hash (deduplication) + // Generate filename from content hash (deduplication). hash := sha256.Sum256(pic.Data) - hashStr := hex.EncodeToString(hash[:8]) // First 8 bytes = 16 hex chars + hashStr := hex.EncodeToString(hash[:8]) // First 8 bytes = 16 hex chars. ext := pic.Ext if ext == "" { - // Determine extension from MIME type ext = extensionFromMIME(pic.MIMEType) } @@ -61,103 +94,157 @@ func (l *Library) saveCoverArt(pic *metadata.PictureData) (string, error) { filePath := filepath.Join(coverDir, filename) // Skip if already exists (same content hash). - // Missing thumbnails are handled by generateMissingThumbnails() at the end of a scan. + // Missing sized variants are handled by + // generateMissingSizedVariants() at the end of a scan. if _, err := os.Stat(filePath); err == nil { - l.logger.Debug("cover art already exists", "path", filePath) + l.logger.Debug( + "cover art already exists", "path", filePath, + ) return filePath, nil } - // Write file - if err := os.WriteFile(filePath, pic.Data, 0o644); err != nil { - return "", fmt.Errorf("could not write cover art: %w", err) + // Write file. + if err := os.WriteFile( + filePath, pic.Data, 0o644, + ); err != nil { + return "", fmt.Errorf( + "could not write cover art: %w", err, + ) } - l.logger.Debug("saved cover art", "path", filePath, "size", len(pic.Data)) + l.logger.Debug( + "saved cover art", + "path", filePath, "size", len(pic.Data), + ) - // Generate thumbnail alongside the original - if err := l.generateThumbnail(pic.Data, coverDir, hashStr); err != nil { - l.logger.Warn("could not generate thumbnail", "path", filePath, "err", err) + // Generate all sized variants alongside the original. + if err := l.generateSizedVariants( + pic.Data, coverDir, hashStr, + ); err != nil { + l.logger.Warn( + "could not generate sized variants", + "path", filePath, "err", err, + ) } return filePath, nil } -// generateThumbnail creates a downscaled JPEG thumbnail from cover art image data. -// The thumbnail is saved as {hashStr}_thumb.jpg in the given directory. -func (l *Library) generateThumbnail(imgData []byte, dir, hashStr string) error { - thumbFilename := fmt.Sprintf("%s%s.jpg", hashStr, thumbnailSuffix) - thumbPath := filepath.Join(dir, thumbFilename) - - // Decode the source image +// generateSizedVariants creates all thumbnail tiers for the given image data. +// Each tier is saved as {hashStr}{suffix}.jpg in the given directory. +func (l *Library) generateSizedVariants( + imgData []byte, + dir, hashStr string, +) error { src, _, err := image.Decode(bytes.NewReader(imgData)) if err != nil { - return fmt.Errorf("could not decode image for thumbnail: %w", err) + return fmt.Errorf( + "could not decode image for thumbnails: %w", err, + ) } - // Calculate thumbnail dimensions preserving aspect ratio bounds := src.Bounds() srcW := bounds.Dx() srcH := bounds.Dy() - // Skip if image is already smaller than the thumbnail size - if srcW <= thumbnailMaxSize && srcH <= thumbnailMaxSize { - // Still save a JPEG copy for consistent serving - return l.encodeAndSaveThumbnail(src, thumbPath, srcW, srcH) + for _, tier := range thumbnailTiers { + tierPath := filepath.Join( + dir, + fmt.Sprintf("%s%s.jpg", hashStr, tier.Suffix), + ) + + w, h := fitDimensions(srcW, srcH, tier.MaxSize) + + if err := encodeAndSaveImage( + src, tierPath, w, h, tier.Quality, + ); err != nil { + l.logger.Warn( + "could not generate sized variant", + "tier", tier.Suffix, + "path", tierPath, + "err", err, + ) + + continue + } + + l.logger.Debug( + "saved sized variant", + "tier", tier.Suffix, + "path", tierPath, + "dimensions", fmt.Sprintf("%dx%d", w, h), + ) } - // Scale down preserving aspect ratio - thumbW, thumbH := thumbnailMaxSize, thumbnailMaxSize - if srcW > srcH { - thumbH = srcH * thumbnailMaxSize / srcW - } else { - thumbW = srcW * thumbnailMaxSize / srcH - } - - return l.encodeAndSaveThumbnail(src, thumbPath, thumbW, thumbH) -} - -// encodeAndSaveThumbnail scales the source image to the given dimensions and saves as JPEG. -func (l *Library) encodeAndSaveThumbnail(src image.Image, path string, w, h int) error { - dst := image.NewRGBA(image.Rect(0, 0, w, h)) - draw.CatmullRom.Scale(dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil) - - var buf bytes.Buffer - - if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: thumbnailQuality}); err != nil { - return fmt.Errorf("could not encode thumbnail: %w", err) - } - - if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { - return fmt.Errorf("could not write thumbnail: %w", err) - } - - l.logger.Debug( - "saved thumbnail", - "path", - path, - "size", - buf.Len(), - "dimensions", - fmt.Sprintf("%dx%d", w, h), - ) - return nil } -// generateMissingThumbnails scans the covers directory and generates thumbnails -// for any original cover art files that do not yet have a corresponding _thumb.jpg. -func (l *Library) generateMissingThumbnails() error { +// fitDimensions calculates the output dimensions that fit within maxSize +// while preserving the aspect ratio. If the source is already smaller +// than maxSize, the original dimensions are returned unchanged. +func fitDimensions(srcW, srcH, maxSize int) (int, int) { + if srcW <= maxSize && srcH <= maxSize { + return srcW, srcH + } + + w, h := maxSize, maxSize + if srcW > srcH { + h = srcH * maxSize / srcW + } else { + w = srcW * maxSize / srcH + } + + return w, h +} + +// encodeAndSaveImage scales the source image to the given dimensions +// and saves it as a JPEG with the specified quality. +func encodeAndSaveImage( + src image.Image, + path string, + w, h, quality int, +) error { + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + draw.CatmullRom.Scale( + dst, dst.Bounds(), src, src.Bounds(), draw.Over, nil, + ) + + var buf bytes.Buffer + + if err := jpeg.Encode( + &buf, dst, &jpeg.Options{Quality: quality}, + ); err != nil { + return fmt.Errorf("could not encode image: %w", err) + } + + if err := os.WriteFile( + path, buf.Bytes(), 0o644, + ); err != nil { + return fmt.Errorf("could not write image: %w", err) + } + + return nil +} + +// generateMissingSizedVariants scans the covers directory, migrates legacy +// _thumb files to _md, and generates any missing sized variants for each +// original cover art file. +func (l *Library) generateMissingSizedVariants() error { dataDir, err := system.GetUserDataDirPath() if err != nil { - return fmt.Errorf("could not get user data directory: %w", err) + return fmt.Errorf( + "could not get user data directory: %w", err, + ) } coverDir := filepath.Join(dataDir, "covers") entries, err := os.ReadDir(coverDir) if err != nil { - return fmt.Errorf("could not read covers directory: %w", err) + return fmt.Errorf( + "could not read covers directory: %w", err, + ) } // Build a set of existing filenames for quick lookup. @@ -169,38 +256,64 @@ func (l *Library) generateMissingThumbnails() error { } } + // First pass: migrate legacy _thumb files to _md. + migrated := l.migrateLegacyThumbs( + coverDir, existing, + ) + + // Second pass: generate missing sized variants. var generated, skipped int for _, entry := range entries { name := entry.Name() - // Skip directories and thumbnails themselves. - if entry.IsDir() || strings.Contains(name, thumbnailSuffix) { + // Skip directories and any sized variants. + if entry.IsDir() || isSizedVariant(name) { continue } - thumbName := ThumbnailFilename(name) - if _, exists := existing[thumbName]; exists { + hashStr := strings.SplitN(name, ".", 2)[0] + + // Check which tiers are missing. + allPresent := true + + for _, tier := range thumbnailTiers { + tierName := fmt.Sprintf( + "%s%s.jpg", hashStr, tier.Suffix, + ) + if _, exists := existing[tierName]; !exists { + allPresent = false + + break + } + } + + if allPresent { skipped++ continue } - // Extract hash from filename (everything before the first dot). - hashStr := strings.SplitN(name, ".", 2)[0] - - imgData, err := os.ReadFile(filepath.Join(coverDir, name)) + // Read the original and generate missing tiers. + imgData, err := os.ReadFile( + filepath.Join(coverDir, name), + ) if err != nil { l.logger.Warn( - "could not read cover art for thumbnail generation", + "could not read cover art for variant generation", "file", name, "err", err, ) continue } - if err := l.generateThumbnail(imgData, coverDir, hashStr); err != nil { - l.logger.Warn("could not generate thumbnail", "file", name, "err", err) + if err := l.generateSizedVariants( + imgData, coverDir, hashStr, + ); err != nil { + l.logger.Warn( + "could not generate sized variants", + "file", name, "err", err, + ) continue } @@ -208,18 +321,83 @@ func (l *Library) generateMissingThumbnails() error { generated++ } - l.logger.Info("thumbnail generation complete", "generated", generated, "skipped", skipped) + l.logger.Info( + "sized variant generation complete", + "generated", generated, + "skipped", skipped, + "migrated", migrated, + ) return nil } -// ThumbnailFilename derives the thumbnail filename from an original cover art filename. -// For example, "a1b2c3d4.jpg" becomes "a1b2c3d4_thumb.jpg". -func ThumbnailFilename(originalFilename string) string { +// migrateLegacyThumbs renames _thumb.jpg files to _md.jpg. +// Returns the number of files migrated. +func (l *Library) migrateLegacyThumbs( + coverDir string, + existing map[string]struct{}, +) int { + var migrated int + + for name := range existing { + if !strings.Contains(name, legacyThumbSuffix) { + continue + } + + // Derive the _md name from the legacy name. + mdName := strings.Replace( + name, legacyThumbSuffix, "_md", 1, + ) + + oldPath := filepath.Join(coverDir, name) + newPath := filepath.Join(coverDir, mdName) + + // Only rename if _md doesn't already exist. + if _, exists := existing[mdName]; exists { + // Both exist; remove the legacy file. + if err := os.Remove(oldPath); err != nil { + l.logger.Warn( + "could not remove legacy thumbnail", + "file", name, "err", err, + ) + } + + continue + } + + if err := os.Rename(oldPath, newPath); err != nil { + l.logger.Warn( + "could not migrate legacy thumbnail", + "from", name, "to", mdName, "err", err, + ) + + continue + } + + // Update the existing set so subsequent lookups + // see the new name. + delete(existing, name) + existing[mdName] = struct{}{} + + migrated++ + + l.logger.Debug( + "migrated legacy thumbnail", + "from", name, "to", mdName, + ) + } + + return migrated +} + +// SizedFilename derives a sized-variant filename from an original cover art +// filename and a size suffix. +// For example, SizedFilename("a1b2c3d4.jpg", "_sm") returns "a1b2c3d4_sm.jpg". +func SizedFilename(originalFilename, suffix string) string { ext := filepath.Ext(originalFilename) name := strings.TrimSuffix(originalFilename, ext) - return name + thumbnailSuffix + ".jpg" + return name + suffix + ".jpg" } // extensionFromMIME returns a file extension for common image MIME types. @@ -236,6 +414,6 @@ func extensionFromMIME(mimeType string) string { case "image/bmp": return "bmp" default: - return "jpg" // Default to jpg + return "jpg" // Default to jpg. } } diff --git a/backend/library/library.go b/backend/library/library.go index 63d729f..2acee18 100644 --- a/backend/library/library.go +++ b/backend/library/library.go @@ -321,9 +321,13 @@ func (l *Library) Scan() error { return true }) - // Generate thumbnails for any cover art that doesn't have one yet. - if err := l.generateMissingThumbnails(); err != nil { - l.logger.Warn("could not generate missing thumbnails", "err", err) + // Generate sized variants for any cover art missing them, + // and migrate legacy _thumb files. + if err := l.generateMissingSizedVariants(); err != nil { + l.logger.Warn( + "could not generate missing sized variants", + "err", err, + ) } l.logger.Info( diff --git a/backend/library/query.go b/backend/library/query.go index 4fc6c66..267fdce 100644 --- a/backend/library/query.go +++ b/backend/library/query.go @@ -25,12 +25,14 @@ type Track struct { // Album represents an album for the cover grid display. type Album struct { - ID int64 - Name string - ArtistName string - CoverArtPath string - CoverArtThumbnailPath string - Year int64 + ID int64 + Name string + ArtistName string + CoverArtPath string + CoverArtSmall string + CoverArtMedium string + CoverArtLarge string + Year int64 } // GetAllTracks returns an array of track structs of every file in the library. @@ -123,11 +125,16 @@ func (l *Library) GetAllAlbums() ([]Album, error) { album.Year = row.Year.Int64 } - // Convert filesystem path to URL path for the asset handler + // Convert filesystem path to URL path for the asset handler. if row.CoverArtPath != "" { base := filepath.Base(row.CoverArtPath) album.CoverArtPath = "/covers/" + base - album.CoverArtThumbnailPath = "/covers/" + ThumbnailFilename(base) + album.CoverArtSmall = "/covers/" + + SizedFilename(base, "_sm") + album.CoverArtMedium = "/covers/" + + SizedFilename(base, "_md") + album.CoverArtLarge = "/covers/" + + SizedFilename(base, "_lg") } albums = append(albums, album) diff --git a/backend/player/player.go b/backend/player/player.go index c3a70aa..441080d 100644 --- a/backend/player/player.go +++ b/backend/player/player.go @@ -20,6 +20,7 @@ import ( "yellowjacket/backend/database" "yellowjacket/backend/database/sql/sqlcgen" "yellowjacket/backend/events" + "yellowjacket/backend/library" "yellowjacket/backend/metadata" ) @@ -598,12 +599,14 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) { fileName := filepath.Base(p.currentFile.Name()) filePath := p.currentFile.Name() - // Default values + // Default values. title := fileName artist := "" album := "" coverArt := "" - coverArtThumbnail := "" + coverArtSmall := "" + coverArtMedium := "" + coverArtLarge := "" // Try to get metadata from database if p.db != nil { @@ -619,11 +622,12 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) { if meta.CoverArtPath != "" { base := filepath.Base(meta.CoverArtPath) coverArt = "/covers/" + base - - // Derive thumbnail filename from original: hash.ext -> hash_thumb.jpg - ext := filepath.Ext(base) - name := base[:len(base)-len(ext)] - coverArtThumbnail = "/covers/" + name + "_thumb.jpg" + coverArtSmall = "/covers/" + + library.SizedFilename(base, "_sm") + coverArtMedium = "/covers/" + + library.SizedFilename(base, "_md") + coverArtLarge = "/covers/" + + library.SizedFilename(base, "_lg") } } else { p.logger.Debug( @@ -634,14 +638,16 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) { } return map[string]interface{}{ - "fileName": fileName, - "filePath": filePath, - "state": string(p.state), - "title": title, - "artist": artist, - "album": album, - "coverArt": coverArt, - "coverArtThumbnail": coverArtThumbnail, + "fileName": fileName, + "filePath": filePath, + "state": string(p.state), + "title": title, + "artist": artist, + "album": album, + "coverArt": coverArt, + "coverArtSmall": coverArtSmall, + "coverArtMedium": coverArtMedium, + "coverArtLarge": coverArtLarge, }, nil } diff --git a/backend/playlist/playlist.go b/backend/playlist/playlist.go index 318f5d6..7679afb 100644 --- a/backend/playlist/playlist.go +++ b/backend/playlist/playlist.go @@ -29,15 +29,17 @@ type Summary struct { // Track represents a track within a playlist, including its metadata. type Track struct { - ID int64 `json:"ID"` - Position int64 `json:"Position"` - FilePath string `json:"FilePath"` - Title string `json:"Title"` - Artist string `json:"Artist"` - Album string `json:"Album"` - CoverArtPath string `json:"CoverArtPath"` - CoverArtThumbnailPath string `json:"CoverArtThumbnailPath"` - Duration string `json:"Duration"` + ID int64 `json:"ID"` + Position int64 `json:"Position"` + FilePath string `json:"FilePath"` + Title string `json:"Title"` + Artist string `json:"Artist"` + Album string `json:"Album"` + CoverArtPath string `json:"CoverArtPath"` + CoverArtSmall string `json:"CoverArtSmall"` + CoverArtMedium string `json:"CoverArtMedium"` + CoverArtLarge string `json:"CoverArtLarge"` + Duration string `json:"Duration"` } // WithTracks contains a playlist summary and all its tracks. @@ -213,8 +215,12 @@ func trackFromRow( if coverArtPath != "" { base := filepath.Base(coverArtPath) track.CoverArtPath = "/covers/" + base - track.CoverArtThumbnailPath = "/covers/" + - library.ThumbnailFilename(base) + track.CoverArtSmall = "/covers/" + + library.SizedFilename(base, "_sm") + track.CoverArtMedium = "/covers/" + + library.SizedFilename(base, "_md") + track.CoverArtLarge = "/covers/" + + library.SizedFilename(base, "_lg") } return track diff --git a/frontend/src/components/cover-grid/album-dropdown.ts b/frontend/src/components/cover-grid/album-dropdown.ts index 48b0c24..79aaf43 100644 --- a/frontend/src/components/cover-grid/album-dropdown.ts +++ b/frontend/src/components/cover-grid/album-dropdown.ts @@ -49,6 +49,18 @@ export class AlbumDropdown extends LitElement { @property({ type: Number, attribute: 'phantom-rows' }) phantomRows = 1; + /** Grid item height in pixels (passed from parent). */ + @property({ type: Number }) + gridItemHeight = 230; + + /** Grid gap in pixels (passed from parent). */ + @property({ type: Number }) + gridGap = 16; + + /** Width of the grid container in pixels (passed from parent). */ + @property({ type: Number }) + containerWidth = 800; + static override styles = css` :host { display: block; @@ -60,7 +72,6 @@ export class AlbumDropdown extends LitElement { border-radius: 4px; padding: 12px 16px; box-sizing: border-box; - min-height: 230px; } .dropdown-loading { @@ -73,7 +84,6 @@ export class AlbumDropdown extends LitElement { } .dropdown-tracks { - column-count: 3; column-fill: auto; column-gap: 24px; } @@ -87,6 +97,7 @@ export class AlbumDropdown extends LitElement { cursor: default; user-select: none; font-size: 12px; + line-height: 16px; break-inside: avoid; } @@ -150,6 +161,24 @@ export class AlbumDropdown extends LitElement { } `; + /* ================================================================ + * Layout helpers + * ================================================================ */ + + /** + * Derive the number of track-list columns from the + * grid container width. + */ + get columnCount(): number { + const w = this.containerWidth; + + if (w < 500) return 1; + if (w < 800) return 2; + if (w < 1200) return 3; + + return 4; + } + /* ================================================================ * Rendering helpers * ================================================================ */ @@ -165,21 +194,28 @@ export class AlbumDropdown extends LitElement { } /** - * Compute the track container height from the number - * of phantom grid rows allocated by the parent. + * Total height of the outer .album-dropdown box, + * matching the phantom grid space exactly. + */ + private get dropdownHeight(): number { + return ( + this.phantomRows * this.gridItemHeight + + (this.phantomRows - 1) * this.gridGap + ); + } + + /** + * Height of the inner .dropdown-tracks container. + * Uses the full inner space so that column-fill:auto + * fills each column completely before moving to the + * next. * - * Grid constants: itemHeight=230, gap=16. * Dropdown chrome: 12+12 padding + 2+2 border = 28px. */ private get tracksHeight(): number { - const gridItemHeight = 230; - const gridGap = 16; const chrome = 28; - const total = - this.phantomRows * gridItemHeight + - (this.phantomRows - 1) * gridGap; - return total - chrome; + return this.dropdownHeight - chrome; } /* ================================================================ @@ -323,10 +359,13 @@ export class AlbumDropdown extends LitElement { } return html` -
+
@@ -1605,7 +1917,8 @@ export class CoverGrid extends LitElement { @visibilityChanged=${this.onVisibilityChanged} > - ${this.expandedAlbumId !== null + ${this.expandedAlbumId !== null && + this.expandedTracks.length > 0 ? html` ${this.contextMenuOpen @@ -1693,6 +2011,8 @@ export class CoverGrid extends LitElement { ${this.playlistSubmenuOpen diff --git a/frontend/src/components/now-playing/now-playing.ts b/frontend/src/components/now-playing/now-playing.ts index 70b1555..211078a 100644 --- a/frontend/src/components/now-playing/now-playing.ts +++ b/frontend/src/components/now-playing/now-playing.ts @@ -135,7 +135,7 @@ export class NowPlaying extends LitElement {
${track.coverArt ? html`Album cover { const img = e.target as HTMLImageElement; diff --git a/frontend/src/components/playlist-view/playlist-view.ts b/frontend/src/components/playlist-view/playlist-view.ts index c02d168..d1167b2 100644 --- a/frontend/src/components/playlist-view/playlist-view.ts +++ b/frontend/src/components/playlist-view/playlist-view.ts @@ -849,6 +849,8 @@ export class PlaylistView ${this.contextMenuOpen @@ -930,6 +932,8 @@ export class PlaylistView ${this.playlistSubmenuOpen && diff --git a/frontend/src/components/queue-panel/queue-panel.ts b/frontend/src/components/queue-panel/queue-panel.ts index fe95544..731a080 100644 --- a/frontend/src/components/queue-panel/queue-panel.ts +++ b/frontend/src/components/queue-panel/queue-panel.ts @@ -777,6 +777,8 @@ export class QueuePanel ${this.contextMenuOpen @@ -834,6 +836,8 @@ export class QueuePanel ${this.playlistSubmenuOpen && diff --git a/frontend/src/components/track-info/track-info.ts b/frontend/src/components/track-info/track-info.ts index 73f7367..f9f07b7 100644 --- a/frontend/src/components/track-info/track-info.ts +++ b/frontend/src/components/track-info/track-info.ts @@ -24,7 +24,7 @@ import { formatMilliseconds } from '@utils/time'; * trackTitle="Song Name" * artist="Artist Name" * coverArt="/covers/abc.jpg" - * coverArtThumbnail="/covers/abc_thumb.jpg" + * coverArtSmall="/covers/abc_sm.jpg" * > * ``` */ @@ -34,7 +34,7 @@ export class TrackInfo extends LitElement { @property() artist?: string; @property() album?: string; @property() coverArt?: string; - @property() coverArtThumbnail?: string; + @property() coverArtSmall?: string; @property() duration?: string; @property() filePath?: string; @@ -109,7 +109,7 @@ export class TrackInfo extends LitElement { override render() { const showCover = - this.coverArt !== undefined || this.coverArtThumbnail !== undefined; + this.coverArt !== undefined || this.coverArtSmall !== undefined; const displayTitle = this.getDisplayTitle(); const secondaryParts = this.getSecondaryText(); @@ -134,7 +134,7 @@ export class TrackInfo extends LitElement { } private renderCoverArt() { - const src = this.coverArtThumbnail ?? this.coverArt; + const src = this.coverArtSmall ?? this.coverArt; if (!src) { return html` diff --git a/frontend/src/components/track-list/track-list.ts b/frontend/src/components/track-list/track-list.ts index fc2b857..197c890 100644 --- a/frontend/src/components/track-list/track-list.ts +++ b/frontend/src/components/track-list/track-list.ts @@ -713,6 +713,8 @@ export class TrackList extends LitElement implements SelectionHost { ${this.contextMenuOpen @@ -756,6 +758,8 @@ export class TrackList extends LitElement implements SelectionHost { ${this.playlistSubmenuOpen && this.selection.hasSelection diff --git a/frontend/src/store/controllers/library-controller.ts b/frontend/src/store/controllers/library-controller.ts index ad29588..0815c69 100644 --- a/frontend/src/store/controllers/library-controller.ts +++ b/frontend/src/store/controllers/library-controller.ts @@ -78,4 +78,16 @@ export class LibraryController implements ReactiveController { setScrollPosition(view: ViewName, offset: number): void { libraryStore.setScrollPosition(view, offset); } + + // =================================================================== + // COVER SIZE + // =================================================================== + + get coverSize(): number { + return libraryStore.getCoverSize(); + } + + set coverSize(size: number) { + libraryStore.setCoverSize(size); + } } diff --git a/frontend/src/store/library-store.ts b/frontend/src/store/library-store.ts index 5c3853f..bad9ece 100644 --- a/frontend/src/store/library-store.ts +++ b/frontend/src/store/library-store.ts @@ -7,6 +7,18 @@ type ViewName = 'tracks' | 'albums'; type Subscriber = () => void; +/** Minimum album card width in CSS pixels. */ +const COVER_SIZE_MIN = 100; + +/** Maximum album card width in CSS pixels. */ +const COVER_SIZE_MAX = 350; + +/** Default album card width in CSS pixels. */ +const COVER_SIZE_DEFAULT = 176; + +/** localStorage key for persisted cover size. */ +const COVER_SIZE_KEY = 'cover-grid-size'; + class LibraryStore { private tracks: library.Track[] | null = null; private albums: library.Album[] | null = null; @@ -14,6 +26,8 @@ class LibraryStore { private tracksLoading = false; private albumsLoading = false; + private coverSizeValue: number = COVER_SIZE_DEFAULT; + private scrollPositions: Record = { tracks: 0, albums: 0, @@ -25,6 +39,8 @@ class LibraryStore { EventsOn(Events.LibraryScanComplete, () => { this.invalidate(); }); + + this.loadCoverSize(); } // =================================================================== @@ -111,6 +127,56 @@ class LibraryStore { this.scrollPositions[view] = offset; } + // =================================================================== + // COVER SIZE + // =================================================================== + + getCoverSize(): number { + return this.coverSizeValue; + } + + setCoverSize(size: number): void { + const clamped = Math.round( + Math.max(COVER_SIZE_MIN, Math.min(COVER_SIZE_MAX, size)), + ); + + if (clamped === this.coverSizeValue) return; + + this.coverSizeValue = clamped; + this.saveCoverSize(); + this.notify(); + } + + private loadCoverSize(): void { + try { + const stored = localStorage.getItem(COVER_SIZE_KEY); + + if (stored !== null) { + const parsed = parseInt(stored, 10); + + if (!Number.isNaN(parsed)) { + this.coverSizeValue = Math.max( + COVER_SIZE_MIN, + Math.min(COVER_SIZE_MAX, parsed), + ); + } + } + } catch { + // localStorage may be unavailable. + } + } + + private saveCoverSize(): void { + try { + localStorage.setItem( + COVER_SIZE_KEY, + String(this.coverSizeValue), + ); + } catch { + // localStorage may be unavailable. + } + } + // =================================================================== // INVALIDATION // =================================================================== diff --git a/frontend/src/store/player-store.ts b/frontend/src/store/player-store.ts index 444c67d..bae6274 100644 --- a/frontend/src/store/player-store.ts +++ b/frontend/src/store/player-store.ts @@ -11,8 +11,10 @@ export interface TrackInfo { title: string; // track title (falls back to fileName) artist: string; // artist name album: string; // album name - coverArt: string; // URL path to cover art (e.g., "/covers/abc.jpg") or empty string - coverArtThumbnail: string; // URL path to thumbnail (e.g., "/covers/abc_thumb.jpg") or empty string + coverArt: string; // URL path to full-size cover art or empty string + coverArtSmall: string; // URL path to small variant (100px max) or empty string + coverArtMedium: string; // URL path to medium variant (200px max) or empty string + coverArtLarge: string; // URL path to large variant (400px max) or empty string trackChangeId: number; // monotonic counter to detect track changes even when the same file plays consecutively } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index b2d3f84..8cd3526 100755 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -5,7 +5,9 @@ export namespace library { Name: string; ArtistName: string; CoverArtPath: string; - CoverArtThumbnailPath: string; + CoverArtSmall: string; + CoverArtMedium: string; + CoverArtLarge: string; Year: number; static createFrom(source: any = {}) { @@ -18,7 +20,9 @@ export namespace library { this.Name = source["Name"]; this.ArtistName = source["ArtistName"]; this.CoverArtPath = source["CoverArtPath"]; - this.CoverArtThumbnailPath = source["CoverArtThumbnailPath"]; + this.CoverArtSmall = source["CoverArtSmall"]; + this.CoverArtMedium = source["CoverArtMedium"]; + this.CoverArtLarge = source["CoverArtLarge"]; this.Year = source["Year"]; } } @@ -71,7 +75,9 @@ export namespace playlist { Artist: string; Album: string; CoverArtPath: string; - CoverArtThumbnailPath: string; + CoverArtSmall: string; + CoverArtMedium: string; + CoverArtLarge: string; Duration: string; static createFrom(source: any = {}) { @@ -87,7 +93,9 @@ export namespace playlist { this.Artist = source["Artist"]; this.Album = source["Album"]; this.CoverArtPath = source["CoverArtPath"]; - this.CoverArtThumbnailPath = source["CoverArtThumbnailPath"]; + this.CoverArtSmall = source["CoverArtSmall"]; + this.CoverArtMedium = source["CoverArtMedium"]; + this.CoverArtLarge = source["CoverArtLarge"]; this.Duration = source["Duration"]; } }