sidebar and artist pipeline: one list in the rail, recents anchored to ids, and popular takes its own lane

This commit is contained in:
KeelrLeek committed 2026-07-31 18:21:04 -05:00
1 parent 606250c361
commit eba29824c9
12 files changed
+1595 -281

No files matched your search

+63 -1
View File
@@ -105,6 +105,27 @@ export async function refreshPlaylists() {
}
}
// ---- recently played (the sidebar's list) ----
// A play row is opened after ~1s of audio, so without a floor this list is a
// log of skips. 45s is the floor the live table was measured against.
export const RECENTS_FLOOR_SECS = 45;
const RECENTS_LIMIT = 20;
export const recentsStore = $state({
albums: [], // [{ album_id, artist, title, art_path, last_played_at }]
loaded: false,
});
export async function refreshRecents() {
try {
recentsStore.albums = await api.recentAlbums(RECENTS_LIMIT, RECENTS_FLOOR_SECS);
} catch {
// backend not available
} finally {
recentsStore.loaded = true;
}
}
const MIB = 1024 * 1024;
function isPending(value) {
@@ -2071,6 +2092,26 @@ export async function openCatalogArtist(artistId, seedArtist = null) {
})
.catch(() => {});
// Popular's fast lane. The overview paints the section immediately (its
// supplements are budgeted), so a cold page's rows start in Deezer's
// order with no listener counts. The real list — Last.fm order and
// counts — needs none of the dates/recordings work the full page also
// waits for, so it is asked for on its own and patched in the moment it
// lands (~2s cold, instant warm). Last writer wins against the overview:
// warm, the two lists are computed from identical inputs and cannot
// differ. The full page stays authoritative once it arrives, and a
// replayed page already shows the last visit's FINAL list — patching a
// pool-shaped list over either would be a downgrade.
void api.catalogArtistPopular(artistId)
.then((tracks) => {
if (!active() || !tracks?.length) return;
const current = catalog.artistPage;
if (!current || !sameArtistId(current.artist?.id, artistId)) return;
if (current.art_preview === false || current.replayed) return;
catalog.artistPage = { ...current, popular_tracks: tracks };
})
.catch(() => {});
try {
// Overview, identity art, and the first hero all remain immediate.
// Give rapid back-to-back navigation a brief chance to settle before
@@ -4116,7 +4157,12 @@ function listenTick(positionSecs) {
if (!title || !artist || artist.includes("…")) return;
const key = `${artist}\u0000${title}`;
if (listenSession?.key !== key) {
// The outgoing track's final seconds go in here — which is the moment
// its album can cross the floor. Re-read once that write has landed;
// waiting only on the fixed timer below meant a track that crossed 45s
// late never showed up until something else happened to refresh.
listenFlush();
setTimeout(refreshRecents, 1500);
listenSession = { key, artist, title, pending: 0, playIdP: null };
listenLastPos = null;
}
@@ -4125,8 +4171,24 @@ function listenTick(positionSecs) {
if (delta <= 0 || delta > 3) return;
listenSession.pending += delta;
if (!listenSession.playIdP && listenSession.pending >= 1) {
listenSession.playIdP = api.recordPlay(artist, title);
// Two possible anchors, both exact ids, never a name. A local track is
// tied to its library album through tracks.path; a STREAMED one — which
// is most listening here — through the catalog album id, carrying its
// own title/artist/cover because there is no local row to read them
// back from.
const q = player.queue?.[player.queueIndex] ?? null;
listenSession.playIdP = api.recordPlay(artist, title, t?.path ?? null, {
albumId: player.catalogRef?.albumId ?? null,
albumTitle: q?.album ?? null,
albumArtist: q?.artist ?? null,
coverUrl: q?.artUrl ?? null,
});
listenSession.playIdP.catch(() => {});
// The row is opened at ~1s but only earns its place in recents once it
// passes the floor, so re-read after the track has had time to count.
listenSession.playIdP
.then(() => setTimeout(refreshRecents, RECENTS_FLOOR_SECS * 1000 + 2000))
.catch(() => {});
}
if (listenSession.pending >= LISTEN_FLUSH_SECS) listenFlush();
}