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:
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user