cover-grid album dropdown behavior fixes

This commit is contained in:
2026-02-18 11:34:10 -05:00
parent 026ab1c333
commit c9e78c491c
17 changed files with 934 additions and 424 deletions
-150
View File
@@ -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 `<lit-virtualizer>`. 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)
+274 -96
View File
@@ -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.
}
}
+7 -3
View File
@@ -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(
+15 -8
View File
@@ -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)
+21 -15
View File
@@ -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
}
+17 -11
View File
@@ -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
@@ -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`
<div class="album-dropdown">
<div
class="album-dropdown"
style="height:${this.dropdownHeight}px"
>
<div
class="dropdown-tracks"
style="height:${this.tracksHeight}px"
style="height:${this.tracksHeight}px;column-count:${this.columnCount}"
>
${this.tracks.map(
(track, i) =>
+437 -117
View File
@@ -39,25 +39,26 @@ type ContextMenuTarget =
*/
type GridEntry =
| {
kind: 'album';
album: library.Album;
albumIndex: number;
}
kind: 'album';
album: library.Album;
albumIndex: number;
}
| { kind: 'phantom'; phantomIndex: number };
/** Milliseconds to debounce visibility-changed saves. */
const SCROLL_DEBOUNCE_MS = 100;
/** Pixels to change card width per scroll tick. */
const ZOOM_STEP = 16;
@customElement('cover-grid')
export class CoverGrid extends LitElement {
private libraryCtrl = new LibraryController(this);
// Grid layout constants — must match the virtualizer
// grid config and the CSS card dimensions.
private static readonly GRID_ITEM_WIDTH = 176;
private static readonly GRID_ITEM_HEIGHT = 230;
private static readonly GRID_GAP = 16;
private static readonly GRID_PADDING = 16;
// Fixed grid spacing constants.
private static readonly GRID_GAP = 8;
private static readonly GRID_PADDING = 8;
private static readonly CARD_PADDING = 5;
private lastSelectedAlbumIndex: number | null = null;
private lastSelectedTrackIndex: number | null = null;
@@ -68,22 +69,72 @@ export class CoverGrid extends LitElement {
private closeHandler = () => this.closeContextMenu();
/**
* Tracks per phantom row. Each track row is ~28px,
* the base content height is 202px, each column fits
* ~7 tracks, and 3 columns = 21 tracks per row.
* Exact height of a single track row in pixels.
* line-height 16 + padding 4+4 = 24.
*/
private static readonly TRACKS_PER_PHANTOM_ROW = 21;
private static readonly TRACK_ROW_HEIGHT = 24;
// Virtualizer grid layout instance.
private gridLayout = grid({
itemSize: {
width: `${CoverGrid.GRID_ITEM_WIDTH}px`,
height: `${CoverGrid.GRID_ITEM_HEIGHT}px`,
},
gap: `${CoverGrid.GRID_GAP}px`,
padding: `${CoverGrid.GRID_PADDING}px`,
justify: 'center',
});
/** Dropdown chrome: 12+12 padding + 2+2 border. */
private static readonly DROPDOWN_CHROME = 28;
/** Current card width — driven by the store. */
private get cardWidth(): number {
return this.libraryCtrl.coverSize;
}
/**
* Height of the text area below the cover image.
* Two lines: album name (+ year) and artist.
*/
private get cardTextHeight(): number {
const w = this.cardWidth;
if (w < 160) return 36;
return w > 250 ? 46 : 40;
}
/** Derived card height from card width. */
private get cardHeight(): number {
return this.cardWidth + this.cardTextHeight;
}
/** Image size inside the card (card minus padding). */
private get imageSize(): number {
return this.cardWidth - CoverGrid.CARD_PADDING * 2;
}
// Virtualizer grid layout instance — recreated when
// the card size changes.
private gridLayout = this.createGridLayout();
private gridLayoutWidth = 0;
private createGridLayout() {
const w = this.libraryCtrl?.coverSize ?? 176;
this.gridLayoutWidth = w;
const h = w + this.cardTextHeight;
const gap = CoverGrid.GRID_GAP;
const pad = CoverGrid.GRID_PADDING;
return grid({
itemSize: {
width: `${w}px`,
height: `${h}px`,
},
gap: `${gap}px`,
padding: `${pad}px`,
justify: 'center',
});
}
/** Wheel event handler ref for manual add/remove. */
private wheelHandler = (e: WheelEvent) => {
this.onWheel(e);
};
private wheelListenerAttached = false;
// buildVirtualizerItems() memoization cache.
private itemsCacheAlbums: library.Album[] = [];
@@ -114,10 +165,10 @@ export class CoverGrid extends LitElement {
flex-direction: column;
cursor: pointer;
border-radius: 8px;
padding: 8px;
padding: 5px;
transition: background-color 0.2s ease;
box-sizing: border-box;
width: 176px;
width: var(--card-width, 176px);
}
.album-card:hover {
@@ -166,17 +217,18 @@ export class CoverGrid extends LitElement {
#282828 100%
);
color: #b3b3b3;
font-size: 48px;
font-size: var(--placeholder-font, 48px);
}
.album-info {
margin-top: 8px;
margin-top: 4px;
min-width: 0;
text-align: center;
}
.album-name {
font-size: 14px;
font-weight: 600;
font-size: var(--album-name-font, 14px);
font-weight: 400;
color: #fff;
white-space: nowrap;
overflow: hidden;
@@ -184,12 +236,16 @@ export class CoverGrid extends LitElement {
}
.artist-name {
font-size: 12px;
font-size: var(--artist-name-font, 12px);
color: #b3b3b3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 4px;
margin-top: 2px;
}
.album-year {
color: #888;
}
/* ========================================
@@ -334,7 +390,7 @@ export class CoverGrid extends LitElement {
/** Number of phantom rows reserved for the dropdown overlay. */
@state()
private phantomRowCount = 1;
private phantomRowCount = 0;
/** Pixel offset of the dropdown overlay from the top of the scroll content. */
@state()
@@ -387,6 +443,7 @@ export class CoverGrid extends LitElement {
this.onGridImageError,
true,
);
}
override disconnectedCallback() {
@@ -404,6 +461,11 @@ export class CoverGrid extends LitElement {
this.onGridImageError,
true,
);
this.scrollContainer?.removeEventListener(
'wheel',
this.wheelHandler,
);
this.wheelListenerAttached = false;
if (this.scrollDebounceTimer !== null) {
clearTimeout(this.scrollDebounceTimer);
@@ -422,40 +484,145 @@ export class CoverGrid extends LitElement {
) {
super.updated(changed);
// When the dropdown opens or tracks change,
// recompute the phantom row count and overlay
// position.
// Ctrl+Scroll zoom — lazily attach to the
// scroll container once it exists in the DOM.
if (
changed.has('expandedAlbumId') ||
changed.has('expandedTracks')
!this.wheelListenerAttached &&
this.scrollContainer
) {
this.scrollContainer.addEventListener(
'wheel',
this.wheelHandler,
{ passive: false },
);
this.wheelListenerAttached = true;
}
// Recreate the virtualizer grid layout when
// the card size changes.
const cardSizeChanged =
this.gridLayoutWidth !== this.cardWidth;
if (cardSizeChanged) {
this.gridLayout = this.createGridLayout();
// Invalidate the items cache so the
// virtualizer picks up the new layout.
this.itemsCacheColumns = 0;
}
// Apply CSS custom properties for dynamic sizing.
this.updateSizeProperties();
// When the dropdown opens, tracks change, or
// card size changes (zoom), recompute the
// phantom row count and overlay position.
// Phantom rows are only injected once tracks
// have loaded so the dropdown renders at the
// correct size immediately.
const dropdownNeedsUpdate =
changed.has('expandedAlbumId') ||
changed.has('expandedTracks') ||
(cardSizeChanged &&
this.expandedAlbumId !== null &&
this.expandedTracks.length > 0);
if (dropdownNeedsUpdate) {
this.phantomRowCount =
this.expandedAlbumId !== null
? Math.max(
1,
Math.ceil(
this.expandedTracks
.length /
CoverGrid.TRACKS_PER_PHANTOM_ROW,
),
)
: 1;
? this.computePhantomRowCount(
this.expandedTracks.length,
)
: 0;
this.updateDropdownPosition();
}
// When a new album is expanded, scroll its
// row to the top of the viewport after the
// DOM reflects the new phantom rows.
if (
changed.has('expandedAlbumId') &&
this.expandedAlbumId !== null
) {
// Scroll to show the dropdown once tracks
// have loaded, or re-focus the expanded album
// after a zoom.
const shouldScroll =
(changed.has('expandedTracks') &&
this.expandedAlbumId !== null &&
this.expandedTracks.length > 0) ||
(cardSizeChanged &&
this.expandedAlbumId !== null &&
this.expandedTracks.length > 0);
if (shouldScroll) {
void this.updateComplete.then(() => {
this.scrollToExpandedAlbum();
this.scrollToShowDropdown();
});
}
}
/* ====================================================================
* Dynamic size properties
* ==================================================================== */
private updateSizeProperties() {
const w = this.cardWidth;
this.style.setProperty(
'--card-width',
`${w}px`,
);
// Scale placeholder initial font.
const placeholderFont =
Math.max(16, Math.round(w * 0.3));
this.style.setProperty(
'--placeholder-font',
`${placeholderFont}px`,
);
// Text sizing tiers.
if (w < 160) {
this.classList.add('size-small');
this.style.setProperty(
'--album-name-font',
'11px',
);
this.style.setProperty(
'--artist-name-font',
'10px',
);
} else if (w > 250) {
this.classList.remove('size-small');
this.style.setProperty(
'--album-name-font',
'16px',
);
this.style.setProperty(
'--artist-name-font',
'13px',
);
} else {
this.classList.remove('size-small');
this.style.setProperty(
'--album-name-font',
'14px',
);
this.style.setProperty(
'--artist-name-font',
'12px',
);
}
}
/* ====================================================================
* Ctrl+Scroll zoom
* ==================================================================== */
private onWheel(e: WheelEvent) {
if (!e.ctrlKey) return;
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
this.libraryCtrl.coverSize =
this.cardWidth + delta;
}
/* ====================================================================
* Data loading
* ==================================================================== */
@@ -561,13 +728,6 @@ export class CoverGrid extends LitElement {
// repeated calls (e.g. library re-scan).
this.resizeObserver?.disconnect();
const {
GRID_ITEM_HEIGHT,
GRID_GAP,
GRID_PADDING,
} = CoverGrid;
const rowStep = GRID_ITEM_HEIGHT + GRID_GAP;
this.currentColumnCount =
this.getColumnCount();
@@ -585,15 +745,24 @@ export class CoverGrid extends LitElement {
this.currentColumnCount = newColumns;
// If a dropdown is open, snap the
// expanded album's row to the top.
// If a dropdown is open, reposition and
// re-evaluate scroll with smart logic.
if (this.expandedAlbumId !== null) {
this.phantomRowCount =
this.computePhantomRowCount(
this.expandedTracks.length,
);
this.updateDropdownPosition();
this.scrollToExpandedAlbum();
this.scrollToShowDropdown();
return;
}
const gap = CoverGrid.GRID_GAP;
const pad = CoverGrid.GRID_PADDING;
const rowStep =
this.cardHeight + gap;
// Derive the album's row under the new
// column count. Both albumIndex and
// newColumns are integers, so newRow is
@@ -602,7 +771,7 @@ export class CoverGrid extends LitElement {
pending.albumIndex / newColumns,
);
const newY =
GRID_PADDING + newRow * rowStep;
pad + newRow * rowStep;
container.scrollTop =
newY - pending.viewportOffset;
@@ -610,6 +779,9 @@ export class CoverGrid extends LitElement {
this.resizeObserver = new ResizeObserver(
() => {
const rowStep =
this.cardHeight + CoverGrid.GRID_GAP;
// Capture on the first event using
// the pre-resize column count.
if (this.pendingFocus === null) {
@@ -681,7 +853,7 @@ export class CoverGrid extends LitElement {
container: HTMLElement,
rowStep: number,
) {
const { GRID_PADDING } = CoverGrid;
const pad = CoverGrid.GRID_PADDING;
const cols = this.currentColumnCount;
// Prefer the expanded album as focus.
@@ -695,7 +867,7 @@ export class CoverGrid extends LitElement {
idx / cols,
);
const albumY =
GRID_PADDING + albumRow * rowStep;
pad + albumRow * rowStep;
this.pendingFocus = {
albumIndex: idx,
@@ -713,8 +885,8 @@ export class CoverGrid extends LitElement {
container.scrollTop +
container.clientHeight / 2;
const centerRow = Math.floor(
Math.max(0, centerY - GRID_PADDING) /
rowStep,
Math.max(0, centerY - pad) /
rowStep,
);
const albumIndex = Math.min(
centerRow * cols,
@@ -725,7 +897,7 @@ export class CoverGrid extends LitElement {
// to the viewport top — used exactly once in
// restoreScroll, never fed back.
const albumY =
GRID_PADDING + centerRow * rowStep;
pad + centerRow * rowStep;
this.pendingFocus = {
albumIndex,
@@ -744,16 +916,72 @@ export class CoverGrid extends LitElement {
if (!el) return 1;
const { GRID_ITEM_WIDTH, GRID_GAP, GRID_PADDING } =
CoverGrid;
const gap = CoverGrid.GRID_GAP;
const pad = CoverGrid.GRID_PADDING;
const availableWidth =
el.clientWidth - GRID_PADDING * 2;
el.clientWidth - pad * 2;
return Math.max(
1,
Math.floor(
(availableWidth + GRID_GAP) /
(GRID_ITEM_WIDTH + GRID_GAP),
(availableWidth + gap) /
(this.cardWidth + gap),
),
);
}
/** Container width in pixels for the dropdown. */
private getContainerWidth(): number {
const el =
this.scrollContainer ?? this.virtualizer;
return el?.clientWidth ?? 800;
}
/**
* Derive the number of track-list columns from
* the grid container width. Mirrors the logic
* in AlbumDropdown.columnCount.
*/
private getDropdownColumnCount(): number {
const w = this.getContainerWidth();
if (w < 500) return 1;
if (w < 800) return 2;
if (w < 1200) return 3;
return 4;
}
/**
* Compute how many phantom grid rows are needed
* to fit all tracks given the current dropdown
* column count and card height.
*
* Derives the required height from the actual
* track content rather than per-column capacity,
* keeping the phantom space tight regardless of
* the number of columns.
*/
private computePhantomRowCount(
trackCount: number,
): number {
if (trackCount === 0) return 0;
const cols = this.getDropdownColumnCount();
const rowsPerCol = Math.ceil(
trackCount / cols,
);
const contentHeight =
rowsPerCol * CoverGrid.TRACK_ROW_HEIGHT +
CoverGrid.DROPDOWN_CHROME;
const gap = CoverGrid.GRID_GAP;
const rowStep = this.cardHeight + gap;
return Math.max(
1,
Math.ceil(
(contentHeight + gap) / rowStep,
),
);
}
@@ -778,7 +1006,7 @@ export class CoverGrid extends LitElement {
if (
this.itemsCacheAlbums === this.albums &&
this.itemsCacheExpandedId ===
this.expandedAlbumId &&
this.expandedAlbumId &&
this.itemsCacheColumns === columns &&
this.itemsCachePhantomRows === phantomRows
) {
@@ -804,13 +1032,13 @@ export class CoverGrid extends LitElement {
const insertAfter =
expandedIndex >= 0
? Math.min(
(Math.floor(
expandedIndex / columns,
) +
1) *
columns,
this.albums.length,
)
(Math.floor(
expandedIndex / columns,
) +
1) *
columns,
this.albums.length,
)
: this.albums.length;
const phantomCount = columns * phantomRows;
@@ -871,10 +1099,22 @@ export class CoverGrid extends LitElement {
* ==================================================================== */
/**
* Smooth-scroll the container so the expanded
* album's row is positioned at the top of the view.
* Scroll the container so the expanded album card
* and its dropdown are visible, using minimal
* movement:
*
* 1. If both fit in the viewport already, don't
* scroll.
* 2. If the album card top is slightly above the
* viewport, scroll up to reveal it.
* 3. If the dropdown bottom overflows below the
* viewport, scroll down to align it with the
* viewport bottom.
* 4. If showing the dropdown bottom would push
* the album card above the viewport, pin the
* album card top to the viewport top instead.
*/
private scrollToExpandedAlbum() {
private scrollToShowDropdown() {
const container = this.scrollContainer;
if (
@@ -890,24 +1130,60 @@ export class CoverGrid extends LitElement {
if (expandedIndex < 0) return;
const {
GRID_ITEM_HEIGHT,
GRID_GAP,
GRID_PADDING,
} = CoverGrid;
const gap = CoverGrid.GRID_GAP;
const pad = CoverGrid.GRID_PADDING;
const columns = this.getColumnCount();
const rowStep = GRID_ITEM_HEIGHT + GRID_GAP;
const rowStep = this.cardHeight + gap;
const albumRow = Math.floor(
expandedIndex / columns,
);
container.scrollTo({
top:
GRID_PADDING +
albumRow * rowStep -
GRID_GAP / 2,
behavior: 'instant',
});
// Top of the album card (at the midpoint of
// the gap above the row) in scroll-content
// coordinates.
const albumTop =
pad + albumRow * rowStep - gap / 2;
// Bottom of the dropdown overlay.
const dropdownHeight =
this.phantomRowCount * this.cardHeight +
(this.phantomRowCount - 1) * gap;
const dropdownBottom =
this.dropdownTopPx + dropdownHeight;
const viewTop = container.scrollTop;
const viewHeight = container.clientHeight;
// The valid scroll range where both the album
// top and dropdown bottom are in view:
// scrollTop <= albumTop (card visible)
// scrollTop >= dropdownBottom - viewHeight
const minScroll =
dropdownBottom - viewHeight;
const maxScroll = albumTop;
let newScrollTop: number;
if (minScroll <= maxScroll) {
// Both can fit — clamp to the valid
// range, only scrolling if needed.
newScrollTop = Math.max(
minScroll,
Math.min(viewTop, maxScroll),
);
} else {
// Combined height exceeds the viewport.
// Pin the album card top to the viewport
// top so it stays visible.
newScrollTop = albumTop;
}
if (newScrollTop !== viewTop) {
container.scrollTo({
top: newScrollTop,
behavior: 'instant',
});
}
}
private updateDropdownPosition() {
@@ -920,17 +1196,14 @@ export class CoverGrid extends LitElement {
if (expandedIndex < 0) return;
const {
GRID_ITEM_HEIGHT,
GRID_GAP,
GRID_PADDING,
} = CoverGrid;
const rowStep = GRID_ITEM_HEIGHT + GRID_GAP;
const gap = CoverGrid.GRID_GAP;
const pad = CoverGrid.GRID_PADDING;
const rowStep = this.cardHeight + gap;
const phantomStartRow =
Math.floor(expandedIndex / columns) + 1;
this.dropdownTopPx =
GRID_PADDING + phantomStartRow * rowStep;
pad + phantomStartRow * rowStep;
}
/* ====================================================================
@@ -1035,7 +1308,7 @@ export class CoverGrid extends LitElement {
this.expandedTracks = [];
this.selectedTracks = new Set();
this.lastSelectedTrackIndex = null;
this.phantomRowCount = 1;
this.phantomRowCount = 0;
return;
}
@@ -1045,7 +1318,7 @@ export class CoverGrid extends LitElement {
this.expandedTracks = [];
this.selectedTracks = new Set();
this.lastSelectedTrackIndex = null;
this.phantomRowCount = 1;
this.phantomRowCount = 0;
this.loadingTracks = true;
try {
@@ -1476,6 +1749,40 @@ export class CoverGrid extends LitElement {
return name.charAt(0).toUpperCase();
}
/**
* Pick the appropriate cover art URL based on the
* current card size and device pixel ratio.
*/
private getCoverUrl(album: library.Album): string {
const needed =
this.imageSize * window.devicePixelRatio;
if (needed <= 100) {
return (
album.CoverArtSmall ||
album.CoverArtMedium ||
album.CoverArtPath
);
}
if (needed <= 200) {
return (
album.CoverArtMedium ||
album.CoverArtLarge ||
album.CoverArtPath
);
}
if (needed <= 400) {
return (
album.CoverArtLarge ||
album.CoverArtPath
);
}
return album.CoverArtPath;
}
/* ====================================================================
* Render: grid entry (virtualizer renderItem)
* ==================================================================== */
@@ -1520,6 +1827,8 @@ export class CoverGrid extends LitElement {
.filter(Boolean)
.join(' ');
const imgSize = this.imageSize;
return html`
<div
class=${classes}
@@ -1532,10 +1841,10 @@ export class CoverGrid extends LitElement {
${album.CoverArtPath
? html`<img
class="cover-image"
src="${album.CoverArtThumbnailPath || album.CoverArtPath}"
src="${this.getCoverUrl(album)}"
alt="${album.Name} cover"
width="160"
height="160"
width="${imgSize}"
height="${imgSize}"
loading="lazy"
decoding="async"
/>`
@@ -1550,15 +1859,18 @@ export class CoverGrid extends LitElement {
class="album-name"
title="${album.Name}"
>
${album.Name}
${album.Name}${album.Year
? html`
<span class="album-year">
(${album.Year})</span
>`
: nothing}
</div>
<div
class="artist-name"
title="${album.ArtistName}"
>
${album.ArtistName}${album.Year
? ` - ${album.Year}`
: ''}
${album.ArtistName}
</div>
</div>
</div>
@@ -1605,7 +1917,8 @@ export class CoverGrid extends LitElement {
@visibilityChanged=${this.onVisibilityChanged}
></lit-virtualizer>
${this.expandedAlbumId !== null
${this.expandedAlbumId !== null &&
this.expandedTracks.length > 0
? html`
<album-dropdown
class="dropdown-overlay"
@@ -1614,6 +1927,9 @@ export class CoverGrid extends LitElement {
?loading-tracks=${this.loadingTracks}
.selectedTracks=${this.selectedTracks}
.phantomRows=${this.phantomRowCount}
.gridItemHeight=${this.cardHeight}
.gridGap=${CoverGrid.GRID_GAP}
.containerWidth=${this.getContainerWidth()}
@track-click=${this.onTrackClick}
@track-dblclick=${this.onTrackDblClick}
@track-contextmenu=${this.onTrackContextMenu}
@@ -1625,6 +1941,8 @@ export class CoverGrid extends LitElement {
<wa-popup
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.contextMenuOpen}
>
${this.contextMenuOpen
@@ -1693,6 +2011,8 @@ export class CoverGrid extends LitElement {
<wa-popup
id="playlist-submenu"
placement="right-start"
flip
shift
.active=${this.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen
@@ -135,7 +135,7 @@ export class NowPlaying extends LitElement {
<div class="cover-art">
${track.coverArt
? html`<img
src="${track.coverArtThumbnail || track.coverArt}"
src="${track.coverArtSmall || track.coverArt}"
alt="Album cover"
@error=${(e: Event) => {
const img = e.target as HTMLImageElement;
@@ -849,6 +849,8 @@ export class PlaylistView
<wa-popup
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.contextMenuOpen}
>
${this.contextMenuOpen
@@ -930,6 +932,8 @@ export class PlaylistView
<wa-popup
id="playlist-submenu"
placement="right-start"
flip
shift
.active=${this.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen &&
@@ -777,6 +777,8 @@ export class QueuePanel
<wa-popup
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.contextMenuOpen}
>
${this.contextMenuOpen
@@ -834,6 +836,8 @@ export class QueuePanel
<wa-popup
id="playlist-submenu"
placement="right-start"
flip
shift
.active=${this.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen &&
@@ -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"
* ></track-info>
* ```
*/
@@ -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`
@@ -713,6 +713,8 @@ export class TrackList extends LitElement implements SelectionHost {
<wa-popup
id="context-menu"
placement="bottom-start"
flip
shift
.active=${this.contextMenuOpen}
>
${this.contextMenuOpen
@@ -756,6 +758,8 @@ export class TrackList extends LitElement implements SelectionHost {
<wa-popup
id="playlist-submenu"
placement="right-start"
flip
shift
.active=${this.playlistSubmenuOpen}
>
${this.playlistSubmenuOpen && this.selection.hasSelection
@@ -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);
}
}
+66
View File
@@ -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<ViewName, number> = {
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
// ===================================================================
+4 -2
View File
@@ -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
}
+12 -4
View File
@@ -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"];
}
}