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:
@@ -6,10 +6,8 @@ import { writable } from 'svelte/store';
|
||||
export const isInitialized = writable(false);
|
||||
export const pendingSyncCount = writable(0);
|
||||
export const isAndroid = writable(false);
|
||||
export const shuffle = writable(false);
|
||||
export const repeat = writable<'off' | 'all' | 'one'>('off');
|
||||
export const hasNext = writable(false);
|
||||
export const hasPrevious = writable(false);
|
||||
// Shuffle/repeat/next/previous state now lives in the event-driven queue store
|
||||
// ($lib/stores/queue), the single source of truth.
|
||||
export const showSleepTimerModal = writable(false);
|
||||
|
||||
// Library-specific state
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
@@ -9,26 +9,31 @@ vi.mock("@tauri-apps/api/core", () => ({
|
||||
invoke: (...args: unknown[]) => mockInvoke(...args),
|
||||
}));
|
||||
|
||||
// Mock the sessions store
|
||||
// Mock the sessions store. `selectedSession` is read via svelte's `get()`, which
|
||||
// calls subscribe and synchronously receives the current value; tests set
|
||||
// `currentSelectedSession` to control what the store yields.
|
||||
const mockSelectSession = vi.fn();
|
||||
let currentSelectedSession: unknown = null;
|
||||
vi.mock("./sessions", () => ({
|
||||
sessions: {
|
||||
selectSession: (...args: unknown[]) => mockSelectSession(...args),
|
||||
},
|
||||
selectedSession: {
|
||||
subscribe: vi.fn((callback: (value: null) => void) => {
|
||||
callback(null);
|
||||
subscribe: vi.fn((callback: (value: unknown) => void) => {
|
||||
callback(currentSelectedSession);
|
||||
return () => {};
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock auth store
|
||||
const mockGetHandle = vi.fn(() => "repo-handle-1");
|
||||
vi.mock("./auth", () => ({
|
||||
auth: {
|
||||
getRepository: vi.fn(() => ({
|
||||
getPlaybackInfo: vi.fn().mockResolvedValue({ streamUrl: "http://test.com/stream" }),
|
||||
getImageUrl: vi.fn().mockReturnValue("http://test.com/image"),
|
||||
getHandle: () => mockGetHandle(),
|
||||
})),
|
||||
},
|
||||
}));
|
||||
@@ -36,6 +41,7 @@ vi.mock("./auth", () => ({
|
||||
describe("playbackMode store", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
currentSelectedSession = null;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -204,6 +210,90 @@ describe("playbackMode store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("transferToLocal", () => {
|
||||
// 50,000,000 ticks = 5 seconds (10M ticks per second).
|
||||
const REMOTE_POSITION_TICKS = 50_000_000;
|
||||
const REMOTE_POSITION_SECONDS = 5;
|
||||
|
||||
function setRemoteSessionPlaying() {
|
||||
currentSelectedSession = {
|
||||
id: "session-123",
|
||||
nowPlayingItem: { id: "item-abc", name: "Test Track", runTimeTicks: 1_800_000_000 },
|
||||
playState: { positionTicks: REMOTE_POSITION_TICKS },
|
||||
};
|
||||
}
|
||||
|
||||
it("resumes local playback at the remote position, not from 0", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
|
||||
playbackMode.setMode("remote", "session-123");
|
||||
setRemoteSessionPlaying();
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
|
||||
await playbackMode.transferToLocal();
|
||||
|
||||
// Bug 2 guard: the resume position must be passed to play_tracks so the
|
||||
// backend seeks at load time. Restarting from 0 means startPosition is missing.
|
||||
const playTracksCall = mockInvoke.mock.calls.find((c) => c[0] === "player_play_tracks");
|
||||
expect(playTracksCall).toBeDefined();
|
||||
expect(playTracksCall![1]).toMatchObject({
|
||||
repositoryHandle: "repo-handle-1",
|
||||
request: {
|
||||
trackIds: ["item-abc"],
|
||||
startIndex: 0,
|
||||
startPosition: REMOTE_POSITION_SECONDS,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not issue a separate player_seek (no start-at-0-then-jump race)", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
|
||||
playbackMode.setMode("remote", "session-123");
|
||||
setRemoteSessionPlaying();
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
|
||||
await playbackMode.transferToLocal();
|
||||
|
||||
const seekCall = mockInvoke.mock.calls.find((c) => c[0] === "player_seek");
|
||||
expect(seekCall).toBeUndefined();
|
||||
});
|
||||
|
||||
it("tells Rust to stop the remote session with the current item and position", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
|
||||
playbackMode.setMode("remote", "session-123");
|
||||
setRemoteSessionPlaying();
|
||||
mockInvoke.mockResolvedValue(undefined);
|
||||
|
||||
await playbackMode.transferToLocal();
|
||||
|
||||
expect(mockInvoke).toHaveBeenCalledWith("playback_mode_transfer_to_local", {
|
||||
currentItemId: "item-abc",
|
||||
positionTicks: REMOTE_POSITION_TICKS,
|
||||
});
|
||||
|
||||
const state = get(playbackMode);
|
||||
expect(state.mode).toBe("local");
|
||||
expect(state.remoteSessionId).toBeNull();
|
||||
expect(state.isTransferring).toBe(false);
|
||||
});
|
||||
|
||||
it("switches to local without playing media when remote has nothing playing", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
|
||||
playbackMode.setMode("remote", "session-123");
|
||||
currentSelectedSession = { id: "session-123", nowPlayingItem: null, playState: null };
|
||||
|
||||
await playbackMode.transferToLocal();
|
||||
|
||||
expect(mockInvoke).not.toHaveBeenCalledWith("player_play_tracks", expect.anything());
|
||||
const state = get(playbackMode);
|
||||
expect(state.mode).toBe("local");
|
||||
expect(state.remoteSessionId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("clearError", () => {
|
||||
it("should clear transfer error", async () => {
|
||||
const { playbackMode } = await import("./playbackMode");
|
||||
|
||||
@@ -192,10 +192,14 @@ function createPlaybackModeStore() {
|
||||
// Use player_play_tracks - backend fetches all metadata from single ID
|
||||
const repositoryHandle = repository.getHandle();
|
||||
|
||||
// Pass the resume position so the backend seeks at load time. Doing the
|
||||
// seek here (rather than a delayed playerSeek) avoids the race where the
|
||||
// media isn't loaded yet and the seek is lost, restarting from 0.
|
||||
await commands.playerPlayTracks(repositoryHandle, {
|
||||
trackIds: [itemId],
|
||||
startIndex: 0,
|
||||
shuffle: false,
|
||||
startPosition: positionSeconds,
|
||||
context: {
|
||||
type: "search",
|
||||
searchQuery: "",
|
||||
@@ -204,16 +208,6 @@ function createPlaybackModeStore() {
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
// Wait briefly for media to load
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Seek to position if not at the very start
|
||||
if (positionSeconds > 0.5) {
|
||||
await commands.playerSeek(positionSeconds);
|
||||
}
|
||||
|
||||
if (aborted) return;
|
||||
|
||||
// Let Rust handle stopping remote playback
|
||||
await commands.playbackModeTransferToLocal(itemId, positionTicks);
|
||||
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
// TRACES: UR-005, UR-015 | DR-005, DR-020
|
||||
|
||||
import { writable, derived, get } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { commands, events } from "$lib/api/bindings";
|
||||
import type { MediaItem } from "$lib/api/types";
|
||||
import { auth } from "$lib/stores/auth";
|
||||
|
||||
@@ -51,12 +50,12 @@ function createQueueStore() {
|
||||
// Initial sync from backend
|
||||
await syncFromRust();
|
||||
|
||||
// Listen for queue changed events
|
||||
unlisten = await listen<QueueChangedEvent>("player-event", (event) => {
|
||||
if ((event.payload as any).type === "queue_changed") {
|
||||
const queueEvent = event.payload as any;
|
||||
// Listen for queue changed events (generated tauri-specta event)
|
||||
unlisten = await events.playerStatusEvent.listen((event) => {
|
||||
if (event.payload.type === "queue_changed") {
|
||||
const queueEvent = event.payload;
|
||||
set({
|
||||
items: queueEvent.items,
|
||||
items: queueEvent.items as unknown as MediaItem[],
|
||||
currentIndex: queueEvent.current_index,
|
||||
shuffle: queueEvent.shuffle,
|
||||
repeat: queueEvent.repeat,
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
// TRACES: UR-010 | DR-037
|
||||
|
||||
import { writable, derived } from "svelte/store";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { commands } from "$lib/api/bindings";
|
||||
import { commands, events } from "$lib/api/bindings";
|
||||
import type { Session } from "$lib/api/types";
|
||||
|
||||
interface SessionsState {
|
||||
@@ -14,11 +13,6 @@ interface SessionsState {
|
||||
lastUpdated: Date | null;
|
||||
}
|
||||
|
||||
interface PlayerStatusEvent {
|
||||
type: string;
|
||||
sessions?: Session[];
|
||||
}
|
||||
|
||||
function createSessionsStore() {
|
||||
const initialState: SessionsState = {
|
||||
sessions: [],
|
||||
@@ -30,16 +24,17 @@ function createSessionsStore() {
|
||||
|
||||
const { subscribe, update } = writable<SessionsState>(initialState);
|
||||
|
||||
// Listen for session updates from Rust backend
|
||||
listen<PlayerStatusEvent>("player-event", (event) => {
|
||||
if (event.payload.type === "sessions_updated" && event.payload.sessions) {
|
||||
console.log(`[Sessions] Received ${event.payload.sessions.length} sessions from backend`);
|
||||
event.payload.sessions.forEach((s, i) => {
|
||||
// Listen for session updates from Rust backend (generated tauri-specta event)
|
||||
events.playerStatusEvent.listen((event) => {
|
||||
if (event.payload.type === "sessions_updated") {
|
||||
const sessions = event.payload.sessions as unknown as Session[];
|
||||
console.log(`[Sessions] Received ${sessions.length} sessions from backend`);
|
||||
sessions.forEach((s, i) => {
|
||||
console.log(`[Sessions] Session ${i}: id=${s.id}, device=${s.deviceName}, supportsRemoteControl=${s.supportsRemoteControl}`);
|
||||
});
|
||||
update((s) => ({
|
||||
...s,
|
||||
sessions: event.payload.sessions!,
|
||||
sessions,
|
||||
lastUpdated: new Date(),
|
||||
error: null,
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user