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:
+91
-1
@@ -2,9 +2,17 @@
|
||||
// Powers the focused music landing: hero + horizontal sliders.
|
||||
// TRACES: UR-007, UR-034 | DR-007, DR-038, DR-039
|
||||
import { writable, derived } from "svelte/store";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import type { MediaItem, Genre } from "$lib/api/types";
|
||||
import { auth } from "./auth";
|
||||
import { excludePodcasts } from "$lib/utils/podcastFilter";
|
||||
import { selectDiverseGenres, sampleAcross } from "$lib/utils/genreDiversity";
|
||||
|
||||
/** A single "by genre" row: the genre name plus the albums in it. */
|
||||
export interface GenreRow {
|
||||
id: string;
|
||||
name: string;
|
||||
items: MediaItem[];
|
||||
}
|
||||
|
||||
interface MusicState {
|
||||
// Albums grouped from recently played tracks (backend handles grouping).
|
||||
@@ -15,6 +23,8 @@ interface MusicState {
|
||||
playlists: MediaItem[];
|
||||
// Albums the user has played but hasn't returned to in a while.
|
||||
rediscover: MediaItem[];
|
||||
// One slider per genre (top genres by album count).
|
||||
genreRows: GenreRow[];
|
||||
// Mix of recently played + rediscover, used for the hero banner.
|
||||
heroItems: MediaItem[];
|
||||
isLoading: boolean;
|
||||
@@ -22,6 +32,11 @@ interface MusicState {
|
||||
}
|
||||
|
||||
const SECTION_LIMIT = 16;
|
||||
// How many genre sliders to show, and how many genres to probe to find them.
|
||||
const MAX_GENRE_ROWS = 8;
|
||||
// Probe a wide pool so the diverse-selection step has genres from across the
|
||||
// whole (alphabetical) list to choose between, not just the first handful.
|
||||
const MAX_GENRES_PROBED = 40;
|
||||
|
||||
function createMusicStore() {
|
||||
const initialState: MusicState = {
|
||||
@@ -29,6 +44,7 @@ function createMusicStore() {
|
||||
newlyAdded: [],
|
||||
playlists: [],
|
||||
rediscover: [],
|
||||
genreRows: [],
|
||||
heroItems: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
@@ -110,6 +126,10 @@ function createMusicStore() {
|
||||
heroItems,
|
||||
isLoading: false,
|
||||
}));
|
||||
|
||||
// Genre rows are secondary — load them after the main sections paint so
|
||||
// the page isn't blocked on N per-genre queries.
|
||||
loadGenreRows(libraryId);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to load music sections";
|
||||
update(s => ({ ...s, isLoading: false, error: message }));
|
||||
@@ -117,6 +137,76 @@ function createMusicStore() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetch up to SECTION_LIMIT albums for one genre as a slider row. */
|
||||
async function loadGenreRow(libraryId: string, genre: Genre): Promise<GenreRow> {
|
||||
const repo = auth.getRepository();
|
||||
try {
|
||||
const result = await repo.getItems(libraryId, {
|
||||
includeItemTypes: ["MusicAlbum"],
|
||||
genres: [genre.name],
|
||||
sortBy: "SortName",
|
||||
sortOrder: "Ascending",
|
||||
recursive: true,
|
||||
limit: SECTION_LIMIT,
|
||||
});
|
||||
// HACK: drop the "Podcasts" folder that lives in the music library.
|
||||
return { id: genre.id, name: genre.name, items: excludePodcasts(result.items) };
|
||||
} catch (e) {
|
||||
console.warn(`Failed to load genre row "${genre.name}":`, e);
|
||||
return { id: genre.id, name: genre.name, items: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build one slider per genre, showing albums in each. We pick a *diverse* set
|
||||
* rather than just the most populous — otherwise a cluster of near-synonyms
|
||||
* ("Rock", "Hard Rock", "Classic Rock", ...) crowds out musically distinct
|
||||
* genres. See selectDiverseGenres. Album genres rarely carry community
|
||||
* ratings, so we order each row by name.
|
||||
*
|
||||
* When the backend reports per-genre album counts (online), we rank and pick
|
||||
* before fetching, so we only query albums for the genres we'll actually
|
||||
* show. When counts are missing (offline), we fall back to probing a wide
|
||||
* pool, dropping empties, then ranking by what came back.
|
||||
*/
|
||||
async function loadGenreRows(libraryId: string) {
|
||||
try {
|
||||
const repo = auth.getRepository();
|
||||
const genres = await repo.getGenres(libraryId);
|
||||
if (genres.length === 0) return;
|
||||
|
||||
const hasCounts = genres.some(g => g.albumCount != null);
|
||||
|
||||
let genreRows: GenreRow[];
|
||||
if (hasCounts) {
|
||||
// Rank by reported count, pick a diverse subset, then fetch only those.
|
||||
const ranked = [...genres].sort(
|
||||
(a, b) => (b.albumCount ?? 0) - (a.albumCount ?? 0)
|
||||
);
|
||||
const chosen = selectDiverseGenres(ranked, MAX_GENRE_ROWS);
|
||||
genreRows = (await Promise.all(chosen.map(g => loadGenreRow(libraryId, g)))).filter(
|
||||
row => row.items.length > 0
|
||||
);
|
||||
} else {
|
||||
// No counts (offline, or a server that ignores Fields=ItemCounts).
|
||||
// The genre list is alphabetical, so probing the first N would only
|
||||
// ever surface A-genres. Sample at an even stride across the whole
|
||||
// list instead, so the probe pool spans A→Z; then drop empties, rank
|
||||
// by what came back, and pick a diverse subset.
|
||||
const probed = sampleAcross(genres, MAX_GENRES_PROBED);
|
||||
const rows = await Promise.all(probed.map(g => loadGenreRow(libraryId, g)));
|
||||
const populated = rows
|
||||
.filter(row => row.items.length > 0)
|
||||
.sort((a, b) => b.items.length - a.items.length);
|
||||
genreRows = selectDiverseGenres(populated, MAX_GENRE_ROWS);
|
||||
}
|
||||
|
||||
update(s => ({ ...s, genreRows }));
|
||||
} catch (e) {
|
||||
console.warn("Failed to load music genre rows:", e);
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
set(initialState);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user