diff --git a/CHANGELOG.md b/CHANGELOG.md index 22fdcb5..15e2e65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **A–Z fast-scroll rail on the v3 Songs grid.** A vertical letter rail (Plex/Radarr/iOS-contacts pattern) pinned to the right edge next to the scrollbar lets you jump the library to a starting letter — tap a letter, drag to scrub with a live letter bubble, or arrow-key between letters. It shows **only** for the grid view + alphabetical (artist/title) sorts, and only offers letters actually present in the current sort **and filter set**, so a tap always lands on a real card (absent letters are dimmed + non-interactive). Because the grid is forward-only, server-paged infinite scroll, a jump pages through to the target card and scrolls to it (a newer jump supersedes an in-flight one); a keyset-seek + virtualized window is the noted scaling follow-up for very large libraries. Backend: `/api/library/stats` now accepts `sort` and returns an additive `sort_letters` map (songs-per-first-letter of the active sort column — artist or title), filter-synced; the legacy `letters` (distinct-artist) field is unchanged for the dashboard + classic tree. Frontend: `static/v3/songs.js` (`refreshRail`/`jumpToLetter`, cards tagged with `data-letter`), `static/v3/v3.css` (`.v3-azrail`). The classic (v2) tree already had letter selection; this brings the new grid to parity. Tests: `tests/test_library_filters.py` (sort_letters artist/title + song-vs-artist counting), `tests/test_library_providers.py` (sort forwarded to providers), `tests/js/v3_az_rail.test.js`. - **Playlists get content-dependent covers + custom art.** Playlist cards were a tiny `🎵` emoji on an empty square. Now a playlist's cover reflects its contents: **empty → the icon**, **a few songs → the first song's album art**, **4+ songs → a 2×2 art mosaic**. You can also **upload a custom cover** (a "Cover" button in the playlist detail view → image picker; "Remove cover" reverts to the content view). `MetadataDB.list_playlists()` now returns each playlist's first few song `art_urls`; `GET /api/playlists` and `GET /api/playlists/{id}` add `cover_url` when a custom cover exists. New routes `POST` / `GET` / `DELETE /api/playlists/{id}/cover` store a small PNG thumbnail under `CONFIG_DIR/playlist_covers/` (PIL-converted, like song-art upload); the cover is removed with the playlist. Frontend: `playlistCoverHtml(p)` in `static/v3/playlists.js`. Tests: `tests/test_playlists_api.py` (art_urls + cover roundtrip / reject-non-image / delete-cleanup), `tests/js/v3_playlist_cover.test.js`. - **v3 Songs: "Add to playlist" is now on each song's ⋮ "More" menu.** Previously a song could only be added to a playlist through select-mode (the checkbox → batch bar). The per-card overflow menu now has an **Add to playlist** row that targets that one song, reusing the same picker (choose a listed number or type a new name to create it). The select-mode batch flow and the single-song menu now share one extracted `addFilenamesToPlaylist(filenames)` helper in `static/v3/songs.js` (both grid and tree rows, since they share `openCardMenu`). Tests: `tests/js/v3_add_to_playlist_menu.test.js`. - **Resume where you left off — leaving a song now snapshots your place so an exit is recoverable, not a restart-from-zero.** Exiting the player (`showScreen()` teardown, before audio unload) writes `{song, arrangement, position, speed}` to `localStorage` (`feedBack.resumeSession`), and a non-blocking **"Resume practice"** pill offers it back on the next non-player screen (and on the next app launch). Clicking Resume re-enters the song, restores the arrangement + playback speed, and seeks to the saved position via the existing `_audioSeek` funnel; `playSong()` gains a `{ resume: {position, speed} }` option that arms a `song:ready`-consumed restore instead of the normal autostart, so the two never fight over playback. The snapshot is deliberately conservative — ignored for a song you barely started (< 3s) or had basically finished (within 5s of the end), cleared on natural song-end and once consumed, and expired after 24h. The pill is self-contained (inline-styled, body-appended, works identically in the classic and v3 shells with no Tailwind rebuild), never blocks, and a dismiss forgets the current snapshot for the session. This pairs with the Escape focus fix: now that Escape reliably leaves regardless of focus, an *accidental* exit is one tap to undo. Public surface: `window.resumeLastSession()` / `window.feedBack.resumeLastSession`. (The broader nav-state work — returning to a song after wandering into Settings → Tone Builder — is a separate, larger track; this lands the player-session slice.) Tests: `tests/browser/resume-session.spec.ts` (snapshot guards, staleness, pill show/hide/dismiss, resume consumption). diff --git a/server.py b/server.py index 0dbf258..0b25d2f 100644 --- a/server.py +++ b/server.py @@ -1995,10 +1995,24 @@ class MetadataDB: stems_lacks: list[str] | None = None, has_lyrics: int | None = None, tunings: list[str] | None = None, + sort: str = "artist", + want_sort_letters: bool = False, naming_mode: str = "legacy") -> dict: """Aggregate stats for the letter bar. Accepts the same filter params as query_page so the letter counts stay synchronized - with the grid when filters are active.""" + with the grid when filters are active. + + `sort` selects the column the v3 jump rail's `sort_letters` + breakdown keys on (artist for artist sorts, title for title + sorts) so the rail's present-letters match the grid's actual + order; other sorts fall back to artist (the rail is hidden for + them client-side anyway). The legacy `letters` field is always + the artist breakdown, unchanged, for the dashboard + classic tree. + + `sort_letters` is computed (and the key included) ONLY when + `want_sort_letters` is set — the jump rail opts in, while the + dashboard / v2 tree read only `letters` and skip the extra + per-letter aggregate scan.""" where, params = self._build_where( q=q, favorites_only=favorites_only, format_filter=format_filter, artist_filter=artist_filter, album_filter=album_filter, @@ -2029,7 +2043,30 @@ class MetadataDB: letters[key] = letters.get(key, 0) + count else: letters["#"] = letters.get("#", 0) + count - return {"total_songs": total, "total_artists": artist_count, "letters": letters} + result = {"total_songs": total, "total_artists": artist_count, "letters": letters} + # Active-sort letter buckets for the v3 jump rail. Counts SONGS (the + # grid's unit, unlike `letters` which counts distinct artists) per + # first-letter bucket of the column the active sort keys on, so a tap + # on a present letter always finds a card. Non-A–Z first chars bucket + # under '#'. Only artist/title sorts are alphabetical; anything else + # keys on artist here but the client hides the rail for it. Computed + # only when the caller opts in, so non-rail callers skip the scan. + if want_sort_letters: + sort_col = "title" if sort in ("title", "title-desc") else "artist" + sort_rows = self.conn.execute( + f"SELECT UPPER(SUBSTR(COALESCE({sort_col}, ''), 1, 1)) AS letter, COUNT(*) " + f"FROM songs {where} GROUP BY letter", params + ).fetchall() + sort_letters: dict[str, int] = {} + for letter, count in sort_rows: + count = int(count or 0) + if count <= 0: + continue + key = str(letter or "") + bucket = key if (key.isascii() and key.isalpha()) else "#" + sort_letters[bucket] = sort_letters.get(bucket, 0) + count + result["sort_letters"] = sort_letters + return result class AudioEffectsMappingDB: @@ -2602,7 +2639,7 @@ def _require_library_provider_capability(provider: object, capability: str) -> N ) -_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode",) +_OPTIONAL_NEW_PROVIDER_KWARGS = ("naming_mode", "sort", "want_sort_letters") def _filter_provider_kwargs(method: object, kwargs: dict) -> dict: @@ -4539,15 +4576,21 @@ async def library_stats(favorites: int = 0, q: str = "", format: str = "", arrangements_has: str = "", arrangements_lacks: str = "", stems_has: str = "", stems_lacks: str = "", has_lyrics: str = "", tunings: str = "", provider: str = "local", + sort: str = "artist", sort_letters: int = 0, naming_mode: str = "legacy"): """Aggregate stats for the UI. Accepts the same filter params as - /api/library so the letter bar mirrors the active grid filter set.""" + /api/library so the letter bar mirrors the active grid filter set. + `sort` selects the column the jump rail's `sort_letters` keys on; + `sort_letters=1` opts into that breakdown (the rail), so non-rail + callers skip the extra per-letter aggregate.""" library_provider = _get_library_provider(provider) _require_library_provider_capability(library_provider, "library.read") return await _call_library_provider_async( library_provider, "query_stats", naming_mode=naming_mode, + sort=sort, + want_sort_letters=bool(sort_letters), **_library_filter_args( q=q, favorites=favorites, format=format, artist=artist, album=album, diff --git a/static/v3/songs.js b/static/v3/songs.js index 732d113..aa0d730 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -51,8 +51,34 @@ artistCatalog: [], renderedHash: '', scrollBound: false, songsById: {}, selectMode: false, selected: new Set(), + railLetters: null, railJumping: false, }; + // ── A–Z jump rail ─────────────────────────────────────────────────────── + // Ordered buckets shown on the rail: '#' (non-alphabetic) first, then A–Z. + const RAIL_BUCKETS = ['#'].concat('ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('')); + // The rail only makes sense for the alphabetical sorts; for recent/year/ + // tuning a letter jump is meaningless, so it's hidden. Returns the column + // the active sort keys on ('artist' | 'title') or null when not alphabetical. + function railSortColumn() { + if (state.sort === 'artist' || state.sort === 'artist-desc') return 'artist'; + if (state.sort === 'title' || state.sort === 'title-desc') return 'title'; + return null; + } + // The bucket a song falls in for the active sort: first char of the sort + // column, uppercased; anything non-A–Z (digits, symbols, accents, blank) + // buckets under '#'. Mirrors the server's letter grouping in query_stats — + // which keys on raw SUBSTR(col, 1, 1) with no trim, and the grid ORDER BY + // is likewise raw, so we must NOT trim here either: a leading-space title + // sorts (and buckets) under '#' on both sides, keeping the rail consistent. + function songBucket(song) { + const col = railSortColumn(); + if (!col) return ''; + const raw = String((col === 'title' ? song.title : song.artist) || ''); + const ch = raw.charAt(0).toUpperCase(); + return (ch >= 'A' && ch <= 'Z') ? ch : '#'; + } + function activeFilterCount() { const f = state.filters; return f.arr_has.length + f.arr_lacks.length + f.stem_has.length + f.stem_lacks.length + @@ -463,7 +489,7 @@ const overlay = overlayActs.length ? '