mirror of
https://github.com/got-feedBack/feedBack.git
synced 2026-08-11 11:19:24 +00:00
v3 library: Albums view — the client half of the album-condense work (#716)
* v3 library: Albums view — the client half of the album-condense work Follow-up to the query_albums endpoint: the UI that consumes it, plus the track-order plumbing the endpoint's track list needs. - Albums view (a fourth view toggle next to grid/tree/folder): album cards (cover / title / artist / track count) from /api/library/albums, respecting the active filter drawer; clicking one opens the track list with per-track play and a Play-album button that feeds the play queue (falls back to plain playSong when the queue plugin is absent). - Track order: the scanner now reads the feedpak `track`/`disc` fields (spec 1.12.0) into new nullable songs columns (idempotent ALTERs), and the album track list orders by the new `track` sort — disc, then track number, unauthored charts to the bottom by title. Charts without authored numbers keep working; they just sort alphabetically. - The albums view persists like the other view choices. 3 new tests: manifest track/disc extraction (and unauthored -> None), the disc->track->title sort order over /api/library, and the put() round-trip. Full-suite failure set matches the known env baseline (one tuner-config name swapped inside the suite-ordering flake family — the file passes 25/25 in isolation on clean main). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Nm7tHs1Yvjjtnnu4nzJgdN * v3 albums: honour Genre/Match filters in album grid + detail (review fixes) The Albums view only partially respected the filter drawer: - /api/library/albums silently dropped the `genre` and `match` params the client sends via queryParams(), so with a Genre or Match filter active the album grid surfaced albums with zero matching tracks. Thread match_states/ genre through the endpoint -> query_albums -> _build_where, mirroring the /api/library grid route. (SmartCollection/pass-through providers keep their existing kwarg handling.) - The album-detail track list built its own params (provider/artist/album/ sort only), so it ignored ALL active filters — the track list and the Play-album queue could include songs the user had filtered out. Reuse queryParams({...}, {catalog: true}) so detail honours the same filters as the grid while pinning this album's artist/album and track order. +1 regression test (albums endpoint honours the Genre filter). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> Co-authored-by: byrongamatos <xasiklas@gmail.com>
This commit is contained in:
co-authored by
Claude Fable 5
byrongamatos
parent
2e4383524f
commit
0a8c8945ea
Vendored
+1
-1
File diff suppressed because one or more lines are too long
+66
-3
@@ -153,7 +153,7 @@
|
||||
if (!saved || typeof saved !== 'object') return;
|
||||
if (SORTS.some(([v]) => v === saved.sort)) state.sort = saved.sort;
|
||||
if (FORMATS.some(([v]) => v === saved.format)) state.format = saved.format;
|
||||
if (saved.view === 'grid' || saved.view === 'tree' || saved.view === 'folder') state.view = saved.view;
|
||||
if (saved.view === 'grid' || saved.view === 'tree' || saved.view === 'folder' || saved.view === 'albums') state.view = saved.view;
|
||||
if (typeof saved.grouping === 'boolean') state.grouping = saved.grouping;
|
||||
const f = saved.filters;
|
||||
if (f && typeof f === 'object') {
|
||||
@@ -2000,6 +2000,65 @@
|
||||
}
|
||||
|
||||
// ── Tree ────────────────────────────────────────────────────────────────
|
||||
// ── Albums (album-condensed browse; consumes /api/library/albums) ─────────
|
||||
// Album cards -> click -> a track list (reusing /api/library?artist=&album=)
|
||||
// with Play-album (feeds the play-queue). Respects the active drawer filters.
|
||||
async function loadAlbums() {
|
||||
const host = document.getElementById('v3-songs-albums');
|
||||
if (!host) return;
|
||||
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
|
||||
const data = await jget('/api/library/albums?' + queryParams().toString());
|
||||
const albums = (data && data.albums) || [];
|
||||
if (!albums.length) { host.innerHTML = '<p class="text-fb-textDim text-sm py-8 text-center">No albums match.</p>'; return; }
|
||||
host.innerHTML =
|
||||
'<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">' +
|
||||
albums.map((a, i) =>
|
||||
'<button data-album="' + i + '" class="group text-left">' +
|
||||
'<div class="aspect-square rounded-lg overflow-hidden bg-fb-card mb-2">' +
|
||||
(a.cover ? '<img src="' + esc(artUrl({ filename: a.cover })) + '" alt="" loading="lazy" decoding="async" class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105" onerror="this.style.visibility=\'hidden\'">' : '') +
|
||||
'</div>' +
|
||||
'<div class="text-sm text-fb-text truncate">' + esc(a.album) + '</div>' +
|
||||
'<div class="text-xs text-fb-textDim truncate">' + esc(a.artist) + ' · ' + (a.count || 0) + ' track' + (a.count === 1 ? '' : 's') + '</div>' +
|
||||
'</button>').join('') + '</div>';
|
||||
host.querySelectorAll('[data-album]').forEach((b) => b.addEventListener('click', () => {
|
||||
const a = albums[Number(b.getAttribute('data-album'))];
|
||||
if (a) openAlbum(a);
|
||||
}));
|
||||
}
|
||||
async function openAlbum(a) {
|
||||
const host = document.getElementById('v3-songs-albums');
|
||||
if (!host) return;
|
||||
host.innerHTML = '<p class="text-fb-textDim text-sm">Loading…</p>';
|
||||
// Honour the active drawer filters (like the album grid) but pin THIS
|
||||
// album's artist/album and force track order — so the track list and
|
||||
// Play-album never include songs the user filtered out.
|
||||
const p = queryParams({ artist: a.artist, album: a.album, size: '300', sort: 'track' }, { catalog: true });
|
||||
const data = await jget('/api/library?' + p.toString());
|
||||
const songs = (data && data.songs) || [];
|
||||
host.innerHTML =
|
||||
'<button data-albums-back class="text-sm text-fb-textDim hover:text-fb-text mb-4">← Albums</button>' +
|
||||
'<div class="flex items-center justify-between gap-3 mb-4">' +
|
||||
'<div class="min-w-0"><h2 class="text-2xl font-bold text-fb-text truncate">' + esc(a.album) + '</h2>' +
|
||||
'<p class="text-sm text-fb-textDim truncate">' + esc(a.artist) + ' · ' + songs.length + ' track' + (songs.length === 1 ? '' : 's') + '</p></div>' +
|
||||
(songs.length ? '<button data-album-playall class="bg-fb-primary hover:bg-fb-primaryHi text-white text-sm font-medium px-4 py-2 rounded-md shrink-0">▶ Play album</button>' : '') +
|
||||
'</div>' +
|
||||
'<ul class="space-y-1">' + songs.map((s, i) =>
|
||||
'<li><button data-album-track="' + i + '" class="w-full flex items-center gap-3 px-3 py-2 rounded-md hover:bg-white/5 text-left">' +
|
||||
'<span class="text-xs text-fb-textDim w-6 text-right">' + (i + 1) + '</span>' +
|
||||
'<span class="flex-1 truncate text-sm text-fb-text">' + esc(s.title || s.filename) + '</span></button></li>').join('') + '</ul>';
|
||||
host.querySelector('[data-albums-back]')?.addEventListener('click', () => loadAlbums());
|
||||
host.querySelector('[data-album-playall]')?.addEventListener('click', () => {
|
||||
const files = songs.map((s) => s.filename).filter(Boolean);
|
||||
if (!files.length) return;
|
||||
if (window.feedBack && window.feedBack.playQueue) window.feedBack.playQueue.start(files, { source: a.album });
|
||||
else if (typeof window.playSong === 'function') window.playSong(enc(files[0]));
|
||||
});
|
||||
host.querySelectorAll('[data-album-track]').forEach((b) => b.addEventListener('click', () => {
|
||||
const s = songs[Number(b.getAttribute('data-album-track'))];
|
||||
if (s && window.playSong) window.playSong(enc(s.filename));
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadTree() {
|
||||
const host = document.getElementById('v3-songs-tree');
|
||||
if (!host) return;
|
||||
@@ -2553,6 +2612,7 @@
|
||||
document.getElementById('v3-songs-gridsizer')?.classList.toggle('hidden', state.view !== 'grid');
|
||||
document.getElementById('v3-songs-tree')?.classList.toggle('hidden', state.view !== 'tree');
|
||||
document.getElementById('lib-folder-tree')?.classList.toggle('hidden', state.view !== 'folder');
|
||||
document.getElementById('v3-songs-albums')?.classList.toggle('hidden', state.view !== 'albums');
|
||||
// Refresh the A–Z jump rail (shows only for the grid + alphabetical
|
||||
// sorts; hides itself otherwise). Independent of the grid load.
|
||||
refreshRail();
|
||||
@@ -2564,7 +2624,7 @@
|
||||
_applyMainScrollTop(0);
|
||||
return _ensureFolderLibrary().then(() => window.folderLibrary?.load());
|
||||
}
|
||||
const loaded = state.view === 'grid' ? loadGrid(true) : loadTree();
|
||||
const loaded = state.view === 'grid' ? loadGrid(true) : state.view === 'albums' ? loadAlbums() : loadTree();
|
||||
_applyMainScrollTop(0);
|
||||
return loaded;
|
||||
}
|
||||
@@ -2625,7 +2685,7 @@
|
||||
(providers.length > 1 ? '<select id="v3-songs-provider" class="' + ctrl + '">' + provOpts + '</select>' : '') +
|
||||
'<select id="v3-songs-artist" class="' + ctrl + ' max-w-[11rem]" aria-label="Artist">' + artistSelectHtml() + '</select>' +
|
||||
'<select id="v3-songs-album" class="' + ctrl + ' max-w-[11rem]" aria-label="Album"' + (state.artist ? '' : ' disabled') + '>' + albumSelectHtml() + '</select>' +
|
||||
'<div class="flex rounded-md overflow-hidden border border-gray-700"><button id="v3-songs-grid-btn" class="px-3 py-2 text-sm">▦</button><button id="v3-songs-tree-btn" class="px-3 py-2 text-sm">≣</button><button id="v3-songs-folder-btn" class="px-3 py-2 text-sm" style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:2.25rem"><svg fill="currentColor" viewBox="0 0 16 16" style="width:12px;height:12px;flex-shrink:0"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg></button></div>' +
|
||||
'<div class="flex rounded-md overflow-hidden border border-gray-700"><button id="v3-songs-grid-btn" class="px-3 py-2 text-sm">▦</button><button id="v3-songs-tree-btn" class="px-3 py-2 text-sm">≣</button><button id="v3-songs-albums-btn" title="Albums" class="px-3 py-2 text-sm">💿</button><button id="v3-songs-folder-btn" class="px-3 py-2 text-sm" style="display:inline-flex;align-items:center;justify-content:center;box-sizing:border-box;width:2.25rem"><svg fill="currentColor" viewBox="0 0 16 16" style="width:12px;height:12px;flex-shrink:0"><path d="M1 3.5A1.5 1.5 0 012.5 2h3.086a1.5 1.5 0 011.06.44l.915.914H13.5A1.5 1.5 0 0115 4.914V12.5a1.5 1.5 0 01-1.5 1.5h-11A1.5 1.5 0 011 12.5v-9z"/></svg></button></div>' +
|
||||
'<select id="v3-songs-sort" class="' + ctrl + '">' + opt(SORTS, state.sort) + '</select>' +
|
||||
'<select id="v3-songs-format" class="' + ctrl + '">' + opt(FORMATS, state.format) + '</select>' +
|
||||
'<button id="v3-songs-filters" class="relative ' + ctrl + ' flex items-center gap-2">Filters<span id="v3-songs-filter-count" class="hidden bg-fb-primary text-white text-xs rounded-full px-1.5">0</span></button>' +
|
||||
@@ -2645,6 +2705,7 @@
|
||||
'<div id="v3-songs-grid" class="v3-grid-window grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4"></div>' +
|
||||
'</div>' +
|
||||
'<div id="v3-songs-tree" class="hidden"></div>' +
|
||||
'<div id="v3-songs-albums" class="hidden"></div>' +
|
||||
'<div id="lib-folder-controls" style="display:none"></div>' +
|
||||
'<div id="lib-folder-tree" class="space-y-1 hidden"></div>' +
|
||||
'<div id="v3-songs-sentinel" class="h-8"></div>' +
|
||||
@@ -2736,12 +2797,14 @@
|
||||
byId('v3-songs-grid-btn').className = 'px-3 py-2 text-sm ' + (v === 'grid' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
|
||||
byId('v3-songs-tree-btn').className = 'px-3 py-2 text-sm ' + (v === 'tree' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
|
||||
byId('v3-songs-folder-btn').className = 'px-3 py-2 text-sm ' + (v === 'folder' ? 'bg-fb-primary text-white' : 'text-fb-textDim');
|
||||
{ const ab = byId('v3-songs-albums-btn'); if (ab) ab.className = 'px-3 py-2 text-sm ' + (v === 'albums' ? 'bg-fb-primary text-white' : 'text-fb-textDim'); }
|
||||
if (v === 'folder') await _ensureFolderLibrary();
|
||||
return reload();
|
||||
};
|
||||
byId('v3-songs-grid-btn').addEventListener('click', () => setView('grid'));
|
||||
byId('v3-songs-tree-btn').addEventListener('click', () => setView('tree'));
|
||||
byId('v3-songs-folder-btn').addEventListener('click', () => setView('folder'));
|
||||
byId('v3-songs-albums-btn')?.addEventListener('click', () => setView('albums'));
|
||||
// Await the initial load so a caller awaiting render() (the scroll
|
||||
// restore on screen re-entry) sees a populated grid + real state.total
|
||||
// before it tries to page deeper.
|
||||
|
||||
Reference in New Issue
Block a user