feat(library): genre sliders, artist links, and navigation utils
- music landing: diverse per-genre album sliders (online counts / offline wide-probe fallback) and home-screen library shortcuts - add ArtistLinks component and shared navigation/genreDiversity utils - player/playback-mode refinements across Rust and frontend
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
// Selecting a *diverse* set of genres for the music landing page.
|
||||
//
|
||||
// The naive approach ("keep the genres with the most albums") tends to surface
|
||||
// a cluster of near-synonyms — "Rock", "Hard Rock", "Classic Rock", "Pop Rock"
|
||||
// — because big umbrella genres and their sub-genres are all populous. The
|
||||
// result reads as one genre repeated, not a tour of the library.
|
||||
//
|
||||
// Instead we pick greedily for *spread*: start from the most populous genre,
|
||||
// then repeatedly add whichever remaining genre is least similar to everything
|
||||
// already chosen (a max-min / farthest-point selection). Similarity is word-
|
||||
// token overlap (Jaccard), so "Hard Rock" stays close to "Rock" but far from
|
||||
// "Jazz" or "Hip Hop". Count still acts as a gentle tie-breaker so we don't
|
||||
// promote a one-album novelty genre over a healthy distinct one.
|
||||
|
||||
/** Anything with a name and a relative weight (album count) we can rank by. */
|
||||
export interface DiversityCandidate {
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sample up to `count` items at an even stride across `items`. Genre lists come
|
||||
* back alphabetical, so taking the first N would only ever surface A-genres;
|
||||
* striding spreads the sample A→Z. Always includes the first item.
|
||||
*/
|
||||
export function sampleAcross<T>(items: T[], count: number): T[] {
|
||||
if (items.length <= count) return items.slice();
|
||||
const stride = Math.max(1, Math.floor(items.length / count));
|
||||
return items.filter((_, i) => i % stride === 0).slice(0, count);
|
||||
}
|
||||
|
||||
/** Split a genre name into a set of lowercased word tokens. */
|
||||
function tokenize(name: string): Set<string> {
|
||||
return new Set(
|
||||
name
|
||||
.toLowerCase()
|
||||
.split(/[^a-z0-9]+/)
|
||||
.filter(Boolean)
|
||||
);
|
||||
}
|
||||
|
||||
/** Jaccard similarity of two token sets: |A∩B| / |A∪B|, in [0, 1]. */
|
||||
function jaccard(a: Set<string>, b: Set<string>): number {
|
||||
if (a.size === 0 && b.size === 0) return 1;
|
||||
let intersection = 0;
|
||||
for (const t of a) if (b.has(t)) intersection++;
|
||||
const union = a.size + b.size - intersection;
|
||||
return union === 0 ? 0 : intersection / union;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick up to `limit` genres that are textually distinct from one another,
|
||||
* preferring more populous genres. Input order is treated as the count ranking
|
||||
* (most albums first); ties in distance fall back to that order.
|
||||
*/
|
||||
export function selectDiverseGenres<T extends DiversityCandidate>(
|
||||
candidates: T[],
|
||||
limit: number
|
||||
): T[] {
|
||||
if (candidates.length <= limit) return candidates.slice();
|
||||
|
||||
const tokens = candidates.map(c => tokenize(c.name));
|
||||
const chosen: number[] = [];
|
||||
const remaining = new Set(candidates.map((_, i) => i));
|
||||
|
||||
// Seed with the most populous genre (candidates[0]).
|
||||
chosen.push(0);
|
||||
remaining.delete(0);
|
||||
|
||||
while (chosen.length < limit && remaining.size > 0) {
|
||||
let best = -1;
|
||||
// The best candidate is the one *least* similar to its most-similar chosen
|
||||
// member. We track that max-similarity and minimise it: a genre is only
|
||||
// "diverse" if it resembles *nothing* already chosen, so looking at the
|
||||
// single nearest neighbour (as plain farthest-point does) isn't enough —
|
||||
// "Hard Rock" must stay close to "Rock" even after unrelated "Jazz" is in.
|
||||
let bestMaxSim = Infinity;
|
||||
for (const i of remaining) {
|
||||
let maxSim = 0;
|
||||
for (const c of chosen) {
|
||||
const sim = jaccard(tokens[i], tokens[c]);
|
||||
if (sim > maxSim) maxSim = sim;
|
||||
}
|
||||
// Lower max-similarity wins; on a tie keep the earlier (more populous)
|
||||
// one, guaranteed because `remaining` iterates in insertion order.
|
||||
if (maxSim < bestMaxSim) {
|
||||
bestMaxSim = maxSim;
|
||||
best = i;
|
||||
}
|
||||
}
|
||||
chosen.push(best);
|
||||
remaining.delete(best);
|
||||
}
|
||||
|
||||
return chosen.map(i => candidates[i]);
|
||||
}
|
||||
Reference in New Issue
Block a user