Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a4aba05
desktop: redesign the fullscreen player as a Material 3 Expressive no…
claude Sep 24, 2026
b5509d3
desktop: queue Clear buttons outside the listbox; e2e for the new player
claude Sep 24, 2026
6fc4276
desktop: Material 3 Expressive shapes, tonal surfaces and spring moti…
claude Sep 24, 2026
5a07901
desktop: M3 Expressive wavy seek bar and a desktop layout for the player
claude Sep 24, 2026
ebb270b
desktop: stack a large cover over the player controls in pane modes
claude Sep 24, 2026
96a8d61
desktop: Material Symbols (rounded) icons everywhere
claude Sep 24, 2026
f7be858
desktop: condense the player controls in pane modes so the cover span…
claude Sep 24, 2026
534e23c
desktop: more room between the stacked cover and the title
claude Sep 24, 2026
240ee4e
desktop: stylesheets for the M3 Expressive pass (shell, library, pages)
claude Sep 24, 2026
b19aa14
desktop: M3 Expressive shell (floating panes, drawer, search bar, sid…
claude Sep 24, 2026
ce539ba
desktop: M3 Expressive settings, setup and tool pages
claude Sep 24, 2026
5e69638
desktop: M3 Expressive library views
claude Sep 24, 2026
ca34fbe
desktop: custom M3 Expressive slider, switch and select everywhere
claude Sep 24, 2026
a7837ca
desktop: polish and consistency pass over the M3 Expressive stylesheets
claude Sep 24, 2026
5bdec41
Merge claude/desktop-player-redesign (M3 form controls) into polish-pass
claude Sep 24, 2026
4823879
desktop: sort group styles follow the new M3 select
claude Sep 24, 2026
e1a90f0
desktop: toast action hover keeps its 4.5:1 contrast
claude Sep 25, 2026
50b743c
Merge remote-tracking branch 'origin/main' into claude/desktop-player…
claude Sep 25, 2026
309cc4e
desktop: Ctrl shortcuts from the native core fire off macOS; native e…
claude Sep 25, 2026
e02869d
Merge remote-tracking branch 'origin/main' into claude/desktop-player…
claude Sep 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions desktop/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,18 @@ test.describe("axe: no violations anywhere", () => {
await populate(page);
for (const theme of THEMES) {
await setTheme(page, theme);
// Fullscreen player, each tab.
// Fullscreen player: the artwork, then each pane.
await page.getByTestId("toggle-fullscreen").click();
await expect(page.getByTestId("fullscreen-player")).toBeVisible();
for (const tab of ["lyrics", "upNext", "related"]) {
await page.getByTestId(`fs-tab-${tab}`).click();
await page.waitForTimeout(300);
await expectNoViolations(page, `${theme} fullscreen/${tab}`);
await page.waitForTimeout(700);
await expectNoViolations(page, `${theme} fullscreen/artwork`);
for (const mode of ["lyrics", "queue", "about"]) {
await page.getByTestId(`fs-mode-${mode}`).click();
await page.waitForTimeout(700);
await expectNoViolations(page, `${theme} fullscreen/${mode}`);
}
// Back to the artwork for the next theme.
await page.getByTestId("fs-mode-about").click();
await page.keyboard.press("Escape");
await expect(page.getByTestId("fullscreen-player")).toHaveCount(0);
// Command palette with results and actions.
Expand Down
4 changes: 2 additions & 2 deletions desktop/e2e/backup.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { completeSetup, expect, launchFake, stubDialogs, test } from "./fixtures";
import { completeSetup, expect, launchFake, stubDialogs, test, chooseOption } from "./fixtures";

test.describe("config backup", () => {
test("export with server passwords → import on a fresh install signs in and restores settings", async () => {
Expand All @@ -18,7 +18,7 @@ test.describe("config backup", () => {
await completeSetup(page);
await page.getByTestId("nav-settings").click();
await page.getByTestId("settings-nav-appearance").click();
await page.getByTestId("setting-theme").selectOption("dark");
await chooseOption(page.getByTestId("setting-theme"), "dark");
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
await page.getByTestId("settings-nav-backup").click();
await stubDialogs(app, file);
Expand Down
18 changes: 9 additions & 9 deletions desktop/e2e/cache.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import type { ElectronApplication, Page } from "@playwright/test";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { completeSetup, expect, test } from "./fixtures";
import { completeSetup, expect, test, chooseOption } from "./fixtures";
import { expectNoViolations, serverId, setTheme } from "./a11y-helpers";

interface Prime { kind: string; id: string; trackId: string; outcome: string }
Expand Down Expand Up @@ -163,12 +163,12 @@ test.describe("cached and downloaded badges", () => {
const cached = page.getByTestId("album-tracks").locator('[data-testid="offline-badge"][data-state="cached"]');
await expect(cached.first()).toBeVisible();
await expect(cached.first().getByRole("img", { name: "Cached" })).toBeVisible();
const cachedIcon = await cached.first().locator("svg").getAttribute("class");
const cachedIcon = await cached.first().locator("svg").getAttribute("data-icon");
// A downloaded album: every row is Downloaded, with a different icon.
await gotoAlbum(app, page, await albumWhere(page, "someDownloaded"));
const dl = page.getByTestId("album-tracks").locator('[data-testid="offline-badge"][data-state="downloaded"]');
await expect(dl.first().getByRole("img", { name: "Downloaded" })).toBeVisible();
expect(await dl.first().locator("svg").getAttribute("class")).not.toBe(cachedIcon);
expect(await dl.first().locator("svg").getAttribute("data-icon")).not.toBe(cachedIcon);
for (const theme of ["light", "dark"] as const) {
await setTheme(page, theme);
await expectNoViolations(page, `${theme} album with downloaded badges`);
Expand Down Expand Up @@ -267,10 +267,10 @@ test.describe("settings: storage", () => {
const usage = page.getByTestId("cache-usage");
await expect(usage).toContainText(/of 2\.0 GB: .* complete songs, .* partial/);
const mode = page.getByTestId("cache-budget-mode");
await expect(mode).toHaveValue("auto");
await expect(mode.locator("option[value=auto]")).toHaveText("Automatic (currently 2.0 GB)");
await expect(mode).toHaveAttribute("data-value", "auto");
await expect(mode).toHaveText("Automatic (currently 2.0 GB)");
await expect(page.getByTestId("data-saved")).toHaveText(/\d/);
const prefetch = page.getByRole("checkbox", { name: "Prefetch upcoming songs on mobile data" });
const prefetch = page.getByRole("switch", { name: "Prefetch upcoming songs on mobile data" });
await expect(prefetch).not.toBeChecked();
await prefetch.check();
await expect(prefetch).toBeChecked();
Expand All @@ -288,7 +288,7 @@ test.describe("settings: storage", () => {
});
expect(await budget()).toEqual({ auto: true, value: "null" });
// Custom starts at the current size: 2 GB is a size of its own (null is automatic).
await mode.selectOption("custom");
await chooseOption(mode, "custom");
const gb = page.getByTestId("cache-budget-gb");
await expect(gb).toHaveValue("2");
await expect.poll(budget).toEqual({ auto: false, value: String(2 * 1024 ** 3) });
Expand All @@ -302,9 +302,9 @@ test.describe("settings: storage", () => {
await gb.press("Enter");
await expect(usage).toContainText("of 256.0 MB");
await expect(usage).not.toHaveText(before ?? "");
await expect(mode).toHaveValue("custom");
await expect(mode).toHaveAttribute("data-value", "custom");
// Back to automatic.
await mode.selectOption("auto");
await chooseOption(mode, "auto");
await expect(usage).toContainText("of 2.0 GB");
await expect(gb).toHaveCount(0);
await expect.poll(budget).toEqual({ auto: true, value: "null" });
Expand Down
10 changes: 9 additions & 1 deletion desktop/e2e/fixtures.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Launches the built app (out/main/index.cjs) with the fake core and a fresh
// user-data dir per test, and drives it through Playwright's Electron API.
import { _electron as electron, test as base, type ElectronApplication, type Page } from "@playwright/test";
import { _electron as electron, test as base, type ElectronApplication, type Locator, type Page } from "@playwright/test";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
Expand Down Expand Up @@ -67,3 +67,11 @@ export async function playFirstAlbum(page: Page): Promise<void> {
await page.getByTestId("album-play").click();
await expect(page.getByTestId("queue-row-current")).toBeVisible();
}

/** Pick `value` in one of the app's Select comboboxes (components/controls.tsx) by pointer, like a user. */
export async function chooseOption(select: Locator, value: string): Promise<void> {
await select.click();
await select.page().locator(`[role="listbox"] [role="option"][data-value="${value}"]`).click();
await expect(select).toHaveAttribute("aria-expanded", "false");
await expect(select).toHaveAttribute("data-value", value);
}
12 changes: 8 additions & 4 deletions desktop/e2e/keyboard-a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -286,10 +286,14 @@ test.describe("keyboard only", () => {
expect(inside, `Tab #${i}`).toBe(true);
expect(hasRing(await focused(page)), `ring at Tab #${i}`).toBe(true);
}
// Tabs inside fullscreen rove too.
await tabTo(page, byTestId("fs-tab-lyrics"), "the Lyrics tab");
await page.keyboard.press("ArrowLeft");
await expect(page.getByTestId("fs-tab-related")).toHaveAttribute("aria-selected", "true");
// The Lyrics / Queue / About toggles are buttons: Enter shows the pane, again brings the artwork back.
await tabTo(page, byTestId("fs-mode-queue"), "the Queue toggle");
await page.keyboard.press("Enter");
await expect(page.getByTestId("fs-mode-queue")).toHaveAttribute("aria-pressed", "true");
await expect(fs.getByTestId("queue-timeline")).toBeVisible();
await expect(page.getByTestId("fs-mode-queue")).toBeFocused();
await page.keyboard.press("Enter");
await expect(page.getByTestId("fs-mode-queue")).toHaveAttribute("aria-pressed", "false");
await page.keyboard.press("Escape");
await expect(fs).toHaveCount(0);
await expect(page.getByTestId("nav-albums")).toBeFocused();
Expand Down
8 changes: 5 additions & 3 deletions desktop/e2e/lyrics.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// background-vocal agent) document for one track; the AMLL renderer must sweep
// word by word, show the background line as a sub-line, and size itself from
// the device-local preference.
import { completeSetup, expect, test } from "./fixtures";
import { completeSetup, expect, test, chooseOption } from "./fixtures";

async function playShowcase(page: import("@playwright/test").Page): Promise<void> {
const servers = await page.evaluate(async () => (await window.hocket.query({ type: "servers" })) as { type: string; data: { id: string }[] });
Expand Down Expand Up @@ -81,8 +81,8 @@ test.describe("lyrics", () => {
// The Appearance page shows the same device-local preference.
await page.getByTestId("nav-settings").click();
await page.getByTestId("settings-nav-appearance").click();
await expect(page.getByTestId("setting-lyrics-size")).toHaveValue("small");
await page.getByTestId("setting-lyrics-size").selectOption("large");
await expect(page.getByTestId("setting-lyrics-size")).toHaveAttribute("data-value", "small");
await chooseOption(page.getByTestId("setting-lyrics-size"), "large");
await expect(page.getByTestId("amll-host")).toHaveAttribute("data-size", "large");
expect(await fontPx()).toBeGreaterThan(medium);
// Persisted on this device.
Expand All @@ -92,6 +92,8 @@ test.describe("lyrics", () => {
// The fullscreen player keeps its large layout regardless.
await page.getByTestId("content").click();
await page.keyboard.press("f");
// It opens on the artwork; L shows the lyrics in its place.
await page.keyboard.press("l");
const fs = page.getByTestId("fullscreen-player").locator(".amll-lyric-player").first();
await expect(fs).toBeVisible();
expect(await fs.evaluate((el) => Number.parseFloat(getComputedStyle(el).fontSize))).toBeGreaterThan(medium * 1.5);
Expand Down
2 changes: 1 addition & 1 deletion desktop/e2e/manual/real-server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ test.describe("real server (manual)", () => {
// (The button, not F: F in a focused album grid is type-ahead.)
await page.getByTestId("toggle-fullscreen").click();
await expect(page.getByTestId("fullscreen-player")).toBeVisible();
await page.getByTestId("fs-tab-lyrics").click();
await page.getByTestId("fs-mode-lyrics").click();
await page.waitForTimeout(4000);
await shot("fullscreen-lyrics");
await page.keyboard.press("Escape");
Expand Down
5 changes: 3 additions & 2 deletions desktop/e2e/motion-contrast.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ test.describe("motion, contrast and zoom", () => {
await expect(fs).toBeVisible();
await expect(fs.getByTestId("fs-still")).toBeVisible();
await expect(fs.getByTestId("fluid-bg")).toHaveCount(0);
await fs.getByTestId("fs-mode-lyrics").click();
await expect(fs.getByTestId("lyrics-view")).toHaveAttribute("data-mode", "plain");
await page.waitForTimeout(500);
expect(await runningAnimations(page)).toBe(0);
Expand Down Expand Up @@ -125,7 +126,7 @@ test.describe("motion, contrast and zoom", () => {
await page.waitForTimeout(600);
const texts = await page.evaluate(() => {
const out: { what: string; rect: { x: number; y: number; w: number; h: number }; color: string }[] = [];
for (const sel of [".fullscreen .info .t1", ".fullscreen .info .t2", ".fullscreen .seek > span", ".fullscreen .side .tab"]) {
for (const sel of [".np-text .t1", ".np-text .t2", ".np-seek .time", ".np-kicker", ".np-source-name", ".np-mode:not(.on)"]) {
for (const el of Array.from(document.querySelectorAll<HTMLElement>(sel))) {
const r = el.getBoundingClientRect();
if (r.width && r.height && el.textContent?.trim()) out.push({ what: `${sel} "${el.textContent.trim().slice(0, 20)}"`, rect: { x: r.x, y: r.y, w: r.width, h: r.height }, color: getComputedStyle(el).color });
Expand All @@ -135,7 +136,7 @@ test.describe("motion, contrast and zoom", () => {
});
expect(texts.length).toBeGreaterThan(5);
// Photograph the background alone: hide everything drawn over it.
await page.addStyleTag({ content: ".fullscreen .left, .fullscreen .side, .fullscreen .close { visibility: hidden !important; }" }).then((h) => h.evaluate((el) => (el as Element).setAttribute("data-e2e-hide", "")));
await page.addStyleTag({ content: ".np-stage, .toasts { visibility: hidden !important; }" }).then((h) => h.evaluate((el) => (el as Element).setAttribute("data-e2e-hide", "")));
const png = (await page.screenshot()).toString("base64");
await page.evaluate(() => document.querySelector("[data-e2e-hide]")?.remove());
const worst = await page.evaluate(async ({ png, texts }) => {
Expand Down
11 changes: 8 additions & 3 deletions desktop/e2e/native.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { existsSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { FakeNavidrome } from "./fake-navidrome";
import { chooseOption } from "./fixtures";

const root = resolve(__dirname, "..");
const addonBuilt = existsSync(join(root, "native", "index.js"));
Expand Down Expand Up @@ -71,7 +72,7 @@ test.describe("real core against a fake Navidrome", () => {
await page.getByTestId("album-play").click();
await expect(page.getByTestId("queue-row-current")).toBeVisible({ timeout: 20_000 });
await expect(page.getByTestId("queue-row-upcoming")).toHaveCount(5);
await expect(page.getByTestId("queue-timeline")).toContainText("Continuing from");
await expect(page.getByTestId("queue-timeline")).toContainText("Continue playing");
const firstTitle = await page.getByTestId("queue-row-current").locator(".t1").textContent();

// Position extrapolates from TransportChanged stamps while playing.
Expand All @@ -94,7 +95,11 @@ test.describe("real core against a fake Navidrome", () => {
await expect.poll(() => server.callsTo("setRating").length, { timeout: 15_000 }).toBeGreaterThan(0);
// Rating leaves the queue alone, so no toast: undo from the keyboard.
await expect(page.getByTestId("toast")).toHaveCount(0);
await page.getByTestId("content").click();
// Focus the content pane rather than clicking its centre (that lands on whatever the layout
// puts there, e.g. the album's own rating), and move the pointer off the stars so the row
// shows its value, not the hover preview.
await page.mouse.move(0, 0);
await page.getByTestId("content").focus();
await page.keyboard.press("Control+z");
await expect(row.locator(".stars .star.on")).toHaveCount(0, { timeout: 10_000 });

Expand All @@ -106,7 +111,7 @@ test.describe("real core against a fake Navidrome", () => {
// Change a registry setting: theme → dark.
await page.getByTestId("nav-settings").click();
await page.getByTestId("settings-nav-appearance").click();
await page.getByTestId("setting-theme").selectOption("dark");
await chooseOption(page.getByTestId("setting-theme"), "dark");
await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
// No settling delay: quitting waits for the core's flush (before-quit → Core::shutdown).
} finally {
Expand Down
Loading
Loading