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
+79
View File
@@ -0,0 +1,79 @@
import { describe, it, expect } from "vitest";
import { selectDiverseGenres, sampleAcross } from "./genreDiversity";
const g = (...names: string[]) => names.map(name => ({ name }));
describe("sampleAcross", () => {
it("returns input unchanged when at or under the count", () => {
expect(sampleAcross([1, 2, 3], 5)).toEqual([1, 2, 3]);
});
it("spreads the sample across the whole list rather than taking a prefix", () => {
// 26 letters, want ~7: a prefix would be AG; striding spans A→Z.
const letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".split("");
const picked = sampleAcross(letters, 7);
expect(picked[0]).toBe("A");
// Reaches deep into the alphabet, not stuck near the front.
expect(picked[picked.length - 1] >= "S").toBe(true);
expect(picked.length).toBeLessThanOrEqual(7);
});
it("never exceeds the requested count", () => {
const items = Array.from({ length: 100 }, (_, i) => i);
expect(sampleAcross(items, 8).length).toBeLessThanOrEqual(8);
});
});
describe("selectDiverseGenres", () => {
it("returns input unchanged when at or under the limit", () => {
const input = g("Rock", "Jazz");
expect(selectDiverseGenres(input, 5)).toEqual(input);
});
it("seeds with the most populous genre (first in input)", () => {
const out = selectDiverseGenres(g("Rock", "Jazz", "Hip Hop"), 1);
expect(out.map(x => x.name)).toEqual(["Rock"]);
});
it("spreads the family instead of stacking near-synonyms", () => {
// Count ranking puts all the Rock variants first; a naive top-N would
// return four flavours of Rock. Diversity should pull in distinct genres.
const input = g(
"Rock",
"Hard Rock",
"Classic Rock",
"Pop Rock",
"Jazz",
"Hip Hop",
"Classical"
);
const names = selectDiverseGenres(input, 4).map(x => x.name);
expect(names[0]).toBe("Rock"); // seeded by count
expect(names).toContain("Jazz");
expect(names).toContain("Hip Hop");
expect(names).toContain("Classical");
// Only the seed represents the Rock cluster.
expect(names.filter(n => n.includes("Rock"))).toEqual(["Rock"]);
});
it("preserves count order as the tie-breaker among equally-distinct genres", () => {
// All four are mutually distinct (no shared tokens), so every pick after
// the seed is a distance tie and should follow input (count) order.
const input = g("Rock", "Jazz", "Blues", "Folk");
expect(selectDiverseGenres(input, 3).map(x => x.name)).toEqual([
"Rock",
"Jazz",
"Blues",
]);
});
it("is case- and separator-insensitive when comparing", () => {
const input = g("Hip Hop", "hip-hop", "Reggae");
// "Hip Hop" and "hip-hop" share all tokens → the second is redundant.
expect(selectDiverseGenres(input, 2).map(x => x.name)).toEqual([
"Hip Hop",
"Reggae",
]);
});
});
+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]);
}
+35
View File
@@ -0,0 +1,35 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { navigateBack } from "./navigation";
const goto = vi.fn();
vi.mock("$app/navigation", () => ({
goto: (...args: unknown[]) => goto(...args),
}));
describe("navigateBack", () => {
beforeEach(() => {
goto.mockClear();
});
it("pops real history when there is in-app history to go back to", () => {
const back = vi.fn();
vi.spyOn(history, "back").mockImplementation(back);
vi.spyOn(history, "length", "get").mockReturnValue(3);
navigateBack("/library");
expect(back).toHaveBeenCalledOnce();
expect(goto).not.toHaveBeenCalled();
});
it("falls back to the given path on a fresh deep-link (no history)", () => {
const back = vi.fn();
vi.spyOn(history, "back").mockImplementation(back);
vi.spyOn(history, "length", "get").mockReturnValue(1);
navigateBack("/library/music");
expect(goto).toHaveBeenCalledWith("/library/music");
expect(back).not.toHaveBeenCalled();
});
});
+33
View File
@@ -0,0 +1,33 @@
import { goto } from "$app/navigation";
/**
* Navigate "back" using real browser/Android history when possible, falling
* back to an explicit path otherwise.
*
* Hardcoded `goto(backPath)` always sends the user to a fixed screen, which
* loses track of where they actually came from (e.g. reaching the genres list
* from different entry points). Preferring `history.back()` keeps the back
* affordance consistent with the platform back gesture and the browser/Android
* hardware back button.
*
* We only use history when there is somewhere to go back to *within the app*.
* On a fresh deep-link (history length 1, or an external referrer) we fall back
* to `fallbackPath` so the user never gets stranded or bounced out of the app.
*/
export function navigateBack(fallbackPath: string): void {
if (canGoBack()) {
history.back();
} else {
goto(fallbackPath);
}
}
/**
* True when there is in-app history to pop. `history.length > 1` means the user
* navigated here from another page in this session rather than landing here
* directly (deep link, refresh, or first load).
*/
function canGoBack(): boolean {
if (typeof history === "undefined") return false;
return history.length > 1;
}