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"
|
"yellowjacket/backend/system"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
// thumbnailTier defines a single size tier for generated cover art thumbnails.
|
||||||
// thumbnailMaxSize is the maximum width/height for generated thumbnails.
|
type thumbnailTier struct {
|
||||||
thumbnailMaxSize = 200
|
// Suffix appended to the content hash (e.g. "_sm", "_md", "_lg").
|
||||||
// thumbnailQuality is the JPEG encoding quality for thumbnails.
|
Suffix string
|
||||||
thumbnailQuality = 80
|
// MaxSize is the maximum width or height in pixels.
|
||||||
// thumbnailSuffix is appended to the content hash for thumbnail filenames.
|
MaxSize int
|
||||||
thumbnailSuffix = "_thumb"
|
// 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.
|
// 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.
|
// 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 {
|
if pic == nil || len(pic.Data) == 0 {
|
||||||
return "", nil
|
return "", nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the data directory for storing cover art
|
// Get the data directory for storing cover art.
|
||||||
dataDir, err := system.GetUserDataDirPath()
|
dataDir, err := system.GetUserDataDirPath()
|
||||||
if err != nil {
|
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")
|
coverDir := filepath.Join(dataDir, "covers")
|
||||||
|
|
||||||
// Ensure directory exists
|
// Ensure directory exists.
|
||||||
if err := os.MkdirAll(coverDir, 0o755); err != nil {
|
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)
|
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
|
ext := pic.Ext
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
// Determine extension from MIME type
|
|
||||||
ext = extensionFromMIME(pic.MIMEType)
|
ext = extensionFromMIME(pic.MIMEType)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,103 +94,157 @@ func (l *Library) saveCoverArt(pic *metadata.PictureData) (string, error) {
|
|||||||
filePath := filepath.Join(coverDir, filename)
|
filePath := filepath.Join(coverDir, filename)
|
||||||
|
|
||||||
// Skip if already exists (same content hash).
|
// 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 {
|
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
|
return filePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write file
|
// Write file.
|
||||||
if err := os.WriteFile(filePath, pic.Data, 0o644); err != nil {
|
if err := os.WriteFile(
|
||||||
return "", fmt.Errorf("could not write cover art: %w", err)
|
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
|
// Generate all sized variants alongside the original.
|
||||||
if err := l.generateThumbnail(pic.Data, coverDir, hashStr); err != nil {
|
if err := l.generateSizedVariants(
|
||||||
l.logger.Warn("could not generate thumbnail", "path", filePath, "err", err)
|
pic.Data, coverDir, hashStr,
|
||||||
|
); err != nil {
|
||||||
|
l.logger.Warn(
|
||||||
|
"could not generate sized variants",
|
||||||
|
"path", filePath, "err", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return filePath, nil
|
return filePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateThumbnail creates a downscaled JPEG thumbnail from cover art image data.
|
// generateSizedVariants creates all thumbnail tiers for the given image data.
|
||||||
// The thumbnail is saved as {hashStr}_thumb.jpg in the given directory.
|
// Each tier is saved as {hashStr}{suffix}.jpg in the given directory.
|
||||||
func (l *Library) generateThumbnail(imgData []byte, dir, hashStr string) error {
|
func (l *Library) generateSizedVariants(
|
||||||
thumbFilename := fmt.Sprintf("%s%s.jpg", hashStr, thumbnailSuffix)
|
imgData []byte,
|
||||||
thumbPath := filepath.Join(dir, thumbFilename)
|
dir, hashStr string,
|
||||||
|
) error {
|
||||||
// Decode the source image
|
|
||||||
src, _, err := image.Decode(bytes.NewReader(imgData))
|
src, _, err := image.Decode(bytes.NewReader(imgData))
|
||||||
if err != nil {
|
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()
|
bounds := src.Bounds()
|
||||||
srcW := bounds.Dx()
|
srcW := bounds.Dx()
|
||||||
srcH := bounds.Dy()
|
srcH := bounds.Dy()
|
||||||
|
|
||||||
// Skip if image is already smaller than the thumbnail size
|
for _, tier := range thumbnailTiers {
|
||||||
if srcW <= thumbnailMaxSize && srcH <= thumbnailMaxSize {
|
tierPath := filepath.Join(
|
||||||
// Still save a JPEG copy for consistent serving
|
dir,
|
||||||
return l.encodeAndSaveThumbnail(src, thumbPath, srcW, srcH)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateMissingThumbnails scans the covers directory and generates thumbnails
|
// fitDimensions calculates the output dimensions that fit within maxSize
|
||||||
// for any original cover art files that do not yet have a corresponding _thumb.jpg.
|
// while preserving the aspect ratio. If the source is already smaller
|
||||||
func (l *Library) generateMissingThumbnails() error {
|
// 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()
|
dataDir, err := system.GetUserDataDirPath()
|
||||||
if err != nil {
|
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")
|
coverDir := filepath.Join(dataDir, "covers")
|
||||||
|
|
||||||
entries, err := os.ReadDir(coverDir)
|
entries, err := os.ReadDir(coverDir)
|
||||||
if err != nil {
|
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.
|
// 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
|
var generated, skipped int
|
||||||
|
|
||||||
for _, entry := range entries {
|
for _, entry := range entries {
|
||||||
name := entry.Name()
|
name := entry.Name()
|
||||||
|
|
||||||
// Skip directories and thumbnails themselves.
|
// Skip directories and any sized variants.
|
||||||
if entry.IsDir() || strings.Contains(name, thumbnailSuffix) {
|
if entry.IsDir() || isSizedVariant(name) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
thumbName := ThumbnailFilename(name)
|
hashStr := strings.SplitN(name, ".", 2)[0]
|
||||||
if _, exists := existing[thumbName]; exists {
|
|
||||||
|
// 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++
|
skipped++
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract hash from filename (everything before the first dot).
|
// Read the original and generate missing tiers.
|
||||||
hashStr := strings.SplitN(name, ".", 2)[0]
|
imgData, err := os.ReadFile(
|
||||||
|
filepath.Join(coverDir, name),
|
||||||
imgData, err := os.ReadFile(filepath.Join(coverDir, name))
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.logger.Warn(
|
l.logger.Warn(
|
||||||
"could not read cover art for thumbnail generation",
|
"could not read cover art for variant generation",
|
||||||
"file", name, "err", err,
|
"file", name, "err", err,
|
||||||
)
|
)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := l.generateThumbnail(imgData, coverDir, hashStr); err != nil {
|
if err := l.generateSizedVariants(
|
||||||
l.logger.Warn("could not generate thumbnail", "file", name, "err", err)
|
imgData, coverDir, hashStr,
|
||||||
|
); err != nil {
|
||||||
|
l.logger.Warn(
|
||||||
|
"could not generate sized variants",
|
||||||
|
"file", name, "err", err,
|
||||||
|
)
|
||||||
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -208,18 +321,83 @@ func (l *Library) generateMissingThumbnails() error {
|
|||||||
generated++
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ThumbnailFilename derives the thumbnail filename from an original cover art filename.
|
// migrateLegacyThumbs renames _thumb.jpg files to _md.jpg.
|
||||||
// For example, "a1b2c3d4.jpg" becomes "a1b2c3d4_thumb.jpg".
|
// Returns the number of files migrated.
|
||||||
func ThumbnailFilename(originalFilename string) string {
|
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)
|
ext := filepath.Ext(originalFilename)
|
||||||
name := strings.TrimSuffix(originalFilename, ext)
|
name := strings.TrimSuffix(originalFilename, ext)
|
||||||
|
|
||||||
return name + thumbnailSuffix + ".jpg"
|
return name + suffix + ".jpg"
|
||||||
}
|
}
|
||||||
|
|
||||||
// extensionFromMIME returns a file extension for common image MIME types.
|
// extensionFromMIME returns a file extension for common image MIME types.
|
||||||
@@ -236,6 +414,6 @@ func extensionFromMIME(mimeType string) string {
|
|||||||
case "image/bmp":
|
case "image/bmp":
|
||||||
return "bmp"
|
return "bmp"
|
||||||
default:
|
default:
|
||||||
return "jpg" // Default to jpg
|
return "jpg" // Default to jpg.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -321,9 +321,13 @@ func (l *Library) Scan() error {
|
|||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
|
|
||||||
// Generate thumbnails for any cover art that doesn't have one yet.
|
// Generate sized variants for any cover art missing them,
|
||||||
if err := l.generateMissingThumbnails(); err != nil {
|
// and migrate legacy _thumb files.
|
||||||
l.logger.Warn("could not generate missing thumbnails", "err", err)
|
if err := l.generateMissingSizedVariants(); err != nil {
|
||||||
|
l.logger.Warn(
|
||||||
|
"could not generate missing sized variants",
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
l.logger.Info(
|
l.logger.Info(
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ type Track struct {
|
|||||||
|
|
||||||
// Album represents an album for the cover grid display.
|
// Album represents an album for the cover grid display.
|
||||||
type Album struct {
|
type Album struct {
|
||||||
ID int64
|
ID int64
|
||||||
Name string
|
Name string
|
||||||
ArtistName string
|
ArtistName string
|
||||||
CoverArtPath string
|
CoverArtPath string
|
||||||
CoverArtThumbnailPath string
|
CoverArtSmall string
|
||||||
Year int64
|
CoverArtMedium string
|
||||||
|
CoverArtLarge string
|
||||||
|
Year int64
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAllTracks returns an array of track structs of every file in the library.
|
// 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
|
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 != "" {
|
if row.CoverArtPath != "" {
|
||||||
base := filepath.Base(row.CoverArtPath)
|
base := filepath.Base(row.CoverArtPath)
|
||||||
album.CoverArtPath = "/covers/" + base
|
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)
|
albums = append(albums, album)
|
||||||
|
|||||||
+21
-15
@@ -20,6 +20,7 @@ import (
|
|||||||
"yellowjacket/backend/database"
|
"yellowjacket/backend/database"
|
||||||
"yellowjacket/backend/database/sql/sqlcgen"
|
"yellowjacket/backend/database/sql/sqlcgen"
|
||||||
"yellowjacket/backend/events"
|
"yellowjacket/backend/events"
|
||||||
|
"yellowjacket/backend/library"
|
||||||
"yellowjacket/backend/metadata"
|
"yellowjacket/backend/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -598,12 +599,14 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
|
|||||||
fileName := filepath.Base(p.currentFile.Name())
|
fileName := filepath.Base(p.currentFile.Name())
|
||||||
filePath := p.currentFile.Name()
|
filePath := p.currentFile.Name()
|
||||||
|
|
||||||
// Default values
|
// Default values.
|
||||||
title := fileName
|
title := fileName
|
||||||
artist := ""
|
artist := ""
|
||||||
album := ""
|
album := ""
|
||||||
coverArt := ""
|
coverArt := ""
|
||||||
coverArtThumbnail := ""
|
coverArtSmall := ""
|
||||||
|
coverArtMedium := ""
|
||||||
|
coverArtLarge := ""
|
||||||
|
|
||||||
// Try to get metadata from database
|
// Try to get metadata from database
|
||||||
if p.db != nil {
|
if p.db != nil {
|
||||||
@@ -619,11 +622,12 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
|
|||||||
if meta.CoverArtPath != "" {
|
if meta.CoverArtPath != "" {
|
||||||
base := filepath.Base(meta.CoverArtPath)
|
base := filepath.Base(meta.CoverArtPath)
|
||||||
coverArt = "/covers/" + base
|
coverArt = "/covers/" + base
|
||||||
|
coverArtSmall = "/covers/" +
|
||||||
// Derive thumbnail filename from original: hash.ext -> hash_thumb.jpg
|
library.SizedFilename(base, "_sm")
|
||||||
ext := filepath.Ext(base)
|
coverArtMedium = "/covers/" +
|
||||||
name := base[:len(base)-len(ext)]
|
library.SizedFilename(base, "_md")
|
||||||
coverArtThumbnail = "/covers/" + name + "_thumb.jpg"
|
coverArtLarge = "/covers/" +
|
||||||
|
library.SizedFilename(base, "_lg")
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
p.logger.Debug(
|
p.logger.Debug(
|
||||||
@@ -634,14 +638,16 @@ func (p *Player) GetCurrentTrackInfo() (map[string]interface{}, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
"fileName": fileName,
|
"fileName": fileName,
|
||||||
"filePath": filePath,
|
"filePath": filePath,
|
||||||
"state": string(p.state),
|
"state": string(p.state),
|
||||||
"title": title,
|
"title": title,
|
||||||
"artist": artist,
|
"artist": artist,
|
||||||
"album": album,
|
"album": album,
|
||||||
"coverArt": coverArt,
|
"coverArt": coverArt,
|
||||||
"coverArtThumbnail": coverArtThumbnail,
|
"coverArtSmall": coverArtSmall,
|
||||||
|
"coverArtMedium": coverArtMedium,
|
||||||
|
"coverArtLarge": coverArtLarge,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,15 +29,17 @@ type Summary struct {
|
|||||||
|
|
||||||
// Track represents a track within a playlist, including its metadata.
|
// Track represents a track within a playlist, including its metadata.
|
||||||
type Track struct {
|
type Track struct {
|
||||||
ID int64 `json:"ID"`
|
ID int64 `json:"ID"`
|
||||||
Position int64 `json:"Position"`
|
Position int64 `json:"Position"`
|
||||||
FilePath string `json:"FilePath"`
|
FilePath string `json:"FilePath"`
|
||||||
Title string `json:"Title"`
|
Title string `json:"Title"`
|
||||||
Artist string `json:"Artist"`
|
Artist string `json:"Artist"`
|
||||||
Album string `json:"Album"`
|
Album string `json:"Album"`
|
||||||
CoverArtPath string `json:"CoverArtPath"`
|
CoverArtPath string `json:"CoverArtPath"`
|
||||||
CoverArtThumbnailPath string `json:"CoverArtThumbnailPath"`
|
CoverArtSmall string `json:"CoverArtSmall"`
|
||||||
Duration string `json:"Duration"`
|
CoverArtMedium string `json:"CoverArtMedium"`
|
||||||
|
CoverArtLarge string `json:"CoverArtLarge"`
|
||||||
|
Duration string `json:"Duration"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithTracks contains a playlist summary and all its tracks.
|
// WithTracks contains a playlist summary and all its tracks.
|
||||||
@@ -213,8 +215,12 @@ func trackFromRow(
|
|||||||
if coverArtPath != "" {
|
if coverArtPath != "" {
|
||||||
base := filepath.Base(coverArtPath)
|
base := filepath.Base(coverArtPath)
|
||||||
track.CoverArtPath = "/covers/" + base
|
track.CoverArtPath = "/covers/" + base
|
||||||
track.CoverArtThumbnailPath = "/covers/" +
|
track.CoverArtSmall = "/covers/" +
|
||||||
library.ThumbnailFilename(base)
|
library.SizedFilename(base, "_sm")
|
||||||
|
track.CoverArtMedium = "/covers/" +
|
||||||
|
library.SizedFilename(base, "_md")
|
||||||
|
track.CoverArtLarge = "/covers/" +
|
||||||
|
library.SizedFilename(base, "_lg")
|
||||||
}
|
}
|
||||||
|
|
||||||
return track
|
return track
|
||||||
|
|||||||
@@ -49,6 +49,18 @@ export class AlbumDropdown extends LitElement {
|
|||||||
@property({ type: Number, attribute: 'phantom-rows' })
|
@property({ type: Number, attribute: 'phantom-rows' })
|
||||||
phantomRows = 1;
|
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`
|
static override styles = css`
|
||||||
:host {
|
:host {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -60,7 +72,6 @@ export class AlbumDropdown extends LitElement {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
min-height: 230px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-loading {
|
.dropdown-loading {
|
||||||
@@ -73,7 +84,6 @@ export class AlbumDropdown extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-tracks {
|
.dropdown-tracks {
|
||||||
column-count: 3;
|
|
||||||
column-fill: auto;
|
column-fill: auto;
|
||||||
column-gap: 24px;
|
column-gap: 24px;
|
||||||
}
|
}
|
||||||
@@ -87,6 +97,7 @@ export class AlbumDropdown extends LitElement {
|
|||||||
cursor: default;
|
cursor: default;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
break-inside: avoid;
|
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
|
* Rendering helpers
|
||||||
* ================================================================ */
|
* ================================================================ */
|
||||||
@@ -165,21 +194,28 @@ export class AlbumDropdown extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute the track container height from the number
|
* Total height of the outer .album-dropdown box,
|
||||||
* of phantom grid rows allocated by the parent.
|
* 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.
|
* Dropdown chrome: 12+12 padding + 2+2 border = 28px.
|
||||||
*/
|
*/
|
||||||
private get tracksHeight(): number {
|
private get tracksHeight(): number {
|
||||||
const gridItemHeight = 230;
|
|
||||||
const gridGap = 16;
|
|
||||||
const chrome = 28;
|
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`
|
return html`
|
||||||
<div class="album-dropdown">
|
<div
|
||||||
|
class="album-dropdown"
|
||||||
|
style="height:${this.dropdownHeight}px"
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
class="dropdown-tracks"
|
class="dropdown-tracks"
|
||||||
style="height:${this.tracksHeight}px"
|
style="height:${this.tracksHeight}px;column-count:${this.columnCount}"
|
||||||
>
|
>
|
||||||
${this.tracks.map(
|
${this.tracks.map(
|
||||||
(track, i) =>
|
(track, i) =>
|
||||||
|
|||||||
@@ -39,25 +39,26 @@ type ContextMenuTarget =
|
|||||||
*/
|
*/
|
||||||
type GridEntry =
|
type GridEntry =
|
||||||
| {
|
| {
|
||||||
kind: 'album';
|
kind: 'album';
|
||||||
album: library.Album;
|
album: library.Album;
|
||||||
albumIndex: number;
|
albumIndex: number;
|
||||||
}
|
}
|
||||||
| { kind: 'phantom'; phantomIndex: number };
|
| { kind: 'phantom'; phantomIndex: number };
|
||||||
|
|
||||||
/** Milliseconds to debounce visibility-changed saves. */
|
/** Milliseconds to debounce visibility-changed saves. */
|
||||||
const SCROLL_DEBOUNCE_MS = 100;
|
const SCROLL_DEBOUNCE_MS = 100;
|
||||||
|
|
||||||
|
/** Pixels to change card width per scroll tick. */
|
||||||
|
const ZOOM_STEP = 16;
|
||||||
|
|
||||||
@customElement('cover-grid')
|
@customElement('cover-grid')
|
||||||
export class CoverGrid extends LitElement {
|
export class CoverGrid extends LitElement {
|
||||||
private libraryCtrl = new LibraryController(this);
|
private libraryCtrl = new LibraryController(this);
|
||||||
|
|
||||||
// Grid layout constants — must match the virtualizer
|
// Fixed grid spacing constants.
|
||||||
// grid config and the CSS card dimensions.
|
private static readonly GRID_GAP = 8;
|
||||||
private static readonly GRID_ITEM_WIDTH = 176;
|
private static readonly GRID_PADDING = 8;
|
||||||
private static readonly GRID_ITEM_HEIGHT = 230;
|
private static readonly CARD_PADDING = 5;
|
||||||
private static readonly GRID_GAP = 16;
|
|
||||||
private static readonly GRID_PADDING = 16;
|
|
||||||
|
|
||||||
private lastSelectedAlbumIndex: number | null = null;
|
private lastSelectedAlbumIndex: number | null = null;
|
||||||
private lastSelectedTrackIndex: number | null = null;
|
private lastSelectedTrackIndex: number | null = null;
|
||||||
@@ -68,22 +69,72 @@ export class CoverGrid extends LitElement {
|
|||||||
private closeHandler = () => this.closeContextMenu();
|
private closeHandler = () => this.closeContextMenu();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tracks per phantom row. Each track row is ~28px,
|
* Exact height of a single track row in pixels.
|
||||||
* the base content height is 202px, each column fits
|
* line-height 16 + padding 4+4 = 24.
|
||||||
* ~7 tracks, and 3 columns = 21 tracks per row.
|
|
||||||
*/
|
*/
|
||||||
private static readonly TRACKS_PER_PHANTOM_ROW = 21;
|
private static readonly TRACK_ROW_HEIGHT = 24;
|
||||||
|
|
||||||
// Virtualizer grid layout instance.
|
/** Dropdown chrome: 12+12 padding + 2+2 border. */
|
||||||
private gridLayout = grid({
|
private static readonly DROPDOWN_CHROME = 28;
|
||||||
itemSize: {
|
|
||||||
width: `${CoverGrid.GRID_ITEM_WIDTH}px`,
|
/** Current card width — driven by the store. */
|
||||||
height: `${CoverGrid.GRID_ITEM_HEIGHT}px`,
|
private get cardWidth(): number {
|
||||||
},
|
return this.libraryCtrl.coverSize;
|
||||||
gap: `${CoverGrid.GRID_GAP}px`,
|
}
|
||||||
padding: `${CoverGrid.GRID_PADDING}px`,
|
|
||||||
justify: 'center',
|
/**
|
||||||
});
|
* 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.
|
// buildVirtualizerItems() memoization cache.
|
||||||
private itemsCacheAlbums: library.Album[] = [];
|
private itemsCacheAlbums: library.Album[] = [];
|
||||||
@@ -114,10 +165,10 @@ export class CoverGrid extends LitElement {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px;
|
padding: 5px;
|
||||||
transition: background-color 0.2s ease;
|
transition: background-color 0.2s ease;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
width: 176px;
|
width: var(--card-width, 176px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-card:hover {
|
.album-card:hover {
|
||||||
@@ -166,17 +217,18 @@ export class CoverGrid extends LitElement {
|
|||||||
#282828 100%
|
#282828 100%
|
||||||
);
|
);
|
||||||
color: #b3b3b3;
|
color: #b3b3b3;
|
||||||
font-size: 48px;
|
font-size: var(--placeholder-font, 48px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-info {
|
.album-info {
|
||||||
margin-top: 8px;
|
margin-top: 4px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.album-name {
|
.album-name {
|
||||||
font-size: 14px;
|
font-size: var(--album-name-font, 14px);
|
||||||
font-weight: 600;
|
font-weight: 400;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -184,12 +236,16 @@ export class CoverGrid extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.artist-name {
|
.artist-name {
|
||||||
font-size: 12px;
|
font-size: var(--artist-name-font, 12px);
|
||||||
color: #b3b3b3;
|
color: #b3b3b3;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
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. */
|
/** Number of phantom rows reserved for the dropdown overlay. */
|
||||||
@state()
|
@state()
|
||||||
private phantomRowCount = 1;
|
private phantomRowCount = 0;
|
||||||
|
|
||||||
/** Pixel offset of the dropdown overlay from the top of the scroll content. */
|
/** Pixel offset of the dropdown overlay from the top of the scroll content. */
|
||||||
@state()
|
@state()
|
||||||
@@ -387,6 +443,7 @@ export class CoverGrid extends LitElement {
|
|||||||
this.onGridImageError,
|
this.onGridImageError,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override disconnectedCallback() {
|
override disconnectedCallback() {
|
||||||
@@ -404,6 +461,11 @@ export class CoverGrid extends LitElement {
|
|||||||
this.onGridImageError,
|
this.onGridImageError,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
|
this.scrollContainer?.removeEventListener(
|
||||||
|
'wheel',
|
||||||
|
this.wheelHandler,
|
||||||
|
);
|
||||||
|
this.wheelListenerAttached = false;
|
||||||
|
|
||||||
if (this.scrollDebounceTimer !== null) {
|
if (this.scrollDebounceTimer !== null) {
|
||||||
clearTimeout(this.scrollDebounceTimer);
|
clearTimeout(this.scrollDebounceTimer);
|
||||||
@@ -422,40 +484,145 @@ export class CoverGrid extends LitElement {
|
|||||||
) {
|
) {
|
||||||
super.updated(changed);
|
super.updated(changed);
|
||||||
|
|
||||||
// When the dropdown opens or tracks change,
|
// Ctrl+Scroll zoom — lazily attach to the
|
||||||
// recompute the phantom row count and overlay
|
// scroll container once it exists in the DOM.
|
||||||
// position.
|
|
||||||
if (
|
if (
|
||||||
changed.has('expandedAlbumId') ||
|
!this.wheelListenerAttached &&
|
||||||
changed.has('expandedTracks')
|
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.phantomRowCount =
|
||||||
this.expandedAlbumId !== null
|
this.expandedAlbumId !== null
|
||||||
? Math.max(
|
? this.computePhantomRowCount(
|
||||||
1,
|
this.expandedTracks.length,
|
||||||
Math.ceil(
|
)
|
||||||
this.expandedTracks
|
: 0;
|
||||||
.length /
|
|
||||||
CoverGrid.TRACKS_PER_PHANTOM_ROW,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: 1;
|
|
||||||
this.updateDropdownPosition();
|
this.updateDropdownPosition();
|
||||||
}
|
}
|
||||||
|
|
||||||
// When a new album is expanded, scroll its
|
// Scroll to show the dropdown once tracks
|
||||||
// row to the top of the viewport after the
|
// have loaded, or re-focus the expanded album
|
||||||
// DOM reflects the new phantom rows.
|
// after a zoom.
|
||||||
if (
|
const shouldScroll =
|
||||||
changed.has('expandedAlbumId') &&
|
(changed.has('expandedTracks') &&
|
||||||
this.expandedAlbumId !== null
|
this.expandedAlbumId !== null &&
|
||||||
) {
|
this.expandedTracks.length > 0) ||
|
||||||
|
(cardSizeChanged &&
|
||||||
|
this.expandedAlbumId !== null &&
|
||||||
|
this.expandedTracks.length > 0);
|
||||||
|
|
||||||
|
if (shouldScroll) {
|
||||||
void this.updateComplete.then(() => {
|
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
|
* Data loading
|
||||||
* ==================================================================== */
|
* ==================================================================== */
|
||||||
@@ -561,13 +728,6 @@ export class CoverGrid extends LitElement {
|
|||||||
// repeated calls (e.g. library re-scan).
|
// repeated calls (e.g. library re-scan).
|
||||||
this.resizeObserver?.disconnect();
|
this.resizeObserver?.disconnect();
|
||||||
|
|
||||||
const {
|
|
||||||
GRID_ITEM_HEIGHT,
|
|
||||||
GRID_GAP,
|
|
||||||
GRID_PADDING,
|
|
||||||
} = CoverGrid;
|
|
||||||
const rowStep = GRID_ITEM_HEIGHT + GRID_GAP;
|
|
||||||
|
|
||||||
this.currentColumnCount =
|
this.currentColumnCount =
|
||||||
this.getColumnCount();
|
this.getColumnCount();
|
||||||
|
|
||||||
@@ -585,15 +745,24 @@ export class CoverGrid extends LitElement {
|
|||||||
|
|
||||||
this.currentColumnCount = newColumns;
|
this.currentColumnCount = newColumns;
|
||||||
|
|
||||||
// If a dropdown is open, snap the
|
// If a dropdown is open, reposition and
|
||||||
// expanded album's row to the top.
|
// re-evaluate scroll with smart logic.
|
||||||
if (this.expandedAlbumId !== null) {
|
if (this.expandedAlbumId !== null) {
|
||||||
|
this.phantomRowCount =
|
||||||
|
this.computePhantomRowCount(
|
||||||
|
this.expandedTracks.length,
|
||||||
|
);
|
||||||
this.updateDropdownPosition();
|
this.updateDropdownPosition();
|
||||||
this.scrollToExpandedAlbum();
|
this.scrollToShowDropdown();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const gap = CoverGrid.GRID_GAP;
|
||||||
|
const pad = CoverGrid.GRID_PADDING;
|
||||||
|
const rowStep =
|
||||||
|
this.cardHeight + gap;
|
||||||
|
|
||||||
// Derive the album's row under the new
|
// Derive the album's row under the new
|
||||||
// column count. Both albumIndex and
|
// column count. Both albumIndex and
|
||||||
// newColumns are integers, so newRow is
|
// newColumns are integers, so newRow is
|
||||||
@@ -602,7 +771,7 @@ export class CoverGrid extends LitElement {
|
|||||||
pending.albumIndex / newColumns,
|
pending.albumIndex / newColumns,
|
||||||
);
|
);
|
||||||
const newY =
|
const newY =
|
||||||
GRID_PADDING + newRow * rowStep;
|
pad + newRow * rowStep;
|
||||||
|
|
||||||
container.scrollTop =
|
container.scrollTop =
|
||||||
newY - pending.viewportOffset;
|
newY - pending.viewportOffset;
|
||||||
@@ -610,6 +779,9 @@ export class CoverGrid extends LitElement {
|
|||||||
|
|
||||||
this.resizeObserver = new ResizeObserver(
|
this.resizeObserver = new ResizeObserver(
|
||||||
() => {
|
() => {
|
||||||
|
const rowStep =
|
||||||
|
this.cardHeight + CoverGrid.GRID_GAP;
|
||||||
|
|
||||||
// Capture on the first event using
|
// Capture on the first event using
|
||||||
// the pre-resize column count.
|
// the pre-resize column count.
|
||||||
if (this.pendingFocus === null) {
|
if (this.pendingFocus === null) {
|
||||||
@@ -681,7 +853,7 @@ export class CoverGrid extends LitElement {
|
|||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
rowStep: number,
|
rowStep: number,
|
||||||
) {
|
) {
|
||||||
const { GRID_PADDING } = CoverGrid;
|
const pad = CoverGrid.GRID_PADDING;
|
||||||
const cols = this.currentColumnCount;
|
const cols = this.currentColumnCount;
|
||||||
|
|
||||||
// Prefer the expanded album as focus.
|
// Prefer the expanded album as focus.
|
||||||
@@ -695,7 +867,7 @@ export class CoverGrid extends LitElement {
|
|||||||
idx / cols,
|
idx / cols,
|
||||||
);
|
);
|
||||||
const albumY =
|
const albumY =
|
||||||
GRID_PADDING + albumRow * rowStep;
|
pad + albumRow * rowStep;
|
||||||
|
|
||||||
this.pendingFocus = {
|
this.pendingFocus = {
|
||||||
albumIndex: idx,
|
albumIndex: idx,
|
||||||
@@ -713,8 +885,8 @@ export class CoverGrid extends LitElement {
|
|||||||
container.scrollTop +
|
container.scrollTop +
|
||||||
container.clientHeight / 2;
|
container.clientHeight / 2;
|
||||||
const centerRow = Math.floor(
|
const centerRow = Math.floor(
|
||||||
Math.max(0, centerY - GRID_PADDING) /
|
Math.max(0, centerY - pad) /
|
||||||
rowStep,
|
rowStep,
|
||||||
);
|
);
|
||||||
const albumIndex = Math.min(
|
const albumIndex = Math.min(
|
||||||
centerRow * cols,
|
centerRow * cols,
|
||||||
@@ -725,7 +897,7 @@ export class CoverGrid extends LitElement {
|
|||||||
// to the viewport top — used exactly once in
|
// to the viewport top — used exactly once in
|
||||||
// restoreScroll, never fed back.
|
// restoreScroll, never fed back.
|
||||||
const albumY =
|
const albumY =
|
||||||
GRID_PADDING + centerRow * rowStep;
|
pad + centerRow * rowStep;
|
||||||
|
|
||||||
this.pendingFocus = {
|
this.pendingFocus = {
|
||||||
albumIndex,
|
albumIndex,
|
||||||
@@ -744,16 +916,72 @@ export class CoverGrid extends LitElement {
|
|||||||
|
|
||||||
if (!el) return 1;
|
if (!el) return 1;
|
||||||
|
|
||||||
const { GRID_ITEM_WIDTH, GRID_GAP, GRID_PADDING } =
|
const gap = CoverGrid.GRID_GAP;
|
||||||
CoverGrid;
|
const pad = CoverGrid.GRID_PADDING;
|
||||||
const availableWidth =
|
const availableWidth =
|
||||||
el.clientWidth - GRID_PADDING * 2;
|
el.clientWidth - pad * 2;
|
||||||
|
|
||||||
return Math.max(
|
return Math.max(
|
||||||
1,
|
1,
|
||||||
Math.floor(
|
Math.floor(
|
||||||
(availableWidth + GRID_GAP) /
|
(availableWidth + gap) /
|
||||||
(GRID_ITEM_WIDTH + GRID_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 (
|
if (
|
||||||
this.itemsCacheAlbums === this.albums &&
|
this.itemsCacheAlbums === this.albums &&
|
||||||
this.itemsCacheExpandedId ===
|
this.itemsCacheExpandedId ===
|
||||||
this.expandedAlbumId &&
|
this.expandedAlbumId &&
|
||||||
this.itemsCacheColumns === columns &&
|
this.itemsCacheColumns === columns &&
|
||||||
this.itemsCachePhantomRows === phantomRows
|
this.itemsCachePhantomRows === phantomRows
|
||||||
) {
|
) {
|
||||||
@@ -804,13 +1032,13 @@ export class CoverGrid extends LitElement {
|
|||||||
const insertAfter =
|
const insertAfter =
|
||||||
expandedIndex >= 0
|
expandedIndex >= 0
|
||||||
? Math.min(
|
? Math.min(
|
||||||
(Math.floor(
|
(Math.floor(
|
||||||
expandedIndex / columns,
|
expandedIndex / columns,
|
||||||
) +
|
) +
|
||||||
1) *
|
1) *
|
||||||
columns,
|
columns,
|
||||||
this.albums.length,
|
this.albums.length,
|
||||||
)
|
)
|
||||||
: this.albums.length;
|
: this.albums.length;
|
||||||
|
|
||||||
const phantomCount = columns * phantomRows;
|
const phantomCount = columns * phantomRows;
|
||||||
@@ -871,10 +1099,22 @@ export class CoverGrid extends LitElement {
|
|||||||
* ==================================================================== */
|
* ==================================================================== */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Smooth-scroll the container so the expanded
|
* Scroll the container so the expanded album card
|
||||||
* album's row is positioned at the top of the view.
|
* 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;
|
const container = this.scrollContainer;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
@@ -890,24 +1130,60 @@ export class CoverGrid extends LitElement {
|
|||||||
|
|
||||||
if (expandedIndex < 0) return;
|
if (expandedIndex < 0) return;
|
||||||
|
|
||||||
const {
|
const gap = CoverGrid.GRID_GAP;
|
||||||
GRID_ITEM_HEIGHT,
|
const pad = CoverGrid.GRID_PADDING;
|
||||||
GRID_GAP,
|
|
||||||
GRID_PADDING,
|
|
||||||
} = CoverGrid;
|
|
||||||
const columns = this.getColumnCount();
|
const columns = this.getColumnCount();
|
||||||
const rowStep = GRID_ITEM_HEIGHT + GRID_GAP;
|
const rowStep = this.cardHeight + gap;
|
||||||
const albumRow = Math.floor(
|
const albumRow = Math.floor(
|
||||||
expandedIndex / columns,
|
expandedIndex / columns,
|
||||||
);
|
);
|
||||||
|
|
||||||
container.scrollTo({
|
// Top of the album card (at the midpoint of
|
||||||
top:
|
// the gap above the row) in scroll-content
|
||||||
GRID_PADDING +
|
// coordinates.
|
||||||
albumRow * rowStep -
|
const albumTop =
|
||||||
GRID_GAP / 2,
|
pad + albumRow * rowStep - gap / 2;
|
||||||
behavior: 'instant',
|
|
||||||
});
|
// 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() {
|
private updateDropdownPosition() {
|
||||||
@@ -920,17 +1196,14 @@ export class CoverGrid extends LitElement {
|
|||||||
|
|
||||||
if (expandedIndex < 0) return;
|
if (expandedIndex < 0) return;
|
||||||
|
|
||||||
const {
|
const gap = CoverGrid.GRID_GAP;
|
||||||
GRID_ITEM_HEIGHT,
|
const pad = CoverGrid.GRID_PADDING;
|
||||||
GRID_GAP,
|
const rowStep = this.cardHeight + gap;
|
||||||
GRID_PADDING,
|
|
||||||
} = CoverGrid;
|
|
||||||
const rowStep = GRID_ITEM_HEIGHT + GRID_GAP;
|
|
||||||
const phantomStartRow =
|
const phantomStartRow =
|
||||||
Math.floor(expandedIndex / columns) + 1;
|
Math.floor(expandedIndex / columns) + 1;
|
||||||
|
|
||||||
this.dropdownTopPx =
|
this.dropdownTopPx =
|
||||||
GRID_PADDING + phantomStartRow * rowStep;
|
pad + phantomStartRow * rowStep;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ====================================================================
|
/* ====================================================================
|
||||||
@@ -1035,7 +1308,7 @@ export class CoverGrid extends LitElement {
|
|||||||
this.expandedTracks = [];
|
this.expandedTracks = [];
|
||||||
this.selectedTracks = new Set();
|
this.selectedTracks = new Set();
|
||||||
this.lastSelectedTrackIndex = null;
|
this.lastSelectedTrackIndex = null;
|
||||||
this.phantomRowCount = 1;
|
this.phantomRowCount = 0;
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1045,7 +1318,7 @@ export class CoverGrid extends LitElement {
|
|||||||
this.expandedTracks = [];
|
this.expandedTracks = [];
|
||||||
this.selectedTracks = new Set();
|
this.selectedTracks = new Set();
|
||||||
this.lastSelectedTrackIndex = null;
|
this.lastSelectedTrackIndex = null;
|
||||||
this.phantomRowCount = 1;
|
this.phantomRowCount = 0;
|
||||||
this.loadingTracks = true;
|
this.loadingTracks = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1476,6 +1749,40 @@ export class CoverGrid extends LitElement {
|
|||||||
return name.charAt(0).toUpperCase();
|
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)
|
* Render: grid entry (virtualizer renderItem)
|
||||||
* ==================================================================== */
|
* ==================================================================== */
|
||||||
@@ -1520,6 +1827,8 @@ export class CoverGrid extends LitElement {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join(' ');
|
.join(' ');
|
||||||
|
|
||||||
|
const imgSize = this.imageSize;
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div
|
<div
|
||||||
class=${classes}
|
class=${classes}
|
||||||
@@ -1532,10 +1841,10 @@ export class CoverGrid extends LitElement {
|
|||||||
${album.CoverArtPath
|
${album.CoverArtPath
|
||||||
? html`<img
|
? html`<img
|
||||||
class="cover-image"
|
class="cover-image"
|
||||||
src="${album.CoverArtThumbnailPath || album.CoverArtPath}"
|
src="${this.getCoverUrl(album)}"
|
||||||
alt="${album.Name} cover"
|
alt="${album.Name} cover"
|
||||||
width="160"
|
width="${imgSize}"
|
||||||
height="160"
|
height="${imgSize}"
|
||||||
loading="lazy"
|
loading="lazy"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
/>`
|
/>`
|
||||||
@@ -1550,15 +1859,18 @@ export class CoverGrid extends LitElement {
|
|||||||
class="album-name"
|
class="album-name"
|
||||||
title="${album.Name}"
|
title="${album.Name}"
|
||||||
>
|
>
|
||||||
${album.Name}
|
${album.Name}${album.Year
|
||||||
|
? html`
|
||||||
|
<span class="album-year">
|
||||||
|
(${album.Year})</span
|
||||||
|
>`
|
||||||
|
: nothing}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="artist-name"
|
class="artist-name"
|
||||||
title="${album.ArtistName}"
|
title="${album.ArtistName}"
|
||||||
>
|
>
|
||||||
${album.ArtistName}${album.Year
|
${album.ArtistName}
|
||||||
? ` - ${album.Year}`
|
|
||||||
: ''}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1605,7 +1917,8 @@ export class CoverGrid extends LitElement {
|
|||||||
@visibilityChanged=${this.onVisibilityChanged}
|
@visibilityChanged=${this.onVisibilityChanged}
|
||||||
></lit-virtualizer>
|
></lit-virtualizer>
|
||||||
|
|
||||||
${this.expandedAlbumId !== null
|
${this.expandedAlbumId !== null &&
|
||||||
|
this.expandedTracks.length > 0
|
||||||
? html`
|
? html`
|
||||||
<album-dropdown
|
<album-dropdown
|
||||||
class="dropdown-overlay"
|
class="dropdown-overlay"
|
||||||
@@ -1614,6 +1927,9 @@ export class CoverGrid extends LitElement {
|
|||||||
?loading-tracks=${this.loadingTracks}
|
?loading-tracks=${this.loadingTracks}
|
||||||
.selectedTracks=${this.selectedTracks}
|
.selectedTracks=${this.selectedTracks}
|
||||||
.phantomRows=${this.phantomRowCount}
|
.phantomRows=${this.phantomRowCount}
|
||||||
|
.gridItemHeight=${this.cardHeight}
|
||||||
|
.gridGap=${CoverGrid.GRID_GAP}
|
||||||
|
.containerWidth=${this.getContainerWidth()}
|
||||||
@track-click=${this.onTrackClick}
|
@track-click=${this.onTrackClick}
|
||||||
@track-dblclick=${this.onTrackDblClick}
|
@track-dblclick=${this.onTrackDblClick}
|
||||||
@track-contextmenu=${this.onTrackContextMenu}
|
@track-contextmenu=${this.onTrackContextMenu}
|
||||||
@@ -1625,6 +1941,8 @@ export class CoverGrid extends LitElement {
|
|||||||
<wa-popup
|
<wa-popup
|
||||||
id="context-menu"
|
id="context-menu"
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
|
flip
|
||||||
|
shift
|
||||||
.active=${this.contextMenuOpen}
|
.active=${this.contextMenuOpen}
|
||||||
>
|
>
|
||||||
${this.contextMenuOpen
|
${this.contextMenuOpen
|
||||||
@@ -1693,6 +2011,8 @@ export class CoverGrid extends LitElement {
|
|||||||
<wa-popup
|
<wa-popup
|
||||||
id="playlist-submenu"
|
id="playlist-submenu"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
|
flip
|
||||||
|
shift
|
||||||
.active=${this.playlistSubmenuOpen}
|
.active=${this.playlistSubmenuOpen}
|
||||||
>
|
>
|
||||||
${this.playlistSubmenuOpen
|
${this.playlistSubmenuOpen
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export class NowPlaying extends LitElement {
|
|||||||
<div class="cover-art">
|
<div class="cover-art">
|
||||||
${track.coverArt
|
${track.coverArt
|
||||||
? html`<img
|
? html`<img
|
||||||
src="${track.coverArtThumbnail || track.coverArt}"
|
src="${track.coverArtSmall || track.coverArt}"
|
||||||
alt="Album cover"
|
alt="Album cover"
|
||||||
@error=${(e: Event) => {
|
@error=${(e: Event) => {
|
||||||
const img = e.target as HTMLImageElement;
|
const img = e.target as HTMLImageElement;
|
||||||
|
|||||||
@@ -849,6 +849,8 @@ export class PlaylistView
|
|||||||
<wa-popup
|
<wa-popup
|
||||||
id="context-menu"
|
id="context-menu"
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
|
flip
|
||||||
|
shift
|
||||||
.active=${this.contextMenuOpen}
|
.active=${this.contextMenuOpen}
|
||||||
>
|
>
|
||||||
${this.contextMenuOpen
|
${this.contextMenuOpen
|
||||||
@@ -930,6 +932,8 @@ export class PlaylistView
|
|||||||
<wa-popup
|
<wa-popup
|
||||||
id="playlist-submenu"
|
id="playlist-submenu"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
|
flip
|
||||||
|
shift
|
||||||
.active=${this.playlistSubmenuOpen}
|
.active=${this.playlistSubmenuOpen}
|
||||||
>
|
>
|
||||||
${this.playlistSubmenuOpen &&
|
${this.playlistSubmenuOpen &&
|
||||||
|
|||||||
@@ -777,6 +777,8 @@ export class QueuePanel
|
|||||||
<wa-popup
|
<wa-popup
|
||||||
id="context-menu"
|
id="context-menu"
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
|
flip
|
||||||
|
shift
|
||||||
.active=${this.contextMenuOpen}
|
.active=${this.contextMenuOpen}
|
||||||
>
|
>
|
||||||
${this.contextMenuOpen
|
${this.contextMenuOpen
|
||||||
@@ -834,6 +836,8 @@ export class QueuePanel
|
|||||||
<wa-popup
|
<wa-popup
|
||||||
id="playlist-submenu"
|
id="playlist-submenu"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
|
flip
|
||||||
|
shift
|
||||||
.active=${this.playlistSubmenuOpen}
|
.active=${this.playlistSubmenuOpen}
|
||||||
>
|
>
|
||||||
${this.playlistSubmenuOpen &&
|
${this.playlistSubmenuOpen &&
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import { formatMilliseconds } from '@utils/time';
|
|||||||
* trackTitle="Song Name"
|
* trackTitle="Song Name"
|
||||||
* artist="Artist Name"
|
* artist="Artist Name"
|
||||||
* coverArt="/covers/abc.jpg"
|
* coverArt="/covers/abc.jpg"
|
||||||
* coverArtThumbnail="/covers/abc_thumb.jpg"
|
* coverArtSmall="/covers/abc_sm.jpg"
|
||||||
* ></track-info>
|
* ></track-info>
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
@@ -34,7 +34,7 @@ export class TrackInfo extends LitElement {
|
|||||||
@property() artist?: string;
|
@property() artist?: string;
|
||||||
@property() album?: string;
|
@property() album?: string;
|
||||||
@property() coverArt?: string;
|
@property() coverArt?: string;
|
||||||
@property() coverArtThumbnail?: string;
|
@property() coverArtSmall?: string;
|
||||||
@property() duration?: string;
|
@property() duration?: string;
|
||||||
@property() filePath?: string;
|
@property() filePath?: string;
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ export class TrackInfo extends LitElement {
|
|||||||
|
|
||||||
override render() {
|
override render() {
|
||||||
const showCover =
|
const showCover =
|
||||||
this.coverArt !== undefined || this.coverArtThumbnail !== undefined;
|
this.coverArt !== undefined || this.coverArtSmall !== undefined;
|
||||||
const displayTitle = this.getDisplayTitle();
|
const displayTitle = this.getDisplayTitle();
|
||||||
const secondaryParts = this.getSecondaryText();
|
const secondaryParts = this.getSecondaryText();
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ export class TrackInfo extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderCoverArt() {
|
private renderCoverArt() {
|
||||||
const src = this.coverArtThumbnail ?? this.coverArt;
|
const src = this.coverArtSmall ?? this.coverArt;
|
||||||
|
|
||||||
if (!src) {
|
if (!src) {
|
||||||
return html`
|
return html`
|
||||||
|
|||||||
@@ -713,6 +713,8 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
<wa-popup
|
<wa-popup
|
||||||
id="context-menu"
|
id="context-menu"
|
||||||
placement="bottom-start"
|
placement="bottom-start"
|
||||||
|
flip
|
||||||
|
shift
|
||||||
.active=${this.contextMenuOpen}
|
.active=${this.contextMenuOpen}
|
||||||
>
|
>
|
||||||
${this.contextMenuOpen
|
${this.contextMenuOpen
|
||||||
@@ -756,6 +758,8 @@ export class TrackList extends LitElement implements SelectionHost {
|
|||||||
<wa-popup
|
<wa-popup
|
||||||
id="playlist-submenu"
|
id="playlist-submenu"
|
||||||
placement="right-start"
|
placement="right-start"
|
||||||
|
flip
|
||||||
|
shift
|
||||||
.active=${this.playlistSubmenuOpen}
|
.active=${this.playlistSubmenuOpen}
|
||||||
>
|
>
|
||||||
${this.playlistSubmenuOpen && this.selection.hasSelection
|
${this.playlistSubmenuOpen && this.selection.hasSelection
|
||||||
|
|||||||
@@ -78,4 +78,16 @@ export class LibraryController implements ReactiveController {
|
|||||||
setScrollPosition(view: ViewName, offset: number): void {
|
setScrollPosition(view: ViewName, offset: number): void {
|
||||||
libraryStore.setScrollPosition(view, offset);
|
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;
|
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 {
|
class LibraryStore {
|
||||||
private tracks: library.Track[] | null = null;
|
private tracks: library.Track[] | null = null;
|
||||||
private albums: library.Album[] | null = null;
|
private albums: library.Album[] | null = null;
|
||||||
@@ -14,6 +26,8 @@ class LibraryStore {
|
|||||||
private tracksLoading = false;
|
private tracksLoading = false;
|
||||||
private albumsLoading = false;
|
private albumsLoading = false;
|
||||||
|
|
||||||
|
private coverSizeValue: number = COVER_SIZE_DEFAULT;
|
||||||
|
|
||||||
private scrollPositions: Record<ViewName, number> = {
|
private scrollPositions: Record<ViewName, number> = {
|
||||||
tracks: 0,
|
tracks: 0,
|
||||||
albums: 0,
|
albums: 0,
|
||||||
@@ -25,6 +39,8 @@ class LibraryStore {
|
|||||||
EventsOn(Events.LibraryScanComplete, () => {
|
EventsOn(Events.LibraryScanComplete, () => {
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.loadCoverSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
@@ -111,6 +127,56 @@ class LibraryStore {
|
|||||||
this.scrollPositions[view] = offset;
|
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
|
// INVALIDATION
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ export interface TrackInfo {
|
|||||||
title: string; // track title (falls back to fileName)
|
title: string; // track title (falls back to fileName)
|
||||||
artist: string; // artist name
|
artist: string; // artist name
|
||||||
album: string; // album name
|
album: string; // album name
|
||||||
coverArt: string; // URL path to cover art (e.g., "/covers/abc.jpg") or empty string
|
coverArt: string; // URL path to full-size cover art or empty string
|
||||||
coverArtThumbnail: string; // URL path to thumbnail (e.g., "/covers/abc_thumb.jpg") 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
|
trackChangeId: number; // monotonic counter to detect track changes even when the same file plays consecutively
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ export namespace library {
|
|||||||
Name: string;
|
Name: string;
|
||||||
ArtistName: string;
|
ArtistName: string;
|
||||||
CoverArtPath: string;
|
CoverArtPath: string;
|
||||||
CoverArtThumbnailPath: string;
|
CoverArtSmall: string;
|
||||||
|
CoverArtMedium: string;
|
||||||
|
CoverArtLarge: string;
|
||||||
Year: number;
|
Year: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -18,7 +20,9 @@ export namespace library {
|
|||||||
this.Name = source["Name"];
|
this.Name = source["Name"];
|
||||||
this.ArtistName = source["ArtistName"];
|
this.ArtistName = source["ArtistName"];
|
||||||
this.CoverArtPath = source["CoverArtPath"];
|
this.CoverArtPath = source["CoverArtPath"];
|
||||||
this.CoverArtThumbnailPath = source["CoverArtThumbnailPath"];
|
this.CoverArtSmall = source["CoverArtSmall"];
|
||||||
|
this.CoverArtMedium = source["CoverArtMedium"];
|
||||||
|
this.CoverArtLarge = source["CoverArtLarge"];
|
||||||
this.Year = source["Year"];
|
this.Year = source["Year"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,7 +75,9 @@ export namespace playlist {
|
|||||||
Artist: string;
|
Artist: string;
|
||||||
Album: string;
|
Album: string;
|
||||||
CoverArtPath: string;
|
CoverArtPath: string;
|
||||||
CoverArtThumbnailPath: string;
|
CoverArtSmall: string;
|
||||||
|
CoverArtMedium: string;
|
||||||
|
CoverArtLarge: string;
|
||||||
Duration: string;
|
Duration: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -87,7 +93,9 @@ export namespace playlist {
|
|||||||
this.Artist = source["Artist"];
|
this.Artist = source["Artist"];
|
||||||
this.Album = source["Album"];
|
this.Album = source["Album"];
|
||||||
this.CoverArtPath = source["CoverArtPath"];
|
this.CoverArtPath = source["CoverArtPath"];
|
||||||
this.CoverArtThumbnailPath = source["CoverArtThumbnailPath"];
|
this.CoverArtSmall = source["CoverArtSmall"];
|
||||||
|
this.CoverArtMedium = source["CoverArtMedium"];
|
||||||
|
this.CoverArtLarge = source["CoverArtLarge"];
|
||||||
this.Duration = source["Duration"];
|
this.Duration = source["Duration"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user