cover-grid album dropdown behavior fixes
This commit is contained in:
@@ -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
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
// ===================================================================
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"];
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user