feat(library): genre sliders, artist links, and navigation utils
🏗️ Build and Test JellyTau / Run Tests (pull_request) Successful in 3m49s
Traceability Validation / Check Requirement Traces (pull_request) Successful in 19s
🏗️ Build and Test JellyTau / Build Android APK (pull_request) Successful in 18m24s

- 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:
2026-06-25 19:18:06 +02:00
parent 62874564ff
commit 1836615dc0
38 changed files with 1009 additions and 207 deletions
+95
View File
@@ -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| / |AB|, 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]);
}